From 33748bea366fa536434a4572073677837b41fbbd Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sun, 16 Aug 2026 22:30:41 -0400 Subject: [PATCH] stats+log : am stats (agregats par agent, periodes 7d/30d/90d, barres ASCII) et am log (filtres agent/kind/date, limite) (issue #6) --- src/cli.rs | 22 ++++ src/commands/log_cmd.rs | 120 +++++++++++++++++++++ src/commands/mod.rs | 9 ++ src/commands/stats_cmd.rs | 212 ++++++++++++++++++++++++++++++++++++++ src/events.rs | 18 ++++ src/help.rs | 40 +++++++ src/repl.rs | 4 +- tests/stats_test.rs | 110 ++++++++++++++++++++ 8 files changed, 534 insertions(+), 1 deletion(-) create mode 100644 src/commands/log_cmd.rs create mode 100644 src/commands/stats_cmd.rs create mode 100644 tests/stats_test.rs diff --git a/src/cli.rs b/src/cli.rs index a3f6005..7200884 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -112,6 +112,28 @@ pub enum Command { #[arg(long, value_name = "ID")] show: Option, }, + /// Show usage statistics computed from the event journal + Stats { + /// Agent name or alias (omit for the global view) + agent: Option, + /// Only events of the last period (7d, 30d, 90d, all) + #[arg(long, value_name = "PERIOD")] + period: Option, + }, + /// Read the event journal: everything am did + Log { + /// Only events of this agent + agent: Option, + /// Only these event kinds (comma separated: start,stop,run,install,...) + #[arg(long, value_name = "KINDS")] + kind: Option, + /// Only events at or after this date (YYYY-MM-DD or RFC 3339) + #[arg(long, value_name = "DATE")] + since: Option, + /// Maximum number of events (default: 50) + #[arg(long, value_name = "N", default_value = "50")] + limit: usize, + }, /// Install an agent and its dependencies Install { /// Agent name or alias diff --git a/src/commands/log_cmd.rs b/src/commands/log_cmd.rs new file mode 100644 index 0000000..93a11b3 --- /dev/null +++ b/src/commands/log_cmd.rs @@ -0,0 +1,120 @@ +//! log: a filtered view of the event journal — everything am did. + +use super::*; +use crate::events::{self, Event}; +use crate::output::print_json; +use serde::Serialize; + +/// Filter journal events: agent, kind list, since date, and a max count. +/// Returns events in reverse chronological order (most recent first). +pub fn filter_events( + app: &App, + agent: Option<&str>, + kinds: Option<&str>, + since: Option<&str>, + limit: usize, +) -> Result> { + let mut events = events::read_events(&app.events_dir(), 0); + if let Some(a) = agent { + events.retain(|e| e.agent.as_deref() == Some(a)); + } + if let Some(list) = kinds { + let wanted: Vec<&str> = list.split(',').map(|s| s.trim()).collect(); + events.retain(|e| wanted.iter().any(|w| e.kind.as_str() == *w)); + } + if let Some(s) = since { + let cut = parse_since(s)?; + events.retain(|e| { + chrono::DateTime::parse_from_rfc3339(&e.ts) + .map(|t| t >= cut) + .unwrap_or(false) + }); + } + events.reverse(); // most recent first + events.truncate(limit); + Ok(events) +} + +fn parse_since(s: &str) -> Result> { + if let Ok(t) = chrono::DateTime::parse_from_rfc3339(s) { + return Ok(t); + } + chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") + .map_err(|_| anyhow!("invalid date '{s}' — expected YYYY-MM-DD or an RFC 3339 timestamp"))? + .and_hms_opt(0, 0, 0) + .map(|d| chrono::DateTime::from_naive_utc_and_offset(d, chrono::FixedOffset::east_opt(0).unwrap())) + .ok_or_else(|| anyhow!("invalid date '{s}'")) +} + +#[derive(Serialize)] +struct LogRow { + ts: String, + kind: String, + agent: Option, + pid: Option, + exit_code: Option, + duration_s: Option, + session: Option, +} + +impl From<&Event> for LogRow { + fn from(e: &Event) -> Self { + LogRow { + ts: e.ts.clone(), + kind: e.kind.as_str().to_string(), + agent: e.agent.clone(), + pid: e.pid, + exit_code: e.exit_code, + duration_s: e.duration_s, + session: e.session.clone(), + } + } +} + +pub fn run( + app: &App, + agent: Option<&str>, + kinds: Option<&str>, + since: Option<&str>, + limit: usize, +) -> Result { + let events = filter_events(app, agent, kinds, since, limit)?; + if app.json() { + let rows: Vec = events.iter().map(LogRow::from).collect(); + print_json(&rows); + return Ok(0); + } + for e in &events { + let mut parts = vec![e.ts.clone(), e.kind.as_str().to_string()]; + if let Some(a) = &e.agent { + parts.push(a.clone()); + } + if let Some(p) = e.pid { + parts.push(format!("pid={p}")); + } + if let Some(c) = e.exit_code { + parts.push(format!("exit={c}")); + } + if let Some(d) = e.duration_s { + parts.push(format!("dur={d}s")); + } + println!("{}", parts.join(" ")); + } + if events.is_empty() { + app.log.info("no matching events in the journal"); + } + Ok(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_since_accepts_date_and_timestamp() { + let d = parse_since("2026-08-01").unwrap(); + assert_eq!(d.format("%Y-%m-%d").to_string(), "2026-08-01"); + assert!(parse_since("2026-08-01T00:00:00Z").is_ok()); + assert!(parse_since("hier").is_err()); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index f7941f8..f31fbfd 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -8,10 +8,12 @@ pub mod help_cmd; pub mod info_cmd; pub mod install_cmd; pub mod list_cmd; +pub mod log_cmd; pub mod run_cmd; pub mod search_cmd; pub mod self_uninstall; pub mod sessions_cmd; +pub mod stats_cmd; pub mod self_update; pub mod status_cmd; pub mod uninstall_cmd; @@ -49,6 +51,13 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result { timeout, } => run_cmd::restart(app, start, *force, *timeout), 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::Log { + agent, + kind, + since, + limit, + } => log_cmd::run(app, agent.as_deref(), kind.as_deref(), since.as_deref(), *limit), Command::Sessions { agent, project, diff --git a/src/commands/stats_cmd.rs b/src/commands/stats_cmd.rs new file mode 100644 index 0000000..0a04eae --- /dev/null +++ b/src/commands/stats_cmd.rs @@ -0,0 +1,212 @@ +//! stats: usage statistics computed from the event journal. + +use super::*; +use crate::events::{self, EventKind}; +use crate::output::{print_json, Table}; +use serde::Serialize; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Default, Serialize)] +pub struct AgentStats { + pub agent: String, + pub launches: u64, + pub total_duration_s: u64, + pub avg_duration_s: u64, + pub failures: u64, + pub installs: u64, + pub updates: u64, + pub last_used: Option, +} + +/// Cutoff instant for a period: 7d / 30d / 90d, or None for everything. +pub fn cutoff(period: Option<&str>) -> Result>> { + let days = match period { + None | Some("all") => return Ok(None), + Some("7d") => 7, + Some("30d") => 30, + Some("90d") => 90, + Some(other) => bail!("invalid period '{other}' — expected 7d, 30d, 90d or all"), + }; + Ok(Some(chrono::Utc::now() - chrono::Duration::days(days))) +} + +/// Aggregate journal events into per-agent statistics. +pub fn aggregate(app: &App, agent: Option<&str>, period: Option<&str>) -> Result> { + let cut = cutoff(period)?; + let events = events::read_events(&app.events_dir(), 0); + let mut map: BTreeMap = BTreeMap::new(); + let mut dur_count: BTreeMap = BTreeMap::new(); + for ev in events { + if let Some(c) = cut { + let Ok(ts) = chrono::DateTime::parse_from_rfc3339(&ev.ts) else { + continue; + }; + if ts.with_timezone(&chrono::Utc) < c { + continue; + } + } + let Some(name) = ev.agent else { + continue; + }; + if let Some(a) = agent { + if name != a { + continue; + } + } + let s = map.entry(name.clone()).or_default(); + s.agent = name.clone(); + match ev.kind { + EventKind::Start | EventKind::Run => s.launches += 1, + EventKind::Install => s.installs += 1, + EventKind::Update => s.updates += 1, + _ => {} + } + if let Some(d) = ev.duration_s { + s.total_duration_s += d; + *dur_count.entry(name.clone()).or_default() += 1; + } + if let Some(code) = ev.exit_code { + if code != 0 { + s.failures += 1; + } + } + s.last_used = Some(ev.ts); + } + for (name, s) in map.iter_mut() { + let n = dur_count.get(name).copied().unwrap_or(0); + s.avg_duration_s = if n > 0 { s.total_duration_s / n } else { 0 }; + } + Ok(map.into_values().collect()) +} + +pub fn run(app: &App, agent: Option<&str>, period: Option<&str>) -> Result { + let rows = aggregate(app, agent, 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 — run or start an agent first"); + 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", "AVG", "FAIL", "LAST"]); + 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), + format_duration(r.avg_duration_s), + if r.failures > 0 { + crate::output::status_style("error", theme, color) + } else { + r.failures.to_string() + }, + r.last_used + .as_deref() + .map(|t| t[..t.len().min(10)].to_string()) + .unwrap_or_else(|| "—".to_string()), + ]); + } + print!("{}", table.render()); + Ok(0) +} + +/// ASCII bar: relative weight of v against max. +fn bar(v: u64, max: u64, width: usize) -> String { + if max == 0 { + return String::new(); + } + let n = ((v * width as u64) / max).max(if v > 0 { 1 } else { 0 }) as usize; + format!("{}{}", "█".repeat(n), "░".repeat(width.saturating_sub(n))) +} + +fn format_duration(secs: u64) -> String { + if secs == 0 { + return "—".to_string(); + } + if secs < 60 { + format!("{secs}s") + } else if secs < 3600 { + format!("{}m{:02}s", secs / 60, secs % 60) + } else { + format!("{}h{:02}m", secs / 3600, (secs % 3600) / 60) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + fn app_with_events(events_jsonl: &str) -> crate::app::App { + // The TempDir guard would delete the directory when this helper + // returns; leak it on purpose so the App keeps a live events dir. + let guard = tempfile::tempdir().unwrap(); + let dir = guard.path().to_path_buf(); + std::mem::forget(guard); + let cfg = dir.join("config.yaml"); + std::fs::write( + &cfg, + concat!( + "version: \"1.0\"\n", + "settings:\n", + " auto_install_deps: false\n", + "agents: []\n", + ), + ) + .unwrap(); + let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]); + let mut app = crate::app::App::from_cli(cli).unwrap(); + let mut p = app.paths.clone(); + p.install_dir = dir.join("agents"); + p.bin_dir = dir.join("agents").join("bin"); + p.log_dir = dir.join("logs"); + p.state_file = dir.join("state.json"); + p.probe_cache_file = dir.join("probe-cache.json"); + p.config_dir = Some(dir.join("config")); + app.paths = p; + std::fs::write(dir.join("events-test.jsonl"), events_jsonl).unwrap(); + // Rename into the right month file so read_events picks it up. + let month = chrono::Utc::now().format("%Y%m"); + std::fs::rename( + dir.join("events-test.jsonl"), + dir.join(format!("events-{month}.jsonl")), + ) + .unwrap(); + app + } + + #[test] + fn aggregates_counts_durations_and_failures() { + let lines = [ + r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"a","exit_code":0,"duration_s":10}"#, + r#"{"ts":"2026-08-02T10:00:00Z","kind":"run","agent":"a","exit_code":0,"duration_s":30}"#, + r#"{"ts":"2026-08-03T10:00:00Z","kind":"run","agent":"b","exit_code":1,"duration_s":5}"#, + r#"{"ts":"2026-08-03T11:00:00Z","kind":"install","agent":"a"}"#, + ]; + let app = app_with_events(&lines.join("\n")); + let rows = aggregate(&app, None, None).unwrap(); + let a = rows.iter().find(|r| r.agent == "a").unwrap(); + assert_eq!(a.launches, 2); + assert_eq!(a.total_duration_s, 40); + assert_eq!(a.avg_duration_s, 20); + assert_eq!(a.failures, 0); + assert_eq!(a.installs, 1); + let b = rows.iter().find(|r| r.agent == "b").unwrap(); + assert_eq!(b.failures, 1); + assert_eq!(b.launches, 1); + } + + #[test] + fn period_cuts_old_events() { + let app = app_with_events( + r#"{"ts":"2020-01-01T10:00:00Z","kind":"run","agent":"old","exit_code":0}"#, + ); + assert!(aggregate(&app, None, Some("30d")).unwrap().is_empty()); + assert_eq!(aggregate(&app, None, None).unwrap().len(), 1); + assert!(cutoff(Some("bad")).is_err()); + } +} diff --git a/src/events.rs b/src/events.rs index 979c4d7..ef868b7 100644 --- a/src/events.rs +++ b/src/events.rs @@ -32,6 +32,24 @@ pub enum EventKind { Shell, } +impl EventKind { + pub fn as_str(&self) -> &'static str { + match self { + EventKind::Start => "start", + EventKind::Stop => "stop", + EventKind::Run => "run", + EventKind::Restart => "restart", + EventKind::Install => "install", + EventKind::Update => "update", + EventKind::Uninstall => "uninstall", + EventKind::Doctor => "doctor", + EventKind::Config => "config", + EventKind::Repl => "repl", + EventKind::Shell => "shell", + } + } +} + /// One journal entry. Optional fields are omitted from the JSON line. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Event { diff --git a/src/help.rs b/src/help.rs index b10c657..857aeca 100644 --- a/src/help.rs +++ b/src/help.rs @@ -198,6 +198,46 @@ pub static HELP_SPECS: &[HelpSpec] = &[ HelpExample { desc: "Show one session with its log excerpt.", code: "sessions --show 20260815_143926_a1b2c3" }, ], }, + HelpSpec { + name: "stats", + category: "Commands", + usage: "stats {flags} [agent]", + about: "Show usage statistics computed from the event journal.", + search_terms: &["usage", "metrics", "duration"], + flags: &[ + HelpFlag { short: "", long: "--period", value: "PERIOD", desc: "Only events of the last period (7d, 30d, 90d, all)" }, + ], + subcommands: &[], + parameters: &[ + HelpParam { name: "agent", typ: "string", desc: "Agent name or alias (omit for the global view)" }, + ], + io: None, + examples: &[ + HelpExample { desc: "Global usage statistics.", code: "stats" }, + HelpExample { desc: "One agent over the last month.", code: "stats claude-code --period 30d" }, + ], + }, + HelpSpec { + name: "log", + category: "Commands", + usage: "log {flags} [agent]", + about: "Read the event journal: everything am did.", + search_terms: &["journal", "events", "history"], + flags: &[ + HelpFlag { short: "", long: "--kind", value: "KINDS", desc: "Only these event kinds (comma separated)" }, + HelpFlag { short: "", long: "--since", value: "DATE", desc: "Only events at or after this date (YYYY-MM-DD or RFC 3339)" }, + HelpFlag { short: "", long: "--limit", value: "N", desc: "Maximum number of events (default: 50)" }, + ], + subcommands: &[], + parameters: &[ + HelpParam { name: "agent", typ: "string", desc: "Only events of this agent" }, + ], + io: None, + examples: &[ + HelpExample { desc: "The last 50 events.", code: "log" }, + HelpExample { desc: "Starts of one agent since a date.", code: "log claude-code --kind start --since 2026-08-01" }, + ], + }, HelpSpec { name: "search", category: "Commands", diff --git a/src/repl.rs b/src/repl.rs index 7ab01eb..f26dacc 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -61,6 +61,8 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[ ("list", "list installed agents"), ("status", "show agent state"), ("sessions", "list agent sessions"), + ("stats", "usage statistics"), + ("log", "read the event journal"), ("search", "search the catalog"), ("info", "agent details"), ("install", "install an agent"), @@ -137,7 +139,7 @@ impl AmCompleter { Self { theme: std::cell::Cell::new(crate::theme::default_theme()), commands: vec![ - "list", "status", "sessions", "search", "info", "install", "uninstall", "update", + "list", "status", "sessions", "stats", "log", "search", "info", "install", "uninstall", "update", "start", "stop", "restart", "run", "doctor", "config", "completion", "self-update", "self-uninstall", "export", "import", "shell", "theme", "ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit", diff --git a/tests/stats_test.rs b/tests/stats_test.rs new file mode 100644 index 0000000..c9f6c2f --- /dev/null +++ b/tests/stats_test.rs @@ -0,0 +1,110 @@ +//! stats + log: aggregation and filtering over the event journal. + +mod common; + +use agent_manager::app::App; +use agent_manager::commands::log_cmd; +use agent_manager::commands::stats_cmd; +use agent_manager::events; + +fn app_with_events(tag: &str, lines: &[&str]) -> 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")); + let mut text = String::new(); + for l in lines { + text.push_str(l); + text.push('\n'); + } + std::fs::create_dir_all(app.events_dir()).unwrap(); + std::fs::write(&path, text).unwrap(); + app +} + +#[test] +fn stats_aggregate_counts_everything() { + let app = app_with_events( + "agg", + &[ + r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"a","exit_code":0,"duration_s":10}"#, + r#"{"ts":"2026-08-02T10:00:00Z","kind":"run","agent":"a","exit_code":0,"duration_s":30}"#, + r#"{"ts":"2026-08-03T10:00:00Z","kind":"run","agent":"b","exit_code":1,"duration_s":5}"#, + r#"{"ts":"2026-08-03T11:00:00Z","kind":"install","agent":"a"}"#, + r#"{"ts":"2026-08-03T12:00:00Z","kind":"update","agent":"a"}"#, + ], + ); + let rows = stats_cmd::aggregate(&app, None, None).unwrap(); + let a = rows.iter().find(|r| r.agent == "a").unwrap(); + assert_eq!(a.launches, 2); + assert_eq!(a.total_duration_s, 40); + assert_eq!(a.avg_duration_s, 20); + assert_eq!(a.failures, 0); + assert_eq!(a.installs, 1); + assert_eq!(a.updates, 1); + let b = rows.iter().find(|r| r.agent == "b").unwrap(); + assert_eq!(b.failures, 1); + assert_eq!(b.launches, 1); +} + +#[test] +fn stats_period_cuts_old_events() { + let app = app_with_events( + "period", + &[r#"{"ts":"2020-01-01T10:00:00Z","kind":"run","agent":"old","exit_code":0}"#], + ); + assert!(stats_cmd::aggregate(&app, None, Some("30d")).unwrap().is_empty()); + assert_eq!(stats_cmd::aggregate(&app, None, None).unwrap().len(), 1); + assert!(stats_cmd::aggregate(&app, None, Some("7d")).unwrap().is_empty()); +} + +#[test] +fn log_filters_by_agent_kind_and_limit() { + let app = app_with_events( + "logf", + &[ + r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"a","exit_code":0}"#, + r#"{"ts":"2026-08-01T10:00:01Z","kind":"start","agent":"a","pid":42}"#, + r#"{"ts":"2026-08-01T10:00:02Z","kind":"run","agent":"b","exit_code":1}"#, + ], + ); + let all = log_cmd::filter_events(&app, None, None, None, 50).unwrap(); + assert_eq!(all.len(), 3); + assert_eq!(all[0].agent.as_deref(), Some("b"), "most recent first"); + + let only_a = log_cmd::filter_events(&app, Some("a"), None, None, 50).unwrap(); + assert_eq!(only_a.len(), 2); + + let starts = log_cmd::filter_events(&app, None, Some("start"), None, 50).unwrap(); + assert_eq!(starts.len(), 1); + assert_eq!(starts[0].pid, Some(42)); + + let limited = log_cmd::filter_events(&app, None, None, None, 1).unwrap(); + assert_eq!(limited.len(), 1); + + let since = log_cmd::filter_events(&app, None, None, Some("2026-09-01"), 50).unwrap(); + assert!(since.is_empty()); +} + +#[test] +fn stats_command_runs() { + let app = app_with_events( + "cmd", + &[r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"a","exit_code":0}"#], + ); + let code = stats_cmd::run(&app, None, None).unwrap(); + assert_eq!(code, 0); +} + +#[test] +fn journal_reads_back_through_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") + .with_exit_code(0), + ) + .unwrap(); + let all = events::read_events(&app.events_dir(), 0); + assert_eq!(all.len(), 1); +} -- 2.54.0