feat: phase 2 — automatisation: doctor --watch, schedule, service, orchestration, conteneurs (closes #55 #56 #57 #58 #59)
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user