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.
This commit is contained in:
2026-08-18 10:08:20 -04:00
parent d9ffc34156
commit 6a6676fc5d
23 changed files with 561 additions and 71 deletions
+166
View File
@@ -247,6 +247,130 @@ pub fn duration_s(r: &SessionRecord) -> Option<u64> {
Some(secs.max(0) as u64)
}
/// Default retention: sessions older than this many days are purged by
/// 'am sessions --retention' / startup (issue #53).
pub const DEFAULT_RETENTION_DAYS: u64 = 90;
/// Complete, self-contained export of one session (issue #53): metadata,
/// the recorded commands and a log excerpt. Round-trips through
/// 'am import' / archiving; the primary event journal is never touched.
#[derive(Debug, Clone, Serialize)]
pub struct SessionExport {
pub format: &'static str,
pub format_version: u32,
pub exported_at: String,
pub session: SessionRecord,
/// Commands recorded by the REPL during this session (history/<id>.jsonl).
pub commands: Vec<crate::history::HistoryEntry>,
/// Tail of the agent log file (last ~4 KB), if any.
pub log_excerpt: String,
pub log_path: Option<String>,
/// Human-readable rendering, for manual archiving.
pub readable: String,
}
/// Export one session (issue #53). Fails with a clear message when the id
/// is unknown.
pub fn export_session(app: &App, id: &str) -> Result<SessionExport> {
let sf = load(app)?;
let session = sf
.sessions
.get(id)
.cloned()
.ok_or_else(|| anyhow!("unknown session '{id}' — use 'am sessions' to list them"))?;
// Commands recorded by the REPL during this session.
let commands = crate::history::read_session(app, id);
// Log excerpt: last ~4 KB of the agent log file.
let (log_excerpt, log_path) = match &session.log {
Some(p) if PathBuf::from(p).is_file() => {
let text = std::fs::read_to_string(p).unwrap_or_default();
let excerpt = text.chars().rev().take(4096).collect::<String>().chars().rev().collect();
(excerpt, Some(p.clone()))
}
other => (String::new(), other.clone()),
};
let mut readable = format!(
"Session {}\n kind={} status={} agent={}\n started={} ended={}\n",
session.id,
session.kind,
session.status,
session.agent.as_deref().unwrap_or("-"),
session.started_at,
session.ended_at.as_deref().unwrap_or("-"),
);
if let Some(p) = &session.project {
readable.push_str(&format!(" project={p}\n"));
}
if let Some(c) = session.exit_code {
readable.push_str(&format!(" exit_code={c}\n"));
}
readable.push_str(&format!(
" duration={}s\n",
duration_s(&session).unwrap_or(0)
));
if !commands.is_empty() {
readable.push_str(&format!("\ncommands ({}):\n", commands.len()));
for c in commands.iter().rev().take(25) {
readable.push_str(&format!(" {}\n", c.cmd));
}
}
if !log_excerpt.is_empty() {
readable.push_str(&format!("\nlog excerpt:\n{log_excerpt}\n"));
}
Ok(SessionExport {
format: "am-session-export",
format_version: 1,
exported_at: crate::installers::now_rfc3339(),
session,
commands,
log_excerpt,
log_path,
readable,
})
}
/// Purge sessions finished more than `retention_days` ago (issue #53).
/// Only the derived sessions.json index is pruned — the primary event
/// journal is untouched. Purged ids are journaled as EventKind::Prune so
/// 'am audit' can explain the disappearance.
pub fn purge_expired(app: &App, retention_days: u64) -> Result<usize> {
let mut sf = load(app)?;
let cutoff = chrono::Utc::now() - chrono::Duration::days(retention_days as i64);
let mut purged: Vec<String> = Vec::new();
sf.sessions.retain(|id, r| {
if r.status == "running" {
return true; // never purge a live session
}
let end = r
.ended_at
.as_deref()
.or(Some(r.started_at.as_str()))
.and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())
.map(|t| t.with_timezone(&chrono::Utc));
let keep = match end {
Some(e) => e >= cutoff,
None => true, // undatable session: keep it
};
if !keep {
purged.push(id.clone());
}
keep
});
if !purged.is_empty() {
save(app, &sf)?;
let _ = app.emit(
&crate::events::Event::now(crate::events::EventKind::Prune)
.with_reason("sessions-retention")
.with_args(vec![
format!("{retention_days}d"),
purged.len().to_string(),
purged.join(","),
]),
);
}
Ok(purged.len())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -336,4 +460,46 @@ mod tests {
assert_eq!(stopped[0].exit_code, Some(1));
assert_eq!(stopped[0].status, "failed");
}
#[test]
fn export_is_complete_and_reproducible() {
let app = test_app("export");
let id = start_agent(&app, "claude-code", 4242, Some("/tmp".into()), None, vec![], vec![], Some("/nonexistent.log".into())).unwrap();
finish_agent(&app, "claude-code", Some(4242), 0, true).unwrap();
let ex = export_session(&app, &id).unwrap();
assert_eq!(ex.format, "am-session-export");
assert_eq!(ex.session.id, id);
assert!(ex.readable.contains("claude-code"), "readable rendering");
// Unknown id fails clearly.
assert!(export_session(&app, "nope").is_err());
}
#[test]
fn purge_removes_only_finished_old_sessions() {
let app = test_app("purge");
// Finished 200 days ago: candidate.
let id_old = start_agent(&app, "a", 1, None, None, vec![], vec![], None).unwrap();
finish_agent(&app, "a", Some(1), 0, true).unwrap();
// Finished yesterday: kept.
let id_recent = start_agent(&app, "b", 2, None, None, vec![], vec![], None).unwrap();
finish_agent(&app, "b", Some(2), 0, true).unwrap();
// Still running: never purged even if old.
let id_running = start_agent(&app, "c", 3, None, None, vec![], vec![], None).unwrap();
// Rewind the dates in one load-modify-save pass.
let mut sf = load(&app).unwrap();
let past = (chrono::Utc::now() - chrono::Duration::days(200)).to_rfc3339();
sf.sessions.get_mut(&id_old).unwrap().ended_at = Some(past.clone());
sf.sessions.get_mut(&id_recent).unwrap().ended_at =
Some((chrono::Utc::now() - chrono::Duration::days(1)).to_rfc3339());
sf.sessions.get_mut(&id_running).unwrap().started_at = past;
save(&app, &sf).unwrap();
let purged = purge_expired(&app, 90).unwrap();
assert_eq!(purged, 1, "only the 200-day-old finished session");
let sf = load(&app).unwrap();
assert!(!sf.sessions.contains_key(&id_old));
assert!(sf.sessions.contains_key(&id_recent));
assert!(sf.sessions.contains_key(&id_running));
}
}