//! Handler for the `status` command and pipeline status helpers. use crate::agents::{AgentPool, AgentStatus}; use crate::config::ProjectConfig; use crate::pipeline_state::{PipelineItem, Stage}; use std::collections::{HashMap, HashSet}; use super::CommandContext; pub(super) fn handle_status(ctx: &CommandContext) -> Option { if ctx.args.trim().is_empty() { Some(build_pipeline_status(ctx.project_root, ctx.agents)) } else { super::triage::handle_triage(ctx) } } /// Format a short display label for a work item. /// /// Extracts the leading numeric ID and optional type tag from the file stem /// (e.g. `"293"` and `"story"` from `"293_story_register_all_bot_commands"`) /// and combines them with the human-readable name from the front matter when /// available. Known types (`story`, `bug`, `spike`, `refactor`) are shown as /// bracketed labels; unknown or missing types are omitted silently. /// /// Examples: /// - `("293_story_foo", Some("Register all bot commands"))` → `"293 [story] — Register all bot commands"` /// - `("375_bug_foo", None)` → `"375 [bug]"` /// - `("293_story_foo", None)` → `"293 [story]"` /// - `("no_number_here", None)` → `"no_number_here"` pub(super) fn story_short_label(stem: &str, name: Option<&str>) -> String { let mut parts = stem.splitn(3, '_'); let first = parts.next().unwrap_or(stem); let (number, type_label) = if !first.is_empty() && first.chars().all(|c| c.is_ascii_digit()) { let t = parts.next().and_then(|t| match t { "story" | "bug" | "spike" | "refactor" => Some(t), _ => None, }); (first, t) } else { (stem, None) }; let prefix = match type_label { Some(t) => format!("{number} [{t}]"), None => number.to_string(), }; match name { Some(n) => format!("{prefix} — {n}"), None => prefix, } } /// Choose the traffic-light indicator for a work item. /// /// Priority: blocked > throttled > running > idle. /// Uses coloured emoji so indicators render natively in all Matrix clients /// (Element X and others do not support `` HTML tags). /// /// - 🔴 hard-blocked (retry limit exceeded) /// - 🟠 throttled (rate-limit warning received) /// - 🟢 running normally (active agent, no throttle) /// - ⚪ idle / no active agent pub(super) fn traffic_light_dot(blocked: bool, throttled: bool, has_agent: bool) -> &'static str { if blocked { "\u{1F534} " // 🔴 — hard blocked } else if throttled { "\u{1F7E0} " // 🟠 — throttled } else if has_agent { "\u{1F7E2} " // 🟢 — running normally } else { "\u{26AA} " // ⚪ — idle / no agent } } /// Check which dependency numbers from `item.depends_on` are unmet. /// /// A dependency is considered met if the dep is in `Done` or `Archived` stage /// in `all_items`. If the dep is not found in `all_items` at all (e.g. it was /// archived before the CRDT migration and has no row), it is treated as met. pub(super) fn unmet_deps_from_items(item: &PipelineItem, all_items: &[PipelineItem]) -> Vec { item.depends_on .iter() .filter_map(|dep_id| { // dep_id.0 is the raw number string (e.g. "999") as projected // from PipelineItemView.depends_on: Vec. let dep_num: u32 = dep_id.0.parse().ok()?; // Find the dep by matching the numeric prefix of its story_id. let dep = all_items.iter().find(|i| { i.story_id.0 == dep_id.0 || i.story_id.0.split('_').next() == Some(dep_id.0.as_str()) }); match dep { Some(d) if matches!(d.stage, Stage::Done { .. } | Stage::Archived { .. }) => None, Some(_) => Some(dep_num), // Found but not done = unmet None => None, // Not in CRDT; treat as met } }) .collect() } /// Build the full pipeline status text formatted for Matrix (markdown). pub(super) fn build_pipeline_status(project_root: &std::path::Path, agents: &AgentPool) -> String { let items = crate::pipeline_state::read_all_typed(); build_status_from_items(project_root, agents, &items) } /// Inner implementation that accepts pre-loaded items for testability. fn build_status_from_items( project_root: &std::path::Path, agents: &AgentPool, items: &[PipelineItem], ) -> String { // Build a map from story_id → active AgentInfo for quick lookup. let active_agents = agents.list_agents().unwrap_or_default(); let active_map: HashMap = active_agents .iter() .filter(|a| matches!(a.status, AgentStatus::Running | AgentStatus::Pending)) .map(|a| (a.story_id.clone(), a)) .collect(); // Read token usage once for all stories to avoid repeated file I/O. let cost_by_story: HashMap = crate::agents::token_usage::read_all(project_root) .unwrap_or_default() .into_iter() .fold(HashMap::new(), |mut map, r| { *map.entry(r.story_id).or_insert(0.0) += r.usage.total_cost_usd; map }); let config = ProjectConfig::load(project_root).ok(); let mut out = String::from("**Pipeline Status**\n\n"); // Active pipeline stages to display (Archived is handled separately below). type StagePredicate = fn(&Stage) -> bool; let stage_filters: &[(&str, StagePredicate)] = &[ ("Backlog", |s| matches!(s, Stage::Backlog)), ("In Progress", |s| matches!(s, Stage::Coding)), ("QA", |s| matches!(s, Stage::Qa)), ("Merge", |s| matches!(s, Stage::Merge { .. })), ("Done", |s| matches!(s, Stage::Done { .. })), ]; for (label, filter) in stage_filters { let mut stage_items: Vec<&PipelineItem> = items.iter().filter(|i| filter(&i.stage)).collect(); stage_items.sort_by(|a, b| a.story_id.0.cmp(&b.story_id.0)); let count = stage_items.len(); out.push_str(&format!("**{label}** ({count})\n")); if stage_items.is_empty() { out.push_str(" *(none)*\n"); } else { for item in &stage_items { out.push_str(&render_item_line( item, items, &active_map, &cost_by_story, &config, )); } } out.push('\n'); } // Blocked items: Archived { reason: Blocked } shown with 🔴 indicator. let mut blocked_items: Vec<&PipelineItem> = items.iter().filter(|i| i.stage.is_blocked()).collect(); blocked_items.sort_by(|a, b| a.story_id.0.cmp(&b.story_id.0)); if !blocked_items.is_empty() { out.push_str(&format!("**Blocked** ({})\n", blocked_items.len())); for item in &blocked_items { out.push_str(&render_item_line( item, items, &active_map, &cost_by_story, &config, )); } out.push('\n'); } // Free agents: configured agents not currently running or pending. out.push_str("**Free Agents**\n"); if let Some(cfg) = &config { let busy_names: HashSet = active_agents .iter() .filter(|a| matches!(a.status, AgentStatus::Running | AgentStatus::Pending)) .map(|a| a.agent_name.clone()) .collect(); let free: Vec = cfg .agent .iter() .filter(|a| !busy_names.contains(&a.name)) .map(|a| match &a.model { Some(m) => format!("{} ({})", a.name, m), None => a.name.clone(), }) .collect(); if free.is_empty() { out.push_str(" *(none — all agents busy)*\n"); } else { for name in &free { out.push_str(&format!(" • {name}\n")); } } } else { out.push_str(" *(no agent config found)*\n"); } out } /// Render a single status line for one pipeline item. fn render_item_line( item: &PipelineItem, all_items: &[PipelineItem], active_map: &HashMap, cost_by_story: &HashMap, config: &Option, ) -> String { let story_id = &item.story_id.0; let name_opt = if item.name.is_empty() { None } else { Some(item.name.as_str()) }; let frozen = crate::io::story_metadata::is_story_frozen_in_store(story_id); let base_label = story_short_label(story_id, name_opt); let display = if frozen { format!("\u{2744}\u{FE0F} {base_label}") // ❄️ prefix } else { base_label }; let cost_suffix = cost_by_story .get(story_id) .filter(|&&c| c > 0.0) .map(|c| format!(" — ${c:.2}")) .unwrap_or_default(); let blocked = item.stage.is_blocked(); let agent = active_map.get(story_id); let throttled = agent.map(|a| a.throttled).unwrap_or(false); let dot = traffic_light_dot(blocked, throttled, agent.is_some()); let unmet = unmet_deps_from_items(item, all_items); let dep_suffix = if unmet.is_empty() { String::new() } else { let nums: Vec = unmet.iter().map(|n| n.to_string()).collect(); format!(" *(waiting on: {})*", nums.join(", ")) }; if let Some(agent) = agent { let model_str = config .as_ref() .and_then(|cfg| cfg.find_agent(&agent.agent_name)) .and_then(|ac| ac.model.as_deref()) .unwrap_or("?"); format!( " {dot}{display}{cost_suffix}{dep_suffix} — {} ({model_str})\n", agent.agent_name ) } else { format!(" {dot}{display}{cost_suffix}{dep_suffix}\n") } } #[cfg(test)] mod tests { use super::*; use crate::agents::AgentPool; use crate::pipeline_state::{ArchiveReason, PipelineItem, Stage, StoryId}; use chrono::Utc; /// Build a minimal PipelineItem for tests. fn make_item(id: &str, name: &str, stage: Stage) -> PipelineItem { PipelineItem { story_id: StoryId(id.to_string()), name: name.to_string(), stage, depends_on: Vec::new(), retry_count: 0, } } /// Build a PipelineItem with dependencies for tests. fn make_item_with_deps(id: &str, name: &str, stage: Stage, deps: Vec) -> PipelineItem { PipelineItem { story_id: StoryId(id.to_string()), name: name.to_string(), stage, depends_on: deps.iter().map(|n| StoryId(n.to_string())).collect(), retry_count: 0, } } #[test] fn status_command_matches() { let result = super::super::tests::try_cmd_addressed( "Timmy", "@timmy:homeserver.local", "@timmy status", ); assert!(result.is_some(), "status command should match"); } #[test] fn status_command_returns_pipeline_text() { let result = super::super::tests::try_cmd_addressed( "Timmy", "@timmy:homeserver.local", "@timmy status", ); let output = result.unwrap(); assert!( output.contains("Pipeline Status"), "status output should contain pipeline info: {output}" ); } #[test] fn status_command_case_insensitive() { let result = super::super::tests::try_cmd_addressed( "Timmy", "@timmy:homeserver.local", "@timmy STATUS", ); assert!(result.is_some(), "STATUS should match case-insensitively"); } // -- story_short_label -------------------------------------------------- #[test] fn short_label_extracts_number_and_name() { let label = story_short_label( "293_story_register_all_bot_commands", Some("Register all bot commands"), ); assert_eq!(label, "293 [story] — Register all bot commands"); } #[test] fn short_label_number_only_when_no_name() { let label = story_short_label("297_story_improve_bot_status_command_formatting", None); assert_eq!(label, "297 [story]"); } #[test] fn short_label_falls_back_to_stem_when_no_numeric_prefix() { let label = story_short_label("no_number_here", None); assert_eq!(label, "no_number_here"); } #[test] fn short_label_does_not_include_underscore_slug() { let label = story_short_label( "293_story_register_all_bot_commands_in_the_command_registry", Some("Register all bot commands"), ); assert!( !label.contains("story_register"), "label should not contain the slug portion: {label}" ); } #[test] fn short_label_shows_bug_type() { let label = story_short_label( "375_bug_default_project_toml", Some("Default project.toml issue"), ); assert_eq!(label, "375 [bug] — Default project.toml issue"); } #[test] fn short_label_shows_spike_type() { let label = story_short_label( "61_spike_filesystem_watcher_architecture", Some("Filesystem watcher architecture"), ); assert_eq!(label, "61 [spike] — Filesystem watcher architecture"); } #[test] fn short_label_shows_refactor_type() { let label = story_short_label( "260_refactor_upgrade_libsqlite3_sys", Some("Upgrade libsqlite3-sys"), ); assert_eq!(label, "260 [refactor] — Upgrade libsqlite3-sys"); } #[test] fn short_label_omits_unknown_type() { let label = story_short_label("42_task_do_something", Some("Do something")); assert_eq!(label, "42 — Do something"); } #[test] fn short_label_no_type_when_only_id() { // Stem with only a numeric ID and no type segment let label = story_short_label("42", Some("Some item")); assert_eq!(label, "42 — Some item"); } // -- build_status_from_items formatting ----------------------------------- #[test] fn status_does_not_show_full_filename_stem() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let items = vec![make_item( "293_story_register_all_bot_commands", "Register all bot commands", Stage::Coding, )]; let agents = AgentPool::new_test(3000); let output = build_status_from_items(tmp.path(), &agents, &items); assert!( !output.contains("293_story_register_all_bot_commands"), "output must not show full filename stem: {output}" ); assert!( output.contains("293 [story] — Register all bot commands"), "output must show number, type, and title: {output}" ); } // -- token cost in status output ---------------------------------------- #[test] fn status_shows_cost_when_token_usage_exists() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let items = vec![make_item( "293_story_register_all_bot_commands", "Register all bot commands", Stage::Coding, )]; // Write token usage for this story. let usage = crate::agents::TokenUsage { input_tokens: 100, output_tokens: 200, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, total_cost_usd: 0.29, }; let record = crate::agents::token_usage::build_record( "293_story_register_all_bot_commands", "coder-1", None, usage, ); crate::agents::token_usage::append_record(tmp.path(), &record).unwrap(); let agents = AgentPool::new_test(3000); let output = build_status_from_items(tmp.path(), &agents, &items); assert!( output.contains("293 [story] — Register all bot commands — $0.29"), "output must show cost next to story: {output}" ); } #[test] fn status_no_cost_when_no_usage() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let items = vec![make_item( "293_story_register_all_bot_commands", "Register all bot commands", Stage::Coding, )]; let agents = AgentPool::new_test(3000); let output = build_status_from_items(tmp.path(), &agents, &items); assert!( !output.contains("$"), "output must not show cost when no usage exists: {output}" ); } #[test] fn status_aggregates_multiple_records_per_story() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let items = vec![make_item( "293_story_register_all_bot_commands", "Register all bot commands", Stage::Coding, )]; // Write two records for the same story — costs should be summed. for cost in [0.10, 0.19] { let usage = crate::agents::TokenUsage { input_tokens: 50, output_tokens: 100, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, total_cost_usd: cost, }; let record = crate::agents::token_usage::build_record( "293_story_register_all_bot_commands", "coder-1", None, usage, ); crate::agents::token_usage::append_record(tmp.path(), &record).unwrap(); } let agents = AgentPool::new_test(3000); let output = build_status_from_items(tmp.path(), &agents, &items); assert!( output.contains("293 [story] — Register all bot commands — $0.29"), "output must show aggregated cost: {output}" ); } // -- dependency display in status output -------------------------------- #[test] fn status_shows_waiting_on_for_story_with_unmet_deps() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); // Story 10 depends on story 999, which is NOT in all_items (treated as met) // OR present in backlog (unmet). Let's add dep 999 in Backlog stage (unmet). let items = vec![ make_item_with_deps( "10_story_waiting", "Waiting Story", Stage::Coding, vec![999], ), make_item("999_story_dep", "Dep Story", Stage::Backlog), ]; let agents = AgentPool::new_test(3000); let output = build_status_from_items(tmp.path(), &agents, &items); assert!( output.contains("waiting on: 999"), "status should show waiting-on info for unmet deps: {output}" ); } #[test] fn status_does_not_show_waiting_on_when_dep_is_done() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); // Dep 999 is in Done stage — met. let items = vec![ make_item_with_deps( "10_story_unblocked", "Unblocked Story", Stage::Coding, vec![999], ), make_item( "999_story_dep", "Dep Story", Stage::Done { merged_at: Utc::now(), merge_commit: crate::pipeline_state::GitSha("abc123".to_string()), }, ), ]; let agents = AgentPool::new_test(3000); let output = build_status_from_items(tmp.path(), &agents, &items); assert!( !output.contains("waiting on"), "status should not show waiting-on when all deps are done: {output}" ); } #[test] fn status_shows_no_waiting_info_when_no_deps() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let items = vec![make_item("42_story_nodeps", "No Deps Story", Stage::Coding)]; let agents = AgentPool::new_test(3000); let output = build_status_from_items(tmp.path(), &agents, &items); assert!( !output.contains("waiting on"), "status should not show waiting-on for stories without deps: {output}" ); } // -- traffic_light_dot -------------------------------------------------- #[test] fn dot_idle_when_no_agent() { assert_eq!(traffic_light_dot(false, false, false), "\u{26AA} "); // ⚪ } #[test] fn dot_running_when_agent_not_throttled() { assert_eq!(traffic_light_dot(false, false, true), "\u{1F7E2} "); // 🟢 } #[test] fn dot_throttled_when_agent_throttled() { assert_eq!(traffic_light_dot(false, true, true), "\u{1F7E0} "); // 🟠 } #[test] fn dot_blocked_takes_priority_over_throttled() { assert_eq!(traffic_light_dot(true, true, true), "\u{1F534} "); // 🔴 } #[test] fn dot_blocked_when_no_agent_but_blocked_flag() { assert_eq!(traffic_light_dot(true, false, false), "\u{1F534} "); // 🔴 } // -- Stage::is_blocked() replaces read_story_blocked -------------------- #[test] fn stage_is_blocked_returns_true_for_archived_blocked() { let stage = Stage::Archived { archived_at: Utc::now(), reason: ArchiveReason::Blocked { reason: "too many retries".to_string(), }, }; assert!(stage.is_blocked()); } #[test] fn stage_is_blocked_returns_false_for_coding() { assert!(!Stage::Coding.is_blocked()); } #[test] fn stage_is_blocked_returns_false_for_archived_completed() { let stage = Stage::Archived { archived_at: Utc::now(), reason: ArchiveReason::Completed, }; assert!(!stage.is_blocked()); } // -- status output shows idle dot for items with no active agent -------- #[test] fn status_shows_idle_dot_for_unassigned_story() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let items = vec![make_item("42_story_idle", "Idle Story", Stage::Coding)]; let agents = AgentPool::new_test(3000); let output = build_status_from_items(tmp.path(), &agents, &items); assert!( output.contains("\u{26AA} "), // ⚪ "idle story should show white circle emoji: {output}" ); } #[test] fn status_shows_blocked_dot_for_blocked_story() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let items = vec![make_item( "42_story_blocked", "Blocked Story", Stage::Archived { archived_at: Utc::now(), reason: ArchiveReason::Blocked { reason: "retry limit exceeded".to_string(), }, }, )]; let agents = AgentPool::new_test(3000); let output = build_status_from_items(tmp.path(), &agents, &items); assert!( output.contains("\u{1F534} "), // 🔴 "blocked story should show red circle emoji: {output}" ); } // -- Regression: CRDT-only story appears in correct stage --------------- #[test] fn status_shows_crdt_done_story_in_done_not_backlog() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); // Story is Done in the typed API — even if a stale 1_backlog/ shadow existed // on the filesystem, the status must show it in Done, not Backlog. let items = vec![make_item( "503_story_some_feature", "Some Feature", Stage::Done { merged_at: Utc::now(), merge_commit: crate::pipeline_state::GitSha("deadbeef".to_string()), }, )]; let agents = AgentPool::new_test(3000); let output = build_status_from_items(tmp.path(), &agents, &items); // Must appear under Done, not Backlog. let done_pos = output.find("**Done**").expect("Done section must exist"); let backlog_pos = output .find("**Backlog**") .expect("Backlog section must exist"); let story_pos = output .find("503 [story]") .expect("story must appear in output"); assert!( story_pos > done_pos, "story should appear after Done header: backlog={backlog_pos} done={done_pos} story={story_pos}\n{output}" ); assert!( story_pos > backlog_pos, "story should not be in the Backlog section: {output}" ); // Verify it's not in Backlog section specifically. let backlog_section = &output[backlog_pos..done_pos]; assert!( !backlog_section.contains("503"), "503 must not appear in Backlog section: {backlog_section}" ); } }