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
Generated
+1 -1
View File
@@ -21,7 +21,7 @@ dependencies = [
[[package]]
name = "agent-manager"
version = "0.4.6"
version = "0.4.7"
dependencies = [
"anyhow",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "agent-manager"
version = "0.4.6"
version = "0.4.7"
edition = "2021"
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
license = "MIT"
+3 -1
View File
@@ -84,7 +84,9 @@ plateforme, il compile automatiquement depuis les sources.
| am models --prune | purge des modèles inutilisés (--dry-run : simulation) |
| am catalog update / add <url> | catalogue distant : rafraîchit l'officiel ou ajoute un catalogue d'équipe |
| am audit | qui a modifié quoi, quand (checksums config + journal) |
| am update --rollback | annule la dernière mise à jour (backup automatique avant chaque update) |
| am sessions --export <id> | export d'une session (métadonnées + commandes + extrait de log) |
| am sessions --retention <jours> | purge des sessions terminées au-delà de N jours |
| am init --template <stack> | génère une config pré-remplie (web, python, rust, cli) |
**Statuts** : 🟢 running · 🔵 installed (géré par am) · 🟡 external (trouvé
sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop)
+2 -2
View File
@@ -433,8 +433,8 @@ moins de 2 secondes.
| [#49](https://git.dracodev.net/Projets/agent-manager/issues/49) | Suivi des coûts — tokens/€ par session | M |
| [#50](https://git.dracodev.net/Projets/agent-manager/issues/50) | am monitor — TUI temps réel (CPU/mémoire par PID, alertes) | L |
| [#51](https://git.dracodev.net/Projets/agent-manager/issues/51) | Playbooks — am history --save + am playbook | L |
| [#52](https://git.dracodev.net/Projets/agent-manager/issues/52) | Templates — am init --template web | M |
| [#53](https://git.dracodev.net/Projets/agent-manager/issues/53) | am sessions export + rétention configurable | M |
| [#52](https://git.dracodev.net/Projets/agent-manager/issues/52) | ✅ Templates — am init --template web/python/rust/cli (substitution + validation) | M |
| [#53](https://git.dracodev.net/Projets/agent-manager/issues/53) | ✅ am sessions export + rétention configurable (sessions_retention_days) | M |
| [#54](https://git.dracodev.net/Projets/agent-manager/issues/54) | am web — serveur local + API JSON + graphiques | XL |
| [#55](https://git.dracodev.net/Projets/agent-manager/issues/55) | Services système — am service install (systemd/launchd/tâches Windows) | L |
| [#56](https://git.dracodev.net/Projets/agent-manager/issues/56) | Planification — am schedule (--daily update --all, check santé) | M |
+1
View File
@@ -24,6 +24,7 @@ settings:
catalog_cache_ttl_secs: 3600 # TTL du cache des catalogues distants (#60 #67)
catalog_url: null # URL officielle du catalogue distant (défaut: config.yaml du repo) (#60)
backups_keep: 5 # nombre de backups conservés pour am update --rollback (#64)
sessions_retention_days: 90 # purge des sessions terminées au-delà de N jours (#53)
# --- Command aliases ----------------------------------------------------------
aliases:
+4 -1
View File
@@ -4,7 +4,7 @@
.SH NAME
init \- Generate a local agent\-manager.yaml for the current directory
.SH SYNOPSIS
\fBinit\fR [\fB\-\-force\fR] [\fB\-h\fR|\fB\-\-help\fR]
\fBinit\fR [\fB\-\-force\fR] [\fB\-\-template\fR] [\fB\-h\fR|\fB\-\-help\fR]
.SH DESCRIPTION
Generate a local agent\-manager.yaml for the current directory
.SH OPTIONS
@@ -12,5 +12,8 @@ Generate a local agent\-manager.yaml for the current directory
\fB\-\-force\fR
Overwrite an existing agent\-manager.yaml
.TP
\fB\-\-template\fR \fI<TEMPLATE>\fR
Use a template (web, python, rust, cli) instead of stack detection; "list" shows the available templates
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
+10 -1
View File
@@ -4,7 +4,7 @@
.SH NAME
sessions \- List the sessions of every agent (and of the interactive shell)
.SH SYNOPSIS
\fBsessions\fR [\fB\-\-project\fR] [\fB\-\-status\fR] [\fB\-\-show\fR] [\fB\-\-resume\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
\fBsessions\fR [\fB\-\-project\fR] [\fB\-\-status\fR] [\fB\-\-show\fR] [\fB\-\-resume\fR] [\fB\-\-export\fR] [\fB\-\-output\fR] [\fB\-\-retention\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
.SH DESCRIPTION
List the sessions of every agent (and of the interactive shell)
.SH OPTIONS
@@ -21,6 +21,15 @@ Show one session in detail (summary + log excerpt)
\fB\-\-resume\fR \fI<ID>\fR
Resume a finished session: relaunch the agent with its recorded arguments
.TP
\fB\-\-export\fR \fI<ID>\fR
Export one session (metadata, commands, log excerpt) to a JSON file
.TP
\fB\-\-output\fR \fI<FILE>\fR
Destination of the export (default: ./am\-session\-<ID>.json)
.TP
\fB\-\-retention\fR \fI<DAYS>\fR
Purge sessions finished more than N days ago (settings default 90)
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.TP
+2 -2
View File
@@ -1,6 +1,6 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am 1 "am 0.4.6"
.TH am 1 "am 0.4.7"
.SH NAME
am \- agent\-manager (am) — manage local AI coding agents
.SH SYNOPSIS
@@ -192,4 +192,4 @@ Export the configuration and installation state (backup)
am\-import(1)
Import a previously exported configuration and state
.SH VERSION
v0.4.6
v0.4.7
+13
View File
@@ -233,6 +233,15 @@ pub enum Command {
/// Resume a finished session: relaunch the agent with its recorded arguments
#[arg(long, value_name = "ID")]
resume: Option<String>,
/// Export one session (metadata, commands, log excerpt) to a JSON file
#[arg(long, value_name = "ID")]
export: Option<String>,
/// Destination of the export (default: ./am-session-<ID>.json)
#[arg(long, value_name = "FILE")]
output: Option<String>,
/// Purge sessions finished more than N days ago (settings default 90)
#[arg(long, value_name = "DAYS")]
retention: Option<u64>,
},
/// Show usage statistics computed from the event journal
Stats {
@@ -373,6 +382,10 @@ pub enum Command {
/// Overwrite an existing agent-manager.yaml
#[arg(long)]
force: bool,
/// Use a template (web, python, rust, cli) instead of stack detection;
/// "list" shows the available templates
#[arg(long, value_name = "TEMPLATE")]
template: Option<String>,
},
/// Display help: the overview, a command, the command list, or a search
Help {
+34 -9
View File
@@ -1,5 +1,6 @@
//! init: generate a local agent-manager.yaml for the current directory,
//! based on the detected technology stack.
//! based on the detected technology stack or an explicit template
//! (issue #52: 'am init --template web|python|rust|cli').
use super::*;
use crate::context;
@@ -7,8 +8,9 @@ use anyhow::{anyhow, Context, Result};
use std::path::{Path, PathBuf};
/// Generate a local config file in dir; refuses to overwrite unless force.
/// Returns the written path.
pub fn generate(dir: &Path, force: bool) -> Result<PathBuf> {
/// `template` renders a pre-filled config for a stack ('am init --template');
/// None falls back on the automatic stack detection. Returns the written path.
pub fn generate(dir: &Path, force: bool, template: Option<&str>) -> Result<PathBuf> {
let target = dir.join(crate::config::LOCAL_CONFIG_NAME);
if target.exists() && !force {
return Err(anyhow!(
@@ -16,6 +18,14 @@ pub fn generate(dir: &Path, force: bool) -> Result<PathBuf> {
target.display()
));
}
let project = dir
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "project".to_string());
let dir_str = dir.to_string_lossy().to_string();
let content = if let Some(t) = template {
crate::templates::render(t, &project, &dir_str)?
} else {
let ctx = context::detect(dir);
let agents = context::recommended_agents(&ctx.stack);
let members = agents
@@ -28,7 +38,7 @@ pub fn generate(dir: &Path, force: bool) -> Result<PathBuf> {
} else {
ctx.stack.join(", ")
};
let content = format!(
format!(
"# Généré par 'am init' le {date}
# Pile détectée : {stack}
version: \"1.0\"
@@ -37,13 +47,22 @@ groups:
agents: []
",
date = crate::installers::now_rfc3339(),
);
)
};
std::fs::write(&target, content)
.with_context(|| format!("cannot write {}", target.display()))?;
Ok(target)
}
pub fn run(app: &App, force: bool) -> Result<i32> {
pub fn run(app: &App, force: bool, template: Option<&str>) -> Result<i32> {
// 'am init --template list' prints the available templates.
if template == Some("list") {
println!("available templates:");
for (name, desc) in crate::templates::TEMPLATES {
println!(" {name:<8} {desc}");
}
return Ok(0);
}
if app.dry_run() {
app.log.dry(format!(
"would generate {} in the current directory",
@@ -53,8 +72,13 @@ pub fn run(app: &App, force: bool) -> Result<i32> {
}
let cwd = std::env::current_dir().context("cannot determine the current directory")?;
let ctx = context::detect(&cwd);
let path = generate(&cwd, force)?;
app.log.success(&format!(
let path = generate(&cwd, force, template)?;
match template {
Some(t) => app.log.success(&format!(
"wrote {} (template: {t})",
path.display()
)),
None => app.log.success(&format!(
"wrote {} (stack: {})",
path.display(),
if ctx.stack.is_empty() {
@@ -62,7 +86,8 @@ pub fn run(app: &App, force: bool) -> Result<i32> {
} else {
ctx.stack.join(", ")
}
));
)),
}
app.log.info(&format!(
"group 'dev' prêt : am start group:dev — ajustez ensuite la config générée"
));
+9 -1
View File
@@ -130,6 +130,9 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
status,
show,
resume,
export,
output,
retention,
} => sessions_cmd::run(
app,
agent.as_deref(),
@@ -137,6 +140,9 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
status.as_deref(),
show.as_deref(),
resume.as_deref(),
export.as_deref(),
output.as_deref(),
*retention,
),
Command::Install {
agent,
@@ -170,7 +176,9 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
*rerun,
),
Command::Info { agent } => info_cmd::run(app, agent),
Command::Init { force } => init_cmd::run(app, *force),
Command::Init { force, template } => {
init_cmd::run(app, *force, template.as_deref())
}
Command::Help { command, find } => help_cmd::run(app, command.as_deref(), find.as_deref()),
Command::Version => version_cmd::run(app),
Command::Tip { random } => tip_cmd::run(app, *random),
+61
View File
@@ -4,6 +4,7 @@ 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 {
@@ -45,7 +46,16 @@ pub fn run(
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);
}
@@ -181,6 +191,57 @@ 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")
+15
View File
@@ -134,6 +134,21 @@ pub static SECTIONS: &[TipSection] = &[
],
example: "update --rollback list",
},
TipEntry {
usage: "init --template <stack>",
about: "génère une config projet pré-remplie (web, python, rust, cli)",
options: &[("--template list", "liste les templates disponibles")],
example: "init --template python",
},
TipEntry {
usage: "sessions --export <id>",
about: "exporte une session : métadonnées + commandes + extrait de log (JSON)",
options: &[
("--output <fichier>", "destination (défaut ./am-session-<id>.json)"),
("--retention <jours>", "purge les sessions terminées au-delà de N jours"),
],
example: "sessions --export 20260815_143926 --output session.json",
},
TipEntry {
usage: "install <agent>",
about: "installe l'agent et ses dépendances (Node, Python, Rust…)",
+6
View File
@@ -121,6 +121,9 @@ pub struct Settings {
/// (issue #64, default 5).
#[serde(default)]
pub backups_keep: Option<u32>,
/// Purge sessions finished more than N days ago (issue #53, default 90).
#[serde(default)]
pub sessions_retention_days: Option<u64>,
}
/// Policy controlling how aggressively 'am update' upgrades installed agents.
@@ -645,6 +648,9 @@ pub fn merge(base: &mut Config, overlay: Config) {
if o.backups_keep.is_some() {
s.backups_keep = o.backups_keep;
}
if o.sessions_retention_days.is_some() {
s.sessions_retention_days = o.sessions_retention_days;
}
for (k, v) in o.hooks {
s.hooks.insert(k, v);
}
+3
View File
@@ -40,6 +40,8 @@ pub enum EventKind {
Backup,
/// Rollback of a previous backup (issue #64).
Rollback,
/// Derived index pruned (sessions retention, issue #53).
Prune,
}
impl EventKind {
@@ -61,6 +63,7 @@ impl EventKind {
EventKind::Catalog => "catalog",
EventKind::Backup => "backup",
EventKind::Rollback => "rollback",
EventKind::Prune => "prune",
}
}
}
+15 -7
View File
@@ -181,12 +181,16 @@ pub static HELP_SPECS: &[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"],
about: "List sessions; export one as JSON; enforce retention on old ones.",
search_terms: &["session", "history", "log", "export", "retention", "purge", "resume"],
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)" },
HelpFlag { short: "", long: "--export", value: "ID", desc: "Export a session (metadata + commands + log excerpt) to JSON" },
HelpFlag { short: "", long: "--output", value: "FILE", desc: "Export destination (default ./am-session-<ID>.json)" },
HelpFlag { short: "", long: "--retention", value: "DAYS", desc: "Purge sessions finished more than N days ago (settings default 90)" },
HelpFlag { short: "", long: "--resume", value: "ID", desc: "Relaunch a finished agent session with its recorded arguments" },
],
subcommands: &[],
parameters: &[
@@ -195,7 +199,9 @@ pub static HELP_SPECS: &[HelpSpec] = &[
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" },
HelpExample { desc: "Export one session.", code: "sessions --export 20260815_143926_a1b2c3" },
HelpExample { desc: "Purge sessions older than 30 days.", code: "sessions --retention 30" },
HelpExample { desc: "Resume a finished session.", code: "sessions --resume 20260815_143926_a1b2c3" },
],
},
HelpSpec {
@@ -557,17 +563,19 @@ pub static HELP_SPECS: &[HelpSpec] = &[
name: "init",
category: "Commands",
usage: "init {flags}",
about: "Generate a local agent-manager.yaml for the current directory, based on the detected stack.",
search_terms: &["project", "setup", "scaffold"],
about: "Generate a local agent-manager.yaml — stack detection or an embedded template.",
search_terms: &["project", "setup", "scaffold", "template"],
flags: &[
HelpFlag { short: "", long: "--force", value: "", desc: "Overwrite an existing agent-manager.yaml" },
HelpFlag { short: "", long: "--template", value: "NAME", desc: "web | python | rust | cli ('list' shows them)" },
],
subcommands: &[],
parameters: &[],
io: None,
examples: &[
HelpExample { desc: "Generate a project config.", code: "init" },
HelpExample { desc: "Regenerate over an existing one.", code: "init --force" },
HelpExample { desc: "Generate a project config (stack detection).", code: "init" },
HelpExample { desc: "Generate a web-stack config.", code: "init --template web" },
HelpExample { desc: "List the available templates.", code: "init --template list" },
],
},
HelpSpec {
+13
View File
@@ -198,6 +198,19 @@ pub fn read_all(app: &App, limit: usize) -> Vec<HistoryEntry> {
out
}
/// Read the entries of one session file (oldest first). Missing or corrupt
/// files yield an empty list — an export never fails on history.
pub fn read_session(app: &App, session: &str) -> Vec<HistoryEntry> {
let path = dir(app).join(format!("{session}.jsonl"));
let Ok(text) = std::fs::read_to_string(&path) else {
return Vec::new();
};
text.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str::<HistoryEntry>(l).ok())
.collect()
}
/// Parse the session id from a history file name ("20260815_143926.jsonl").
pub fn session_of_file(path: &Path) -> Option<String> {
path.file_name()
+15 -2
View File
@@ -14,6 +14,7 @@
pub mod app;
pub mod backup;
pub mod catalog;
pub mod templates;
pub mod catalog_remote;
pub mod cli;
pub mod commands;
@@ -79,7 +80,18 @@ fn real_main() -> i32 {
}
let cli = cli::Cli::parse();
match app::App::from_cli(cli) {
Ok(app) => match commands::execute(&app) {
Ok(app) => {
// Issue #53: enforce the session retention at every startup
// (silent; the primary event journal is never touched).
let days = app
.config
.settings
.sessions_retention_days
.unwrap_or(sessions::DEFAULT_RETENTION_DAYS);
if let Err(e) = sessions::purge_expired(&app, days) {
app.log.verbose(&format!("session retention skipped: {e:#}"));
}
match commands::execute(&app) {
Ok(code) => code,
Err(err) => {
if app.json() {
@@ -93,7 +105,8 @@ fn real_main() -> i32 {
}
1
}
},
}
}
Err(err) => {
// Config could not even be loaded: fall back to plain stderr output.
eprintln!("{} {}", "error:".red().bold(), format!("{:#}", err));
+7 -1
View File
@@ -1519,6 +1519,9 @@ fn handle_line(
status: opt_value("--status"),
show: opt_value("--show"),
resume: opt_value("--resume"),
export: opt_value("--export"),
output: opt_value("--output"),
retention: opt_value("--retention").and_then(|v| v.parse().ok()),
},
"stats" => Command::Stats {
agent: rest.first().cloned(),
@@ -1557,7 +1560,10 @@ fn handle_line(
limit: opt_value("--limit").and_then(|v| v.parse().ok()).unwrap_or(100),
rerun: opt_value("--rerun").and_then(|v| v.parse().ok()),
},
"init" => Command::Init { force: flag("--force") },
"init" => Command::Init {
force: flag("--force"),
template: opt_value("--template"),
},
"search" => {
let words: Vec<String> = rest
.iter()
+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));
}
}
+138
View File
@@ -0,0 +1,138 @@
//! Embedded project templates for 'am init --template' (issue #52).
//! Each template renders an agent-manager.yaml pre-filled for a stack:
//! a dev group, a default profile and project hooks. Variables {project}
//! and {dir} are substituted at render time.
use anyhow::{bail, Result};
/// (name, description) of every available template.
pub const TEMPLATES: &[(&str, &str)] = &[
("web", "frontend/web full-stack : node, npm, tests, serveur local"),
("python", "python : uv/pip, pytest, ruff, mypy"),
("rust", "rust : cargo, clippy, tests, rustfmt"),
("cli", "générique CLI : commandes shell, scripts, git"),
];
/// The four embedded templates.
const WEB: &str = r#"# Généré par 'am init --template web' — projet {project}
# Répertoire : {dir}
version: "1.0"
groups:
dev: [claude-code, opencode, gemini-cli]
profiles:
dev:
agent: claude-code
env:
NODE_ENV: development
CI: "1"
args: []
settings:
hooks:
on_install: ["npm install"]
on_start: ["npm run dev"]
agents: []
"#;
const PYTHON: &str = r#"# Généré par 'am init --template python' — projet {project}
# Répertoire : {dir}
version: "1.0"
groups:
dev: [claude-code, codex, aider]
profiles:
dev:
agent: claude-code
env:
PYTHONUNBUFFERED: "1"
PYTHONDONTWRITEBYTECODE: "1"
args: []
settings:
hooks:
on_install: ["uv sync --all-extras"]
on_start: ["uv run pytest -q"]
agents: []
"#;
const RUST: &str = r#"# Généré par 'am init --template rust' — projet {project}
# Répertoire : {dir}
version: "1.0"
groups:
dev: [claude-code, codex, jcode]
profiles:
dev:
agent: claude-code
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: "1"
args: []
settings:
hooks:
on_install: ["cargo fetch"]
on_start: ["cargo test"]
agents: []
"#;
const CLI: &str = r#"# Généré par 'am init --template cli' — projet {project}
# Répertoire : {dir}
version: "1.0"
groups:
dev: [claude-code, opencode]
profiles:
dev:
agent: claude-code
env: {}
args: []
agents: []
"#;
fn template_of(name: &str) -> Option<&'static str> {
match name {
"web" => Some(WEB),
"python" => Some(PYTHON),
"rust" => Some(RUST),
"cli" => Some(CLI),
_ => None,
}
}
/// Render a template for a project. `project` is the project name (used in
/// the header comment), `dir` the absolute directory path. Unknown template
/// names fail with the available list.
pub fn render(name: &str, project: &str, dir: &str) -> Result<String> {
let Some(tpl) = template_of(name) else {
let list = TEMPLATES
.iter()
.map(|(n, _)| *n)
.collect::<Vec<_>>()
.join(", ");
bail!("unknown template '{name}' — available: {list}");
};
Ok(tpl.replace("{project}", project).replace("{dir}", dir))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn renders_all_templates_with_substitution() {
for (name, _) in TEMPLATES {
let out = render(name, "mon-app", "/work/mon-app").unwrap();
assert!(out.contains("mon-app"), "{name}: project substituted");
assert!(out.contains("/work/mon-app"), "{name}: dir substituted");
assert!(!out.contains("{project}"), "{name}: no leftover var");
assert!(!out.contains("{dir}"), "{name}: no leftover var");
// The rendered config must parse and validate.
let cfg: crate::config::Config = serde_yaml::from_str(&out).unwrap_or_else(|e| {
panic!("{name}: invalid YAML: {e}\n{out}")
});
assert!(crate::config::validate(&cfg).is_empty(), "{name}: invalid config");
assert!(!cfg.groups.is_empty(), "{name}: a dev group is expected");
}
}
#[test]
fn unknown_template_fails_with_list() {
let err = render("cobol", "x", "/tmp").unwrap_err();
assert!(err.to_string().contains("web"), "{err}");
}
}
+5 -5
View File
@@ -19,21 +19,21 @@ fn context_detects_stack_from_markers() {
fn generate_writes_valid_config_and_refuses_overwrite() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
let path = init_cmd::generate(dir.path(), false).unwrap();
let path = init_cmd::generate(dir.path(), false, None).unwrap();
assert!(path.ends_with("agent-manager.yaml"));
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains(r#"version: "1.0""#), "config valide: {text}");
assert!(text.contains("smelt"), "agent recommande pour rust: {text}");
// Un second appel sans --force échoue.
assert!(init_cmd::generate(dir.path(), false).is_err());
assert!(init_cmd::generate(dir.path(), false, None).is_err());
// Avec --force, il écrase.
assert!(init_cmd::generate(dir.path(), true).is_ok());
assert!(init_cmd::generate(dir.path(), true, None).is_ok());
}
#[test]
fn generate_uses_default_for_empty_dir() {
let dir = tempfile::tempdir().unwrap();
let path = init_cmd::generate(dir.path(), false).unwrap();
let path = init_cmd::generate(dir.path(), false, None).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("claude-code"), "default agent: {text}");
}
@@ -41,6 +41,6 @@ fn generate_uses_default_for_empty_dir() {
#[test]
fn command_runs_dry() {
let app = common::test_app("initdry", &["init", "--dry-run"]);
let code = init_cmd::run(&app, false).unwrap();
let code = init_cmd::run(&app, false, None).unwrap();
assert_eq!(code, 0);
}
+5 -5
View File
@@ -58,7 +58,7 @@ fn agent_session_roundtrip_and_counter() {
fn command_lists_sessions() {
let app = common::test_app("sesscmd", &["sessions"]);
sessions::start_agent(&app, "aider", 4242, None, None, vec![], vec![], None).unwrap();
let code = sessions_cmd::run(&app, None, None, None, None, None).unwrap();
let code = sessions_cmd::run(&app, None, None, None, None, 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();
@@ -67,9 +67,9 @@ fn command_lists_sessions() {
#[test]
fn resume_unknown_or_repl_session_errors() {
let app = common::test_app("resume", &["sessions"]);
assert!(sessions_cmd::run(&app, None, None, None, None, Some("inconnue")).is_err());
assert!(sessions_cmd::run(&app, None, None, None, None, Some("inconnue"), None, None, None).is_err());
sessions::start_repl(&app, "r1", None).unwrap();
assert!(sessions_cmd::run(&app, None, None, None, None, Some("r1")).is_err());
assert!(sessions_cmd::run(&app, None, None, None, None, Some("r1"), None, None, None).is_err());
}
#[test]
@@ -77,7 +77,7 @@ fn resume_dry_run_announces() {
let app = common::test_app("resumedry", &["sessions", "--dry-run"]);
let id = sessions::start_agent(&app, "claude-code", 4242, None, None, vec![], vec![], None).unwrap();
sessions::finish_agent(&app, "claude-code", Some(4242), 0, true).unwrap();
let code = sessions_cmd::run(&app, None, None, None, None, Some(&id)).unwrap();
let code = sessions_cmd::run(&app, None, None, None, None, Some(&id), None, None, None).unwrap();
assert_eq!(code, 0, "dry-run resume");
}
@@ -85,6 +85,6 @@ fn resume_dry_run_announces() {
fn show_one_accepts_known_id() {
let app = common::test_app("sessshow", &["sessions"]);
let id = sessions::start_agent(&app, "jcode", 99, None, None, vec![], vec![], None).unwrap();
let code = sessions_cmd::run(&app, None, None, None, Some(&id), None).unwrap();
let code = sessions_cmd::run(&app, None, None, None, Some(&id), None, None, None, None).unwrap();
assert_eq!(code, 0);
}