From be5db846ccd5d08bcd78c5028746a1fc88cb1b83 Mon Sep 17 00:00:00 2001 From: dave Date: Wed, 29 Apr 2026 10:36:09 +0000 Subject: [PATCH] huskies: merge 835 --- server/src/chat/commands/status.rs | 1007 --------------------- server/src/chat/commands/status/labels.rs | 58 ++ server/src/chat/commands/status/mod.rs | 30 + server/src/chat/commands/status/render.rs | 260 ++++++ server/src/chat/commands/status/tests.rs | 667 ++++++++++++++ 5 files changed, 1015 insertions(+), 1007 deletions(-) delete mode 100644 server/src/chat/commands/status.rs create mode 100644 server/src/chat/commands/status/labels.rs create mode 100644 server/src/chat/commands/status/mod.rs create mode 100644 server/src/chat/commands/status/render.rs create mode 100644 server/src/chat/commands/status/tests.rs diff --git a/server/src/chat/commands/status.rs b/server/src/chat/commands/status.rs deleted file mode 100644 index 9edfe82a..00000000 --- a/server/src/chat/commands/status.rs +++ /dev/null @@ -1,1007 +0,0 @@ -//! 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.effective_root(), - &ctx.services.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() -} - -/// Extract the first non-empty line from `text`, truncated to `max_len` chars. -fn first_non_empty_snippet(text: &str, max_len: usize) -> String { - let line = text.lines().find(|l| !l.trim().is_empty()).unwrap_or(""); - let mut chars = line.chars(); - let truncated: String = chars.by_ref().take(max_len).collect(); - if chars.next().is_some() { - format!("{truncated}…") - } else { - truncated - } -} - -/// 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(); - - // Pre-fetch merge-specific state: deterministic merges in flight and - // any merge_failure text persisted to the story's front matter. - let running_merges: HashSet = agents - .list_running_merges() - .unwrap_or_default() - .into_iter() - .collect(); - let merge_failures: HashMap = items - .iter() - .filter(|i| matches!(i.stage, Stage::Merge { .. })) - .filter_map(|i| { - let content = crate::db::read_content(&i.story_id.0)?; - let meta = crate::io::story_metadata::parse_front_matter(&content).ok()?; - let mf = meta.merge_failure?; - Some((i.story_id.0.clone(), mf)) - }) - .collect(); - - 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, - &running_merges, - &merge_failures, - )); - } - } - 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, - &running_merges, - &merge_failures, - )); - } - 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, - running_merges: &HashSet, - merge_failures: &HashMap, -) -> 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 agent = active_map.get(story_id); - 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(", ")) - }; - - // Merge-stage items get a dedicated breakdown indicator instead of the - // generic traffic-light dot. - if matches!(item.stage, Stage::Merge { .. }) { - let in_det_merge = running_merges.contains(story_id); - let merge_failure = merge_failures.get(story_id); - if in_det_merge { - // A fresh deterministic merge is in progress — always prefer 🔄, - // even when a previous attempt recorded a merge_failure. - return format!( - " \u{1F504} {display}{cost_suffix}{dep_suffix} — deterministic-merge running\n" - ); - } else if agent.is_some() { - return format!( - " \u{1F916} {display}{cost_suffix}{dep_suffix} — mergemaster running\n" - ); - } else if let Some(mf) = merge_failure { - let snippet = first_non_empty_snippet(mf, 120); - return format!(" \u{26D4} {display}{cost_suffix}{dep_suffix} — {snippet}\n"); - } else { - return format!(" \u{23F3} {display}{cost_suffix}{dep_suffix}\n"); - } - } - - let blocked = item.stage.is_blocked(); - let throttled = agent.map(|a| a.throttled).unwrap_or(false); - let dot = traffic_light_dot(blocked, throttled, agent.is_some()); - 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}" - ); - } - - // -- merge-stage breakdown indicators ------------------------------------ - - fn merge_stage() -> Stage { - use crate::pipeline_state::BranchName; - Stage::Merge { - feature_branch: BranchName("feature/test".to_string()), - commits_ahead: std::num::NonZeroU32::new(1).unwrap(), - } - } - - #[test] - fn merge_item_queued_shows_hourglass() { - use tempfile::TempDir; - let tmp = TempDir::new().unwrap(); - let items = vec![make_item( - "901_story_merge_q", - "Queued Story", - merge_stage(), - )]; - let agents = AgentPool::new_test(3000); - let output = build_status_from_items(tmp.path(), &agents, &items); - assert!( - output.contains("\u{23F3}"), // ⏳ - "queued merge item should show hourglass: {output}" - ); - } - - #[test] - fn merge_item_with_active_agent_shows_robot() { - use tempfile::TempDir; - let tmp = TempDir::new().unwrap(); - let items = vec![make_item( - "902_story_merge_mm", - "Mergemaster Story", - merge_stage(), - )]; - let agents = AgentPool::new_test(3000); - agents.inject_test_agent("902_story_merge_mm", "mergemaster", AgentStatus::Running); - let output = build_status_from_items(tmp.path(), &agents, &items); - assert!( - output.contains("\u{1F916}"), // 🤖 - "merge item with running agent should show robot: {output}" - ); - assert!( - output.contains("mergemaster running"), - "output should contain 'mergemaster running': {output}" - ); - } - - #[test] - fn merge_item_with_failure_shows_stop_sign_and_snippet() { - use tempfile::TempDir; - let tmp = TempDir::new().unwrap(); - crate::db::ensure_content_store(); - crate::db::write_item_with_content( - "903_story_merge_fail", - "4_merge", - "---\nname: Failed Story\nmerge_failure: \"conflicts in src/lib.rs\"\n---\n", - ); - let items = vec![make_item( - "903_story_merge_fail", - "Failed Story", - merge_stage(), - )]; - let agents = AgentPool::new_test(3000); - let output = build_status_from_items(tmp.path(), &agents, &items); - assert!( - output.contains("\u{26D4}"), // ⛔ - "merge item with failure should show stop sign: {output}" - ); - assert!( - output.contains("conflicts in src/lib.rs"), - "output should contain failure snippet: {output}" - ); - } - - #[test] - fn merge_item_failure_snippet_truncated_at_120_chars() { - use tempfile::TempDir; - let tmp = TempDir::new().unwrap(); - crate::db::ensure_content_store(); - let long_reason = "x".repeat(200); - let content = format!("---\nname: Long Fail\nmerge_failure: \"{long_reason}\"\n---\n"); - crate::db::write_item_with_content("904_story_long_fail", "4_merge", &content); - let items = vec![make_item("904_story_long_fail", "Long Fail", merge_stage())]; - let agents = AgentPool::new_test(3000); - let output = build_status_from_items(tmp.path(), &agents, &items); - assert!( - output.contains("…"), - "long failure should be truncated with ellipsis: {output}" - ); - // The snippet should not exceed 120 chars plus the ellipsis character. - let snippet_start = output.find("\u{26D4}").expect("stop sign must be present"); - let line = output[snippet_start..].lines().next().unwrap_or(""); - // Find the last " — " separator (before the snippet) and take what follows. - if let Some(sep_pos) = line.rfind(" \u{2014} ") { - let snippet = &line[sep_pos + 5..]; // " — " is 5 bytes (space + 3-byte em dash + space) - assert!( - snippet.chars().count() <= 122, // 120 chars + "…" (1 char) + possible trailing - "snippet should be at most ~121 chars: {snippet}" - ); - } - } - - #[test] - fn merge_item_failure_snippet_is_first_non_empty_line() { - use tempfile::TempDir; - let tmp = TempDir::new().unwrap(); - crate::db::ensure_content_store(); - // Multi-line failure reason — first non-empty line should be used. - let reason = "\nfirst line of error\nsecond line"; - let content = format!( - "---\nname: Multi Line\nmerge_failure: \"{}\" \n---\n", - reason.replace('\n', "\\n") - ); - crate::db::write_item_with_content("905_story_multiline", "4_merge", &content); - // Write with literal \n as the content (simulating stored text with newlines). - let content2 = "---\nname: Multi Line\nmerge_failure: |\n \n first line of error\n second line\n---\n"; - crate::db::write_item_with_content("905_story_multiline", "4_merge", content2); - let items = vec![make_item( - "905_story_multiline", - "Multi Line", - merge_stage(), - )]; - let agents = AgentPool::new_test(3000); - let output = build_status_from_items(tmp.path(), &agents, &items); - assert!( - output.contains("first line of error"), - "snippet should use first non-empty line: {output}" - ); - assert!( - !output.contains("second line"), - "snippet should not include second line: {output}" - ); - } - - #[test] - fn merge_item_det_merge_running_preferred_over_failure() { - use tempfile::TempDir; - let tmp = TempDir::new().unwrap(); - crate::db::ensure_content_store(); - crate::db::write_item_with_content( - "906_story_det_over_fail", - "4_merge", - "---\nname: Det Over Fail\nmerge_failure: \"old failure\"\n---\n", - ); - // Record a running deterministic merge in the CRDT. - crate::crdt_state::write_merge_job("906_story_det_over_fail", "running", 0.0, None, None); - let items = vec![make_item( - "906_story_det_over_fail", - "Det Over Fail", - merge_stage(), - )]; - let agents = AgentPool::new_test(3000); - let output = build_status_from_items(tmp.path(), &agents, &items); - assert!( - output.contains("\u{1F504}"), // 🔄 - "deterministic merge running should be preferred over failure: {output}" - ); - assert!( - !output.contains("\u{26D4}"), // ⛔ should not appear - "stop sign should not appear when det merge is running: {output}" - ); - } - - // -- first_non_empty_snippet unit tests --------------------------------- - - #[test] - fn snippet_empty_text_returns_empty() { - assert_eq!(first_non_empty_snippet("", 120), ""); - } - - #[test] - fn snippet_short_text_returned_as_is() { - assert_eq!(first_non_empty_snippet("short error", 120), "short error"); - } - - #[test] - fn snippet_long_text_truncated_with_ellipsis() { - let text = "a".repeat(200); - let result = first_non_empty_snippet(&text, 120); - assert!(result.ends_with('…'), "should end with ellipsis: {result}"); - assert_eq!( - result.chars().count(), - 121, - "should be 120 + ellipsis: {result}" - ); - } - - #[test] - fn snippet_skips_leading_empty_lines() { - let text = "\n\n \nactual error here\nsecond line"; - let result = first_non_empty_snippet(text, 120); - assert_eq!(result, "actual error here"); - } -} diff --git a/server/src/chat/commands/status/labels.rs b/server/src/chat/commands/status/labels.rs new file mode 100644 index 00000000..d71d21a0 --- /dev/null +++ b/server/src/chat/commands/status/labels.rs @@ -0,0 +1,58 @@ +//! Short label and traffic-light indicator helpers for pipeline status display. + +/// 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(crate) 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(crate) 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 + } +} diff --git a/server/src/chat/commands/status/mod.rs b/server/src/chat/commands/status/mod.rs new file mode 100644 index 00000000..fde36507 --- /dev/null +++ b/server/src/chat/commands/status/mod.rs @@ -0,0 +1,30 @@ +//! Handler for the `status` command and pipeline status helpers. + +mod labels; +mod render; + +#[cfg(test)] +mod tests; + +pub(super) use labels::{story_short_label, traffic_light_dot}; +pub(super) use render::{build_pipeline_status, unmet_deps_from_items}; + +#[cfg(test)] +pub(super) use render::{build_status_from_items, first_non_empty_snippet}; + +use super::CommandContext; + +/// Dispatch the `status` bot command. +/// +/// With no arguments, renders the full pipeline status. With arguments, +/// delegates to the triage handler for per-story details. +pub(super) fn handle_status(ctx: &CommandContext) -> Option { + if ctx.args.trim().is_empty() { + Some(build_pipeline_status( + ctx.effective_root(), + &ctx.services.agents, + )) + } else { + super::triage::handle_triage(ctx) + } +} diff --git a/server/src/chat/commands/status/render.rs b/server/src/chat/commands/status/render.rs new file mode 100644 index 00000000..fc507c74 --- /dev/null +++ b/server/src/chat/commands/status/render.rs @@ -0,0 +1,260 @@ +//! Pipeline rendering: builds the full status text from pipeline items. + +use crate::agents::{AgentPool, AgentStatus}; +use crate::config::ProjectConfig; +use crate::pipeline_state::{PipelineItem, Stage}; +use std::collections::{HashMap, HashSet}; + +/// 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(crate) 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() +} + +/// Extract the first non-empty line from `text`, truncated to `max_len` chars. +pub(crate) fn first_non_empty_snippet(text: &str, max_len: usize) -> String { + let line = text.lines().find(|l| !l.trim().is_empty()).unwrap_or(""); + let mut chars = line.chars(); + let truncated: String = chars.by_ref().take(max_len).collect(); + if chars.next().is_some() { + format!("{truncated}…") + } else { + truncated + } +} + +/// Build the full pipeline status text formatted for Matrix (markdown). +pub(crate) 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. +pub(crate) 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(); + + // Pre-fetch merge-specific state: deterministic merges in flight and + // any merge_failure text persisted to the story's front matter. + let running_merges: HashSet = agents + .list_running_merges() + .unwrap_or_default() + .into_iter() + .collect(); + let merge_failures: HashMap = items + .iter() + .filter(|i| matches!(i.stage, Stage::Merge { .. })) + .filter_map(|i| { + let content = crate::db::read_content(&i.story_id.0)?; + let meta = crate::io::story_metadata::parse_front_matter(&content).ok()?; + let mf = meta.merge_failure?; + Some((i.story_id.0.clone(), mf)) + }) + .collect(); + + 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, + &running_merges, + &merge_failures, + )); + } + } + 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, + &running_merges, + &merge_failures, + )); + } + 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, + running_merges: &HashSet, + merge_failures: &HashMap, +) -> 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 = super::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 agent = active_map.get(story_id); + 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(", ")) + }; + + // Merge-stage items get a dedicated breakdown indicator instead of the + // generic traffic-light dot. + if matches!(item.stage, Stage::Merge { .. }) { + let in_det_merge = running_merges.contains(story_id); + let merge_failure = merge_failures.get(story_id); + if in_det_merge { + // A fresh deterministic merge is in progress — always prefer 🔄, + // even when a previous attempt recorded a merge_failure. + return format!( + " \u{1F504} {display}{cost_suffix}{dep_suffix} — deterministic-merge running\n" + ); + } else if agent.is_some() { + return format!( + " \u{1F916} {display}{cost_suffix}{dep_suffix} — mergemaster running\n" + ); + } else if let Some(mf) = merge_failure { + let snippet = first_non_empty_snippet(mf, 120); + return format!(" \u{26D4} {display}{cost_suffix}{dep_suffix} — {snippet}\n"); + } else { + return format!(" \u{23F3} {display}{cost_suffix}{dep_suffix}\n"); + } + } + + let blocked = item.stage.is_blocked(); + let throttled = agent.map(|a| a.throttled).unwrap_or(false); + let dot = super::traffic_light_dot(blocked, throttled, agent.is_some()); + 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") + } +} diff --git a/server/src/chat/commands/status/tests.rs b/server/src/chat/commands/status/tests.rs new file mode 100644 index 00000000..6dc672a7 --- /dev/null +++ b/server/src/chat/commands/status/tests.rs @@ -0,0 +1,667 @@ +//! Tests for the `status` command submodules. + +use super::*; +use crate::agents::{AgentPool, AgentStatus}; +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}" + ); +} + +// -- merge-stage breakdown indicators ------------------------------------ + +fn merge_stage() -> Stage { + use crate::pipeline_state::BranchName; + Stage::Merge { + feature_branch: BranchName("feature/test".to_string()), + commits_ahead: std::num::NonZeroU32::new(1).unwrap(), + } +} + +#[test] +fn merge_item_queued_shows_hourglass() { + use tempfile::TempDir; + let tmp = TempDir::new().unwrap(); + let items = vec![make_item( + "901_story_merge_q", + "Queued Story", + merge_stage(), + )]; + let agents = AgentPool::new_test(3000); + let output = build_status_from_items(tmp.path(), &agents, &items); + assert!( + output.contains("\u{23F3}"), // ⏳ + "queued merge item should show hourglass: {output}" + ); +} + +#[test] +fn merge_item_with_active_agent_shows_robot() { + use tempfile::TempDir; + let tmp = TempDir::new().unwrap(); + let items = vec![make_item( + "902_story_merge_mm", + "Mergemaster Story", + merge_stage(), + )]; + let agents = AgentPool::new_test(3000); + agents.inject_test_agent("902_story_merge_mm", "mergemaster", AgentStatus::Running); + let output = build_status_from_items(tmp.path(), &agents, &items); + assert!( + output.contains("\u{1F916}"), // 🤖 + "merge item with running agent should show robot: {output}" + ); + assert!( + output.contains("mergemaster running"), + "output should contain 'mergemaster running': {output}" + ); +} + +#[test] +fn merge_item_with_failure_shows_stop_sign_and_snippet() { + use tempfile::TempDir; + let tmp = TempDir::new().unwrap(); + crate::db::ensure_content_store(); + crate::db::write_item_with_content( + "903_story_merge_fail", + "4_merge", + "---\nname: Failed Story\nmerge_failure: \"conflicts in src/lib.rs\"\n---\n", + ); + let items = vec![make_item( + "903_story_merge_fail", + "Failed Story", + merge_stage(), + )]; + let agents = AgentPool::new_test(3000); + let output = build_status_from_items(tmp.path(), &agents, &items); + assert!( + output.contains("\u{26D4}"), // ⛔ + "merge item with failure should show stop sign: {output}" + ); + assert!( + output.contains("conflicts in src/lib.rs"), + "output should contain failure snippet: {output}" + ); +} + +#[test] +fn merge_item_failure_snippet_truncated_at_120_chars() { + use tempfile::TempDir; + let tmp = TempDir::new().unwrap(); + crate::db::ensure_content_store(); + let long_reason = "x".repeat(200); + let content = format!("---\nname: Long Fail\nmerge_failure: \"{long_reason}\"\n---\n"); + crate::db::write_item_with_content("904_story_long_fail", "4_merge", &content); + let items = vec![make_item("904_story_long_fail", "Long Fail", merge_stage())]; + let agents = AgentPool::new_test(3000); + let output = build_status_from_items(tmp.path(), &agents, &items); + assert!( + output.contains("…"), + "long failure should be truncated with ellipsis: {output}" + ); + // The snippet should not exceed 120 chars plus the ellipsis character. + let snippet_start = output.find("\u{26D4}").expect("stop sign must be present"); + let line = output[snippet_start..].lines().next().unwrap_or(""); + // Find the last " — " separator (before the snippet) and take what follows. + if let Some(sep_pos) = line.rfind(" \u{2014} ") { + let snippet = &line[sep_pos + 5..]; // " — " is 5 bytes (space + 3-byte em dash + space) + assert!( + snippet.chars().count() <= 122, // 120 chars + "…" (1 char) + possible trailing + "snippet should be at most ~121 chars: {snippet}" + ); + } +} + +#[test] +fn merge_item_failure_snippet_is_first_non_empty_line() { + use tempfile::TempDir; + let tmp = TempDir::new().unwrap(); + crate::db::ensure_content_store(); + // Multi-line failure reason — first non-empty line should be used. + let reason = "\nfirst line of error\nsecond line"; + let content = format!( + "---\nname: Multi Line\nmerge_failure: \"{}\" \n---\n", + reason.replace('\n', "\\n") + ); + crate::db::write_item_with_content("905_story_multiline", "4_merge", &content); + // Write with literal \n as the content (simulating stored text with newlines). + let content2 = + "---\nname: Multi Line\nmerge_failure: |\n \n first line of error\n second line\n---\n"; + crate::db::write_item_with_content("905_story_multiline", "4_merge", content2); + let items = vec![make_item( + "905_story_multiline", + "Multi Line", + merge_stage(), + )]; + let agents = AgentPool::new_test(3000); + let output = build_status_from_items(tmp.path(), &agents, &items); + assert!( + output.contains("first line of error"), + "snippet should use first non-empty line: {output}" + ); + assert!( + !output.contains("second line"), + "snippet should not include second line: {output}" + ); +} + +#[test] +fn merge_item_det_merge_running_preferred_over_failure() { + use tempfile::TempDir; + let tmp = TempDir::new().unwrap(); + crate::db::ensure_content_store(); + crate::db::write_item_with_content( + "906_story_det_over_fail", + "4_merge", + "---\nname: Det Over Fail\nmerge_failure: \"old failure\"\n---\n", + ); + // Record a running deterministic merge in the CRDT. + crate::crdt_state::write_merge_job("906_story_det_over_fail", "running", 0.0, None, None); + let items = vec![make_item( + "906_story_det_over_fail", + "Det Over Fail", + merge_stage(), + )]; + let agents = AgentPool::new_test(3000); + let output = build_status_from_items(tmp.path(), &agents, &items); + assert!( + output.contains("\u{1F504}"), // 🔄 + "deterministic merge running should be preferred over failure: {output}" + ); + assert!( + !output.contains("\u{26D4}"), // ⛔ should not appear + "stop sign should not appear when det merge is running: {output}" + ); +} + +// -- first_non_empty_snippet unit tests --------------------------------- + +#[test] +fn snippet_empty_text_returns_empty() { + assert_eq!(first_non_empty_snippet("", 120), ""); +} + +#[test] +fn snippet_short_text_returned_as_is() { + assert_eq!(first_non_empty_snippet("short error", 120), "short error"); +} + +#[test] +fn snippet_long_text_truncated_with_ellipsis() { + let text = "a".repeat(200); + let result = first_non_empty_snippet(&text, 120); + assert!(result.ends_with('…'), "should end with ellipsis: {result}"); + assert_eq!( + result.chars().count(), + 121, + "should be 120 + ellipsis: {result}" + ); +} + +#[test] +fn snippet_skips_leading_empty_lines() { + let text = "\n\n \nactual error here\nsecond line"; + let result = first_non_empty_snippet(text, 120); + assert_eq!(result, "actual error here"); +}