From ed795c522fd4dc01ae74bb341a7ef564ce643ff6 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Mon, 17 Aug 2026 08:45:03 -0400 Subject: [PATCH] timeline : vue chronologique unifiee journal + historique REPL (issue #31) - filtres agent/projet/date, limite, parse_since partage --- src/cli.rs | 14 +++ src/commands/log_cmd.rs | 2 +- src/commands/mod.rs | 7 ++ src/commands/timeline_cmd.rs | 179 +++++++++++++++++++++++++++++++++++ src/help.rs | 21 ++++ src/repl.rs | 10 +- 6 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 src/commands/timeline_cmd.rs diff --git a/src/cli.rs b/src/cli.rs index 6f956d9..134f6a6 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -150,6 +150,20 @@ pub enum Command { /// Project name (omit to list every project) name: Option, }, + /// One chronological view of every activity (journal + REPL history) + Timeline { + /// Only activity of this agent + agent: Option, + /// Only activity of this project + #[arg(long, value_name = "PROJECT")] + project: Option, + /// Only activity at or after this date (YYYY-MM-DD or RFC 3339) + #[arg(long, value_name = "DATE")] + since: Option, + /// Maximum number of entries (default: 50) + #[arg(long, value_name = "N", default_value = "50")] + limit: usize, + }, /// Read the event journal: everything am did Log { /// Only events of this agent diff --git a/src/commands/log_cmd.rs b/src/commands/log_cmd.rs index 93a11b3..7a00fc4 100644 --- a/src/commands/log_cmd.rs +++ b/src/commands/log_cmd.rs @@ -35,7 +35,7 @@ pub fn filter_events( Ok(events) } -fn parse_since(s: &str) -> Result> { +pub fn parse_since(s: &str) -> Result> { if let Ok(t) = chrono::DateTime::parse_from_rfc3339(s) { return Ok(t); } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 37c7e90..0283268 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -22,6 +22,7 @@ pub mod sessions_cmd; pub mod stats_cmd; pub mod self_update; pub mod status_cmd; +pub mod timeline_cmd; pub mod uninstall_cmd; pub mod update_cmd; pub mod version_cmd; @@ -77,6 +78,12 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result { limit, } => log_cmd::run(app, agent.as_deref(), kind.as_deref(), since.as_deref(), *limit), Command::Projects { name } => projects_cmd::run(app, name.as_deref()), + Command::Timeline { + agent, + project, + since, + limit, + } => timeline_cmd::run(app, agent.as_deref(), project.as_deref(), since.as_deref(), *limit), Command::Sessions { agent, project, diff --git a/src/commands/timeline_cmd.rs b/src/commands/timeline_cmd.rs new file mode 100644 index 0000000..f40f326 --- /dev/null +++ b/src/commands/timeline_cmd.rs @@ -0,0 +1,179 @@ +//! timeline: one chronological view over the event journal and the REPL +//! command history. + +use super::*; +use crate::events::{self, Event}; +use crate::history::{self, HistoryEntry}; +use crate::output::print_json; +use anyhow::Result; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +pub struct TimelineRow { + pub ts: String, + pub source: String, + pub kind: String, + pub agent: Option, + pub summary: String, +} + +/// Merge events and history entries into one chronological list, most +/// recent first, filtered by agent/project/date and capped by limit. +pub fn collect( + app: &App, + agent: Option<&str>, + project: Option<&str>, + since: Option<&str>, + limit: usize, +) -> Result> { + let cut = match since { + Some(s) => Some(crate::commands::log_cmd::parse_since(s)?), + None => None, + }; + let mut rows: Vec = Vec::new(); + for ev in events::read_events(&app.events_dir(), 0) { + if agent.map_or(true, |a| ev.agent.as_deref() == Some(a)) + && project.map_or(true, |p| ev.project.as_deref() == Some(p)) + { + if let Some(c) = cut { + if chrono::DateTime::parse_from_rfc3339(&ev.ts) + .map(|t| t < c) + .unwrap_or(true) + { + continue; + } + } + rows.push(event_row(&ev)); + } + } + // REPL history entries carry no agent/project: include them only + // when no such filter is set. + if agent.is_none() && project.is_none() { + for (_, e) in crate::commands::history_cmd::filter(app, None, None, None, false, None, 0) { + let ts_ok = match cut { + Some(c) => chrono::DateTime::parse_from_rfc3339(&e.ts) + .map(|t| t >= c) + .unwrap_or(false), + None => true, + }; + if !ts_ok { + continue; + } + rows.push(TimelineRow { + ts: e.ts.clone(), + source: "repl".to_string(), + kind: e.kind.clone(), + agent: e.agent.clone(), + summary: e.cmd.clone(), + }); + } + } + rows.sort_by(|a, b| b.ts.cmp(&a.ts)); + rows.truncate(if limit > 0 { limit } else { rows.len() }); + Ok(rows) +} + +fn event_row(ev: &Event) -> TimelineRow { + let mut parts = Vec::new(); + if let Some(p) = ev.pid { + parts.push(format!("pid {p}")); + } + if let Some(d) = ev.duration_s { + parts.push(format!("{d}s")); + } + if let Some(c) = ev.exit_code { + parts.push(format!("exit {c}")); + } + TimelineRow { + ts: ev.ts.clone(), + source: "journal".to_string(), + kind: ev.kind.as_str().to_string(), + agent: ev.agent.clone(), + summary: if parts.is_empty() { + ev.project.clone().unwrap_or_default() + } else { + parts.join(", ") + }, + } +} + +pub fn run( + app: &App, + agent: Option<&str>, + project: Option<&str>, + since: Option<&str>, + limit: usize, +) -> Result { + let rows = collect(app, agent, project, since, limit)?; + if app.json() { + print_json(&rows); + return Ok(0); + } + for r in &rows { + let ts = r.ts.get(0..16).unwrap_or(&r.ts); + let actor = r.agent.as_deref().unwrap_or("repl"); + println!("{ts} {:<9} {:<16} {}", r.kind, actor, r.summary); + } + if rows.is_empty() { + app.log.info("no activity recorded yet"); + } + Ok(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + fn app_with(lines: &[&str]) -> App { + 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 = 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; + let month = chrono::Utc::now().format("%Y%m"); + let path = dir.join(format!("events-{month}.jsonl")); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(&path, lines.join("\n")).unwrap(); + app + } + + #[test] + fn merges_events_and_history_chronologically() { + let app = app_with(&[ + r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"a","exit_code":0,"duration_s":5}"#, + r#"{"ts":"2026-08-03T10:00:00Z","kind":"start","agent":"a","pid":42}"#, + ]); + history::record(&app, "s1", &history::HistoryEntry::now("list --all")).unwrap(); + let rows = collect(&app, None, None, None, 0).unwrap(); + assert!(rows.len() >= 3, "events + history: {rows:?}"); + // Ordre antichronologique strict. + let ts: Vec<&str> = rows.iter().map(|r| r.ts.as_str()).collect(); + let mut sorted = ts.clone(); + sorted.sort_by(|a, b| b.cmp(a)); + assert_eq!(ts, sorted); + // Filtres. + assert_eq!(collect(&app, Some("a"), None, None, 0).unwrap().len(), 2); + assert_eq!(collect(&app, Some("zzz"), None, None, 0).unwrap().len(), 0); + assert_eq!(collect(&app, None, None, None, 2).unwrap().len(), 2); + } +} diff --git a/src/help.rs b/src/help.rs index 5d4a074..fdb2ea5 100644 --- a/src/help.rs +++ b/src/help.rs @@ -234,6 +234,27 @@ pub static HELP_SPECS: &[HelpSpec] = &[ HelpExample { desc: "One project in detail.", code: "projects api" }, ], }, + HelpSpec { + name: "timeline", + category: "Commands", + usage: "timeline {flags}", + about: "One chronological view of every activity (journal + REPL history).", + search_terms: &["chronology", "activity", "merged"], + flags: &[ + HelpFlag { short: "", long: "--project", value: "PROJECT", desc: "Only activity of this project" }, + HelpFlag { short: "", long: "--since", value: "DATE", desc: "Only activity at or after this date" }, + HelpFlag { short: "", long: "--limit", value: "N", desc: "Maximum number of entries (default: 50)" }, + ], + subcommands: &[], + parameters: &[ + HelpParam { name: "agent", typ: "string", desc: "Only activity of this agent" }, + ], + io: None, + examples: &[ + HelpExample { desc: "The last 50 activities.", code: "timeline" }, + HelpExample { desc: "One project since a date.", code: "timeline --project api --since 2026-08-01" }, + ], + }, HelpSpec { name: "log", category: "Commands", diff --git a/src/repl.rs b/src/repl.rs index ca2f0f1..2213a5a 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -63,6 +63,7 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[ ("sessions", "list agent sessions"), ("stats", "usage statistics"), ("projects", "project activity"), + ("timeline", "unified activity view"), ("log", "read the event journal"), ("logs", "tail an agent log"), ("history", "search command history"), @@ -145,7 +146,7 @@ impl AmCompleter { Self { theme: std::cell::Cell::new(crate::theme::default_theme()), commands: vec![ - "list", "status", "sessions", "stats", "projects", "log", "logs", "history", "init", "alias", "open", "search", "info", "install", "uninstall", "update", + "list", "status", "sessions", "stats", "projects", "timeline", "log", "logs", "history", "init", "alias", "open", "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", @@ -1259,6 +1260,12 @@ fn handle_line( "projects" => Command::Projects { name: rest.first().cloned(), }, + "timeline" => Command::Timeline { + agent: rest.first().cloned(), + project: opt_value("--project"), + since: opt_value("--since"), + limit: opt_value("--limit").and_then(|v| v.parse().ok()).unwrap_or(50), + }, "log" => Command::Log { agent: rest.first().cloned(), kind: opt_value("--kind"), @@ -1383,6 +1390,7 @@ fn is_am_command(word: &str) -> bool { | "sessions" | "stats" | "projects" + | "timeline" | "log" | "logs" | "history" -- 2.54.0