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>
This commit is contained in:
dave
2026-03-22 19:07:07 +00:00
parent 89f776b978
commit f610ef6046
174 changed files with 84280 additions and 0 deletions

1989
server/src/matrix/bot.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,171 @@
//! Handler for the `ambient` command.
use super::CommandContext;
use crate::matrix::config::save_ambient_rooms;
/// Toggle ambient mode for this room.
///
/// Works whether or not the message directly addressed the bot — the user can
/// say "timmy ambient on", "@timmy ambient on", or just "ambient on" in an
/// ambient-mode room. The command is specific enough (must be the first word
/// after any bot-mention prefix) that accidental triggering is very unlikely.
pub(super) fn handle_ambient(ctx: &CommandContext) -> Option<String> {
let enable = match ctx.args {
"on" => true,
"off" => false,
_ => return Some("Usage: `ambient on` or `ambient off`".to_string()),
};
let room_ids: Vec<String> = {
let mut ambient = ctx.ambient_rooms.lock().unwrap();
if enable {
ambient.insert(ctx.room_id.to_string());
} else {
ambient.remove(ctx.room_id);
}
ambient.iter().cloned().collect()
};
save_ambient_rooms(ctx.project_root, &room_ids);
let msg = if enable {
"Ambient mode on. I'll respond to all messages in this room."
} else {
"Ambient mode off. I'll only respond when mentioned."
};
Some(msg.to_string())
}
#[cfg(test)]
mod tests {
use crate::agents::AgentPool;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use super::super::{CommandDispatch, try_handle_command};
fn test_ambient_rooms() -> Arc<Mutex<HashSet<String>>> {
Arc::new(Mutex::new(HashSet::new()))
}
fn test_agents() -> Arc<AgentPool> {
Arc::new(AgentPool::new_test(3000))
}
// Bug 352: ambient commands were being forwarded to LLM after refactors
// 328/330 because handle_ambient required is_addressed=true, but
// mentions_bot() only matches @-prefixed mentions, not bare bot names.
// "timmy ambient off" sets is_addressed=false even though it names the bot.
#[test]
fn ambient_on_works_when_unaddressed() {
let ambient_rooms = test_ambient_rooms();
let room_id = "!myroom:example.com".to_string();
let agents = test_agents();
let dispatch = CommandDispatch {
bot_name: "Timmy",
bot_user_id: "@timmy:homeserver.local",
project_root: std::path::Path::new("/tmp"),
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
// "timmy ambient on" — bot name mentioned but not @-prefixed, so
// is_addressed is false; strip_bot_mention still strips "timmy ".
let result = try_handle_command(&dispatch, "timmy ambient on");
assert!(result.is_some(), "ambient on should fire even when is_addressed=false");
assert!(
ambient_rooms.lock().unwrap().contains(&room_id),
"room should be in ambient_rooms after ambient on"
);
}
#[test]
fn ambient_off_works_bare_in_ambient_room() {
let ambient_rooms = test_ambient_rooms();
let room_id = "!myroom:example.com".to_string();
ambient_rooms.lock().unwrap().insert(room_id.clone());
let agents = test_agents();
let dispatch = CommandDispatch {
bot_name: "Timmy",
bot_user_id: "@timmy:homeserver.local",
project_root: std::path::Path::new("/tmp"),
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
// Bare "ambient off" in an ambient room (is_addressed=false).
let result = try_handle_command(&dispatch, "ambient off");
assert!(result.is_some(), "bare ambient off should be handled without LLM");
let output = result.unwrap();
assert!(
output.contains("Ambient mode off"),
"response should confirm ambient off: {output}"
);
assert!(
!ambient_rooms.lock().unwrap().contains(&room_id),
"room should be removed from ambient_rooms after ambient off"
);
}
#[test]
fn ambient_on_enables_ambient_mode() {
let ambient_rooms = test_ambient_rooms();
let agents = test_agents();
let room_id = "!myroom:example.com".to_string();
let dispatch = CommandDispatch {
bot_name: "Timmy",
bot_user_id: "@timmy:homeserver.local",
project_root: std::path::Path::new("/tmp"),
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
let result = try_handle_command(&dispatch, "@timmy ambient on");
assert!(result.is_some(), "ambient on should produce a response");
let output = result.unwrap();
assert!(
output.contains("Ambient mode on"),
"response should confirm ambient on: {output}"
);
assert!(
ambient_rooms.lock().unwrap().contains(&room_id),
"room should be in ambient_rooms after ambient on"
);
}
#[test]
fn ambient_off_disables_ambient_mode() {
let ambient_rooms = test_ambient_rooms();
let agents = test_agents();
let room_id = "!myroom:example.com".to_string();
// Pre-insert the room
ambient_rooms.lock().unwrap().insert(room_id.clone());
let dispatch = CommandDispatch {
bot_name: "Timmy",
bot_user_id: "@timmy:homeserver.local",
project_root: std::path::Path::new("/tmp"),
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
let result = try_handle_command(&dispatch, "@timmy ambient off");
assert!(result.is_some(), "ambient off should produce a response");
let output = result.unwrap();
assert!(
output.contains("Ambient mode off"),
"response should confirm ambient off: {output}"
);
assert!(
!ambient_rooms.lock().unwrap().contains(&room_id),
"room should be removed from ambient_rooms after ambient off"
);
}
#[test]
fn ambient_invalid_args_returns_usage() {
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy ambient");
let output = result.unwrap();
assert!(
output.contains("Usage"),
"invalid ambient args should show usage: {output}"
);
}
}

View File

@@ -0,0 +1,385 @@
//! Handler for the `assign` command.
//!
//! `assign <number> <model>` pre-assigns a coder model (e.g. `opus`, `sonnet`)
//! to a story before it starts. The assignment persists in the story file's
//! front matter as `agent: coder-<model>` so that when the pipeline picks up
//! the story — either via auto-assign or the `start` command — it uses the
//! assigned model instead of the default.
use super::CommandContext;
use crate::io::story_metadata::{parse_front_matter, set_front_matter_field};
/// All pipeline stage directories to search when finding a work item by number.
const STAGES: &[&str] = &[
"1_backlog",
"2_current",
"3_qa",
"4_merge",
"5_done",
"6_archived",
];
/// Resolve a model name hint (e.g. `"opus"`) to a full agent name
/// (e.g. `"coder-opus"`). If the hint already starts with `"coder-"`,
/// it is returned unchanged to prevent double-prefixing.
fn resolve_agent_name(model: &str) -> String {
if model.starts_with("coder-") {
model.to_string()
} else {
format!("coder-{model}")
}
}
pub(super) fn handle_assign(ctx: &CommandContext) -> Option<String> {
let args = ctx.args.trim();
// Parse `<number> <model>` from args.
let (number_str, model_str) = match args.split_once(char::is_whitespace) {
Some((n, m)) => (n.trim(), m.trim()),
None => {
return Some(format!(
"Usage: `{} assign <number> <model>` (e.g. `assign 42 opus`)",
ctx.bot_name
));
}
};
if number_str.is_empty() || !number_str.chars().all(|c| c.is_ascii_digit()) {
return Some(format!(
"Invalid story number `{number_str}`. Usage: `{} assign <number> <model>`",
ctx.bot_name
));
}
if model_str.is_empty() {
return Some(format!(
"Usage: `{} assign <number> <model>` (e.g. `assign 42 opus`)",
ctx.bot_name
));
}
// Find the story file across all pipeline stages.
let mut found: Option<(std::path::PathBuf, String)> = None;
'outer: for stage in STAGES {
let dir = ctx.project_root.join(".storkit").join("work").join(stage);
if !dir.exists() {
continue;
}
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())
.map(|s| s.to_string())
{
let file_num = stem
.split('_')
.next()
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
.unwrap_or("")
.to_string();
if file_num == number_str {
found = Some((path, stem));
break 'outer;
}
}
}
}
}
let (path, story_id) = match found {
Some(f) => f,
None => {
return Some(format!(
"No story, bug, or spike with number **{number_str}** found."
));
}
};
// Read the human-readable name from front matter for the response.
let story_name = std::fs::read_to_string(&path)
.ok()
.and_then(|contents| {
parse_front_matter(&contents)
.ok()
.and_then(|m| m.name)
})
.unwrap_or_else(|| story_id.clone());
let agent_name = resolve_agent_name(model_str);
// Write `agent: <agent_name>` into the story's front matter.
let result = std::fs::read_to_string(&path)
.map_err(|e| format!("Failed to read story file: {e}"))
.and_then(|contents| {
let updated = set_front_matter_field(&contents, "agent", &agent_name);
std::fs::write(&path, &updated)
.map_err(|e| format!("Failed to write story file: {e}"))
});
match result {
Ok(()) => Some(format!(
"Assigned **{agent_name}** to **{story_name}** (story {number_str}). \
The model will be used when the story starts."
)),
Err(e) => Some(format!(
"Failed to assign model to **{story_name}**: {e}"
)),
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use crate::agents::AgentPool;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use super::super::{CommandDispatch, try_handle_command};
fn assign_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> {
let agents = Arc::new(AgentPool::new_test(3000));
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
bot_name: "Timmy",
bot_user_id: "@timmy:homeserver.local",
project_root: root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
try_handle_command(&dispatch, &format!("@timmy assign {args}"))
}
fn write_story_file(root: &std::path::Path, stage: &str, filename: &str, content: &str) {
let dir = root.join(".storkit/work").join(stage);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(filename), content).unwrap();
}
// -- registration / help ------------------------------------------------
#[test]
fn assign_command_is_registered() {
use super::super::commands;
let found = commands().iter().any(|c| c.name == "assign");
assert!(found, "assign command must be in the registry");
}
#[test]
fn assign_command_appears_in_help() {
let result = super::super::tests::try_cmd_addressed(
"Timmy",
"@timmy:homeserver.local",
"@timmy help",
);
let output = result.unwrap();
assert!(
output.contains("assign"),
"help should list assign command: {output}"
);
}
// -- argument validation ------------------------------------------------
#[test]
fn assign_no_args_returns_usage() {
let tmp = tempfile::TempDir::new().unwrap();
let output = assign_cmd_with_root(tmp.path(), "").unwrap();
assert!(
output.contains("Usage"),
"no args should show usage: {output}"
);
}
#[test]
fn assign_missing_model_returns_usage() {
let tmp = tempfile::TempDir::new().unwrap();
let output = assign_cmd_with_root(tmp.path(), "42").unwrap();
assert!(
output.contains("Usage"),
"missing model should show usage: {output}"
);
}
#[test]
fn assign_non_numeric_number_returns_error() {
let tmp = tempfile::TempDir::new().unwrap();
let output = assign_cmd_with_root(tmp.path(), "abc opus").unwrap();
assert!(
output.contains("Invalid story number"),
"non-numeric number should return error: {output}"
);
}
// -- story not found ----------------------------------------------------
#[test]
fn assign_unknown_story_returns_friendly_message() {
let tmp = tempfile::TempDir::new().unwrap();
// Create stage dirs but no matching story.
for stage in &["1_backlog", "2_current"] {
std::fs::create_dir_all(tmp.path().join(".storkit/work").join(stage)).unwrap();
}
let output = assign_cmd_with_root(tmp.path(), "999 opus").unwrap();
assert!(
output.contains("999") && output.contains("found"),
"not-found message should include number and 'found': {output}"
);
}
// -- successful assignment ----------------------------------------------
#[test]
fn assign_writes_agent_field_to_front_matter() {
let tmp = tempfile::TempDir::new().unwrap();
write_story_file(
tmp.path(),
"1_backlog",
"42_story_test_feature.md",
"---\nname: Test Feature\n---\n\n# Story 42\n",
);
let output = assign_cmd_with_root(tmp.path(), "42 opus").unwrap();
assert!(
output.contains("coder-opus"),
"confirmation should include resolved agent name: {output}"
);
assert!(
output.contains("Test Feature"),
"confirmation should include story name: {output}"
);
// Verify the file was updated.
let contents = std::fs::read_to_string(
tmp.path()
.join(".storkit/work/1_backlog/42_story_test_feature.md"),
)
.unwrap();
assert!(
contents.contains("agent: coder-opus"),
"front matter should contain agent field: {contents}"
);
}
#[test]
fn assign_with_sonnet_writes_coder_sonnet() {
let tmp = tempfile::TempDir::new().unwrap();
write_story_file(
tmp.path(),
"2_current",
"10_story_current.md",
"---\nname: Current Story\n---\n",
);
assign_cmd_with_root(tmp.path(), "10 sonnet").unwrap();
let contents = std::fs::read_to_string(
tmp.path()
.join(".storkit/work/2_current/10_story_current.md"),
)
.unwrap();
assert!(
contents.contains("agent: coder-sonnet"),
"front matter should contain agent: coder-sonnet: {contents}"
);
}
#[test]
fn assign_with_already_prefixed_name_does_not_double_prefix() {
let tmp = tempfile::TempDir::new().unwrap();
write_story_file(
tmp.path(),
"1_backlog",
"7_story_small.md",
"---\nname: Small Story\n---\n",
);
let output = assign_cmd_with_root(tmp.path(), "7 coder-opus").unwrap();
assert!(
output.contains("coder-opus"),
"should not double-prefix: {output}"
);
assert!(
!output.contains("coder-coder-opus"),
"must not double-prefix: {output}"
);
let contents = std::fs::read_to_string(
tmp.path().join(".storkit/work/1_backlog/7_story_small.md"),
)
.unwrap();
assert!(
contents.contains("agent: coder-opus"),
"must write coder-opus, not coder-coder-opus: {contents}"
);
}
#[test]
fn assign_overwrites_existing_agent_field() {
let tmp = tempfile::TempDir::new().unwrap();
write_story_file(
tmp.path(),
"1_backlog",
"5_story_existing.md",
"---\nname: Existing\nagent: coder-sonnet\n---\n",
);
assign_cmd_with_root(tmp.path(), "5 opus").unwrap();
let contents = std::fs::read_to_string(
tmp.path()
.join(".storkit/work/1_backlog/5_story_existing.md"),
)
.unwrap();
assert!(
contents.contains("agent: coder-opus"),
"should overwrite old agent with new: {contents}"
);
assert!(
!contents.contains("coder-sonnet"),
"old agent should no longer appear: {contents}"
);
}
#[test]
fn assign_finds_story_in_any_stage() {
let tmp = tempfile::TempDir::new().unwrap();
// Story is in 3_qa/, not backlog.
write_story_file(
tmp.path(),
"3_qa",
"99_story_in_qa.md",
"---\nname: In QA\n---\n",
);
let output = assign_cmd_with_root(tmp.path(), "99 opus").unwrap();
assert!(
output.contains("coder-opus"),
"should find story in qa stage: {output}"
);
}
// -- resolve_agent_name unit tests --------------------------------------
#[test]
fn resolve_agent_name_prefixes_bare_model() {
assert_eq!(super::resolve_agent_name("opus"), "coder-opus");
assert_eq!(super::resolve_agent_name("sonnet"), "coder-sonnet");
assert_eq!(super::resolve_agent_name("haiku"), "coder-haiku");
}
#[test]
fn resolve_agent_name_does_not_double_prefix() {
assert_eq!(super::resolve_agent_name("coder-opus"), "coder-opus");
assert_eq!(super::resolve_agent_name("coder-sonnet"), "coder-sonnet");
}
}

View File

@@ -0,0 +1,271 @@
//! Handler for the `cost` command.
use std::collections::HashMap;
use super::status::story_short_label;
use super::CommandContext;
/// Show token spend: 24h total, top 5 stories, agent-type breakdown, and
/// all-time total.
pub(super) fn handle_cost(ctx: &CommandContext) -> Option<String> {
let records = match crate::agents::token_usage::read_all(ctx.project_root) {
Ok(r) => r,
Err(e) => return Some(format!("Failed to read token usage: {e}")),
};
if records.is_empty() {
return Some("**Token Spend**\n\nNo usage records found.".to_string());
}
let now = chrono::Utc::now();
let cutoff = now - chrono::Duration::hours(24);
// Partition into 24h window and all-time
let mut recent = Vec::new();
let mut all_time_cost = 0.0;
for r in &records {
all_time_cost += r.usage.total_cost_usd;
if let Ok(ts) = chrono::DateTime::parse_from_rfc3339(&r.timestamp)
&& ts >= cutoff
{
recent.push(r);
}
}
// 24h total
let recent_cost: f64 = recent.iter().map(|r| r.usage.total_cost_usd).sum();
let mut out = String::from("**Token Spend**\n\n");
out.push_str(&format!("**Last 24h:** ${:.2}\n", recent_cost));
out.push_str(&format!("**All-time:** ${:.2}\n\n", all_time_cost));
// Top 5 most expensive stories (last 24h)
let mut story_costs: HashMap<&str, f64> = HashMap::new();
for r in &recent {
*story_costs.entry(r.story_id.as_str()).or_default() += r.usage.total_cost_usd;
}
let mut story_list: Vec<(&str, f64)> = story_costs.into_iter().collect();
story_list.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
story_list.truncate(5);
out.push_str("**Top Stories (24h)**\n");
if story_list.is_empty() {
out.push_str(" *(none)*\n");
} else {
for (story_id, cost) in &story_list {
let label = story_short_label(story_id, None);
out.push_str(&format!("{label} — ${cost:.2}\n"));
}
}
out.push('\n');
// Breakdown by agent type (last 24h)
// Agent names follow pattern "coder-1", "qa-1", "mergemaster" — extract
// the type as everything before the last '-' digit, or the full name.
let mut type_costs: HashMap<String, f64> = HashMap::new();
for r in &recent {
let agent_type = extract_agent_type(&r.agent_name);
*type_costs.entry(agent_type).or_default() += r.usage.total_cost_usd;
}
let mut type_list: Vec<(String, f64)> = type_costs.into_iter().collect();
type_list.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
out.push_str("**By Agent Type (24h)**\n");
if type_list.is_empty() {
out.push_str(" *(none)*\n");
} else {
for (agent_type, cost) in &type_list {
out.push_str(&format!("{agent_type} — ${cost:.2}\n"));
}
}
Some(out)
}
/// Extract the agent type from an agent name.
///
/// Agent names like "coder-1", "qa-2", "mergemaster" map to types "coder",
/// "qa", "mergemaster". If the name ends with `-<digits>`, strip the suffix.
pub(super) fn extract_agent_type(agent_name: &str) -> String {
if let Some(pos) = agent_name.rfind('-') {
let suffix = &agent_name[pos + 1..];
if !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit()) {
return agent_name[..pos].to_string();
}
}
agent_name.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agents::AgentPool;
use std::sync::Arc;
fn write_token_records(root: &std::path::Path, records: &[crate::agents::token_usage::TokenUsageRecord]) {
for r in records {
crate::agents::token_usage::append_record(root, r).unwrap();
}
}
fn make_usage(cost: f64) -> crate::agents::TokenUsage {
crate::agents::TokenUsage {
input_tokens: 100,
output_tokens: 200,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
total_cost_usd: cost,
}
}
fn make_record(story_id: &str, agent_name: &str, cost: f64, hours_ago: i64) -> crate::agents::token_usage::TokenUsageRecord {
let ts = (chrono::Utc::now() - chrono::Duration::hours(hours_ago)).to_rfc3339();
crate::agents::token_usage::TokenUsageRecord {
story_id: story_id.to_string(),
agent_name: agent_name.to_string(),
timestamp: ts,
model: None,
usage: make_usage(cost),
}
}
fn cost_cmd_with_root(root: &std::path::Path) -> Option<String> {
use super::super::{CommandDispatch, try_handle_command};
use std::collections::HashSet;
use std::sync::Mutex;
let agents = Arc::new(AgentPool::new_test(3000));
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
bot_name: "Timmy",
bot_user_id: "@timmy:homeserver.local",
project_root: root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
try_handle_command(&dispatch, "@timmy cost")
}
#[test]
fn cost_command_is_registered() {
use super::super::commands;
let found = commands().iter().any(|c| c.name == "cost");
assert!(found, "cost command must be in the registry");
}
#[test]
fn cost_command_appears_in_help() {
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
let output = result.unwrap();
assert!(output.contains("cost"), "help should list cost command: {output}");
}
#[test]
fn cost_command_no_records() {
let tmp = tempfile::TempDir::new().unwrap();
let output = cost_cmd_with_root(tmp.path()).unwrap();
assert!(output.contains("No usage records found"), "should show empty message: {output}");
}
#[test]
fn cost_command_shows_24h_total() {
let tmp = tempfile::TempDir::new().unwrap();
write_token_records(tmp.path(), &[
make_record("42_story_foo", "coder-1", 1.50, 2),
make_record("42_story_foo", "coder-1", 0.50, 5),
]);
let output = cost_cmd_with_root(tmp.path()).unwrap();
assert!(output.contains("**Last 24h:** $2.00"), "should show 24h total: {output}");
}
#[test]
fn cost_command_excludes_old_from_24h() {
let tmp = tempfile::TempDir::new().unwrap();
write_token_records(tmp.path(), &[
make_record("42_story_foo", "coder-1", 1.00, 2), // within 24h
make_record("43_story_bar", "coder-1", 5.00, 48), // older
]);
let output = cost_cmd_with_root(tmp.path()).unwrap();
assert!(output.contains("**Last 24h:** $1.00"), "should only count recent: {output}");
assert!(output.contains("**All-time:** $6.00"), "all-time should include everything: {output}");
}
#[test]
fn cost_command_shows_top_stories() {
let tmp = tempfile::TempDir::new().unwrap();
write_token_records(tmp.path(), &[
make_record("42_story_foo", "coder-1", 3.00, 1),
make_record("43_story_bar", "coder-1", 1.00, 1),
make_record("42_story_foo", "qa-1", 2.00, 1),
]);
let output = cost_cmd_with_root(tmp.path()).unwrap();
assert!(output.contains("Top Stories"), "should have top stories section: {output}");
// Story 42 ($5.00) should appear before story 43 ($1.00)
let pos_42 = output.find("42").unwrap();
let pos_43 = output.find("43").unwrap();
assert!(pos_42 < pos_43, "story 42 should appear before 43 (sorted by cost): {output}");
}
#[test]
fn cost_command_limits_to_5_stories() {
let tmp = tempfile::TempDir::new().unwrap();
let mut records = Vec::new();
for i in 1..=7 {
records.push(make_record(&format!("{i}_story_s{i}"), "coder-1", i as f64, 1));
}
write_token_records(tmp.path(), &records);
let output = cost_cmd_with_root(tmp.path()).unwrap();
// The top 5 most expensive are stories 7,6,5,4,3. Stories 1 and 2 should be excluded.
let top_section = output.split("**By Agent Type").next().unwrap();
assert!(!top_section.contains("• 1 —"), "story 1 should not be in top 5: {output}");
assert!(!top_section.contains("• 2 —"), "story 2 should not be in top 5: {output}");
}
#[test]
fn cost_command_shows_agent_type_breakdown() {
let tmp = tempfile::TempDir::new().unwrap();
write_token_records(tmp.path(), &[
make_record("42_story_foo", "coder-1", 2.00, 1),
make_record("42_story_foo", "qa-1", 1.50, 1),
make_record("42_story_foo", "mergemaster", 0.50, 1),
]);
let output = cost_cmd_with_root(tmp.path()).unwrap();
assert!(output.contains("By Agent Type"), "should have agent type section: {output}");
assert!(output.contains("coder"), "should show coder type: {output}");
assert!(output.contains("qa"), "should show qa type: {output}");
assert!(output.contains("mergemaster"), "should show mergemaster type: {output}");
}
#[test]
fn cost_command_shows_all_time_total() {
let tmp = tempfile::TempDir::new().unwrap();
write_token_records(tmp.path(), &[
make_record("42_story_foo", "coder-1", 1.00, 2),
make_record("43_story_bar", "coder-1", 9.00, 100),
]);
let output = cost_cmd_with_root(tmp.path()).unwrap();
assert!(output.contains("**All-time:** $10.00"), "should show all-time total: {output}");
}
#[test]
fn cost_command_case_insensitive() {
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy COST");
assert!(result.is_some(), "COST should match case-insensitively");
}
// -- extract_agent_type -------------------------------------------------
#[test]
fn extract_agent_type_strips_numeric_suffix() {
assert_eq!(extract_agent_type("coder-1"), "coder");
assert_eq!(extract_agent_type("qa-2"), "qa");
}
#[test]
fn extract_agent_type_keeps_non_numeric_suffix() {
assert_eq!(extract_agent_type("mergemaster"), "mergemaster");
assert_eq!(extract_agent_type("coder-alpha"), "coder-alpha");
}
}

View File

@@ -0,0 +1,203 @@
//! Handler for the `git` command.
use super::CommandContext;
/// Show compact git status: branch, uncommitted files, ahead/behind remote.
pub(super) fn handle_git(ctx: &CommandContext) -> Option<String> {
use std::process::Command;
// Current branch
let branch = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(ctx.project_root)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_else(|| "unknown".to_string());
// Porcelain status for staged + unstaged changes
let status_output = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(ctx.project_root)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
.unwrap_or_default();
let changed_files: Vec<&str> = status_output.lines().filter(|l| !l.is_empty()).collect();
let change_count = changed_files.len();
// Ahead/behind: --left-right gives "N\tM" (ahead\tbehind)
let ahead_behind = Command::new("git")
.args(["rev-list", "--count", "--left-right", "HEAD...@{u}"])
.current_dir(ctx.project_root)
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| {
let s = String::from_utf8_lossy(&o.stdout).trim().to_string();
let mut parts = s.split_whitespace();
let ahead: u32 = parts.next()?.parse().ok()?;
let behind: u32 = parts.next()?.parse().ok()?;
Some((ahead, behind))
});
let mut out = format!("**Branch:** `{branch}`\n");
if change_count == 0 {
out.push_str("**Changes:** clean\n");
} else {
out.push_str(&format!("**Changes:** {change_count} file(s)\n"));
for line in &changed_files {
// Porcelain format: "XY filename" (2-char status + space + path)
if line.len() > 3 {
let codes = &line[..2];
let name = line[3..].trim();
out.push_str(&format!(" • `{codes}` {name}\n"));
} else {
out.push_str(&format!("{line}\n"));
}
}
}
match ahead_behind {
Some((0, 0)) => out.push_str("**Remote:** up to date\n"),
Some((ahead, 0)) => out.push_str(&format!("**Remote:** ↑{ahead} ahead\n")),
Some((0, behind)) => out.push_str(&format!("**Remote:** ↓{behind} behind\n")),
Some((ahead, behind)) => {
out.push_str(&format!("**Remote:** ↑{ahead} ahead, ↓{behind} behind\n"));
}
None => out.push_str("**Remote:** no tracking branch\n"),
}
Some(out)
}
#[cfg(test)]
mod tests {
use crate::agents::AgentPool;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use super::super::{CommandDispatch, try_handle_command};
fn test_ambient_rooms() -> Arc<Mutex<HashSet<String>>> {
Arc::new(Mutex::new(HashSet::new()))
}
fn test_agents() -> Arc<AgentPool> {
Arc::new(AgentPool::new_test(3000))
}
#[test]
fn git_command_is_registered() {
use super::super::commands;
let found = commands().iter().any(|c| c.name == "git");
assert!(found, "git command must be in the registry");
}
#[test]
fn git_command_appears_in_help() {
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
let output = result.unwrap();
assert!(output.contains("git"), "help should list git command: {output}");
}
#[test]
fn git_command_returns_some() {
// Run from the actual repo root so git commands have a real repo to query.
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap_or(std::path::Path::new("."));
let agents = test_agents();
let ambient_rooms = test_ambient_rooms();
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
bot_name: "Timmy",
bot_user_id: "@timmy:homeserver.local",
project_root: repo_root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
let result = try_handle_command(&dispatch, "@timmy git");
assert!(result.is_some(), "git command should always return Some");
}
#[test]
fn git_command_output_contains_branch() {
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap_or(std::path::Path::new("."));
let agents = test_agents();
let ambient_rooms = test_ambient_rooms();
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
bot_name: "Timmy",
bot_user_id: "@timmy:homeserver.local",
project_root: repo_root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
let output = try_handle_command(&dispatch, "@timmy git").unwrap();
assert!(
output.contains("**Branch:**"),
"git output should contain branch info: {output}"
);
}
#[test]
fn git_command_output_contains_changes() {
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap_or(std::path::Path::new("."));
let agents = test_agents();
let ambient_rooms = test_ambient_rooms();
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
bot_name: "Timmy",
bot_user_id: "@timmy:homeserver.local",
project_root: repo_root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
let output = try_handle_command(&dispatch, "@timmy git").unwrap();
assert!(
output.contains("**Changes:**"),
"git output should contain changes section: {output}"
);
}
#[test]
fn git_command_output_contains_remote() {
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap_or(std::path::Path::new("."));
let agents = test_agents();
let ambient_rooms = test_ambient_rooms();
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
bot_name: "Timmy",
bot_user_id: "@timmy:homeserver.local",
project_root: repo_root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
let output = try_handle_command(&dispatch, "@timmy git").unwrap();
assert!(
output.contains("**Remote:**"),
"git output should contain remote section: {output}"
);
}
#[test]
fn git_command_case_insensitive() {
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy GIT");
assert!(result.is_some(), "GIT should match case-insensitively");
}
}

View File

@@ -0,0 +1,113 @@
//! Handler for the `help` command.
use super::{commands, CommandContext};
pub(super) fn handle_help(ctx: &CommandContext) -> Option<String> {
let mut output = format!("**{} Commands**\n\n", ctx.bot_name);
let mut sorted: Vec<_> = commands().iter().collect();
sorted.sort_by_key(|c| c.name);
for cmd in sorted {
output.push_str(&format!("- **{}** — {}\n", cmd.name, cmd.description));
}
Some(output)
}
#[cfg(test)]
mod tests {
use super::super::tests::{try_cmd_addressed, commands};
#[test]
fn help_command_matches() {
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
assert!(result.is_some(), "help command should match");
}
#[test]
fn help_command_case_insensitive() {
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy HELP");
assert!(result.is_some(), "HELP should match case-insensitively");
}
#[test]
fn help_output_contains_all_commands() {
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
let output = result.unwrap();
for cmd in commands() {
assert!(
output.contains(cmd.name),
"help output must include command '{}'",
cmd.name
);
assert!(
output.contains(cmd.description),
"help output must include description for '{}'",
cmd.name
);
}
}
#[test]
fn help_output_uses_bot_name() {
let result = try_cmd_addressed("HAL", "@hal:example.com", "@hal help");
let output = result.unwrap();
assert!(
output.contains("HAL Commands"),
"help output should use bot name: {output}"
);
}
#[test]
fn help_output_formatted_as_markdown() {
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
let output = result.unwrap();
assert!(
output.contains("**help**"),
"command name should be bold: {output}"
);
assert!(
output.contains("- **"),
"commands should be in a list: {output}"
);
}
#[test]
fn help_output_includes_status() {
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
let output = result.unwrap();
assert!(output.contains("status"), "help should list status command: {output}");
}
#[test]
fn help_output_is_alphabetical() {
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
let output = result.unwrap();
// Search for **name** (bold markdown) to avoid substring matches in descriptions.
let mut positions: Vec<(usize, &str)> = commands()
.iter()
.map(|c| {
let marker = format!("**{}**", c.name);
let pos = output.find(&marker).expect("command must appear in help as **name**");
(pos, c.name)
})
.collect();
positions.sort_by_key(|(pos, _)| *pos);
let names_in_order: Vec<&str> = positions.iter().map(|(_, n)| *n).collect();
let mut sorted = names_in_order.clone();
sorted.sort();
assert_eq!(names_in_order, sorted, "commands must appear in alphabetical order");
}
#[test]
fn help_output_includes_ambient() {
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
let output = result.unwrap();
assert!(output.contains("ambient"), "help should list ambient command: {output}");
}
#[test]
fn help_output_includes_htop() {
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
let output = result.unwrap();
assert!(output.contains("htop"), "help should list htop command: {output}");
}
}

View File

@@ -0,0 +1,470 @@
//! Bot-level command registry for the Matrix bot.
//!
//! Commands registered here are handled directly by the bot without invoking
//! the LLM. The registry is the single source of truth — the `help` command
//! iterates it automatically so new commands appear in the help output as soon
//! as they are added.
mod ambient;
mod assign;
mod cost;
mod git;
mod help;
mod move_story;
mod overview;
mod show;
mod status;
mod whatsup;
use crate::agents::AgentPool;
use std::collections::HashSet;
use std::path::Path;
use std::sync::{Arc, Mutex};
/// A bot-level command that is handled without LLM invocation.
pub struct BotCommand {
/// The command keyword (e.g., `"help"`). Always lowercase.
pub name: &'static str,
/// Short description shown in help output.
pub description: &'static str,
/// Handler that produces the response text (Markdown), or `None` to fall
/// through to the LLM (e.g. when a command requires direct addressing but
/// the message arrived via ambient mode).
pub handler: fn(&CommandContext) -> Option<String>,
}
/// Dispatch parameters passed to `try_handle_command`.
///
/// Groups all the caller-supplied context needed to dispatch and execute bot
/// commands. Construct one per incoming message and pass it alongside the raw
/// message body.
///
/// All identifiers are platform-agnostic strings so this struct works with
/// any [`ChatTransport`](crate::transport::ChatTransport) implementation.
pub struct CommandDispatch<'a> {
/// The bot's display name (e.g., "Timmy").
pub bot_name: &'a str,
/// The bot's full user ID (e.g., `"@timmy:homeserver.local"` on Matrix).
pub bot_user_id: &'a str,
/// Project root directory (needed by status, ambient).
pub project_root: &'a Path,
/// Agent pool (needed by status).
pub agents: &'a AgentPool,
/// Set of room IDs with ambient mode enabled (needed by ambient).
pub ambient_rooms: &'a Arc<Mutex<HashSet<String>>>,
/// The room this message came from (needed by ambient).
pub room_id: &'a str,
}
/// Context passed to individual command handlers.
pub struct CommandContext<'a> {
/// The bot's display name (e.g., "Timmy").
pub bot_name: &'a str,
/// Any text after the command keyword, trimmed.
pub args: &'a str,
/// Project root directory (needed by status, ambient).
pub project_root: &'a Path,
/// Agent pool (needed by status).
pub agents: &'a AgentPool,
/// Set of room IDs with ambient mode enabled (needed by ambient).
pub ambient_rooms: &'a Arc<Mutex<HashSet<String>>>,
/// The room this message came from (needed by ambient).
pub room_id: &'a str,
}
/// Returns the full list of registered bot commands.
///
/// Add new commands here — they will automatically appear in `help` output.
pub fn commands() -> &'static [BotCommand] {
&[
BotCommand {
name: "assign",
description: "Pre-assign a model to a story: `assign <number> <model>` (e.g. `assign 42 opus`)",
handler: assign::handle_assign,
},
BotCommand {
name: "help",
description: "Show this list of available commands",
handler: help::handle_help,
},
BotCommand {
name: "status",
description: "Show pipeline status and agent availability",
handler: status::handle_status,
},
BotCommand {
name: "ambient",
description: "Toggle ambient mode for this room: `ambient on` or `ambient off`",
handler: ambient::handle_ambient,
},
BotCommand {
name: "git",
description: "Show git status: branch, uncommitted changes, and ahead/behind remote",
handler: git::handle_git,
},
BotCommand {
name: "htop",
description: "Show live system and agent process dashboard (`htop`, `htop 10m`, `htop stop`)",
handler: handle_htop_fallback,
},
BotCommand {
name: "cost",
description: "Show token spend: 24h total, top stories, breakdown by agent type, and all-time total",
handler: cost::handle_cost,
},
BotCommand {
name: "move",
description: "Move a work item to a pipeline stage: `move <number> <stage>` (stages: backlog, current, qa, merge, done)",
handler: move_story::handle_move,
},
BotCommand {
name: "show",
description: "Display the full text of a work item: `show <number>`",
handler: show::handle_show,
},
BotCommand {
name: "overview",
description: "Show implementation summary for a merged story: `overview <number>`",
handler: overview::handle_overview,
},
BotCommand {
name: "whatsup",
description: "Show in-progress triage dump for a story: `whatsup <number>`",
handler: whatsup::handle_whatsup,
},
BotCommand {
name: "start",
description: "Start a coder on a story: `start <number>` or `start <number> opus`",
handler: handle_start_fallback,
},
BotCommand {
name: "delete",
description: "Remove a work item from the pipeline: `delete <number>`",
handler: handle_delete_fallback,
},
BotCommand {
name: "reset",
description: "Clear the current Claude Code session and start fresh",
handler: handle_reset_fallback,
},
BotCommand {
name: "rebuild",
description: "Rebuild the server binary and restart",
handler: handle_rebuild_fallback,
},
]
}
/// Try to match a user message against a registered bot command.
///
/// The message is expected to be the raw body text from Matrix (e.g.,
/// `"@timmy help"`). The bot mention prefix is stripped before matching.
///
/// Returns `Some(response)` if a command matched and was handled, `None`
/// otherwise (the caller should fall through to the LLM).
pub fn try_handle_command(dispatch: &CommandDispatch<'_>, message: &str) -> Option<String> {
let command_text = strip_bot_mention(message, dispatch.bot_name, dispatch.bot_user_id);
let trimmed = command_text.trim();
if trimmed.is_empty() {
return None;
}
let (cmd_name, args) = match trimmed.split_once(char::is_whitespace) {
Some((c, a)) => (c, a.trim()),
None => (trimmed, ""),
};
let cmd_lower = cmd_name.to_ascii_lowercase();
let ctx = CommandContext {
bot_name: dispatch.bot_name,
args,
project_root: dispatch.project_root,
agents: dispatch.agents,
ambient_rooms: dispatch.ambient_rooms,
room_id: dispatch.room_id,
};
commands()
.iter()
.find(|c| c.name == cmd_lower)
.and_then(|c| (c.handler)(&ctx))
}
/// Strip the bot mention prefix from a raw message body.
///
/// Handles these forms (case-insensitive where applicable):
/// - `@bot_localpart:server.com rest` → `rest`
/// - `@bot_localpart rest` → `rest`
/// - `DisplayName rest` → `rest`
fn strip_bot_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) -> &'a str {
let trimmed = message.trim();
// Try full Matrix user ID (e.g. "@timmy:homeserver.local")
if let Some(rest) = strip_prefix_ci(trimmed, bot_user_id) {
return rest;
}
// Try @localpart (e.g. "@timmy")
if let Some(localpart) = bot_user_id.split(':').next()
&& let Some(rest) = strip_prefix_ci(trimmed, localpart)
{
return rest;
}
// Try display name (e.g. "Timmy")
if let Some(rest) = strip_prefix_ci(trimmed, bot_name) {
return rest;
}
trimmed
}
/// Case-insensitive prefix strip that also requires the match to end at a
/// word boundary (whitespace, punctuation, or end-of-string).
fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
if text.len() < prefix.len() {
return None;
}
if !text[..prefix.len()].eq_ignore_ascii_case(prefix) {
return None;
}
let rest = &text[prefix.len()..];
// Must be at end or followed by non-alphanumeric
match rest.chars().next() {
None => Some(rest), // exact match, empty remainder
Some(c) if c.is_alphanumeric() || c == '-' || c == '_' => None, // not a word boundary
_ => Some(rest),
}
}
/// Fallback handler for the `htop` command when it is not intercepted by the
/// async handler in `on_room_message`. In practice this is never called —
/// htop is detected and handled before `try_handle_command` is invoked.
/// The entry exists in the registry only so `help` lists it.
///
/// Returns `None` to prevent the LLM from receiving "htop" as a prompt.
fn handle_htop_fallback(_ctx: &CommandContext) -> Option<String> {
None
}
/// Fallback handler for the `start` command when it is not intercepted by
/// the async handler in `on_room_message`. In practice this is never called —
/// start is detected and handled before `try_handle_command` is invoked.
/// The entry exists in the registry only so `help` lists it.
///
/// Returns `None` to prevent the LLM from receiving "start" as a prompt.
fn handle_start_fallback(_ctx: &CommandContext) -> Option<String> {
None
}
/// Fallback handler for the `delete` command when it is not intercepted by
/// the async handler in `on_room_message`. In practice this is never called —
/// delete is detected and handled before `try_handle_command` is invoked.
/// The entry exists in the registry only so `help` lists it.
///
/// Returns `None` to prevent the LLM from receiving "delete" as a prompt.
fn handle_delete_fallback(_ctx: &CommandContext) -> Option<String> {
None
}
/// Fallback handler for the `reset` command when it is not intercepted by
/// the async handler in `on_room_message`. In practice this is never called —
/// reset is detected and handled before `try_handle_command` is invoked.
/// The entry exists in the registry only so `help` lists it.
///
/// Returns `None` to prevent the LLM from receiving "reset" as a prompt.
fn handle_reset_fallback(_ctx: &CommandContext) -> Option<String> {
None
}
/// Fallback handler for the `rebuild` command when it is not intercepted by
/// the async handler in `on_room_message`. In practice this is never called —
/// rebuild is detected and handled before `try_handle_command` is invoked.
/// The entry exists in the registry only so `help` lists it.
///
/// Returns `None` to prevent the LLM from receiving "rebuild" as a prompt.
fn handle_rebuild_fallback(_ctx: &CommandContext) -> Option<String> {
None
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use crate::agents::AgentPool;
// -- test helpers (shared with submodule tests) -------------------------
pub fn test_ambient_rooms() -> Arc<Mutex<HashSet<String>>> {
Arc::new(Mutex::new(HashSet::new()))
}
pub fn test_agents() -> Arc<AgentPool> {
Arc::new(AgentPool::new_test(3000))
}
pub fn try_cmd(
bot_name: &str,
bot_user_id: &str,
message: &str,
ambient_rooms: &Arc<Mutex<HashSet<String>>>,
) -> Option<String> {
let agents = test_agents();
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
bot_name,
bot_user_id,
project_root: std::path::Path::new("/tmp"),
agents: &agents,
ambient_rooms,
room_id: &room_id,
};
try_handle_command(&dispatch, message)
}
pub fn try_cmd_addressed(bot_name: &str, bot_user_id: &str, message: &str) -> Option<String> {
try_cmd(bot_name, bot_user_id, message, &test_ambient_rooms())
}
// Re-export commands() for submodule tests
pub use super::commands;
// -- strip_bot_mention --------------------------------------------------
#[test]
fn strip_mention_full_user_id() {
let rest = strip_bot_mention(
"@timmy:homeserver.local help",
"Timmy",
"@timmy:homeserver.local",
);
assert_eq!(rest.trim(), "help");
}
#[test]
fn strip_mention_localpart() {
let rest = strip_bot_mention("@timmy help me", "Timmy", "@timmy:homeserver.local");
assert_eq!(rest.trim(), "help me");
}
#[test]
fn strip_mention_display_name() {
let rest = strip_bot_mention("Timmy help", "Timmy", "@timmy:homeserver.local");
assert_eq!(rest.trim(), "help");
}
#[test]
fn strip_mention_display_name_case_insensitive() {
let rest = strip_bot_mention("timmy help", "Timmy", "@timmy:homeserver.local");
assert_eq!(rest.trim(), "help");
}
#[test]
fn strip_mention_no_match_returns_original() {
let rest = strip_bot_mention("hello world", "Timmy", "@timmy:homeserver.local");
assert_eq!(rest, "hello world");
}
#[test]
fn strip_mention_does_not_match_longer_name() {
// "@timmybot" should NOT match "@timmy"
let rest = strip_bot_mention("@timmybot help", "Timmy", "@timmy:homeserver.local");
assert_eq!(rest, "@timmybot help");
}
#[test]
fn strip_mention_comma_after_name() {
let rest = strip_bot_mention("@timmy, help", "Timmy", "@timmy:homeserver.local");
assert_eq!(rest.trim().trim_start_matches(',').trim(), "help");
}
// -- try_handle_command -------------------------------------------------
#[test]
fn unknown_command_returns_none() {
let result = try_cmd_addressed(
"Timmy",
"@timmy:homeserver.local",
"@timmy what is the weather?",
);
assert!(result.is_none(), "non-command should return None");
}
#[test]
fn empty_message_after_mention_returns_none() {
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy");
assert!(
result.is_none(),
"bare mention with no command should fall through to LLM"
);
}
#[test]
fn htop_command_falls_through_to_none() {
// The htop handler returns None so the message is handled asynchronously
// in on_room_message, not here. try_handle_command must return None.
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy htop");
assert!(
result.is_none(),
"htop should not produce a sync response (handled async): {result:?}"
);
}
// -- strip_prefix_ci ----------------------------------------------------
#[test]
fn strip_prefix_ci_basic() {
assert_eq!(strip_prefix_ci("Hello world", "hello"), Some(" world"));
}
#[test]
fn strip_prefix_ci_no_match() {
assert_eq!(strip_prefix_ci("goodbye", "hello"), None);
}
#[test]
fn strip_prefix_ci_word_boundary_required() {
assert_eq!(strip_prefix_ci("helloworld", "hello"), None);
}
#[test]
fn strip_prefix_ci_exact_match() {
assert_eq!(strip_prefix_ci("hello", "hello"), Some(""));
}
// -- commands registry --------------------------------------------------
#[test]
fn commands_registry_is_not_empty() {
assert!(
!commands().is_empty(),
"command registry must contain at least one command"
);
}
#[test]
fn all_command_names_are_lowercase() {
for cmd in commands() {
assert_eq!(
cmd.name,
cmd.name.to_ascii_lowercase(),
"command name '{}' must be lowercase",
cmd.name
);
}
}
#[test]
fn all_commands_have_descriptions() {
for cmd in commands() {
assert!(
!cmd.description.is_empty(),
"command '{}' must have a description",
cmd.name
);
}
}
}

View File

@@ -0,0 +1,296 @@
//! Handler for the `move` command.
//!
//! `{bot_name} move {number} {stage}` finds the work item by number across all
//! pipeline stages, moves it to the specified stage, and returns a confirmation
//! with the story title, old stage, and new stage.
use super::CommandContext;
use crate::agents::move_story_to_stage;
/// Valid stage names accepted by the move command.
const VALID_STAGES: &[&str] = &["backlog", "current", "qa", "merge", "done"];
/// All pipeline stage directories to search when finding a work item by number.
const SEARCH_DIRS: &[&str] = &[
"1_backlog",
"2_current",
"3_qa",
"4_merge",
"5_done",
"6_archived",
];
/// Handle the `move` command.
///
/// Parses `<number> <stage>` from `ctx.args`, locates the work item by its
/// numeric prefix, moves it to the target stage using the shared lifecycle
/// function, and returns a Markdown confirmation string.
pub(super) fn handle_move(ctx: &CommandContext) -> Option<String> {
let args = ctx.args.trim();
// Parse `number stage` from args.
let (num_str, stage_raw) = match args.split_once(char::is_whitespace) {
Some((n, s)) => (n.trim(), s.trim()),
None => {
return Some(format!(
"Usage: `{} move <number> <stage>`\n\nValid stages: {}",
ctx.bot_name,
VALID_STAGES.join(", ")
));
}
};
if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) {
return Some(format!(
"Invalid story number: `{num_str}`. Usage: `{} move <number> <stage>`",
ctx.bot_name
));
}
let target_stage = stage_raw.to_ascii_lowercase();
if !VALID_STAGES.contains(&target_stage.as_str()) {
return Some(format!(
"Invalid stage: `{stage_raw}`. Valid stages: {}",
VALID_STAGES.join(", ")
));
}
// Find the story file across all pipeline stages by numeric prefix.
let mut found_story_id: Option<String> = None;
let mut found_name: Option<String> = None;
'outer: for stage_dir in SEARCH_DIRS {
let dir = ctx
.project_root
.join(".storkit")
.join("work")
.join(stage_dir);
if !dir.exists() {
continue;
}
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 file_num = stem
.split('_')
.next()
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
.unwrap_or("");
if file_num == num_str {
found_story_id = Some(stem.to_string());
found_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)
});
break 'outer;
}
}
}
}
}
let story_id = match found_story_id {
Some(id) => id,
None => {
return Some(format!(
"No story, bug, or spike with number **{num_str}** found."
));
}
};
let display_name = found_name.as_deref().unwrap_or(&story_id);
match move_story_to_stage(ctx.project_root, &story_id, &target_stage) {
Ok((from_stage, to_stage)) => Some(format!(
"Moved **{display_name}** from **{from_stage}** to **{to_stage}**."
)),
Err(e) => Some(format!("Failed to move story {num_str}: {e}")),
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use crate::agents::AgentPool;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use super::super::{CommandDispatch, try_handle_command};
fn move_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> {
let agents = Arc::new(AgentPool::new_test(3000));
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
bot_name: "Timmy",
bot_user_id: "@timmy:homeserver.local",
project_root: root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
try_handle_command(&dispatch, &format!("@timmy move {args}"))
}
fn write_story_file(root: &std::path::Path, stage: &str, filename: &str, content: &str) {
let dir = root.join(".storkit/work").join(stage);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(filename), content).unwrap();
}
#[test]
fn move_command_is_registered() {
use super::super::commands;
let found = commands().iter().any(|c| c.name == "move");
assert!(found, "move command must be in the registry");
}
#[test]
fn move_command_appears_in_help() {
let result = super::super::tests::try_cmd_addressed(
"Timmy",
"@timmy:homeserver.local",
"@timmy help",
);
let output = result.unwrap();
assert!(
output.contains("move"),
"help should list move command: {output}"
);
}
#[test]
fn move_command_no_args_returns_usage() {
let tmp = tempfile::TempDir::new().unwrap();
let output = move_cmd_with_root(tmp.path(), "").unwrap();
assert!(
output.contains("Usage"),
"no args should show usage hint: {output}"
);
}
#[test]
fn move_command_missing_stage_returns_usage() {
let tmp = tempfile::TempDir::new().unwrap();
let output = move_cmd_with_root(tmp.path(), "42").unwrap();
assert!(
output.contains("Usage"),
"missing stage should show usage hint: {output}"
);
}
#[test]
fn move_command_invalid_stage_returns_error() {
let tmp = tempfile::TempDir::new().unwrap();
let output = move_cmd_with_root(tmp.path(), "42 invalid_stage").unwrap();
assert!(
output.contains("Invalid stage"),
"invalid stage should return error: {output}"
);
}
#[test]
fn move_command_non_numeric_number_returns_error() {
let tmp = tempfile::TempDir::new().unwrap();
let output = move_cmd_with_root(tmp.path(), "abc current").unwrap();
assert!(
output.contains("Invalid story number"),
"non-numeric number should return error: {output}"
);
}
#[test]
fn move_command_not_found_returns_friendly_message() {
let tmp = tempfile::TempDir::new().unwrap();
let output = move_cmd_with_root(tmp.path(), "999 current").unwrap();
assert!(
output.contains("999") && output.contains("found"),
"not-found message should include number and 'found': {output}"
);
}
#[test]
fn move_command_moves_story_and_confirms() {
let tmp = tempfile::TempDir::new().unwrap();
write_story_file(
tmp.path(),
"1_backlog",
"42_story_some_feature.md",
"---\nname: Some Feature\n---\n\n# Story 42\n",
);
let output = move_cmd_with_root(tmp.path(), "42 current").unwrap();
assert!(
output.contains("Some Feature"),
"confirmation should include story name: {output}"
);
assert!(
output.contains("backlog"),
"confirmation should include old stage: {output}"
);
assert!(
output.contains("current"),
"confirmation should include new stage: {output}"
);
// Verify the file was actually moved.
let new_path = tmp
.path()
.join(".storkit/work/2_current/42_story_some_feature.md");
assert!(new_path.exists(), "story file should be in 2_current/");
}
#[test]
fn move_command_case_insensitive_stage() {
let tmp = tempfile::TempDir::new().unwrap();
write_story_file(
tmp.path(),
"2_current",
"10_story_test.md",
"---\nname: Test\n---\n",
);
let output = move_cmd_with_root(tmp.path(), "10 BACKLOG").unwrap();
assert!(
output.contains("Test") && output.contains("backlog"),
"stage matching should be case-insensitive: {output}"
);
}
#[test]
fn move_command_idempotent_when_already_in_target() {
let tmp = tempfile::TempDir::new().unwrap();
write_story_file(
tmp.path(),
"2_current",
"5_story_already_current.md",
"---\nname: Already Current\n---\n",
);
// Moving to the stage it's already in should return a success message.
let output = move_cmd_with_root(tmp.path(), "5 current").unwrap();
assert!(
output.contains("Moved") || output.contains("current"),
"idempotent move should succeed: {output}"
);
}
#[test]
fn move_command_case_insensitive_command() {
let result = super::super::tests::try_cmd_addressed(
"Timmy",
"@timmy:homeserver.local",
"@timmy MOVE 1 backlog",
);
// Returns Some (the registry matched, regardless of result content)
assert!(result.is_some(), "MOVE should match case-insensitively");
}
}

View File

@@ -0,0 +1,380 @@
//! Handler for the `overview` command.
use super::CommandContext;
/// Show implementation summary for a story identified by its number.
///
/// Finds the `storkit: merge {story_id}` commit on master, displays the
/// git diff --stat (files changed with line counts), and extracts key
/// function/struct/type names added or modified in the implementation.
/// Returns a friendly message when no merge commit is found.
pub(super) fn handle_overview(ctx: &CommandContext) -> Option<String> {
let num_str = ctx.args.trim();
if num_str.is_empty() {
return Some(format!(
"Usage: `{} overview <number>`\n\nShows the implementation summary for a story.",
ctx.bot_name
));
}
if !num_str.chars().all(|c| c.is_ascii_digit()) {
return Some(format!(
"Invalid story number: `{num_str}`. Usage: `{} overview <number>`",
ctx.bot_name
));
}
let commit_hash = match find_story_merge_commit(ctx.project_root, num_str) {
Some(h) => h,
None => {
return Some(format!(
"No implementation found for story **{num_str}**. \
It may still be in the backlog or was never merged."
));
}
};
let stat_output = get_commit_stat(ctx.project_root, &commit_hash);
let symbols = extract_diff_symbols(ctx.project_root, &commit_hash);
let story_name = find_story_name(ctx.project_root, num_str);
let short_hash = &commit_hash[..commit_hash.len().min(8)];
let mut out = match story_name {
Some(name) => format!("**Overview: Story {num_str}{name}**\n\n"),
None => format!("**Overview: Story {num_str}**\n\n"),
};
out.push_str(&format!("Commit: `{short_hash}`\n\n"));
// Parse stat output: collect per-file lines and the summary line.
let mut file_lines: Vec<String> = Vec::new();
let mut summary_line = String::new();
for line in stat_output.lines() {
if line.contains("changed") && (line.contains("insertion") || line.contains("deletion")) {
summary_line = line.trim().to_string();
} else if !line.trim().is_empty() && line.contains('|') {
file_lines.push(line.trim().to_string());
}
}
if !summary_line.is_empty() {
out.push_str(&format!("**Changes:** {summary_line}\n"));
}
if !file_lines.is_empty() {
out.push_str("**Files:**\n");
for f in file_lines.iter().take(8) {
out.push_str(&format!(" • `{f}`\n"));
}
if file_lines.len() > 8 {
out.push_str(&format!(" … and {} more\n", file_lines.len() - 8));
}
}
if !symbols.is_empty() {
out.push_str("\n**Key symbols:**\n");
for sym in &symbols {
out.push_str(&format!("{sym}\n"));
}
}
Some(out)
}
/// Find the merge commit hash for a story by its numeric ID.
///
/// Searches git log for a commit whose subject matches
/// `storkit: merge {num}_*` or the legacy `story-kit: merge {num}_*`.
fn find_story_merge_commit(root: &std::path::Path, num_str: &str) -> Option<String> {
use std::process::Command;
// Match both the current prefix and the legacy one from before the rename.
let grep_pattern = format!("(storkit|story-kit): merge {num_str}_");
let output = Command::new("git")
.args([
"log",
"--format=%H",
"--all",
"--extended-regexp",
"--grep",
&grep_pattern,
])
.current_dir(root)
.output()
.ok()
.filter(|o| o.status.success())?;
let text = String::from_utf8_lossy(&output.stdout);
let hash = text.lines().next()?.trim().to_string();
if hash.is_empty() { None } else { Some(hash) }
}
/// Find the human-readable name of a story by searching all pipeline stages.
fn find_story_name(root: &std::path::Path, num_str: &str) -> Option<String> {
let stages = [
"1_backlog",
"2_current",
"3_qa",
"4_merge",
"5_done",
"6_archived",
];
for stage in &stages {
let dir = root.join(".storkit").join("work").join(stage);
if !dir.exists() {
continue;
}
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 file_num = stem
.split('_')
.next()
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
.unwrap_or("");
if file_num == num_str {
return std::fs::read_to_string(&path).ok().and_then(|c| {
crate::io::story_metadata::parse_front_matter(&c)
.ok()
.and_then(|m| m.name)
});
}
}
}
}
}
None
}
/// Return the `git show --stat` output for a commit.
fn get_commit_stat(root: &std::path::Path, hash: &str) -> String {
use std::process::Command;
Command::new("git")
.args(["show", "--stat", hash])
.current_dir(root)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
.unwrap_or_default()
}
/// Extract up to 12 unique top-level symbol definitions from a commit diff.
///
/// Scans added lines (`+`) for Rust `fn`, `struct`, `enum`, `type`, `trait`,
/// and `impl` declarations and returns them formatted as `` `Name` (kind) ``.
fn extract_diff_symbols(root: &std::path::Path, hash: &str) -> Vec<String> {
use std::process::Command;
let output = Command::new("git")
.args(["show", hash])
.current_dir(root)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
.unwrap_or_default();
let mut symbols: Vec<String> = Vec::new();
for line in output.lines() {
if !line.starts_with('+') || line.starts_with("+++") {
continue;
}
if let Some(sym) = parse_symbol_definition(&line[1..]) {
if !symbols.contains(&sym) {
symbols.push(sym);
}
if symbols.len() >= 12 {
break;
}
}
}
symbols
}
/// Parse a single line of code and return a formatted symbol if it opens a
/// top-level Rust definition (`fn`, `struct`, `enum`, `type`, `trait`, `impl`).
fn parse_symbol_definition(code: &str) -> Option<String> {
let t = code.trim();
let patterns: &[(&str, &str)] = &[
("pub async fn ", "fn"),
("async fn ", "fn"),
("pub fn ", "fn"),
("fn ", "fn"),
("pub struct ", "struct"),
("struct ", "struct"),
("pub enum ", "enum"),
("enum ", "enum"),
("pub type ", "type"),
("type ", "type"),
("pub trait ", "trait"),
("trait ", "trait"),
("impl ", "impl"),
];
for (prefix, kind) in patterns {
if let Some(rest) = t.strip_prefix(prefix) {
let name: String = rest
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
if !name.is_empty() {
return Some(format!("`{name}` ({kind})"));
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agents::AgentPool;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use super::super::{CommandDispatch, try_handle_command};
fn overview_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> {
let agents = Arc::new(AgentPool::new_test(3000));
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
bot_name: "Timmy",
bot_user_id: "@timmy:homeserver.local",
project_root: root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
try_handle_command(&dispatch, &format!("@timmy overview {args}"))
}
#[test]
fn overview_command_is_registered() {
use super::super::commands;
let found = commands().iter().any(|c| c.name == "overview");
assert!(found, "overview command must be in the registry");
}
#[test]
fn overview_command_appears_in_help() {
let result = super::super::tests::try_cmd_addressed(
"Timmy",
"@timmy:homeserver.local",
"@timmy help",
);
let output = result.unwrap();
assert!(
output.contains("overview"),
"help should list overview command: {output}"
);
}
#[test]
fn overview_command_no_args_returns_usage() {
let tmp = tempfile::TempDir::new().unwrap();
let output = overview_cmd_with_root(tmp.path(), "").unwrap();
assert!(
output.contains("Usage"),
"no args should show usage hint: {output}"
);
}
#[test]
fn overview_command_non_numeric_arg_returns_error() {
let tmp = tempfile::TempDir::new().unwrap();
let output = overview_cmd_with_root(tmp.path(), "abc").unwrap();
assert!(
output.contains("Invalid"),
"non-numeric arg should return error: {output}"
);
}
#[test]
fn overview_command_not_found_returns_friendly_message() {
// Use the real repo root but a story number that was never merged.
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap_or(std::path::Path::new("."));
let output = overview_cmd_with_root(repo_root, "99999").unwrap();
assert!(
output.contains("99999"),
"not-found message should include the story number: {output}"
);
assert!(
output.contains("backlog") || output.contains("No implementation"),
"not-found message should explain why: {output}"
);
}
#[test]
fn overview_command_found_shows_commit_and_stat() {
// Story 324 has a real merge commit in master.
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap_or(std::path::Path::new("."));
let output = overview_cmd_with_root(repo_root, "324").unwrap();
assert!(
output.contains("**Overview: Story 324"),
"output should show story header: {output}"
);
assert!(
output.contains("Commit:"),
"output should show commit hash: {output}"
);
assert!(
output.contains("**Changes:**") || output.contains("**Files:**"),
"output should show file changes: {output}"
);
}
#[test]
fn overview_command_case_insensitive() {
let result = super::super::tests::try_cmd_addressed(
"Timmy",
"@timmy:homeserver.local",
"@timmy OVERVIEW 1",
);
assert!(result.is_some(), "OVERVIEW should match case-insensitively");
}
// -- parse_symbol_definition --------------------------------------------
#[test]
fn parse_symbol_pub_fn() {
let result =
parse_symbol_definition("pub fn handle_foo(ctx: &Context) -> Option<String> {");
assert_eq!(result, Some("`handle_foo` (fn)".to_string()));
}
#[test]
fn parse_symbol_pub_struct() {
let result = parse_symbol_definition("pub struct SlackTransport {");
assert_eq!(result, Some("`SlackTransport` (struct)".to_string()));
}
#[test]
fn parse_symbol_impl() {
let result = parse_symbol_definition("impl ChatTransport for SlackTransport {");
assert_eq!(result, Some("`ChatTransport` (impl)".to_string()));
}
#[test]
fn parse_symbol_no_match() {
let result = parse_symbol_definition(" let x = 42;");
assert_eq!(result, None);
}
#[test]
fn parse_symbol_pub_enum() {
let result = parse_symbol_definition("pub enum QaMode {");
assert_eq!(result, Some("`QaMode` (enum)".to_string()));
}
#[test]
fn parse_symbol_pub_type() {
let result = parse_symbol_definition(
"pub type SlackHistory = Arc<Mutex<HashMap<String, Vec<u8>>>>;",
);
assert_eq!(result, Some("`SlackHistory` (type)".to_string()));
}
}

View File

@@ -0,0 +1,201 @@
//! Handler for the `show` command.
use super::CommandContext;
/// Display the full markdown text of a work item identified by its numeric ID.
///
/// Searches all pipeline stages in order and returns the raw file contents of
/// the first matching story, bug, or spike. Returns a friendly message when
/// no match is found.
pub(super) fn handle_show(ctx: &CommandContext) -> Option<String> {
let num_str = ctx.args.trim();
if num_str.is_empty() {
return Some(format!(
"Usage: `{} show <number>`\n\nDisplays the full text of a story, bug, or spike.",
ctx.bot_name
));
}
if !num_str.chars().all(|c| c.is_ascii_digit()) {
return Some(format!(
"Invalid story number: `{num_str}`. Usage: `{} show <number>`",
ctx.bot_name
));
}
let stages = [
"1_backlog",
"2_current",
"3_qa",
"4_merge",
"5_done",
"6_archived",
];
for stage in &stages {
let dir = ctx
.project_root
.join(".storkit")
.join("work")
.join(stage);
if !dir.exists() {
continue;
}
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 file_num = stem
.split('_')
.next()
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
.unwrap_or("");
if file_num == num_str {
return match std::fs::read_to_string(&path) {
Ok(contents) => Some(contents),
Err(e) => Some(format!("Failed to read story {num_str}: {e}")),
};
}
}
}
}
}
Some(format!(
"No story, bug, or spike with number **{num_str}** found."
))
}
#[cfg(test)]
mod tests {
use crate::agents::AgentPool;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use super::super::{CommandDispatch, try_handle_command};
fn show_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> {
let agents = Arc::new(AgentPool::new_test(3000));
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
bot_name: "Timmy",
bot_user_id: "@timmy:homeserver.local",
project_root: root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
try_handle_command(&dispatch, &format!("@timmy show {args}"))
}
fn write_story_file(root: &std::path::Path, stage: &str, filename: &str, content: &str) {
let dir = root.join(".storkit/work").join(stage);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(filename), content).unwrap();
}
#[test]
fn show_command_is_registered() {
use super::super::commands;
let found = commands().iter().any(|c| c.name == "show");
assert!(found, "show command must be in the registry");
}
#[test]
fn show_command_appears_in_help() {
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
let output = result.unwrap();
assert!(output.contains("show"), "help should list show command: {output}");
}
#[test]
fn show_command_no_args_returns_usage() {
let tmp = tempfile::TempDir::new().unwrap();
let output = show_cmd_with_root(tmp.path(), "").unwrap();
assert!(
output.contains("Usage"),
"no args should show usage hint: {output}"
);
}
#[test]
fn show_command_non_numeric_args_returns_error() {
let tmp = tempfile::TempDir::new().unwrap();
let output = show_cmd_with_root(tmp.path(), "abc").unwrap();
assert!(
output.contains("Invalid"),
"non-numeric arg should return error message: {output}"
);
}
#[test]
fn show_command_not_found_returns_friendly_message() {
let tmp = tempfile::TempDir::new().unwrap();
let output = show_cmd_with_root(tmp.path(), "999").unwrap();
assert!(
output.contains("999"),
"not-found message should include the queried number: {output}"
);
assert!(
output.contains("found"),
"not-found message should say not found: {output}"
);
}
#[test]
fn show_command_finds_story_in_backlog() {
let tmp = tempfile::TempDir::new().unwrap();
write_story_file(
tmp.path(),
"1_backlog",
"305_story_show_command.md",
"---\nname: Show command\n---\n\n# Story 305\n\nFull story text here.",
);
let output = show_cmd_with_root(tmp.path(), "305").unwrap();
assert!(
output.contains("Full story text here."),
"show should return full story content: {output}"
);
}
#[test]
fn show_command_finds_story_in_current() {
let tmp = tempfile::TempDir::new().unwrap();
write_story_file(
tmp.path(),
"2_current",
"42_story_do_something.md",
"---\nname: Do something\n---\n\n# Story 42\n\nIn progress.",
);
let output = show_cmd_with_root(tmp.path(), "42").unwrap();
assert!(
output.contains("In progress."),
"show should return story from current stage: {output}"
);
}
#[test]
fn show_command_finds_bug() {
let tmp = tempfile::TempDir::new().unwrap();
write_story_file(
tmp.path(),
"1_backlog",
"7_bug_crash_on_login.md",
"---\nname: Crash on login\n---\n\n## Symptom\n\nCrashes.",
);
let output = show_cmd_with_root(tmp.path(), "7").unwrap();
assert!(
output.contains("Symptom"),
"show should return bug content: {output}"
);
}
#[test]
fn show_command_case_insensitive() {
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy SHOW 1");
assert!(result.is_some(), "SHOW should match case-insensitively");
}
}

View File

@@ -0,0 +1,354 @@
//! 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}"
);
}
}

View File

@@ -0,0 +1,548 @@
//! Handler for the `whatsup` command.
//!
//! Produces a triage dump for a story that is currently in-progress
//! (`work/2_current/`): metadata, acceptance criteria, worktree/branch state,
//! git diff, recent commits, and the tail of the agent log.
//!
//! The command is handled entirely at the bot level — no LLM invocation.
use super::CommandContext;
use std::path::{Path, PathBuf};
use std::process::Command;
/// Handle `{bot_name} whatsup {number}`.
pub(super) fn handle_whatsup(ctx: &CommandContext) -> Option<String> {
let num_str = ctx.args.trim();
if num_str.is_empty() {
return Some(format!(
"Usage: `{} whatsup <number>`\n\nShows a triage dump for a story currently in progress.",
ctx.bot_name
));
}
if !num_str.chars().all(|c| c.is_ascii_digit()) {
return Some(format!(
"Invalid story number: `{num_str}`. Usage: `{} whatsup <number>`",
ctx.bot_name
));
}
let current_dir = ctx
.project_root
.join(".storkit")
.join("work")
.join("2_current");
match find_story_in_dir(&current_dir, num_str) {
Some((path, stem)) => Some(build_triage_dump(ctx, &path, &stem, num_str)),
None => Some(format!(
"Story **{num_str}** is not currently in progress (not found in `work/2_current/`)."
)),
}
}
/// Find a `.md` file whose numeric prefix matches `num_str` in `dir`.
///
/// Returns `(path, file_stem)` for the first match.
fn find_story_in_dir(dir: &Path, num_str: &str) -> Option<(PathBuf, String)> {
let entries = std::fs::read_dir(dir).ok()?;
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 file_num = stem
.split('_')
.next()
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
.unwrap_or("");
if file_num == num_str {
return Some((path.clone(), stem.to_string()));
}
}
}
None
}
/// Build the full triage dump for a story.
fn build_triage_dump(
ctx: &CommandContext,
story_path: &Path,
story_id: &str,
num_str: &str,
) -> String {
let contents = match std::fs::read_to_string(story_path) {
Ok(c) => c,
Err(e) => return format!("Failed to read story {num_str}: {e}"),
};
let meta = crate::io::story_metadata::parse_front_matter(&contents).ok();
let name = meta.as_ref().and_then(|m| m.name.as_deref()).unwrap_or("(unnamed)");
let mut out = String::new();
// ---- Header ----
out.push_str(&format!("## Story {num_str}{name}\n"));
out.push_str("**Stage:** In Progress (`2_current`)\n\n");
// ---- Front matter fields ----
if let Some(ref m) = meta {
let mut fields: Vec<String> = Vec::new();
if let Some(true) = m.blocked {
fields.push("**blocked:** true".to_string());
}
if let Some(ref agent) = m.agent {
fields.push(format!("**agent:** {agent}"));
}
if let Some(ref qa) = m.qa {
fields.push(format!("**qa:** {qa}"));
}
if let Some(true) = m.review_hold {
fields.push("**review_hold:** true".to_string());
}
if let Some(rc) = m.retry_count
&& rc > 0
{
fields.push(format!("**retry_count:** {rc}"));
}
if let Some(ref cb) = m.coverage_baseline {
fields.push(format!("**coverage_baseline:** {cb}"));
}
if let Some(ref mf) = m.merge_failure {
fields.push(format!("**merge_failure:** {mf}"));
}
if !fields.is_empty() {
out.push_str("**Front matter:**\n");
for f in &fields {
out.push_str(&format!("{f}\n"));
}
out.push('\n');
}
}
// ---- Acceptance criteria ----
let criteria = parse_acceptance_criteria(&contents);
if !criteria.is_empty() {
out.push_str("**Acceptance Criteria:**\n");
for (checked, text) in &criteria {
let mark = if *checked { "" } else { "" };
out.push_str(&format!(" {mark} {text}\n"));
}
let total = criteria.len();
let done = criteria.iter().filter(|(c, _)| *c).count();
out.push_str(&format!(" *{done}/{total} complete*\n"));
out.push('\n');
}
// ---- Worktree and branch ----
let wt_path = crate::worktree::worktree_path(ctx.project_root, story_id);
let branch = format!("feature/story-{story_id}");
if wt_path.is_dir() {
out.push_str(&format!("**Worktree:** `{}`\n", wt_path.display()));
out.push_str(&format!("**Branch:** `{branch}`\n\n"));
// ---- git diff --stat ----
let diff_stat = run_git(
&wt_path,
&["diff", "--stat", "master...HEAD"],
);
if !diff_stat.is_empty() {
out.push_str("**Diff stat (vs master):**\n```\n");
out.push_str(&diff_stat);
out.push_str("```\n\n");
} else {
out.push_str("**Diff stat (vs master):** *(no changes)*\n\n");
}
// ---- Last 5 commits on feature branch ----
let log = run_git(
&wt_path,
&[
"log",
"master..HEAD",
"--pretty=format:%h %s",
"-5",
],
);
if !log.is_empty() {
out.push_str("**Recent commits (branch only):**\n```\n");
out.push_str(&log);
out.push_str("\n```\n\n");
} else {
out.push_str("**Recent commits (branch only):** *(none yet)*\n\n");
}
} else {
out.push_str(&format!("**Branch:** `{branch}`\n"));
out.push_str("**Worktree:** *(not yet created)*\n\n");
}
// ---- Agent log tail ----
let log_dir = ctx
.project_root
.join(".storkit")
.join("logs")
.join(story_id);
match latest_log_file(&log_dir) {
Some(log_path) => {
let tail = read_log_tail(&log_path, 20);
let filename = log_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("agent.log");
if tail.is_empty() {
out.push_str(&format!("**Agent log** (`{filename}`):** *(empty)*\n"));
} else {
out.push_str(&format!("**Agent log tail** (`{filename}`):\n```\n"));
out.push_str(&tail);
out.push_str("\n```\n");
}
}
None => {
out.push_str("**Agent log:** *(no log found)*\n");
}
}
out
}
/// Parse acceptance criteria from story markdown.
///
/// Returns a list of `(checked, text)` for every `- [ ] ...` and `- [x] ...` line.
fn parse_acceptance_criteria(contents: &str) -> Vec<(bool, String)> {
contents
.lines()
.filter_map(|line| {
let trimmed = line.trim();
if let Some(text) = trimmed.strip_prefix("- [x] ").or_else(|| trimmed.strip_prefix("- [X] ")) {
Some((true, text.to_string()))
} else {
trimmed.strip_prefix("- [ ] ").map(|text| (false, text.to_string()))
}
})
.collect()
}
/// Run a git command in the given directory, returning trimmed stdout (or empty on error).
fn run_git(dir: &Path, args: &[&str]) -> String {
Command::new("git")
.args(args)
.current_dir(dir)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default()
}
/// Find the most recently modified `.log` file in the given directory,
/// regardless of agent name.
fn latest_log_file(log_dir: &Path) -> Option<PathBuf> {
if !log_dir.is_dir() {
return None;
}
let mut best: Option<(PathBuf, std::time::SystemTime)> = None;
for entry in std::fs::read_dir(log_dir).ok()?.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("log") {
continue;
}
let modified = match entry.metadata().and_then(|m| m.modified()) {
Ok(t) => t,
Err(_) => continue,
};
if best.as_ref().is_none_or(|(_, t)| modified > *t) {
best = Some((path, modified));
}
}
best.map(|(p, _)| p)
}
/// Read the last `n` non-empty lines from a file as a single string.
fn read_log_tail(path: &Path, n: usize) -> String {
let contents = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return String::new(),
};
let lines: Vec<&str> = contents.lines().filter(|l| !l.trim().is_empty()).collect();
let start = lines.len().saturating_sub(n);
lines[start..].join("\n")
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::agents::AgentPool;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use super::super::{CommandDispatch, try_handle_command};
fn whatsup_cmd(root: &Path, args: &str) -> Option<String> {
let agents = Arc::new(AgentPool::new_test(3000));
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
bot_name: "Timmy",
bot_user_id: "@timmy:homeserver.local",
project_root: root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
try_handle_command(&dispatch, &format!("@timmy whatsup {args}"))
}
fn write_story_file(root: &Path, stage: &str, filename: &str, content: &str) {
let dir = root.join(".storkit/work").join(stage);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(filename), content).unwrap();
}
// -- registration -------------------------------------------------------
#[test]
fn whatsup_command_is_registered() {
let found = super::super::commands().iter().any(|c| c.name == "whatsup");
assert!(found, "whatsup command must be in the registry");
}
#[test]
fn whatsup_command_appears_in_help() {
let result = super::super::tests::try_cmd_addressed(
"Timmy",
"@timmy:homeserver.local",
"@timmy help",
);
let output = result.unwrap();
assert!(
output.contains("whatsup"),
"help should list whatsup command: {output}"
);
}
// -- input validation ---------------------------------------------------
#[test]
fn whatsup_no_args_returns_usage() {
let tmp = tempfile::TempDir::new().unwrap();
let output = whatsup_cmd(tmp.path(), "").unwrap();
assert!(
output.contains("Usage"),
"no args should show usage: {output}"
);
}
#[test]
fn whatsup_non_numeric_returns_error() {
let tmp = tempfile::TempDir::new().unwrap();
let output = whatsup_cmd(tmp.path(), "abc").unwrap();
assert!(
output.contains("Invalid"),
"non-numeric arg should return error: {output}"
);
}
// -- not found ----------------------------------------------------------
#[test]
fn whatsup_story_not_in_current_returns_friendly_message() {
let tmp = tempfile::TempDir::new().unwrap();
// Create the directory but put the story in backlog, not current
write_story_file(
tmp.path(),
"1_backlog",
"42_story_not_in_current.md",
"---\nname: Not in current\n---\n",
);
let output = whatsup_cmd(tmp.path(), "42").unwrap();
assert!(
output.contains("42"),
"message should include story number: {output}"
);
assert!(
output.contains("not") || output.contains("Not"),
"message should say not found/in progress: {output}"
);
}
// -- found in 2_current -------------------------------------------------
#[test]
fn whatsup_shows_story_name_and_stage() {
let tmp = tempfile::TempDir::new().unwrap();
write_story_file(
tmp.path(),
"2_current",
"99_story_my_feature.md",
"---\nname: My Feature\n---\n\n## Acceptance Criteria\n\n- [ ] First thing\n- [x] Done thing\n",
);
let output = whatsup_cmd(tmp.path(), "99").unwrap();
assert!(output.contains("99"), "should show story number: {output}");
assert!(
output.contains("My Feature"),
"should show story name: {output}"
);
assert!(
output.contains("In Progress") || output.contains("2_current"),
"should show pipeline stage: {output}"
);
}
#[test]
fn whatsup_shows_acceptance_criteria() {
let tmp = tempfile::TempDir::new().unwrap();
write_story_file(
tmp.path(),
"2_current",
"99_story_criteria_test.md",
"---\nname: Criteria Test\n---\n\n- [ ] First thing\n- [x] Done thing\n- [ ] Second thing\n",
);
let output = whatsup_cmd(tmp.path(), "99").unwrap();
assert!(
output.contains("First thing"),
"should show unchecked criterion: {output}"
);
assert!(
output.contains("Done thing"),
"should show checked criterion: {output}"
);
// 1 of 3 done
assert!(
output.contains("1/3"),
"should show checked/total count: {output}"
);
}
#[test]
fn whatsup_shows_blocked_field() {
let tmp = tempfile::TempDir::new().unwrap();
write_story_file(
tmp.path(),
"2_current",
"55_story_blocked_story.md",
"---\nname: Blocked Story\nblocked: true\n---\n",
);
let output = whatsup_cmd(tmp.path(), "55").unwrap();
assert!(
output.contains("blocked"),
"should show blocked field: {output}"
);
}
#[test]
fn whatsup_shows_agent_field() {
let tmp = tempfile::TempDir::new().unwrap();
write_story_file(
tmp.path(),
"2_current",
"55_story_agent_story.md",
"---\nname: Agent Story\nagent: coder-1\n---\n",
);
let output = whatsup_cmd(tmp.path(), "55").unwrap();
assert!(
output.contains("coder-1"),
"should show agent field: {output}"
);
}
#[test]
fn whatsup_no_worktree_shows_not_created() {
let tmp = tempfile::TempDir::new().unwrap();
write_story_file(
tmp.path(),
"2_current",
"77_story_no_worktree.md",
"---\nname: No Worktree\n---\n",
);
let output = whatsup_cmd(tmp.path(), "77").unwrap();
// Branch name should still appear
assert!(
output.contains("feature/story-77"),
"should show branch name: {output}"
);
}
#[test]
fn whatsup_no_log_shows_no_log_message() {
let tmp = tempfile::TempDir::new().unwrap();
write_story_file(
tmp.path(),
"2_current",
"77_story_no_log.md",
"---\nname: No Log\n---\n",
);
let output = whatsup_cmd(tmp.path(), "77").unwrap();
assert!(
output.contains("no log") || output.contains("No log") || output.contains("*(no log found)*"),
"should indicate no log exists: {output}"
);
}
// -- parse_acceptance_criteria ------------------------------------------
#[test]
fn parse_criteria_mixed() {
let input = "## AC\n- [ ] First\n- [x] Done\n- [X] Also done\n- [ ] Last\n";
let result = parse_acceptance_criteria(input);
assert_eq!(result.len(), 4);
assert_eq!(result[0], (false, "First".to_string()));
assert_eq!(result[1], (true, "Done".to_string()));
assert_eq!(result[2], (true, "Also done".to_string()));
assert_eq!(result[3], (false, "Last".to_string()));
}
#[test]
fn parse_criteria_empty() {
let input = "# Story\nNo checkboxes here.\n";
let result = parse_acceptance_criteria(input);
assert!(result.is_empty());
}
// -- read_log_tail -------------------------------------------------------
#[test]
fn read_log_tail_returns_last_n_lines() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("test.log");
let content = (1..=30).map(|i| format!("line {i}")).collect::<Vec<_>>().join("\n");
std::fs::write(&path, &content).unwrap();
let tail = read_log_tail(&path, 5);
let lines: Vec<&str> = tail.lines().collect();
assert_eq!(lines.len(), 5);
assert_eq!(lines[0], "line 26");
assert_eq!(lines[4], "line 30");
}
#[test]
fn read_log_tail_fewer_lines_than_n() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("short.log");
std::fs::write(&path, "line A\nline B\n").unwrap();
let tail = read_log_tail(&path, 20);
assert!(tail.contains("line A"));
assert!(tail.contains("line B"));
}
// -- latest_log_file ----------------------------------------------------
#[test]
fn latest_log_file_returns_none_for_missing_dir() {
let tmp = tempfile::TempDir::new().unwrap();
let result = latest_log_file(&tmp.path().join("nonexistent"));
assert!(result.is_none());
}
#[test]
fn latest_log_file_finds_log() {
let tmp = tempfile::TempDir::new().unwrap();
let log_path = tmp.path().join("coder-1-sess-abc.log");
std::fs::write(&log_path, "some log content\n").unwrap();
let result = latest_log_file(tmp.path());
assert!(result.is_some());
assert_eq!(result.unwrap(), log_path);
}
}

815
server/src/matrix/config.rs Normal file
View File

@@ -0,0 +1,815 @@
use serde::Deserialize;
use std::path::Path;
fn default_history_size() -> usize {
20
}
fn default_permission_timeout_secs() -> u64 {
120
}
/// Configuration for the Matrix bot, read from `.storkit/bot.toml`.
#[derive(Deserialize, Clone, Debug)]
pub struct BotConfig {
/// Matrix homeserver URL, e.g. `https://matrix.example.com`
pub homeserver: String,
/// Bot user ID, e.g. `@storykit:example.com`
pub username: String,
/// Bot password
pub password: String,
/// Matrix room IDs to join, e.g. `["!roomid:example.com"]`.
/// Use an array for multiple rooms; a single string is accepted via the
/// deprecated `room_id` key for backwards compatibility.
#[serde(default)]
pub room_ids: Vec<String>,
/// Deprecated: use `room_ids` (list) instead. Still accepted so existing
/// `bot.toml` files continue to work without modification.
#[serde(default)]
pub room_id: Option<String>,
/// Set to `true` to enable the bot (default: false)
#[serde(default)]
pub enabled: bool,
/// Matrix user IDs allowed to interact with the bot.
/// If empty or omitted, the bot ignores ALL messages (fail-closed).
#[serde(default)]
pub allowed_users: Vec<String>,
/// Maximum number of conversation turns (user + assistant pairs) to keep
/// per room. When the history exceeds this limit the oldest messages are
/// dropped. Defaults to 20.
#[serde(default = "default_history_size")]
pub history_size: usize,
/// Timeout in seconds for permission prompts surfaced to the Matrix room.
/// If the user does not respond within this window the permission is denied
/// (fail-closed). Defaults to 120 seconds.
#[serde(default = "default_permission_timeout_secs")]
pub permission_timeout_secs: u64,
/// Previously used to select an Anthropic model. Now ignored — the bot
/// uses Claude Code which manages its own model selection. Kept for
/// backwards compatibility so existing bot.toml files still parse.
#[allow(dead_code)]
pub model: Option<String>,
/// Display name the bot uses to identify itself in conversations.
/// If unset, the bot falls back to "Assistant".
#[serde(default)]
pub display_name: Option<String>,
/// Room IDs where ambient mode is active (bot responds to all messages).
/// Updated at runtime when the user toggles ambient mode — do not edit
/// manually while the bot is running.
#[serde(default)]
pub ambient_rooms: Vec<String>,
/// Chat transport to use: `"matrix"` (default) or `"whatsapp"`.
///
/// Selects which [`ChatTransport`] implementation the bot uses for
/// sending and editing messages. Currently only read during bot
/// startup to select the transport; the field is kept for config
/// round-tripping.
#[serde(default = "default_transport")]
pub transport: String,
// ── WhatsApp Business API fields ─────────────────────────────────
// These are only required when `transport = "whatsapp"`.
/// WhatsApp Business phone number ID from the Meta dashboard.
#[serde(default)]
pub whatsapp_phone_number_id: Option<String>,
/// Long-lived access token for the WhatsApp Business API.
#[serde(default)]
pub whatsapp_access_token: Option<String>,
/// Verify token used in the webhook handshake (you choose this value
/// and configure it in the Meta webhook settings).
#[serde(default)]
pub whatsapp_verify_token: Option<String>,
/// Name of the approved Meta message template used for pipeline
/// notifications when the 24-hour messaging window has expired.
///
/// The template must be registered in the Meta Business Manager before
/// use. Defaults to `"pipeline_notification"`.
#[serde(default)]
pub whatsapp_notification_template: Option<String>,
// ── Slack Bot API fields ─────────────────────────────────────────
// These are only required when `transport = "slack"`.
/// Slack Bot User OAuth Token (starts with `xoxb-`).
#[serde(default)]
pub slack_bot_token: Option<String>,
/// Slack Signing Secret used to verify incoming webhook requests.
#[serde(default)]
pub slack_signing_secret: Option<String>,
/// Slack channel IDs the bot should listen in.
#[serde(default)]
pub slack_channel_ids: Vec<String>,
}
fn default_transport() -> String {
"matrix".to_string()
}
impl BotConfig {
/// Load bot configuration from `.storkit/bot.toml`.
///
/// Returns `None` if the file does not exist, fails to parse, has
/// `enabled = false`, or specifies no room IDs.
pub fn load(project_root: &Path) -> Option<Self> {
let path = project_root.join(".storkit").join("bot.toml");
if !path.exists() {
return None;
}
let content = std::fs::read_to_string(&path)
.map_err(|e| eprintln!("[matrix-bot] Failed to read bot.toml: {e}"))
.ok()?;
let mut config: BotConfig = toml::from_str(&content)
.map_err(|e| eprintln!("[matrix-bot] Invalid bot.toml: {e}"))
.ok()?;
if !config.enabled {
return None;
}
// Merge deprecated `room_id` (single string) into `room_ids` (list).
if let Some(single) = config.room_id.take()
&& !config.room_ids.contains(&single)
{
config.room_ids.push(single);
}
if config.transport == "whatsapp" {
// Validate WhatsApp-specific fields.
if config.whatsapp_phone_number_id.as_ref().is_none_or(|s| s.is_empty()) {
eprintln!(
"[bot] bot.toml: transport=\"whatsapp\" requires \
whatsapp_phone_number_id"
);
return None;
}
if config.whatsapp_access_token.as_ref().is_none_or(|s| s.is_empty()) {
eprintln!(
"[bot] bot.toml: transport=\"whatsapp\" requires \
whatsapp_access_token"
);
return None;
}
if config.whatsapp_verify_token.as_ref().is_none_or(|s| s.is_empty()) {
eprintln!(
"[bot] bot.toml: transport=\"whatsapp\" requires \
whatsapp_verify_token"
);
return None;
}
} else if config.transport == "slack" {
// Validate Slack-specific fields.
if config.slack_bot_token.as_ref().is_none_or(|s| s.is_empty()) {
eprintln!(
"[bot] bot.toml: transport=\"slack\" requires \
slack_bot_token"
);
return None;
}
if config.slack_signing_secret.as_ref().is_none_or(|s| s.is_empty()) {
eprintln!(
"[bot] bot.toml: transport=\"slack\" requires \
slack_signing_secret"
);
return None;
}
if config.slack_channel_ids.is_empty() {
eprintln!(
"[bot] bot.toml: transport=\"slack\" requires \
at least one slack_channel_ids entry"
);
return None;
}
} else if config.room_ids.is_empty() {
eprintln!(
"[matrix-bot] bot.toml has no room_ids configured — \
add `room_ids = [\"!roomid:example.com\"]` to bot.toml"
);
return None;
}
Some(config)
}
/// Returns all configured room IDs as a flat list. Combines `room_ids`
/// and (after loading) any merged `room_id` value.
pub fn effective_room_ids(&self) -> &[String] {
&self.room_ids
}
}
/// Persist the current set of ambient room IDs back to `bot.toml`.
///
/// Reads the existing file as a TOML document, updates the `ambient_rooms`
/// array, and writes the result back. Errors are logged but not propagated
/// so a persistence failure never interrupts the bot's message handling.
pub fn save_ambient_rooms(project_root: &Path, room_ids: &[String]) {
let path = project_root.join(".storkit").join("bot.toml");
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
eprintln!("[matrix-bot] save_ambient_rooms: failed to read bot.toml: {e}");
return;
}
};
let mut doc: toml::Value = match toml::from_str(&content) {
Ok(v) => v,
Err(e) => {
eprintln!("[matrix-bot] save_ambient_rooms: failed to parse bot.toml: {e}");
return;
}
};
if let toml::Value::Table(ref mut t) = doc {
let arr = toml::Value::Array(
room_ids
.iter()
.map(|s| toml::Value::String(s.clone()))
.collect(),
);
t.insert("ambient_rooms".to_string(), arr);
}
match toml::to_string_pretty(&doc) {
Ok(new_content) => {
if let Err(e) = std::fs::write(&path, new_content) {
eprintln!("[matrix-bot] save_ambient_rooms: failed to write bot.toml: {e}");
}
}
Err(e) => eprintln!("[matrix-bot] save_ambient_rooms: failed to serialise bot.toml: {e}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn load_returns_none_when_file_missing() {
let tmp = tempfile::tempdir().unwrap();
let result = BotConfig::load(tmp.path());
assert!(result.is_none());
}
#[test]
fn load_returns_none_when_disabled() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".storkit");
fs::create_dir_all(&sk).unwrap();
fs::write(
sk.join("bot.toml"),
r#"
homeserver = "https://matrix.example.com"
username = "@bot:example.com"
password = "secret"
room_ids = ["!abc:example.com"]
enabled = false
"#,
)
.unwrap();
let result = BotConfig::load(tmp.path());
assert!(result.is_none());
}
#[test]
fn load_returns_config_when_enabled_with_room_ids() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".storkit");
fs::create_dir_all(&sk).unwrap();
fs::write(
sk.join("bot.toml"),
r#"
homeserver = "https://matrix.example.com"
username = "@bot:example.com"
password = "secret"
room_ids = ["!abc:example.com", "!def:example.com"]
enabled = true
"#,
)
.unwrap();
let result = BotConfig::load(tmp.path());
assert!(result.is_some());
let config = result.unwrap();
assert_eq!(config.homeserver, "https://matrix.example.com");
assert_eq!(config.username, "@bot:example.com");
assert_eq!(
config.effective_room_ids(),
&["!abc:example.com", "!def:example.com"]
);
assert!(config.model.is_none());
}
#[test]
fn load_merges_deprecated_room_id_into_room_ids() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".storkit");
fs::create_dir_all(&sk).unwrap();
// Old-style single room_id key — should still work.
fs::write(
sk.join("bot.toml"),
r#"
homeserver = "https://matrix.example.com"
username = "@bot:example.com"
password = "secret"
room_id = "!abc:example.com"
enabled = true
"#,
)
.unwrap();
let config = BotConfig::load(tmp.path()).unwrap();
assert_eq!(config.effective_room_ids(), &["!abc:example.com"]);
}
#[test]
fn load_returns_none_when_no_room_ids() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".storkit");
fs::create_dir_all(&sk).unwrap();
fs::write(
sk.join("bot.toml"),
r#"
homeserver = "https://matrix.example.com"
username = "@bot:example.com"
password = "secret"
enabled = true
"#,
)
.unwrap();
let result = BotConfig::load(tmp.path());
assert!(result.is_none());
}
#[test]
fn load_returns_none_when_toml_invalid() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".storkit");
fs::create_dir_all(&sk).unwrap();
fs::write(sk.join("bot.toml"), "not valid toml {{{").unwrap();
let result = BotConfig::load(tmp.path());
assert!(result.is_none());
}
#[test]
fn load_respects_optional_model() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".storkit");
fs::create_dir_all(&sk).unwrap();
fs::write(
sk.join("bot.toml"),
r#"
homeserver = "https://matrix.example.com"
username = "@bot:example.com"
password = "secret"
room_ids = ["!abc:example.com"]
enabled = true
model = "claude-sonnet-4-6"
"#,
)
.unwrap();
let config = BotConfig::load(tmp.path()).unwrap();
assert_eq!(config.model.as_deref(), Some("claude-sonnet-4-6"));
}
#[test]
fn load_uses_default_history_size() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".storkit");
fs::create_dir_all(&sk).unwrap();
fs::write(
sk.join("bot.toml"),
r#"
homeserver = "https://matrix.example.com"
username = "@bot:example.com"
password = "secret"
room_ids = ["!abc:example.com"]
enabled = true
"#,
)
.unwrap();
let config = BotConfig::load(tmp.path()).unwrap();
assert_eq!(config.history_size, 20);
}
#[test]
fn load_respects_custom_history_size() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".storkit");
fs::create_dir_all(&sk).unwrap();
fs::write(
sk.join("bot.toml"),
r#"
homeserver = "https://matrix.example.com"
username = "@bot:example.com"
password = "secret"
room_ids = ["!abc:example.com"]
enabled = true
history_size = 50
"#,
)
.unwrap();
let config = BotConfig::load(tmp.path()).unwrap();
assert_eq!(config.history_size, 50);
}
#[test]
fn load_reads_display_name() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".storkit");
fs::create_dir_all(&sk).unwrap();
fs::write(
sk.join("bot.toml"),
r#"
homeserver = "https://matrix.example.com"
username = "@bot:example.com"
password = "secret"
room_ids = ["!abc:example.com"]
enabled = true
display_name = "Timmy"
"#,
)
.unwrap();
let config = BotConfig::load(tmp.path()).unwrap();
assert_eq!(config.display_name.as_deref(), Some("Timmy"));
}
#[test]
fn load_display_name_defaults_to_none_when_absent() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".storkit");
fs::create_dir_all(&sk).unwrap();
fs::write(
sk.join("bot.toml"),
r#"
homeserver = "https://matrix.example.com"
username = "@bot:example.com"
password = "secret"
room_ids = ["!abc:example.com"]
enabled = true
"#,
)
.unwrap();
let config = BotConfig::load(tmp.path()).unwrap();
assert!(config.display_name.is_none());
}
#[test]
fn load_uses_default_permission_timeout() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".storkit");
fs::create_dir_all(&sk).unwrap();
fs::write(
sk.join("bot.toml"),
r#"
homeserver = "https://matrix.example.com"
username = "@bot:example.com"
password = "secret"
room_ids = ["!abc:example.com"]
enabled = true
"#,
)
.unwrap();
let config = BotConfig::load(tmp.path()).unwrap();
assert_eq!(config.permission_timeout_secs, 120);
}
#[test]
fn load_respects_custom_permission_timeout() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".storkit");
fs::create_dir_all(&sk).unwrap();
fs::write(
sk.join("bot.toml"),
r#"
homeserver = "https://matrix.example.com"
username = "@bot:example.com"
password = "secret"
room_ids = ["!abc:example.com"]
enabled = true
permission_timeout_secs = 60
"#,
)
.unwrap();
let config = BotConfig::load(tmp.path()).unwrap();
assert_eq!(config.permission_timeout_secs, 60);
}
#[test]
fn load_ignores_legacy_require_verified_devices_key() {
// Old bot.toml files that still have `require_verified_devices = true`
// must parse successfully — the field is simply ignored now that
// verification is always enforced unconditionally.
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".storkit");
fs::create_dir_all(&sk).unwrap();
fs::write(
sk.join("bot.toml"),
r#"
homeserver = "https://matrix.example.com"
username = "@bot:example.com"
password = "secret"
room_ids = ["!abc:example.com"]
enabled = true
require_verified_devices = true
"#,
)
.unwrap();
// Should still load successfully despite the unknown field.
let config = BotConfig::load(tmp.path());
assert!(
config.is_some(),
"bot.toml with legacy require_verified_devices key must still load"
);
}
#[test]
fn load_reads_ambient_rooms() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".storkit");
fs::create_dir_all(&sk).unwrap();
fs::write(
sk.join("bot.toml"),
r#"
homeserver = "https://matrix.example.com"
username = "@bot:example.com"
password = "secret"
room_ids = ["!abc:example.com"]
enabled = true
ambient_rooms = ["!abc:example.com"]
"#,
)
.unwrap();
let config = BotConfig::load(tmp.path()).unwrap();
assert_eq!(config.ambient_rooms, vec!["!abc:example.com"]);
}
#[test]
fn load_ambient_rooms_defaults_to_empty_when_absent() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".storkit");
fs::create_dir_all(&sk).unwrap();
fs::write(
sk.join("bot.toml"),
r#"
homeserver = "https://matrix.example.com"
username = "@bot:example.com"
password = "secret"
room_ids = ["!abc:example.com"]
enabled = true
"#,
)
.unwrap();
let config = BotConfig::load(tmp.path()).unwrap();
assert!(config.ambient_rooms.is_empty());
}
#[test]
fn save_ambient_rooms_persists_to_bot_toml() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".storkit");
fs::create_dir_all(&sk).unwrap();
fs::write(
sk.join("bot.toml"),
r#"homeserver = "https://matrix.example.com"
username = "@bot:example.com"
password = "secret"
room_ids = ["!abc:example.com"]
enabled = true
"#,
)
.unwrap();
save_ambient_rooms(tmp.path(), &["!abc:example.com".to_string()]);
let config = BotConfig::load(tmp.path()).unwrap();
assert_eq!(config.ambient_rooms, vec!["!abc:example.com"]);
}
#[test]
fn save_ambient_rooms_clears_when_empty() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".storkit");
fs::create_dir_all(&sk).unwrap();
fs::write(
sk.join("bot.toml"),
r#"homeserver = "https://matrix.example.com"
username = "@bot:example.com"
password = "secret"
room_ids = ["!abc:example.com"]
enabled = true
ambient_rooms = ["!abc:example.com"]
"#,
)
.unwrap();
save_ambient_rooms(tmp.path(), &[]);
let config = BotConfig::load(tmp.path()).unwrap();
assert!(config.ambient_rooms.is_empty());
}
#[test]
fn load_transport_defaults_to_matrix() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".storkit");
fs::create_dir_all(&sk).unwrap();
fs::write(
sk.join("bot.toml"),
r#"
homeserver = "https://matrix.example.com"
username = "@bot:example.com"
password = "secret"
room_ids = ["!abc:example.com"]
enabled = true
"#,
)
.unwrap();
let config = BotConfig::load(tmp.path()).unwrap();
assert_eq!(config.transport, "matrix");
}
#[test]
fn load_transport_reads_custom_value() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".storkit");
fs::create_dir_all(&sk).unwrap();
fs::write(
sk.join("bot.toml"),
r#"
homeserver = "https://matrix.example.com"
username = "@bot:example.com"
password = "secret"
room_ids = ["!abc:example.com"]
enabled = true
transport = "whatsapp"
whatsapp_phone_number_id = "123456"
whatsapp_access_token = "EAAtoken"
whatsapp_verify_token = "my-verify"
"#,
)
.unwrap();
let config = BotConfig::load(tmp.path()).unwrap();
assert_eq!(config.transport, "whatsapp");
assert_eq!(
config.whatsapp_phone_number_id.as_deref(),
Some("123456")
);
assert_eq!(
config.whatsapp_access_token.as_deref(),
Some("EAAtoken")
);
assert_eq!(
config.whatsapp_verify_token.as_deref(),
Some("my-verify")
);
}
#[test]
fn load_whatsapp_returns_none_when_missing_phone_number_id() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".storkit");
fs::create_dir_all(&sk).unwrap();
fs::write(
sk.join("bot.toml"),
r#"
homeserver = "https://matrix.example.com"
username = "@bot:example.com"
password = "secret"
enabled = true
transport = "whatsapp"
whatsapp_access_token = "EAAtoken"
whatsapp_verify_token = "my-verify"
"#,
)
.unwrap();
assert!(BotConfig::load(tmp.path()).is_none());
}
#[test]
fn load_whatsapp_returns_none_when_missing_access_token() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".storkit");
fs::create_dir_all(&sk).unwrap();
fs::write(
sk.join("bot.toml"),
r#"
homeserver = "https://matrix.example.com"
username = "@bot:example.com"
password = "secret"
enabled = true
transport = "whatsapp"
whatsapp_phone_number_id = "123456"
whatsapp_verify_token = "my-verify"
"#,
)
.unwrap();
assert!(BotConfig::load(tmp.path()).is_none());
}
#[test]
fn load_whatsapp_returns_none_when_missing_verify_token() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".storkit");
fs::create_dir_all(&sk).unwrap();
fs::write(
sk.join("bot.toml"),
r#"
homeserver = "https://matrix.example.com"
username = "@bot:example.com"
password = "secret"
enabled = true
transport = "whatsapp"
whatsapp_phone_number_id = "123456"
whatsapp_access_token = "EAAtoken"
"#,
)
.unwrap();
assert!(BotConfig::load(tmp.path()).is_none());
}
// ── Slack config tests ─────────────────────────────────────────────
#[test]
fn load_slack_transport_reads_config() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".storkit");
fs::create_dir_all(&sk).unwrap();
fs::write(
sk.join("bot.toml"),
r#"
homeserver = "https://matrix.example.com"
username = "@bot:example.com"
password = "secret"
enabled = true
transport = "slack"
slack_bot_token = "xoxb-123"
slack_signing_secret = "secret123"
slack_channel_ids = ["C01ABCDEF"]
"#,
)
.unwrap();
let config = BotConfig::load(tmp.path()).unwrap();
assert_eq!(config.transport, "slack");
assert_eq!(config.slack_bot_token.as_deref(), Some("xoxb-123"));
assert_eq!(config.slack_signing_secret.as_deref(), Some("secret123"));
assert_eq!(config.slack_channel_ids, vec!["C01ABCDEF"]);
}
#[test]
fn load_slack_returns_none_when_missing_bot_token() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".storkit");
fs::create_dir_all(&sk).unwrap();
fs::write(
sk.join("bot.toml"),
r#"
homeserver = "https://matrix.example.com"
username = "@bot:example.com"
password = "secret"
enabled = true
transport = "slack"
slack_signing_secret = "secret123"
slack_channel_ids = ["C01ABCDEF"]
"#,
)
.unwrap();
assert!(BotConfig::load(tmp.path()).is_none());
}
#[test]
fn load_slack_returns_none_when_missing_signing_secret() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".storkit");
fs::create_dir_all(&sk).unwrap();
fs::write(
sk.join("bot.toml"),
r#"
homeserver = "https://matrix.example.com"
username = "@bot:example.com"
password = "secret"
enabled = true
transport = "slack"
slack_bot_token = "xoxb-123"
slack_channel_ids = ["C01ABCDEF"]
"#,
)
.unwrap();
assert!(BotConfig::load(tmp.path()).is_none());
}
#[test]
fn load_slack_returns_none_when_missing_channel_ids() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".storkit");
fs::create_dir_all(&sk).unwrap();
fs::write(
sk.join("bot.toml"),
r#"
homeserver = "https://matrix.example.com"
username = "@bot:example.com"
password = "secret"
enabled = true
transport = "slack"
slack_bot_token = "xoxb-123"
slack_signing_secret = "secret123"
"#,
)
.unwrap();
assert!(BotConfig::load(tmp.path()).is_none());
}
}

384
server/src/matrix/delete.rs Normal file
View File

@@ -0,0 +1,384 @@
//! Delete command: remove a story/bug/spike from the pipeline.
//!
//! `{bot_name} delete {number}` finds the work item by number across all pipeline
//! stages, stops any running agent, removes the worktree, deletes the file, and
//! commits the change to git.
use crate::agents::{AgentPool, AgentStatus};
use std::path::Path;
/// A parsed delete command from a Matrix message body.
#[derive(Debug, PartialEq)]
pub enum DeleteCommand {
/// Delete the story with this number (digits only, e.g. `"42"`).
Delete { story_number: String },
/// The user typed `delete` but without a valid numeric argument.
BadArgs,
}
/// Parse a delete command from a raw Matrix message body.
///
/// Strips the bot mention prefix and checks whether the first word is `delete`.
/// Returns `None` when the message is not a delete command at all.
pub fn extract_delete_command(
message: &str,
bot_name: &str,
bot_user_id: &str,
) -> Option<DeleteCommand> {
let stripped = strip_mention(message, bot_name, bot_user_id);
let trimmed = stripped
.trim()
.trim_start_matches(|c: char| !c.is_alphanumeric());
let (cmd, args) = match trimmed.split_once(char::is_whitespace) {
Some((c, a)) => (c, a.trim()),
None => (trimmed, ""),
};
if !cmd.eq_ignore_ascii_case("delete") {
return None;
}
if !args.is_empty() && args.chars().all(|c| c.is_ascii_digit()) {
Some(DeleteCommand::Delete {
story_number: args.to_string(),
})
} else {
Some(DeleteCommand::BadArgs)
}
}
/// Handle a delete command asynchronously.
///
/// Finds the work item by `story_number` across all pipeline stages, stops any
/// running agent, removes the worktree, deletes the file, and commits to git.
/// Returns a markdown-formatted response string.
pub async fn handle_delete(
bot_name: &str,
story_number: &str,
project_root: &Path,
agents: &AgentPool,
) -> String {
const STAGES: &[&str] = &[
"1_backlog",
"2_current",
"3_qa",
"4_merge",
"5_done",
"6_archived",
];
// Find the story file across all pipeline stages.
let mut found: Option<(std::path::PathBuf, &str, String)> = None; // (path, stage, story_id)
'outer: for stage in STAGES {
let dir = project_root.join(".storkit").join("work").join(stage);
if !dir.exists() {
continue;
}
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())
.map(|s| s.to_string())
{
let file_num = stem
.split('_')
.next()
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
.unwrap_or("")
.to_string();
if file_num == story_number {
found = Some((path, stage, stem));
break 'outer;
}
}
}
}
}
let (path, stage, story_id) = match found {
Some(f) => f,
None => {
return format!("No story, bug, or spike with number **{story_number}** found.");
}
};
// Read the human-readable name from front matter for the confirmation message.
let story_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)
})
.unwrap_or_else(|| story_id.clone());
// Stop any running or pending agents for this story.
let running_agents: Vec<(String, String)> = agents
.list_agents()
.unwrap_or_default()
.into_iter()
.filter(|a| {
a.story_id == story_id
&& matches!(a.status, AgentStatus::Running | AgentStatus::Pending)
})
.map(|a| (a.story_id.clone(), a.agent_name.clone()))
.collect();
let mut stopped_agents: Vec<String> = Vec::new();
for (sid, agent_name) in &running_agents {
if let Err(e) = agents.stop_agent(project_root, sid, agent_name).await {
return format!("Failed to stop agent '{agent_name}' for story {story_number}: {e}");
}
stopped_agents.push(agent_name.clone());
}
// Remove the worktree if one exists (best-effort; ignore errors).
let _ = crate::worktree::prune_worktree_sync(project_root, &story_id);
// Delete the story file.
if let Err(e) = std::fs::remove_file(&path) {
return format!("Failed to delete story {story_number}: {e}");
}
// Commit the deletion to git.
let commit_msg = format!("storkit: delete {story_id}");
let work_rel = std::path::PathBuf::from(".storkit").join("work");
let _ = std::process::Command::new("git")
.args(["add", "-A"])
.arg(&work_rel)
.current_dir(project_root)
.output();
let _ = std::process::Command::new("git")
.args(["commit", "-m", &commit_msg])
.current_dir(project_root)
.output();
// Build the response.
let stage_label = stage_display_name(stage);
let mut response = format!("Deleted **{story_name}** from **{stage_label}**.");
if !stopped_agents.is_empty() {
let agent_list = stopped_agents.join(", ");
response.push_str(&format!(" Stopped agent(s): {agent_list}."));
}
crate::slog!("[matrix-bot] delete command: removed {story_id} from {stage} (bot={bot_name})");
response
}
/// Human-readable label for a pipeline stage directory name.
fn stage_display_name(stage: &str) -> &str {
match stage {
"1_backlog" => "backlog",
"2_current" => "in-progress",
"3_qa" => "QA",
"4_merge" => "merge",
"5_done" => "done",
"6_archived" => "archived",
other => other,
}
}
/// Strip the bot mention prefix from a raw Matrix message body.
///
/// Mirrors the logic in `commands::strip_bot_mention` and `htop::strip_mention`
/// so delete detection works without depending on private symbols.
fn strip_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) -> &'a str {
let trimmed = message.trim();
if let Some(rest) = strip_prefix_ci(trimmed, bot_user_id) {
return rest;
}
if let Some(localpart) = bot_user_id.split(':').next()
&& let Some(rest) = strip_prefix_ci(trimmed, localpart)
{
return rest;
}
if let Some(rest) = strip_prefix_ci(trimmed, bot_name) {
return rest;
}
trimmed
}
fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
if text.len() < prefix.len() {
return None;
}
if !text[..prefix.len()].eq_ignore_ascii_case(prefix) {
return None;
}
let rest = &text[prefix.len()..];
match rest.chars().next() {
None => Some(rest),
Some(c) if c.is_alphanumeric() || c == '-' || c == '_' => None,
_ => Some(rest),
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
// -- extract_delete_command ---------------------------------------------
#[test]
fn extract_with_full_user_id() {
let cmd =
extract_delete_command("@timmy:home.local delete 42", "Timmy", "@timmy:home.local");
assert_eq!(
cmd,
Some(DeleteCommand::Delete {
story_number: "42".to_string()
})
);
}
#[test]
fn extract_with_display_name() {
let cmd = extract_delete_command("Timmy delete 310", "Timmy", "@timmy:home.local");
assert_eq!(
cmd,
Some(DeleteCommand::Delete {
story_number: "310".to_string()
})
);
}
#[test]
fn extract_with_localpart() {
let cmd = extract_delete_command("@timmy delete 7", "Timmy", "@timmy:home.local");
assert_eq!(
cmd,
Some(DeleteCommand::Delete {
story_number: "7".to_string()
})
);
}
#[test]
fn extract_case_insensitive_command() {
let cmd = extract_delete_command("Timmy DELETE 99", "Timmy", "@timmy:home.local");
assert_eq!(
cmd,
Some(DeleteCommand::Delete {
story_number: "99".to_string()
})
);
}
#[test]
fn extract_no_args_is_bad_args() {
let cmd = extract_delete_command("Timmy delete", "Timmy", "@timmy:home.local");
assert_eq!(cmd, Some(DeleteCommand::BadArgs));
}
#[test]
fn extract_non_numeric_arg_is_bad_args() {
let cmd = extract_delete_command("Timmy delete foo", "Timmy", "@timmy:home.local");
assert_eq!(cmd, Some(DeleteCommand::BadArgs));
}
#[test]
fn extract_non_delete_command_returns_none() {
let cmd = extract_delete_command("Timmy help", "Timmy", "@timmy:home.local");
assert_eq!(cmd, None);
}
#[test]
fn extract_no_bot_prefix_returns_none() {
let cmd = extract_delete_command("delete 42", "Timmy", "@timmy:home.local");
// Without mention prefix the raw text is "delete 42" — cmd is "delete", args "42"
// strip_mention returns the full trimmed text when no prefix matches,
// so this is a valid delete command addressed to no-one (ambient mode).
assert_eq!(
cmd,
Some(DeleteCommand::Delete {
story_number: "42".to_string()
})
);
}
// -- handle_delete (integration-style, uses temp filesystem) -----------
#[tokio::test]
async fn handle_delete_returns_not_found_for_unknown_number() {
let tmp = tempfile::tempdir().unwrap();
let project_root = tmp.path();
// Create the pipeline directories.
for stage in &[
"1_backlog",
"2_current",
"3_qa",
"4_merge",
"5_done",
"6_archived",
] {
std::fs::create_dir_all(project_root.join(".storkit").join("work").join(stage))
.unwrap();
}
let agents = std::sync::Arc::new(crate::agents::AgentPool::new_test(3000));
let response = handle_delete("Timmy", "999", project_root, &agents).await;
assert!(
response.contains("No story") && response.contains("999"),
"unexpected response: {response}"
);
}
#[tokio::test]
async fn handle_delete_removes_story_file_and_confirms() {
let tmp = tempfile::tempdir().unwrap();
let project_root = tmp.path();
// Init a bare git repo so the commit step doesn't fail fatally.
std::process::Command::new("git")
.args(["init"])
.current_dir(project_root)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(project_root)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(project_root)
.output()
.unwrap();
let backlog_dir = project_root.join(".storkit").join("work").join("1_backlog");
std::fs::create_dir_all(&backlog_dir).unwrap();
let story_path = backlog_dir.join("42_story_some_feature.md");
std::fs::write(&story_path, "---\nname: Some Feature\n---\n\n# Story 42\n").unwrap();
// Initial commit so git doesn't complain about no commits.
std::process::Command::new("git")
.args(["add", "-A"])
.current_dir(project_root)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(project_root)
.output()
.unwrap();
let agents = std::sync::Arc::new(crate::agents::AgentPool::new_test(3000));
let response = handle_delete("Timmy", "42", project_root, &agents).await;
assert!(
response.contains("Some Feature") && response.contains("backlog"),
"unexpected response: {response}"
);
assert!(!story_path.exists(), "story file should have been deleted");
}
}

596
server/src/matrix/htop.rs Normal file
View File

@@ -0,0 +1,596 @@
//! htop command: live-updating system and agent process dashboard.
//!
//! Sends an initial message to a Matrix room showing load average and
//! per-agent process info, then edits it in-place every 5 seconds using
//! Matrix replacement events. A single htop session per room is enforced;
//! a new `htop` invocation stops any existing session and starts a fresh one.
//! Sessions auto-stop after the configured duration (default 5 minutes).
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{Mutex as TokioMutex, watch};
use crate::agents::{AgentPool, AgentStatus};
use crate::slog;
use crate::transport::ChatTransport;
use super::bot::markdown_to_html;
/// A parsed htop command from a Matrix message body.
#[derive(Debug, PartialEq)]
pub enum HtopCommand {
/// Start (or restart) monitoring. `duration_secs` is the auto-stop
/// timeout; defaults to 300 (5 minutes).
Start { duration_secs: u64 },
/// Stop any active monitoring session for the room.
Stop,
}
/// Per-room htop session: holds the stop-signal sender so callers can cancel.
pub struct HtopSession {
/// Send `true` to request a graceful stop of the background loop.
pub stop_tx: watch::Sender<bool>,
}
/// Per-room htop session map type alias.
///
/// Keys are platform-agnostic room ID strings (e.g. `"!abc:example.com"` on
/// Matrix) so this type works with any [`ChatTransport`] implementation.
pub type HtopSessions = Arc<TokioMutex<HashMap<String, HtopSession>>>;
/// Parse an htop command from a raw Matrix message body.
///
/// Strips the bot mention prefix and checks whether the first word is `htop`.
/// Returns `None` when the message is not an htop command.
///
/// Recognised forms (after stripping the bot mention):
/// - `htop` → `Start { duration_secs: 300 }`
/// - `htop stop` → `Stop`
/// - `htop 10m` → `Start { duration_secs: 600 }`
/// - `htop 120` → `Start { duration_secs: 120 }` (bare seconds)
pub fn extract_htop_command(message: &str, bot_name: &str, bot_user_id: &str) -> Option<HtopCommand> {
let stripped = strip_mention(message, bot_name, bot_user_id);
let trimmed = stripped.trim();
// Strip leading punctuation (e.g. the comma in "@timmy, htop")
let trimmed = trimmed.trim_start_matches(|c: char| !c.is_alphanumeric());
let (cmd, args) = match trimmed.split_once(char::is_whitespace) {
Some((c, a)) => (c, a.trim()),
None => (trimmed, ""),
};
if !cmd.eq_ignore_ascii_case("htop") {
return None;
}
if args.eq_ignore_ascii_case("stop") {
return Some(HtopCommand::Stop);
}
let duration_secs = parse_duration(args).unwrap_or(300);
Some(HtopCommand::Start { duration_secs })
}
/// Parse an optional duration argument.
///
/// Accepts `""` (empty → `None`), `"5m"` / `"10M"` (minutes), or a bare
/// integer interpreted as seconds.
fn parse_duration(s: &str) -> Option<u64> {
if s.is_empty() {
return None;
}
if let Some(mins_str) = s.strip_suffix('m').or_else(|| s.strip_suffix('M')) {
return mins_str.parse::<u64>().ok().map(|m| m * 60);
}
s.parse::<u64>().ok()
}
/// Strip the bot mention prefix from a raw Matrix message body.
///
/// Mirrors the logic in `commands::strip_bot_mention` so htop detection works
/// without depending on private symbols in that module.
fn strip_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) -> &'a str {
let trimmed = message.trim();
if let Some(rest) = strip_prefix_ci(trimmed, bot_user_id) {
return rest;
}
if let Some(localpart) = bot_user_id.split(':').next()
&& let Some(rest) = strip_prefix_ci(trimmed, localpart)
{
return rest;
}
if let Some(rest) = strip_prefix_ci(trimmed, bot_name) {
return rest;
}
trimmed
}
fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
if text.len() < prefix.len() {
return None;
}
if !text[..prefix.len()].eq_ignore_ascii_case(prefix) {
return None;
}
let rest = &text[prefix.len()..];
match rest.chars().next() {
None => Some(rest),
Some(c) if c.is_alphanumeric() || c == '-' || c == '_' => None,
_ => Some(rest),
}
}
// ---------------------------------------------------------------------------
// System stats
// ---------------------------------------------------------------------------
/// Read the system load average using the `uptime` command.
///
/// Returns a short string like `"load average: 1.23, 0.98, 0.75"` on success,
/// or `"load: unknown"` on failure.
fn get_load_average() -> String {
let output = std::process::Command::new("uptime")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.unwrap_or_default();
// uptime output typically contains "load average: X, Y, Z" (Linux/macOS)
// or "load averages: X Y Z" (some BSD variants).
if let Some(idx) = output.find("load average") {
output[idx..].trim().trim_end_matches('\n').to_string()
} else {
"load: unknown".to_string()
}
}
/// Process stats for a single agent, gathered from `ps`.
#[derive(Debug, Default)]
struct AgentProcessStats {
cpu_pct: f64,
mem_pct: f64,
num_procs: usize,
}
/// Gather CPU% and MEM% for processes whose command line contains `worktree_path`.
///
/// Runs `ps aux` and sums all matching lines. Returns `None` when no
/// matching process is found.
fn gather_process_stats(worktree_path: &str) -> Option<AgentProcessStats> {
let output = std::process::Command::new("ps")
.args(["aux"])
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())?;
let mut stats = AgentProcessStats::default();
for line in output.lines().skip(1) {
// Avoid matching against our own status display (the ps command itself)
if !line.contains(worktree_path) {
continue;
}
let parts: Vec<&str> = line.split_whitespace().collect();
// ps aux columns: USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND...
if parts.len() >= 4
&& let (Ok(cpu), Ok(mem)) = (parts[2].parse::<f64>(), parts[3].parse::<f64>())
{
stats.cpu_pct += cpu;
stats.mem_pct += mem;
stats.num_procs += 1;
}
}
if stats.num_procs > 0 {
Some(stats)
} else {
None
}
}
// ---------------------------------------------------------------------------
// Message formatting
// ---------------------------------------------------------------------------
/// Build the Markdown text for the htop dashboard.
///
/// `tick` is the number of updates sent so far (0 = initial).
/// `total_duration_secs` is the configured auto-stop timeout.
///
/// Output uses a compact single-line format per agent so it renders
/// without wrapping on narrow screens (~40 chars), such as mobile
/// Matrix clients.
pub fn build_htop_message(agents: &AgentPool, tick: u32, total_duration_secs: u64) -> String {
let elapsed_secs = (tick as u64) * 5;
let remaining_secs = total_duration_secs.saturating_sub(elapsed_secs);
let remaining_mins = remaining_secs / 60;
let remaining_secs_rem = remaining_secs % 60;
let load = get_load_average();
let mut lines = vec![
format!(
"**htop** · auto-stops in {}m{}s",
remaining_mins, remaining_secs_rem
),
load,
String::new(),
];
let all_agents = agents.list_agents().unwrap_or_default();
let active: Vec<_> = all_agents
.iter()
.filter(|a| matches!(a.status, AgentStatus::Running | AgentStatus::Pending))
.collect();
if active.is_empty() {
lines.push("*No agents currently running.*".to_string());
} else {
for agent in &active {
let story_label = agent
.story_id
.split('_')
.next()
.unwrap_or(&agent.story_id)
.to_string();
let stats = agent
.worktree_path
.as_deref()
.and_then(gather_process_stats)
.unwrap_or_default();
lines.push(format!(
"**{}** #{} cpu:{:.1}% mem:{:.1}%",
agent.agent_name, story_label, stats.cpu_pct, stats.mem_pct,
));
}
}
lines.join("\n")
}
// ---------------------------------------------------------------------------
// Background monitoring loop
// ---------------------------------------------------------------------------
/// Run the htop background loop: update the message every 5 seconds until
/// the stop signal is received or the timeout expires.
///
/// Uses the [`ChatTransport`] abstraction so the loop works with any chat
/// platform, not just Matrix.
pub async fn run_htop_loop(
transport: Arc<dyn ChatTransport>,
room_id: String,
initial_message_id: String,
agents: Arc<AgentPool>,
mut stop_rx: watch::Receiver<bool>,
duration_secs: u64,
) {
let interval_secs: u64 = 5;
let max_ticks = (duration_secs / interval_secs).max(1);
for tick in 1..=max_ticks {
// Wait for the interval or a stop signal.
let sleep = tokio::time::sleep(Duration::from_secs(interval_secs));
tokio::pin!(sleep);
tokio::select! {
_ = &mut sleep => {}
Ok(()) = stop_rx.changed() => {
if *stop_rx.borrow() {
send_stopped_message(&*transport, &room_id, &initial_message_id).await;
return;
}
}
}
// Re-check after waking — the sender might have signalled while we slept.
if *stop_rx.borrow() {
send_stopped_message(&*transport, &room_id, &initial_message_id).await;
return;
}
let text = build_htop_message(&agents, tick as u32, duration_secs);
let html = markdown_to_html(&text);
if let Err(e) = transport.edit_message(&room_id, &initial_message_id, &text, &html).await {
slog!("[htop] Failed to update message: {e}");
return;
}
}
// Auto-stop: timeout reached.
send_stopped_message(&*transport, &room_id, &initial_message_id).await;
}
async fn send_stopped_message(transport: &dyn ChatTransport, room_id: &str, message_id: &str) {
let text = "**htop** — monitoring stopped.";
let html = markdown_to_html(text);
if let Err(e) = transport.edit_message(room_id, message_id, text, &html).await {
slog!("[htop] Failed to send stop message: {e}");
}
}
// ---------------------------------------------------------------------------
// Public command handlers (called from on_room_message in bot.rs)
// ---------------------------------------------------------------------------
/// Start a new htop monitoring session for `room_id`.
///
/// Stops any existing session for the room, sends the initial dashboard
/// message, and spawns a background task that edits it every 5 seconds.
///
/// Uses the [`ChatTransport`] abstraction so htop works with any platform.
pub async fn handle_htop_start(
transport: &Arc<dyn ChatTransport>,
room_id: &str,
htop_sessions: &HtopSessions,
agents: Arc<AgentPool>,
duration_secs: u64,
) {
// Stop any existing session (best-effort; ignore errors if already done).
stop_existing_session(htop_sessions, room_id).await;
// Send the initial message.
let initial_text = build_htop_message(&agents, 0, duration_secs);
let initial_html = markdown_to_html(&initial_text);
let message_id = match transport.send_message(room_id, &initial_text, &initial_html).await {
Ok(id) => id,
Err(e) => {
slog!("[htop] Failed to send initial message: {e}");
return;
}
};
// Create the stop channel and register the session.
let (stop_tx, stop_rx) = watch::channel(false);
{
let mut sessions = htop_sessions.lock().await;
sessions.insert(room_id.to_string(), HtopSession { stop_tx });
}
// Spawn the background update loop.
let transport_clone = Arc::clone(transport);
let sessions_clone = Arc::clone(htop_sessions);
let room_id_owned = room_id.to_string();
tokio::spawn(async move {
run_htop_loop(
transport_clone,
room_id_owned.clone(),
message_id,
agents,
stop_rx,
duration_secs,
)
.await;
// Clean up the session entry when the loop exits naturally.
let mut sessions = sessions_clone.lock().await;
sessions.remove(&room_id_owned);
});
}
/// Stop the active htop session for `room_id`, if any.
///
/// When there is no active session, sends a "no active session" reply
/// to the room so the user knows the command was received.
pub async fn handle_htop_stop(
transport: &dyn ChatTransport,
room_id: &str,
htop_sessions: &HtopSessions,
) {
let had_session = stop_existing_session(htop_sessions, room_id).await;
if !had_session {
let msg = "No active htop session in this room.";
let html = markdown_to_html(msg);
if let Err(e) = transport.send_message(room_id, msg, &html).await {
slog!("[htop] Failed to send no-session reply: {e}");
}
}
// When a session was active, the background task handles the final edit.
}
/// Signal and remove the existing session for `room_id`.
///
/// Returns `true` if a session was found and stopped.
async fn stop_existing_session(htop_sessions: &HtopSessions, room_id: &str) -> bool {
let mut sessions = htop_sessions.lock().await;
if let Some(session) = sessions.remove(room_id) {
// Signal the background task to stop (ignore error — task may be done).
let _ = session.stop_tx.send(true);
true
} else {
false
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
// -- extract_htop_command -----------------------------------------------
#[test]
fn htop_bare_command() {
let cmd = extract_htop_command("@timmy htop", "Timmy", "@timmy:homeserver.local");
assert_eq!(cmd, Some(HtopCommand::Start { duration_secs: 300 }));
}
#[test]
fn htop_with_display_name() {
let cmd = extract_htop_command("Timmy htop", "Timmy", "@timmy:homeserver.local");
assert_eq!(cmd, Some(HtopCommand::Start { duration_secs: 300 }));
}
#[test]
fn htop_stop() {
let cmd = extract_htop_command("@timmy htop stop", "Timmy", "@timmy:homeserver.local");
assert_eq!(cmd, Some(HtopCommand::Stop));
}
#[test]
fn htop_duration_minutes() {
let cmd = extract_htop_command("@timmy htop 10m", "Timmy", "@timmy:homeserver.local");
assert_eq!(cmd, Some(HtopCommand::Start { duration_secs: 600 }));
}
#[test]
fn htop_duration_uppercase_m() {
let cmd = extract_htop_command("@timmy htop 2M", "Timmy", "@timmy:homeserver.local");
assert_eq!(cmd, Some(HtopCommand::Start { duration_secs: 120 }));
}
#[test]
fn htop_duration_seconds() {
let cmd = extract_htop_command("@timmy htop 90", "Timmy", "@timmy:homeserver.local");
assert_eq!(cmd, Some(HtopCommand::Start { duration_secs: 90 }));
}
#[test]
fn non_htop_command_returns_none() {
let cmd = extract_htop_command("@timmy status", "Timmy", "@timmy:homeserver.local");
assert!(cmd.is_none());
}
#[test]
fn unrelated_message_returns_none() {
let cmd = extract_htop_command("hello world", "Timmy", "@timmy:homeserver.local");
assert!(cmd.is_none());
}
#[test]
fn htop_case_insensitive() {
let cmd = extract_htop_command("@timmy HTOP", "Timmy", "@timmy:homeserver.local");
assert_eq!(cmd, Some(HtopCommand::Start { duration_secs: 300 }));
}
#[test]
fn htop_full_user_id() {
let cmd = extract_htop_command(
"@timmy:homeserver.local htop",
"Timmy",
"@timmy:homeserver.local",
);
assert_eq!(cmd, Some(HtopCommand::Start { duration_secs: 300 }));
}
#[test]
fn htop_with_comma_after_mention() {
// Some Matrix clients format mentions as "@timmy, htop"
let cmd = extract_htop_command("@timmy, htop", "Timmy", "@timmy:homeserver.local");
assert_eq!(cmd, Some(HtopCommand::Start { duration_secs: 300 }));
}
// -- parse_duration -----------------------------------------------------
#[test]
fn parse_duration_empty_returns_none() {
assert_eq!(parse_duration(""), None);
}
#[test]
fn parse_duration_minutes() {
assert_eq!(parse_duration("5m"), Some(300));
}
#[test]
fn parse_duration_seconds() {
assert_eq!(parse_duration("120"), Some(120));
}
#[test]
fn parse_duration_invalid_returns_none() {
assert_eq!(parse_duration("abc"), None);
}
// -- build_htop_message -------------------------------------------------
#[test]
fn build_htop_message_no_agents() {
let pool = Arc::new(crate::agents::AgentPool::new_test(3000));
let text = build_htop_message(&pool, 0, 300);
assert!(text.contains("htop"), "should mention htop: {text}");
assert!(
text.contains("No agents currently running"),
"should note no agents: {text}"
);
}
#[test]
fn build_htop_message_contains_load() {
let pool = Arc::new(crate::agents::AgentPool::new_test(3000));
let text = build_htop_message(&pool, 0, 300);
// Load average is gathered via `uptime`; it should appear in some form.
assert!(
text.contains("load"),
"message should contain load info: {text}"
);
}
#[test]
fn build_htop_message_shows_remaining_time() {
let pool = Arc::new(crate::agents::AgentPool::new_test(3000));
let text = build_htop_message(&pool, 0, 300);
assert!(
text.contains("auto-stops in"),
"should show remaining time: {text}"
);
}
#[test]
fn build_htop_message_load_on_own_line() {
// Load average must be on its own line, not combined with the htop header.
let pool = Arc::new(crate::agents::AgentPool::new_test(3000));
let text = build_htop_message(&pool, 0, 300);
let lines: Vec<&str> = text.lines().collect();
let header_line = lines.first().expect("should have a header line");
// Header line must NOT contain "load" — load is on the second line.
assert!(
!header_line.contains("load"),
"load should be on its own line, not the header: {header_line}"
);
// Second line must contain "load".
let load_line = lines.get(1).expect("should have a load line");
assert!(
load_line.contains("load"),
"second line should contain load info: {load_line}"
);
}
#[test]
fn build_htop_message_no_table_syntax() {
// Must not use Markdown table format (pipes/separators) — those are too
// wide for narrow mobile screens.
let pool = Arc::new(crate::agents::AgentPool::new_test(3000));
let text = build_htop_message(&pool, 0, 300);
assert!(
!text.contains("|----"),
"output must not contain table separator rows: {text}"
);
assert!(
!text.contains("| Agent"),
"output must not contain table header row: {text}"
);
}
#[test]
fn build_htop_message_header_fits_40_chars() {
// The header line (htop + remaining time) must fit in ~40 rendered chars.
let pool = Arc::new(crate::agents::AgentPool::new_test(3000));
let text = build_htop_message(&pool, 0, 300);
let header = text.lines().next().expect("should have a header line");
// Strip markdown bold markers (**) for length calculation.
let rendered = header.replace("**", "");
assert!(
rendered.len() <= 40,
"header line too wide for mobile ({} chars): {rendered}",
rendered.len()
);
}
}

90
server/src/matrix/mod.rs Normal file
View File

@@ -0,0 +1,90 @@
//! Matrix bot integration for Story Kit.
//!
//! When a `.storkit/bot.toml` file is present with `enabled = true`, the
//! server spawns a Matrix bot that:
//!
//! 1. Connects to the configured homeserver and joins the configured room.
//! 2. Listens for messages from other users in the room.
//! 3. Passes each message to Claude Code (the same provider as the web UI),
//! which has native access to Story Kit MCP tools.
//! 4. Posts Claude Code's response back to the room.
//!
//! The bot is optional — if `bot.toml` is missing or `enabled = false`, the
//! server starts normally with no Matrix connection.
//!
//! Multi-room support: configure `room_ids = ["!room1:…", "!room2:…"]` in
//! `bot.toml`. Each room maintains its own independent conversation history.
mod bot;
pub mod commands;
mod config;
pub mod delete;
pub mod htop;
pub mod rebuild;
pub mod reset;
pub mod start;
pub mod notifications;
pub mod transport_impl;
pub use bot::{ConversationEntry, ConversationRole, RoomConversation, drain_complete_paragraphs};
pub use config::BotConfig;
use crate::agents::AgentPool;
use crate::http::context::PermissionForward;
use crate::io::watcher::WatcherEvent;
use std::path::Path;
use std::sync::Arc;
use tokio::sync::{Mutex as TokioMutex, broadcast, mpsc};
/// Attempt to start the Matrix bot.
///
/// Reads the bot configuration from `.storkit/bot.toml`. If the file is
/// absent or `enabled = false`, this function returns immediately without
/// spawning anything — the server continues normally.
///
/// When the bot is enabled, a notification listener is also spawned that
/// posts stage-transition messages to all configured rooms whenever a work
/// item moves between pipeline stages.
///
/// `perm_rx` is the permission-request receiver shared with the MCP
/// `prompt_permission` tool. The bot locks it during active chat sessions
/// to surface permission prompts to the Matrix room and relay user decisions.
///
/// Must be called from within a Tokio runtime context (e.g., from `main`).
pub fn spawn_bot(
project_root: &Path,
watcher_tx: broadcast::Sender<WatcherEvent>,
perm_rx: Arc<TokioMutex<mpsc::UnboundedReceiver<PermissionForward>>>,
agents: Arc<AgentPool>,
) {
let config = match BotConfig::load(project_root) {
Some(c) => c,
None => {
crate::slog!("[matrix-bot] bot.toml absent or disabled; Matrix integration skipped");
return;
}
};
// WhatsApp and Slack transports are handled via HTTP webhooks, not the Matrix sync loop.
if config.transport == "whatsapp" || config.transport == "slack" {
crate::slog!(
"[bot] transport={} — skipping Matrix bot; webhooks handle this transport",
config.transport
);
return;
}
crate::slog!(
"[matrix-bot] Starting Matrix bot → homeserver={} rooms={:?}",
config.homeserver,
config.effective_room_ids()
);
let root = project_root.to_path_buf();
let watcher_rx = watcher_tx.subscribe();
tokio::spawn(async move {
if let Err(e) = bot::run_bot(config, root, watcher_rx, perm_rx, agents).await {
crate::slog!("[matrix-bot] Fatal error: {e}");
}
});
}

View File

@@ -0,0 +1,646 @@
//! Stage transition notifications for Matrix rooms.
//!
//! Subscribes to [`WatcherEvent`] broadcasts and posts a notification to all
//! configured Matrix rooms whenever a work item moves between pipeline stages.
use crate::io::story_metadata::parse_front_matter;
use crate::io::watcher::WatcherEvent;
use crate::slog;
use crate::transport::ChatTransport;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::broadcast;
/// Human-readable display name for a pipeline stage directory.
pub fn stage_display_name(stage: &str) -> &'static str {
match stage {
"1_backlog" => "Backlog",
"2_current" => "Current",
"3_qa" => "QA",
"4_merge" => "Merge",
"5_done" => "Done",
"6_archived" => "Archived",
_ => "Unknown",
}
}
/// Infer the previous pipeline stage for a given destination stage.
///
/// Returns `None` for `1_backlog` since items are created there (not
/// transitioned from another stage).
pub fn inferred_from_stage(to_stage: &str) -> Option<&'static str> {
match to_stage {
"2_current" => Some("Backlog"),
"3_qa" => Some("Current"),
"4_merge" => Some("QA"),
"5_done" => Some("Merge"),
"6_archived" => Some("Done"),
_ => None,
}
}
/// Extract the numeric story number from an item ID like `"261_story_slug"`.
pub fn extract_story_number(item_id: &str) -> Option<&str> {
item_id
.split('_')
.next()
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
}
/// Read the story name from the work item file's YAML front matter.
///
/// Returns `None` if the file doesn't exist or has no parseable name.
pub fn read_story_name(project_root: &Path, stage: &str, item_id: &str) -> Option<String> {
let path = project_root
.join(".storkit")
.join("work")
.join(stage)
.join(format!("{item_id}.md"));
let contents = std::fs::read_to_string(&path).ok()?;
let meta = parse_front_matter(&contents).ok()?;
meta.name
}
/// Format a stage transition notification message.
///
/// Returns `(plain_text, html)` suitable for `RoomMessageEventContent::text_html`.
pub fn format_stage_notification(
item_id: &str,
story_name: Option<&str>,
from_stage: &str,
to_stage: &str,
) -> (String, String) {
let number = extract_story_number(item_id).unwrap_or(item_id);
let name = story_name.unwrap_or(item_id);
let prefix = if to_stage == "Done" { "\u{1f389} " } else { "" };
let plain = format!("{prefix}#{number} {name} \u{2014} {from_stage} \u{2192} {to_stage}");
let html = format!(
"{prefix}<strong>#{number}</strong> <em>{name}</em> \u{2014} {from_stage} \u{2192} {to_stage}"
);
(plain, html)
}
/// Format an error notification message for a story failure.
///
/// Returns `(plain_text, html)` suitable for `RoomMessageEventContent::text_html`.
pub fn format_error_notification(
item_id: &str,
story_name: Option<&str>,
reason: &str,
) -> (String, String) {
let number = extract_story_number(item_id).unwrap_or(item_id);
let name = story_name.unwrap_or(item_id);
let plain = format!("\u{274c} #{number} {name} \u{2014} {reason}");
let html = format!(
"\u{274c} <strong>#{number}</strong> <em>{name}</em> \u{2014} {reason}"
);
(plain, html)
}
/// Search all pipeline stages for a story name.
///
/// Tries each known pipeline stage directory in order and returns the first
/// name found. Used for events (like rate-limit warnings) that arrive without
/// a known stage.
fn find_story_name_any_stage(project_root: &Path, item_id: &str) -> Option<String> {
for stage in &["2_current", "3_qa", "4_merge", "1_backlog", "5_done"] {
if let Some(name) = read_story_name(project_root, stage, item_id) {
return Some(name);
}
}
None
}
/// Minimum time between rate-limit notifications for the same agent.
const RATE_LIMIT_DEBOUNCE: Duration = Duration::from_secs(60);
/// Format a rate limit warning notification message.
///
/// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`.
pub fn format_rate_limit_notification(
item_id: &str,
story_name: Option<&str>,
agent_name: &str,
) -> (String, String) {
let number = extract_story_number(item_id).unwrap_or(item_id);
let name = story_name.unwrap_or(item_id);
let plain = format!(
"\u{26a0}\u{fe0f} #{number} {name} \u{2014} {agent_name} hit an API rate limit"
);
let html = format!(
"\u{26a0}\u{fe0f} <strong>#{number}</strong> <em>{name}</em> \u{2014} \
{agent_name} hit an API rate limit"
);
(plain, html)
}
/// Spawn a background task that listens for watcher events and posts
/// stage-transition notifications to all configured rooms via the
/// [`ChatTransport`] abstraction.
pub fn spawn_notification_listener(
transport: Arc<dyn ChatTransport>,
room_ids: Vec<String>,
watcher_rx: broadcast::Receiver<WatcherEvent>,
project_root: PathBuf,
) {
tokio::spawn(async move {
let mut rx = watcher_rx;
// Tracks when a rate-limit notification was last sent for each
// "story_id:agent_name" key, to debounce repeated warnings.
let mut rate_limit_last_notified: HashMap<String, Instant> = HashMap::new();
loop {
match rx.recv().await {
Ok(WatcherEvent::WorkItem {
ref stage,
ref item_id,
..
}) => {
// Only notify on stage transitions, not creations.
let Some(from_display) = inferred_from_stage(stage) else {
continue;
};
let to_display = stage_display_name(stage);
let story_name = read_story_name(&project_root, stage, item_id);
let (plain, html) = format_stage_notification(
item_id,
story_name.as_deref(),
from_display,
to_display,
);
slog!("[matrix-bot] Sending stage notification: {plain}");
for room_id in &room_ids {
if let Err(e) = transport.send_message(room_id, &plain, &html).await {
slog!(
"[matrix-bot] Failed to send notification to {room_id}: {e}"
);
}
}
}
Ok(WatcherEvent::MergeFailure {
ref story_id,
ref reason,
}) => {
let story_name =
read_story_name(&project_root, "4_merge", story_id);
let (plain, html) = format_error_notification(
story_id,
story_name.as_deref(),
reason,
);
slog!("[matrix-bot] Sending error notification: {plain}");
for room_id in &room_ids {
if let Err(e) = transport.send_message(room_id, &plain, &html).await {
slog!(
"[matrix-bot] Failed to send error notification to {room_id}: {e}"
);
}
}
}
Ok(WatcherEvent::RateLimitWarning {
ref story_id,
ref agent_name,
}) => {
// Debounce: skip if we sent a notification for this agent
// within the last RATE_LIMIT_DEBOUNCE seconds.
let debounce_key = format!("{story_id}:{agent_name}");
let now = Instant::now();
if let Some(&last) = rate_limit_last_notified.get(&debounce_key)
&& now.duration_since(last) < RATE_LIMIT_DEBOUNCE
{
slog!(
"[matrix-bot] Rate-limit notification debounced for \
{story_id}:{agent_name}"
);
continue;
}
rate_limit_last_notified.insert(debounce_key, now);
let story_name = find_story_name_any_stage(&project_root, story_id);
let (plain, html) = format_rate_limit_notification(
story_id,
story_name.as_deref(),
agent_name,
);
slog!("[matrix-bot] Sending rate-limit notification: {plain}");
for room_id in &room_ids {
if let Err(e) = transport.send_message(room_id, &plain, &html).await {
slog!(
"[matrix-bot] Failed to send rate-limit notification \
to {room_id}: {e}"
);
}
}
}
Ok(_) => {} // Ignore non-work-item events
Err(broadcast::error::RecvError::Lagged(n)) => {
slog!(
"[matrix-bot] Notification listener lagged, skipped {n} events"
);
}
Err(broadcast::error::RecvError::Closed) => {
slog!(
"[matrix-bot] Watcher channel closed, stopping notification listener"
);
break;
}
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use crate::transport::MessageId;
// ── MockTransport ───────────────────────────────────────────────────────
type CallLog = Arc<std::sync::Mutex<Vec<(String, String, String)>>>;
/// Records every `send_message` call for inspection in tests.
struct MockTransport {
calls: CallLog,
}
impl MockTransport {
fn new() -> (Arc<Self>, CallLog) {
let calls: CallLog = Arc::new(std::sync::Mutex::new(Vec::new()));
(Arc::new(Self { calls: Arc::clone(&calls) }), calls)
}
}
#[async_trait]
impl crate::transport::ChatTransport for MockTransport {
async fn send_message(&self, room_id: &str, plain: &str, html: &str) -> Result<MessageId, String> {
self.calls.lock().unwrap().push((room_id.to_string(), plain.to_string(), html.to_string()));
Ok("mock-msg-id".to_string())
}
async fn edit_message(&self, _room_id: &str, _id: &str, _plain: &str, _html: &str) -> Result<(), String> {
Ok(())
}
async fn send_typing(&self, _room_id: &str, _typing: bool) -> Result<(), String> {
Ok(())
}
}
// ── spawn_notification_listener: RateLimitWarning ───────────────────────
/// AC2 + AC3: when a RateLimitWarning event arrives, send_message is called
/// with a notification that names the agent and story.
#[tokio::test]
async fn rate_limit_warning_sends_notification_with_agent_and_story() {
let tmp = tempfile::tempdir().unwrap();
let stage_dir = tmp.path().join(".storkit").join("work").join("2_current");
std::fs::create_dir_all(&stage_dir).unwrap();
std::fs::write(
stage_dir.join("365_story_rate_limit.md"),
"---\nname: Rate Limit Test Story\n---\n",
)
.unwrap();
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
let (transport, calls) = MockTransport::new();
spawn_notification_listener(
transport,
vec!["!room123:example.org".to_string()],
watcher_rx,
tmp.path().to_path_buf(),
);
watcher_tx.send(WatcherEvent::RateLimitWarning {
story_id: "365_story_rate_limit".to_string(),
agent_name: "coder-1".to_string(),
}).unwrap();
// Give the spawned task time to process the event.
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let calls = calls.lock().unwrap();
assert_eq!(calls.len(), 1, "Expected exactly one notification");
let (room_id, plain, _html) = &calls[0];
assert_eq!(room_id, "!room123:example.org");
assert!(plain.contains("365"), "plain should contain story number");
assert!(plain.contains("Rate Limit Test Story"), "plain should contain story name");
assert!(plain.contains("coder-1"), "plain should contain agent name");
assert!(plain.contains("rate limit"), "plain should mention rate limit");
}
/// AC4: a second RateLimitWarning for the same agent within the debounce
/// window must NOT trigger a second notification.
#[tokio::test]
async fn rate_limit_warning_is_debounced() {
let tmp = tempfile::tempdir().unwrap();
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
let (transport, calls) = MockTransport::new();
spawn_notification_listener(
transport,
vec!["!room1:example.org".to_string()],
watcher_rx,
tmp.path().to_path_buf(),
);
// Send the same warning twice in rapid succession.
for _ in 0..2 {
watcher_tx.send(WatcherEvent::RateLimitWarning {
story_id: "42_story_debounce".to_string(),
agent_name: "coder-2".to_string(),
}).unwrap();
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let calls = calls.lock().unwrap();
assert_eq!(calls.len(), 1, "Debounce should suppress the second notification");
}
/// AC4 (corollary): warnings for different agents are NOT debounced against
/// each other — both should produce notifications.
#[tokio::test]
async fn rate_limit_warnings_for_different_agents_both_notify() {
let tmp = tempfile::tempdir().unwrap();
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
let (transport, calls) = MockTransport::new();
spawn_notification_listener(
transport,
vec!["!room1:example.org".to_string()],
watcher_rx,
tmp.path().to_path_buf(),
);
watcher_tx.send(WatcherEvent::RateLimitWarning {
story_id: "42_story_foo".to_string(),
agent_name: "coder-1".to_string(),
}).unwrap();
watcher_tx.send(WatcherEvent::RateLimitWarning {
story_id: "42_story_foo".to_string(),
agent_name: "coder-2".to_string(),
}).unwrap();
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let calls = calls.lock().unwrap();
assert_eq!(calls.len(), 2, "Different agents should each trigger a notification");
}
// ── stage_display_name ──────────────────────────────────────────────────
#[test]
fn stage_display_name_maps_all_known_stages() {
assert_eq!(stage_display_name("1_backlog"), "Backlog");
assert_eq!(stage_display_name("2_current"), "Current");
assert_eq!(stage_display_name("3_qa"), "QA");
assert_eq!(stage_display_name("4_merge"), "Merge");
assert_eq!(stage_display_name("5_done"), "Done");
assert_eq!(stage_display_name("6_archived"), "Archived");
assert_eq!(stage_display_name("unknown"), "Unknown");
}
// ── inferred_from_stage ─────────────────────────────────────────────────
#[test]
fn inferred_from_stage_returns_previous_stage() {
assert_eq!(inferred_from_stage("2_current"), Some("Backlog"));
assert_eq!(inferred_from_stage("3_qa"), Some("Current"));
assert_eq!(inferred_from_stage("4_merge"), Some("QA"));
assert_eq!(inferred_from_stage("5_done"), Some("Merge"));
assert_eq!(inferred_from_stage("6_archived"), Some("Done"));
}
#[test]
fn inferred_from_stage_returns_none_for_backlog() {
assert_eq!(inferred_from_stage("1_backlog"), None);
}
#[test]
fn inferred_from_stage_returns_none_for_unknown() {
assert_eq!(inferred_from_stage("9_unknown"), None);
}
// ── extract_story_number ────────────────────────────────────────────────
#[test]
fn extract_story_number_parses_numeric_prefix() {
assert_eq!(
extract_story_number("261_story_bot_notifications"),
Some("261")
);
assert_eq!(extract_story_number("42_bug_fix_thing"), Some("42"));
assert_eq!(extract_story_number("1_spike_research"), Some("1"));
}
#[test]
fn extract_story_number_returns_none_for_non_numeric() {
assert_eq!(extract_story_number("abc_story_thing"), None);
assert_eq!(extract_story_number(""), None);
}
// ── read_story_name ─────────────────────────────────────────────────────
#[test]
fn read_story_name_reads_from_front_matter() {
let tmp = tempfile::tempdir().unwrap();
let stage_dir = tmp
.path()
.join(".storkit")
.join("work")
.join("2_current");
std::fs::create_dir_all(&stage_dir).unwrap();
std::fs::write(
stage_dir.join("42_story_my_feature.md"),
"---\nname: My Cool Feature\n---\n# Story\n",
)
.unwrap();
let name = read_story_name(tmp.path(), "2_current", "42_story_my_feature");
assert_eq!(name.as_deref(), Some("My Cool Feature"));
}
#[test]
fn read_story_name_returns_none_for_missing_file() {
let tmp = tempfile::tempdir().unwrap();
let name = read_story_name(tmp.path(), "2_current", "99_story_missing");
assert_eq!(name, None);
}
#[test]
fn read_story_name_returns_none_for_missing_name_field() {
let tmp = tempfile::tempdir().unwrap();
let stage_dir = tmp
.path()
.join(".storkit")
.join("work")
.join("2_current");
std::fs::create_dir_all(&stage_dir).unwrap();
std::fs::write(
stage_dir.join("42_story_no_name.md"),
"---\ncoverage_baseline: 50%\n---\n# Story\n",
)
.unwrap();
let name = read_story_name(tmp.path(), "2_current", "42_story_no_name");
assert_eq!(name, None);
}
// ── format_error_notification ────────────────────────────────────────────
#[test]
fn format_error_notification_with_story_name() {
let (plain, html) =
format_error_notification("262_story_bot_errors", Some("Bot error notifications"), "merge conflict in src/main.rs");
assert_eq!(
plain,
"\u{274c} #262 Bot error notifications \u{2014} merge conflict in src/main.rs"
);
assert_eq!(
html,
"\u{274c} <strong>#262</strong> <em>Bot error notifications</em> \u{2014} merge conflict in src/main.rs"
);
}
#[test]
fn format_error_notification_without_story_name_falls_back_to_item_id() {
let (plain, _html) =
format_error_notification("42_bug_fix_thing", None, "tests failed");
assert_eq!(
plain,
"\u{274c} #42 42_bug_fix_thing \u{2014} tests failed"
);
}
#[test]
fn format_error_notification_non_numeric_id_uses_full_id() {
let (plain, _html) =
format_error_notification("abc_story_thing", Some("Some Story"), "clippy errors");
assert_eq!(
plain,
"\u{274c} #abc_story_thing Some Story \u{2014} clippy errors"
);
}
// ── format_rate_limit_notification ─────────────────────────────────────
#[test]
fn format_rate_limit_notification_includes_agent_and_story() {
let (plain, html) = format_rate_limit_notification(
"365_story_my_feature",
Some("My Feature"),
"coder-2",
);
assert_eq!(
plain,
"\u{26a0}\u{fe0f} #365 My Feature \u{2014} coder-2 hit an API rate limit"
);
assert_eq!(
html,
"\u{26a0}\u{fe0f} <strong>#365</strong> <em>My Feature</em> \u{2014} coder-2 hit an API rate limit"
);
}
#[test]
fn format_rate_limit_notification_falls_back_to_item_id() {
let (plain, _html) =
format_rate_limit_notification("42_story_thing", None, "coder-1");
assert_eq!(
plain,
"\u{26a0}\u{fe0f} #42 42_story_thing \u{2014} coder-1 hit an API rate limit"
);
}
// ── format_stage_notification ───────────────────────────────────────────
#[test]
fn format_notification_done_stage_includes_party_emoji() {
let (plain, html) = format_stage_notification(
"353_story_done",
Some("Done Story"),
"Merge",
"Done",
);
assert_eq!(
plain,
"\u{1f389} #353 Done Story \u{2014} Merge \u{2192} Done"
);
assert_eq!(
html,
"\u{1f389} <strong>#353</strong> <em>Done Story</em> \u{2014} Merge \u{2192} Done"
);
}
#[test]
fn format_notification_non_done_stage_has_no_emoji() {
let (plain, _html) = format_stage_notification(
"42_story_thing",
Some("Some Story"),
"Backlog",
"Current",
);
assert!(!plain.contains("\u{1f389}"));
}
#[test]
fn format_notification_with_story_name() {
let (plain, html) = format_stage_notification(
"261_story_bot_notifications",
Some("Bot notifications"),
"Upcoming",
"Current",
);
assert_eq!(
plain,
"#261 Bot notifications \u{2014} Upcoming \u{2192} Current"
);
assert_eq!(
html,
"<strong>#261</strong> <em>Bot notifications</em> \u{2014} Upcoming \u{2192} Current"
);
}
#[test]
fn format_notification_without_story_name_falls_back_to_item_id() {
let (plain, _html) = format_stage_notification(
"42_bug_fix_thing",
None,
"Current",
"QA",
);
assert_eq!(
plain,
"#42 42_bug_fix_thing \u{2014} Current \u{2192} QA"
);
}
#[test]
fn format_notification_non_numeric_id_uses_full_id() {
let (plain, _html) = format_stage_notification(
"abc_story_thing",
Some("Some Story"),
"QA",
"Merge",
);
assert_eq!(
plain,
"#abc_story_thing Some Story \u{2014} QA \u{2192} Merge"
);
}
}

View File

@@ -0,0 +1,145 @@
//! Rebuild command: trigger a server rebuild and restart.
//!
//! `{bot_name} rebuild` stops all running agents, rebuilds the server binary
//! with `cargo build`, and re-execs the process with the new binary. If the
//! build fails the error is reported back to the room and the server keeps
//! running.
use crate::agents::AgentPool;
use std::path::Path;
use std::sync::Arc;
/// A parsed rebuild command.
#[derive(Debug, PartialEq)]
pub struct RebuildCommand;
/// Parse a rebuild command from a raw message body.
///
/// Strips the bot mention prefix and checks whether the command word is
/// `rebuild`. Returns `None` when the message is not a rebuild command.
pub fn extract_rebuild_command(
message: &str,
bot_name: &str,
bot_user_id: &str,
) -> Option<RebuildCommand> {
let stripped = strip_mention(message, bot_name, bot_user_id);
let trimmed = stripped
.trim()
.trim_start_matches(|c: char| !c.is_alphanumeric());
let cmd = match trimmed.split_once(char::is_whitespace) {
Some((c, _)) => c,
None => trimmed,
};
if cmd.eq_ignore_ascii_case("rebuild") {
Some(RebuildCommand)
} else {
None
}
}
/// Handle a rebuild command: trigger server rebuild and restart.
///
/// Returns a string describing the outcome. On build failure the error
/// message is returned so it can be posted to the room; the server keeps
/// running. On success this function never returns (the process re-execs).
pub async fn handle_rebuild(
bot_name: &str,
project_root: &Path,
agents: &Arc<AgentPool>,
) -> String {
crate::slog!("[matrix-bot] rebuild command received (bot={bot_name})");
match crate::rebuild::rebuild_and_restart(agents, project_root).await {
Ok(msg) => msg,
Err(e) => format!("Rebuild failed: {e}"),
}
}
/// Strip the bot mention prefix from a raw Matrix message body.
fn strip_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) -> &'a str {
let trimmed = message.trim();
if let Some(rest) = strip_prefix_ci(trimmed, bot_user_id) {
return rest;
}
if let Some(localpart) = bot_user_id.split(':').next()
&& let Some(rest) = strip_prefix_ci(trimmed, localpart)
{
return rest;
}
if let Some(rest) = strip_prefix_ci(trimmed, bot_name) {
return rest;
}
trimmed
}
fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
if text.len() < prefix.len() {
return None;
}
if !text[..prefix.len()].eq_ignore_ascii_case(prefix) {
return None;
}
let rest = &text[prefix.len()..];
match rest.chars().next() {
None => Some(rest),
Some(c) if c.is_alphanumeric() || c == '-' || c == '_' => None,
_ => Some(rest),
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_with_display_name() {
let cmd = extract_rebuild_command("Timmy rebuild", "Timmy", "@timmy:home.local");
assert_eq!(cmd, Some(RebuildCommand));
}
#[test]
fn extract_with_full_user_id() {
let cmd = extract_rebuild_command(
"@timmy:home.local rebuild",
"Timmy",
"@timmy:home.local",
);
assert_eq!(cmd, Some(RebuildCommand));
}
#[test]
fn extract_with_localpart() {
let cmd = extract_rebuild_command("@timmy rebuild", "Timmy", "@timmy:home.local");
assert_eq!(cmd, Some(RebuildCommand));
}
#[test]
fn extract_case_insensitive() {
let cmd = extract_rebuild_command("Timmy REBUILD", "Timmy", "@timmy:home.local");
assert_eq!(cmd, Some(RebuildCommand));
}
#[test]
fn extract_non_rebuild_returns_none() {
let cmd = extract_rebuild_command("Timmy help", "Timmy", "@timmy:home.local");
assert_eq!(cmd, None);
}
#[test]
fn extract_ignores_extra_args() {
// "rebuild" with trailing text is still a rebuild command
let cmd = extract_rebuild_command("Timmy rebuild now", "Timmy", "@timmy:home.local");
assert_eq!(cmd, Some(RebuildCommand));
}
#[test]
fn extract_no_match_returns_none() {
let cmd = extract_rebuild_command("Timmy status", "Timmy", "@timmy:home.local");
assert_eq!(cmd, None);
}
}

170
server/src/matrix/reset.rs Normal file
View File

@@ -0,0 +1,170 @@
//! Reset command: clear the current Claude Code session for a room.
//!
//! `{bot_name} reset` drops the stored session ID and conversation history for
//! the current room so the next message starts a brand-new Claude Code session
//! with clean context. File-system memories (auto-memory directory) are not
//! affected — only the in-memory/persisted conversation state is cleared.
use crate::matrix::bot::{ConversationHistory, RoomConversation};
use matrix_sdk::ruma::OwnedRoomId;
use std::path::Path;
/// A parsed reset command.
#[derive(Debug, PartialEq)]
pub struct ResetCommand;
/// Parse a reset command from a raw message body.
///
/// Strips the bot mention prefix and checks whether the command word is
/// `reset`. Returns `None` when the message is not a reset command at all.
pub fn extract_reset_command(
message: &str,
bot_name: &str,
bot_user_id: &str,
) -> Option<ResetCommand> {
let stripped = strip_mention(message, bot_name, bot_user_id);
let trimmed = stripped
.trim()
.trim_start_matches(|c: char| !c.is_alphanumeric());
let cmd = match trimmed.split_once(char::is_whitespace) {
Some((c, _)) => c,
None => trimmed,
};
if cmd.eq_ignore_ascii_case("reset") {
Some(ResetCommand)
} else {
None
}
}
/// Handle a reset command: clear the session ID and conversation entries for
/// the given room, persist the updated history, and return a confirmation.
pub async fn handle_reset(
bot_name: &str,
room_id: &OwnedRoomId,
history: &ConversationHistory,
project_root: &Path,
) -> String {
{
let mut guard = history.lock().await;
let conv = guard.entry(room_id.clone()).or_insert_with(RoomConversation::default);
conv.session_id = None;
conv.entries.clear();
crate::matrix::bot::save_history(project_root, &guard);
}
crate::slog!("[matrix-bot] reset command: cleared session for room {room_id} (bot={bot_name})");
"Session reset. Starting fresh — previous context has been cleared.".to_string()
}
/// Strip the bot mention prefix from a raw Matrix message body.
fn strip_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) -> &'a str {
let trimmed = message.trim();
if let Some(rest) = strip_prefix_ci(trimmed, bot_user_id) {
return rest;
}
if let Some(localpart) = bot_user_id.split(':').next()
&& let Some(rest) = strip_prefix_ci(trimmed, localpart)
{
return rest;
}
if let Some(rest) = strip_prefix_ci(trimmed, bot_name) {
return rest;
}
trimmed
}
fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
if text.len() < prefix.len() {
return None;
}
if !text[..prefix.len()].eq_ignore_ascii_case(prefix) {
return None;
}
let rest = &text[prefix.len()..];
match rest.chars().next() {
None => Some(rest),
Some(c) if c.is_alphanumeric() || c == '-' || c == '_' => None,
_ => Some(rest),
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_with_display_name() {
let cmd = extract_reset_command("Timmy reset", "Timmy", "@timmy:home.local");
assert_eq!(cmd, Some(ResetCommand));
}
#[test]
fn extract_with_full_user_id() {
let cmd =
extract_reset_command("@timmy:home.local reset", "Timmy", "@timmy:home.local");
assert_eq!(cmd, Some(ResetCommand));
}
#[test]
fn extract_with_localpart() {
let cmd = extract_reset_command("@timmy reset", "Timmy", "@timmy:home.local");
assert_eq!(cmd, Some(ResetCommand));
}
#[test]
fn extract_case_insensitive() {
let cmd = extract_reset_command("Timmy RESET", "Timmy", "@timmy:home.local");
assert_eq!(cmd, Some(ResetCommand));
}
#[test]
fn extract_non_reset_returns_none() {
let cmd = extract_reset_command("Timmy help", "Timmy", "@timmy:home.local");
assert_eq!(cmd, None);
}
#[test]
fn extract_ignores_extra_args() {
// "reset" with trailing text is still a reset command
let cmd = extract_reset_command("Timmy reset everything", "Timmy", "@timmy:home.local");
assert_eq!(cmd, Some(ResetCommand));
}
#[tokio::test]
async fn handle_reset_clears_session_and_entries() {
use crate::matrix::bot::{ConversationEntry, ConversationRole};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex as TokioMutex;
let room_id: OwnedRoomId = "!test:example.com".parse().unwrap();
let history: ConversationHistory = Arc::new(TokioMutex::new({
let mut m = HashMap::new();
m.insert(room_id.clone(), RoomConversation {
session_id: Some("old-session-id".to_string()),
entries: vec![ConversationEntry {
role: ConversationRole::User,
sender: "@alice:example.com".to_string(),
content: "previous message".to_string(),
}],
});
m
}));
let tmp = tempfile::tempdir().unwrap();
let response = handle_reset("Timmy", &room_id, &history, tmp.path()).await;
assert!(response.contains("reset"), "response should mention reset: {response}");
let guard = history.lock().await;
let conv = guard.get(&room_id).unwrap();
assert!(conv.session_id.is_none(), "session_id should be cleared");
assert!(conv.entries.is_empty(), "entries should be cleared");
}
}

391
server/src/matrix/start.rs Normal file
View File

@@ -0,0 +1,391 @@
//! Start command: start a coder agent on a story.
//!
//! `{bot_name} start {number}` finds the story by number, selects the default
//! coder agent, and starts it.
//!
//! `{bot_name} start {number} opus` starts `coder-opus` (or any agent whose
//! name ends with the supplied hint, e.g. `coder-{hint}`).
use crate::agents::AgentPool;
use std::path::Path;
/// A parsed start command from a Matrix message body.
#[derive(Debug, PartialEq)]
pub enum StartCommand {
/// Start the story with this number using the (optional) agent hint.
Start {
story_number: String,
/// Optional agent name hint (e.g. `"opus"` → resolved to `"coder-opus"`).
agent_hint: Option<String>,
},
/// The user typed `start` but without a valid numeric argument.
BadArgs,
}
/// Parse a start command from a raw Matrix message body.
///
/// Strips the bot mention prefix and checks whether the first word is `start`.
/// Returns `None` when the message is not a start command at all.
pub fn extract_start_command(
message: &str,
bot_name: &str,
bot_user_id: &str,
) -> Option<StartCommand> {
let stripped = strip_mention(message, bot_name, bot_user_id);
let trimmed = stripped
.trim()
.trim_start_matches(|c: char| !c.is_alphanumeric());
let (cmd, args) = match trimmed.split_once(char::is_whitespace) {
Some((c, a)) => (c, a.trim()),
None => (trimmed, ""),
};
if !cmd.eq_ignore_ascii_case("start") {
return None;
}
// Split args into story number and optional agent hint.
let (number_str, hint_str) = match args.split_once(char::is_whitespace) {
Some((n, h)) => (n.trim(), h.trim()),
None => (args, ""),
};
if !number_str.is_empty() && number_str.chars().all(|c| c.is_ascii_digit()) {
let agent_hint = if hint_str.is_empty() {
None
} else {
Some(hint_str.to_string())
};
Some(StartCommand::Start {
story_number: number_str.to_string(),
agent_hint,
})
} else {
Some(StartCommand::BadArgs)
}
}
/// Handle a start command asynchronously.
///
/// Finds the work item by `story_number` across all pipeline stages, resolves
/// the agent name from `agent_hint`, and calls `agents.start_agent`.
/// Returns a markdown-formatted response string.
pub async fn handle_start(
bot_name: &str,
story_number: &str,
agent_hint: Option<&str>,
project_root: &Path,
agents: &AgentPool,
) -> String {
const STAGES: &[&str] = &[
"1_backlog",
"2_current",
"3_qa",
"4_merge",
"5_done",
"6_archived",
];
// Find the story file across all pipeline stages.
let mut found: Option<(std::path::PathBuf, String)> = None; // (path, story_id)
'outer: for stage in STAGES {
let dir = project_root.join(".storkit").join("work").join(stage);
if !dir.exists() {
continue;
}
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())
.map(|s| s.to_string())
{
let file_num = stem
.split('_')
.next()
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
.unwrap_or("")
.to_string();
if file_num == story_number {
found = Some((path, stem));
break 'outer;
}
}
}
}
}
let (path, story_id) = match found {
Some(f) => f,
None => {
return format!(
"No story, bug, or spike with number **{story_number}** found."
);
}
};
// Read the human-readable name from front matter for the response.
let story_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)
})
.unwrap_or_else(|| story_id.clone());
// Resolve agent name: try "coder-{hint}" first, then the hint as-is.
let resolved_agent: Option<String> = agent_hint.map(|hint| {
let with_prefix = format!("coder-{hint}");
// We'll pass the prefixed form; start_agent validates against config.
// If coder- prefix is already there, don't double-prefix.
if hint.starts_with("coder-") {
hint.to_string()
} else {
with_prefix
}
});
crate::slog!(
"[matrix-bot] start command: starting story {story_id} with agent={resolved_agent:?} (bot={bot_name})"
);
match agents
.start_agent(project_root, &story_id, resolved_agent.as_deref(), None)
.await
{
Ok(info) => {
format!(
"Started **{story_name}** with agent **{}**.",
info.agent_name
)
}
Err(e) if e.contains("All coder agents are busy") => {
format!(
"**{story_name}** has been queued in `work/2_current/` and will start \
automatically when a coder becomes available."
)
}
Err(e) => {
format!("Failed to start **{story_name}**: {e}")
}
}
}
/// Strip the bot mention prefix from a raw Matrix message body.
///
/// Mirrors the logic in `commands::strip_bot_mention` and `delete::strip_mention`.
fn strip_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) -> &'a str {
let trimmed = message.trim();
if let Some(rest) = strip_prefix_ci(trimmed, bot_user_id) {
return rest;
}
if let Some(localpart) = bot_user_id.split(':').next()
&& let Some(rest) = strip_prefix_ci(trimmed, localpart)
{
return rest;
}
if let Some(rest) = strip_prefix_ci(trimmed, bot_name) {
return rest;
}
trimmed
}
fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
if text.len() < prefix.len() {
return None;
}
if !text[..prefix.len()].eq_ignore_ascii_case(prefix) {
return None;
}
let rest = &text[prefix.len()..];
match rest.chars().next() {
None => Some(rest),
Some(c) if c.is_alphanumeric() || c == '-' || c == '_' => None,
_ => Some(rest),
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
// -- extract_start_command -----------------------------------------------
#[test]
fn extract_with_full_user_id() {
let cmd =
extract_start_command("@timmy:home.local start 331", "Timmy", "@timmy:home.local");
assert_eq!(
cmd,
Some(StartCommand::Start {
story_number: "331".to_string(),
agent_hint: None
})
);
}
#[test]
fn extract_with_display_name() {
let cmd = extract_start_command("Timmy start 42", "Timmy", "@timmy:home.local");
assert_eq!(
cmd,
Some(StartCommand::Start {
story_number: "42".to_string(),
agent_hint: None
})
);
}
#[test]
fn extract_with_localpart() {
let cmd = extract_start_command("@timmy start 7", "Timmy", "@timmy:home.local");
assert_eq!(
cmd,
Some(StartCommand::Start {
story_number: "7".to_string(),
agent_hint: None
})
);
}
#[test]
fn extract_with_agent_hint() {
let cmd = extract_start_command("Timmy start 331 opus", "Timmy", "@timmy:home.local");
assert_eq!(
cmd,
Some(StartCommand::Start {
story_number: "331".to_string(),
agent_hint: Some("opus".to_string())
})
);
}
#[test]
fn extract_case_insensitive_command() {
let cmd = extract_start_command("Timmy START 99", "Timmy", "@timmy:home.local");
assert_eq!(
cmd,
Some(StartCommand::Start {
story_number: "99".to_string(),
agent_hint: None
})
);
}
#[test]
fn extract_no_args_is_bad_args() {
let cmd = extract_start_command("Timmy start", "Timmy", "@timmy:home.local");
assert_eq!(cmd, Some(StartCommand::BadArgs));
}
#[test]
fn extract_non_numeric_arg_is_bad_args() {
let cmd = extract_start_command("Timmy start foo", "Timmy", "@timmy:home.local");
assert_eq!(cmd, Some(StartCommand::BadArgs));
}
#[test]
fn extract_non_start_command_returns_none() {
let cmd = extract_start_command("Timmy help", "Timmy", "@timmy:home.local");
assert_eq!(cmd, None);
}
// -- handle_start (integration-style, uses temp filesystem) --------------
#[tokio::test]
async fn handle_start_returns_not_found_for_unknown_number() {
let tmp = tempfile::tempdir().unwrap();
let project_root = tmp.path();
for stage in &["1_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_archived"] {
std::fs::create_dir_all(project_root.join(".storkit").join("work").join(stage))
.unwrap();
}
let agents = std::sync::Arc::new(crate::agents::AgentPool::new_test(3000));
let response = handle_start("Timmy", "999", None, project_root, &agents).await;
assert!(
response.contains("No story") && response.contains("999"),
"unexpected response: {response}"
);
}
#[tokio::test]
async fn handle_start_says_queued_not_error_when_all_coders_busy() {
use crate::agents::{AgentPool, AgentStatus};
use std::sync::Arc;
let tmp = tempfile::tempdir().unwrap();
let project_root = tmp.path();
let sk = project_root.join(".storkit");
let backlog = sk.join("work/1_backlog");
std::fs::create_dir_all(&backlog).unwrap();
std::fs::write(
sk.join("project.toml"),
"[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n",
)
.unwrap();
std::fs::write(
backlog.join("356_story_test.md"),
"---\nname: Test Story\n---\n",
)
.unwrap();
let agents = Arc::new(AgentPool::new_test(3000));
agents.inject_test_agent("other-story", "coder-1", AgentStatus::Running);
let response = handle_start("Timmy", "356", None, project_root, &agents).await;
assert!(
!response.contains("Failed"),
"response must not say 'Failed' when coders are busy: {response}"
);
assert!(
response.to_lowercase().contains("queue") || response.to_lowercase().contains("available"),
"response must mention queued/available state: {response}"
);
}
#[test]
fn start_command_is_registered() {
use crate::matrix::commands::commands;
let found = commands().iter().any(|c| c.name == "start");
assert!(found, "start command must be in the registry");
}
#[test]
fn start_command_appears_in_help() {
let result = crate::matrix::commands::tests::try_cmd_addressed(
"Timmy",
"@timmy:homeserver.local",
"@timmy help",
);
let output = result.unwrap();
assert!(
output.contains("start"),
"help should list start command: {output}"
);
}
#[test]
fn start_command_falls_through_to_none_in_registry() {
// The start handler in the registry returns None (handled async in bot.rs).
let result = crate::matrix::commands::tests::try_cmd_addressed(
"Timmy",
"@timmy:homeserver.local",
"@timmy start 42",
);
assert!(
result.is_none(),
"start should not produce a sync response (handled async): {result:?}"
);
}
}

View File

@@ -0,0 +1,96 @@
//! Matrix implementation of [`ChatTransport`].
//!
//! Wraps a [`matrix_sdk::Client`] and delegates message sending / editing
//! to the Matrix SDK.
use async_trait::async_trait;
use matrix_sdk::Client;
use matrix_sdk::ruma::OwnedRoomId;
use matrix_sdk::ruma::events::room::message::{
ReplacementMetadata, RoomMessageEventContent, RoomMessageEventContentWithoutRelation,
};
use crate::transport::{ChatTransport, MessageId};
/// Matrix-backed [`ChatTransport`] implementation.
///
/// Holds a [`Client`] and resolves room IDs at send time.
pub struct MatrixTransport {
client: Client,
}
impl MatrixTransport {
pub fn new(client: Client) -> Self {
Self { client }
}
}
#[async_trait]
impl ChatTransport for MatrixTransport {
async fn send_message(
&self,
room_id: &str,
plain: &str,
html: &str,
) -> Result<MessageId, String> {
let room_id: OwnedRoomId = room_id
.parse()
.map_err(|e| format!("Invalid room ID '{room_id}': {e}"))?;
let room = self
.client
.get_room(&room_id)
.ok_or_else(|| format!("Room {room_id} not found in client state"))?;
let content = RoomMessageEventContent::text_html(plain.to_string(), html.to_string());
let resp = room
.send(content)
.await
.map_err(|e| format!("Matrix send error: {e}"))?;
Ok(resp.event_id.to_string())
}
async fn edit_message(
&self,
room_id: &str,
original_message_id: &str,
plain: &str,
html: &str,
) -> Result<(), String> {
let room_id: OwnedRoomId = room_id
.parse()
.map_err(|e| format!("Invalid room ID '{room_id}': {e}"))?;
let room = self
.client
.get_room(&room_id)
.ok_or_else(|| format!("Room {room_id} not found in client state"))?;
let original_event_id = original_message_id
.parse()
.map_err(|e| format!("Invalid event ID '{original_message_id}': {e}"))?;
let new_content =
RoomMessageEventContentWithoutRelation::text_html(plain.to_string(), html.to_string());
let metadata = ReplacementMetadata::new(original_event_id, None);
let content = new_content.make_replacement(metadata);
room.send(content)
.await
.map(|_| ())
.map_err(|e| format!("Matrix edit error: {e}"))
}
async fn send_typing(&self, room_id: &str, typing: bool) -> Result<(), String> {
let room_id: OwnedRoomId = room_id
.parse()
.map_err(|e| format!("Invalid room ID '{room_id}': {e}"))?;
let room = self
.client
.get_room(&room_id)
.ok_or_else(|| format!("Room {room_id} not found in client state"))?;
room.typing_notice(typing)
.await
.map_err(|e| format!("Matrix typing indicator error: {e}"))
}
}