//! 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 --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!( "\n\ \n\ \n\ \n\ \tLabel\n\ \tcom.agent-manager.{agent}\n\ \tProgramArguments\n\ \t\n\ \t\t{am_bin}\n\ \t\tstart\n\ \t\t{agent}\n\ \t\t--background\n\ \t\t--notify\n\ \t\n\ \tRunAtLoad\n\ \t\n\ \tKeepAlive\n\ \t\n\ \n\ \n" ) } /// Windows scheduled task XML (on logon, with retry). pub fn windows_task_xml(agent: &str, am_bin: &str) -> String { format!( "\n\ \n\ \ttrue\n\ \tInteractiveToken\n\ \tLeastPrivilege\n\ \tIgnoreNew\n\ \tfalse\n\ \tfalse\n\ \tPT1M3\n\ \t\n\ \t\n\ \t{am_bin}\n\ \tstart {agent} --background --notify\n\ \t\n\ \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 . pub fn schedule_run_command(app: &crate::app::App, entry: &crate::state::ScheduleEntry) -> Result { // Re-dispatch through the CLI so hooks/events/state stay consistent. let args: Vec = 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 { 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> { 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("/opt/homebrew/bin/am")); assert!(plist.contains("RunAtLoad")); } #[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()); } }