timeline : vue chronologique unifiée (issue #31) #81

Merged
bruno merged 1 commits from feat/timeline into main 2026-08-17 08:45:16 -04:00
6 changed files with 231 additions and 2 deletions
+14
View File
@@ -150,6 +150,20 @@ pub enum Command {
/// Project name (omit to list every project) /// Project name (omit to list every project)
name: Option<String>, name: Option<String>,
}, },
/// One chronological view of every activity (journal + REPL history)
Timeline {
/// Only activity of this agent
agent: Option<String>,
/// Only activity of this project
#[arg(long, value_name = "PROJECT")]
project: Option<String>,
/// Only activity at or after this date (YYYY-MM-DD or RFC 3339)
#[arg(long, value_name = "DATE")]
since: Option<String>,
/// Maximum number of entries (default: 50)
#[arg(long, value_name = "N", default_value = "50")]
limit: usize,
},
/// Read the event journal: everything am did /// Read the event journal: everything am did
Log { Log {
/// Only events of this agent /// Only events of this agent
+1 -1
View File
@@ -35,7 +35,7 @@ pub fn filter_events(
Ok(events) Ok(events)
} }
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);
} }
+7
View File
@@ -22,6 +22,7 @@ pub mod sessions_cmd;
pub mod stats_cmd; pub mod stats_cmd;
pub mod self_update; pub mod self_update;
pub mod status_cmd; pub mod status_cmd;
pub mod timeline_cmd;
pub mod uninstall_cmd; pub mod uninstall_cmd;
pub mod update_cmd; pub mod update_cmd;
pub mod version_cmd; pub mod version_cmd;
@@ -77,6 +78,12 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
limit, limit,
} => log_cmd::run(app, agent.as_deref(), kind.as_deref(), since.as_deref(), *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::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 { Command::Sessions {
agent, agent,
project, project,
+179
View File
@@ -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<String>,
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<Vec<TimelineRow>> {
let cut = match since {
Some(s) => Some(crate::commands::log_cmd::parse_since(s)?),
None => None,
};
let mut rows: Vec<TimelineRow> = 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<i32> {
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);
}
}
+21
View File
@@ -234,6 +234,27 @@ pub static HELP_SPECS: &[HelpSpec] = &[
HelpExample { desc: "One project in detail.", code: "projects api" }, 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 { HelpSpec {
name: "log", name: "log",
category: "Commands", category: "Commands",
+9 -1
View File
@@ -63,6 +63,7 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
("sessions", "list agent sessions"), ("sessions", "list agent sessions"),
("stats", "usage statistics"), ("stats", "usage statistics"),
("projects", "project activity"), ("projects", "project activity"),
("timeline", "unified activity view"),
("log", "read the event journal"), ("log", "read the event journal"),
("logs", "tail an agent log"), ("logs", "tail an agent log"),
("history", "search command history"), ("history", "search command history"),
@@ -145,7 +146,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", "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", "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",
@@ -1259,6 +1260,12 @@ fn handle_line(
"projects" => Command::Projects { "projects" => Command::Projects {
name: rest.first().cloned(), 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 { "log" => Command::Log {
agent: rest.first().cloned(), agent: rest.first().cloned(),
kind: opt_value("--kind"), kind: opt_value("--kind"),
@@ -1383,6 +1390,7 @@ fn is_am_command(word: &str) -> bool {
| "sessions" | "sessions"
| "stats" | "stats"
| "projects" | "projects"
| "timeline"
| "log" | "log"
| "logs" | "logs"
| "history" | "history"