agent-berth 2.2.1

Monitor and resume coding agents
Documentation
use std::process::Command;
use std::time::{Duration, Instant};

use anyhow::{Context, Result};

use crate::ipc;
use crate::paths::Context as AppContext;

#[cfg(windows)]
const TASK_NAME: &str = "AgentBerth";

const START_TIMEOUT: Duration = Duration::from_secs(15);

pub fn server_log_path(ctx: &AppContext) -> std::path::PathBuf {
    ctx.state_dir.join("server.log")
}

pub fn install(ctx: &AppContext) -> Result<()> {
    if install_backend(ctx)? {
        wait_running(ctx)?;
    }
    Ok(())
}

pub fn uninstall(ctx: &AppContext) -> Result<()> {
    remove_backend(ctx);
    cleanup(ctx);
    Ok(())
}

pub fn start(ctx: &AppContext) -> Result<()> {
    if running(ctx) {
        return Ok(());
    }
    ensure_installed(ctx)?;
    start_backend(ctx)?;
    wait_running(ctx)
}

pub fn stop(ctx: &AppContext) -> Result<()> {
    stop_backend(ctx);
    cleanup(ctx);
    Ok(())
}

pub fn restart(ctx: &AppContext) -> Result<()> {
    ensure_installed(ctx)?;
    restart_backend(ctx)?;
    wait_running(ctx)
}

pub fn running(ctx: &AppContext) -> bool {
    ipc::ping(ctx).is_ok()
}

pub fn installed(ctx: &AppContext) -> bool {
    #[cfg(windows)]
    {
        let _ = ctx;
        Windows::task_exists()
    }
    #[cfg(unix)]
    {
        ctx.systemd_unit_path().is_file()
    }
}

fn ensure_installed(ctx: &AppContext) -> Result<()> {
    if installed(ctx) {
        Ok(())
    } else {
        anyhow::bail!("agent-berth service is not installed (run: agent-berth setup)")
    }
}

fn wait_running(ctx: &AppContext) -> Result<()> {
    let deadline = Instant::now() + START_TIMEOUT;
    loop {
        if running(ctx) {
            return Ok(());
        }
        if Instant::now() >= deadline {
            anyhow::bail!(
                "agent-berth server did not start; see {}",
                server_log_path(ctx).display()
            );
        }
        std::thread::sleep(Duration::from_millis(250));
    }
}

fn cleanup(ctx: &AppContext) {
    if let Ok(pid_text) = std::fs::read_to_string(ctx.pid_path())
        && let Ok(pid) = pid_text.trim().parse::<u32>()
    {
        kill_pid(pid);
    }
    let _ = std::fs::remove_file(ctx.pid_path());
    #[cfg(unix)]
    let _ = std::fs::remove_file(ctx.socket_path());
}

#[cfg(windows)]
fn install_backend(ctx: &AppContext) -> Result<bool> {
    Windows::run(ctx, "install")?;
    println!("Installed Windows scheduled task {TASK_NAME}");
    Ok(true)
}

#[cfg(windows)]
fn start_backend(ctx: &AppContext) -> Result<()> {
    Windows::run(ctx, "start")
}

#[cfg(windows)]
fn stop_backend(ctx: &AppContext) {
    if installed(ctx) {
        let _ = Windows::run(ctx, "stop");
    }
}

#[cfg(windows)]
fn restart_backend(ctx: &AppContext) -> Result<()> {
    Windows::run(ctx, "restart")
}

#[cfg(windows)]
fn remove_backend(ctx: &AppContext) {
    let _ = Windows::run(ctx, "uninstall");
    println!("Removed Windows scheduled task {TASK_NAME}");
}

#[cfg(unix)]
fn install_backend(ctx: &AppContext) -> Result<bool> {
    install_systemd(ctx)
}

#[cfg(unix)]
fn start_backend(_ctx: &AppContext) -> Result<()> {
    let status = Command::new("systemctl")
        .args(["--user", "start", "agent-berth.service"])
        .status()
        .context("systemctl start")?;
    if !status.success() {
        anyhow::bail!("failed to start agent-berth.service");
    }
    Ok(())
}

#[cfg(unix)]
fn stop_backend(_ctx: &AppContext) {
    let _ = Command::new("systemctl")
        .args(["--user", "stop", "agent-berth.service"])
        .status();
}

#[cfg(unix)]
fn restart_backend(_ctx: &AppContext) -> Result<()> {
    let status = Command::new("systemctl")
        .args(["--user", "restart", "agent-berth.service"])
        .status()
        .context("systemctl restart")?;
    if !status.success() {
        anyhow::bail!("failed to restart agent-berth.service");
    }
    Ok(())
}

#[cfg(unix)]
fn remove_backend(ctx: &AppContext) {
    uninstall_systemd(ctx);
}

#[cfg(windows)]
struct Windows;

#[cfg(windows)]
impl Windows {
    fn task_exists() -> bool {
        Command::new("schtasks")
            .args(["/Query", "/TN", TASK_NAME])
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .status()
            .map(|status| status.success())
            .unwrap_or(false)
    }

    fn run(ctx: &AppContext, action: &str) -> Result<()> {
        let script = Self::script(ctx);
        let path =
            std::env::temp_dir().join(format!("agent-berth-service-{}.ps1", std::process::id()));
        let mut content = vec![0xEF, 0xBB, 0xBF];
        content.extend_from_slice(script.as_bytes());
        std::fs::write(&path, content).with_context(|| format!("write {}", path.display()))?;
        let status = Command::new("powershell")
            .args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"])
            .arg(&path)
            .arg(action)
            .status();
        let _ = std::fs::remove_file(&path);
        let status = status.context("run powershell")?;
        if !status.success() {
            anyhow::bail!("agent-berth service {action} failed");
        }
        Ok(())
    }

    fn script(ctx: &AppContext) -> String {
        let exe = ctx.berth_bin.display().to_string().replace('\'', "''");
        let pipe = ctx.pipe_name().replace('\'', "''");
        format!(
            r#"param([Parameter(Position = 0)][ValidateSet('install', 'uninstall', 'start', 'stop', 'restart')][string]$Action = 'start')

$ErrorActionPreference = 'Stop'
$TaskName = '{TASK_NAME}'
$Exe = '{exe}'
$PipeName = '{pipe}'

$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]$identity
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {{
    $shell = if ($PSVersionTable.PSEdition -eq 'Core') {{ 'pwsh' }} else {{ 'powershell' }}
    $relaunch = @('-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', "`"$PSCommandPath`"", $Action)
    $proc = Start-Process -Verb RunAs -FilePath $shell -ArgumentList $relaunch -Wait -PassThru
    exit $proc.ExitCode
}}

function Test-AgentBerthTask {{
    schtasks /Query /TN $TaskName *> $null
    return $LASTEXITCODE -eq 0
}}

function Wait-AgentBerthStopped {{
    for ($i = 0; $i -lt 50; $i++) {{
        if (-not (Test-Path "\\.\pipe\$PipeName")) {{ return }}
        Start-Sleep -Milliseconds 200
    }}
}}

function Register-AgentBerthTask {{
    $user = $identity.Name
    $xml = @"
<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
  <RegistrationInfo>
    <Description>Agent Berth server</Description>
  </RegistrationInfo>
  <Triggers>
    <LogonTrigger>
      <Enabled>true</Enabled>
      <UserId>$user</UserId>
    </LogonTrigger>
  </Triggers>
  <Principals>
    <Principal id="Author">
      <UserId>$user</UserId>
      <LogonType>S4U</LogonType>
      <RunLevel>LeastPrivilege</RunLevel>
    </Principal>
  </Principals>
  <Settings>
    <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
    <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
    <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
    <AllowHardTerminate>true</AllowHardTerminate>
    <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
    <RestartOnFailure>
      <Interval>PT1M</Interval>
      <Count>3</Count>
    </RestartOnFailure>
  </Settings>
  <Actions Context="Author">
    <Exec>
      <Command>$Exe</Command>
      <Arguments>server</Arguments>
    </Exec>
  </Actions>
</Task>
"@
    $tmp = [IO.Path]::GetTempFileName()
    try {{
        [IO.File]::WriteAllText($tmp, $xml, [Text.Encoding]::Unicode)
        schtasks /Create /TN $TaskName /XML $tmp /F | Out-Null
        if ($LASTEXITCODE -ne 0) {{ throw "failed to register scheduled task '$TaskName'" }}
    }} finally {{
        Remove-Item $tmp -Force -ErrorAction SilentlyContinue
    }}
}}

switch ($Action) {{
    'install' {{
        Register-AgentBerthTask
        schtasks /End /TN $TaskName *> $null
        Wait-AgentBerthStopped
        schtasks /Run /TN $TaskName | Out-Null
    }}
    'start' {{
        if (-not (Test-AgentBerthTask)) {{ throw "scheduled task '$TaskName' is not installed; run: agent-berth setup" }}
        schtasks /Run /TN $TaskName | Out-Null
    }}
    'stop' {{
        if (Test-AgentBerthTask) {{ schtasks /End /TN $TaskName | Out-Null }}
        Wait-AgentBerthStopped
    }}
    'restart' {{
        if (Test-AgentBerthTask) {{ schtasks /End /TN $TaskName | Out-Null }}
        Wait-AgentBerthStopped
        schtasks /Run /TN $TaskName | Out-Null
    }}
    'uninstall' {{
        if (Test-AgentBerthTask) {{
            schtasks /End /TN $TaskName *> $null
            schtasks /Delete /TN $TaskName /F | Out-Null
        }}
    }}
}}
"#
        )
    }
}

#[cfg(unix)]
fn install_systemd(ctx: &AppContext) -> Result<bool> {
    let unit = ctx.systemd_unit_path();
    if let Some(parent) = unit.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let exec = crate::paths::quote_path(&ctx.berth_bin);
    let body = format!(
        "[Unit]\nDescription=Agent Berth\nAfter=default.target\n\n[Service]\nType=simple\nExecStart={exec} server\nRestart=on-failure\nRestartSec=2\n\n[Install]\nWantedBy=default.target\n"
    );
    std::fs::write(&unit, body)?;
    let daemon = Command::new("systemctl")
        .args(["--user", "daemon-reload"])
        .status();
    match daemon {
        Ok(status) if status.success() => {
            let enable = Command::new("systemctl")
                .args(["--user", "enable", "--now", "agent-berth.service"])
                .status()
                .context("systemctl enable")?;
            if !enable.success() {
                anyhow::bail!("failed to enable agent-berth.service");
            }
            println!("Installed systemd user unit {}", unit.display());
            return Ok(true);
        }
        _ => {
            println!(
                "Wrote {} (systemctl --user not available; enable it later)",
                unit.display()
            );
        }
    }
    Ok(false)
}

#[cfg(unix)]
fn uninstall_systemd(ctx: &AppContext) {
    let _ = Command::new("systemctl")
        .args(["--user", "disable", "--now", "agent-berth.service"])
        .status();
    let unit = ctx.systemd_unit_path();
    if unit.exists() {
        let _ = std::fs::remove_file(&unit);
    }
    let _ = Command::new("systemctl")
        .args(["--user", "daemon-reload"])
        .status();
    println!("Removed systemd user unit");
}

fn kill_pid(pid: u32) {
    #[cfg(windows)]
    {
        let _ = Command::new("taskkill")
            .args(["/PID", &pid.to_string(), "/F"])
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .status();
    }
    #[cfg(unix)]
    {
        unsafe {
            libc::kill(pid as i32, libc::SIGTERM);
        }
    }
}