feat: phase 2 — sauvegarde git (am sync) + transfert machine (am migrate) (closes #66 #68)

This commit is contained in:
2026-08-18 12:09:43 -04:00
parent 1b3fe09074
commit e82979e6a3
18 changed files with 966 additions and 10 deletions
Generated
+1 -1
View File
@@ -21,7 +21,7 @@ dependencies = [
[[package]]
name = "agent-manager"
version = "0.5.1"
version = "0.5.2"
dependencies = [
"anyhow",
"chrono",
+1 -1
View File
@@ -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"
+2
View File
@@ -90,6 +90,8 @@ plateforme, il compile automatiquement depuis les sources.
| am doctor --watch <s> | 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 <m>] | 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 <agent> --autostart | service système (systemd / launchd / tâche Windows) + démarrage auto |
| am schedule add <cmd...> --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) |
+4 -4
View File
@@ -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 |
+4
View File
@@ -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:
+22
View File
@@ -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<FILE>\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
+16
View File
@@ -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<MESSAGE>\fR
Commit message (default: "am sync — state update")
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
+8 -2
View File
@@ -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
+18
View File
@@ -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<String>,
},
/// 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<PathBuf>,
/// Bundle path to import
#[arg(value_name = "BUNDLE")]
bundle: Option<PathBuf>,
},
/// Show the most used agents (top 10)
Top {
/// Only events of the last period (7d, 30d, 90d, all)
+489
View File
@@ -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<BundleFile>,
}
/// 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/<id>/).
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<usize> {
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<u8>> = 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<Vec<u8>>)> {
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<Vec<String>> {
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<i32> {
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 <bundle.amx>"))?;
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 <agent>'), (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);
}
}
+6
View File
@@ -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<i32> {
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,
+26
View File
@@ -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<i32> {
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: <url>' 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)
}
}
}
+12
View File
@@ -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 <m>", "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 <f>", "chemin du bundle")],
example: "migrate --export --output backup.amx",
},
TipEntry {
usage: "timeline",
about: "une vue chronologique de toute l'activité",
+18
View File
@@ -130,6 +130,12 @@ pub struct Settings {
/// CPU/memory thresholds for 'am monitor' alerts (issue #50).
#[serde(default)]
pub monitor_thresholds: Option<MonitorThresholds>,
/// Git repository where 'am sync' pushes the state (issue #66).
#[serde(default)]
pub sync_repo: Option<String>,
/// Push automatically when the REPL exits (issue #66, opt-in).
#[serde(default)]
pub sync_on_exit: Option<bool>,
}
/// 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);
}
+37
View File
@@ -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",
+1
View File
@@ -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;
+32 -2
View File
@@ -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<i32> {
}
};
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"),
},
+269
View File
@@ -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<String> {
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<PathBuf> {
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<SyncOutcome> {
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"));
}
}