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]] [[package]]
name = "agent-manager" name = "agent-manager"
version = "0.5.1" version = "0.5.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "agent-manager" name = "agent-manager"
version = "0.5.1" version = "0.5.2"
edition = "2021" edition = "2021"
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog." description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
license = "MIT" 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 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 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 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 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 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) | | 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 | | 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 | | 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 | | am serve --token — API HTTP + WebSocket pour piloter à distance | XL | P3 |
### Axe 10 — 🧩 Confort & personnalisation ### 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 | | [#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 | | [#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 | | [#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 | | [#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 | | [#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 | | [#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 | | [#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: # monitor_thresholds:
# cpu_pct: 80.0 # cpu_pct: 80.0
# mem_mb: 2048 # 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 ---------------------------------------------------------- # --- Command aliases ----------------------------------------------------------
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 .ie \n(.g .ds Aq \(aq
.el .ds Aq ' .el .ds Aq '
.TH am 1 "am 0.5.1" .TH am 1 "am 0.5.2"
.SH NAME .SH NAME
am \- agent\-manager (am) — manage local AI coding agents am \- agent\-manager (am) — manage local AI coding agents
.SH SYNOPSIS .SH SYNOPSIS
@@ -132,6 +132,12 @@ Show usage statistics computed from the event journal
am\-monitor(1) am\-monitor(1)
Real\-time monitor of the managed processes (issue #50) Real\-time monitor of the managed processes (issue #50)
.TP .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) am\-top(1)
Show the most used agents (top 10) Show the most used agents (top 10)
.TP .TP
@@ -201,4 +207,4 @@ Export the configuration and installation state (backup)
am\-import(1) am\-import(1)
Import a previously exported configuration and state Import a previously exported configuration and state
.SH VERSION .SH VERSION
v0.5.1 v0.5.2
+18
View File
@@ -269,6 +269,24 @@ pub enum Command {
#[arg(long, action = ArgAction::SetTrue)] #[arg(long, action = ArgAction::SetTrue)]
json: bool, 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) /// Show the most used agents (top 10)
Top { Top {
/// Only events of the last period (7d, 30d, 90d, all) /// 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 man_cmd;
pub mod models_cmd; pub mod models_cmd;
pub mod monitor_cmd; pub mod monitor_cmd;
pub mod migrate_cmd;
pub mod open_cmd; pub mod open_cmd;
pub mod profile_cmd; pub mod profile_cmd;
pub mod projects_cmd; pub mod projects_cmd;
@@ -35,6 +36,7 @@ pub mod sessions_cmd;
pub mod stats_cmd; pub mod stats_cmd;
pub mod status_cmd; pub mod status_cmd;
pub mod suggest_cmd; pub mod suggest_cmd;
pub mod sync_cmd;
pub mod theme_cmd; pub mod theme_cmd;
pub mod timeline_cmd; pub mod timeline_cmd;
pub mod tip_cmd; pub mod tip_cmd;
@@ -112,6 +114,10 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
Command::Monitor { interval, json } => { Command::Monitor { interval, json } => {
monitor_cmd::run(app, *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::Top { period } => stats_cmd::run_top(app, period.as_deref()),
Command::Report { Command::Report {
last_week, 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", 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 { TipEntry {
usage: "timeline", usage: "timeline",
about: "une vue chronologique de toute l'activité", 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). /// CPU/memory thresholds for 'am monitor' alerts (issue #50).
#[serde(default)] #[serde(default)]
pub monitor_thresholds: Option<MonitorThresholds>, 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). /// 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() { if o.sessions_retention_days.is_some() {
s.sessions_retention_days = o.sessions_retention_days; 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 { for (k, v) in o.hooks {
s.hooks.insert(k, v); 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" }, 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 { HelpSpec {
name: "stats", name: "stats",
category: "Commands", category: "Commands",
+1
View File
@@ -44,6 +44,7 @@ pub mod secrets;
pub mod sessions; pub mod sessions;
pub mod shell; pub mod shell;
pub mod state; pub mod state;
pub mod sync;
pub mod tables; pub mod tables;
pub mod theme; pub mod theme;
pub mod toolchain; 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)"), ("service", "register an agent as a system service (autostart)"),
("schedule", "plan am commands (daily) and check the fleet health"), ("schedule", "plan am commands (daily) and check the fleet health"),
("monitor", "real-time TUI of managed processes (cpu/rss/uptime)"), ("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"), ("shell", "show or switch the system shell"),
("theme", "show or switch the color theme"), ("theme", "show or switch the color theme"),
("tip", "cheat sheet of the most useful commands"), ("tip", "cheat sheet of the most useful commands"),
@@ -227,7 +229,7 @@ impl AmCompleter {
"self-update", "self-uninstall", "export", "import", "shell", "theme", "self-update", "self-uninstall", "export", "import", "shell", "theme",
"tip", "dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags", "tip", "dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags",
"profile", "man", "models", "catalog", "suggest", "audit", "profile", "man", "models", "catalog", "suggest", "audit",
"service", "schedule", "monitor", "service", "schedule", "monitor", "sync", "migrate",
"ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit", "ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
], ],
config_sub: vec!["show", "path", "edit", "validate", "add"], 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(), " models models · models --prune · catalog · suggest · audit".to_string(),
)); ));
rows.push(inner( 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( rows.push(inner(
" system self-update · self-uninstall · export · import".to_string(), " 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); 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 result
} }
@@ -1539,6 +1551,24 @@ fn handle_line(
interval: opt_value("--interval").and_then(|v| v.parse().ok()), interval: opt_value("--interval").and_then(|v| v.parse().ok()),
json: flag("--json"), 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 { "top" => Command::Top {
period: opt_value("--period"), 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"));
}
}