Files
agent-manager/src/commands/sessions_cmd.rs
T
bruno 6a6676fc5d feat: phase 2 — sessions export + rétention, init --template (closes #52 #53)
Axe 3 (#52) : templates embarqués dans le binaire (web, python, rust, cli)
- am init --template <stack> génère une config pré-remplie : groupe dev,
  profil par défaut, hooks projet (npm install / uv sync / cargo fetch)
- substitution {project} {dir} au rendu ; --template list pour lister
- fallback détection automatique de stack quand aucun template
- config générée validée (parse YAML + validate) par les tests

Axe 4 (#53) : am sessions export + rétention configurable
- am sessions --export <id> [--output <fichier>] : JSON complet et
  reproductible (métadonnées + commandes history/<id>.jsonl + extrait de
  log 4 Ko + rendu lisible 'readable'), compatible archivage manuel
- settings.sessions_retention_days (défaut 90) : purge au démarrage
  (silencieuse) et am sessions --retention <jours>
- purge limitée à l'index dérivé sessions.json — le journal d'événements
  primaire n'est jamais touché ; EventKind::Prune journalisé (visibilité
  am audit)
- sessions en cours (running) jamais purgées ; erreur claire si id inconnu

REPL : sessions --export/--retention/--output + init --template ; help
specs, tip cheat sheet, config.yaml, README, man pages régénérées.
Version 0.4.7, ROADMAP cochée, 276 tests verts.
2026-08-18 10:08:20 -04:00

254 lines
8.0 KiB
Rust

//! 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<String>,
status: String,
pid: Option<u32>,
cwd: Option<String>,
project: Option<String>,
started_at: String,
ended_at: Option<String>,
duration_s: Option<u64>,
exit_code: Option<i32>,
log: Option<String>,
}
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<u64>,
) -> Result<i32> {
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<SessionRow> = 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 <agent> --background' or run 'am'");
}
Ok(0)
}
fn show_one(app: &App, id: &str) -> Result<i32> {
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<i32> {
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 <id> [--output <file>]' — write a
/// complete, self-contained JSON export (metadata, commands, log excerpt)
/// plus a human-readable rendering. Default destination:
/// ./am-session-<id>.json in the current directory.
fn export_one(app: &App, id: &str, output: Option<&str>) -> Result<i32> {
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 <days>' — 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<i32> {
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)
}
}