top + report + log --follow (issues #37 #38) #85

Merged
bruno merged 1 commits from feat/top-report into main 2026-08-17 09:03:58 -04:00
7 changed files with 331 additions and 2 deletions
+21
View File
@@ -162,6 +162,24 @@ pub enum Command {
#[arg(long, value_name = "PERIOD")] #[arg(long, value_name = "PERIOD")]
period: Option<String>, period: Option<String>,
}, },
/// Show the most used agents (top 10)
Top {
/// Only events of the last period (7d, 30d, 90d, all)
#[arg(long, value_name = "PERIOD")]
period: Option<String>,
},
/// Write a markdown activity report
Report {
/// Cover the last 7 days
#[arg(long, conflicts_with = "last_month")]
last_week: bool,
/// Cover the last 30 days
#[arg(long)]
last_month: bool,
/// Output file (default: report-YYYYMMDD.md in the state directory)
#[arg(long, value_name = "FILE")]
output: Option<PathBuf>,
},
/// Show which agent works on which project /// Show which agent works on which project
Projects { Projects {
/// Project name (omit to list every project) /// Project name (omit to list every project)
@@ -194,6 +212,9 @@ pub enum Command {
/// Maximum number of events (default: 50) /// Maximum number of events (default: 50)
#[arg(long, value_name = "N", default_value = "50")] #[arg(long, value_name = "N", default_value = "50")]
limit: usize, limit: usize,
/// Follow the journal as new events arrive
#[arg(long)]
follow: bool,
}, },
/// Install an agent and its dependencies /// Install an agent and its dependencies
Install { Install {
+40
View File
@@ -35,6 +35,34 @@ pub fn filter_events(
Ok(events) Ok(events)
} }
/// Live view: print matching events as they arrive (issue #38).
fn follow_loop(app: &App, agent: Option<&str>, kinds: Option<&str>, since: Option<&str>) -> Result<i32> {
app.log.info("following the journal (Ctrl-C to stop)");
let mut seen: usize = 0;
loop {
let events = filter_events(app, agent, kinds, since, 200)?;
let mut rendered = String::new();
for e in events.iter().rev() {
rendered.push_str(&e.ts);
rendered.push(' ');
rendered.push_str(e.kind.as_str());
if let Some(a) = &e.agent {
rendered.push(' ');
rendered.push_str(a);
}
rendered.push('\n');
}
let bytes = rendered.as_bytes();
if bytes.len() > seen {
print!("{}", text_after(&rendered, seen));
use std::io::Write;
let _ = std::io::stdout().flush();
}
seen = bytes.len();
std::thread::sleep(std::time::Duration::from_secs(1));
}
}
pub fn parse_since(s: &str) -> Result<chrono::DateTime<chrono::FixedOffset>> { pub fn parse_since(s: &str) -> Result<chrono::DateTime<chrono::FixedOffset>> {
if let Ok(t) = chrono::DateTime::parse_from_rfc3339(s) { if let Ok(t) = chrono::DateTime::parse_from_rfc3339(s) {
return Ok(t); return Ok(t);
@@ -71,13 +99,25 @@ impl From<&Event> for LogRow {
} }
} }
/// Chars of text after byte offset seen (UTF-8 safe).
pub fn text_after(text: &str, seen: usize) -> String {
text.char_indices()
.filter(|(i, _)| *i >= seen)
.map(|(_, c)| c)
.collect()
}
pub fn run( pub fn run(
app: &App, app: &App,
agent: Option<&str>, agent: Option<&str>,
kinds: Option<&str>, kinds: Option<&str>,
since: Option<&str>, since: Option<&str>,
limit: usize, limit: usize,
follow: bool,
) -> Result<i32> { ) -> Result<i32> {
if follow {
return follow_loop(app, agent, kinds, since);
}
let events = filter_events(app, agent, kinds, since, limit)?; let events = filter_events(app, agent, kinds, since, limit)?;
if app.json() { if app.json() {
let rows: Vec<LogRow> = events.iter().map(LogRow::from).collect(); let rows: Vec<LogRow> = events.iter().map(LogRow::from).collect();
+8 -1
View File
@@ -78,12 +78,19 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
} => run_cmd::restart(app, start, *force, *timeout), } => run_cmd::restart(app, start, *force, *timeout),
Command::Status { agent } => status_cmd::run(app, agent.as_deref()), Command::Status { agent } => status_cmd::run(app, agent.as_deref()),
Command::Stats { agent, period } => stats_cmd::run(app, agent.as_deref(), period.as_deref()), Command::Stats { agent, period } => stats_cmd::run(app, agent.as_deref(), period.as_deref()),
Command::Top { period } => stats_cmd::run_top(app, period.as_deref()),
Command::Report {
last_week,
last_month,
output,
} => stats_cmd::run_report(app, *last_week, *last_month, output.as_deref()),
Command::Log { Command::Log {
agent, agent,
kind, kind,
since, since,
limit, limit,
} => log_cmd::run(app, agent.as_deref(), kind.as_deref(), since.as_deref(), *limit), follow,
} => log_cmd::run(app, agent.as_deref(), kind.as_deref(), since.as_deref(), *limit, *follow),
Command::Projects { name } => projects_cmd::run(app, name.as_deref()), Command::Projects { name } => projects_cmd::run(app, name.as_deref()),
Command::Timeline { Command::Timeline {
agent, agent,
+116
View File
@@ -3,6 +3,7 @@
use super::*; use super::*;
use crate::events::{self, EventKind}; use crate::events::{self, EventKind};
use crate::output::{print_json, Table}; use crate::output::{print_json, Table};
use anyhow::Context;
use serde::Serialize; use serde::Serialize;
use std::collections::BTreeMap; use std::collections::BTreeMap;
@@ -79,6 +80,61 @@ pub fn aggregate(app: &App, agent: Option<&str>, period: Option<&str>) -> Result
Ok(map.into_values().collect()) Ok(map.into_values().collect())
} }
/// Top 10 agents by launch count (issue #37).
pub fn top(app: &App, period: Option<&str>) -> Result<Vec<AgentStats>> {
let mut rows = aggregate(app, None, period)?;
rows.sort_by(|a, b| b.launches.cmp(&a.launches).then(b.total_duration_s.cmp(&a.total_duration_s)));
rows.truncate(10);
Ok(rows)
}
/// Markdown weekly/monthly report over the last N days (issue #37).
pub fn report_md(app: &App, days: u32) -> String {
let cut = chrono::Utc::now() - chrono::Duration::days(days as i64);
let events = events::read_events(&app.events_dir(), 0);
let mut total_launches = 0u64;
let mut failures = 0u64;
let mut changes = 0u64;
let mut duration = 0u64;
for ev in events {
let ok = chrono::DateTime::parse_from_rfc3339(&ev.ts)
.map(|t| t.with_timezone(&chrono::Utc) >= cut)
.unwrap_or(false);
if !ok {
continue;
}
match ev.kind {
EventKind::Start | EventKind::Run => total_launches += 1,
EventKind::Install | EventKind::Update | EventKind::Uninstall => changes += 1,
_ => {}
}
if let Some(d) = ev.duration_s {
duration += d;
}
if let Some(c) = ev.exit_code {
if c != 0 {
failures += 1;
}
}
}
let sessions = crate::sessions::load(app).map(|s| s.sessions.len()).unwrap_or(0);
let top_rows = top(app, None).unwrap_or_default();
let mut md = format!(
"# Rapport agent-manager — {days} derniers jours\n\n- Lancements : {total_launches}\n- Échecs : {failures}\n- Changements (install/update/uninstall) : {changes}\n- Durée cumulée : {}s\n- Sessions enregistrées : {sessions}\n",
duration
);
if !top_rows.is_empty() {
md.push_str("\n## Top agents\n\n| Agent | Lancements | Durée | Échecs |\n|---|---|---|---|\n");
for r in &top_rows {
md.push_str(&format!(
"| {} | {} | {}s | {} |\n",
r.agent, r.launches, r.total_duration_s, r.failures
));
}
}
md
}
pub fn run(app: &App, agent: Option<&str>, period: Option<&str>) -> Result<i32> { pub fn run(app: &App, agent: Option<&str>, period: Option<&str>) -> Result<i32> {
let rows = aggregate(app, agent, period)?; let rows = aggregate(app, agent, period)?;
if app.json() { if app.json() {
@@ -114,6 +170,66 @@ pub fn run(app: &App, agent: Option<&str>, period: Option<&str>) -> Result<i32>
Ok(0) Ok(0)
} }
/// am top : the most used agents (issue #37).
pub fn run_top(app: &App, period: Option<&str>) -> Result<i32> {
let rows = top(app, period)?;
if app.json() {
print_json(&rows);
return Ok(0);
}
let color = app.color();
let theme = app.theme();
if rows.is_empty() {
app.log.info("no usage recorded yet");
return Ok(0);
}
let max_launches = rows.iter().map(|r| r.launches).max().unwrap_or(1);
let mut table = Table::new(vec!["AGENT", "LAUNCHES", "DURATION", "FAIL"]);
for r in &rows {
table.row(vec![
r.agent.clone(),
format!("{} {}", r.launches, bar(r.launches, max_launches, 8)),
format_duration(r.total_duration_s),
if r.failures > 0 {
crate::output::status_style("error", theme, color)
} else {
r.failures.to_string()
},
]);
}
print!("{}", table.render());
Ok(0)
}
/// am report : write the markdown digest (issue #37).
pub fn run_report(
app: &App,
last_week: bool,
last_month: bool,
output: Option<&std::path::Path>,
) -> Result<i32> {
let days: u32 = if last_week { 7 } else { 30 };
let md = report_md(app, days);
let path = match output {
Some(p) => p.to_path_buf(),
None => {
let stamp = chrono::Local::now().format("%Y%m%d");
app.events_dir().join(format!("report-{stamp}.md"))
}
};
if app.dry_run() {
app.log.dry(format!("would write {}", path.display()));
return Ok(0);
}
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
std::fs::write(&path, md)
.with_context(|| format!("cannot write {}", path.display()))?;
app.log.success(&format!("report written to {}", path.display()));
Ok(0)
}
/// ASCII bar: relative weight of v against max. /// ASCII bar: relative weight of v against max.
fn bar(v: u64, max: u64, width: usize) -> String { fn bar(v: u64, max: u64, width: usize) -> String {
if max == 0 { if max == 0 {
+35
View File
@@ -217,6 +217,41 @@ pub static HELP_SPECS: &[HelpSpec] = &[
HelpExample { desc: "One agent over the last month.", code: "stats claude-code --period 30d" }, HelpExample { desc: "One agent over the last month.", code: "stats claude-code --period 30d" },
], ],
}, },
HelpSpec {
name: "top",
category: "Commands",
usage: "top {flags}",
about: "Show the most used agents (top 10).",
search_terms: &["ranking", "most", "usage"],
flags: &[
HelpFlag { short: "", long: "--period", value: "PERIOD", desc: "Only events of the last period (7d, 30d, 90d, all)" },
],
subcommands: &[],
parameters: &[],
io: None,
examples: &[
HelpExample { desc: "The most used agents.", code: "top" },
],
},
HelpSpec {
name: "report",
category: "Commands",
usage: "report {flags}",
about: "Write a markdown activity report.",
search_terms: &["digest", "weekly", "monthly"],
flags: &[
HelpFlag { short: "", long: "--last-week", value: "", desc: "Cover the last 7 days" },
HelpFlag { short: "", long: "--last-month", value: "", desc: "Cover the last 30 days" },
HelpFlag { short: "", long: "--output", value: "FILE", desc: "Output file (default: report-YYYYMMDD.md in the state directory)" },
],
subcommands: &[],
parameters: &[],
io: None,
examples: &[
HelpExample { desc: "Weekly digest.", code: "report --last-week" },
HelpExample { desc: "Monthly digest to a file.", code: "report --last-month --output ~/rapport-am.md" },
],
},
HelpSpec { HelpSpec {
name: "projects", name: "projects",
category: "Commands", category: "Commands",
+14 -1
View File
@@ -62,6 +62,8 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
("status", "show agent state"), ("status", "show agent state"),
("sessions", "list agent sessions"), ("sessions", "list agent sessions"),
("stats", "usage statistics"), ("stats", "usage statistics"),
("top", "most used agents"),
("report", "activity report"),
("projects", "project activity"), ("projects", "project activity"),
("timeline", "unified activity view"), ("timeline", "unified activity view"),
("log", "read the event journal"), ("log", "read the event journal"),
@@ -147,7 +149,7 @@ impl AmCompleter {
Self { Self {
theme: std::cell::Cell::new(crate::theme::default_theme()), theme: std::cell::Cell::new(crate::theme::default_theme()),
commands: vec![ commands: vec![
"list", "status", "sessions", "stats", "projects", "timeline", "log", "logs", "history", "init", "alias", "open", "watch", "search", "info", "install", "uninstall", "update", "list", "status", "sessions", "stats", "top", "report", "projects", "timeline", "log", "logs", "history", "init", "alias", "open", "watch", "search", "info", "install", "uninstall", "update",
"start", "stop", "restart", "run", "doctor", "config", "completion", "start", "stop", "restart", "run", "doctor", "config", "completion",
"self-update", "self-uninstall", "export", "import", "shell", "theme", "self-update", "self-uninstall", "export", "import", "shell", "theme",
"ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit", "ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
@@ -1265,6 +1267,14 @@ fn handle_line(
agent: rest.first().cloned(), agent: rest.first().cloned(),
period: opt_value("--period"), period: opt_value("--period"),
}, },
"top" => Command::Top {
period: opt_value("--period"),
},
"report" => Command::Report {
last_week: flag("--last-week"),
last_month: flag("--last-month"),
output: opt_value("--output").map(PathBuf::from),
},
"projects" => Command::Projects { "projects" => Command::Projects {
name: rest.first().cloned(), name: rest.first().cloned(),
}, },
@@ -1279,6 +1289,7 @@ fn handle_line(
kind: opt_value("--kind"), kind: opt_value("--kind"),
since: opt_value("--since"), since: opt_value("--since"),
limit: opt_value("--limit").and_then(|v| v.parse().ok()).unwrap_or(50), limit: opt_value("--limit").and_then(|v| v.parse().ok()).unwrap_or(50),
follow: flag("--follow"),
}, },
"history" => Command::History { "history" => Command::History {
kind: opt_value("--kind"), kind: opt_value("--kind"),
@@ -1397,6 +1408,8 @@ fn is_am_command(word: &str) -> bool {
| "status" | "status"
| "sessions" | "sessions"
| "stats" | "stats"
| "top"
| "report"
| "projects" | "projects"
| "timeline" | "timeline"
| "log" | "log"
+97
View File
@@ -0,0 +1,97 @@
//! top, report and log --follow (issues #37 #38).
mod common;
use agent_manager::commands::stats_cmd;
use agent_manager::events;
fn app_with_events(tag: &str, lines: &[&str]) -> agent_manager::app::App {
let app = common::test_app(tag, &["stats"]);
let month = chrono::Utc::now().format("%Y%m").to_string();
let path = app.events_dir().join(format!("events-{month}.jsonl"));
std::fs::create_dir_all(app.events_dir()).unwrap();
let mut text = String::new();
for l in lines {
text.push_str(l);
text.push('\n');
}
std::fs::write(&path, text).unwrap();
app
}
#[test]
fn top_sorts_by_launches() {
let app = app_with_events(
"top",
&[
r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"a","exit_code":0}"#,
r#"{"ts":"2026-08-02T10:00:00Z","kind":"run","agent":"a","exit_code":0}"#,
r#"{"ts":"2026-08-03T10:00:00Z","kind":"run","agent":"b","exit_code":0}"#,
],
);
let rows = stats_cmd::top(&app, None).unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].agent, "a");
assert_eq!(rows[0].launches, 2);
}
#[test]
fn report_md_contains_numbers_and_table() {
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
let lines = [
format!(r#"{{"ts":"{today}T10:00:00Z","kind":"run","agent":"a","exit_code":0,"duration_s":10}}"#),
format!(r#"{{"ts":"{today}T10:01:00Z","kind":"run","agent":"a","exit_code":1,"duration_s":5}}"#),
format!(r#"{{"ts":"{today}T10:02:00Z","kind":"install","agent":"b"}}"#),
];
let refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
let app = app_with_events("report", &refs);
let md = stats_cmd::report_md(&app, 7);
assert!(md.contains("# Rapport agent-manager"), "md: {md}");
assert!(md.contains("Lancements : 2"));
assert!(md.contains("Échecs : 1"));
assert!(md.contains("Changements"));
assert!(md.contains("| a | 2 |"), "table top: {md}");
}
#[test]
fn report_command_writes_file() {
let app = app_with_events("reportcmd", &[]);
let out = app.events_dir().join("report-test.md");
let code = stats_cmd::run_report(&app, true, false, Some(&out)).unwrap();
assert_eq!(code, 0);
assert!(out.exists());
let _ = std::fs::remove_file(&out);
}
#[test]
fn text_after_is_utf8_safe() {
use agent_manager::commands::log_cmd;
let text = "héllo wörld
second line
";
assert_eq!(log_cmd::text_after(text, 0), text);
assert_eq!(log_cmd::text_after(text, 3), "llo wörld
second line
");
let full = text.len();
assert_eq!(log_cmd::text_after(text, full), "");
}
#[test]
fn follow_flag_parses() {
use agent_manager::cli::{Cli, Command};
use clap::Parser;
let cli = Cli::parse_from(["am", "log", "--follow"]);
match &cli.command {
Some(Command::Log { follow, .. }) => assert!(*follow),
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn journal_still_reads_after_events_api() {
let app = app_with_events("api", &[]);
let mut log = events::EventLog::new(app.events_dir());
log.append(&events::Event::now(events::EventKind::Run).with_agent("x")).unwrap();
assert_eq!(events::read_events(&app.events_dir(), 0).len(), 1);
}