mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-10 11:23:32 +08:00
feat: group ecc2 sessions by project and task
This commit is contained in:
@@ -344,10 +344,29 @@ async fn main() -> Result<()> {
|
||||
from_session,
|
||||
}) => {
|
||||
let use_worktree = worktree.resolve(&cfg);
|
||||
let session_id =
|
||||
session::manager::create_session(&db, &cfg, &task, &agent, use_worktree).await?;
|
||||
if let Some(from_session) = from_session {
|
||||
let from_id = resolve_session_id(&db, &from_session)?;
|
||||
let source = if let Some(from_session) = from_session.as_ref() {
|
||||
let from_id = resolve_session_id(&db, from_session)?;
|
||||
Some(
|
||||
db.get_session(&from_id)?
|
||||
.ok_or_else(|| anyhow::anyhow!("Session not found: {from_id}"))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let session_id = session::manager::create_session_with_grouping(
|
||||
&db,
|
||||
&cfg,
|
||||
&task,
|
||||
&agent,
|
||||
use_worktree,
|
||||
session::SessionGrouping {
|
||||
project: source.as_ref().map(|session| session.project.clone()),
|
||||
task_group: source.as_ref().map(|session| session.task_group.clone()),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
if let Some(source) = source {
|
||||
let from_id = source.id;
|
||||
send_handoff_message(&db, &from_id, &session_id)?;
|
||||
}
|
||||
println!("Session started: {session_id}");
|
||||
@@ -371,8 +390,18 @@ async fn main() -> Result<()> {
|
||||
)
|
||||
});
|
||||
|
||||
let session_id =
|
||||
session::manager::create_session(&db, &cfg, &task, &agent, use_worktree).await?;
|
||||
let session_id = session::manager::create_session_with_grouping(
|
||||
&db,
|
||||
&cfg,
|
||||
&task,
|
||||
&agent,
|
||||
use_worktree,
|
||||
session::SessionGrouping {
|
||||
project: Some(source.project.clone()),
|
||||
task_group: Some(source.task_group.clone()),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
send_handoff_message(&db, &source.id, &session_id)?;
|
||||
println!(
|
||||
"Delegated session started: {} <- {}",
|
||||
@@ -1908,6 +1937,8 @@ mod tests {
|
||||
Session {
|
||||
id: id.to_string(),
|
||||
task: task.to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: PathBuf::from("/tmp/ecc"),
|
||||
state,
|
||||
|
||||
@@ -314,6 +314,8 @@ mod tests {
|
||||
Session {
|
||||
id: id.to_string(),
|
||||
task: "test task".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: PathBuf::from("/tmp"),
|
||||
state: SessionState::Pending,
|
||||
|
||||
@@ -480,6 +480,8 @@ mod tests {
|
||||
Session {
|
||||
id: id.to_string(),
|
||||
task: "Recover crashed worker".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: PathBuf::from("/tmp"),
|
||||
state,
|
||||
|
||||
@@ -9,7 +9,10 @@ use tokio::process::Command;
|
||||
use super::output::SessionOutputStore;
|
||||
use super::runtime::capture_command_output;
|
||||
use super::store::StateStore;
|
||||
use super::{Session, SessionMetrics, SessionState};
|
||||
use super::{
|
||||
default_project_label, default_task_group_label, normalize_group_label, Session,
|
||||
SessionGrouping, SessionMetrics, SessionState,
|
||||
};
|
||||
use crate::comms::{self, MessageType};
|
||||
use crate::config::Config;
|
||||
use crate::observability::{log_tool_call, ToolCallEvent, ToolLogEntry, ToolLogPage, ToolLogger};
|
||||
@@ -21,10 +24,29 @@ pub async fn create_session(
|
||||
task: &str,
|
||||
agent_type: &str,
|
||||
use_worktree: bool,
|
||||
) -> Result<String> {
|
||||
create_session_with_grouping(
|
||||
db,
|
||||
cfg,
|
||||
task,
|
||||
agent_type,
|
||||
use_worktree,
|
||||
SessionGrouping::default(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_session_with_grouping(
|
||||
db: &StateStore,
|
||||
cfg: &Config,
|
||||
task: &str,
|
||||
agent_type: &str,
|
||||
use_worktree: bool,
|
||||
grouping: SessionGrouping,
|
||||
) -> Result<String> {
|
||||
let repo_root =
|
||||
std::env::current_dir().context("Failed to resolve current working directory")?;
|
||||
queue_session_in_dir(db, cfg, task, agent_type, use_worktree, &repo_root).await
|
||||
queue_session_in_dir(db, cfg, task, agent_type, use_worktree, &repo_root, grouping).await
|
||||
}
|
||||
|
||||
pub fn list_sessions(db: &StateStore) -> Result<Vec<Session>> {
|
||||
@@ -127,6 +149,27 @@ pub async fn assign_session(
|
||||
task: &str,
|
||||
agent_type: &str,
|
||||
use_worktree: bool,
|
||||
) -> Result<AssignmentOutcome> {
|
||||
assign_session_with_grouping(
|
||||
db,
|
||||
cfg,
|
||||
lead_id,
|
||||
task,
|
||||
agent_type,
|
||||
use_worktree,
|
||||
SessionGrouping::default(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn assign_session_with_grouping(
|
||||
db: &StateStore,
|
||||
cfg: &Config,
|
||||
lead_id: &str,
|
||||
task: &str,
|
||||
agent_type: &str,
|
||||
use_worktree: bool,
|
||||
grouping: SessionGrouping,
|
||||
) -> Result<AssignmentOutcome> {
|
||||
let repo_root =
|
||||
std::env::current_dir().context("Failed to resolve current working directory")?;
|
||||
@@ -139,6 +182,7 @@ pub async fn assign_session(
|
||||
use_worktree,
|
||||
&repo_root,
|
||||
&std::env::current_exe().context("Failed to resolve ECC executable path")?,
|
||||
grouping,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -175,6 +219,7 @@ pub async fn drain_inbox(
|
||||
use_worktree,
|
||||
&repo_root,
|
||||
&runner_program,
|
||||
SessionGrouping::default(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -380,6 +425,7 @@ pub async fn rebalance_team_backlog(
|
||||
use_worktree,
|
||||
&repo_root,
|
||||
&runner_program,
|
||||
SessionGrouping::default(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -538,8 +584,17 @@ async fn assign_session_in_dir_with_runner_program(
|
||||
use_worktree: bool,
|
||||
repo_root: &Path,
|
||||
runner_program: &Path,
|
||||
grouping: SessionGrouping,
|
||||
) -> Result<AssignmentOutcome> {
|
||||
let lead = resolve_session(db, lead_id)?;
|
||||
let inherited_grouping = SessionGrouping {
|
||||
project: grouping
|
||||
.project
|
||||
.or_else(|| normalize_group_label(&lead.project)),
|
||||
task_group: grouping
|
||||
.task_group
|
||||
.or_else(|| normalize_group_label(&lead.task_group)),
|
||||
};
|
||||
let delegates = direct_delegate_sessions(db, &lead.id, agent_type)?;
|
||||
let delegate_handoff_backlog = delegates
|
||||
.iter()
|
||||
@@ -577,6 +632,7 @@ async fn assign_session_in_dir_with_runner_program(
|
||||
use_worktree,
|
||||
repo_root,
|
||||
runner_program,
|
||||
inherited_grouping.clone(),
|
||||
)
|
||||
.await?;
|
||||
send_task_handoff(db, &lead, &session_id, task, "spawned new delegate")?;
|
||||
@@ -651,6 +707,7 @@ async fn assign_session_in_dir_with_runner_program(
|
||||
use_worktree,
|
||||
repo_root,
|
||||
runner_program,
|
||||
inherited_grouping,
|
||||
)
|
||||
.await?;
|
||||
send_task_handoff(db, &lead, &session_id, task, "spawned fallback delegate")?;
|
||||
@@ -1093,6 +1150,7 @@ async fn queue_session_in_dir(
|
||||
agent_type: &str,
|
||||
use_worktree: bool,
|
||||
repo_root: &Path,
|
||||
grouping: SessionGrouping,
|
||||
) -> Result<String> {
|
||||
queue_session_in_dir_with_runner_program(
|
||||
db,
|
||||
@@ -1102,6 +1160,7 @@ async fn queue_session_in_dir(
|
||||
use_worktree,
|
||||
repo_root,
|
||||
&std::env::current_exe().context("Failed to resolve ECC executable path")?,
|
||||
grouping,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -1114,8 +1173,17 @@ async fn queue_session_in_dir_with_runner_program(
|
||||
use_worktree: bool,
|
||||
repo_root: &Path,
|
||||
runner_program: &Path,
|
||||
grouping: SessionGrouping,
|
||||
) -> Result<String> {
|
||||
let session = build_session_record(db, task, agent_type, use_worktree, cfg, repo_root)?;
|
||||
let session = build_session_record(
|
||||
db,
|
||||
task,
|
||||
agent_type,
|
||||
use_worktree,
|
||||
cfg,
|
||||
repo_root,
|
||||
grouping,
|
||||
)?;
|
||||
db.insert_session(&session)?;
|
||||
|
||||
if use_worktree && session.worktree.is_none() {
|
||||
@@ -1158,6 +1226,7 @@ fn build_session_record(
|
||||
use_worktree: bool,
|
||||
cfg: &Config,
|
||||
repo_root: &Path,
|
||||
grouping: SessionGrouping,
|
||||
) -> Result<Session> {
|
||||
let id = uuid::Uuid::new_v4().to_string()[..8].to_string();
|
||||
let now = chrono::Utc::now();
|
||||
@@ -1171,10 +1240,22 @@ fn build_session_record(
|
||||
.as_ref()
|
||||
.map(|worktree| worktree.path.clone())
|
||||
.unwrap_or_else(|| repo_root.to_path_buf());
|
||||
let project = grouping
|
||||
.project
|
||||
.as_deref()
|
||||
.and_then(normalize_group_label)
|
||||
.unwrap_or_else(|| default_project_label(repo_root));
|
||||
let task_group = grouping
|
||||
.task_group
|
||||
.as_deref()
|
||||
.and_then(normalize_group_label)
|
||||
.unwrap_or_else(|| default_task_group_label(task));
|
||||
|
||||
Ok(Session {
|
||||
id,
|
||||
task: task.to_string(),
|
||||
project,
|
||||
task_group,
|
||||
agent_type: agent_type.to_string(),
|
||||
working_dir,
|
||||
state: SessionState::Pending,
|
||||
@@ -1196,7 +1277,15 @@ async fn create_session_in_dir(
|
||||
repo_root: &Path,
|
||||
agent_program: &Path,
|
||||
) -> Result<String> {
|
||||
let session = build_session_record(db, task, agent_type, use_worktree, cfg, repo_root)?;
|
||||
let session = build_session_record(
|
||||
db,
|
||||
task,
|
||||
agent_type,
|
||||
use_worktree,
|
||||
cfg,
|
||||
repo_root,
|
||||
SessionGrouping::default(),
|
||||
)?;
|
||||
|
||||
db.insert_session(&session)?;
|
||||
|
||||
@@ -1962,6 +2051,8 @@ mod tests {
|
||||
Session {
|
||||
id: id.to_string(),
|
||||
task: format!("task-{id}"),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: PathBuf::from("/tmp"),
|
||||
state,
|
||||
@@ -1984,6 +2075,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "stale-1".to_string(),
|
||||
task: "heartbeat overdue".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: PathBuf::from("/tmp"),
|
||||
state: SessionState::Running,
|
||||
@@ -2019,6 +2112,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "stale-2".to_string(),
|
||||
task: "terminate overdue".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: PathBuf::from("/tmp"),
|
||||
state: SessionState::Running,
|
||||
@@ -2171,6 +2266,37 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn create_session_derives_project_and_task_group_defaults() -> Result<()> {
|
||||
let tempdir = TestDir::new("manager-create-session-grouping-defaults")?;
|
||||
let repo_root = tempdir.path().join("checkout-api");
|
||||
init_git_repo(&repo_root)?;
|
||||
|
||||
let cfg = build_config(tempdir.path());
|
||||
let db = StateStore::open(&cfg.db_path)?;
|
||||
let (fake_claude, _) = write_fake_claude(tempdir.path())?;
|
||||
|
||||
let session_id = create_session_in_dir(
|
||||
&db,
|
||||
&cfg,
|
||||
"stabilize auth callback",
|
||||
"claude",
|
||||
false,
|
||||
&repo_root,
|
||||
&fake_claude,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let session = db
|
||||
.get_session(&session_id)?
|
||||
.context("session should exist")?;
|
||||
assert_eq!(session.project, "checkout-api");
|
||||
assert_eq!(session.task_group, "stabilize auth callback");
|
||||
|
||||
stop_session_with_options(&db, &session_id, false).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn stop_session_kills_process_and_optionally_cleans_worktree() -> Result<()> {
|
||||
let tempdir = TestDir::new("manager-stop-session")?;
|
||||
@@ -2379,6 +2505,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "active-over-budget".to_string(),
|
||||
task: "pause on hard limit".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: tempdir.path().to_path_buf(),
|
||||
state: SessionState::Running,
|
||||
@@ -2440,6 +2568,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "completed-over-budget".to_string(),
|
||||
task: "already done".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: tempdir.path().to_path_buf(),
|
||||
state: SessionState::Completed,
|
||||
@@ -2485,6 +2615,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "deadbeef".to_string(),
|
||||
task: "resume previous task".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: tempdir.path().join("resume-working-dir"),
|
||||
state: SessionState::Failed,
|
||||
@@ -2797,6 +2929,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "merge-ready".to_string(),
|
||||
task: "merge me".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: merged_worktree.path.clone(),
|
||||
state: SessionState::Completed,
|
||||
@@ -2813,6 +2947,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "active-worktree".to_string(),
|
||||
task: "still running".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: active_worktree.path.clone(),
|
||||
state: SessionState::Running,
|
||||
@@ -2830,6 +2966,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "dirty-worktree".to_string(),
|
||||
task: "needs commit".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: dirty_worktree.path.clone(),
|
||||
state: SessionState::Stopped,
|
||||
@@ -3056,6 +3194,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "lead".to_string(),
|
||||
task: "lead task".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: repo_root.clone(),
|
||||
state: SessionState::Running,
|
||||
@@ -3069,6 +3209,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "idle-worker".to_string(),
|
||||
task: "old worker task".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: repo_root.clone(),
|
||||
state: SessionState::Idle,
|
||||
@@ -3097,6 +3239,7 @@ mod tests {
|
||||
true,
|
||||
&repo_root,
|
||||
&fake_runner,
|
||||
SessionGrouping::default(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -3125,6 +3268,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "lead".to_string(),
|
||||
task: "lead task".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: repo_root.clone(),
|
||||
state: SessionState::Running,
|
||||
@@ -3138,6 +3283,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "idle-worker".to_string(),
|
||||
task: "old worker task".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: repo_root.clone(),
|
||||
state: SessionState::Idle,
|
||||
@@ -3165,6 +3312,7 @@ mod tests {
|
||||
true,
|
||||
&repo_root,
|
||||
&fake_runner,
|
||||
SessionGrouping::default(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -3203,6 +3351,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "lead".to_string(),
|
||||
task: "lead task".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: repo_root.clone(),
|
||||
state: SessionState::Running,
|
||||
@@ -3216,6 +3366,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "idle-worker".to_string(),
|
||||
task: "old worker task".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: repo_root.clone(),
|
||||
state: SessionState::Idle,
|
||||
@@ -3245,6 +3397,7 @@ mod tests {
|
||||
true,
|
||||
&repo_root,
|
||||
&fake_runner,
|
||||
SessionGrouping::default(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -3272,6 +3425,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "lead".to_string(),
|
||||
task: "lead task".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: repo_root.clone(),
|
||||
state: SessionState::Running,
|
||||
@@ -3285,6 +3440,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "busy-worker".to_string(),
|
||||
task: "existing work".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: repo_root.clone(),
|
||||
state: SessionState::Running,
|
||||
@@ -3312,6 +3469,7 @@ mod tests {
|
||||
true,
|
||||
&repo_root,
|
||||
&fake_runner,
|
||||
SessionGrouping::default(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -3331,6 +3489,57 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn assign_session_inherits_lead_grouping_for_spawned_delegate() -> Result<()> {
|
||||
let tempdir = TestDir::new("manager-assign-grouping-inheritance")?;
|
||||
let repo_root = tempdir.path().join("repo");
|
||||
init_git_repo(&repo_root)?;
|
||||
|
||||
let cfg = build_config(tempdir.path());
|
||||
let db = StateStore::open(&cfg.db_path)?;
|
||||
let now = Utc::now();
|
||||
|
||||
db.insert_session(&Session {
|
||||
id: "lead".to_string(),
|
||||
task: "lead task".to_string(),
|
||||
project: "ecc-platform".to_string(),
|
||||
task_group: "checkout recovery".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: repo_root.clone(),
|
||||
state: SessionState::Running,
|
||||
pid: Some(42),
|
||||
worktree: None,
|
||||
created_at: now - Duration::minutes(3),
|
||||
updated_at: now - Duration::minutes(3),
|
||||
last_heartbeat_at: now - Duration::minutes(3),
|
||||
metrics: SessionMetrics::default(),
|
||||
})?;
|
||||
|
||||
let (fake_runner, _) = write_fake_claude(tempdir.path())?;
|
||||
let outcome = assign_session_in_dir_with_runner_program(
|
||||
&db,
|
||||
&cfg,
|
||||
"lead",
|
||||
"investigate webhook retry edge cases",
|
||||
"claude",
|
||||
true,
|
||||
&repo_root,
|
||||
&fake_runner,
|
||||
SessionGrouping::default(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(outcome.action, AssignmentAction::Spawned);
|
||||
|
||||
let spawned = db
|
||||
.get_session(&outcome.session_id)?
|
||||
.context("spawned delegated session missing")?;
|
||||
assert_eq!(spawned.project, "ecc-platform");
|
||||
assert_eq!(spawned.task_group, "checkout recovery");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn assign_session_defers_when_team_is_saturated() -> Result<()> {
|
||||
let tempdir = TestDir::new("manager-assign-defer-saturated")?;
|
||||
@@ -3345,6 +3554,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "lead".to_string(),
|
||||
task: "lead task".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: repo_root.clone(),
|
||||
state: SessionState::Running,
|
||||
@@ -3358,6 +3569,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "busy-worker".to_string(),
|
||||
task: "existing work".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: repo_root.clone(),
|
||||
state: SessionState::Running,
|
||||
@@ -3385,6 +3598,7 @@ mod tests {
|
||||
true,
|
||||
&repo_root,
|
||||
&fake_runner,
|
||||
SessionGrouping::default(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -3412,6 +3626,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "lead".to_string(),
|
||||
task: "lead task".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: repo_root.clone(),
|
||||
state: SessionState::Running,
|
||||
@@ -3460,6 +3676,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "lead".to_string(),
|
||||
task: "lead task".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: repo_root.clone(),
|
||||
state: SessionState::Running,
|
||||
@@ -3473,6 +3691,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "busy-worker".to_string(),
|
||||
task: "existing work".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: repo_root.clone(),
|
||||
state: SessionState::Running,
|
||||
@@ -3529,6 +3749,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: lead_id.to_string(),
|
||||
task: format!("{lead_id} task"),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: repo_root.clone(),
|
||||
state: SessionState::Running,
|
||||
@@ -3589,6 +3811,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: lead_id.to_string(),
|
||||
task: format!("{lead_id} task"),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: repo_root.clone(),
|
||||
state: SessionState::Running,
|
||||
@@ -3641,6 +3865,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "worker".to_string(),
|
||||
task: "worker task".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: repo_root.clone(),
|
||||
state: SessionState::Running,
|
||||
@@ -3655,6 +3881,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "worker-child".to_string(),
|
||||
task: "delegate task".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: repo_root.clone(),
|
||||
state: SessionState::Running,
|
||||
@@ -3711,6 +3939,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "lead".to_string(),
|
||||
task: "lead task".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: repo_root.clone(),
|
||||
state: SessionState::Running,
|
||||
@@ -3724,6 +3954,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "worker-a".to_string(),
|
||||
task: "auth lane".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: repo_root.clone(),
|
||||
state: SessionState::Idle,
|
||||
@@ -3737,6 +3969,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "worker-b".to_string(),
|
||||
task: "billing lane".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: repo_root.clone(),
|
||||
state: SessionState::Idle,
|
||||
@@ -3799,6 +4033,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "lead".to_string(),
|
||||
task: "lead task".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: repo_root.clone(),
|
||||
state: SessionState::Running,
|
||||
@@ -3812,6 +4048,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "worker".to_string(),
|
||||
task: "delegate task".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: repo_root,
|
||||
state: SessionState::Idle,
|
||||
|
||||
@@ -7,12 +7,15 @@ pub mod store;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
pub id: String,
|
||||
pub task: String,
|
||||
pub project: String,
|
||||
pub task_group: String,
|
||||
pub agent_type: String,
|
||||
pub working_dir: PathBuf,
|
||||
pub state: SessionState,
|
||||
@@ -149,3 +152,30 @@ pub enum FileActivityAction {
|
||||
Delete,
|
||||
Touch,
|
||||
}
|
||||
|
||||
pub fn normalize_group_label(value: &str) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_project_label(working_dir: &Path) -> String {
|
||||
working_dir
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.and_then(normalize_group_label)
|
||||
.unwrap_or_else(|| "workspace".to_string())
|
||||
}
|
||||
|
||||
pub fn default_task_group_label(task: &str) -> String {
|
||||
normalize_group_label(task).unwrap_or_else(|| "general".to_string())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SessionGrouping {
|
||||
pub project: Option<String>,
|
||||
pub task_group: Option<String>,
|
||||
}
|
||||
|
||||
@@ -272,6 +272,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: session_id.clone(),
|
||||
task: "stream output".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "test".to_string(),
|
||||
working_dir: env::temp_dir(),
|
||||
state: SessionState::Pending,
|
||||
@@ -338,6 +340,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: session_id.clone(),
|
||||
task: "quiet process".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "test".to_string(),
|
||||
working_dir: env::temp_dir(),
|
||||
state: SessionState::Pending,
|
||||
|
||||
@@ -13,8 +13,8 @@ use crate::observability::{ToolCallEvent, ToolLogEntry, ToolLogPage};
|
||||
|
||||
use super::output::{OutputLine, OutputStream, OUTPUT_BUFFER_LIMIT};
|
||||
use super::{
|
||||
FileActivityAction, FileActivityEntry, Session, SessionMessage, SessionMetrics, SessionState,
|
||||
WorktreeInfo,
|
||||
default_project_label, default_task_group_label, normalize_group_label, FileActivityAction,
|
||||
FileActivityEntry, Session, SessionMessage, SessionMetrics, SessionState, WorktreeInfo,
|
||||
};
|
||||
|
||||
pub struct StateStore {
|
||||
@@ -138,6 +138,8 @@ impl StateStore {
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
task TEXT NOT NULL,
|
||||
project TEXT NOT NULL DEFAULT '',
|
||||
task_group TEXT NOT NULL DEFAULT '',
|
||||
agent_type TEXT NOT NULL,
|
||||
working_dir TEXT NOT NULL DEFAULT '.',
|
||||
state TEXT NOT NULL DEFAULT 'pending',
|
||||
@@ -255,6 +257,24 @@ impl StateStore {
|
||||
.context("Failed to add pid column to sessions table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("sessions", "project")? {
|
||||
self.conn
|
||||
.execute(
|
||||
"ALTER TABLE sessions ADD COLUMN project TEXT NOT NULL DEFAULT ''",
|
||||
[],
|
||||
)
|
||||
.context("Failed to add project column to sessions table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("sessions", "task_group")? {
|
||||
self.conn
|
||||
.execute(
|
||||
"ALTER TABLE sessions ADD COLUMN task_group TEXT NOT NULL DEFAULT ''",
|
||||
[],
|
||||
)
|
||||
.context("Failed to add task_group column to sessions table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("sessions", "input_tokens")? {
|
||||
self.conn
|
||||
.execute(
|
||||
@@ -478,11 +498,13 @@ impl StateStore {
|
||||
|
||||
pub fn insert_session(&self, session: &Session) -> Result<()> {
|
||||
self.conn.execute(
|
||||
"INSERT INTO sessions (id, task, agent_type, working_dir, state, pid, worktree_path, worktree_branch, worktree_base, created_at, updated_at, last_heartbeat_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
"INSERT INTO sessions (id, task, project, task_group, agent_type, working_dir, state, pid, worktree_path, worktree_branch, worktree_base, created_at, updated_at, last_heartbeat_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
|
||||
rusqlite::params![
|
||||
session.id,
|
||||
session.task,
|
||||
session.project,
|
||||
session.task_group,
|
||||
session.agent_type,
|
||||
session.working_dir.to_string_lossy().to_string(),
|
||||
session.state.to_string(),
|
||||
@@ -1062,7 +1084,7 @@ impl StateStore {
|
||||
|
||||
pub fn list_sessions(&self) -> Result<Vec<Session>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, task, agent_type, working_dir, state, pid, worktree_path, worktree_branch, worktree_base,
|
||||
"SELECT id, task, project, task_group, agent_type, working_dir, state, pid, worktree_path, worktree_branch, worktree_base,
|
||||
input_tokens, output_tokens, tokens_used, tool_calls, files_changed, duration_secs, cost_usd,
|
||||
created_at, updated_at, last_heartbeat_at
|
||||
FROM sessions ORDER BY updated_at DESC",
|
||||
@@ -1070,27 +1092,42 @@ impl StateStore {
|
||||
|
||||
let sessions = stmt
|
||||
.query_map([], |row| {
|
||||
let state_str: String = row.get(4)?;
|
||||
let state_str: String = row.get(6)?;
|
||||
let state = SessionState::from_db_value(&state_str);
|
||||
|
||||
let worktree_path: Option<String> = row.get(6)?;
|
||||
let working_dir = PathBuf::from(row.get::<_, String>(5)?);
|
||||
let project = row
|
||||
.get::<_, String>(2)
|
||||
.ok()
|
||||
.and_then(|value| normalize_group_label(&value))
|
||||
.unwrap_or_else(|| default_project_label(&working_dir));
|
||||
let task: String = row.get(1)?;
|
||||
let task_group = row
|
||||
.get::<_, String>(3)
|
||||
.ok()
|
||||
.and_then(|value| normalize_group_label(&value))
|
||||
.unwrap_or_else(|| default_task_group_label(&task));
|
||||
|
||||
let worktree_path: Option<String> = row.get(8)?;
|
||||
let worktree = worktree_path.map(|path| super::WorktreeInfo {
|
||||
path: PathBuf::from(path),
|
||||
branch: row.get::<_, String>(7).unwrap_or_default(),
|
||||
base_branch: row.get::<_, String>(8).unwrap_or_default(),
|
||||
branch: row.get::<_, String>(9).unwrap_or_default(),
|
||||
base_branch: row.get::<_, String>(10).unwrap_or_default(),
|
||||
});
|
||||
|
||||
let created_str: String = row.get(16)?;
|
||||
let updated_str: String = row.get(17)?;
|
||||
let heartbeat_str: String = row.get(18)?;
|
||||
let created_str: String = row.get(18)?;
|
||||
let updated_str: String = row.get(19)?;
|
||||
let heartbeat_str: String = row.get(20)?;
|
||||
|
||||
Ok(Session {
|
||||
id: row.get(0)?,
|
||||
task: row.get(1)?,
|
||||
agent_type: row.get(2)?,
|
||||
working_dir: PathBuf::from(row.get::<_, String>(3)?),
|
||||
task,
|
||||
project,
|
||||
task_group,
|
||||
agent_type: row.get(4)?,
|
||||
working_dir,
|
||||
state,
|
||||
pid: row.get::<_, Option<u32>>(5)?,
|
||||
pid: row.get::<_, Option<u32>>(7)?,
|
||||
worktree,
|
||||
created_at: chrono::DateTime::parse_from_rfc3339(&created_str)
|
||||
.unwrap_or_default()
|
||||
@@ -1104,13 +1141,13 @@ impl StateStore {
|
||||
})
|
||||
.with_timezone(&chrono::Utc),
|
||||
metrics: SessionMetrics {
|
||||
input_tokens: row.get(9)?,
|
||||
output_tokens: row.get(10)?,
|
||||
tokens_used: row.get(11)?,
|
||||
tool_calls: row.get(12)?,
|
||||
files_changed: row.get(13)?,
|
||||
duration_secs: row.get(14)?,
|
||||
cost_usd: row.get(15)?,
|
||||
input_tokens: row.get(11)?,
|
||||
output_tokens: row.get(12)?,
|
||||
tokens_used: row.get(13)?,
|
||||
tool_calls: row.get(14)?,
|
||||
files_changed: row.get(15)?,
|
||||
duration_secs: row.get(16)?,
|
||||
cost_usd: row.get(17)?,
|
||||
},
|
||||
})
|
||||
})?
|
||||
@@ -2023,6 +2060,8 @@ mod tests {
|
||||
Session {
|
||||
id: id.to_string(),
|
||||
task: "task".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: PathBuf::from("/tmp"),
|
||||
state,
|
||||
@@ -2106,6 +2145,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "session-1".to_string(),
|
||||
task: "sync usage".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: PathBuf::from("/tmp"),
|
||||
state: SessionState::Running,
|
||||
@@ -2151,6 +2192,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "session-1".to_string(),
|
||||
task: "sync tools".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: PathBuf::from("/tmp"),
|
||||
state: SessionState::Running,
|
||||
@@ -2164,6 +2207,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "session-2".to_string(),
|
||||
task: "no activity".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: PathBuf::from("/tmp"),
|
||||
state: SessionState::Pending,
|
||||
@@ -2228,6 +2273,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "session-1".to_string(),
|
||||
task: "sync tools".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: PathBuf::from("/tmp"),
|
||||
state: SessionState::Running,
|
||||
@@ -2273,6 +2320,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "session-1".to_string(),
|
||||
task: "sync tools".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: PathBuf::from("/tmp"),
|
||||
state: SessionState::Running,
|
||||
@@ -2321,6 +2370,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "session-1".to_string(),
|
||||
task: "focus".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: PathBuf::from("/tmp"),
|
||||
state: SessionState::Running,
|
||||
@@ -2334,6 +2385,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "session-2".to_string(),
|
||||
task: "delegate".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: PathBuf::from("/tmp"),
|
||||
state: SessionState::Idle,
|
||||
@@ -2347,6 +2400,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "session-3".to_string(),
|
||||
task: "done".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: PathBuf::from("/tmp"),
|
||||
state: SessionState::Completed,
|
||||
@@ -2392,6 +2447,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "running-1".to_string(),
|
||||
task: "live run".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: PathBuf::from("/tmp"),
|
||||
state: SessionState::Running,
|
||||
@@ -2405,6 +2462,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "done-1".to_string(),
|
||||
task: "finished run".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: PathBuf::from("/tmp"),
|
||||
state: SessionState::Completed,
|
||||
@@ -2440,6 +2499,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "session-1".to_string(),
|
||||
task: "heartbeat".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: PathBuf::from("/tmp"),
|
||||
state: SessionState::Running,
|
||||
@@ -2470,6 +2531,8 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: "session-1".to_string(),
|
||||
task: "buffer output".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: PathBuf::from("/tmp"),
|
||||
state: SessionState::Running,
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::session::output::{
|
||||
OutputEvent, OutputLine, OutputStream, SessionOutputStore, OUTPUT_BUFFER_LIMIT,
|
||||
};
|
||||
use crate::session::store::{DaemonActivity, FileActivityOverlap, StateStore};
|
||||
use crate::session::{FileActivityEntry, Session, SessionMessage, SessionState};
|
||||
use crate::session::{FileActivityEntry, Session, SessionGrouping, SessionMessage, SessionState};
|
||||
use crate::worktree;
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -115,6 +115,8 @@ pub struct Dashboard {
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
struct SessionSummary {
|
||||
total: usize,
|
||||
projects: usize,
|
||||
task_groups: usize,
|
||||
pending: usize,
|
||||
running: usize,
|
||||
idle: usize,
|
||||
@@ -373,6 +375,7 @@ impl Dashboard {
|
||||
last_tool_activity_signature: initial_tool_activity_signature,
|
||||
last_budget_alert_state: BudgetState::Normal,
|
||||
};
|
||||
sort_sessions_for_display(&mut dashboard.sessions);
|
||||
dashboard.unread_message_counts = dashboard.db.unread_message_counts().unwrap_or_default();
|
||||
dashboard.sync_handoff_backlog_counts();
|
||||
dashboard.sync_global_handoff_backlog();
|
||||
@@ -489,9 +492,27 @@ impl Dashboard {
|
||||
|
||||
frame.render_widget(Paragraph::new(overview_lines), chunks[0]);
|
||||
|
||||
let mut previous_project: Option<&str> = None;
|
||||
let mut previous_task_group: Option<&str> = None;
|
||||
let rows = self.sessions.iter().map(|session| {
|
||||
let project_cell = if previous_project == Some(session.project.as_str()) {
|
||||
None
|
||||
} else {
|
||||
previous_project = Some(session.project.as_str());
|
||||
previous_task_group = None;
|
||||
Some(session.project.clone())
|
||||
};
|
||||
let task_group_cell = if previous_task_group == Some(session.task_group.as_str()) {
|
||||
None
|
||||
} else {
|
||||
previous_task_group = Some(session.task_group.as_str());
|
||||
Some(session.task_group.clone())
|
||||
};
|
||||
|
||||
session_row(
|
||||
session,
|
||||
project_cell,
|
||||
task_group_cell,
|
||||
self.approval_queue_counts
|
||||
.get(&session.id)
|
||||
.copied()
|
||||
@@ -504,6 +525,8 @@ impl Dashboard {
|
||||
});
|
||||
let header = Row::new([
|
||||
"ID",
|
||||
"Project",
|
||||
"Group",
|
||||
"Agent",
|
||||
"State",
|
||||
"Branch",
|
||||
@@ -517,6 +540,8 @@ impl Dashboard {
|
||||
.style(Style::default().add_modifier(Modifier::BOLD));
|
||||
let widths = [
|
||||
Constraint::Length(8),
|
||||
Constraint::Length(12),
|
||||
Constraint::Length(18),
|
||||
Constraint::Length(10),
|
||||
Constraint::Length(10),
|
||||
Constraint::Min(12),
|
||||
@@ -1650,13 +1675,22 @@ impl Dashboard {
|
||||
|
||||
let task = self.new_session_task();
|
||||
let agent = self.cfg.default_agent.clone();
|
||||
let grouping = self
|
||||
.sessions
|
||||
.get(self.selected_session)
|
||||
.map(|session| SessionGrouping {
|
||||
project: Some(session.project.clone()),
|
||||
task_group: Some(session.task_group.clone()),
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let session_id = match manager::create_session(
|
||||
let session_id = match manager::create_session_with_grouping(
|
||||
&self.db,
|
||||
&self.cfg,
|
||||
&task,
|
||||
&agent,
|
||||
self.cfg.auto_create_worktrees,
|
||||
grouping,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -2610,16 +2644,24 @@ impl Dashboard {
|
||||
});
|
||||
let source_task = source_session.as_ref().map(|session| session.task.clone());
|
||||
let source_session_id = source_session.as_ref().map(|session| session.id.clone());
|
||||
let source_grouping = source_session
|
||||
.as_ref()
|
||||
.map(|session| SessionGrouping {
|
||||
project: Some(session.project.clone()),
|
||||
task_group: Some(session.task_group.clone()),
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let agent = self.cfg.default_agent.clone();
|
||||
let mut created_ids = Vec::new();
|
||||
|
||||
for task in expand_spawn_tasks(&plan.task, plan.spawn_count) {
|
||||
let session_id = match manager::create_session(
|
||||
let session_id = match manager::create_session_with_grouping(
|
||||
&self.db,
|
||||
&self.cfg,
|
||||
&task,
|
||||
&agent,
|
||||
self.cfg.auto_create_worktrees,
|
||||
source_grouping.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -2950,7 +2992,10 @@ impl Dashboard {
|
||||
let (heartbeat_enforcement, budget_enforcement) = self.sync_runtime_metrics();
|
||||
let selected_id = self.selected_session_id().map(ToOwned::to_owned);
|
||||
self.sessions = match self.db.list_sessions() {
|
||||
Ok(sessions) => sessions,
|
||||
Ok(mut sessions) => {
|
||||
sort_sessions_for_display(&mut sessions);
|
||||
sessions
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!("Failed to refresh sessions: {error}");
|
||||
Vec::new()
|
||||
@@ -4105,6 +4150,14 @@ impl Dashboard {
|
||||
fn selected_session_metrics_text(&self) -> String {
|
||||
if let Some(session) = self.sessions.get(self.selected_session) {
|
||||
let metrics = &session.metrics;
|
||||
let group_peers = self
|
||||
.sessions
|
||||
.iter()
|
||||
.filter(|candidate| {
|
||||
candidate.project == session.project
|
||||
&& candidate.task_group == session.task_group
|
||||
})
|
||||
.count();
|
||||
let mut lines = vec![
|
||||
format!(
|
||||
"Selected {} [{}]",
|
||||
@@ -4112,6 +4165,10 @@ impl Dashboard {
|
||||
session.state
|
||||
),
|
||||
format!("Task {}", session.task),
|
||||
format!(
|
||||
"Project {} | Group {} | Peer sessions {}",
|
||||
session.project, session.task_group, group_peers
|
||||
),
|
||||
];
|
||||
|
||||
if let Some(parent) = self.selected_parent_session.as_ref() {
|
||||
@@ -5203,9 +5260,21 @@ impl SessionSummary {
|
||||
worktree_health_by_session: &HashMap<String, worktree::WorktreeHealth>,
|
||||
suppress_inbox_attention: bool,
|
||||
) -> Self {
|
||||
let projects = sessions
|
||||
.iter()
|
||||
.map(|session| session.project.as_str())
|
||||
.collect::<HashSet<_>>()
|
||||
.len();
|
||||
let task_groups = sessions
|
||||
.iter()
|
||||
.map(|session| (session.project.as_str(), session.task_group.as_str()))
|
||||
.collect::<HashSet<_>>()
|
||||
.len();
|
||||
sessions.iter().fold(
|
||||
Self {
|
||||
total: sessions.len(),
|
||||
projects,
|
||||
task_groups,
|
||||
unread_messages: if suppress_inbox_attention {
|
||||
0
|
||||
} else {
|
||||
@@ -5248,6 +5317,8 @@ impl SessionSummary {
|
||||
|
||||
fn session_row(
|
||||
session: &Session,
|
||||
project_label: Option<String>,
|
||||
task_group_label: Option<String>,
|
||||
approval_requests: usize,
|
||||
unread_messages: usize,
|
||||
) -> Row<'static> {
|
||||
@@ -5255,6 +5326,8 @@ fn session_row(
|
||||
let state_color = session_state_color(&session.state);
|
||||
Row::new(vec![
|
||||
Cell::from(format_session_id(&session.id)),
|
||||
Cell::from(project_label.unwrap_or_default()),
|
||||
Cell::from(task_group_label.unwrap_or_default()),
|
||||
Cell::from(session.agent_type.clone()),
|
||||
Cell::from(state_label).style(
|
||||
Style::default()
|
||||
@@ -5293,12 +5366,24 @@ fn session_row(
|
||||
])
|
||||
}
|
||||
|
||||
fn sort_sessions_for_display(sessions: &mut [Session]) {
|
||||
sessions.sort_by(|left, right| {
|
||||
left.project
|
||||
.cmp(&right.project)
|
||||
.then_with(|| left.task_group.cmp(&right.task_group))
|
||||
.then_with(|| right.updated_at.cmp(&left.updated_at))
|
||||
.then_with(|| left.id.cmp(&right.id))
|
||||
});
|
||||
}
|
||||
|
||||
fn summary_line(summary: &SessionSummary) -> Line<'static> {
|
||||
let mut spans = vec![
|
||||
Span::styled(
|
||||
format!("Total {} ", summary.total),
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
),
|
||||
summary_span("Projects", summary.projects, Color::Cyan),
|
||||
summary_span("Groups", summary.task_groups, Color::Magenta),
|
||||
summary_span("Running", summary.running, Color::Green),
|
||||
summary_span("Idle", summary.idle, Color::Yellow),
|
||||
summary_span("Stale", summary.stale, Color::LightRed),
|
||||
@@ -6284,8 +6369,9 @@ mod tests {
|
||||
|
||||
let rendered = render_dashboard_text(dashboard, 220, 24);
|
||||
assert!(rendered.contains("ID"));
|
||||
assert!(rendered.contains("Project"));
|
||||
assert!(rendered.contains("Group"));
|
||||
assert!(rendered.contains("Branch"));
|
||||
assert!(rendered.contains("Tool Files"));
|
||||
assert!(rendered.contains("Total 2"));
|
||||
assert!(rendered.contains("Running 1"));
|
||||
assert!(rendered.contains("Completed 1"));
|
||||
@@ -8285,6 +8371,8 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
db.insert_session(&Session {
|
||||
id: "sess-1".to_string(),
|
||||
task: "sync activity".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: PathBuf::from("/tmp"),
|
||||
state: SessionState::Running,
|
||||
@@ -8326,6 +8414,8 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
db.insert_session(&Session {
|
||||
id: "stale-1".to_string(),
|
||||
task: "stale session".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: PathBuf::from("/tmp"),
|
||||
state: SessionState::Running,
|
||||
@@ -8479,6 +8569,8 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
db.insert_session(&Session {
|
||||
id: "older".to_string(),
|
||||
task: "older".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: PathBuf::from("/tmp"),
|
||||
state: SessionState::Idle,
|
||||
@@ -8493,6 +8585,8 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
db.insert_session(&Session {
|
||||
id: "newer".to_string(),
|
||||
task: "newer".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: PathBuf::from("/tmp"),
|
||||
state: SessionState::Running,
|
||||
@@ -8523,6 +8617,8 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
db.insert_session(&Session {
|
||||
id: "session-1".to_string(),
|
||||
task: "inspect output".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: PathBuf::from("/tmp"),
|
||||
state: SessionState::Running,
|
||||
@@ -8566,6 +8662,8 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
db.insert_session(&Session {
|
||||
id: "session-1".to_string(),
|
||||
task: "tail output".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: PathBuf::from("/tmp"),
|
||||
state: SessionState::Running,
|
||||
@@ -9201,6 +9299,8 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
db.insert_session(&Session {
|
||||
id: "running-1".to_string(),
|
||||
task: "stop me".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
state: SessionState::Running,
|
||||
working_dir: PathBuf::from("/tmp"),
|
||||
@@ -9235,6 +9335,8 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
db.insert_session(&Session {
|
||||
id: "failed-1".to_string(),
|
||||
task: "resume me".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
state: SessionState::Failed,
|
||||
working_dir: PathBuf::from("/tmp/ecc2-resume"),
|
||||
@@ -9275,6 +9377,8 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
db.insert_session(&Session {
|
||||
id: "stopped-1".to_string(),
|
||||
task: "cleanup me".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
state: SessionState::Stopped,
|
||||
working_dir: worktree_path.clone(),
|
||||
@@ -9316,6 +9420,8 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
db.insert_session(&Session {
|
||||
id: "running-1".to_string(),
|
||||
task: "keep alive".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: PathBuf::from("/tmp"),
|
||||
state: SessionState::Running,
|
||||
@@ -9353,6 +9459,8 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
db.insert_session(&Session {
|
||||
id: "running-1".to_string(),
|
||||
task: "keep worktree".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: active_path.clone(),
|
||||
state: SessionState::Running,
|
||||
@@ -9370,6 +9478,8 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
db.insert_session(&Session {
|
||||
id: "stopped-1".to_string(),
|
||||
task: "prune me".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: stopped_path.clone(),
|
||||
state: SessionState::Stopped,
|
||||
@@ -9421,6 +9531,8 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
db.insert_session(&Session {
|
||||
id: "stopped-1".to_string(),
|
||||
task: "retain me".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: retained_path.clone(),
|
||||
state: SessionState::Stopped,
|
||||
@@ -9473,6 +9585,8 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
db.insert_session(&Session {
|
||||
id: session_id.clone(),
|
||||
task: "merge via dashboard".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: worktree.path.clone(),
|
||||
state: SessionState::Completed,
|
||||
@@ -9555,6 +9669,8 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
db.insert_session(&Session {
|
||||
id: "merge-ready".to_string(),
|
||||
task: "merge via dashboard".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: merged_worktree.path.clone(),
|
||||
state: SessionState::Completed,
|
||||
@@ -9571,6 +9687,8 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
db.insert_session(&Session {
|
||||
id: "active-ready".to_string(),
|
||||
task: "still active".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: active_worktree.path.clone(),
|
||||
state: SessionState::Running,
|
||||
@@ -9615,6 +9733,8 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
db.insert_session(&Session {
|
||||
id: "done-1".to_string(),
|
||||
task: "delete me".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: PathBuf::from("/tmp"),
|
||||
state: SessionState::Completed,
|
||||
@@ -9648,6 +9768,8 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
db.insert_session(&Session {
|
||||
id: "lead-1".to_string(),
|
||||
task: "coordinate".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: PathBuf::from("/tmp"),
|
||||
state: SessionState::Running,
|
||||
@@ -9681,6 +9803,8 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
db.insert_session(&Session {
|
||||
id: "lead-1".to_string(),
|
||||
task: "coordinate".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: PathBuf::from("/tmp"),
|
||||
state: SessionState::Running,
|
||||
@@ -9714,6 +9838,8 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
db.insert_session(&Session {
|
||||
id: "lead-1".to_string(),
|
||||
task: "coordinate".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: PathBuf::from("/tmp"),
|
||||
state: SessionState::Running,
|
||||
@@ -9747,6 +9873,8 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
db.insert_session(&Session {
|
||||
id: "lead-1".to_string(),
|
||||
task: "coordinate".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: PathBuf::from("/tmp"),
|
||||
state: SessionState::Running,
|
||||
@@ -10424,6 +10552,8 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
Session {
|
||||
id: id.to_string(),
|
||||
task: "Render dashboard rows".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: agent_type.to_string(),
|
||||
state,
|
||||
working_dir: branch
|
||||
@@ -10455,6 +10585,8 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
Session {
|
||||
id: id.to_string(),
|
||||
task: "Budget tracking".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
state: SessionState::Running,
|
||||
working_dir: PathBuf::from("/tmp"),
|
||||
|
||||
Reference in New Issue
Block a user