timeline : vue chronologique unifiee journal + historique REPL (issue #31) - filtres agent/projet/date, limite, parse_since partage

This commit is contained in:
2026-08-17 08:45:03 -04:00
parent bbc2bb889d
commit ed795c522f
6 changed files with 231 additions and 2 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ pub fn filter_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) {
return Ok(t);
}
+7
View File
@@ -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<i32> {
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,
+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);
}
}