//! 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); } }