295 lines
9.6 KiB
Rust
295 lines
9.6 KiB
Rust
|
|
//! 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);
|
||
|
|
}
|
||
|
|
}
|