diff --git a/server/src/chat/commands/logs.rs b/server/src/chat/commands/logs.rs new file mode 100644 index 00000000..237ac912 --- /dev/null +++ b/server/src/chat/commands/logs.rs @@ -0,0 +1,294 @@ +//! Handler for the `logs ` command — shows agent log tail for a story. +//! +//! Reads from the CRDT pipeline state (to resolve story numbers) and from the +//! agent log directory on disk. No LLM invocation — handled entirely at the +//! bot level. + +use super::CommandContext; +use std::path::{Path, PathBuf}; + +/// Handle `{bot_name} logs `. +/// +/// Finds the story by its numeric prefix in the CRDT, then tails the most +/// recently modified agent log file for that story. +pub(super) fn handle_logs(ctx: &CommandContext) -> Option { + let num_str = ctx.args.trim(); + if num_str.is_empty() { + return Some(format!( + "Usage: `{} logs `\n\nShows the last agent log lines 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: `{} logs `", + ctx.bot_name + )); + } + + let story_id = match find_story_id_by_number(num_str) { + Some(id) => id, + None => return Some(format!("Story **{num_str}** not found in the pipeline.")), + }; + + let log_dir = ctx + .project_root + .join(".huskies") + .join("logs") + .join(&story_id); + + match latest_log_file(&log_dir) { + Some(log_path) => { + let tail = read_log_tail(&log_path, 30); + let filename = log_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("agent.log"); + if tail.is_empty() { + Some(format!("**Agent log** (`{filename}`): *(empty)*")) + } else { + Some(format!( + "**Agent log tail** (`{filename}`):\n```\n{tail}\n```" + )) + } + } + None => Some(format!("**Story {num_str}:** *(no agent log found)*")), + } +} + +/// Find a story's full ID by its numeric prefix using the CRDT pipeline state. +fn find_story_id_by_number(num_str: &str) -> Option { + let items = crate::pipeline_state::read_all_typed(); + items.into_iter().find_map(|item| { + let file_num = item + .story_id + .0 + .split('_') + .next() + .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())) + .unwrap_or(""); + if file_num == num_str { + Some(item.story_id.0) + } else { + None + } + }) +} + +/// Find the most recently modified `.log` file in the given directory. +fn latest_log_file(log_dir: &Path) -> Option { + 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 logs_cmd(root: &Path, args: &str) -> Option { + 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 logs {args}")) + } + + // -- registration ------------------------------------------------------- + + #[test] + fn logs_command_is_registered() { + let found = super::super::commands().iter().any(|c| c.name == "logs"); + assert!(found, "logs command must be in the registry"); + } + + #[test] + fn logs_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("logs"), + "help should list logs command: {output}" + ); + } + + // -- input validation --------------------------------------------------- + + #[test] + fn logs_no_args_returns_usage() { + let tmp = tempfile::TempDir::new().unwrap(); + let output = logs_cmd(tmp.path(), "").unwrap(); + assert!( + output.contains("Usage"), + "no args should show usage: {output}" + ); + } + + #[test] + fn logs_non_numeric_returns_error() { + let tmp = tempfile::TempDir::new().unwrap(); + let output = logs_cmd(tmp.path(), "abc").unwrap(); + assert!( + output.contains("Invalid"), + "non-numeric arg should return error: {output}" + ); + } + + // -- not found ---------------------------------------------------------- + + #[test] + fn logs_story_not_in_pipeline_returns_friendly_message() { + crate::db::ensure_content_store(); + let tmp = tempfile::TempDir::new().unwrap(); + let output = logs_cmd(tmp.path(), "99998").unwrap(); + assert!( + output.contains("99998"), + "message should include story number: {output}" + ); + assert!( + output.contains("not found") || output.contains("Not found"), + "message should say not found: {output}" + ); + } + + // -- no log file -------------------------------------------------------- + + #[test] + fn logs_no_log_shows_no_log_message() { + use crate::chat::test_helpers::write_story_file; + 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 = logs_cmd(tmp.path(), "77").unwrap(); + assert!( + output.contains("no agent log") || output.contains("No agent log"), + "should indicate no log exists: {output}" + ); + } + + // -- log file present --------------------------------------------------- + + #[test] + fn logs_shows_log_tail_when_log_exists() { + use crate::chat::test_helpers::write_story_file; + let tmp = tempfile::TempDir::new().unwrap(); + write_story_file( + tmp.path(), + "2_current", + "88_story_has_log.md", + "---\nname: Has Log\n---\n", + ); + // Write a log file in the expected location. + let log_dir = tmp + .path() + .join(".huskies") + .join("logs") + .join("88_story_has_log"); + std::fs::create_dir_all(&log_dir).unwrap(); + let log_path = log_dir.join("coder-1-session.log"); + std::fs::write(&log_path, "line one\nline two\nline three\n").unwrap(); + + let output = logs_cmd(tmp.path(), "88").unwrap(); + assert!( + output.contains("line one") || output.contains("line three"), + "should show log content: {output}" + ); + } + + // -- 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::>() + .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); + } +} diff --git a/server/src/chat/commands/mod.rs b/server/src/chat/commands/mod.rs index 06389f17..79c1c1c5 100644 --- a/server/src/chat/commands/mod.rs +++ b/server/src/chat/commands/mod.rs @@ -14,6 +14,7 @@ mod depends; mod git; mod help; pub(crate) mod loc; +mod logs; mod move_story; mod overview; mod run_tests; @@ -104,9 +105,14 @@ pub fn commands() -> &'static [BotCommand] { }, BotCommand { name: "status", - description: "Show pipeline status and agent availability; or `status ` for a story triage dump", + description: "Show pipeline status and agent availability; or `status ` for pipeline info (stage, ACs, git diff, recent commits)", handler: status::handle_status, }, + BotCommand { + name: "logs", + description: "Show last agent log lines for a story: `logs `", + handler: logs::handle_logs, + }, BotCommand { name: "ambient", description: "Toggle ambient mode for this room: `ambient on` or `ambient off`", diff --git a/server/src/chat/commands/triage.rs b/server/src/chat/commands/triage.rs index c54621e1..4b12f2f8 100644 --- a/server/src/chat/commands/triage.rs +++ b/server/src/chat/commands/triage.rs @@ -1,17 +1,18 @@ -//! Handler for the story triage dump subcommand of `status`. +//! Handler for the story pipeline-info subcommand of `status`. //! -//! Produces a triage dump for a story: metadata, acceptance criteria, -//! worktree/branch state, git diff, recent commits, and the tail of the -//! agent log. +//! Produces a pipeline info dump for a story: metadata, acceptance criteria, +//! worktree/branch state, git diff, and recent commits. //! //! Reads from the CRDT pipeline state and the in-memory content store — no //! filesystem access for story content. Works for stories in any pipeline //! stage, not just `2_current`. //! +//! For agent log output, use the `logs ` command instead. +//! //! The command is handled entirely at the bot level — no LLM invocation. use super::CommandContext; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::process::Command; /// Handle `{bot_name} status {number}`. @@ -19,7 +20,7 @@ pub(super) fn handle_triage(ctx: &CommandContext) -> Option { let num_str = ctx.args.trim(); if num_str.is_empty() { return Some(format!( - "Usage: `{} status `\n\nShows a triage dump for a story currently in progress.", + "Usage: `{} status `\n\nShows pipeline info for a story: stage, ACs, git diff, recent commits.", ctx.bot_name )); } @@ -180,32 +181,6 @@ fn build_triage_dump( out.push_str("**Worktree:** *(not yet created)*\n\n"); } - // ---- Agent log tail ---- - let log_dir = ctx - .project_root - .join(".huskies") - .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 } @@ -238,40 +213,6 @@ fn run_git(dir: &Path, args: &[&str]) -> 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 { - 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 // --------------------------------------------------------------------------- @@ -518,23 +459,8 @@ mod tests { ); } - #[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 = status_triage_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 ------------------------------------------ + // -- parse_acceptance_criteria ----------------------------------------- #[test] fn parse_criteria_mixed() { @@ -554,47 +480,4 @@ mod tests { 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::>().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); - } }