From 38b7a3a9157d8cf1d84b1556bad6d4bfc585063e Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sun, 16 Aug 2026 22:22:09 -0400 Subject: [PATCH] sessions : registre des sessions + commande am sessions (issue #5) - lifecycle agent/repl, reconciliation des sessions interrompues, compteur sessions_count, filtres agent/projet/statut, show avec extrait de log --- src/cli.rs | 14 ++ src/commands/mod.rs | 13 ++ src/commands/run_cmd.rs | 12 ++ src/commands/sessions_cmd.rs | 146 +++++++++++++++ src/help.rs | 21 +++ src/lib.rs | 1 + src/repl.rs | 10 +- src/sessions.rs | 338 +++++++++++++++++++++++++++++++++++ src/state.rs | 7 + tests/sessions_test.rs | 72 ++++++++ 10 files changed, 631 insertions(+), 3 deletions(-) create mode 100644 src/commands/sessions_cmd.rs create mode 100644 src/sessions.rs create mode 100644 tests/sessions_test.rs diff --git a/src/cli.rs b/src/cli.rs index d2177e3..a3f6005 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -98,6 +98,20 @@ pub enum Command { /// Agent name or alias (omit to show all installed agents) agent: Option, }, + /// List the sessions of every agent (and of the interactive shell) + Sessions { + /// Only sessions of this agent + agent: Option, + /// Only sessions of this project + #[arg(long, value_name = "PROJECT")] + project: Option, + /// Only sessions with this status (running, stopped, failed, interrupted) + #[arg(long, value_name = "STATUS")] + status: Option, + /// Show one session in detail (summary + log excerpt) + #[arg(long, value_name = "ID")] + show: Option, + }, /// Install an agent and its dependencies Install { /// Agent name or alias diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 84e072d..f7941f8 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -11,6 +11,7 @@ pub mod list_cmd; pub mod run_cmd; pub mod search_cmd; pub mod self_uninstall; +pub mod sessions_cmd; pub mod self_update; pub mod status_cmd; pub mod uninstall_cmd; @@ -48,6 +49,18 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result { timeout, } => run_cmd::restart(app, start, *force, *timeout), Command::Status { agent } => status_cmd::run(app, agent.as_deref()), + Command::Sessions { + agent, + project, + status, + show, + } => sessions_cmd::run( + app, + agent.as_deref(), + project.as_deref(), + status.as_deref(), + show.as_deref(), + ), Command::Install { agent, method, diff --git a/src/commands/run_cmd.rs b/src/commands/run_cmd.rs index b0a2efd..0c0d6b7 100644 --- a/src/commands/run_cmd.rs +++ b/src/commands/run_cmd.rs @@ -4,6 +4,7 @@ use super::*; use crate::cli::StartArgs; use crate::events::{self, Event, EventKind}; use crate::process; +use crate::sessions; use anyhow::{bail, Context, Result}; use std::collections::BTreeMap; use std::ffi::OsString; @@ -65,6 +66,15 @@ fn start_one( let _ = app.state.touch(&agent.name); } let log = process::agent_log_path(app, &agent.name); + let _ = sessions::start_agent( + app, + &agent.name, + pid, + cwd_string(), + exec.args.clone(), + events::env_keys(&exec.env), + Some(log.display().to_string()), + ); app.log.success(&format!( "{} started (pid {pid}); log: {}", agent.title(), @@ -154,6 +164,7 @@ fn stop_one(app: &App, agent: &AgentDef, force: bool, timeout: u64) -> Result Result, + status: String, + pid: Option, + cwd: Option, + project: Option, + started_at: String, + ended_at: Option, + duration_s: Option, + exit_code: Option, + log: Option, +} + +fn row_of(r: &SessionRecord) -> SessionRow { + SessionRow { + id: r.id.clone(), + kind: r.kind.clone(), + agent: r.agent.clone(), + status: r.status.clone(), + pid: r.pid, + cwd: r.cwd.clone(), + project: r.project.clone(), + started_at: r.started_at.clone(), + ended_at: r.ended_at.clone(), + duration_s: sessions::duration_s(r), + exit_code: r.exit_code, + log: r.log.clone(), + } +} + +pub fn run( + app: &App, + agent: Option<&str>, + project: Option<&str>, + status: Option<&str>, + show: Option<&str>, +) -> Result { + if let Some(id) = show { + return show_one(app, id); + } + sessions::reconcile(app)?; + let sf = sessions::load(app)?; + let mut rows: Vec = sf + .sessions + .values() + .filter(|r| agent.map_or(true, |a| r.agent.as_deref() == Some(a))) + .filter(|r| project.map_or(true, |p| r.project.as_deref() == Some(p))) + .filter(|r| status.map_or(true, |s| r.status == s)) + .map(row_of) + .collect(); + rows.sort_by(|a, b| b.started_at.cmp(&a.started_at)); + if app.json() { + print_json(&rows); + return Ok(0); + } + let color = app.color(); + let theme = app.theme(); + let mut table = Table::new(vec!["SESSION", "AGENT", "STATUS", "DURATION", "EXIT"]); + for r in &rows { + table.row(vec![ + short_id(&r.id), + r.agent + .clone() + .unwrap_or_else(|| format!("({})", r.kind)), + status_style(&r.status, theme, color), + r.duration_s + .map(format_duration) + .unwrap_or_else(|| "—".to_string()), + r.exit_code + .map(|c| c.to_string()) + .unwrap_or_else(|| "—".to_string()), + ]); + } + print!("{}", table.render()); + if rows.is_empty() { + app.log + .info("no sessions recorded yet — 'am start --background' or run 'am'"); + } + Ok(0) +} + +fn show_one(app: &App, id: &str) -> Result { + sessions::reconcile(app)?; + let sf = sessions::load(app)?; + let Some(r) = sf.sessions.get(id).cloned() else { + bail!("unknown session '{id}' — 'am sessions' lists every session"); + }; + if app.json() { + print_json(&row_of(&r)); + return Ok(0); + } + println!("session {}", r.id); + println!(" kind: {}", r.kind); + println!(" agent: {}", r.agent.as_deref().unwrap_or("—")); + println!(" status: {}", r.status); + println!(" pid: {}", r.pid.map(|p| p.to_string()).unwrap_or_else(|| "—".to_string())); + println!(" cwd: {}", r.cwd.as_deref().unwrap_or("—")); + println!(" project: {}", r.project.as_deref().unwrap_or("—")); + println!(" started: {}", r.started_at); + println!(" ended: {}", r.ended_at.as_deref().unwrap_or("—")); + if let Some(d) = sessions::duration_s(&r) { + println!(" duration: {}", format_duration(d)); + } + if let Some(c) = r.exit_code { + println!(" exit code: {c}"); + } + if let Some(l) = &r.log { + let p = std::path::PathBuf::from(l); + if p.exists() { + println!(" log: {l}"); + let tail = crate::process::last_lines(&p, 8); + if !tail.is_empty() { + println!(" --- log excerpt ---"); + println!("{tail}"); + } + } else { + println!(" log: {l} (missing)"); + } + } + Ok(0) +} + +/// "20260815_143926_a1b2c3" -> "20260815_143926" +fn short_id(id: &str) -> String { + id.chars().take(15).collect() +} + +fn format_duration(secs: u64) -> String { + if secs < 60 { + format!("{secs}s") + } else if secs < 3600 { + format!("{}m{:02}s", secs / 60, secs % 60) + } else { + format!("{}h{:02}m", secs / 3600, (secs % 3600) / 60) + } +} diff --git a/src/help.rs b/src/help.rs index bc91426..b10c657 100644 --- a/src/help.rs +++ b/src/help.rs @@ -177,6 +177,27 @@ pub static HELP_SPECS: &[HelpSpec] = &[ HelpExample { desc: "Show one agent.", code: "status claude-code" }, ], }, + HelpSpec { + name: "sessions", + category: "Commands", + usage: "sessions {flags} [agent]", + about: "List the sessions of every agent and of the interactive shell.", + search_terms: &["session", "history", "log"], + flags: &[ + HelpFlag { short: "", long: "--project", value: "PROJECT", desc: "Only sessions of this project" }, + HelpFlag { short: "", long: "--status", value: "STATUS", desc: "Only sessions with this status (running, stopped, failed, interrupted)" }, + HelpFlag { short: "", long: "--show", value: "ID", desc: "Show one session in detail (summary + log excerpt)" }, + ], + subcommands: &[], + parameters: &[ + HelpParam { name: "agent", typ: "string", desc: "Only sessions of this agent" }, + ], + io: None, + examples: &[ + HelpExample { desc: "List recent sessions.", code: "sessions" }, + HelpExample { desc: "Show one session with its log excerpt.", code: "sessions --show 20260815_143926_a1b2c3" }, + ], + }, HelpSpec { name: "search", category: "Commands", diff --git a/src/lib.rs b/src/lib.rs index 8fc6879..b581d10 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -29,6 +29,7 @@ pub mod ps; pub mod process; pub mod repl; pub mod runner; +pub mod sessions; pub mod shell; pub mod state; pub mod tables; diff --git a/src/repl.rs b/src/repl.rs index db81523..7ab01eb 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -60,6 +60,7 @@ pub struct AmCompleter { const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[ ("list", "list installed agents"), ("status", "show agent state"), + ("sessions", "list agent sessions"), ("search", "search the catalog"), ("info", "agent details"), ("install", "install an agent"), @@ -136,7 +137,7 @@ impl AmCompleter { Self { theme: std::cell::Cell::new(crate::theme::default_theme()), commands: vec![ - "list", "status", "search", "info", "install", "uninstall", "update", + "list", "status", "sessions", "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", @@ -890,8 +891,9 @@ pub fn run(app: &App) -> Result { .with_session(sid.clone()) .with_cwd(Some(current_dir_string())), ); + let _ = crate::sessions::start_repl(app, &sid, Some(current_dir_string())); print!("{}", banner(app, &session, &sid)); - match run_with_editor(app, &mut session) { + let result = match run_with_editor(app, &mut session) { Ok(code) => Ok(code), Err(e) => { app.log.verbose(&format!( @@ -899,7 +901,9 @@ pub fn run(app: &App) -> Result { )); run_plain(app, &mut session) } - } + }; + let _ = crate::sessions::finish_repl(app, &sid); + result } fn run_with_editor(app: &App, session: &mut ShellSession) -> Result { diff --git a/src/sessions.rs b/src/sessions.rs new file mode 100644 index 0000000..7343bbd --- /dev/null +++ b/src/sessions.rs @@ -0,0 +1,338 @@ +//! Session registry: one record per agent session (background start/stop +//! cycles and interactive REPL sessions). sessions.json is an index derived +//! from the event journal; it can be rebuilt with 'am doctor --fix'. + +use crate::app::App; +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::PathBuf; + +pub const SESSIONS_VERSION: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SessionRecord { + pub id: String, + /// "agent" or "repl". + #[serde(default = "kind_agent")] + pub kind: String, + #[serde(default)] + pub agent: Option, + #[serde(default)] + pub pid: Option, + #[serde(default)] + pub cwd: Option, + #[serde(default)] + pub project: Option, + /// RFC 3339 start timestamp. + pub started_at: String, + /// RFC 3339 end timestamp (None while running). + #[serde(default)] + pub ended_at: Option, + #[serde(default)] + pub exit_code: Option, + #[serde(default)] + pub log: Option, + #[serde(default)] + pub args: Vec, + /// Variable names only — never values. + #[serde(default)] + pub env_keys: Vec, + /// running | stopped | failed | interrupted + #[serde(default = "status_running")] + pub status: String, +} + +fn kind_agent() -> String { + "agent".to_string() +} + +fn status_running() -> String { + "running".to_string() +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SessionsFile { + #[serde(default)] + pub version: u32, + #[serde(default)] + pub sessions: BTreeMap, +} + +/// Path of sessions.json (inside the state directory). +pub fn file_path(app: &App) -> PathBuf { + app.events_dir().join("sessions.json") +} + +pub fn load(app: &App) -> Result { + let p = file_path(app); + if !p.exists() { + return Ok(SessionsFile { + version: SESSIONS_VERSION, + sessions: BTreeMap::new(), + }); + } + let text = std::fs::read_to_string(&p) + .with_context(|| format!("cannot read sessions file {}", p.display()))?; + let mut sf: SessionsFile = serde_json::from_str(&text).map_err(|e| { + anyhow!( + "sessions file {} is corrupt ({e}); run 'am doctor --fix' to repair it", + p.display() + ) + })?; + if sf.version < SESSIONS_VERSION { + sf.version = SESSIONS_VERSION; + save(app, &sf)?; + } + Ok(sf) +} + +pub fn save(app: &App, sf: &SessionsFile) -> Result<()> { + let p = file_path(app); + if let Some(parent) = p.parent() { + std::fs::create_dir_all(parent)?; + } + let tmp = p.with_extension("json.tmp"); + let text = serde_json::to_string_pretty(sf)?; + std::fs::write(&tmp, text)?; + std::fs::rename(&tmp, &p) + .with_context(|| format!("cannot write sessions file {}", p.display()))?; + Ok(()) +} + +/// Open a new agent session; returns its id. +#[allow(clippy::too_many_arguments)] +pub fn start_agent( + app: &App, + agent: &str, + pid: u32, + cwd: Option, + args: Vec, + env_keys: Vec, + log: Option, +) -> Result { + let mut sf = load(app)?; + let id = crate::repl::session_id(); + sf.sessions.insert( + id.clone(), + SessionRecord { + id: id.clone(), + kind: "agent".to_string(), + agent: Some(agent.to_string()), + pid: Some(pid), + cwd, + project: None, + started_at: crate::installers::now_rfc3339(), + ended_at: None, + exit_code: None, + log, + args, + env_keys, + status: "running".to_string(), + }, + ); + save(app, &sf)?; + let _ = app.state.bump_sessions(); + Ok(id) +} + +/// Open a REPL session. +pub fn start_repl(app: &App, id: &str, cwd: Option) -> Result<()> { + let mut sf = load(app)?; + sf.sessions.insert( + id.to_string(), + SessionRecord { + id: id.to_string(), + kind: "repl".to_string(), + agent: None, + pid: None, + cwd, + project: None, + started_at: crate::installers::now_rfc3339(), + ended_at: None, + exit_code: None, + log: None, + args: Vec::new(), + env_keys: Vec::new(), + status: "running".to_string(), + }, + ); + save(app, &sf)?; + let _ = app.state.bump_sessions(); + Ok(()) +} + +/// Close the running session of an agent: the one matching the pid when +/// given, otherwise the most recent one. +pub fn finish_agent(app: &App, agent: &str, pid: Option, exit_code: i32, ok: bool) -> Result<()> { + let mut sf = load(app)?; + let mut candidates: Vec = sf + .sessions + .iter() + .filter(|(_, r)| r.kind == "agent" && r.status == "running" && r.agent.as_deref() == Some(agent)) + .map(|(k, _)| k.clone()) + .collect(); + candidates.sort_by(|a, b| sf.sessions[b].started_at.cmp(&sf.sessions[a].started_at)); + let key = if let Some(p) = pid { + candidates + .iter() + .find(|k| sf.sessions[*k].pid == Some(p)) + .cloned() + .or_else(|| candidates.first().cloned()) + } else { + candidates.first().cloned() + }; + if let Some(k) = key { + if let Some(r) = sf.sessions.get_mut(&k) { + r.ended_at = Some(crate::installers::now_rfc3339()); + r.exit_code = Some(exit_code); + r.status = if ok { "stopped" } else { "failed" }.to_string(); + } + save(app, &sf)?; + } + Ok(()) +} + +/// Close a REPL session. +pub fn finish_repl(app: &App, id: &str) -> Result<()> { + let mut sf = load(app)?; + if let Some(r) = sf.sessions.get_mut(id) { + r.ended_at = Some(crate::installers::now_rfc3339()); + r.exit_code = Some(0); + r.status = "stopped".to_string(); + save(app, &sf)?; + } + Ok(()) +} + +/// Mark running sessions whose process is dead as interrupted. Returns the +/// number of sessions reconciled. REPL sessions (no pid) are left untouched: +/// the current REPL has no pid, and only 'am sessions' triggers this. +pub fn reconcile(app: &App) -> Result { + let mut sf = load(app)?; + let now = crate::installers::now_rfc3339(); + let mut changed = 0; + for r in sf.sessions.values_mut() { + if r.status != "running" { + continue; + } + let dead = match r.pid { + Some(p) => !crate::process::is_running(p), + None => false, + }; + if dead { + r.status = "interrupted".to_string(); + r.ended_at = Some(now.clone()); + changed += 1; + } + } + if changed > 0 { + save(app, &sf)?; + } + Ok(changed) +} + +/// Duration of a session in seconds (elapsed when still running). +pub fn duration_s(r: &SessionRecord) -> Option { + let start = chrono::DateTime::parse_from_rfc3339(&r.started_at).ok()?; + let end = match &r.ended_at { + Some(e) => chrono::DateTime::parse_from_rfc3339(e).ok()?, + None => chrono::Utc::now().into(), + }; + let secs = end + .with_timezone(&chrono::Utc) + .signed_duration_since(start.with_timezone(&chrono::Utc)) + .num_seconds(); + Some(secs.max(0) as u64) +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + fn test_app(tag: &str) -> App { + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.yaml"); + std::fs::write( + &cfg, + concat!( + "version: \"1.0\"\n", + "settings:\n", + " auto_install_deps: false\n", + " confirm_before_run: 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.path().join("agents"); + p.bin_dir = dir.path().join("agents").join("bin"); + p.log_dir = dir.path().join("logs"); + p.state_file = dir.path().join("state.json"); + p.probe_cache_file = dir.path().join("probe-cache.json"); + p.config_dir = Some(dir.path().join("config")); + app.paths = p; + app.state = crate::state::StateStore::new(dir.path().join("state.json")); + let _ = tag; + app + } + + #[test] + fn agent_session_lifecycle() { + let app = test_app("sess"); + let id = start_agent(&app, "claude-code", 4242, Some("/tmp".into()), vec![], vec![], None).unwrap(); + let sf = load(&app).unwrap(); + let r = sf.sessions.get(&id).unwrap(); + assert_eq!(r.status, "running"); + assert_eq!(r.pid, Some(4242)); + assert_eq!(app.state.load().unwrap().sessions_count, 1); + + finish_agent(&app, "claude-code", Some(4242), 0, true).unwrap(); + let sf = load(&app).unwrap(); + let r = sf.sessions.get(&id).unwrap(); + assert_eq!(r.status, "stopped"); + assert_eq!(r.exit_code, Some(0)); + assert!(r.ended_at.is_some()); + assert!(duration_s(r).is_some()); + } + + #[test] + fn reconcile_marks_dead_pid_interrupted() { + let app = test_app("recon"); + let id = start_agent(&app, "aider", u32::MAX - 1, None, vec![], vec![], None).unwrap(); + let changed = reconcile(&app).unwrap(); + assert_eq!(changed, 1); + let sf = load(&app).unwrap(); + assert_eq!(sf.sessions.get(&id).unwrap().status, "interrupted"); + } + + #[test] + fn repl_session_lifecycle() { + let app = test_app("repl"); + start_repl(&app, "s1", None).unwrap(); + finish_repl(&app, "s1").unwrap(); + let sf = load(&app).unwrap(); + assert_eq!(sf.sessions.get("s1").unwrap().status, "stopped"); + } + + #[test] + fn finish_matches_by_pid_first() { + let app = test_app("multi"); + start_agent(&app, "jcode", 100, None, vec![], vec![], None).unwrap(); + start_agent(&app, "jcode", 200, None, vec![], vec![], None).unwrap(); + finish_agent(&app, "jcode", Some(200), 1, false).unwrap(); + let sf = load(&app).unwrap(); + let stopped: Vec<_> = sf + .sessions + .values() + .filter(|r| r.status == "stopped" || r.status == "failed") + .collect(); + assert_eq!(stopped.len(), 1); + assert_eq!(stopped[0].pid, Some(200)); + assert_eq!(stopped[0].exit_code, Some(1)); + assert_eq!(stopped[0].status, "failed"); + } +} diff --git a/src/state.rs b/src/state.rs index 0543af2..1c102c0 100644 --- a/src/state.rs +++ b/src/state.rs @@ -155,6 +155,13 @@ impl StateStore { } Ok(()) } + + /// Increment the total session counter (one per session start). + pub fn bump_sessions(&self) -> Result<()> { + let mut sf = self.load()?; + sf.sessions_count = sf.sessions_count.saturating_add(1); + self.save(&sf) + } } #[cfg(test)] diff --git a/tests/sessions_test.rs b/tests/sessions_test.rs new file mode 100644 index 0000000..00b7877 --- /dev/null +++ b/tests/sessions_test.rs @@ -0,0 +1,72 @@ +//! sessions command and session registry: lifecycle, counter, reconciliation +//! and CLI parsing. + +mod common; + +use agent_manager::cli::{Cli, Command}; +use agent_manager::commands::sessions_cmd; +use agent_manager::sessions; +use clap::Parser; + +#[test] +fn cli_parses_sessions() { + let cli = Cli::parse_from(["am", "sessions", "--status", "failed", "--show", "abc"]); + match &cli.command { + Some(Command::Sessions { + agent, + status, + show, + .. + }) => { + assert!(agent.is_none()); + assert_eq!(status.as_deref(), Some("failed")); + assert_eq!(show.as_deref(), Some("abc")); + } + other => panic!("unexpected command: {other:?}"), + } +} + +#[test] +fn agent_session_roundtrip_and_counter() { + let app = common::test_app("sess", &["sessions"]); + let id = sessions::start_agent( + &app, + "claude-code", + 4242, + Some("/tmp".to_string()), + vec![], + vec![], + None, + ) + .unwrap(); + let sf = sessions::load(&app).unwrap(); + let r = sf.sessions.get(&id).unwrap(); + assert_eq!(r.status, "running"); + assert_eq!(r.agent.as_deref(), Some("claude-code")); + assert_eq!(app.state.load().unwrap().sessions_count, 1); + + sessions::finish_agent(&app, "claude-code", Some(4242), 0, true).unwrap(); + let sf = sessions::load(&app).unwrap(); + let r = sf.sessions.get(&id).unwrap(); + assert_eq!(r.status, "stopped"); + assert_eq!(r.exit_code, Some(0)); + assert!(r.ended_at.is_some()); +} + +#[test] +fn command_lists_sessions() { + let app = common::test_app("sesscmd", &["sessions"]); + sessions::start_agent(&app, "aider", 4242, None, vec![], vec![], None).unwrap(); + let code = sessions_cmd::run(&app, None, None, None, None).unwrap(); + assert_eq!(code, 0); + // --json path returns the row set without error. + sessions::finish_agent(&app, "aider", Some(4242), 0, true).unwrap(); +} + +#[test] +fn show_one_accepts_known_id() { + let app = common::test_app("sessshow", &["sessions"]); + let id = sessions::start_agent(&app, "jcode", 99, None, vec![], vec![], None).unwrap(); + let code = sessions_cmd::run(&app, None, None, None, Some(&id)).unwrap(); + assert_eq!(code, 0); +}