//! sessions: the registry of every agent and REPL session. use super::*; use crate::output::{print_json, status_style, Table}; use crate::sessions::{self, SessionRecord}; use serde::Serialize; use std::path::PathBuf; #[derive(Serialize)] struct SessionRow { id: String, kind: String, agent: Option, 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>, resume: Option<&str>, export: Option<&str>, output: Option<&str>, retention: Option, ) -> Result { if let Some(id) = export { return export_one(app, id, output); } if let Some(days) = retention { return purge(app, days); } if let Some(id) = show { return show_one(app, id); } if let Some(id) = resume { return resume_session(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) } /// Resume a finished session: relaunch the agent in the background with /// its recorded arguments and working directory (issue #32). fn resume_session(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 r.kind != "agent" { bail!("session '{id}' is a REPL session — start it again with 'am'"); } if r.status == "running" { app.log.info(&format!("session '{id}' is still running")); return Ok(0); } let Some(agent_name) = r.agent.clone() else { bail!("session '{id}' has no agent"); }; require_agent(app, &agent_name)?; if app.dry_run() { app.log.dry(format!( "would resume session {id}: start {} --background with {} arg(s)", agent_name, r.args.len() )); return Ok(0); } let cwd = r.cwd.as_deref().map(std::path::Path::new); let opts = crate::cli::StartArgs { agent: Some(agent_name.clone()), background: true, args: r.args.clone(), ..Default::default() }; app.log.info(&format!( "resuming session {id}: {} ({} arg(s))", agent_name, r.args.len() )); crate::commands::run_cmd::start_with_cwd(app, &opts, cwd) } /// "20260815_143926_a1b2c3" -> "20260815_143926" fn short_id(id: &str) -> String { id.chars().take(15).collect() } /// Issue #53: 'am sessions export [--output ]' — write a /// complete, self-contained JSON export (metadata, commands, log excerpt) /// plus a human-readable rendering. Default destination: /// ./am-session-.json in the current directory. fn export_one(app: &App, id: &str, output: Option<&str>) -> Result { let export = sessions::export_session(app, id)?; let dest = match output { Some(o) => PathBuf::from(o), None => std::env::current_dir() .unwrap_or_else(|_| PathBuf::from(".")) .join(format!("am-session-{}.json", export.session.id)), }; let text = serde_json::to_string_pretty(&export)?; std::fs::write(&dest, text).map_err(|e| { anyhow::anyhow!("cannot write export {}: {e}", dest.display()) })?; app.log.success(&format!( "exported session {} ({} command(s), {} log bytes) to {}", export.session.id, export.commands.len(), export.log_excerpt.len(), dest.display() )); app.log.info(&format!( "JSON complet + rendu lisible ('readable') — compatible archivage manuel / am import" )); Ok(0) } /// Issue #53: 'am sessions --retention ' — purge finished sessions /// older than the threshold. The primary event journal is untouched; the /// purge is journaled as EventKind::Prune. fn purge(app: &App, days: u64) -> Result { if days == 0 { bail!("retention must be at least 1 day"); } if app.dry_run() { app.log .dry(format!("would purge sessions finished more than {days} days ago")); return Ok(0); } let purged = sessions::purge_expired(app, days)?; match purged { 0 => app.log.info(&format!( "no session finished more than {days} days ago — nothing to purge" )), n => app.log.success(&format!("purged {n} session(s) older than {days} days")), } Ok(0) } 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) } }