2026-03-22 19:07:07 +00:00
|
|
|
//! Handler for the `status` command and pipeline status helpers.
|
|
|
|
|
|
|
|
|
|
use crate::agents::{AgentPool, AgentStatus};
|
|
|
|
|
use crate::config::ProjectConfig;
|
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
|
|
|
|
|
|
|
|
use super::CommandContext;
|
|
|
|
|
|
|
|
|
|
pub(super) fn handle_status(ctx: &CommandContext) -> Option<String> {
|
2026-03-22 22:37:56 +00:00
|
|
|
if ctx.args.trim().is_empty() {
|
|
|
|
|
Some(build_pipeline_status(ctx.project_root, ctx.agents))
|
|
|
|
|
} else {
|
2026-03-24 11:06:43 +00:00
|
|
|
super::triage::handle_triage(ctx)
|
2026-03-22 22:37:56 +00:00
|
|
|
}
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Format a short display label for a work item.
|
|
|
|
|
///
|
2026-03-23 18:40:15 +00:00
|
|
|
/// 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.
|
2026-03-22 19:07:07 +00:00
|
|
|
///
|
|
|
|
|
/// Examples:
|
2026-03-23 18:40:15 +00:00
|
|
|
/// - `("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]"`
|
2026-03-22 19:07:07 +00:00
|
|
|
/// - `("no_number_here", None)` → `"no_number_here"`
|
|
|
|
|
pub(super) fn story_short_label(stem: &str, name: Option<&str>) -> String {
|
2026-03-23 18:40:15 +00:00
|
|
|
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}]"),
|
2026-03-22 19:07:07 +00:00
|
|
|
None => number.to_string(),
|
2026-03-23 18:40:15 +00:00
|
|
|
};
|
|
|
|
|
match name {
|
|
|
|
|
Some(n) => format!("{prefix} — {n}"),
|
|
|
|
|
None => prefix,
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 09:21:03 +00:00
|
|
|
/// Read the `blocked` flag from a story file's YAML front matter.
|
|
|
|
|
///
|
|
|
|
|
/// Returns `true` when the story has `blocked: true` set (retry limit reached).
|
|
|
|
|
fn read_story_blocked(project_root: &std::path::Path, stage_dir: &str, stem: &str) -> bool {
|
|
|
|
|
let path = project_root
|
|
|
|
|
.join(".storkit")
|
|
|
|
|
.join("work")
|
|
|
|
|
.join(stage_dir)
|
|
|
|
|
.join(format!("{stem}.md"));
|
|
|
|
|
std::fs::read_to_string(path)
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|c| crate::io::story_metadata::parse_front_matter(&c).ok())
|
|
|
|
|
.and_then(|m| m.blocked)
|
|
|
|
|
.unwrap_or(false)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Choose the traffic-light dot for a work item.
|
|
|
|
|
///
|
|
|
|
|
/// Priority: blocked > throttled > running > idle.
|
|
|
|
|
/// Uses compact Unicode characters (not large emoji) so the output stays
|
|
|
|
|
/// readable in plain-text chat clients.
|
|
|
|
|
///
|
|
|
|
|
/// - `●` running normally (active agent, no throttle)
|
|
|
|
|
/// - `◑` throttled (rate-limit warning received)
|
|
|
|
|
/// - `✗` hard-blocked (retry limit exceeded)
|
|
|
|
|
/// - `○` idle / no active agent
|
|
|
|
|
pub(super) fn traffic_light_dot(blocked: bool, throttled: bool, has_agent: bool) -> &'static str {
|
|
|
|
|
if blocked {
|
|
|
|
|
"\u{2717} " // ✗ — hard blocked
|
|
|
|
|
} else if throttled {
|
|
|
|
|
"\u{25D1} " // ◑ — throttled
|
|
|
|
|
} else if has_agent {
|
|
|
|
|
"\u{25CF} " // ● — running normally
|
|
|
|
|
} else {
|
|
|
|
|
"\u{25CB} " // ○ — idle / no agent
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 19:07:07 +00:00
|
|
|
/// Read all story IDs and names from a pipeline stage directory.
|
|
|
|
|
fn read_stage_items(
|
|
|
|
|
project_root: &std::path::Path,
|
|
|
|
|
stage_dir: &str,
|
|
|
|
|
) -> Vec<(String, Option<String>)> {
|
|
|
|
|
let dir = project_root
|
|
|
|
|
.join(".storkit")
|
|
|
|
|
.join("work")
|
|
|
|
|
.join(stage_dir);
|
|
|
|
|
if !dir.exists() {
|
|
|
|
|
return Vec::new();
|
|
|
|
|
}
|
|
|
|
|
let mut items = Vec::new();
|
|
|
|
|
if let Ok(entries) = std::fs::read_dir(&dir) {
|
|
|
|
|
for entry in entries.flatten() {
|
|
|
|
|
let path = entry.path();
|
|
|
|
|
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
|
|
|
|
|
let name = std::fs::read_to_string(&path)
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|contents| {
|
|
|
|
|
crate::io::story_metadata::parse_front_matter(&contents)
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|m| m.name)
|
|
|
|
|
});
|
|
|
|
|
items.push((stem.to_string(), name));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
items.sort_by(|a, b| a.0.cmp(&b.0));
|
|
|
|
|
items
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 13:55:01 +00:00
|
|
|
/// Build the HTML `formatted_body` for the pipeline status with Matrix colour
|
|
|
|
|
/// tags on the traffic-light dots.
|
|
|
|
|
///
|
|
|
|
|
/// Converts the plain-text pipeline status (Markdown) to HTML via
|
|
|
|
|
/// pulldown-cmark and wraps each traffic-light character in a
|
|
|
|
|
/// `<font data-mx-color="#rrggbb">` tag so Matrix clients display them in
|
|
|
|
|
/// colour.
|
|
|
|
|
pub(super) fn build_pipeline_status_html(project_root: &std::path::Path, agents: &AgentPool) -> String {
|
|
|
|
|
use pulldown_cmark::{Options, Parser, html};
|
|
|
|
|
|
|
|
|
|
let plain = build_pipeline_status(project_root, agents);
|
|
|
|
|
let normalized = crate::chat::util::normalize_line_breaks(&plain);
|
|
|
|
|
let options = Options::ENABLE_TABLES
|
|
|
|
|
| Options::ENABLE_FOOTNOTES
|
|
|
|
|
| Options::ENABLE_STRIKETHROUGH
|
|
|
|
|
| Options::ENABLE_TASKLISTS;
|
|
|
|
|
let parser = Parser::new_ext(&normalized, options);
|
|
|
|
|
let mut html_out = String::new();
|
|
|
|
|
html::push_html(&mut html_out, parser);
|
|
|
|
|
|
|
|
|
|
// Wrap each traffic-light character with a Matrix colour tag.
|
|
|
|
|
html_out
|
|
|
|
|
.replace('\u{2717}', "<font data-mx-color=\"#cc0000\">\u{2717}</font>") // ✗ blocked
|
|
|
|
|
.replace('\u{25D1}', "<font data-mx-color=\"#ffaa00\">\u{25D1}</font>") // ◑ throttled
|
|
|
|
|
.replace('\u{25CF}', "<font data-mx-color=\"#00cc00\">\u{25CF}</font>") // ● running
|
|
|
|
|
.replace('\u{25CB}', "<font data-mx-color=\"#888888\">\u{25CB}</font>") // ○ idle
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 19:07:07 +00:00
|
|
|
/// Build the full pipeline status text formatted for Matrix (markdown).
|
|
|
|
|
pub(super) fn build_pipeline_status(project_root: &std::path::Path, agents: &AgentPool) -> 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<String, &crate::agents::AgentInfo> = 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<String, f64> =
|
|
|
|
|
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");
|
|
|
|
|
|
|
|
|
|
let stages = [
|
|
|
|
|
("1_backlog", "Backlog"),
|
|
|
|
|
("2_current", "In Progress"),
|
|
|
|
|
("3_qa", "QA"),
|
|
|
|
|
("4_merge", "Merge"),
|
|
|
|
|
("5_done", "Done"),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
for (dir, label) in &stages {
|
|
|
|
|
let items = read_stage_items(project_root, dir);
|
|
|
|
|
let count = items.len();
|
|
|
|
|
out.push_str(&format!("**{label}** ({count})\n"));
|
|
|
|
|
if items.is_empty() {
|
|
|
|
|
out.push_str(" *(none)*\n");
|
|
|
|
|
} else {
|
|
|
|
|
for (story_id, name) in &items {
|
|
|
|
|
let display = story_short_label(story_id, name.as_deref());
|
|
|
|
|
let cost_suffix = cost_by_story
|
|
|
|
|
.get(story_id)
|
|
|
|
|
.filter(|&&c| c > 0.0)
|
|
|
|
|
.map(|c| format!(" — ${c:.2}"))
|
|
|
|
|
.unwrap_or_default();
|
2026-03-28 09:21:03 +00:00
|
|
|
let blocked = read_story_blocked(project_root, dir, story_id);
|
|
|
|
|
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());
|
|
|
|
|
if let Some(agent) = agent {
|
2026-03-22 19:07:07 +00:00
|
|
|
let model_str = config
|
|
|
|
|
.as_ref()
|
|
|
|
|
.and_then(|cfg| cfg.find_agent(&agent.agent_name))
|
|
|
|
|
.and_then(|ac| ac.model.as_deref())
|
|
|
|
|
.unwrap_or("?");
|
|
|
|
|
out.push_str(&format!(
|
2026-03-28 09:21:03 +00:00
|
|
|
" {dot}{display}{cost_suffix} — {} ({model_str})\n",
|
2026-03-22 19:07:07 +00:00
|
|
|
agent.agent_name
|
|
|
|
|
));
|
|
|
|
|
} else {
|
2026-03-28 09:21:03 +00:00
|
|
|
out.push_str(&format!(" {dot}{display}{cost_suffix}\n"));
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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<String> = active_agents
|
|
|
|
|
.iter()
|
|
|
|
|
.filter(|a| matches!(a.status, AgentStatus::Running | AgentStatus::Pending))
|
|
|
|
|
.map(|a| a.agent_name.clone())
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
let free: Vec<String> = 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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use crate::agents::AgentPool;
|
|
|
|
|
|
|
|
|
|
#[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"));
|
2026-03-23 18:40:15 +00:00
|
|
|
assert_eq!(label, "293 [story] — Register all bot commands");
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn short_label_number_only_when_no_name() {
|
|
|
|
|
let label = story_short_label("297_story_improve_bot_status_command_formatting", None);
|
2026-03-23 18:40:15 +00:00
|
|
|
assert_eq!(label, "297 [story]");
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 18:40:15 +00:00
|
|
|
#[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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 19:07:07 +00:00
|
|
|
// -- build_pipeline_status formatting -----------------------------------
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn status_does_not_show_full_filename_stem() {
|
|
|
|
|
use std::io::Write;
|
|
|
|
|
use tempfile::TempDir;
|
|
|
|
|
|
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
|
|
|
let stage_dir = tmp.path().join(".storkit/work/2_current");
|
|
|
|
|
std::fs::create_dir_all(&stage_dir).unwrap();
|
|
|
|
|
|
|
|
|
|
// Write a story file with a front-matter name
|
|
|
|
|
let story_path = stage_dir.join("293_story_register_all_bot_commands.md");
|
|
|
|
|
let mut f = std::fs::File::create(&story_path).unwrap();
|
|
|
|
|
writeln!(f, "---\nname: Register all bot commands\n---\n").unwrap();
|
|
|
|
|
|
|
|
|
|
let agents = AgentPool::new_test(3000);
|
|
|
|
|
let output = build_pipeline_status(tmp.path(), &agents);
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
!output.contains("293_story_register_all_bot_commands"),
|
|
|
|
|
"output must not show full filename stem: {output}"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
2026-03-23 18:40:15 +00:00
|
|
|
output.contains("293 [story] — Register all bot commands"),
|
|
|
|
|
"output must show number, type, and title: {output}"
|
2026-03-22 19:07:07 +00:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -- token cost in status output ----------------------------------------
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn status_shows_cost_when_token_usage_exists() {
|
|
|
|
|
use std::io::Write;
|
|
|
|
|
use tempfile::TempDir;
|
|
|
|
|
|
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
|
|
|
let stage_dir = tmp.path().join(".storkit/work/2_current");
|
|
|
|
|
std::fs::create_dir_all(&stage_dir).unwrap();
|
|
|
|
|
|
|
|
|
|
let story_path = stage_dir.join("293_story_register_all_bot_commands.md");
|
|
|
|
|
let mut f = std::fs::File::create(&story_path).unwrap();
|
|
|
|
|
writeln!(f, "---\nname: Register all bot commands\n---\n").unwrap();
|
|
|
|
|
|
|
|
|
|
// 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_pipeline_status(tmp.path(), &agents);
|
|
|
|
|
|
|
|
|
|
assert!(
|
2026-03-23 18:40:15 +00:00
|
|
|
output.contains("293 [story] — Register all bot commands — $0.29"),
|
2026-03-22 19:07:07 +00:00
|
|
|
"output must show cost next to story: {output}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn status_no_cost_when_no_usage() {
|
|
|
|
|
use std::io::Write;
|
|
|
|
|
use tempfile::TempDir;
|
|
|
|
|
|
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
|
|
|
let stage_dir = tmp.path().join(".storkit/work/2_current");
|
|
|
|
|
std::fs::create_dir_all(&stage_dir).unwrap();
|
|
|
|
|
|
|
|
|
|
let story_path = stage_dir.join("293_story_register_all_bot_commands.md");
|
|
|
|
|
let mut f = std::fs::File::create(&story_path).unwrap();
|
|
|
|
|
writeln!(f, "---\nname: Register all bot commands\n---\n").unwrap();
|
|
|
|
|
|
|
|
|
|
// No token usage written.
|
|
|
|
|
let agents = AgentPool::new_test(3000);
|
|
|
|
|
let output = build_pipeline_status(tmp.path(), &agents);
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
!output.contains("$"),
|
|
|
|
|
"output must not show cost when no usage exists: {output}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn status_aggregates_multiple_records_per_story() {
|
|
|
|
|
use std::io::Write;
|
|
|
|
|
use tempfile::TempDir;
|
|
|
|
|
|
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
|
|
|
let stage_dir = tmp.path().join(".storkit/work/2_current");
|
|
|
|
|
std::fs::create_dir_all(&stage_dir).unwrap();
|
|
|
|
|
|
|
|
|
|
let story_path = stage_dir.join("293_story_register_all_bot_commands.md");
|
|
|
|
|
let mut f = std::fs::File::create(&story_path).unwrap();
|
|
|
|
|
writeln!(f, "---\nname: Register all bot commands\n---\n").unwrap();
|
|
|
|
|
|
|
|
|
|
// 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_pipeline_status(tmp.path(), &agents);
|
|
|
|
|
|
|
|
|
|
assert!(
|
2026-03-23 18:40:15 +00:00
|
|
|
output.contains("293 [story] — Register all bot commands — $0.29"),
|
2026-03-22 19:07:07 +00:00
|
|
|
"output must show aggregated cost: {output}"
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-03-28 09:21:03 +00:00
|
|
|
|
|
|
|
|
// -- traffic_light_dot --------------------------------------------------
|
|
|
|
|
|
2026-03-28 13:55:01 +00:00
|
|
|
// -- build_pipeline_status_html (colored dots) --------------------------
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn html_status_colors_idle_dot_grey() {
|
|
|
|
|
use std::io::Write;
|
|
|
|
|
use tempfile::TempDir;
|
|
|
|
|
|
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
|
|
|
let stage_dir = tmp.path().join(".storkit/work/2_current");
|
|
|
|
|
std::fs::create_dir_all(&stage_dir).unwrap();
|
|
|
|
|
|
|
|
|
|
let story_path = stage_dir.join("42_story_idle.md");
|
|
|
|
|
let mut f = std::fs::File::create(&story_path).unwrap();
|
|
|
|
|
writeln!(f, "---\nname: Idle Story\n---\n").unwrap();
|
|
|
|
|
|
|
|
|
|
let agents = AgentPool::new_test(3000);
|
|
|
|
|
let html = build_pipeline_status_html(tmp.path(), &agents);
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
html.contains("<font data-mx-color=\"#888888\">\u{25CB}</font>"),
|
|
|
|
|
"idle dot should be grey (#888888): {html}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn html_status_colors_blocked_dot_red() {
|
|
|
|
|
use std::io::Write;
|
|
|
|
|
use tempfile::TempDir;
|
|
|
|
|
|
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
|
|
|
let stage_dir = tmp.path().join(".storkit/work/2_current");
|
|
|
|
|
std::fs::create_dir_all(&stage_dir).unwrap();
|
|
|
|
|
|
|
|
|
|
let story_path = stage_dir.join("42_story_blocked.md");
|
|
|
|
|
let mut f = std::fs::File::create(&story_path).unwrap();
|
|
|
|
|
writeln!(f, "---\nname: Blocked Story\nblocked: true\n---\n").unwrap();
|
|
|
|
|
|
|
|
|
|
let agents = AgentPool::new_test(3000);
|
|
|
|
|
let html = build_pipeline_status_html(tmp.path(), &agents);
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
html.contains("<font data-mx-color=\"#cc0000\">\u{2717}</font>"),
|
|
|
|
|
"blocked dot should be red (#cc0000): {html}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn html_status_plain_text_body_unchanged() {
|
|
|
|
|
use std::io::Write;
|
|
|
|
|
use tempfile::TempDir;
|
|
|
|
|
|
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
|
|
|
let stage_dir = tmp.path().join(".storkit/work/2_current");
|
|
|
|
|
std::fs::create_dir_all(&stage_dir).unwrap();
|
|
|
|
|
|
|
|
|
|
let story_path = stage_dir.join("42_story_idle.md");
|
|
|
|
|
let mut f = std::fs::File::create(&story_path).unwrap();
|
|
|
|
|
writeln!(f, "---\nname: Idle Story\n---\n").unwrap();
|
|
|
|
|
|
|
|
|
|
let agents = AgentPool::new_test(3000);
|
|
|
|
|
let plain = build_pipeline_status(tmp.path(), &agents);
|
|
|
|
|
|
|
|
|
|
// Plain text must still use bare Unicode dots (no HTML tags).
|
|
|
|
|
assert!(
|
|
|
|
|
plain.contains('\u{25CB}'),
|
|
|
|
|
"plain text should have bare Unicode idle dot: {plain}"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
!plain.contains("data-mx-color"),
|
|
|
|
|
"plain text must not contain HTML colour attributes: {plain}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -- traffic_light_dot --------------------------------------------------
|
|
|
|
|
|
2026-03-28 09:21:03 +00:00
|
|
|
#[test]
|
|
|
|
|
fn dot_idle_when_no_agent() {
|
|
|
|
|
assert_eq!(traffic_light_dot(false, false, false), "\u{25CB} "); // ○
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn dot_running_when_agent_not_throttled() {
|
|
|
|
|
assert_eq!(traffic_light_dot(false, false, true), "\u{25CF} "); // ●
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn dot_throttled_when_agent_throttled() {
|
|
|
|
|
assert_eq!(traffic_light_dot(false, true, true), "\u{25D1} "); // ◑
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn dot_blocked_takes_priority_over_throttled() {
|
|
|
|
|
assert_eq!(traffic_light_dot(true, true, true), "\u{2717} "); // ✗
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn dot_blocked_when_no_agent_but_blocked_flag() {
|
|
|
|
|
assert_eq!(traffic_light_dot(true, false, false), "\u{2717} "); // ✗
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -- read_story_blocked --------------------------------------------------
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn read_story_blocked_returns_true_when_blocked() {
|
|
|
|
|
use tempfile::TempDir;
|
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
|
|
|
let stage_dir = tmp.path().join(".storkit/work/2_current");
|
|
|
|
|
std::fs::create_dir_all(&stage_dir).unwrap();
|
|
|
|
|
std::fs::write(
|
|
|
|
|
stage_dir.join("42_story_foo.md"),
|
|
|
|
|
"---\nname: Foo\nblocked: true\n---\n",
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert!(read_story_blocked(tmp.path(), "2_current", "42_story_foo"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn read_story_blocked_returns_false_when_not_blocked() {
|
|
|
|
|
use tempfile::TempDir;
|
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
|
|
|
let stage_dir = tmp.path().join(".storkit/work/2_current");
|
|
|
|
|
std::fs::create_dir_all(&stage_dir).unwrap();
|
|
|
|
|
std::fs::write(
|
|
|
|
|
stage_dir.join("42_story_foo.md"),
|
|
|
|
|
"---\nname: Foo\n---\n",
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert!(!read_story_blocked(tmp.path(), "2_current", "42_story_foo"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -- status output shows idle dot for items with no active agent --------
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn status_shows_idle_dot_for_unassigned_story() {
|
|
|
|
|
use std::io::Write;
|
|
|
|
|
use tempfile::TempDir;
|
|
|
|
|
|
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
|
|
|
let stage_dir = tmp.path().join(".storkit/work/2_current");
|
|
|
|
|
std::fs::create_dir_all(&stage_dir).unwrap();
|
|
|
|
|
|
|
|
|
|
let story_path = stage_dir.join("42_story_idle.md");
|
|
|
|
|
let mut f = std::fs::File::create(&story_path).unwrap();
|
|
|
|
|
writeln!(f, "---\nname: Idle Story\n---\n").unwrap();
|
|
|
|
|
|
|
|
|
|
let agents = AgentPool::new_test(3000);
|
|
|
|
|
let output = build_pipeline_status(tmp.path(), &agents);
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
output.contains("\u{25CB} "), // ○
|
|
|
|
|
"idle story should show empty-circle dot: {output}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn status_shows_blocked_dot_for_blocked_story() {
|
|
|
|
|
use std::io::Write;
|
|
|
|
|
use tempfile::TempDir;
|
|
|
|
|
|
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
|
|
|
let stage_dir = tmp.path().join(".storkit/work/2_current");
|
|
|
|
|
std::fs::create_dir_all(&stage_dir).unwrap();
|
|
|
|
|
|
|
|
|
|
let story_path = stage_dir.join("42_story_blocked.md");
|
|
|
|
|
let mut f = std::fs::File::create(&story_path).unwrap();
|
|
|
|
|
writeln!(f, "---\nname: Blocked Story\nblocked: true\n---\n").unwrap();
|
|
|
|
|
|
|
|
|
|
let agents = AgentPool::new_test(3000);
|
|
|
|
|
let output = build_pipeline_status(tmp.path(), &agents);
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
output.contains("\u{2717} "), // ✗
|
|
|
|
|
"blocked story should show X dot: {output}"
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|