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
+42 -1
View File
@@ -7,7 +7,10 @@ use crate::output::{boxed_table, print_json, status_style};
use crate::runner::SystemRunner;
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 {
let repaired = doctor::fix(app)?;
for r in &repaired {
@@ -61,3 +64,41 @@ pub fn run(app: &App, fix: bool) -> Result<i32> {
}
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 projects_cmd;
pub mod run_cmd;
pub mod schedule_cmd;
pub mod search_cmd;
pub mod service_cmd;
pub mod secret_cmd;
pub mod self_uninstall;
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::Config(sub) => config_cmd::run(app, sub),
Command::Theme { name } => theme_cmd::run(app, name.as_deref()),
Command::Doctor { fix } => doctor_cmd::run(app, *fix),
Command::Run { agent, model, args } => run_cmd::run(app, agent, model.as_deref(), args),
Command::Doctor { fix, watch } => {
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::SelfUpdate { check, to } => self_update::run(app, *check, to.as_deref()),
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() {
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 {
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);
}
@@ -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.
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)?;
// 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
.iter()
.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)
}
/// 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).
fn cwd_string() -> Option<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,
hidden: false,
platforms: vec![],
healthcheck: None,
container: None,
}
}
+30
View File
@@ -125,6 +125,36 @@ pub static SECTIONS: &[TipSection] = &[
options: &[("--json", "rapport machine-readable")],
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 {
usage: "update --rollback",
about: "annule la dernière mise à jour (backup automatique avant chaque update)",