feat: phase 2 — automatisation: doctor --watch, schedule, service, orchestration, conteneurs (closes #55 #56 #57 #58 #59)

This commit is contained in:
2026-08-18 10:57:00 -04:00
parent 6a6676fc5d
commit 63dc36660b
27 changed files with 1408 additions and 35 deletions
Generated
+1 -1
View File
@@ -21,7 +21,7 @@ dependencies = [
[[package]] [[package]]
name = "agent-manager" name = "agent-manager"
version = "0.4.7" version = "0.5.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "agent-manager" name = "agent-manager"
version = "0.4.7" version = "0.5.0"
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"
+5
View File
@@ -87,6 +87,11 @@ plateforme, il compile automatiquement depuis les sources.
| am sessions --export <id> | export d'une session (métadonnées + commandes + extrait de log) | | am sessions --export <id> | export d'une session (métadonnées + commandes + extrait de log) |
| am sessions --retention <jours> | purge des sessions terminées au-delà de N jours | | am sessions --retention <jours> | purge des sessions terminées au-delà de N jours |
| am init --template <stack> | génère une config pré-remplie (web, python, rust, cli) | | am init --template <stack> | génère une config pré-remplie (web, python, rust, cli) |
| am doctor --watch <s> | vérifications périodiques de l'environnement + alerte en cas de panne |
| 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) |
| am run <agent> --container | exécute l'agent dans son profil conteneur (docker/podman) |
**Statuts** : 🟢 running · 🔵 installed (géré par am) · 🟡 external (trouvé **Statuts** : 🟢 running · 🔵 installed (géré par am) · 🟡 external (trouvé
sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop) sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop)
+10 -10
View File
@@ -245,11 +245,11 @@ consultable, reprenable, archivable.
|---|---|---| |---|---|---|
| Hooks on_install / on_start / on_stop / on_update (agent, projet, global) — via le runner existant | M | P0 | | Hooks on_install / on_start / on_stop / on_update (agent, projet, global) — via le runner existant | M | P0 |
| am watch claude-code --restart-on-crash --notify | M | P1 | | am watch claude-code --restart-on-crash --notify | M | P1 |
| Services : unités systemd / plists launchd / tâches Windows, am service install claude-code --autostart | L | P2 | | Services : unités systemd / plists launchd / tâches Windows, am service install claude-code --autostart ✅ #55 | L | P2 |
| Planification : am schedule --daily update --all + check santé (cron / Task Scheduler) | M | P2 | | Planification : am schedule add update --all --at 06:30 + check santé (cron / Task Scheduler) ✅ #56 | M | P2 |
| Orchestration de groupes : ordre, --parallel, attente de santé | L | P2 | | Orchestration de groupes : ordre, --parallel, attente de santé ✅ #57 | L | P2 |
| Conteneurs : profil docker/podman par agent (isolation à la demande) | L | P2 | | Conteneurs : profil docker/podman par agent (isolation à la demande) ✅ #58 | L | P2 |
| am doctor --watch — vérifications périodiques avec alertes | M | P2 | | am doctor --watch — vérifications périodiques avec alertes ✅ #59 | M | P2 |
### Axe 7 — 🧠 Intelligence & catalogue ### Axe 7 — 🧠 Intelligence & catalogue
@@ -436,11 +436,11 @@ moins de 2 secondes.
| [#52](https://git.dracodev.net/Projets/agent-manager/issues/52) | ✅ Templates — am init --template web/python/rust/cli (substitution + validation) | M | | [#52](https://git.dracodev.net/Projets/agent-manager/issues/52) | ✅ Templates — am init --template web/python/rust/cli (substitution + validation) | M |
| [#53](https://git.dracodev.net/Projets/agent-manager/issues/53) | ✅ am sessions export + rétention configurable (sessions_retention_days) | M | | [#53](https://git.dracodev.net/Projets/agent-manager/issues/53) | ✅ am sessions export + rétention configurable (sessions_retention_days) | M |
| [#54](https://git.dracodev.net/Projets/agent-manager/issues/54) | am web — serveur local + API JSON + graphiques | XL | | [#54](https://git.dracodev.net/Projets/agent-manager/issues/54) | am web — serveur local + API JSON + graphiques | XL |
| [#55](https://git.dracodev.net/Projets/agent-manager/issues/55) | Services système — am service install (systemd/launchd/tâches Windows) | L | | [#55](https://git.dracodev.net/Projets/agent-manager/issues/55) | Services système — am service install (systemd/launchd/tâches Windows) ✅ | L |
| [#56](https://git.dracodev.net/Projets/agent-manager/issues/56) | Planification — am schedule (--daily update --all, check santé) | M | | [#56](https://git.dracodev.net/Projets/agent-manager/issues/56) | Planification — am schedule (add --at / list / remove / run) ✅ | M |
| [#57](https://git.dracodev.net/Projets/agent-manager/issues/57) | Orchestration de groupes (ordre, --parallel, attente de santé) | L | | [#57](https://git.dracodev.net/Projets/agent-manager/issues/57) | Orchestration de groupes (ordre, --parallel, attente de santé) ✅ | L |
| [#58](https://git.dracodev.net/Projets/agent-manager/issues/58) | Conteneurs — profils docker/podman par agent | L | | [#58](https://git.dracodev.net/Projets/agent-manager/issues/58) | Conteneurs — profils docker/podman par agent ✅ | L |
| [#59](https://git.dracodev.net/Projets/agent-manager/issues/59) | am doctor --watch — vérifications périodiques avec alertes | M | | [#59](https://git.dracodev.net/Projets/agent-manager/issues/59) | am doctor --watch — vérifications périodiques avec alertes ✅ | M |
| [#60](https://git.dracodev.net/Projets/agent-manager/issues/60) | ✅ Catalogue distant — am catalog update / am catalog add <url> | M | | [#60](https://git.dracodev.net/Projets/agent-manager/issues/60) | ✅ Catalogue distant — am catalog update / am catalog add <url> | M |
| [#61](https://git.dracodev.net/Projets/agent-manager/issues/61) | ✅ am suggest — « un agent pour du Python » (tags + usage réel) | M | | [#61](https://git.dracodev.net/Projets/agent-manager/issues/61) | ✅ am suggest — « un agent pour du Python » (tags + usage réel) | M |
| [#62](https://git.dracodev.net/Projets/agent-manager/issues/62) | am lab — benchmark : même tâche sur N agents | L | | [#62](https://git.dracodev.net/Projets/agent-manager/issues/62) | am lab — benchmark : même tâche sur N agents | L |
+4 -1
View File
@@ -4,7 +4,7 @@
.SH NAME .SH NAME
doctor \- Check the environment: tools, config validity, paths, permissions doctor \- Check the environment: tools, config validity, paths, permissions
.SH SYNOPSIS .SH SYNOPSIS
\fBdoctor\fR [\fB\-\-fix\fR] [\fB\-h\fR|\fB\-\-help\fR] \fBdoctor\fR [\fB\-\-fix\fR] [\fB\-\-watch\fR] [\fB\-h\fR|\fB\-\-help\fR]
.SH DESCRIPTION .SH DESCRIPTION
Check the environment: tools, config validity, paths, permissions Check the environment: tools, config validity, paths, permissions
.SH OPTIONS .SH OPTIONS
@@ -12,5 +12,8 @@ Check the environment: tools, config validity, paths, permissions
\fB\-\-fix\fR \fB\-\-fix\fR
Attempt to repair problems (create directories, fix the state file) Attempt to repair problems (create directories, fix the state file)
.TP .TP
\fB\-\-watch\fR \fI<SECONDS>\fR
Re\-run the checks every N seconds, alerting on failure (issue #59)
.TP
\fB\-h\fR, \fB\-\-help\fR \fB\-h\fR, \fB\-\-help\fR
Print help Print help
+7 -1
View File
@@ -4,7 +4,7 @@
.SH NAME .SH NAME
restart \- Restart an agent: stop, then start with the same options restart \- Restart an agent: stop, then start with the same options
.SH SYNOPSIS .SH SYNOPSIS
\fBrestart\fR [\fB\-b\fR|\fB\-\-background\fR] [\fB\-f\fR|\fB\-\-foreground\fR] [\fB\-\-args\fR] [\fB\-\-env\fR] [\fB\-\-notify\fR] [\fB\-\-profile\fR] [\fB\-\-model\fR] [\fB\-\-force\fR] [\fB\-\-timeout\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR] \fBrestart\fR [\fB\-b\fR|\fB\-\-background\fR] [\fB\-f\fR|\fB\-\-foreground\fR] [\fB\-\-args\fR] [\fB\-\-env\fR] [\fB\-\-notify\fR] [\fB\-\-profile\fR] [\fB\-\-model\fR] [\fB\-\-parallel\fR] [\fB\-\-container\fR] [\fB\-\-force\fR] [\fB\-\-timeout\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
.SH DESCRIPTION .SH DESCRIPTION
Restart an agent: stop, then start with the same options Restart an agent: stop, then start with the same options
.SH OPTIONS .SH OPTIONS
@@ -30,6 +30,12 @@ Apply an environment profile (env + args, defined in the config)
\fB\-\-model\fR \fI<MODEL>\fR \fB\-\-model\fR \fI<MODEL>\fR
Local model to use for this run (validated against the local runtimes) Local model to use for this run (validated against the local runtimes)
.TP .TP
\fB\-\-parallel\fR
Start every member of a group simultaneously (issue #57)
.TP
\fB\-\-container\fR
Run the agent inside a container (issue #58)
.TP
\fB\-\-force\fR \fB\-\-force\fR
Kill immediately instead of terminating gracefully Kill immediately instead of terminating gracefully
.TP .TP
+4 -1
View File
@@ -4,7 +4,7 @@
.SH NAME .SH NAME
run \- Run the agent command directly with the given arguments (no process management) run \- Run the agent command directly with the given arguments (no process management)
.SH SYNOPSIS .SH SYNOPSIS
\fBrun\fR [\fB\-\-model\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fIAGENT\fR> [\fIARGS...\fR] \fBrun\fR [\fB\-\-model\fR] [\fB\-\-container\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fIAGENT\fR> [\fIARGS...\fR]
.SH DESCRIPTION .SH DESCRIPTION
Run the agent command directly with the given arguments (no process management) Run the agent command directly with the given arguments (no process management)
.SH OPTIONS .SH OPTIONS
@@ -12,6 +12,9 @@ Run the agent command directly with the given arguments (no process management)
\fB\-\-model\fR \fI<MODEL>\fR \fB\-\-model\fR \fI<MODEL>\fR
Local model to use for this run (validated against the local runtimes) Local model to use for this run (validated against the local runtimes)
.TP .TP
\fB\-\-container\fR
Run the agent inside a container (issue #58)
.TP
\fB\-h\fR, \fB\-\-help\fR \fB\-h\fR, \fB\-\-help\fR
Print help Print help
.TP .TP
+29
View File
@@ -0,0 +1,29 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-schedule 1 "schedule "
.SH NAME
schedule \- Schedule am commands (issue #56)
.SH SYNOPSIS
\fBschedule\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIsubcommands\fR>
.SH DESCRIPTION
Schedule am commands (issue #56)
.SH OPTIONS
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.SH SUBCOMMANDS
.TP
schedule\-add(1)
Add a daily schedule (ex: \*(Aqam schedule add update \-\-all\*(Aq)
.TP
schedule\-list(1)
List the schedules
.TP
schedule\-remove(1)
Remove a schedule by id
.TP
schedule\-run(1)
Run a schedule now (invoked by the OS task, issue #56)
.TP
schedule\-help(1)
Print this message or the help of the given subcommand(s)
+26
View File
@@ -0,0 +1,26 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-service 1 "service "
.SH NAME
service \- Register an agent as a system service (issue #55)
.SH SYNOPSIS
\fBservice\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIsubcommands\fR>
.SH DESCRIPTION
Register an agent as a system service (issue #55)
.SH OPTIONS
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.SH SUBCOMMANDS
.TP
service\-install(1)
Install the service unit (systemd / launchd / Task Scheduler)
.TP
service\-uninstall(1)
Remove the service unit
.TP
service\-status(1)
Show whether the service is installed
.TP
service\-help(1)
Print this message or the help of the given subcommand(s)
+7 -1
View File
@@ -4,7 +4,7 @@
.SH NAME .SH NAME
start \- Start an agent (foreground by default, or detached with \-\-background) start \- Start an agent (foreground by default, or detached with \-\-background)
.SH SYNOPSIS .SH SYNOPSIS
\fBstart\fR [\fB\-b\fR|\fB\-\-background\fR] [\fB\-f\fR|\fB\-\-foreground\fR] [\fB\-\-args\fR] [\fB\-\-env\fR] [\fB\-\-notify\fR] [\fB\-\-profile\fR] [\fB\-\-model\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR] \fBstart\fR [\fB\-b\fR|\fB\-\-background\fR] [\fB\-f\fR|\fB\-\-foreground\fR] [\fB\-\-args\fR] [\fB\-\-env\fR] [\fB\-\-notify\fR] [\fB\-\-profile\fR] [\fB\-\-model\fR] [\fB\-\-parallel\fR] [\fB\-\-container\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
.SH DESCRIPTION .SH DESCRIPTION
Start an agent (foreground by default, or detached with \-\-background) Start an agent (foreground by default, or detached with \-\-background)
.SH OPTIONS .SH OPTIONS
@@ -30,6 +30,12 @@ Apply an environment profile (env + args, defined in the config)
\fB\-\-model\fR \fI<MODEL>\fR \fB\-\-model\fR \fI<MODEL>\fR
Local model to use for this run (validated against the local runtimes) Local model to use for this run (validated against the local runtimes)
.TP .TP
\fB\-\-parallel\fR
Start every member of a group simultaneously (issue #57)
.TP
\fB\-\-container\fR
Run the agent inside a container (issue #58)
.TP
\fB\-h\fR, \fB\-\-help\fR \fB\-h\fR, \fB\-\-help\fR
Print help Print help
.TP .TP
+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.4.7" .TH am 1 "am 0.5.0"
.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
@@ -96,6 +96,12 @@ Manage secrets in the OS keyring (never in plaintext config)
am\-audit(1) am\-audit(1)
Audit the configuration: who changed what, when (issue #63) Audit the configuration: who changed what, when (issue #63)
.TP .TP
am\-service(1)
Register an agent as a system service (issue #55)
.TP
am\-schedule(1)
Schedule am commands (issue #56)
.TP
am\-models(1) am\-models(1)
List local models (ollama, llama.cpp, LM Studio) and prune unused ones List local models (ollama, llama.cpp, LM Studio) and prune unused ones
.TP .TP
@@ -192,4 +198,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.4.7 v0.5.0
+438
View File
@@ -0,0 +1,438 @@
//! Automation helpers (axe 6): system service units, scheduled tasks,
//! container commands and health checks. Pure functions here are unit
//! tested; the thin command layers live in commands/service_cmd.rs,
//! commands/schedule_cmd.rs, run_cmd.rs and doctor_cmd.rs.
use crate::config::{ContainerSpec, HealthCheck};
use anyhow::{bail, Result};
use std::collections::BTreeMap;
use std::net::TcpStream;
use std::path::{Path, PathBuf};
use std::process::Command;
/// Am binary path used inside generated units/tasks (quoted).
pub fn am_bin_quoted() -> String {
let exe = std::env::current_exe()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| "am".to_string());
shell_escape(&exe)
}
fn shell_escape(s: &str) -> String {
if cfg!(windows) {
format!("\"{}\"", s.replace('"', "\\\""))
} else {
format!("'{}'", s.replace('\'', "'\\''"))
}
}
// ---------------------------------------------------------------------------
// #55: system service units
// ---------------------------------------------------------------------------
/// systemd user unit for 'am service install <agent> --autostart'.
pub fn systemd_unit(agent: &str, am_bin: &str) -> String {
format!(
"# Generated by 'am service install {agent}' — managed by agent-manager\n\
[Unit]\n\
Description=agent-manager: {agent}\n\
After=network-online.target\n\
Wants=network-online.target\n\
\n\
[Service]\n\
Type=simple\n\
ExecStart={am_bin} start {agent} --background --notify\n\
Restart=on-failure\n\
RestartSec=10\n\
\n\
[Install]\n\
WantedBy=default.target\n"
)
}
/// launchd plist for macOS.
pub fn launchd_plist(agent: &str, am_bin: &str) -> String {
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
<plist version=\"1.0\">\n\
<dict>\n\
\t<key>Label</key>\n\
\t<string>com.agent-manager.{agent}</string>\n\
\t<key>ProgramArguments</key>\n\
\t<array>\n\
\t\t<string>{am_bin}</string>\n\
\t\t<string>start</string>\n\
\t\t<string>{agent}</string>\n\
\t\t<string>--background</string>\n\
\t\t<string>--notify</string>\n\
\t</array>\n\
\t<key>RunAtLoad</key>\n\
\t<true/>\n\
\t<key>KeepAlive</key>\n\
\t<true/>\n\
</dict>\n\
</plist>\n"
)
}
/// Windows scheduled task XML (on logon, with retry).
pub fn windows_task_xml(agent: &str, am_bin: &str) -> String {
format!(
"<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n\
<Task version=\"1.2\" xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n\
\t<Triggers><LogonTrigger><Enabled>true</Enabled></LogonTrigger></Triggers>\n\
\t<Principals><Principal id=\"Author\"><LogonType>InteractiveToken</LogonType>\n\
\t<RunLevel>LeastPrivilege</RunLevel></Principal></Principals>\n\
\t<Settings><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n\
\t<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n\
\t<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n\
\t<RestartOnFailure><Interval>PT1M</Interval><Count>3</Count></RestartOnFailure>\n\
\t</Settings>\n\
\t<Actions><Exec>\n\
\t<Command>{am_bin}</Command>\n\
\t<Arguments>start {agent} --background --notify</Arguments>\n\
\t</Exec></Actions>\n\
</Task>\n"
)
}
/// Path of the systemd user unit for an agent.
pub fn systemd_unit_path(agent: &str) -> PathBuf {
let base = std::env::var("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| {
crate::config::home_dir()
.map(|h| h.join(".config"))
.unwrap_or_else(|| PathBuf::from(".config"))
});
base.join("systemd").join("user").join(format!("am-{agent}.service"))
}
/// Path of the launchd plist for an agent.
pub fn launchd_path(agent: &str) -> PathBuf {
crate::config::home_dir()
.map(|h| h.join("Library/LaunchAgents"))
.unwrap_or_else(|| PathBuf::from("."))
.join(format!("com.agent-manager.{agent}.plist"))
}
/// Windows task name for an agent.
pub fn windows_task_name(agent: &str) -> String {
format!("am-{agent}")
}
// ---------------------------------------------------------------------------
// #56: scheduled tasks
// ---------------------------------------------------------------------------
/// HH:MM at which a "daily" schedule runs (default 08:00). Parsed leniently.
pub fn parse_hhmm(at: &str) -> (u8, u8) {
let parts: Vec<&str> = at.split(':').collect();
let h: u8 = parts.first().and_then(|p| p.parse().ok()).unwrap_or(8);
let m: u8 = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0);
(h.min(23), m.min(59))
}
/// Crontab line for a daily schedule (Linux/macOS).
pub fn crontab_line(at: &str, am_bin: &str, schedule_id: &str) -> String {
let (h, m) = parse_hhmm(at);
format!("{m} {h} * * * {am_bin} schedule-run {schedule_id}")
}
/// schtasks command registering a daily task (Windows).
pub fn schtasks_daily(at: &str, am_bin: &str, schedule_id: &str, task_name: &str) -> Command {
let (h, m) = parse_hhmm(at);
let mut c = Command::new("schtasks");
c.args([
"/create",
"/tn",
task_name,
"/sc",
"daily",
"/st",
&format!("{h:02}:{m:02}"),
"/tr",
&format!("\"{am_bin}\" schedule-run {schedule_id}"),
"/f",
]);
c
}
/// The command that actually runs a schedule: am schedule-run <id>.
pub fn schedule_run_command(app: &crate::app::App, entry: &crate::state::ScheduleEntry) -> Result<i32> {
// Re-dispatch through the CLI so hooks/events/state stay consistent.
let args: Vec<String> = entry
.command
.iter()
.map(|s| s.to_string())
.collect();
let mut full = vec!["am".to_string()];
if let Some(cfg) = &app.config_origin {
full.push("--config".to_string());
full.push(cfg.to_string_lossy().to_string());
}
full.extend(args);
let status = Command::new(std::env::current_exe()?)
.args(&full[1..])
.status()?;
Ok(status.code().unwrap_or(1))
}
// ---------------------------------------------------------------------------
// #57: health checks
// ---------------------------------------------------------------------------
/// Wait for an agent health check (issue #57). Returns Ok when the check
/// passes within the timeout, Err otherwise. Never probes when the agent
/// has no healthcheck configured.
pub fn wait_health(agent: &str, hc: Option<&HealthCheck>, log: &crate::output::Logger) -> Result<()> {
let Some(hc) = hc else {
return Ok(());
};
let timeout = hc.timeout_secs.unwrap_or(30).max(1);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout);
let mut attempt = 0;
loop {
attempt += 1;
let ok = healthcheck_once(hc);
if ok {
return Ok(());
}
if std::time::Instant::now() >= deadline {
bail!(
"health check for '{agent}' failed after {timeout}s \
(command: {}, port: {})",
hc.command.as_deref().unwrap_or("-"),
hc.port.map(|p| p.to_string()).unwrap_or_else(|| "-".to_string())
);
}
if attempt == 1 || attempt % 5 == 0 {
log.verbose(&format!(
"waiting for '{agent}' health (attempt {attempt})..."
));
}
std::thread::sleep(std::time::Duration::from_secs(2));
}
}
/// One health check probe: command exit 0, or TCP connect on the port.
pub fn healthcheck_once(hc: &HealthCheck) -> bool {
if let Some(cmd) = &hc.command {
let ok = Command::new("sh")
.arg("-c")
.arg(cmd)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if !ok {
return false;
}
}
if let Some(port) = hc.port {
if TcpStream::connect(("127.0.0.1", port)).is_err() {
return false;
}
}
true
}
// ---------------------------------------------------------------------------
// #58: container profiles
// ---------------------------------------------------------------------------
/// Detect a container runtime: podman preferred, docker fallback.
pub fn detect_runtime() -> Option<String> {
for bin in ["podman", "docker"] {
if Command::new(bin)
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
{
return Some(bin.to_string());
}
}
None
}
/// Build the container run command for an agent (issue #58): mounts the
/// state dir and log dir, applies the profile volumes/network/env, runs
/// the agent's run command in the image.
pub fn container_command(
runtime: &str,
agent: &str,
spec: &ContainerSpec,
state_dir: &Path,
log_dir: &Path,
) -> Result<Vec<String>> {
if spec.image.trim().is_empty() {
bail!("agent '{agent}': container profile requires an 'image'");
}
let mut cmd = vec![
runtime.to_string(),
"run".to_string(),
"-d".to_string(),
"--name".to_string(),
format!("am-{agent}"),
];
// State + logs mounts so sessions/history keep working.
cmd.push("-v".to_string());
cmd.push(format!("{}:/data", state_dir.to_string_lossy()));
cmd.push("-v".to_string());
cmd.push(format!("{}:/logs", log_dir.to_string_lossy()));
for v in &spec.volumes {
cmd.push("-v".to_string());
cmd.push(v.clone());
}
match &spec.network {
Some(n) => {
cmd.push("--network".to_string());
cmd.push(n.clone());
}
None => {
cmd.push("--network".to_string());
cmd.push(
if cfg!(target_os = "linux") {
"host".to_string()
} else {
"bridge".to_string()
},
);
}
}
for (k, v) in &spec.env {
cmd.push("-e".to_string());
cmd.push(format!("{k}={v}"));
}
cmd.push(spec.image.clone());
Ok(cmd)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn systemd_unit_is_valid_and_referenced() {
let unit = systemd_unit("claude-code", "/usr/local/bin/am");
assert!(unit.contains("[Unit]"));
assert!(unit.contains("ExecStart=/usr/local/bin/am start claude-code --background --notify"));
assert!(unit.contains("Restart=on-failure"));
assert!(unit.contains("[Install]"));
}
#[test]
fn launchd_plist_is_valid_xml_shape() {
let plist = launchd_plist("aider", "/opt/homebrew/bin/am");
assert!(plist.contains("com.agent-manager.aider"));
assert!(plist.contains("<string>/opt/homebrew/bin/am</string>"));
assert!(plist.contains("<key>RunAtLoad</key>"));
}
#[test]
fn windows_task_xml_has_action() {
let xml = windows_task_xml("codex", "C:\\bin\\am.exe");
assert!(xml.contains("LogonTrigger"));
assert!(xml.contains("am.exe"));
assert!(xml.contains("start codex --background --notify"));
assert!(xml.contains("RestartOnFailure"));
}
#[test]
fn crontab_line_formats_time_and_id() {
let line = crontab_line("22:05", "/usr/bin/am", "sched-1");
assert_eq!(line, "5 22 * * * /usr/bin/am schedule-run sched-1");
let line = crontab_line("", "/usr/bin/am", "sched-2");
assert_eq!(line, "0 8 * * * /usr/bin/am schedule-run sched-2");
}
#[test]
fn parse_hhmm_is_lenient() {
assert_eq!(parse_hhmm("09:30"), (9, 30));
assert_eq!(parse_hhmm("garbage"), (8, 0));
assert_eq!(parse_hhmm("25:99"), (23, 59));
}
#[test]
fn healthcheck_command_exit_0_is_healthy() {
let hc = HealthCheck {
command: Some("true".to_string()),
port: None,
timeout_secs: Some(5),
};
assert!(healthcheck_once(&hc));
let bad = HealthCheck {
command: Some("false".to_string()),
port: None,
timeout_secs: Some(5),
};
assert!(!healthcheck_once(&bad));
}
#[test]
fn healthcheck_without_config_skips() {
let app = test_app();
assert!(wait_health("x", None, &app.log).is_ok());
}
fn test_app() -> 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()]);
let mut app = crate::app::App::from_cli(cli).unwrap();
app.paths.state_file = dir.path().join("state.json");
app.state = crate::state::StateStore::new(dir.path().join("state.json"));
app
}
#[test]
fn container_command_mounts_and_env() {
let spec = ContainerSpec {
image: "node:20".to_string(),
volumes: vec!["/host/work:/work:ro".to_string()],
network: None,
env: BTreeMap::from([("NODE_ENV".to_string(), "production".to_string())]),
};
let cmd = container_command(
"docker",
"my-agent",
&spec,
Path::new("/state"),
Path::new("/logs"),
)
.unwrap();
assert_eq!(cmd[0], "docker");
assert!(cmd.contains(&"run".to_string()));
assert!(cmd.contains(&"-v".to_string()));
assert!(cmd.contains(&"/state:/data".to_string()));
assert!(cmd.contains(&"/logs:/logs".to_string()));
assert!(cmd.contains(&"--network".to_string()));
assert!(cmd.contains(&"-e".to_string()));
assert!(cmd.contains(&"NODE_ENV=production".to_string()));
assert_eq!(cmd.last().unwrap(), "node:20");
// Empty image fails clearly.
let bad = ContainerSpec { image: String::new(), ..Default::default() };
assert!(container_command("docker", "a", &bad, Path::new("/s"), Path::new("/l")).is_err());
}
}
+66 -1
View File
@@ -177,6 +177,12 @@ pub enum Command {
Secret(SecretCmd), Secret(SecretCmd),
/// Audit the configuration: who changed what, when (issue #63) /// Audit the configuration: who changed what, when (issue #63)
Audit, Audit,
/// Register an agent as a system service (issue #55)
#[command(subcommand)]
Service(ServiceCmd),
/// Schedule am commands (issue #56)
#[command(subcommand)]
Schedule(ScheduleCmd),
/// List local models (ollama, llama.cpp, LM Studio) and prune unused ones /// List local models (ollama, llama.cpp, LM Studio) and prune unused ones
Models(ModelsArgs), Models(ModelsArgs),
/// Manage remote catalogs: update the official one, add external ones /// Manage remote catalogs: update the official one, add external ones
@@ -412,6 +418,9 @@ pub enum Command {
/// Attempt to repair problems (create directories, fix the state file) /// Attempt to repair problems (create directories, fix the state file)
#[arg(long)] #[arg(long)]
fix: bool, fix: bool,
/// Re-run the checks every N seconds, alerting on failure (issue #59)
#[arg(long, value_name = "SECONDS")]
watch: Option<u64>,
}, },
/// Run the agent command directly with the given arguments (no process management) /// Run the agent command directly with the given arguments (no process management)
Run { Run {
@@ -420,6 +429,9 @@ pub enum Command {
/// Local model to use for this run (validated against the local runtimes) /// Local model to use for this run (validated against the local runtimes)
#[arg(long, value_name = "MODEL")] #[arg(long, value_name = "MODEL")]
model: Option<String>, model: Option<String>,
/// Run the agent inside a container (issue #58)
#[arg(long)]
container: bool,
/// Arguments passed through to the agent command /// Arguments passed through to the agent command
#[arg(trailing_var_arg = true, allow_hyphen_values = true, value_name = "ARGS...")] #[arg(trailing_var_arg = true, allow_hyphen_values = true, value_name = "ARGS...")]
args: Vec<std::ffi::OsString>, args: Vec<std::ffi::OsString>,
@@ -488,6 +500,12 @@ pub struct StartArgs {
/// Local model to use for this run (validated against the local runtimes) /// Local model to use for this run (validated against the local runtimes)
#[arg(long, value_name = "MODEL")] #[arg(long, value_name = "MODEL")]
pub model: Option<String>, pub model: Option<String>,
/// Start every member of a group simultaneously (issue #57)
#[arg(long, action = ArgAction::SetTrue)]
pub parallel: bool,
/// Run the agent inside a container (issue #58)
#[arg(long, action = ArgAction::SetTrue)]
pub container: bool,
} }
/// Arguments of the models command: inventory by default, prune with --prune. /// Arguments of the models command: inventory by default, prune with --prune.
@@ -534,7 +552,54 @@ pub enum ProfileCmd {
}, },
} }
/// Secret management subcommands. /// 'am service' subcommands (issue #55).
#[derive(Subcommand, Debug, Clone)]
pub enum ServiceCmd {
/// Install the service unit (systemd / launchd / Task Scheduler)
Install {
/// Agent name
agent: String,
/// Enable autostart (boot/logon)
#[arg(long)]
autostart: bool,
},
/// Remove the service unit
Uninstall {
/// Agent name
agent: String,
},
/// Show whether the service is installed
Status {
/// Agent name
agent: String,
},
}
/// 'am schedule' subcommands (issue #56).
#[derive(Subcommand, Debug, Clone)]
pub enum ScheduleCmd {
/// Add a daily schedule (ex: 'am schedule add update --all')
Add {
/// Command to run, e.g. ["update", "--all"]
#[arg(trailing_var_arg = true, allow_hyphen_values = true, value_name = "CMD...")]
command: Vec<String>,
/// Time of day HH:MM (default 08:00)
#[arg(long, value_name = "HH:MM")]
at: Option<String>,
},
/// List the schedules
List,
/// Remove a schedule by id
Remove {
/// Schedule id ('am schedule list' shows them)
id: String,
},
/// Run a schedule now (invoked by the OS task, issue #56)
Run {
/// Schedule id
id: String,
},
}
#[derive(Subcommand, Debug, Clone)] #[derive(Subcommand, Debug, Clone)]
pub enum SecretCmd { pub enum SecretCmd {
/// Store a secret for an agent /// Store a secret for an agent
+42 -1
View File
@@ -7,7 +7,10 @@ use crate::output::{boxed_table, print_json, status_style};
use crate::runner::SystemRunner; use crate::runner::SystemRunner;
use anyhow::Result; use anyhow::Result;
pub fn run(app: &App, fix: bool) -> Result<i32> { pub fn run(app: &App, fix: bool, watch: Option<u64>) -> Result<i32> {
if let Some(interval) = watch {
return watch_loop(app, interval.max(1));
}
if fix { if fix {
let repaired = doctor::fix(app)?; let repaired = doctor::fix(app)?;
for r in &repaired { for r in &repaired {
@@ -61,3 +64,41 @@ pub fn run(app: &App, fix: bool) -> Result<i32> {
} }
Ok(if report.ok() { 0 } else { 1 }) Ok(if report.ok() { 0 } else { 1 })
} }
/// Issue #59: re-run the doctor checks every N seconds; alert (desktop
/// notification + event) on the first failure, recover silently. Clean
/// Ctrl-C via ctrlc.
fn watch_loop(app: &App, interval: u64) -> Result<i32> {
app.log
.info(&format!("watching every {interval}s (Ctrl-C to stop)"));
let mut was_ok = true;
loop {
let runner = SystemRunner::new(false, false, &app.log);
let report = doctor::run(app, &runner);
let ok = report.ok();
if !ok && was_ok {
// First failure: alert.
app.emit(
&Event::now(EventKind::Doctor)
.with_exit_code(1)
.with_reason(format!("{} problem(s) found", report.problems())),
);
app.log.error(&format!(
"{} problem(s) found — 'am doctor --fix' repairs what it can",
report.problems()
));
let _ = crate::process::desktop_notify(
"agent-manager",
&format!("doctor: {} problem(s)", report.problems()),
);
was_ok = false;
} else if ok && !was_ok {
app.log.success("environment recovered");
was_ok = true;
} else if !ok {
app.log
.warn(&format!("still {} problem(s)", report.problems()));
}
std::thread::sleep(std::time::Duration::from_secs(interval));
}
}
+40 -2
View File
@@ -24,7 +24,9 @@ pub mod open_cmd;
pub mod profile_cmd; pub mod profile_cmd;
pub mod projects_cmd; pub mod projects_cmd;
pub mod run_cmd; pub mod run_cmd;
pub mod schedule_cmd;
pub mod search_cmd; pub mod search_cmd;
pub mod service_cmd;
pub mod secret_cmd; pub mod secret_cmd;
pub mod self_uninstall; pub mod self_uninstall;
pub mod self_update; pub mod self_update;
@@ -184,8 +186,44 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
Command::Tip { random } => tip_cmd::run(app, *random), Command::Tip { random } => tip_cmd::run(app, *random),
Command::Config(sub) => config_cmd::run(app, sub), Command::Config(sub) => config_cmd::run(app, sub),
Command::Theme { name } => theme_cmd::run(app, name.as_deref()), Command::Theme { name } => theme_cmd::run(app, name.as_deref()),
Command::Doctor { fix } => doctor_cmd::run(app, *fix), Command::Doctor { fix, watch } => {
Command::Run { agent, model, args } => run_cmd::run(app, agent, model.as_deref(), args), doctor_cmd::run(app, *fix, *watch)
}
Command::Run { agent, model, container, args } => {
run_cmd::run(app, agent, model.as_deref(), *container, args)
}
Command::Service(svc) => match svc {
crate::cli::ServiceCmd::Install { agent, autostart } => {
service_cmd::run(app, "install", Some(agent), *autostart)
}
crate::cli::ServiceCmd::Uninstall { agent } => {
service_cmd::run(app, "uninstall", Some(agent), false)
}
crate::cli::ServiceCmd::Status { agent } => {
service_cmd::run(app, "status", Some(agent), false)
}
},
Command::Schedule(sched) => match sched {
crate::cli::ScheduleCmd::Add { command, at } => {
// '--at HH:MM' may land inside the trailing command args
// (allow_hyphen_values): strip it and recover the value.
let mut cmd = command.clone();
let mut at_value = at.clone();
if let Some(idx) = cmd.iter().position(|a| a == "--at") {
if idx + 1 < cmd.len() {
at_value = at_value.or_else(|| Some(cmd[idx + 1].clone()));
cmd.remove(idx + 1);
}
cmd.remove(idx);
}
schedule_cmd::run(app, "add", cmd, at_value.as_deref())
}
crate::cli::ScheduleCmd::List => schedule_cmd::run(app, "list", vec![], None),
crate::cli::ScheduleCmd::Remove { id } => {
schedule_cmd::run(app, "remove", vec![id.clone()], None)
}
crate::cli::ScheduleCmd::Run { id } => schedule_cmd::run(app, "run", vec![id.clone()], None),
},
Command::Completion { shell, installed } => completion_cmd::run(*shell, *installed), Command::Completion { shell, installed } => completion_cmd::run(*shell, *installed),
Command::SelfUpdate { check, to } => self_update::run(app, *check, to.as_deref()), Command::SelfUpdate { check, to } => self_update::run(app, *check, to.as_deref()),
Command::SelfUninstall => self_uninstall::run(app), Command::SelfUninstall => self_uninstall::run(app),
+71 -1
View File
@@ -84,8 +84,15 @@ pub fn start_with_cwd(app: &App, opts: &StartArgs, cwd: Option<&std::path::Path>
if members.is_empty() { if members.is_empty() {
bail!("unknown group '{group}'"); bail!("unknown group '{group}'");
} }
// Issue #57: --parallel launches every member back-to-back without
// waiting (the OS spawns them concurrently); the default waits each
// health check before the next member.
for m in members { for m in members {
start_one(app, m, &extra_args, &extra_env, opts.notify, true, cwd)?; start_one(app, m, &extra_args, &extra_env, opts.notify, true, cwd)?;
if !opts.parallel {
// Issue #57: block until healthy before the next member.
crate::automation::wait_health(&m.name, m.healthcheck.as_ref(), &app.log)?;
}
} }
return Ok(0); return Ok(0);
} }
@@ -309,8 +316,18 @@ pub fn restart(app: &App, opts: &StartArgs, force: bool, timeout: Option<u64>) -
} }
/// run: execute the agent command directly, no process management. /// run: execute the agent command directly, no process management.
pub fn run(app: &App, target: &str, model: Option<&str>, extra: &[OsString]) -> Result<i32> { pub fn run(
app: &App,
target: &str,
model: Option<&str>,
container: bool,
extra: &[OsString],
) -> Result<i32> {
let agent = require_agent(app, target)?; let agent = require_agent(app, target)?;
// Issue #58: --container runs the agent inside its container profile.
if container {
return run_container(app, agent, extra);
}
let mut extra_args: Vec<String> = extra let mut extra_args: Vec<String> = extra
.iter() .iter()
.map(|o| o.to_string_lossy().to_string()) .map(|o| o.to_string_lossy().to_string())
@@ -353,6 +370,59 @@ pub fn run(app: &App, target: &str, model: Option<&str>, extra: &[OsString]) ->
Ok(code) Ok(code)
} }
/// Issue #58: run the agent inside its container profile. Detects the
/// runtime (podman > docker), mounts state/log dirs and the profile
/// volumes/network/env, then execs the container in the foreground.
fn run_container(app: &App, agent: &crate::config::AgentDef, extra: &[OsString]) -> Result<i32> {
let Some(spec) = &agent.container else {
bail!(
"agent '{}' has no container profile — add 'container: {{image: ...}}' to its definition",
agent.name
);
};
let Some(runtime) = crate::automation::detect_runtime() else {
bail!(
"no container runtime found (docker or podman) — install one or drop --container"
);
};
let state_dir = app.paths.state_file.parent().unwrap_or(std::path::Path::new("."));
let log_dir = &app.paths.log_dir;
let mut cmd = crate::automation::container_command(
&runtime,
&agent.name,
spec,
state_dir,
log_dir,
)?;
// The agent's run command + extra args become the container command.
let exec = resolve_exec(app, agent, &[], &BTreeMap::new())?;
cmd.push(exec.program);
cmd.extend(exec.args.iter().cloned());
cmd.extend(extra.iter().map(|o| o.to_string_lossy().to_string()));
if app.dry_run() {
app.log.dry(&format!("would run: {}", cmd.join(" ")));
return Ok(0);
}
app.log.info(&format!(
"running '{}' in a container ({runtime})",
agent.name
));
let status = Command::new(&cmd[0])
.args(&cmd[1..])
.status()
.with_context(|| format!("failed to run {}", cmd.join(" ")))?;
let code = status.code().unwrap_or(1);
app.emit(
&Event::now(EventKind::Run)
.with_agent(agent.name.clone())
.with_reason(format!("container ({runtime})"))
.with_args(cmd.clone())
.with_exit_code(code),
);
let _ = app.state.touch(&agent.name);
Ok(code)
}
/// Current directory as a string (None when unavailable). /// Current directory as a string (None when unavailable).
fn cwd_string() -> Option<String> { fn cwd_string() -> Option<String> {
std::env::current_dir().ok().map(|p| p.display().to_string()) std::env::current_dir().ok().map(|p| p.display().to_string())
+217
View File
@@ -0,0 +1,217 @@
//! schedule: plan am commands (axe 6, issue #56). Schedules live in
//! state.json; a daily OS-level task (crontab / Task Scheduler) points back
//! at 'am schedule-run <id>' which executes the stored command, records the
//! result and notifies on failure.
use super::*;
use crate::automation;
use crate::events::{Event, EventKind};
use crate::state::ScheduleEntry;
use anyhow::{bail, Context, Result};
use std::process::Command;
pub fn run(app: &App, action: &str, command: Vec<String>, at: Option<&str>) -> Result<i32> {
match action {
"add" => add(app, command, at),
"list" => list(app),
"remove" => {
let id = command
.first()
.ok_or_else(|| anyhow!("schedule remove requires an id"))?;
remove(app, id)
}
"run" => {
let id = command
.first()
.ok_or_else(|| anyhow!("schedule-run requires an id"))?;
run_one(app, id)
}
_ => bail!("usage: am schedule add <cmd...> | am schedule list | am schedule remove <id> | am schedule run <id>"),
}
}
fn task_name(id: &str) -> String {
format!("am-schedule-{id}")
}
/// Register the OS-level daily task (crontab on unix, schtasks on Windows).
/// Best effort: the schedule stays in state.json even if the OS layer
/// cannot be reached (warned, not fatal).
fn register_os_task(app: &App, entry: &ScheduleEntry) {
let am_bin = automation::am_bin_quoted();
let id = &entry.id;
if cfg!(windows) {
let status = automation::schtasks_daily(&entry.at, &am_bin, id, &task_name(id))
.status()
.unwrap_or_default();
if !status.success() {
app.log
.warn("schtasks failed — the schedule is stored but not registered at OS level");
}
} else {
// Append to the user crontab (dedup first).
let existing = Command::new("crontab")
.arg("-l")
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
.unwrap_or_default();
if existing.contains(&format!("schedule-run {id}")) {
return;
}
let line = automation::crontab_line(&entry.at, &am_bin, id);
let mut next = existing.trim_end().to_string();
next.push('\n');
next.push_str(&line);
next.push('\n');
let mut child = Command::new("crontab")
.stdin(std::process::Stdio::piped())
.spawn()
.expect("crontab spawn");
use std::io::Write;
let _ = child
.stdin
.as_mut()
.map(|s| s.write_all(next.as_bytes()));
let _ = child.wait();
}
}
fn add(app: &App, command: Vec<String>, at: Option<&str>) -> Result<i32> {
if command.is_empty() {
bail!("schedule add requires a command — ex: am schedule add update --all");
}
if app.dry_run() {
app.log.dry(&format!("would schedule: {}", command.join(" ")));
return Ok(0);
}
let id = crate::repl::session_id();
let entry = ScheduleEntry {
id,
cadence: "daily".to_string(),
command,
at: at.unwrap_or("08:00").to_string(),
created_at: crate::installers::now_rfc3339(),
last_run: None,
last_exit: None,
};
let mut sf = app.state.load()?;
sf.schedules.push(entry.clone());
app.state.save(&sf)?;
register_os_task(app, &entry);
app.emit(
&Event::now(EventKind::Config)
.with_agent("schedule".to_string())
.with_reason(format!("schedule add {} ({})", entry.id, entry.at)),
);
app.log.success(&format!(
"scheduled daily at {}: {} (id {})",
entry.at,
entry.command.join(" "),
entry.id
));
Ok(0)
}
fn list(app: &App) -> Result<i32> {
let sf = app.state.load()?;
if sf.schedules.is_empty() {
app.log.info("no schedule — 'am schedule add <cmd>' to create one (ex: am schedule add update --all)");
return Ok(0);
}
println!("schedules:");
for e in &sf.schedules {
let last = e
.last_run
.as_deref()
.map(|t| {
e.last_exit
.map(|c| format!("{t} (exit {c})"))
.unwrap_or_else(|| t.to_string())
})
.unwrap_or_else(|| "never".to_string());
println!(
" {} {} {} cmd: {} last: {}",
e.id,
e.cadence,
e.at,
e.command.join(" "),
last
);
}
Ok(0)
}
fn remove(app: &App, id: &str) -> Result<i32> {
let mut sf = app.state.load()?;
let before = sf.schedules.len();
sf.schedules.retain(|e| e.id != id);
if sf.schedules.len() == before {
bail!("unknown schedule '{id}' — 'am schedule list' shows them");
}
app.state.save(&sf)?;
// Best-effort OS cleanup.
if cfg!(windows) {
let _ = Command::new("schtasks")
.args(["/delete", "/tn", &task_name(id), "/f"])
.status();
} else {
let existing = Command::new("crontab")
.arg("-l")
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
.unwrap_or_default();
let cleaned: Vec<&str> = existing
.lines()
.filter(|l| !l.contains(&format!("schedule-run {id}")))
.collect();
let text = format!("{}\n", cleaned.join("\n"));
let mut child = Command::new("crontab")
.stdin(std::process::Stdio::piped())
.spawn()
.expect("crontab spawn");
use std::io::Write;
let _ = child
.stdin
.as_mut()
.map(|s| s.write_all(text.as_bytes()));
let _ = child.wait();
}
app.log.success(&format!("schedule '{id}' removed"));
Ok(0)
}
/// Execute one schedule (invoked by the OS task). Records last_run /
/// last_exit, emits a Schedule event and notifies on failure.
fn run_one(app: &App, id: &str) -> Result<i32> {
let sf = app.state.load()?;
let entry = sf
.schedules
.iter()
.find(|e| e.id == id)
.cloned()
.ok_or_else(|| anyhow!("unknown schedule '{id}'"))?;
app.log.info(&format!("running schedule {id}: {}", entry.command.join(" ")));
let code = automation::schedule_run_command(app, &entry)?;
// Record the outcome.
let mut sf = app.state.load()?;
if let Some(e) = sf.schedules.iter_mut().find(|e| e.id == id) {
e.last_run = Some(crate::installers::now_rfc3339());
e.last_exit = Some(code);
}
app.state.save(&sf)?;
app.emit(
&Event::now(EventKind::Run)
.with_agent("schedule".to_string())
.with_exit_code(code)
.with_reason(format!("schedule {id}: {}", entry.command.join(" "))),
);
if code == 0 {
app.log.success(&format!("schedule {id} completed"));
} else {
app.log.error(&format!(
"schedule {id} FAILED (exit {code}) — command: {}",
entry.command.join(" ")
));
}
Ok(code)
}
+206
View File
@@ -0,0 +1,206 @@
//! service: register an agent as a system service with autostart (issue #55).
//! systemd user unit on Linux, launchd LaunchAgent on macOS, Task Scheduler
//! on Windows. Logs go through the existing 'am start --background' runner.
use super::*;
use crate::automation;
use crate::events::{Event, EventKind};
use anyhow::{bail, Context, Result};
use std::path::PathBuf;
use std::process::Command;
pub fn run(app: &App, action: &str, agent: Option<&str>, autostart: bool) -> Result<i32> {
match action {
"install" => {
let name = agent.ok_or_else(|| anyhow!("service install requires an agent name"))?;
install(app, name, autostart)
}
"uninstall" => {
let name = agent.ok_or_else(|| anyhow!("service uninstall requires an agent name"))?;
uninstall(app, name)
}
"status" => {
let name = agent.ok_or_else(|| anyhow!("service status requires an agent name"))?;
status(app, name)
}
_ => bail!("usage: am service install|uninstall|status <agent> [--autostart]"),
}
}
fn unit_path(agent: &str) -> PathBuf {
if cfg!(target_os = "linux") {
automation::systemd_unit_path(agent)
} else if cfg!(target_os = "macos") {
automation::launchd_path(agent)
} else {
// Windows: no file — the task lives in the Task Scheduler.
PathBuf::new()
}
}
fn install(app: &App, agent: &str, autostart: bool) -> Result<i32> {
require_agent(app, agent)?;
if app.dry_run() {
app.log
.dry(format!("would install '{agent}' as a system service (autostart: {autostart})"));
return Ok(0);
}
let am_bin = automation::am_bin_quoted();
let mut wrote = String::new();
if cfg!(target_os = "linux") {
let path = automation::systemd_unit_path(agent);
if let Some(p) = path.parent() {
std::fs::create_dir_all(p).with_context(|| format!("cannot create {}", p.display()))?;
}
std::fs::write(&path, automation::systemd_unit(agent, &am_bin))
.with_context(|| format!("cannot write {}", path.display()))?;
wrote = path.display().to_string();
// Enable + start the user unit.
let _ = Command::new("systemctl")
.args(["--user", "daemon-reload"])
.status();
if autostart {
let _ = Command::new("systemctl")
.args(["--user", "enable", &format!("am-{agent}.service")])
.status();
}
let _ = Command::new("systemctl")
.args(["--user", "start", &format!("am-{agent}.service")])
.status();
} else if cfg!(target_os = "macos") {
let path = automation::launchd_path(agent);
if let Some(p) = path.parent() {
std::fs::create_dir_all(p).with_context(|| format!("cannot create {}", p.display()))?;
}
std::fs::write(&path, automation::launchd_plist(agent, &am_bin))
.with_context(|| format!("cannot write {}", path.display()))?;
wrote = path.display().to_string();
if autostart {
let _ = Command::new("launchctl")
.args(["load", path.to_str().unwrap_or_default()])
.status();
}
} else {
// Windows: prefer a logon task. /sc onlogon needs admin on some
// setups; fall back to the Startup folder (no admin required).
let task = automation::windows_task_name(agent);
let am_bin = std::env::current_exe().unwrap_or_default();
let tr = format!("\"{}\" start {} --background --notify", am_bin.display(), agent);
let status = Command::new("schtasks")
.args(["/create", "/tn", &task, "/sc", "onlogon", "/tr", &tr, "/f"])
.status()?;
if status.success() {
wrote = format!("Task Scheduler: {task}");
} else {
let startup = crate::config::home_dir()
.map(|h| {
h.join("AppData/Roaming/Microsoft/Windows/Start Menu/Programs/Startup")
})
.unwrap_or_default();
let script = startup.join(format!("am-{agent}.cmd"));
if let Some(p) = script.parent() {
std::fs::create_dir_all(p)
.with_context(|| format!("cannot create {}", p.display()))?;
}
std::fs::write(&script, format!("@echo off\r\nstart \"\" /b {tr}\r\n"))
.with_context(|| format!("cannot write {}", script.display()))?;
wrote = format!("Startup folder: {}", script.display());
}
}
app.emit(
&Event::now(EventKind::Config)
.with_agent(agent.to_string())
.with_reason(format!("service install (autostart: {autostart})")),
);
app.log.success(&format!(
"service '{agent}' installed{} ({wrote})",
if autostart { " with autostart" } else { "" }
));
Ok(0)
}
fn uninstall(app: &App, agent: &str) -> Result<i32> {
if app.dry_run() {
app.log.dry(format!("would remove the service for '{agent}'"));
return Ok(0);
}
let mut removed = String::new();
if cfg!(target_os = "linux") {
let _ = Command::new("systemctl")
.args(["--user", "stop", &format!("am-{agent}.service")])
.status();
if automation::systemd_unit_path(agent).exists() {
let _ = Command::new("systemctl")
.args(["--user", "disable", &format!("am-{agent}.service")])
.status();
std::fs::remove_file(automation::systemd_unit_path(agent))?;
}
let _ = Command::new("systemctl").args(["--user", "daemon-reload"]).status();
removed = "systemd unit removed".to_string();
} else if cfg!(target_os = "macos") {
let path = automation::launchd_path(agent);
if path.exists() {
let _ = Command::new("launchctl").args(["unload", path.to_str().unwrap_or_default()]).status();
std::fs::remove_file(&path)?;
}
removed = "launchd plist removed".to_string();
} else {
let task = automation::windows_task_name(agent);
let _ = Command::new("schtasks")
.args(["/delete", "/tn", &task, "/f"])
.status();
let mut parts = vec![format!("Task Scheduler: {task}")];
// Also remove the Startup folder fallback if present.
if let Some(h) = crate::config::home_dir() {
let script = h.join("AppData/Roaming/Microsoft/Windows/Start Menu/Programs/Startup")
.join(format!("am-{agent}.cmd"));
if script.exists() {
std::fs::remove_file(&script).with_context(|| {
format!("cannot remove {}", script.display())
})?;
parts.push("Startup script removed".to_string());
}
}
removed = parts.join(" · ");
}
app.emit(
&Event::now(EventKind::Config)
.with_agent(agent.to_string())
.with_reason("service uninstall"),
);
app.log.success(&format!("service '{agent}' removed ({removed})"));
Ok(0)
}
fn status(app: &App, agent: &str) -> Result<i32> {
let mut installed = false;
if cfg!(target_os = "linux") {
installed = automation::systemd_unit_path(agent).exists();
} else if cfg!(target_os = "macos") {
installed = automation::launchd_path(agent).exists();
} else {
let task = automation::windows_task_name(agent);
let status = Command::new("schtasks")
.args(["/query", "/tn", &task])
.status()
.unwrap_or_default();
installed = status.success();
if !installed {
// Startup folder fallback?
installed = crate::config::home_dir()
.map(|h| {
h.join("AppData/Roaming/Microsoft/Windows/Start Menu/Programs/Startup")
.join(format!("am-{agent}.cmd"))
.exists()
})
.unwrap_or(false);
}
}
if installed {
println!("service '{agent}': installed");
Ok(0)
} else {
app.log.info(&format!("service '{agent}': not installed"));
Ok(1)
}
}
+2
View File
@@ -196,6 +196,8 @@ mod tests {
note: None, note: None,
hidden: false, hidden: false,
platforms: vec![], platforms: vec![],
healthcheck: None,
container: None,
} }
} }
+30
View File
@@ -125,6 +125,36 @@ pub static SECTIONS: &[TipSection] = &[
options: &[("--json", "rapport machine-readable")], options: &[("--json", "rapport machine-readable")],
example: "audit --json", example: "audit --json",
}, },
TipEntry {
usage: "service install <agent> --autostart",
about: "agent en service système (systemd / launchd / tâche Windows) avec démarrage auto",
options: &[
("status <agent>", "état du service"),
("uninstall <agent>", "retire le service"),
],
example: "service install claude-code --autostart",
},
TipEntry {
usage: "schedule add <cmd...> --at HH:MM",
about: "planifie une commande am quotidienne (ex: update --all le matin)",
options: &[
("schedule list", "liste les planifications"),
("schedule remove <id>", "supprime une planification"),
],
example: "schedule add update --all --at 06:30",
},
TipEntry {
usage: "doctor --watch <secondes>",
about: "vérifications de l'environnement en continu, alerte dès qu'un problème apparaît",
options: &[("--fix", "répare automatiquement ce qui peut l'être")],
example: "doctor --watch 60",
},
TipEntry {
usage: "start group:dev --parallel",
about: "démarre un groupe complet (ordre + attente de santé par défaut, --parallel sans attente)",
options: &[("run <agent> --container", "exécute l'agent dans son profil conteneur (docker/podman)")],
example: "start group:dev --parallel",
},
TipEntry { TipEntry {
usage: "update --rollback", usage: "update --rollback",
about: "annule la dernière mise à jour (backup automatique avant chaque update)", about: "annule la dernière mise à jour (backup automatique avant chaque update)",
+40
View File
@@ -244,6 +244,46 @@ pub struct AgentDef {
/// Restrict the agent to platforms: linux, macos, windows (empty = all). /// Restrict the agent to platforms: linux, macos, windows (empty = all).
#[serde(default)] #[serde(default)]
pub platforms: Vec<String>, pub platforms: Vec<String>,
/// Health check waited before the next orchestration step (issue #57).
#[serde(default)]
pub healthcheck: Option<HealthCheck>,
/// Container profile used by 'am run --container' (issue #58).
#[serde(default)]
pub container: Option<ContainerSpec>,
}
/// Health check for group orchestration (issue #57): a command that must
/// exit 0, or a TCP port that must accept connections, within timeout_secs.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct HealthCheck {
/// Shell command to probe (exit 0 = healthy).
#[serde(default)]
pub command: Option<String>,
/// TCP port to connect to (accepts = healthy).
#[serde(default)]
pub port: Option<u16>,
/// Seconds to wait before declaring failure (default 30).
#[serde(default)]
pub timeout_secs: Option<u64>,
}
/// Container profile (issue #58): image, volumes, network and env applied
/// by 'am run <agent> --container'.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct ContainerSpec {
/// Container image (required).
pub image: String,
/// Extra volumes "host:container[:ro]" on top of state_dir/log_dir.
#[serde(default)]
pub volumes: Vec<String>,
/// Network (default: host on Linux, bridge elsewhere).
#[serde(default)]
pub network: Option<String>,
/// Extra environment variables inside the container.
#[serde(default)]
pub env: BTreeMap<String, String>,
} }
fn default_true() -> bool { fn default_true() -> bool {
+66 -9
View File
@@ -138,6 +138,18 @@ const START_FLAGS: &[HelpFlag] = &[
value: "", value: "",
desc: "Send a desktop notification once the agent has started", desc: "Send a desktop notification once the agent has started",
}, },
HelpFlag {
short: "",
long: "--parallel",
value: "",
desc: "Start every group member back-to-back without waiting (issue #57)",
},
HelpFlag {
short: "",
long: "--container",
value: "",
desc: "Run inside the agent's container profile (issue #58)",
},
]; ];
pub static HELP_SPECS: &[HelpSpec] = &[ pub static HELP_SPECS: &[HelpSpec] = &[
@@ -623,7 +635,7 @@ pub static HELP_SPECS: &[HelpSpec] = &[
category: "Commands", category: "Commands",
usage: "start {flags} <agent>", usage: "start {flags} <agent>",
about: "Start an agent (foreground by default, or detached with --background). --profile applies an environment profile (env + args).", about: "Start an agent (foreground by default, or detached with --background). --profile applies an environment profile (env + args).",
search_terms: &["launch", "begin"], search_terms: &["launch", "begin", "group", "parallel", "healthcheck"],
flags: START_FLAGS, flags: START_FLAGS,
subcommands: &[], subcommands: &[],
parameters: &[ parameters: &[
@@ -633,7 +645,8 @@ pub static HELP_SPECS: &[HelpSpec] = &[
examples: &[ examples: &[
HelpExample { desc: "Start an agent in the background.", code: "start claude-code --background" }, HelpExample { desc: "Start an agent in the background.", code: "start claude-code --background" },
HelpExample { desc: "Start with extra arguments.", code: "start claude-code --args \"--model sonnet\"" }, HelpExample { desc: "Start with extra arguments.", code: "start claude-code --args \"--model sonnet\"" },
HelpExample { desc: "Start every agent of a group.", code: "start group:dev --background" }, HelpExample { desc: "Start every agent of a group, waiting each health check.", code: "start group:dev --background" },
HelpExample { desc: "Start a group without waiting.", code: "start group:dev --parallel" },
HelpExample { desc: "Start with an environment profile.", code: "start claude-code --profile prod" }, HelpExample { desc: "Start with an environment profile.", code: "start claude-code --profile prod" },
], ],
}, },
@@ -687,8 +700,11 @@ pub static HELP_SPECS: &[HelpSpec] = &[
category: "Commands", category: "Commands",
usage: "run {flags} <agent> ...(args)", usage: "run {flags} <agent> ...(args)",
about: "Run the agent command directly with the given arguments (no process management).", about: "Run the agent command directly with the given arguments (no process management).",
search_terms: &["exec", "pass-through"], search_terms: &["exec", "pass-through", "container", "docker", "podman"],
flags: &[], flags: &[
HelpFlag { short: "", long: "--model", value: "MODEL", desc: "Local model to use (issue #71)" },
HelpFlag { short: "", long: "--container", value: "", desc: "Run inside the agent's container profile (issue #58)" },
],
subcommands: &[], subcommands: &[],
parameters: &[ parameters: &[
HelpParam { name: "agent", typ: "string", desc: "Agent name or alias" }, HelpParam { name: "agent", typ: "string", desc: "Agent name or alias" },
@@ -704,17 +720,58 @@ pub static HELP_SPECS: &[HelpSpec] = &[
name: "doctor", name: "doctor",
category: "Commands", category: "Commands",
usage: "doctor {flags}", usage: "doctor {flags}",
about: "Check the environment: tools, config validity, paths, permissions.", about: "Check the environment; --watch re-runs periodically and alerts on failure.",
search_terms: &["check", "environment", "repair"], search_terms: &["health", "watch", "check", "environment", "repair"],
flags: &[ flags: &[
HelpFlag { short: "", long: "--fix", value: "", desc: "Attempt to repair problems (create directories, fix the state file)" }, HelpFlag { short: "", long: "--fix", value: "", desc: "Repair what can be repaired automatically" },
HelpFlag { short: "", long: "--watch", value: "SECONDS", desc: "Re-run the checks every N seconds; alert + event on failure, recover silently" },
], ],
subcommands: &[], subcommands: &[],
parameters: &[], parameters: &[],
io: None, io: None,
examples: &[ examples: &[
HelpExample { desc: "Check the environment.", code: "doctor" }, HelpExample { desc: "One-shot environment report.", code: "doctor" },
HelpExample { desc: "Check and repair.", code: "doctor --fix" }, HelpExample { desc: "Watch every 30s, alerting on failure.", code: "doctor --watch 30" },
HelpExample { desc: "Repair what can be fixed.", code: "doctor --fix" },
],
},
HelpSpec {
name: "service",
category: "Commands",
usage: "service install|uninstall|status <agent> {flags}",
about: "Register an agent as a system service (systemd / launchd / Task Scheduler).",
search_terms: &["systemd", "autostart", "launchd", "task scheduler", "daemon"],
flags: &[
HelpFlag { short: "", long: "--autostart", value: "", desc: "Start the service at boot/logon" },
],
subcommands: &[],
parameters: &[
HelpParam { name: "agent", typ: "string", desc: "Agent name" },
],
io: None,
examples: &[
HelpExample { desc: "Install with autostart.", code: "service install claude-code --autostart" },
HelpExample { desc: "Remove the service.", code: "service uninstall claude-code" },
HelpExample { desc: "Check the service.", code: "service status claude-code" },
],
},
HelpSpec {
name: "schedule",
category: "Commands",
usage: "schedule add|list|remove <cmd...> {flags}",
about: "Plan am commands (daily) and run them with health reporting.",
search_terms: &["cron", "daily", "task scheduler", "scheduled", "planning"],
flags: &[
HelpFlag { short: "", long: "--at", value: "HH:MM", desc: "Time of day (default 08:00)" },
],
subcommands: &[],
parameters: &[],
io: None,
examples: &[
HelpExample { desc: "Daily update of every agent at 06:30.", code: "schedule add update --all --at 06:30" },
HelpExample { desc: "List the schedules.", code: "schedule list" },
HelpExample { desc: "Remove a schedule.", code: "schedule remove <id>" },
HelpExample { desc: "Run a schedule now (used by the OS task).", code: "schedule run <id>" },
], ],
}, },
HelpSpec { HelpSpec {
+1
View File
@@ -12,6 +12,7 @@
//! * commands — one module per CLI command. //! * commands — one module per CLI command.
pub mod app; pub mod app;
pub mod automation;
pub mod backup; pub mod backup;
pub mod catalog; pub mod catalog;
pub mod templates; pub mod templates;
+58 -1
View File
@@ -110,6 +110,8 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
("catalog", "manage remote catalogs"), ("catalog", "manage remote catalogs"),
("suggest", "recommend an agent for a request"), ("suggest", "recommend an agent for a request"),
("audit", "who changed what, when (config checksums)"), ("audit", "who changed what, when (config checksums)"),
("service", "register an agent as a system service (autostart)"),
("schedule", "plan am commands (daily) and check the fleet health"),
("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"),
@@ -224,6 +226,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",
"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"],
@@ -772,6 +775,9 @@ pub fn banner_box(
rows.push(inner( rows.push(inner(
" models models · models --prune · catalog · suggest · audit".to_string(), " models models · models --prune · catalog · suggest · audit".to_string(),
)); ));
rows.push(inner(
" automate service install · schedule add · doctor --watch".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(),
)); ));
@@ -1632,12 +1638,63 @@ fn handle_line(
} }
} }
"audit" => Command::Audit, "audit" => Command::Audit,
"doctor" => Command::Doctor { fix: flag("--fix") }, "service" => match rest.first().map(|s| s.as_str()) {
Some(action) if rest.len() >= 2 => Command::Service(match action {
"install" => crate::cli::ServiceCmd::Install {
agent: rest[1].clone(),
autostart: flag("--autostart"),
},
"uninstall" => crate::cli::ServiceCmd::Uninstall {
agent: rest[1].clone(),
},
"status" => crate::cli::ServiceCmd::Status {
agent: rest[1].clone(),
},
_ => {
app.log
.error("usage: service install|uninstall|status <agent> [--autostart]");
return Ok(false);
}
}),
_ => {
app.log
.error("usage: service install|uninstall|status <agent> [--autostart]");
return Ok(false);
}
},
"schedule" => match rest.first().map(|s| s.as_str()) {
Some("add") => Command::Schedule(crate::cli::ScheduleCmd::Add {
command: rest[1..]
.iter()
.filter(|a| !a.starts_with("--"))
.cloned()
.collect(),
at: opt_value("--at"),
}),
Some("list") | None => Command::Schedule(crate::cli::ScheduleCmd::List),
Some("remove") if rest.len() >= 2 => Command::Schedule(
crate::cli::ScheduleCmd::Remove {
id: rest[1].clone(),
},
),
Some("run") if rest.len() >= 2 => Command::Schedule(crate::cli::ScheduleCmd::Run {
id: rest[1].clone(),
}),
_ => {
app.log.error("usage: schedule add <cmd...> | schedule list | schedule remove <id>");
return Ok(false);
}
},
"doctor" => Command::Doctor {
fix: flag("--fix"),
watch: opt_value("--watch").and_then(|v| v.parse().ok()),
},
"run" => { "run" => {
let agent = need("agent name")?; let agent = need("agent name")?;
Command::Run { Command::Run {
agent, agent,
model: opt_value("--model"), model: opt_value("--model"),
container: flag("--container"),
args: rest[1..].iter().map(|s| s.as_str().into()).collect(), args: rest[1..].iter().map(|s| s.as_str().into()).collect(),
} }
} }
+26
View File
@@ -27,6 +27,31 @@ pub struct StateFile {
/// Config file checksums used by 'am audit' (axe 8, issue #63). /// Config file checksums used by 'am audit' (axe 8, issue #63).
#[serde(default)] #[serde(default)]
pub config_checksums: BTreeMap<String, ConfigChecksum>, pub config_checksums: BTreeMap<String, ConfigChecksum>,
/// Scheduled commands (axe 6, issue #56).
#[serde(default)]
pub schedules: Vec<ScheduleEntry>,
}
/// One scheduled command (issue #56). Stored in state.json; the OS-level
/// task (crontab / Task Scheduler) points back at 'am schedule-run <id>'.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ScheduleEntry {
pub id: String,
/// "daily" (the only cadence supported today).
pub cadence: String,
/// Command arguments to run, e.g. ["update", "--all"].
pub command: Vec<String>,
/// HH:MM local time (default "08:00").
#[serde(default)]
pub at: String,
/// RFC 3339 creation timestamp.
pub created_at: String,
/// RFC 3339 of the last run (None = never).
#[serde(default)]
pub last_run: Option<String>,
/// Exit code of the last run (None = never ran / running).
#[serde(default)]
pub last_exit: Option<i32>,
} }
/// One recorded config checksum: the baseline 'am audit' compares against /// One recorded config checksum: the baseline 'am audit' compares against
@@ -64,6 +89,7 @@ impl Default for StateFile {
last_used: None, last_used: None,
annotations: BTreeMap::new(), annotations: BTreeMap::new(),
config_checksums: BTreeMap::new(), config_checksums: BTreeMap::new(),
schedules: Vec::new(),
} }
} }
} }
+2 -2
View File
@@ -45,7 +45,7 @@ agents:
#[test] #[test]
fn run_emits_one_run_event_with_exit_code() { fn run_emits_one_run_event_with_exit_code() {
let app = app_with_agent("run", &["run", "echo-agent"], ECHO_AGENT); let app = app_with_agent("run", &["run", "echo-agent"], ECHO_AGENT);
let code = run_cmd::run(&app, "echo-agent", None, &[OsString::from("hello")]).unwrap(); let code = run_cmd::run(&app, "echo-agent", None, false, &[OsString::from("hello")]).unwrap();
assert_eq!(code, 0); assert_eq!(code, 0);
let evs = events(&app); let evs = events(&app);
assert_eq!(evs.len(), 1, "exactly one event expected, got {:?}", evs); assert_eq!(evs.len(), 1, "exactly one event expected, got {:?}", evs);
@@ -58,7 +58,7 @@ fn run_emits_one_run_event_with_exit_code() {
#[test] #[test]
fn dry_run_writes_no_event() { fn dry_run_writes_no_event() {
let app = app_with_agent("dry", &["run", "echo-agent", "--dry-run"], ECHO_AGENT); let app = app_with_agent("dry", &["run", "echo-agent", "--dry-run"], ECHO_AGENT);
let code = run_cmd::run(&app, "echo-agent", None, &[]).unwrap(); let code = run_cmd::run(&app, "echo-agent", None, false, &[]).unwrap();
assert_eq!(code, 0); assert_eq!(code, 0);
assert!(events(&app).is_empty(), "dry-run must not append events"); assert!(events(&app).is_empty(), "dry-run must not append events");
assert!(events::journal_files(&app.events_dir()).is_empty()); assert!(events::journal_files(&app.events_dir()).is_empty());
+1
View File
@@ -28,6 +28,7 @@ fn installed_state(name: &str, version: &str) -> StateFile {
last_used: None, last_used: None,
annotations: BTreeMap::new(), annotations: BTreeMap::new(),
config_checksums: BTreeMap::new(), config_checksums: BTreeMap::new(),
schedules: Vec::new(),
} }
} }