439 lines
15 KiB
Rust
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());
|
|
}
|
|
}
|