This commit is contained in:
Generated
+1
-1
@@ -21,7 +21,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-manager"
|
||||
version = "0.5.1"
|
||||
version = "0.5.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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é",
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user