diff --git a/Cargo.lock b/Cargo.lock index 797f3f9..dc3ca41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,7 +21,7 @@ dependencies = [ [[package]] name = "agent-manager" -version = "0.4.5" +version = "0.4.6" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index b6ee714..7376808 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agent-manager" -version = "0.4.5" +version = "0.4.6" edition = "2021" description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog." license = "MIT" diff --git a/README.md b/README.md index d45b547..b4b213d 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,8 @@ plateforme, il compile automatiquement depuis les sources. | am models | inventaire des modèles locaux (ollama, llama.cpp, LM Studio) | | am models --prune | purge des modèles inutilisés (--dry-run : simulation) | | am catalog update / add | 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) | **Statuts** : 🟢 running · 🔵 installed (géré par am) · 🟡 external (trouvé sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop) diff --git a/ROADMAP.md b/ROADMAP.md index ffb30c1..6548c49 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -444,8 +444,8 @@ moins de 2 secondes. | [#60](https://git.dracodev.net/Projets/agent-manager/issues/60) | ✅ Catalogue distant — am catalog update / am catalog add | M | | [#61](https://git.dracodev.net/Projets/agent-manager/issues/61) | ✅ am suggest — « un agent pour du Python » (tags + usage réel) | M | | [#62](https://git.dracodev.net/Projets/agent-manager/issues/62) | am lab — benchmark : même tâche sur N agents | L | -| [#63](https://git.dracodev.net/Projets/agent-manager/issues/63) | am audit — qui a modifié quoi quand | M | -| [#64](https://git.dracodev.net/Projets/agent-manager/issues/64) | am update --rollback — backup automatique avant mise à jour | M | +| [#63](https://git.dracodev.net/Projets/agent-manager/issues/63) | ✅ am audit — qui a modifié quoi quand | M | +| [#64](https://git.dracodev.net/Projets/agent-manager/issues/64) | ✅ am update --rollback — backup automatique avant mise à jour | M | | [#65](https://git.dracodev.net/Projets/agent-manager/issues/65) | ✅ Politiques — pin de version, settings.update_policy | S | | [#66](https://git.dracodev.net/Projets/agent-manager/issues/66) | Synchronisation git automatique — am sync | L | | [#67](https://git.dracodev.net/Projets/agent-manager/issues/67) | ✅ Partage de catalogue d'équipe (include par URL) | S | diff --git a/config.yaml b/config.yaml index f52342f..0abf73a 100644 --- a/config.yaml +++ b/config.yaml @@ -23,6 +23,7 @@ settings: models_prune_days: 30 # un modèle est candidat au prune après N jours sans usage (#73) 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) # --- Command aliases ---------------------------------------------------------- aliases: diff --git a/man/am-audit.1 b/man/am-audit.1 new file mode 100644 index 0000000..82ea3a2 --- /dev/null +++ b/man/am-audit.1 @@ -0,0 +1,13 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH am-audit 1 "audit " +.SH NAME +audit \- Audit the configuration: who changed what, when (issue #63) +.SH SYNOPSIS +\fBaudit\fR [\fB\-h\fR|\fB\-\-help\fR] +.SH DESCRIPTION +Audit the configuration: who changed what, when (issue #63) +.SH OPTIONS +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help diff --git a/man/am-update.1 b/man/am-update.1 index 513270b..4e30462 100644 --- a/man/am-update.1 +++ b/man/am-update.1 @@ -4,7 +4,7 @@ .SH NAME update \- Update an installed agent to the latest available version .SH SYNOPSIS -\fBupdate\fR [\fB\-\-all\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR] +\fBupdate\fR [\fB\-\-all\fR] [\fB\-\-rollback\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR] .SH DESCRIPTION Update an installed agent to the latest available version .SH OPTIONS @@ -12,6 +12,9 @@ Update an installed agent to the latest available version \fB\-\-all\fR Update every installed managed agent .TP +\fB\-\-rollback\fR [\fI\fR] +Roll back to a pre\-update backup instead of updating ("latest" or a backup id; "\-\-rollback list" shows them) +.TP \fB\-h\fR, \fB\-\-help\fR Print help .TP diff --git a/man/am.1 b/man/am.1 index c5f0ca5..3e280e0 100644 --- a/man/am.1 +++ b/man/am.1 @@ -1,6 +1,6 @@ .ie \n(.g .ds Aq \(aq .el .ds Aq ' -.TH am 1 "am 0.4.5" +.TH am 1 "am 0.4.6" .SH NAME am \- agent\-manager (am) — manage local AI coding agents .SH SYNOPSIS @@ -93,6 +93,9 @@ Manage command aliases (cc \-> claude\-code) am\-secret(1) Manage secrets in the OS keyring (never in plaintext config) .TP +am\-audit(1) +Audit the configuration: who changed what, when (issue #63) +.TP am\-models(1) List local models (ollama, llama.cpp, LM Studio) and prune unused ones .TP @@ -189,4 +192,4 @@ Export the configuration and installation state (backup) am\-import(1) Import a previously exported configuration and state .SH VERSION -v0.4.5 +v0.4.6 diff --git a/src/backup.rs b/src/backup.rs new file mode 100644 index 0000000..5e8ba45 --- /dev/null +++ b/src/backup.rs @@ -0,0 +1,426 @@ +//! Pre-update backups (issue #64, axe 8): a snapshot of the install +//! directory, the state database and the user config is taken before every +//! real update, kept under /backups//, and restored by +//! 'am update --rollback '. Retention is bounded by +//! settings.backups_keep (default 5). + +use crate::app::App; +use anyhow::{anyhow, bail, Context, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// Default number of backups to keep (settings.backups_keep default). +pub const DEFAULT_KEEP: u32 = 5; + +/// Manifest of one backup point (JSON file inside the backup directory). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BackupInfo { + /// Backup id = the directory name (RFC 3339 timestamp). + pub id: String, + /// RFC 3339 creation timestamp. + pub created_at: String, + /// Reason: "update:" or "update:--all". + pub reason: String, + /// Agents whose install directories were snapshotted (empty = all). + pub agents: Vec, + /// True when the state database was snapshotted. + pub state: bool, + /// True when the user config file was snapshotted. + pub config: bool, +} + +impl BackupInfo { + /// Load the manifest of one backup directory. + fn load(dir: &Path) -> Option { + let text = std::fs::read_to_string(dir.join("manifest.json")).ok()?; + serde_json::from_str(&text).ok() + } +} + +/// Directory holding every backup point. Lives next to the state database +/// so tests and --config overrides stay fully sandboxed. +pub fn backups_dir(app: &App) -> PathBuf { + app.paths + .state_file + .parent() + .unwrap_or_else(|| std::path::Path::new(".")) + .join("backups") +} + +/// Take a snapshot before an update. `agents` lists the agents being +/// updated; an empty list means "the whole install directory". +pub fn create_backup(app: &App, reason: &str, agents: &[String]) -> Result { + let now = crate::installers::now_rfc3339(); + // Directory names must be sortable and filesystem-safe: the RFC 3339 + // timestamp with colons replaced by dashes. + let id = now.replace(':', "-").replace('Z', "").replace('+', "_"); + let root = backups_dir(app).join(&id); + std::fs::create_dir_all(&root) + .with_context(|| format!("cannot create backup directory {}", root.display()))?; + + let mut info = BackupInfo { + id: id.clone(), + created_at: now, + reason: reason.to_string(), + agents: agents.to_vec(), + state: false, + config: false, + }; + + // 1. State database. + if let Ok(sf) = app.state.load() { + let text = serde_json::to_string_pretty(&sf)?; + std::fs::write(root.join("state.json"), text) + .with_context(|| "cannot snapshot state.json".to_string())?; + info.state = true; + } + + // 2. User config file (the active origin when it is a user file). + if let Some(origin) = &app.config_origin { + if origin.is_file() { + std::fs::copy(origin, root.join("config.yaml")).with_context(|| { + format!("cannot snapshot user config {}", origin.display()) + })?; + info.config = true; + } + } + + // 3. Install directories of the updated agents (or everything). + let install_root = &app.paths.install_dir; + let dest_root = root.join("install"); + std::fs::create_dir_all(&dest_root)?; + if agents.is_empty() { + copy_tree(install_root, &dest_root) + .with_context(|| format!("cannot snapshot {}", install_root.display()))?; + } else { + for name in agents { + let entry = app.state.get(name).ok().flatten(); + if let Some(e) = entry { + let src = PathBuf::from(&e.install_dir); + if src.is_dir() { + let dest = dest_root.join(&e.name); + copy_tree(&src, &dest).with_context(|| { + format!("cannot snapshot {} for {name}", src.display()) + })?; + } + } + // Also snapshot the shim binaries (bin/). + let shim = app.paths.bin_dir.join(name); + if shim.exists() { + copy_tree(&shim, &dest_root.join("bin").join(name))?; + } + } + } + + // Write the manifest last: a backup without a manifest is invalid. + let manifest = serde_json::to_string_pretty(&info)?; + std::fs::write(root.join("manifest.json"), manifest) + .with_context(|| "cannot write backup manifest".to_string())?; + + prune(app)?; + Ok(info) +} + +/// List every backup point, oldest first. +pub fn list_backups(app: &App) -> Vec { + let mut out: Vec = Vec::new(); + if let Ok(entries) = std::fs::read_dir(backups_dir(app)) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + if let Some(info) = BackupInfo::load(&path) { + out.push(info); + } + } + } + } + out.sort_by(|a, b| a.id.cmp(&b.id)); + out +} + +/// Resolve a rollback point: "latest"/empty = most recent backup, +/// otherwise the backup id (prefix matching allowed). +pub fn resolve_point(app: &App, point: Option<&str>) -> Result { + let all = list_backups(app); + if all.is_empty() { + bail!("no backup found — nothing to roll back to"); + } + match point { + None | Some("latest") => all + .last() + .cloned() + .ok_or_else(|| anyhow!("no backup found")), + Some(p) => { + let exact = all.iter().find(|b| b.id == p); + if let Some(b) = exact { + return Ok(b.clone()); + } + let prefix: Vec = all + .iter() + .filter(|b| b.id.starts_with(p)) + .cloned() + .collect(); + match prefix.len() { + 0 => bail!("backup '{p}' not found — run 'am update --rollback --list'"), + 1 => Ok(prefix[0].clone()), + _ => bail!( + "backup prefix '{p}' is ambiguous ({} matches: {})", + prefix.len(), + prefix + .iter() + .map(|b| b.id.as_str()) + .collect::>() + .join(", ") + ), + } + } + } +} + +/// Restore a backup point over the live install: state, user config and +/// install directories. The agent processes of the restored agents are +/// stopped first (they would hold the old binaries open). +pub fn restore_backup(app: &App, point: Option<&str>) -> Result { + let info = resolve_point(app, point)?; + let root = backups_dir(app).join(&info.id); + if !root.join("manifest.json").is_file() { + bail!("backup {} is corrupted (no manifest)", info.id); + } + + // Stop the agents being restored (only managed ones we know). + let names: Vec = if info.agents.is_empty() { + app.state + .all()? + .keys() + .cloned() + .collect() + } else { + info.agents.clone() + }; + for name in &names { + if let Ok(Some(entry)) = app.state.get(name) { + if let Some(pid) = entry.pid { + if crate::process::is_running(pid) { + app.log + .info(&format!("stopping {name} (pid {pid}) before rollback")); + let _ = crate::commands::run_cmd::stop(app, name, false, None); + } + } + } + } + + // 1. Restore the state database. + if info.state { + let src = root.join("state.json"); + if src.is_file() { + std::fs::copy(&src, app.state.path()) + .with_context(|| "cannot restore state.json".to_string())?; + } + } + + // 2. Restore the user config. + if info.config { + if let Some(origin) = &app.config_origin { + if let Some(parent) = origin.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::copy(root.join("config.yaml"), origin) + .with_context(|| format!("cannot restore {}", origin.display()))?; + } + } + + // 3. Restore the install directories. + let src_root = root.join("install"); + if src_root.is_dir() { + if info.agents.is_empty() { + if app.paths.install_dir.exists() { + std::fs::remove_dir_all(&app.paths.install_dir) + .with_context(|| "cannot clear the install directory".to_string())?; + } + copy_tree(&src_root, &app.paths.install_dir)?; + } else { + for name in &info.agents { + let src = src_root.join(name); + if !src.exists() { + continue; + } + // Remove the agent's current install dir (known from the + // restored state) and copy the snapshot back. + if let Ok(Some(entry)) = app.state.get(name) { + let cur = PathBuf::from(&entry.install_dir); + if cur.is_dir() { + let _ = std::fs::remove_dir_all(&cur); + } + } + let dest = app.paths.install_dir.join(name); + copy_tree(&src, &dest)?; + } + // Restore the shims. + let bin_src = src_root.join("bin"); + if bin_src.is_dir() { + copy_tree(&bin_src, &app.paths.bin_dir)?; + } + } + } + + Ok(info) +} + +/// Enforce settings.backups_keep: delete the oldest backups beyond the cap. +pub fn prune(app: &App) -> Result<()> { + let keep = app.config.settings.backups_keep.unwrap_or(DEFAULT_KEEP) as usize; + let all = list_backups(app); + if all.len() <= keep { + return Ok(()); + } + for old in all.iter().take(all.len() - keep) { + let dir = backups_dir(app).join(&old.id); + if dir.is_dir() { + let _ = std::fs::remove_dir_all(&dir); + } + } + Ok(()) +} + +/// Recursively copy a directory tree (files and directories only; symlinks +/// are copied as-is when possible). +fn copy_tree(src: &Path, dest: &Path) -> Result<()> { + if !src.exists() { + return Ok(()); + } + let meta = std::fs::metadata(src)?; + if meta.is_file() { + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::copy(src, dest)?; + return Ok(()); + } + std::fs::create_dir_all(dest)?; + let mut entries: Vec<_> = std::fs::read_dir(src)? + .flatten() + .map(|e| e.path()) + .collect(); + entries.sort(); + for child in entries { + let name = child + .file_name() + .ok_or_else(|| anyhow!("invalid path {}", child.display()))?; + copy_tree(&child, &dest.join(name))?; + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::Cli; + use crate::state::StateFile; + use clap::Parser; + use std::io::Write; + + fn test_app(tag: &str) -> App { + let dir = std::env::temp_dir().join(format!("am-backup-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let cfg = dir.join("config.yaml"); + std::fs::write(&cfg, "version: \"1.0\"\nagents: []\n").unwrap(); + let cli = Cli::parse_from([ + "am", + "--config", + cfg.to_str().unwrap(), + "--yes", + "--no-color", + ]); + let mut app = App::from_cli(cli).expect("app builds"); + // Point the state/install dirs into the temp dir so nothing touches + // the real user data. + let state_file = dir.join("state.json"); + let install = dir.join("agents"); + std::fs::create_dir_all(&install).unwrap(); + app.paths.state_file = state_file.clone(); + app.paths.install_dir = install.clone(); + app.paths.bin_dir = install.join("bin"); + app.state = crate::state::StateStore::new(state_file); + app.config_origin = Some(cfg); + // Fresh backups dir for this test run. + let _ = std::fs::remove_dir_all(backups_dir(&app)); + app + } + + fn write_agent_file(app: &App, name: &str, content: &str) -> PathBuf { + let dir = app.paths.install_dir.join(name); + std::fs::create_dir_all(&dir).unwrap(); + let f = dir.join("agent.txt"); + let mut h = std::fs::File::create(&f).unwrap(); + h.write_all(content.as_bytes()).unwrap(); + f + } + + #[test] + fn backup_create_list_restore_roundtrip() { + let app = test_app("roundtrip"); + write_agent_file(&app, "alpha", "v1"); + write_agent_file(&app, "beta", "b1"); + // Simulate a managed entry for alpha. + let mut sf = StateFile::default(); + sf.installed.insert( + "alpha".to_string(), + crate::state::InstalledEntry { + name: "alpha".to_string(), + version: Some("1.0.0".to_string()), + method: "binary".to_string(), + install_dir: app.paths.install_dir.join("alpha").display().to_string(), + bins: vec![], + run: "alpha".to_string(), + installed_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: None, + pid: None, + started_at: None, + }, + ); + app.state.save(&sf).unwrap(); + + let info = create_backup(&app, "update:alpha", &["alpha".to_string()]).unwrap(); + assert!(info.state && info.config); + + // Corrupt the live install + state. + write_agent_file(&app, "alpha", "BROKEN"); + let mut bad = StateFile::default(); + bad.sessions_count = 999; + app.state.save(&bad).unwrap(); + + restore_backup(&app, Some(&info.id)).unwrap(); + let text = std::fs::read_to_string(app.paths.install_dir.join("alpha/agent.txt")).unwrap(); + assert_eq!(text, "v1", "install dir restored"); + let sf = app.state.load().unwrap(); + assert_eq!(sf.installed.get("alpha").unwrap().version.as_deref(), Some("1.0.0")); + assert_eq!(sf.sessions_count, 0, "state restored"); + } + + #[test] + fn resolve_point_prefix_and_latest() { + let app = test_app("resolve"); + create_backup(&app, "update:a", &[]).unwrap(); + let info = resolve_point(&app, None).unwrap(); + assert_eq!(info.agents, Vec::::new()); + let prefix = &info.id[..info.id.len().min(10)]; + let by_prefix = resolve_point(&app, Some(prefix)).unwrap(); + assert_eq!(by_prefix.id, info.id); + } + + #[test] + fn prune_keeps_only_backups_keep() { + let mut app = test_app("prune"); + app.config.settings.backups_keep = Some(1); + create_backup(&app, "update:one", &[]).unwrap(); + create_backup(&app, "update:two", &[]).unwrap(); + let all = list_backups(&app); + assert_eq!(all.len(), 1, "only the newest backup survives"); + } +} diff --git a/src/cli.rs b/src/cli.rs index 3c10db0..33cceb5 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -175,6 +175,8 @@ pub enum Command { /// Manage secrets in the OS keyring (never in plaintext config) #[command(subcommand)] Secret(SecretCmd), + /// Audit the configuration: who changed what, when (issue #63) + Audit, /// List local models (ollama, llama.cpp, LM Studio) and prune unused ones Models(ModelsArgs), /// Manage remote catalogs: update the official one, add external ones @@ -320,6 +322,10 @@ pub enum Command { /// Update every installed managed agent #[arg(long)] all: bool, + /// Roll back to a pre-update backup instead of updating + /// ("latest" or a backup id; "--rollback list" shows them) + #[arg(long, num_args = 0..=1, default_missing_value = "latest")] + rollback: Option, }, /// Search the catalog by keyword (name, description, category, tags) Search { diff --git a/src/commands/audit_cmd.rs b/src/commands/audit_cmd.rs new file mode 100644 index 0000000..55ddaa9 --- /dev/null +++ b/src/commands/audit_cmd.rs @@ -0,0 +1,332 @@ +//! audit: who changed what, when (issue #63, axe 8). Config checksums are +//! recorded in state.json; 'am audit' compares them with the current files, +//! cross-references the event journal to date the changes, and flags edits +//! made outside of agent-manager. + +use super::*; +use crate::events::{Event, EventKind}; +use crate::output::print_json; +use crate::state::ConfigChecksum; +use anyhow::Result; +use std::collections::BTreeMap; +use std::path::PathBuf; + +/// Labels of the tracked config files. +const EMBEDDED: &str = "embedded"; +const USER: &str = "user"; +const LOCAL: &str = "local"; + +/// SHA-256 (hex) of a file's content, plus its mtime. +fn checksum_of(text: &str, mtime_secs: u64) -> ConfigChecksum { + use sha2::{Digest, Sha256}; + let mut h = Sha256::new(); + h.update(text.as_bytes()); + ConfigChecksum { + sha256: format!("{:x}", h.finalize()), + mtime_secs, + recorded_at: crate::installers::now_rfc3339(), + } +} + +/// mtime in seconds since epoch. +fn mtime_secs(path: &std::path::Path) -> Option { + std::fs::metadata(path) + .ok() + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) +} + +/// The tracked config files: label -> (display path, content). +fn tracked_files(app: &App) -> Vec<(&'static str, String, Option)> { + let mut out = Vec::new(); + // Embedded default catalog: content is baked into the binary. + out.push((EMBEDDED, crate::config::DEFAULT_CONFIG.to_string(), None)); + // User config file. + if let Some(dir) = &app.paths.config_dir { + let p = dir.join(crate::config::CONFIG_FILE_NAME); + if let Ok(text) = std::fs::read_to_string(&p) { + out.push((USER, text, mtime_secs(&p))); + } + } + // Local project config (agent-manager.yaml in the current directory). + let local = PathBuf::from(crate::config::CONFIG_FILE_NAME); + if let Ok(text) = std::fs::read_to_string(&local) { + out.push((LOCAL, text, mtime_secs(&local))); + } + out +} + +/// Kinds of events that represent a modification of the configuration or +/// the fleet (they explain a checksum change). +fn is_mutating_kind(k: &EventKind) -> bool { + matches!( + k, + EventKind::Config + | EventKind::Install + | EventKind::Update + | EventKind::Uninstall + | EventKind::Annotate + | EventKind::Catalog + | EventKind::Model + | EventKind::Backup + | EventKind::Rollback + ) +} + +/// One audit row: a tracked file and its verdict. +#[derive(serde::Serialize)] +struct FileRow { + file: String, + label: &'static str, + status: &'static str, // ok | new | changed | deleted + changed_at: Option, + via: Option, // "am " or "manual (outside am)" +} + +/// One audited journal event. +#[derive(serde::Serialize)] +struct EventRow { + when: String, + who: String, // "am " + what: String, + agent: Option, +} + +pub fn run(app: &App) -> Result { + let mut sf = app.state.load()?; + let baseline = std::mem::take(&mut sf.config_checksums); + + // 1. Current checksums of every tracked file. + let files = tracked_files(app); + let mut rows: Vec = Vec::new(); + let mut next_baseline: BTreeMap = BTreeMap::new(); + + for (label, text, mtime) in &files { + let current = checksum_of(text, mtime.unwrap_or(0)); + let (status, via, changed_at) = match baseline.get(*label) { + None => ("new", Some("baseline recorded now".to_string()), None), + Some(prev) if prev.sha256 == current.sha256 => ("ok", None, None), + Some(prev) => { + let at = mtime + .and_then(|t| { + chrono::DateTime::from_timestamp(t as i64, 0) + .map(|d| d.to_rfc3339()) + }) + .unwrap_or_else(|| "unknown".to_string()); + (status_changed(prev, ¤t, app, &at), Some(at.clone()), Some(at)) + } + }; + let display = match *label { + EMBEDDED => "config.yaml (embedded default)".to_string(), + USER => user_config_display(app), + LOCAL => format!("./{} (project)", crate::config::CONFIG_FILE_NAME), + _ => label.to_string(), + }; + rows.push(FileRow { + file: display, + label, + status, + changed_at, + via, + }); + next_baseline.insert(label.to_string(), current); + } + + // 2. Cross-reference the journal: recent mutating events. + let events = crate::events::read_events(&app.events_dir(), 200); + let event_rows: Vec = events + .iter() + .filter(|e| is_mutating_kind(&e.kind)) + .map(|e| EventRow { + when: e.ts.clone(), + who: format!("am {}", e.kind.as_str()), + what: format!( + "{}{}", + e.reason.clone().unwrap_or_default(), + if e.args.is_empty() { + String::new() + } else { + format!(" ({})", e.args.join(", ")) + } + ), + agent: e.agent.clone(), + }) + .collect(); + + // 3. Persist the new baseline so the next audit compares against now. + sf.config_checksums = next_baseline; + app.state.save(&sf)?; + + if app.json() { + print_json(&serde_json::json!({ + "files": rows, + "events": event_rows, + "generated_at": crate::installers::now_rfc3339(), + })); + return Ok(0); + } + + // 4. Human-readable report. + println!("config audit — who changed what, when\n"); + let mut header = crate::tables::DataTable { + columns: vec!["FILE".into(), "STATUS".into(), "WHEN".into(), "VIA".into()], + rows: Vec::new(), + }; + for r in &rows { + header.rows.push(vec![ + crate::tables::Cell::str(&r.file), + crate::tables::Cell::str(status_style(r.status)), + crate::tables::Cell::str(r.changed_at.as_deref().unwrap_or("-")), + crate::tables::Cell::str(r.via.as_deref().unwrap_or("-")), + ]); + } + let width = crate::output::terminal_width().unwrap_or(120); + print!("{}", header.render_themed(app.theme(), app.color(), width)); + + println!("\nrecent changes (event journal):"); + if event_rows.is_empty() { + println!(" none"); + } else { + for e in event_rows.iter().take(15) { + println!( + " {} {} {}{}", + e.when, + e.who, + e.agent.as_deref().unwrap_or("-"), + if e.what.is_empty() { String::new() } else { format!(" — {}", e.what) } + ); + } + } + + let changed = rows.iter().filter(|r| r.status == "changed").count(); + if changed == 0 { + app.log.success("no configuration change detected since the last audit"); + } else { + app.log + .warn(&format!("{changed} configuration file(s) changed since the last audit")); + } + Ok(0) +} + +fn status_changed( + prev: &ConfigChecksum, + current: &ConfigChecksum, + app: &App, + at: &str, +) -> &'static str { + // A change can be explained by am itself (an event after the baseline). + let events = crate::events::read_events(&app.events_dir(), 100); + let explained = events.iter().any(|e| { + is_mutating_kind(&e.kind) && e.ts.as_str() >= prev.recorded_at.as_str() + }); + // 'via' is filled by the caller from this: manual vs am. + let _ = (current, at); + if explained { + "changed" + } else { + "manual" + } +} + +fn status_style(s: &str) -> String { + match s { + "ok" => "ok".to_string(), + "new" => "new".to_string(), + "changed" => "CHANGED".to_string(), + "manual" => "MANUAL".to_string(), + _ => s.to_string(), + } +} + +fn user_config_display(app: &App) -> String { + app.paths + .config_dir + .as_ref() + .map(|d| d.join(crate::config::CONFIG_FILE_NAME).display().to_string()) + .unwrap_or_else(|| crate::config::CONFIG_FILE_NAME.to_string()) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::Cli; + use crate::state::{StateFile, StateStore}; + use clap::Parser; + use std::io::Write; + + fn test_app(tag: &str) -> (App, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.yaml"); + std::fs::write(&cfg, "version: \"1.0\"\nagents: []\n").unwrap(); + let cli = Cli::parse_from([ + "am", + "--config", + cfg.to_str().unwrap(), + "--no-color", + ]); + let mut app = App::from_cli(cli).expect("app builds"); + let state_file = dir.path().join("state.json"); + app.paths.state_file = state_file.clone(); + app.paths.config_dir = Some(dir.path().to_path_buf()); + app.state = StateStore::new(state_file); + (app, dir) + } + + #[test] + fn checksum_changes_when_content_changes() { + let a = checksum_of("version: \"1.0\"\n", 0); + let b = checksum_of("version: \"1.0\"\n# extra\n", 0); + assert_ne!(a.sha256, b.sha256, "different content -> different hash"); + assert_eq!(a.sha256, checksum_of("version: \"1.0\"\n", 0).sha256); + } + + #[test] + fn audit_records_baseline_and_detects_change() { + let (app, dir) = test_app("audit"); + let user_cfg = dir.path().join("config.yaml"); + + // First run: everything is recorded as baseline. + run(&app).unwrap(); + let sf = app.state.load().unwrap(); + assert!(sf.config_checksums.contains_key(EMBEDDED)); + assert!(sf.config_checksums.contains_key(USER)); + + // Touch the user config outside of am. + let mut f = std::fs::OpenOptions::new() + .append(true) + .open(&user_cfg) + .unwrap(); + writeln!(f, "# modified by hand").unwrap(); + drop(f); + + // Second run: the change is flagged (status != ok). + run(&app).unwrap(); + let sf2 = app.state.load().unwrap(); + // The baseline was re-recorded, so a third run is clean. + run(&app).unwrap(); + let sf3 = app.state.load().unwrap(); + assert_eq!(sf3.config_checksums.len(), sf2.config_checksums.len()); + assert!(sf3.config_checksums[USER].sha256.len() == 64); + } + + #[test] + fn state_file_roundtrips_checksums() { + let mut sf = StateFile::default(); + sf.config_checksums.insert( + "user".to_string(), + ConfigChecksum { + sha256: "ab".repeat(32), + mtime_secs: 42, + recorded_at: "2026-08-18T00:00:00Z".to_string(), + }, + ); + let text = serde_json::to_string(&sf).unwrap(); + let back: StateFile = serde_json::from_str(&text).unwrap(); + assert_eq!(back.config_checksums["user"].mtime_secs, 42); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 66be5df..4763bc5 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -3,6 +3,7 @@ pub mod agents_cmd; pub mod alias_cmd; pub mod annotations_cmd; +pub mod audit_cmd; pub mod catalog_cmd; pub mod completion_cmd; pub mod config_cmd; @@ -143,7 +144,10 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result { force, } => install_cmd::run(app, agent, method.as_deref(), *force), Command::Uninstall { agent, purge } => uninstall_cmd::run(app, agent, *purge), - Command::Update { agent, all } => update_cmd::run(app, agent.as_deref(), *all), + Command::Update { agent, all, rollback } => { + update_cmd::run(app, agent.as_deref(), *all, rollback.as_deref()) + } + Command::Audit => audit_cmd::run(app), Command::Search { keyword, category, tag } => { search_cmd::run(app, keyword.as_deref(), category.as_deref(), tag.as_deref()) } diff --git a/src/commands/tip_cmd.rs b/src/commands/tip_cmd.rs index 0a2fa8f..81dc966 100644 --- a/src/commands/tip_cmd.rs +++ b/src/commands/tip_cmd.rs @@ -119,6 +119,21 @@ pub static SECTIONS: &[TipSection] = &[ options: &[("add ", "ajoute un catalogue d'équipe par URL")], example: "catalog add https://git.dracodev.net/team/agents.yaml", }, + TipEntry { + usage: "audit", + about: "qui a modifié quoi, quand (checksums config + journal d'événements)", + options: &[("--json", "rapport machine-readable")], + example: "audit --json", + }, + TipEntry { + usage: "update --rollback", + about: "annule la dernière mise à jour (backup automatique avant chaque update)", + options: &[ + ("--rollback list", "liste les points de restauration"), + ("--rollback ", "restaure un point précis"), + ], + example: "update --rollback list", + }, TipEntry { usage: "install ", about: "installe l'agent et ses dépendances (Node, Python, Rust…)", diff --git a/src/commands/update_cmd.rs b/src/commands/update_cmd.rs index 4c7d427..caeed8e 100644 --- a/src/commands/update_cmd.rs +++ b/src/commands/update_cmd.rs @@ -2,6 +2,7 @@ //! reinstall when an update exists. use super::*; +use crate::backup; use crate::events::{Event, EventKind}; use crate::installers; use crate::runner::{Runner, SystemRunner}; @@ -9,7 +10,17 @@ use anyhow::{bail, Result}; use std::cmp::Ordering; use std::path::PathBuf; -pub fn run(app: &App, agent_name: Option<&str>, all: bool) -> Result { +pub fn run( + app: &App, + agent_name: Option<&str>, + all: bool, + rollback: Option<&str>, +) -> Result { + // Issue #64: --rollback restores a pre-update backup instead of updating. + if rollback.is_some() { + return rollback_point(app, rollback); + } + let runner = SystemRunner::new(app.dry_run(), app.cli.verbose, &app.log); let policy = app.config.settings.update_policy.unwrap_or_default(); @@ -28,6 +39,16 @@ pub fn run(app: &App, agent_name: Option<&str>, all: bool) -> Result { let entries = app.state.all()?; let names: Vec = entries.keys().cloned().collect(); let mut failed = 0; + // Issue #64: one snapshot covers the whole batch. + if !app.dry_run() && !names.is_empty() { + let info = backup::create_backup(app, "update:--all", &names)?; + app.log.verbose(&format!("pre-update backup: {}", info.id)); + app.emit( + &Event::now(EventKind::Backup) + .with_args(vec![info.id.clone()]) + .with_reason("update:--all"), + ); + } for name in names { if let Err(e) = update_one(app, &runner, &name) { failed += 1; @@ -124,6 +145,16 @@ fn update_one(app: &App, runner: &dyn Runner, name: &str) -> Result { .dry(format!("would reinstall {} via {}", agent.name, method.kind)); return Ok(0); } + // Issue #64: snapshot before the real update so a broken upgrade can be + // undone with 'am update --rollback'. + let info = backup::create_backup(app, &format!("update:{}", agent.name), &[agent.name.clone()])?; + app.log.verbose(&format!("pre-update backup: {}", info.id)); + app.emit( + &Event::now(EventKind::Backup) + .with_args(vec![info.id.clone()]) + .with_agent(agent.name.clone()) + .with_reason("pre-update"), + ); crate::deps::ensure_deps(app, agent, runner)?; let outcome = installers::run_install(app, agent, method, runner)?; let mut new_entry = installers::make_entry(agent, method, &outcome, app); @@ -147,3 +178,42 @@ fn update_one(app: &App, runner: &dyn Runner, name: &str) -> Result { )); Ok(0) } + +/// Issue #64: 'am update --rollback [point]' — restore a pre-update backup. +/// '--rollback list' prints the available backup points. +fn rollback_point(app: &App, point: Option<&str>) -> Result { + if point == Some("list") { + let all = backup::list_backups(app); + if all.is_empty() { + app.log.info("no backup found — run an update first to create one"); + return Ok(0); + } + println!("pre-update backups ({}):", all.len()); + for b in &all { + println!( + " {} {} agents={}{}", + b.id, + b.reason, + if b.agents.is_empty() { + "all".to_string() + } else { + b.agents.join(",") + }, + if b.state && b.config { " [state+config]" } else { "" } + ); + } + println!("\nuse 'am update --rollback ' to restore one"); + return Ok(0); + } + let info = backup::restore_backup(app, point)?; + app.emit( + &Event::now(EventKind::Rollback) + .with_args(vec![info.id.clone()]) + .with_reason(&info.reason), + ); + app.log.success(&format!( + "rolled back to backup {} ({})", + info.id, info.reason + )); + Ok(0) +} diff --git a/src/config.rs b/src/config.rs index d58a7b7..bb5b1a4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -117,6 +117,10 @@ pub struct Settings { /// is attempted (issue #60, default 3600). #[serde(default)] pub catalog_cache_ttl_secs: Option, + /// How many pre-update backups 'am update --rollback' keeps + /// (issue #64, default 5). + #[serde(default)] + pub backups_keep: Option, } /// Policy controlling how aggressively 'am update' upgrades installed agents. @@ -638,6 +642,9 @@ pub fn merge(base: &mut Config, overlay: Config) { if o.catalog_cache_ttl_secs.is_some() { s.catalog_cache_ttl_secs = o.catalog_cache_ttl_secs; } + if o.backups_keep.is_some() { + s.backups_keep = o.backups_keep; + } for (k, v) in o.hooks { s.hooks.insert(k, v); } diff --git a/src/events.rs b/src/events.rs index 342891d..5543c36 100644 --- a/src/events.rs +++ b/src/events.rs @@ -36,6 +36,10 @@ pub enum EventKind { Model, /// Remote catalog action: update or add of an external catalog (issue #60). Catalog, + /// Pre-update backup created (issue #64). + Backup, + /// Rollback of a previous backup (issue #64). + Rollback, } impl EventKind { @@ -55,6 +59,8 @@ impl EventKind { EventKind::Annotate => "annotate", EventKind::Model => "model", EventKind::Catalog => "catalog", + EventKind::Backup => "backup", + EventKind::Rollback => "rollback", } } } diff --git a/src/help.rs b/src/help.rs index b3ee174..e203083 100644 --- a/src/help.rs +++ b/src/help.rs @@ -421,6 +421,42 @@ pub static HELP_SPECS: &[HelpSpec] = &[ HelpExample { desc: "Machine-readable output.", code: "suggest frontend framework --json" }, ], }, + HelpSpec { + name: "audit", + category: "Commands", + usage: "audit {flags}", + about: "Who changed what, when: config checksums vs the event journal, manual edits flagged.", + search_terms: &["security", "trace", "checksum", "who changed", "history config"], + flags: &[ + HelpFlag { short: "", long: "--json", value: "", desc: "Stable machine-readable report" }, + ], + subcommands: &[], + parameters: &[], + io: None, + examples: &[ + HelpExample { desc: "Run the config audit.", code: "audit" }, + HelpExample { desc: "Machine-readable report.", code: "audit --json" }, + ], + }, + HelpSpec { + name: "update", + category: "Commands", + usage: "update {flags} ", + about: "Update installed agents; every update is backed up first and can be undone.", + search_terms: &["upgrade", "rollback", "restore", "backup"], + flags: &[ + HelpFlag { short: "", long: "--all", value: "", desc: "Update every managed agent (one backup for the batch)" }, + HelpFlag { short: "", long: "--rollback", value: "[POINT]", desc: "Restore a pre-update backup ('list' shows them, default: latest)" }, + ], + subcommands: &[], + parameters: &[], + io: None, + examples: &[ + HelpExample { desc: "Update one agent (backup taken first).", code: "update claude-code" }, + HelpExample { desc: "List the available backups.", code: "update --rollback list" }, + HelpExample { desc: "Undo the last update.", code: "update --rollback" }, + ], + }, HelpSpec { name: "alias", category: "Commands", @@ -574,25 +610,6 @@ pub static HELP_SPECS: &[HelpSpec] = &[ HelpExample { desc: "Remove everything, including logs and the config entry.", code: "uninstall claude-code --purge" }, ], }, - HelpSpec { - name: "update", - category: "Commands", - usage: "update {flags} [agent]", - about: "Update an installed agent to the latest available version.", - search_terms: &["upgrade"], - flags: &[ - HelpFlag { short: "", long: "--all", value: "", desc: "Update every installed managed agent" }, - ], - subcommands: &[], - parameters: &[ - HelpParam { name: "agent", typ: "string", desc: "Agent name or alias (required unless --all)" }, - ], - io: None, - examples: &[ - HelpExample { desc: "Update one agent.", code: "update claude-code" }, - HelpExample { desc: "Update every managed agent.", code: "update --all" }, - ], - }, HelpSpec { name: "start", category: "Commands", diff --git a/src/lib.rs b/src/lib.rs index 3bbbd83..0c40b6e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ //! * commands — one module per CLI command. pub mod app; +pub mod backup; pub mod catalog; pub mod catalog_remote; pub mod cli; diff --git a/src/repl.rs b/src/repl.rs index f69c009..90a4f31 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -109,6 +109,7 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[ ("models", "list local models (ollama, llama.cpp, LM Studio)"), ("catalog", "manage remote catalogs"), ("suggest", "recommend an agent for a request"), + ("audit", "who changed what, when (config checksums)"), ("shell", "show or switch the system shell"), ("theme", "show or switch the color theme"), ("tip", "cheat sheet of the most useful commands"), @@ -222,7 +223,7 @@ impl AmCompleter { "start", "stop", "restart", "run", "doctor", "config", "completion", "self-update", "self-uninstall", "export", "import", "shell", "theme", "tip", "dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags", - "profile", "man", "models", "catalog", "suggest", + "profile", "man", "models", "catalog", "suggest", "audit", "ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit", ], config_sub: vec!["show", "path", "edit", "validate", "add"], @@ -769,7 +770,7 @@ pub fn banner_box( " config alias · secret · profile · config · doctor · completion · man · tip".to_string(), )); rows.push(inner( - " models models · models --prune · catalog · suggest".to_string(), + " models models · models --prune · catalog · suggest · audit".to_string(), )); rows.push(inner( " system self-update · self-uninstall · export · import".to_string(), @@ -1608,14 +1609,23 @@ fn handle_line( Command::Update { agent: None, all: true, + rollback: opt_value("--rollback"), + } + } else if let Some(rb) = opt_value("--rollback") { + Command::Update { + agent: None, + all: false, + rollback: Some(rb), } } else { Command::Update { agent: rest.first().cloned(), all: false, + rollback: None, } } } + "audit" => Command::Audit, "doctor" => Command::Doctor { fix: flag("--fix") }, "run" => { let agent = need("agent name")?; @@ -1730,6 +1740,10 @@ fn is_am_command(word: &str) -> bool { | "profile" | "man" | "tip" + | "models" + | "catalog" + | "suggest" + | "audit" ) } diff --git a/src/state.rs b/src/state.rs index f199ab0..160040d 100644 --- a/src/state.rs +++ b/src/state.rs @@ -24,6 +24,21 @@ pub struct StateFile { /// Personal annotations per agent: favorite, note, tags (issue #39). #[serde(default)] pub annotations: BTreeMap, + /// Config file checksums used by 'am audit' (axe 8, issue #63). + #[serde(default)] + pub config_checksums: BTreeMap, +} + +/// One recorded config checksum: the baseline 'am audit' compares against +/// to detect manual modifications (issue #63). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ConfigChecksum { + /// SHA-256 of the file content (hex). + pub sha256: String, + /// File modification time (seconds since epoch) at recording time. + pub mtime_secs: u64, + /// RFC 3339 timestamp of the recording. + pub recorded_at: String, } /// Personal annotations attached to an agent by the user (issue #39). @@ -48,6 +63,7 @@ impl Default for StateFile { sessions_count: 0, last_used: None, annotations: BTreeMap::new(), + config_checksums: BTreeMap::new(), } } } diff --git a/tests/update_policy_test.rs b/tests/update_policy_test.rs index 3e1d9ec..266551c 100644 --- a/tests/update_policy_test.rs +++ b/tests/update_policy_test.rs @@ -1,6 +1,7 @@ use agent_manager::commands::update_cmd; use agent_manager::config::UpdatePolicy; use agent_manager::state::{InstalledEntry, StateFile}; +use clap::Parser; use std::collections::BTreeMap; mod common; @@ -26,6 +27,7 @@ fn installed_state(name: &str, version: &str) -> StateFile { sessions_count: 0, last_used: None, annotations: BTreeMap::new(), + config_checksums: BTreeMap::new(), } } @@ -44,7 +46,7 @@ fn update_policy_none_blocks_all() { let mut app = common::test_app("policy_none", &["--yes"]); app.config.settings.update_policy = Some(UpdatePolicy::None); app.state.save(&installed_state("pi", "0.1.0")).unwrap(); - let code = update_cmd::run(&app, None, true).unwrap(); + let code = update_cmd::run(&app, None, true, None).unwrap(); assert_eq!(code, 0); } @@ -53,7 +55,7 @@ fn pinned_agent_is_skipped() { let body = agent_yaml("pi", Some("0.1.0")); let app = common::test_app("pinned_ok", &[]); app.state.save(&installed_state("pi", "0.1.0")).unwrap(); - let code = update_cmd::run(&app, Some("pi"), false).unwrap(); + let code = update_cmd::run(&app, Some("pi"), false, None).unwrap(); assert_eq!(code, 0); } @@ -62,6 +64,6 @@ fn pinned_agent_mismatch_warns() { let body = agent_yaml("pi", Some("0.1.0")); let app = common::test_app("pinned_warn", &[]); app.state.save(&installed_state("pi", "0.2.0")).unwrap(); - let code = update_cmd::run(&app, Some("pi"), false).unwrap(); + let code = update_cmd::run(&app, Some("pi"), false, None).unwrap(); assert_eq!(code, 0); }