Files
agent-manager/src/automation.rs
T

439 lines
15 KiB
Rust

//! 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());
}
}