From e82979e6a34f30d13761a33c0ab5888c56310d5e Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Tue, 18 Aug 2026 12:09:43 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20phase=202=20=E2=80=94=20sauvegarde=20gi?= =?UTF-8?q?t=20(am=20sync)=20+=20transfert=20machine=20(am=20migrate)=20(c?= =?UTF-8?q?loses=20#66=20#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 2 + ROADMAP.md | 8 +- config.yaml | 4 + man/am-migrate.1 | 22 ++ man/am-sync.1 | 16 ++ man/am.1 | 10 +- src/cli.rs | 18 ++ src/commands/migrate_cmd.rs | 489 ++++++++++++++++++++++++++++++++++++ src/commands/mod.rs | 6 + src/commands/sync_cmd.rs | 26 ++ src/commands/tip_cmd.rs | 12 + src/config.rs | 18 ++ src/help.rs | 37 +++ src/lib.rs | 1 + src/repl.rs | 34 ++- src/sync.rs | 269 ++++++++++++++++++++ 18 files changed, 966 insertions(+), 10 deletions(-) create mode 100644 man/am-migrate.1 create mode 100644 man/am-sync.1 create mode 100644 src/commands/migrate_cmd.rs create mode 100644 src/commands/sync_cmd.rs create mode 100644 src/sync.rs diff --git a/Cargo.lock b/Cargo.lock index 3a3af6a..8d0259e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,7 +21,7 @@ dependencies = [ [[package]] name = "agent-manager" -version = "0.5.1" +version = "0.5.2" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index d8f01c5..b1853ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agent-manager" -version = "0.5.1" +version = "0.5.2" 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 5b74a3f..1a8854b 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,8 @@ plateforme, il compile automatiquement depuis les sources. | am doctor --watch | vérifications périodiques de l'environnement + alerte en cas de panne | | am monitor [--json] | TUI temps réel des processus gérés (CPU/RSS/uptime) + alertes de seuils | | am stats --costs | coût estimé par agent (tokens in/out, $) — modèles de prix configurables | +| am sync [--message ] | sauvegarde git de l'état (state.json, journal, historique) — secrets exclus | +| am migrate export/import | bundle de transfert machine A → B (config + état + historique + backups) | | am service install --autostart | service système (systemd / launchd / tâche Windows) + démarrage auto | | am schedule add --at HH:MM | planifie une commande am (ex: update --all) ; list / remove / run | | am start group:dev --parallel | orchestration de groupes : ordre, --parallel, attente de santé (healthcheck) | diff --git a/ROADMAP.md b/ROADMAP.md index 9cfe788..a0e8b69 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -284,9 +284,9 @@ consultable, reprenable, archivable. | Fonctionnalité | Effort | Phase | |---|---|---| -| Synchronisation git automatique : settings.sync_repo, am sync, push à la fermeture du REPL | L | P2 | +| Synchronisation git automatique : settings.sync_repo, am sync, push à la fermeture du REPL ✅ #66 | L | P2 | | Partage de catalogue d'équipe : include par URL | S | P2 | -| am migrate — assistant de transfert machine A → B | M | P2 | +| am migrate — assistant de transfert machine A → B ✅ #68 | M | P2 | | am serve --token — API HTTP + WebSocket pour piloter à distance | XL | P3 | ### Axe 10 — 🧩 Confort & personnalisation @@ -447,9 +447,9 @@ moins de 2 secondes. | [#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 | +| [#66](https://git.dracodev.net/Projets/agent-manager/issues/66) | ✅ Synchronisation git automatique — am sync (sync_repo, sync_on_exit, secrets exclus) | L | | [#67](https://git.dracodev.net/Projets/agent-manager/issues/67) | ✅ Partage de catalogue d'équipe (include par URL) | S | -| [#68](https://git.dracodev.net/Projets/agent-manager/issues/68) | am migrate — assistant de transfert machine A → B | M | +| [#68](https://git.dracodev.net/Projets/agent-manager/issues/68) | ✅ am migrate — assistant de transfert machine A → B (bundle .amx + doctor) | M | | [#69](https://git.dracodev.net/Projets/agent-manager/issues/69) | ✅ Thèmes couleurs du REPL | S | | [#70](https://git.dracodev.net/Projets/agent-manager/issues/70) | ✅ am models — inventaire des modèles locaux (ollama, llama.cpp, LM Studio) | M | | [#71](https://git.dracodev.net/Projets/agent-manager/issues/71) | ✅ Lien agent ↔ modèle — am run --model | M | diff --git a/config.yaml b/config.yaml index e4f738b..22bc63c 100644 --- a/config.yaml +++ b/config.yaml @@ -33,6 +33,10 @@ settings: # monitor_thresholds: # cpu_pct: 80.0 # mem_mb: 2048 + # Sauvegarde git de l'état (#66) : dépôt où am sync pousse state.json, + # le journal et l'historique (logs/ et backups/ exclus automatiquement). + # sync_repo: https://git.dracodev.net/bruno/am-state.git + # sync_on_exit: true # pousse automatiquement à la fermeture du REPL (opt-in) # --- Command aliases ---------------------------------------------------------- aliases: diff --git a/man/am-migrate.1 b/man/am-migrate.1 new file mode 100644 index 0000000..8fe1b0b --- /dev/null +++ b/man/am-migrate.1 @@ -0,0 +1,22 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH am-migrate 1 "migrate " +.SH NAME +migrate \- Transfer the installation and state to another machine (issue #68) +.SH SYNOPSIS +\fBmigrate\fR [\fB\-\-export\fR] [\fB\-\-output\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIBUNDLE\fR] +.SH DESCRIPTION +Transfer the installation and state to another machine (issue #68) +.SH OPTIONS +.TP +\fB\-\-export\fR +Export the bundle (default) or import one +.TP +\fB\-\-output\fR \fI\fR +Bundle path for export (default agent\-manager\-migrate.amx) +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help +.TP +[\fIBUNDLE\fR] +Bundle path to import diff --git a/man/am-sync.1 b/man/am-sync.1 new file mode 100644 index 0000000..d35b439 --- /dev/null +++ b/man/am-sync.1 @@ -0,0 +1,16 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH am-sync 1 "sync " +.SH NAME +sync \- Push the state into the configured git repository (issue #66) +.SH SYNOPSIS +\fBsync\fR [\fB\-\-message\fR] [\fB\-h\fR|\fB\-\-help\fR] +.SH DESCRIPTION +Push the state into the configured git repository (issue #66) +.SH OPTIONS +.TP +\fB\-\-message\fR \fI\fR +Commit message (default: "am sync — state update") +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help diff --git a/man/am.1 b/man/am.1 index 61521da..6eb2792 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.5.1" +.TH am 1 "am 0.5.2" .SH NAME am \- agent\-manager (am) — manage local AI coding agents .SH SYNOPSIS @@ -132,6 +132,12 @@ Show usage statistics computed from the event journal am\-monitor(1) Real\-time monitor of the managed processes (issue #50) .TP +am\-sync(1) +Push the state into the configured git repository (issue #66) +.TP +am\-migrate(1) +Transfer the installation and state to another machine (issue #68) +.TP am\-top(1) Show the most used agents (top 10) .TP @@ -201,4 +207,4 @@ Export the configuration and installation state (backup) am\-import(1) Import a previously exported configuration and state .SH VERSION -v0.5.1 +v0.5.2 diff --git a/src/cli.rs b/src/cli.rs index ef9060b..9330c23 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -269,6 +269,24 @@ pub enum Command { #[arg(long, action = ArgAction::SetTrue)] json: bool, }, + /// Push the state into the configured git repository (issue #66) + Sync { + /// Commit message (default: "am sync — state update") + #[arg(long, value_name = "MESSAGE")] + message: Option, + }, + /// Transfer the installation and state to another machine (issue #68) + Migrate { + /// Export the bundle (default) or import one + #[arg(long, action = ArgAction::SetTrue)] + export: bool, + /// Bundle path for export (default agent-manager-migrate.amx) + #[arg(long, value_name = "FILE")] + output: Option, + /// Bundle path to import + #[arg(value_name = "BUNDLE")] + bundle: Option, + }, /// Show the most used agents (top 10) Top { /// Only events of the last period (7d, 30d, 90d, all) diff --git a/src/commands/migrate_cmd.rs b/src/commands/migrate_cmd.rs new file mode 100644 index 0000000..69f7a55 --- /dev/null +++ b/src/commands/migrate_cmd.rs @@ -0,0 +1,489 @@ +//! migrate: transfer the installation and state from machine A to machine B +//! (issue #68). `am migrate export` bundles config, state, journal, history, +//! custom catalogs and backups into a single .amx file with a manifest of +//! hashes; `am migrate import` restores it and runs `am doctor` afterwards. +//! Absolute paths are neutralized ({{STATE_DIR}} / {{HOME}} placeholders) so +//! the bundle is portable. No runtime dependency: the bundle format is a +//! JSON manifest line followed by concatenated blobs. + +use super::*; +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; + +pub const BUNDLE_FORMAT: &str = "am-migrate-bundle"; +pub const BUNDLE_VERSION: u32 = 1; + +/// One file inside the bundle. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BundleFile { + /// Relative path inside the state layout ("state.json", "events/...", ...). + pub path: String, + pub size: u64, + pub sha256: String, + /// Byte offset of the blob inside the bundle. + pub offset: u64, +} + +/// Bundle header (first line of the file, then the blobs). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BundleManifest { + pub format: String, + pub version: u32, + pub created: String, + pub am_version: String, + pub files: Vec, +} + +/// sha256 hex of a byte slice. +pub fn sha256_hex(data: &[u8]) -> String { + use sha2::{Digest, Sha256}; + let mut h = Sha256::new(); + h.update(data); + let out = h.finalize(); + out.iter().map(|b| format!("{b:02x}")).collect() +} + +/// Neutralize absolute paths of machine A into portable placeholders. +/// The source paths are replaced only when they match the current machine. +pub fn neutralize(text: &str, state_dir: &Path, home: &Path) -> String { + let mut out = text.to_string(); + let sd = state_dir.to_string_lossy().to_string(); + let h = home.to_string_lossy().to_string(); + out = out.replace(&sd, "{{STATE_DIR}}"); + out = out.replace(&h, "{{HOME}}"); + out +} + +/// Restore the placeholders with machine B paths. +pub fn deneutralize(text: &str, state_dir: &Path, home: &Path) -> String { + let mut out = text.to_string(); + out = out.replace("{{STATE_DIR}}", &state_dir.to_string_lossy()); + out = out.replace("{{HOME}}", &home.to_string_lossy()); + out +} + +/// Collect the files to bundle: config, state, journal, history, catalog +/// cache, backups. Returns (relative path, absolute path, needs path +/// neutralization). +fn collect_files(app: &App) -> Vec<(String, PathBuf, bool)> { + let state = app.events_dir(); + let mut out: Vec<(String, PathBuf, bool)> = Vec::new(); + + // Active config file (when it exists on disk). + if let Some(dir) = &app.paths.config_dir { + let cfg = dir.join(crate::config::CONFIG_FILE_NAME); + if cfg.exists() { + out.push(("config.yaml".to_string(), cfg, true)); + } + } + // State file. + let sf = app.paths.state_file.clone(); + if sf.exists() { + out.push(("state.json".to_string(), sf, true)); + } + // Journal events. + let events = state.join("events"); + if let Ok(entries) = std::fs::read_dir(&events) { + for e in entries.flatten() { + let p = e.path(); + if p.extension().and_then(|x| x.to_str()) == Some("jsonl") { + let rel = format!( + "events/{}", + p.file_name().unwrap_or_default().to_string_lossy() + ); + out.push((rel, p, true)); + } + } + } else if let Ok(entries) = std::fs::read_dir(&state) { + // Older layout: journal files sit directly in the state directory. + for e in entries.flatten() { + let p = e.path(); + let name = p.file_name().unwrap_or_default().to_string_lossy().to_string(); + if name.starts_with("events-") && name.ends_with(".jsonl") { + out.push((name, p, true)); + } + } + } + // History files (per-session JSONL). + let history = state.join("history"); + if let Ok(entries) = std::fs::read_dir(&history) { + for e in entries.flatten() { + let p = e.path(); + let rel = format!( + "history/{}", + p.file_name().unwrap_or_default().to_string_lossy() + ); + out.push((rel, p, true)); + } + } + // Catalog cache (custom catalogs). + let cache = state.join("catalog-cache.json"); + if cache.exists() { + out.push(("catalog-cache.json".to_string(), cache, true)); + } + // Backups (recursive: one entry per file under backups//). + let backups = state.join("backups"); + if let Ok(entries) = std::fs::read_dir(&backups) { + for e in entries.flatten() { + let p = e.path(); + if !p.is_dir() { + continue; + } + let id = p.file_name().unwrap_or_default().to_string_lossy().to_string(); + if let Ok(files) = std::fs::read_dir(&p) { + for f in files.flatten() { + let fp = f.path(); + if fp.is_file() { + let rel = format!( + "backups/{}/{}", + id, + fp.file_name().unwrap_or_default().to_string_lossy() + ); + out.push((rel, fp, false)); + } + } + } + } + } + out +} + +/// Write the bundle. Returns the number of bundled files. +pub fn export_bundle(app: &App, output: &Path) -> Result { + let files = collect_files(app); + let mut manifest = BundleManifest { + format: BUNDLE_FORMAT.to_string(), + version: BUNDLE_VERSION, + created: crate::installers::now_rfc3339(), + am_version: env!("CARGO_PKG_VERSION").to_string(), + files: Vec::new(), + }; + let state_dir = app.events_dir(); + let home = crate::config::home_dir().unwrap_or_else(|| PathBuf::from(".")); + let mut blobs: Vec> = Vec::new(); + let mut offset: u64 = 0; + + for (rel, abs, neutralize_paths) in &files { + let raw = std::fs::read(abs) + .with_context(|| format!("cannot read {}", abs.display()))?; + let data = if *neutralize_paths { + // Text files: neutralize absolute paths (works on both YAML and + // JSON since the placeholders are plain strings). + let text = String::from_utf8_lossy(&raw); + let neutral = neutralize(&text, &state_dir, &home); + neutral.into_bytes() + } else { + raw + }; + manifest.files.push(BundleFile { + path: rel.clone(), + size: data.len() as u64, + sha256: sha256_hex(&data), + offset, + }); + offset += data.len() as u64; + blobs.push(data); + } + + if let Some(parent) = output.parent() { + std::fs::create_dir_all(parent)?; + } + let mut f = std::fs::File::create(output) + .with_context(|| format!("cannot create {}", output.display()))?; + let header = serde_json::to_string(&manifest)?; + writeln!(f, "{header}")?; + for b in &blobs { + f.write_all(b)?; + } + f.flush()?; + Ok(manifest.files.len()) +} + +/// Read and verify the bundle: parses the header, checks that every blob is +/// present and its hash matches. Returns (manifest, blob bytes). +pub fn read_bundle(path: &Path) -> Result<(BundleManifest, Vec>)> { + let mut raw = Vec::new(); + std::fs::File::open(path) + .with_context(|| format!("cannot open {}", path.display()))? + .read_to_end(&mut raw)?; + let nl = raw + .iter() + .position(|&b| b == b'\n') + .ok_or_else(|| anyhow!("{}: missing header line", path.display()))?; + let header: BundleManifest = serde_json::from_slice(&raw[..nl]) + .with_context(|| format!("{}: invalid bundle header", path.display()))?; + if header.format != BUNDLE_FORMAT { + anyhow::bail!("{}: not an am-migrate bundle", path.display()); + } + let body = &raw[nl + 1..]; + let mut blobs = Vec::new(); + for f in &header.files { + let start = f.offset as usize; + let end = start + f.size as usize; + if end > body.len() { + anyhow::bail!( + "{}: truncated bundle — '{}' is incomplete (interrupted transfer?)", + path.display(), + f.path + ); + } + let data = body[start..end].to_vec(); + if sha256_hex(&data) != f.sha256 { + anyhow::bail!( + "{}: checksum mismatch for '{}' — bundle corrupted", + path.display(), + f.path + ); + } + blobs.push(data); + } + Ok((header, blobs)) +} + +/// Restore a bundle into the state layout of machine B. The previous state +/// directory is moved aside (resumable, nothing lost). Returns the list of +/// restored files. +pub fn import_bundle(app: &App, bundle: &Path, confirm: bool) -> Result> { + let (manifest, blobs) = read_bundle(bundle)?; + let state_dir = app.events_dir(); + let home = crate::config::home_dir().unwrap_or_else(|| PathBuf::from(".")); + + // Resumable import: park the current state aside, then write the new one. + let ts = chrono::Utc::now().format("%Y%m%d-%H%M%S"); + if state_dir.exists() { + let park = state_dir.with_extension(format!("prev-{ts}")); + std::fs::rename(&state_dir, &park) + .with_context(|| format!("cannot park old state at {}", park.display()))?; + app.log + .info(&format!("previous state moved aside: {}", park.display())); + } + std::fs::create_dir_all(&state_dir)?; + + let mut restored = Vec::new(); + for (bf, data) in manifest.files.iter().zip(blobs.iter()) { + let rel = Path::new(&bf.path); + let target = if bf.path == "config.yaml" { + // Config goes to the user config directory of machine B. + let dir = crate::config::user_config_dir() + .unwrap_or_else(|| crate::config::home_dir().unwrap_or_else(|| PathBuf::from("."))); + std::fs::create_dir_all(&dir)?; + dir.join(crate::config::CONFIG_FILE_NAME) + } else { + state_dir.join(rel) + }; + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent)?; + } + let mut content = data.clone(); + if !rel.starts_with("backups") { + // Text content: restore machine B paths. + let text = String::from_utf8_lossy(&content); + content = deneutralize(&text, &state_dir, &home).into_bytes(); + } + std::fs::write(&target, &content) + .with_context(|| format!("cannot write {}", target.display()))?; + restored.push(bf.path.clone()); + } + + // Config merge: the imported config replaces the local one only when + // confirmed; otherwise it is written next to it as config.migrated.yaml. + let cfg_rel = "config.yaml"; + if restored.iter().any(|r| r == cfg_rel) && !confirm { + let dir = crate::config::user_config_dir() + .unwrap_or_else(|| crate::config::home_dir().unwrap_or_else(|| PathBuf::from("."))); + let imported = dir.join(crate::config::CONFIG_FILE_NAME); + if imported.exists() { + let target = dir.join("config.migrated.yaml"); + std::fs::rename(&imported, &target)?; + app.log.info(&format!( + "imported config written as {} (use --yes to replace the local config)", + target.display() + )); + } + } + restored.sort(); + Ok(restored) +} + +/// am migrate export/import. +pub fn run(app: &App, export: bool, output: Option<&Path>, bundle: Option<&Path>) -> Result { + if export { + let default_out = PathBuf::from("agent-manager-migrate.amx"); + let out = output.unwrap_or(&default_out); + let n = export_bundle(app, out)?; + app.log.success(&format!( + "bundle written to {} ({n} files) — copy it to machine B and run 'am migrate import {}'", + out.display(), + out.display() + )); + if let Some(dir) = app.paths.config_dir.as_ref() { + if dir.join(crate::config::CONFIG_FILE_NAME).exists() { + app.log.info("the bundle contains config.yaml, state.json, events, history, catalog cache and backups — absolute paths are neutralized"); + } + } + return Ok(0); + } + let bundle = bundle.ok_or_else(|| anyhow!("usage: am migrate import "))?; + if !bundle.exists() { + anyhow::bail!("bundle not found: {}", bundle.display()); + } + let ask = format!( + "import {} into this machine? The current state will be moved aside", + bundle.display() + ); + let confirmed = app.confirm(&ask)?; + if !confirmed { + app.log.info("import cancelled"); + return Ok(0); + } + let restored = import_bundle(app, bundle, app.cli.yes)?; + app.log.success(&format!("imported {} file(s)", restored.len())); + + // Post-import verification (issue #68: doctor passes after import). + let problems = crate::commands::doctor_cmd::run(app, false, None)?; + if problems > 0 { + app.log.warn("doctor found issues after the import — review the report above"); + } else { + app.log.success("doctor: environment OK after import"); + } + app.log.info( + "next steps: (1) verify 'am list' and 'am status', (2) reinstall agents if needed ('am install '), (3) start a session", + ); + Ok(0) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sha256_is_stable() { + let h1 = sha256_hex(b"hello"); + let h2 = sha256_hex(b"hello"); + assert_eq!(h1, h2); + assert_eq!(h1.len(), 64); + assert_ne!(h1, sha256_hex(b"world")); + } + + #[test] + fn neutralization_round_trip() { + let state = PathBuf::from("/mnt/machine-a/state"); + let home = PathBuf::from("/home/user-a"); + let text = format!( + "install_dir: {}\nrun: {}/bin/tool\n", + state.display(), + home.display() + ); + let neutral = neutralize(&text, &state, &home); + assert!(neutral.contains("{{STATE_DIR}}")); + assert!(neutral.contains("{{HOME}}")); + assert!(!neutral.contains("machine-a")); + let state_b = PathBuf::from("/data/machine-b/state"); + let home_b = PathBuf::from("/home/user-b"); + let back = deneutralize(&neutral, &state_b, &home_b); + assert!(back.contains("/data/machine-b/state")); + assert!(back.contains("/home/user-b")); + } + + fn test_app(tag: &str) -> crate::app::App { + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.yaml"); + std::fs::write( + &cfg, + concat!( + "version: \"1.0\"\n", + "settings:\n", + " auto_install_deps: false\n", + " confirm_before_run: false\n", + "agents: []\n", + ), + ) + .unwrap(); + use clap::Parser; + let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap(), "list"]); + let mut app = crate::app::App::from_cli(cli).expect("app should build"); + app.paths.state_file = dir.path().join(format!("state-{tag}/state.json")); + app.paths.config_dir = Some(dir.path().to_path_buf()); + app + } + + #[test] + fn export_import_round_trip() { + let app = test_app("exp"); + let state = app.events_dir(); + std::fs::create_dir_all(&state).unwrap(); + std::fs::write(app.paths.state_file.clone(), "{\"version\":2,\"installed\":{}}").unwrap(); + std::fs::write(state.join("events-202608.jsonl"), "{\"kind\":\"start\"}\n").unwrap(); + std::fs::write(state.join("catalog-cache.json"), "{}").unwrap(); + // The config file referenced by config_dir must exist to be bundled. + let cfg_path = app + .paths + .config_dir + .as_ref() + .unwrap() + .join(crate::config::CONFIG_FILE_NAME); + std::fs::write(&cfg_path, "version: \"1.0\"\nagents: []\n").unwrap(); + + let bundle = std::env::temp_dir().join(format!("am-mig-{}.amx", std::process::id())); + let n = export_bundle(&app, &bundle).unwrap(); + assert!(n >= 3); + + // Import into a second app with a different state dir. + let app_b = test_app("imp"); + let restored = import_bundle(&app_b, &bundle, true).unwrap(); + assert!(restored.iter().any(|r| r == "state.json")); + assert!(restored.iter().any(|r| r == "events-202608.jsonl")); + // State restored with the same content. + let state_b = app_b.events_dir(); + let sf = state_b.join("state.json"); + assert!(sf.exists()); + let text = std::fs::read_to_string(sf).unwrap(); + assert!(text.contains("\"version\":2")); + assert!(state_b.join("events-202608.jsonl").exists()); + let _ = std::fs::remove_file(&bundle); + } + + #[test] + fn truncated_bundle_is_detected() { + let app = test_app("trunc"); + let state = app.events_dir(); + std::fs::create_dir_all(&state).unwrap(); + std::fs::write(state.join("state.json"), "{\"v\":1}").unwrap(); + let bundle = std::env::temp_dir().join(format!("am-trunc-{}.amx", std::process::id())); + export_bundle(&app, &bundle).unwrap(); + // Truncate the last byte. + let mut data = std::fs::read(&bundle).unwrap(); + data.pop(); + std::fs::write(&bundle, data).unwrap(); + let err = read_bundle(&bundle).unwrap_err().to_string(); + assert!(err.contains("truncated") || err.contains("checksum"), "{err}"); + let _ = std::fs::remove_file(&bundle); + } + + #[test] + fn tampered_blob_is_detected() { + let app = test_app("tamper"); + let state = app.events_dir(); + std::fs::create_dir_all(&state).unwrap(); + std::fs::write(state.join("state.json"), "{\"v\":1}").unwrap(); + let bundle = std::env::temp_dir().join(format!("am-tamp-{}.amx", std::process::id())); + export_bundle(&app, &bundle).unwrap(); + // Flip a byte inside the first blob (after the header line), not in + // the header itself. + let mut data = std::fs::read(&bundle).unwrap(); + let nl = data.iter().position(|&b| b == b'\n').unwrap(); + let mid = nl + 1 + (data.len() - nl - 1) / 2; + data[mid] ^= 0xFF; + std::fs::write(&bundle, data).unwrap(); + let err = read_bundle(&bundle).unwrap_err().to_string(); + assert!(err.contains("checksum"), "{err}"); + let _ = std::fs::remove_file(&bundle); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 6181828..cf1082c 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -21,6 +21,7 @@ pub mod logs_cmd; pub mod man_cmd; pub mod models_cmd; pub mod monitor_cmd; +pub mod migrate_cmd; pub mod open_cmd; pub mod profile_cmd; pub mod projects_cmd; @@ -35,6 +36,7 @@ pub mod sessions_cmd; pub mod stats_cmd; pub mod status_cmd; pub mod suggest_cmd; +pub mod sync_cmd; pub mod theme_cmd; pub mod timeline_cmd; pub mod tip_cmd; @@ -112,6 +114,10 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result { Command::Monitor { interval, json } => { monitor_cmd::run(app, *interval, *json) } + Command::Sync { message } => sync_cmd::run(app, message.as_deref()), + Command::Migrate { export, output, bundle } => { + migrate_cmd::run(app, *export, output.as_deref(), bundle.as_deref()) + } Command::Top { period } => stats_cmd::run_top(app, period.as_deref()), Command::Report { last_week, diff --git a/src/commands/sync_cmd.rs b/src/commands/sync_cmd.rs new file mode 100644 index 0000000..a83daa5 --- /dev/null +++ b/src/commands/sync_cmd.rs @@ -0,0 +1,26 @@ +//! sync: push the runtime state (state.json, journal, history) into the +//! configured git repository (issue #66). Secrets are excluded by the +//! managed .gitignore; conflicts resolve "last writer wins" with a backup. + +use super::*; + +pub fn run(app: &App, message: Option<&str>) -> Result { + let msg = message.unwrap_or("am sync — state update").to_string(); + match crate::sync::sync(app, &msg)? { + crate::sync::SyncOutcome::Skipped => { + app.log.info( + "no settings.sync_repo configured — add 'sync_repo: ' to the config (issue #66)", + ); + Ok(0) + } + crate::sync::SyncOutcome::Clean => { + app.log.info("nothing to sync — state already committed"); + Ok(0) + } + crate::sync::SyncOutcome::Pushed { files, message } => { + app.log + .success(&format!("state pushed ({files} file(s)): {message}")); + Ok(0) + } + } +} diff --git a/src/commands/tip_cmd.rs b/src/commands/tip_cmd.rs index b11af7a..6351a3f 100644 --- a/src/commands/tip_cmd.rs +++ b/src/commands/tip_cmd.rs @@ -242,6 +242,18 @@ pub static SECTIONS: &[TipSection] = &[ ], example: "monitor --interval 5", }, + TipEntry { + usage: "sync", + about: "pousse l'état (state.json, journal, historique) dans le dépôt git configuré (sync_repo)", + options: &[("--message ", "message de commit personnalisé"), ("sync_on_exit: true", "pousse automatiquement à la fermeture du REPL")], + example: "sync --message \"sauvegarde du soir\"", + }, + TipEntry { + usage: "migrate", + about: "export/import d'un bundle de transfert machine A → B (config + état + historique + backups)", + options: &[("--export", "crée agent-manager-migrate.amx"), ("--output ", "chemin du bundle")], + example: "migrate --export --output backup.amx", + }, TipEntry { usage: "timeline", about: "une vue chronologique de toute l'activité", diff --git a/src/config.rs b/src/config.rs index 0256bba..ec28553 100644 --- a/src/config.rs +++ b/src/config.rs @@ -130,6 +130,12 @@ pub struct Settings { /// CPU/memory thresholds for 'am monitor' alerts (issue #50). #[serde(default)] pub monitor_thresholds: Option, + /// Git repository where 'am sync' pushes the state (issue #66). + #[serde(default)] + pub sync_repo: Option, + /// Push automatically when the REPL exits (issue #66, opt-in). + #[serde(default)] + pub sync_on_exit: Option, } /// Token price model: USD per million tokens (issue #49). @@ -732,6 +738,18 @@ pub fn merge(base: &mut Config, overlay: Config) { if o.sessions_retention_days.is_some() { s.sessions_retention_days = o.sessions_retention_days; } + if o.cost_models.is_some() { + s.cost_models = o.cost_models; + } + if o.monitor_thresholds.is_some() { + s.monitor_thresholds = o.monitor_thresholds; + } + if o.sync_repo.is_some() { + s.sync_repo = o.sync_repo; + } + if o.sync_on_exit.is_some() { + s.sync_on_exit = o.sync_on_exit; + } for (k, v) in o.hooks { s.hooks.insert(k, v); } diff --git a/src/help.rs b/src/help.rs index 1304000..eaea14c 100644 --- a/src/help.rs +++ b/src/help.rs @@ -216,6 +216,43 @@ pub static HELP_SPECS: &[HelpSpec] = &[ HelpExample { desc: "Resume a finished session.", code: "sessions --resume 20260815_143926_a1b2c3" }, ], }, + HelpSpec { + name: "sync", + category: "Commands", + usage: "sync {flags}", + about: "Push the state (state.json, journal, history) into the configured git repository (issue #66).", + search_terms: &["git", "backup", "push", "sync_repo", "sauvegarde"], + flags: &[ + HelpFlag { short: "", long: "--message", value: "MESSAGE", desc: "Commit message (default: 'am sync — state update')" }, + ], + subcommands: &[], + parameters: &[], + io: None, + examples: &[ + HelpExample { desc: "Commit and push the state.", code: "sync" }, + HelpExample { desc: "Custom commit message.", code: "sync --message \"daily backup\"" }, + ], + }, + HelpSpec { + name: "migrate", + category: "Commands", + usage: "migrate {flags} [bundle]", + about: "Transfer the installation and state to another machine: export a .amx bundle, import it and verify with doctor (issue #68).", + search_terms: &["transfer", "machine", "bundle", "export", "import", "migration"], + flags: &[ + HelpFlag { short: "", long: "--export", value: "", desc: "Export the bundle (default when no bundle path is given)" }, + HelpFlag { short: "", long: "--output", value: "FILE", desc: "Bundle path for export (default agent-manager-migrate.amx)" }, + ], + subcommands: &[], + parameters: &[ + HelpParam { name: "bundle", typ: "path", desc: "Bundle to import (with confirmation + doctor post-import)" }, + ], + io: None, + examples: &[ + HelpExample { desc: "Export the bundle.", code: "migrate --export --output backup.amx" }, + HelpExample { desc: "Import on machine B.", code: "migrate backup.amx" }, + ], + }, HelpSpec { name: "stats", category: "Commands", diff --git a/src/lib.rs b/src/lib.rs index 75510b0..0b5acba 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,6 +44,7 @@ pub mod secrets; pub mod sessions; pub mod shell; pub mod state; +pub mod sync; pub mod tables; pub mod theme; pub mod toolchain; diff --git a/src/repl.rs b/src/repl.rs index 4f24839..45f9865 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -113,6 +113,8 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[ ("service", "register an agent as a system service (autostart)"), ("schedule", "plan am commands (daily) and check the fleet health"), ("monitor", "real-time TUI of managed processes (cpu/rss/uptime)"), + ("sync", "push the state (journal, sessions, config) into a git repo"), + ("migrate", "export/import a machine transfer bundle (config + state)"), ("shell", "show or switch the system shell"), ("theme", "show or switch the color theme"), ("tip", "cheat sheet of the most useful commands"), @@ -227,7 +229,7 @@ impl AmCompleter { "self-update", "self-uninstall", "export", "import", "shell", "theme", "tip", "dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags", "profile", "man", "models", "catalog", "suggest", "audit", - "service", "schedule", "monitor", + "service", "schedule", "monitor", "sync", "migrate", "ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit", ], config_sub: vec!["show", "path", "edit", "validate", "add"], @@ -777,7 +779,7 @@ pub fn banner_box( " models models · models --prune · catalog · suggest · audit".to_string(), )); rows.push(inner( - " automate service install · schedule add · doctor --watch · monitor".to_string(), + " automate service install · schedule add · doctor --watch · monitor · sync · migrate".to_string(), )); rows.push(inner( " system self-update · self-uninstall · export · import".to_string(), @@ -1084,6 +1086,16 @@ pub fn run(app: &App) -> Result { } }; let _ = crate::sessions::finish_repl(app, &sid); + // Issue #66: optional auto-sync of the state when the REPL exits. + if app.config.settings.sync_on_exit.unwrap_or(false) { + match crate::sync::sync(app, "repl exit — state sync") { + Ok(crate::sync::SyncOutcome::Pushed { .. }) => { + app.log.success("state pushed (sync_on_exit)"); + } + Ok(_) => {} + Err(e) => app.log.warn(&format!("sync_on_exit failed: {e:#}")), + } + } result } @@ -1539,6 +1551,24 @@ fn handle_line( interval: opt_value("--interval").and_then(|v| v.parse().ok()), json: flag("--json"), }, + "sync" => Command::Sync { + message: opt_value("--message"), + }, + "migrate" => { + if flag("--export") { + Command::Migrate { + export: true, + output: opt_value("--output").map(std::path::PathBuf::from), + bundle: None, + } + } else { + Command::Migrate { + export: false, + output: None, + bundle: rest.first().map(std::path::PathBuf::from), + } + } + }, "top" => Command::Top { period: opt_value("--period"), }, diff --git a/src/sync.rs b/src/sync.rs new file mode 100644 index 0000000..3c40fc9 --- /dev/null +++ b/src/sync.rs @@ -0,0 +1,269 @@ +//! Synchronization of the runtime state into a git repository (issue #66). +//! `am sync` commits and pushes the state directory (state.json, event +//! journal, history) to settings.sync_repo. Sensitive data is excluded via +//! a managed .gitignore (logs, backups, token/key/env files). Conflicts are +//! resolved "last writer wins" with a local backup of the pre-push state. + +use anyhow::{bail, Context, Result}; +use serde::Serialize; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// Outcome of a sync run. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub enum SyncOutcome { + /// No settings.sync_repo configured. + Skipped, + /// Nothing to commit (working tree clean). + Clean, + /// Committed and pushed. + Pushed { files: u32, message: String }, +} + +/// Content of the managed .gitignore (additive; the user's own entries are +/// preserved). Never expose secrets, logs or backup payloads. +pub const MANAGED_GITIGNORE: &str = r#"# managed by agent-manager (am sync — issue #66) +logs/ +backups/ +*.token +*.key +*.pem +.env +.env.* +secrets*.json +id_rsa* +"#; + +/// Run a git command inside `dir`; returns stdout on success. +pub fn git(dir: &Path, args: &[&str]) -> Result { + let out = Command::new("git") + .args(args) + .current_dir(dir) + .output() + .with_context(|| format!("cannot run git in {}", dir.display()))?; + if !out.status.success() { + bail!( + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&out.stderr).trim() + ); + } + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) +} + +/// Ensure the managed ignore rules are present (merged, never overwriting +/// the user's own rules). +pub fn ensure_gitignore(dir: &Path) -> Result<()> { + let path = dir.join(".gitignore"); + let mut existing = String::new(); + if path.exists() { + existing = std::fs::read_to_string(&path) + .with_context(|| format!("cannot read {}", path.display()))?; + } + let mut out = existing.clone(); + if !existing.contains("# managed by agent-manager") { + if !out.is_empty() && !out.ends_with('\n') { + out.push('\n'); + } + out.push_str(MANAGED_GITIGNORE); + } + if out != existing { + std::fs::write(&path, out) + .with_context(|| format!("cannot write {}", path.display()))?; + } + Ok(()) +} + +/// Count the files staged for the next commit (git status --porcelain). +fn staged_count(dir: &Path) -> u32 { + git(dir, &["status", "--porcelain"]) + .map(|s| s.lines().filter(|l| !l.is_empty()).count() as u32) + .unwrap_or(0) +} + +/// Init the repository when needed and wire the configured remote. +fn ensure_repo(dir: &Path, repo: &str) -> Result<()> { + if !dir.join(".git").exists() { + git(dir, &["init", "-q"])?; + git(dir, &["config", "user.name", "agent-manager"])?; + git(dir, &["config", "user.email", "agent-manager@local"])?; + } + let remotes = git(dir, &["remote"]).unwrap_or_default(); + if !remotes.lines().any(|r| r == "origin") { + git(dir, &["remote", "add", "origin", repo])?; + } else { + let url = git(dir, &["remote", "get-url", "origin"]).unwrap_or_default(); + if url != repo { + git(dir, &["remote", "set-url", "origin", repo])?; + } + } + Ok(()) +} + +/// Back up the current state locally before a forced push (conflict +/// resolution: last writer wins, nothing lost). +fn backup_before_force(dir: &Path) -> Result { + let ts = chrono::Utc::now().format("%Y%m%d-%H%M%S"); + let target = dir.join("backups").join(format!("sync-conflict-{ts}")); + std::fs::create_dir_all(&target) + .with_context(|| format!("cannot create {}", target.display()))?; + for name in ["state.json"] { + let src = dir.join(name); + if src.exists() { + std::fs::copy(&src, target.join(name))?; + } + } + // Also snapshot the current journal file. + let events = dir.join("events"); + if let Ok(entries) = std::fs::read_dir(&events) { + for e in entries.flatten() { + let p = e.path(); + if p.extension().and_then(|x| x.to_str()) == Some("jsonl") { + std::fs::copy(&p, target.join(format!("events-{}", p.file_name().unwrap_or_default().to_string_lossy())))?; + } + } + } + Ok(target) +} + +/// Commit and push the state directory. Returns the outcome. +pub fn sync(app: &crate::app::App, message: &str) -> Result { + let Some(repo) = app.config.settings.sync_repo.clone() else { + return Ok(SyncOutcome::Skipped); + }; + let dir = app.events_dir(); + std::fs::create_dir_all(&dir)?; + ensure_gitignore(&dir)?; + ensure_repo(&dir, &repo)?; + git(&dir, &["add", "-A"])?; + let staged = staged_count(&dir); + if staged == 0 { + return Ok(SyncOutcome::Clean); + } + git(&dir, &["commit", "-q", "-m", message])?; + let push = git(&dir, &["push", "-u", "origin", "HEAD"]); + match push { + Ok(_) => Ok(SyncOutcome::Pushed { + files: staged, + message: message.to_string(), + }), + Err(_) => { + // Non-fast-forward (or remote behind): last writer wins, with a + // local backup of the state before the forced push. + let backup = backup_before_force(&dir)?; + git(&dir, &["push", "--force", "origin", "HEAD"])?; + eprintln!( + "conflict resolved (last writer wins) — pre-push state backed up in {}", + backup.display() + ); + Ok(SyncOutcome::Pushed { + files: staged, + message: message.to_string(), + }) + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn git_available() -> bool { + Command::new("git") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + + #[test] + fn gitignore_managed_lines_are_merged_once() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join(".gitignore"); + std::fs::write(&p, "custom-rule\n").unwrap(); + ensure_gitignore(dir.path()).unwrap(); + let text = std::fs::read_to_string(&p).unwrap(); + assert!(text.contains("custom-rule")); + assert!(text.contains("logs/")); + assert!(text.contains("*.token")); + // Idempotent: running again does not duplicate the block. + ensure_gitignore(dir.path()).unwrap(); + let again = std::fs::read_to_string(&p).unwrap(); + assert_eq!(again.matches("# managed by agent-manager").count(), 1); + } + + #[test] + fn sync_skips_without_repo_setting() { + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.yaml"); + std::fs::write( + &cfg, + "version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\nagents: []\n", + ) + .unwrap(); + use clap::Parser; + let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap(), "list"]); + let mut app = crate::app::App::from_cli(cli).unwrap(); + app.paths.state_file = dir.path().join("state/state.json"); + let out = sync(&app, "test").unwrap(); + assert_eq!(out, SyncOutcome::Skipped); + } + + #[test] + fn sync_pushes_to_local_remote() { + if !git_available() { + eprintln!("git not available — skipping"); + return; + } + let dir = tempfile::tempdir().unwrap(); + let remote = tempfile::tempdir().unwrap(); + let remote_dir = remote.path().join("repo.git"); + git(remote.path(), &["init", "-q", "--bare", remote_dir.to_str().unwrap()]).unwrap(); + + let cfg = dir.path().join("config.yaml"); + std::fs::write( + &cfg, + "version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\n sync_repo: \"file://REPLACE\"\nagents: []\n", + ) + .unwrap(); + // Patch the repo URL into the config text (forward slashes keep the + // YAML quoted scalar valid on Windows paths). + let text = std::fs::read_to_string(&cfg).unwrap().replace( + "file://REPLACE", + &format!("file://{}", remote_dir.to_string_lossy().replace('\\', "/")), + ); + std::fs::write(&cfg, text).unwrap(); + + use clap::Parser; + let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap(), "list"]); + let mut app = crate::app::App::from_cli(cli).unwrap(); + let state_dir = dir.path().join("state"); + std::fs::create_dir_all(&state_dir).unwrap(); + std::fs::write(state_dir.join("state.json"), "{\"version\":2}").unwrap(); + std::fs::write(state_dir.join("events-202608.jsonl"), "{\"kind\":\"start\"}\n").unwrap(); + app.paths.state_file = state_dir.join("state.json"); + + let out = sync(&app, "test push").unwrap(); + match out { + SyncOutcome::Pushed { files, .. } => assert!(files > 0), + other => panic!("expected Pushed, got {other:?}"), + } + // The remote really received the commit. + let log = git(&remote_dir, &["log", "--oneline", "-1"]).unwrap(); + assert!(log.contains("test push")); + } + + #[test] + fn backup_before_force_snapshots_state() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("state")).unwrap(); + std::fs::write(dir.path().join("state/state.json"), "{\"v\":1}").unwrap(); + let backup = backup_before_force(&dir.path().join("state")).unwrap(); + assert!(backup.join("state.json").exists()); + assert!(backup.to_string_lossy().contains("sync-conflict")); + } +}