feat: phase 2 — automatisation: doctor --watch, schedule, service, orchestration, conteneurs (closes #55 #56 #57 #58 #59)
This commit is contained in:
Generated
+1
-1
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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())
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -196,6 +196,8 @@ mod tests {
|
|||||||
note: None,
|
note: None,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
platforms: vec![],
|
platforms: vec![],
|
||||||
|
healthcheck: None,
|
||||||
|
container: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user