Files
storkit/server/src/matrix/commands/status.rs
dave f610ef6046 Restore codebase deleted by bad auto-commit e4227cf
Commit e4227cf (a story creation auto-commit) erroneously deleted 175
files from master's tree, likely due to a race condition between
concurrent git operations. This commit re-adds all files from the
working directory.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:07:07 +00:00

355 lines
12 KiB
Rust

//! 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> {
Some(build_pipeline_status(ctx.project_root, ctx.agents))
}
/// Format a short display label for a work item.
///
/// Extracts the leading numeric ID from the file stem (e.g. `"293"` from
/// `"293_story_register_all_bot_commands"`) and combines it with the human-
/// readable name from the front matter when available.
///
/// Examples:
/// - `("293_story_foo", Some("Register all bot commands"))` → `"293 — Register all bot commands"`
/// - `("293_story_foo", None)` → `"293"`
/// - `("no_number_here", None)` → `"no_number_here"`
pub(super) fn story_short_label(stem: &str, name: Option<&str>) -> String {
let number = stem
.split('_')
.next()
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
.unwrap_or(stem);
match name {
Some(n) => format!("{number}{n}"),
None => number.to_string(),
}
}
/// 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
}
/// 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();
if let Some(agent) = active_map.get(story_id) {
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!(
"{display}{cost_suffix}{} ({model_str})\n",
agent.agent_name
));
} else {
out.push_str(&format!("{display}{cost_suffix}\n"));
}
}
}
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"));
assert_eq!(label, "293 — 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");
}
#[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}"
);
}
// -- 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!(
output.contains("293 — Register all bot commands"),
"output must show number and title: {output}"
);
}
// -- 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!(
output.contains("293 — Register all bot commands — $0.29"),
"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!(
output.contains("293 — Register all bot commands — $0.29"),
"output must show aggregated cost: {output}"
);
}
}