timeline : vue chronologique unifiée (issue #31) #81
+14
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user