huskies: merge 550_story_split_status_command_into_status_pipeline_info_and_logs_agent_output_subcommands

This commit is contained in:
dave
2026-04-12 14:56:16 +00:00
parent da5d604d01
commit 8ae06cc8e2
3 changed files with 309 additions and 126 deletions
+294
View File
@@ -0,0 +1,294 @@
//! Handler for the `logs <number>` 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 <number>`.
///
/// 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<String> {
let num_str = ctx.args.trim();
if num_str.is_empty() {
return Some(format!(
"Usage: `{} logs <number>`\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 <number>`",
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<String> {
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<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 logs_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 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::<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);
}
}
+7 -1
View File
@@ -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 <number>` for a story triage dump",
description: "Show pipeline status and agent availability; or `status <number>` 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 <number>`",
handler: logs::handle_logs,
},
BotCommand {
name: "ambient",
description: "Toggle ambient mode for this room: `ambient on` or `ambient off`",
+8 -125
View File
@@ -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 <number>` 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<String> {
let num_str = ctx.args.trim();
if num_str.is_empty() {
return Some(format!(
"Usage: `{} status <number>`\n\nShows a triage dump for a story currently in progress.",
"Usage: `{} status <number>`\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<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
// ---------------------------------------------------------------------------
@@ -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::<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);
}
}