huskies: merge 550_story_split_status_command_into_status_pipeline_info_and_logs_agent_output_subcommands
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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`",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user