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:
+166
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user