Skip to main content

roboticus_server/
daemon.rs

1use std::path::{Path, PathBuf};
2
3use roboticus_core::{Result, RoboticusError, home_dir};
4
5const WINDOWS_DAEMON_NAME: &str = "RoboticusAgent";
6
7pub fn launchd_plist(binary_path: &str, config_path: &str, port: u16) -> String {
8    let log_dir = home_dir().join(".roboticus").join("logs");
9    let stdout_log = log_dir.join("roboticus.stdout.log");
10    let stderr_log = log_dir.join("roboticus.stderr.log");
11
12    format!(
13        r#"<?xml version="1.0" encoding="UTF-8"?>
14<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
15<plist version="1.0">
16<dict>
17    <key>Label</key>
18    <string>com.roboticus.agent</string>
19    <key>ProgramArguments</key>
20    <array>
21        <string>{binary_path}</string>
22        <string>serve</string>
23        <string>-c</string>
24        <string>{config_path}</string>
25        <string>-p</string>
26        <string>{port}</string>
27    </array>
28    <key>RunAtLoad</key>
29    <true/>
30    <key>KeepAlive</key>
31    <true/>
32    <key>StandardOutPath</key>
33    <string>{stdout}</string>
34    <key>StandardErrorPath</key>
35    <string>{stderr}</string>
36</dict>
37</plist>"#,
38        binary_path = binary_path,
39        config_path = config_path,
40        port = port,
41        stdout = stdout_log.display(),
42        stderr = stderr_log.display(),
43    )
44}
45
46pub fn systemd_unit(binary_path: &str, config_path: &str, port: u16) -> String {
47    format!(
48        r#"[Unit]
49Description=Roboticus Autonomous Agent Runtime
50After=network.target
51
52[Service]
53Type=simple
54ExecStart={binary_path} serve -c {config_path} -p {port}
55Restart=on-failure
56RestartSec=5
57Environment=RUST_LOG=info
58
59[Install]
60WantedBy=default.target
61"#,
62        binary_path = binary_path,
63        config_path = config_path,
64        port = port
65    )
66}
67
68fn plist_path_for(home: &str) -> PathBuf {
69    PathBuf::from(home).join("Library/LaunchAgents/com.roboticus.agent.plist")
70}
71
72pub fn plist_path() -> PathBuf {
73    plist_path_for(&home_dir().to_string_lossy())
74}
75
76fn systemd_path_for(home: &str) -> PathBuf {
77    PathBuf::from(home).join(".config/systemd/user/roboticus.service")
78}
79
80pub fn systemd_path() -> PathBuf {
81    systemd_path_for(&home_dir().to_string_lossy())
82}
83
84fn windows_service_marker_path() -> PathBuf {
85    home_dir()
86        .join(".roboticus")
87        .join("windows-service-install.txt")
88}
89
90#[derive(Debug, Clone)]
91struct WindowsDaemonInstall {
92    binary: String,
93    config: String,
94    port: u16,
95    pid: Option<u32>,
96}
97
98fn parse_windows_daemon_marker(content: &str) -> Option<WindowsDaemonInstall> {
99    let mut binary = None;
100    let mut config = None;
101    let mut port = None;
102    let mut pid = None;
103
104    for line in content.lines() {
105        if let Some((k, v)) = line.split_once('=') {
106            match k.trim() {
107                "binary" => binary = Some(v.trim().to_string()),
108                "config" => config = Some(v.trim().to_string()),
109                "port" => {
110                    port = v.trim().parse::<u16>().ok();
111                }
112                "pid" => {
113                    pid = v.trim().parse::<u32>().ok();
114                }
115                _ => {}
116            }
117        }
118    }
119
120    Some(WindowsDaemonInstall {
121        binary: binary?,
122        config: config?,
123        port: port?,
124        pid,
125    })
126}
127
128fn write_windows_daemon_marker(install: &WindowsDaemonInstall) -> Result<()> {
129    let marker = windows_service_marker_path();
130    if let Some(parent) = marker.parent() {
131        std::fs::create_dir_all(parent)?;
132    }
133    let mut content = format!(
134        "name={WINDOWS_DAEMON_NAME}\nmode=user_process\nbinary={}\nconfig={}\nport={}\n",
135        install.binary, install.config, install.port
136    );
137    if let Some(pid) = install.pid {
138        content.push_str(&format!("pid={pid}\n"));
139    }
140    std::fs::write(&marker, content)?;
141    Ok(())
142}
143
144fn read_windows_daemon_marker() -> Result<Option<WindowsDaemonInstall>> {
145    let marker = windows_service_marker_path();
146    if !marker.exists() {
147        return Ok(None);
148    }
149    let content = std::fs::read_to_string(marker)?;
150    Ok(parse_windows_daemon_marker(&content))
151}
152
153fn windows_pid_running(pid: u32) -> Result<bool> {
154    if std::env::consts::OS != "windows" {
155        return Ok(false);
156    }
157    // Use PowerShell Get-Process which is locale-independent, unlike tasklist
158    // whose text output varies by Windows display language.
159    let script = format!(
160        "try {{ $null = Get-Process -Id {pid} -ErrorAction Stop; Write-Output 'RUNNING' }} catch {{ Write-Output 'NOTFOUND' }}"
161    );
162    let out = command_output("powershell", &["-NoProfile", "-Command", &script])?;
163    if !out.status.success() {
164        // Fallback to tasklist if PowerShell is unavailable
165        let pid_filter = format!("PID eq {pid}");
166        let out = command_output("tasklist", &["/FI", &pid_filter, "/FO", "CSV", "/NH"])?;
167        if !out.status.success() {
168            return Ok(false);
169        }
170        let stdout = String::from_utf8_lossy(&out.stdout);
171        return Ok(stdout.contains(&format!("\"{pid}\"")));
172    }
173    let stdout = String::from_utf8_lossy(&out.stdout);
174    Ok(stdout.trim() == "RUNNING")
175}
176
177fn windows_listening_pid(port: u16) -> Result<Option<u32>> {
178    if std::env::consts::OS != "windows" {
179        return Ok(None);
180    }
181
182    // Prefer PowerShell TCP APIs for locale-independent output.
183    let script = format!(
184        "$c = Get-NetTCPConnection -LocalPort {port} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1; if ($c) {{ Write-Output $c.OwningProcess }}"
185    );
186    if let Ok(out) = command_output("powershell", &["-NoProfile", "-Command", &script])
187        && out.status.success()
188    {
189        let stdout = String::from_utf8_lossy(&out.stdout);
190        let pid = stdout.trim().parse::<u32>().ok();
191        if pid.is_some() {
192            return Ok(pid);
193        }
194    }
195
196    // Fallback: parse netstat output.
197    let out = command_output("netstat", &["-ano"])?;
198    if !out.status.success() {
199        return Ok(None);
200    }
201    let stdout = String::from_utf8_lossy(&out.stdout);
202    let needle = format!(":{port}");
203    for line in stdout.lines() {
204        let lower = line.to_ascii_lowercase();
205        if !lower.contains("listen") || !line.contains(&needle) {
206            continue;
207        }
208        let cols: Vec<&str> = line.split_whitespace().collect();
209        if let Some(last) = cols.last()
210            && let Ok(pid) = last.parse::<u32>()
211        {
212            return Ok(Some(pid));
213        }
214    }
215    Ok(None)
216}
217
218fn spawn_windows_daemon_process(install: &WindowsDaemonInstall) -> Result<u32> {
219    let mut cmd = std::process::Command::new(&install.binary);
220    cmd.args([
221        "serve",
222        "-c",
223        &install.config,
224        "-p",
225        &install.port.to_string(),
226    ])
227    .stdin(std::process::Stdio::null())
228    .stdout(std::process::Stdio::null())
229    .stderr(std::process::Stdio::null());
230    #[cfg(windows)]
231    {
232        use std::os::windows::process::CommandExt;
233        const DETACHED_PROCESS: u32 = 0x00000008;
234        const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
235        cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
236    }
237    let child = cmd
238        .spawn()
239        .map_err(|e| RoboticusError::Config(format!("failed to spawn daemon process: {e}")))?;
240    Ok(child.id())
241}
242
243fn cleanup_legacy_windows_service() {
244    if std::env::consts::OS != "windows" {
245        return;
246    }
247    // sc.exe requires Administrator privileges; only attempt if running elevated.
248    let is_admin = std::process::Command::new("net")
249        .args(["session"])
250        .stdout(std::process::Stdio::null())
251        .stderr(std::process::Stdio::null())
252        .status()
253        .map(|s| s.success())
254        .unwrap_or(false);
255    if !is_admin {
256        tracing::warn!("legacy Windows service cleanup skipped: Administrator privileges required");
257        return;
258    }
259
260    match legacy_windows_service_exists() {
261        Ok(false) => return,
262        Ok(true) => {}
263        Err(e) => {
264            tracing::warn!(error = %e, "unable to verify legacy Windows service presence");
265        }
266    }
267
268    for attempt in 1..=3 {
269        if let Err(e) = run_sc_best_effort("stop", WINDOWS_DAEMON_NAME) {
270            tracing::debug!(
271                error = %e,
272                attempt,
273                "legacy Windows service stop failed"
274            );
275        }
276        if let Err(e) = run_sc_best_effort("delete", WINDOWS_DAEMON_NAME) {
277            tracing::debug!(
278                error = %e,
279                attempt,
280                "legacy Windows service delete failed"
281            );
282        }
283
284        std::thread::sleep(std::time::Duration::from_millis(600));
285
286        match legacy_windows_service_exists() {
287            Ok(false) => {
288                tracing::info!("legacy Windows service cleanup complete");
289                return;
290            }
291            Ok(true) => {}
292            Err(e) => {
293                tracing::warn!(error = %e, "failed to verify legacy Windows service cleanup");
294            }
295        }
296    }
297
298    tracing::warn!(
299        "legacy Windows service still present after cleanup attempts; remove with `sc.exe delete {WINDOWS_DAEMON_NAME}`"
300    );
301}
302
303fn run_sc_best_effort(action: &str, service: &str) -> Result<()> {
304    let out = command_output("sc.exe", &[action, service])?;
305    if out.status.success() {
306        return Ok(());
307    }
308    let stdout = String::from_utf8_lossy(&out.stdout);
309    let stderr = String::from_utf8_lossy(&out.stderr);
310    let combined = format!("{stdout}\n{stderr}");
311    if sc_output_is_not_found(&combined) || sc_output_is_not_active(&combined) {
312        return Ok(());
313    }
314    let detail = if !stderr.trim().is_empty() {
315        stderr.trim().to_string()
316    } else {
317        stdout.trim().to_string()
318    };
319    Err(RoboticusError::Config(format!(
320        "sc.exe {action} failed (exit {}): {}",
321        out.status.code().unwrap_or(-1),
322        detail
323    )))
324}
325
326fn legacy_windows_service_exists() -> Result<bool> {
327    let out = command_output("sc.exe", &["query", WINDOWS_DAEMON_NAME])?;
328    if out.status.success() {
329        return Ok(true);
330    }
331    let stdout = String::from_utf8_lossy(&out.stdout);
332    let stderr = String::from_utf8_lossy(&out.stderr);
333    let combined = format!("{stdout}\n{stderr}");
334    if sc_output_is_not_found(&combined) {
335        return Ok(false);
336    }
337    Err(RoboticusError::Config(format!(
338        "sc.exe query failed (exit {}): {}",
339        out.status.code().unwrap_or(-1),
340        combined.trim()
341    )))
342}
343
344fn sc_output_is_not_found(output: &str) -> bool {
345    let lowered = output.to_ascii_lowercase();
346    lowered.contains("1060") || lowered.contains("does not exist")
347}
348
349fn sc_output_is_not_active(output: &str) -> bool {
350    let lowered = output.to_ascii_lowercase();
351    lowered.contains("1062") || lowered.contains("has not been started")
352}
353
354fn install_daemon_to(
355    binary_path: &str,
356    config_path: &str,
357    port: u16,
358    home: &str,
359) -> Result<PathBuf> {
360    let os = std::env::consts::OS;
361    let (content, path) = match os {
362        "macos" => (
363            launchd_plist(binary_path, config_path, port),
364            plist_path_for(home),
365        ),
366        "linux" => (
367            systemd_unit(binary_path, config_path, port),
368            systemd_path_for(home),
369        ),
370        "windows" => {
371            cleanup_legacy_windows_service();
372            let marker = windows_service_marker_path();
373            let install = WindowsDaemonInstall {
374                binary: binary_path.to_string(),
375                config: config_path.to_string(),
376                port,
377                pid: None,
378            };
379            write_windows_daemon_marker(&install)?;
380            return Ok(marker);
381        }
382        other => {
383            return Err(RoboticusError::Config(format!(
384                "daemon install not supported on {other}"
385            )));
386        }
387    };
388
389    if let Some(parent) = path.parent() {
390        std::fs::create_dir_all(parent)?;
391    }
392
393    std::fs::write(&path, &content)?;
394    Ok(path)
395}
396
397pub fn install_daemon(binary_path: &str, config_path: &str, port: u16) -> Result<PathBuf> {
398    let home = home_dir();
399    let result = install_daemon_to(binary_path, config_path, port, &home.to_string_lossy())?;
400
401    // On Windows, register a Task Scheduler entry so the daemon starts at logon,
402    // matching the RunAtLoad (macOS) and systemd enable (Linux) behavior.
403    #[cfg(windows)]
404    {
405        let task_xml = format!(
406            r#"<?xml version="1.0" encoding="UTF-16"?>
407<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
408  <Triggers>
409    <LogonTrigger><Enabled>true</Enabled></LogonTrigger>
410  </Triggers>
411  <Settings>
412    <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
413    <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
414    <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
415    <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
416    <Hidden>false</Hidden>
417  </Settings>
418  <Actions>
419    <Exec>
420      <Command>{binary}</Command>
421      <Arguments>serve -c "{config}" -p {port}</Arguments>
422    </Exec>
423  </Actions>
424</Task>"#,
425            binary = binary_path,
426            config = config_path,
427            port = port,
428        );
429        let task_file = std::env::temp_dir().join("roboticus-task.xml");
430        std::fs::write(&task_file, &task_xml).map_err(|e| {
431            RoboticusError::Config(format!("failed to write task scheduler XML: {e}"))
432        })?;
433        let schtasks_out = std::process::Command::new("schtasks")
434            .args([
435                "/Create",
436                "/TN",
437                "RoboticusAgent",
438                "/XML",
439                &task_file.to_string_lossy(),
440                "/F",
441            ])
442            .output()
443            .map_err(|e| {
444                let _ = std::fs::remove_file(&task_file);
445                RoboticusError::Config(format!("failed to run schtasks: {e}"))
446            })?;
447        let _ = std::fs::remove_file(&task_file);
448        if !schtasks_out.status.success() {
449            let stderr = String::from_utf8_lossy(&schtasks_out.stderr);
450            return Err(RoboticusError::Config(format!(
451                "schtasks /Create failed (exit {}): {}",
452                schtasks_out.status.code().unwrap_or(-1),
453                stderr.trim()
454            )));
455        }
456    }
457
458    Ok(result)
459}
460
461pub fn start_daemon() -> Result<()> {
462    let os = std::env::consts::OS;
463    match os {
464        "macos" => {
465            let output = std::process::Command::new("launchctl")
466                .args(["load", "-w"])
467                .arg(plist_path())
468                .output()
469                .map_err(|e| RoboticusError::Config(format!("failed to run launchctl: {e}")))?;
470
471            let stderr = String::from_utf8_lossy(&output.stderr);
472            if !output.status.success() {
473                return Err(RoboticusError::Config(format!(
474                    "launchctl load failed (exit {}): {}",
475                    output.status.code().unwrap_or(-1),
476                    stderr.trim()
477                )));
478            }
479
480            std::thread::sleep(std::time::Duration::from_secs(1));
481            verify_launchd_running()?;
482            Ok(())
483        }
484        "linux" => {
485            run_cmd("systemctl", &["--user", "daemon-reload"])?;
486            run_cmd(
487                "systemctl",
488                &["--user", "enable", "--now", "roboticus.service"],
489            )
490        }
491        "windows" => {
492            let mut install = read_windows_daemon_marker()?.ok_or_else(|| {
493                RoboticusError::Config("daemon not installed on windows".to_string())
494            })?;
495            if let Some(pid) = install.pid
496                && windows_pid_running(pid)?
497            {
498                return Ok(());
499            }
500            // Marker PID may be missing/stale; recover from active listener on configured port.
501            if let Some(pid) = windows_listening_pid(install.port)?
502                && windows_pid_running(pid)?
503            {
504                install.pid = Some(pid);
505                write_windows_daemon_marker(&install)?;
506                return Ok(());
507            }
508            let pid = spawn_windows_daemon_process(&install)?;
509            install.pid = Some(pid);
510            write_windows_daemon_marker(&install)?;
511
512            // Verify the spawned process is still alive after a brief settle period.
513            // The process may crash immediately on startup (bad config, port conflict, etc.).
514            std::thread::sleep(std::time::Duration::from_secs(1));
515            if !windows_pid_running(pid)? {
516                let detail = if let Some(owner) = windows_listening_pid(install.port)? {
517                    format!(
518                        "daemon process exited immediately after spawn — port {} is already in use by pid {}",
519                        install.port, owner
520                    )
521                } else {
522                    "daemon process exited immediately after spawn — check config and port availability"
523                        .to_string()
524                };
525                return Err(RoboticusError::Config(detail));
526            }
527            Ok(())
528        }
529        other => Err(RoboticusError::Config(format!(
530            "daemon start not supported on {other}"
531        ))),
532    }
533}
534
535pub fn stop_daemon() -> Result<()> {
536    let os = std::env::consts::OS;
537    match os {
538        "macos" => run_cmd("launchctl", &["unload", &plist_path().to_string_lossy()]),
539        "linux" => run_cmd("systemctl", &["--user", "stop", "roboticus.service"]),
540        "windows" => {
541            let mut install = match read_windows_daemon_marker()? {
542                Some(i) => i,
543                None => return Ok(()),
544            };
545            let pid = install.pid.or(windows_listening_pid(install.port)?);
546            let Some(pid) = pid else {
547                return Ok(());
548            };
549            let pid_s = pid.to_string();
550            if windows_pid_running(pid)? {
551                run_cmd("taskkill", &["/PID", &pid_s, "/T", "/F"])?;
552            }
553            install.pid = None;
554            write_windows_daemon_marker(&install)
555        }
556        other => Err(RoboticusError::Config(format!(
557            "daemon stop not supported on {other}"
558        ))),
559    }
560}
561
562pub fn restart_daemon() -> Result<()> {
563    let os = std::env::consts::OS;
564    match os {
565        "macos" => {
566            if let Err(e) = stop_daemon()
567                && !is_benign_stop_error(&e)
568            {
569                return Err(e);
570            }
571            start_daemon()
572        }
573        "linux" => run_cmd("systemctl", &["--user", "restart", "roboticus.service"]),
574        "windows" => {
575            if let Err(e) = stop_daemon()
576                && !is_benign_stop_error(&e)
577            {
578                return Err(e);
579            }
580            start_daemon()
581        }
582        other => Err(RoboticusError::Config(format!(
583            "daemon restart not supported on {other}"
584        ))),
585    }
586}
587
588const LAUNCHD_LABEL: &str = "com.roboticus.agent";
589
590fn run_cmd(program: &str, args: &[&str]) -> Result<()> {
591    let output = std::process::Command::new(program)
592        .args(args)
593        .output()
594        .map_err(|e| RoboticusError::Config(format!("failed to run {program}: {e}")))?;
595
596    if output.status.success() {
597        Ok(())
598    } else {
599        let stdout = String::from_utf8_lossy(&output.stdout);
600        let stderr = String::from_utf8_lossy(&output.stderr);
601        let detail = if !stderr.trim().is_empty() {
602            stderr.trim().to_string()
603        } else {
604            stdout.trim().to_string()
605        };
606        Err(RoboticusError::Config(format!(
607            "{program} failed (exit {}): {}",
608            output.status.code().unwrap_or(-1),
609            detail
610        )))
611    }
612}
613
614fn command_output(program: &str, args: &[&str]) -> Result<std::process::Output> {
615    std::process::Command::new(program)
616        .args(args)
617        .output()
618        .map_err(|e| RoboticusError::Config(format!("failed to run {program}: {e}")))
619}
620
621fn windows_service_exists() -> Result<bool> {
622    if std::env::consts::OS != "windows" {
623        return Ok(false);
624    }
625    let marker = windows_service_marker_path();
626    Ok(marker.exists())
627}
628
629pub fn daemon_status() -> Result<String> {
630    match std::env::consts::OS {
631        "macos" => {
632            if !is_installed() {
633                return Ok("Daemon not installed".into());
634            }
635            match command_output("launchctl", &["list", LAUNCHD_LABEL]) {
636                Ok(out) if out.status.success() => {
637                    let stdout = String::from_utf8_lossy(&out.stdout);
638                    if stdout.contains("\"PID\"") {
639                        Ok("Daemon running (launchd loaded)".into())
640                    } else {
641                        Ok("Daemon installed but not running".into())
642                    }
643                }
644                Ok(_) => Ok("Daemon installed but not running".into()),
645                Err(e) => Err(e),
646            }
647        }
648        "linux" => {
649            if !is_installed() {
650                return Ok("Daemon not installed".into());
651            }
652            let out = command_output("systemctl", &["--user", "is-active", "roboticus.service"])?;
653            if out.status.success() {
654                Ok("Daemon running (systemd active)".into())
655            } else {
656                Ok("Daemon installed but not running".into())
657            }
658        }
659        "windows" => {
660            if !windows_service_exists()? {
661                return Ok("Daemon not installed".into());
662            }
663            let install = read_windows_daemon_marker()?;
664            match install {
665                Some(i) => {
666                    if let Some(pid) = i.pid
667                        && windows_pid_running(pid)?
668                    {
669                        return Ok(format!("Daemon running (Windows process pid={pid})"));
670                    }
671                    Ok("Daemon installed but stopped (Windows user process)".into())
672                }
673                None => Ok("Daemon not installed".into()),
674            }
675        }
676        other => Ok(format!("Daemon status unsupported on {other}")),
677    }
678}
679
680fn verify_launchd_running() -> Result<()> {
681    let output = std::process::Command::new("launchctl")
682        .args(["list", LAUNCHD_LABEL])
683        .output()
684        .map_err(|e| RoboticusError::Config(format!("failed to query launchctl: {e}")))?;
685
686    if !output.status.success() {
687        return Err(RoboticusError::Config(
688            "daemon service is not loaded — check the plist path and binary".into(),
689        ));
690    }
691
692    let stdout = String::from_utf8_lossy(&output.stdout);
693    for line in stdout.lines() {
694        let trimmed = line.trim();
695        if let Some(rest) = trimmed.strip_prefix("\"LastExitStatus\"") {
696            let code = rest
697                .trim_start_matches(|c: char| !c.is_ascii_digit() && c != '-')
698                .trim_end_matches(';')
699                .trim();
700            if code != "0" {
701                let stderr_path = home_dir().join(".roboticus/logs/roboticus.stderr.log");
702                let hint = if stderr_path.exists() {
703                    format!(" (see {})", stderr_path.display())
704                } else {
705                    String::new()
706                };
707                return Err(RoboticusError::Config(format!(
708                    "daemon exited immediately with code {code}{hint}"
709                )));
710            }
711        }
712    }
713
714    for line in stdout.lines() {
715        let trimmed = line.trim();
716        if let Some(rest) = trimmed.strip_prefix("\"PID\"") {
717            let pid = rest
718                .trim_start_matches(|c: char| !c.is_ascii_digit())
719                .trim_end_matches(';')
720                .trim();
721            if !pid.is_empty() {
722                return Ok(());
723            }
724        }
725    }
726
727    Err(RoboticusError::Config(
728        "daemon loaded but no PID found — service may have crashed on startup".into(),
729    ))
730}
731
732fn is_benign_stop_error(e: &RoboticusError) -> bool {
733    let msg = e.to_string().to_ascii_lowercase();
734    msg.contains("1062")
735        || msg.contains("service has not been started")
736        || msg.contains("the service has not been started")
737        || msg.contains("inactive")
738        || msg.contains("not loaded")
739}
740
741fn is_installed_result() -> Result<bool> {
742    let os = std::env::consts::OS;
743    if os == "windows" {
744        return windows_service_exists();
745    }
746    let path = match os {
747        "macos" => plist_path(),
748        "linux" => systemd_path(),
749        _ => return Ok(false),
750    };
751    Ok(path.exists())
752}
753
754pub fn is_installed() -> bool {
755    is_installed_result().unwrap_or(false)
756}
757
758pub fn uninstall_daemon() -> Result<()> {
759    if !is_installed_result()? {
760        return Ok(());
761    }
762    if let Err(e) = stop_daemon()
763        && !is_benign_stop_error(&e)
764    {
765        return Err(e);
766    }
767    if std::env::consts::OS == "windows" {
768        cleanup_legacy_windows_service();
769        // Remove the logon Task Scheduler entry if present (best-effort on uninstall)
770        let schtasks_del = std::process::Command::new("schtasks")
771            .args(["/Delete", "/TN", "RoboticusAgent", "/F"])
772            .output();
773        if let Ok(out) = schtasks_del
774            && !out.status.success()
775        {
776            let stderr = String::from_utf8_lossy(&out.stderr);
777            // Ignore "task does not exist" — it may never have been registered
778            if !stderr.to_ascii_lowercase().contains("does not exist")
779                && !stderr.to_ascii_lowercase().contains("cannot find")
780            {
781                return Err(RoboticusError::Config(format!(
782                    "schtasks /Delete failed (exit {}): {}",
783                    out.status.code().unwrap_or(-1),
784                    stderr.trim()
785                )));
786            }
787        }
788        let marker = windows_service_marker_path();
789        if marker.exists()
790            && let Err(e) = std::fs::remove_file(&marker)
791            && e.kind() != std::io::ErrorKind::NotFound
792        {
793            return Err(RoboticusError::Config(format!(
794                "failed to remove windows service marker {}: {e}",
795                marker.display()
796            )));
797        }
798        return Ok(());
799    }
800    let path = match std::env::consts::OS {
801        "macos" => plist_path(),
802        "linux" => systemd_path(),
803        _ => return Ok(()),
804    };
805    std::fs::remove_file(&path)?;
806    Ok(())
807}
808
809pub fn write_pid_file(path: &Path) -> Result<()> {
810    let pid = std::process::id();
811    std::fs::write(path, pid.to_string())?;
812    Ok(())
813}
814
815pub fn read_pid_file(path: &Path) -> Result<Option<u32>> {
816    if !path.exists() {
817        return Ok(None);
818    }
819    let contents = std::fs::read_to_string(path)?;
820    let pid = contents
821        .trim()
822        .parse::<u32>()
823        .map_err(|e| RoboticusError::Config(format!("invalid PID file: {e}")))?;
824    Ok(Some(pid))
825}
826
827pub fn remove_pid_file(path: &Path) -> Result<()> {
828    if path.exists() {
829        std::fs::remove_file(path)?;
830    }
831    Ok(())
832}
833
834#[cfg(test)]
835mod tests {
836    use super::*;
837    use crate::test_support::EnvGuard;
838
839    #[test]
840    fn launchd_plist_format() {
841        let plist = launchd_plist("/usr/local/bin/roboticus", "/etc/roboticus.toml", 18789);
842        assert!(plist.contains("com.roboticus.agent"));
843        assert!(plist.contains("/usr/local/bin/roboticus"));
844        assert!(plist.contains("/etc/roboticus.toml"));
845        assert!(plist.contains("18789"));
846        assert!(plist.contains("KeepAlive"));
847    }
848
849    #[test]
850    fn systemd_unit_format() {
851        let unit = systemd_unit("/usr/local/bin/roboticus", "/etc/roboticus.toml", 18789);
852        assert!(unit.contains("ExecStart="));
853        assert!(unit.contains("/usr/local/bin/roboticus"));
854        assert!(unit.contains("Restart=on-failure"));
855        assert!(unit.contains("[Install]"));
856    }
857
858    #[test]
859    fn daemon_paths_are_derived_from_home() {
860        let home = "/tmp/roboticus-home";
861        assert_eq!(
862            plist_path_for(home),
863            PathBuf::from("/tmp/roboticus-home/Library/LaunchAgents/com.roboticus.agent.plist")
864        );
865        assert_eq!(
866            systemd_path_for(home),
867            PathBuf::from("/tmp/roboticus-home/.config/systemd/user/roboticus.service")
868        );
869    }
870
871    #[test]
872    fn pid_file_roundtrip() {
873        let dir = tempfile::tempdir().unwrap();
874        let pid_path = dir.path().join("test.pid");
875
876        write_pid_file(&pid_path).unwrap();
877        let pid = read_pid_file(&pid_path).unwrap();
878        assert!(pid.is_some());
879        assert_eq!(pid.unwrap(), std::process::id());
880
881        remove_pid_file(&pid_path).unwrap();
882        assert!(!pid_path.exists());
883    }
884
885    #[test]
886    fn read_missing_pid_file() {
887        let result = read_pid_file(Path::new("/nonexistent/pid"));
888        assert!(result.is_ok());
889        assert!(result.unwrap().is_none());
890    }
891
892    #[test]
893    fn remove_missing_pid_file() {
894        let result = remove_pid_file(Path::new("/nonexistent/pid"));
895        assert!(result.is_ok());
896    }
897
898    #[test]
899    fn plist_path_is_under_launch_agents() {
900        let path = plist_path();
901        let path_str = path.to_string_lossy();
902        assert!(path_str.contains("LaunchAgents"));
903        assert!(path_str.ends_with("com.roboticus.agent.plist"));
904    }
905
906    #[test]
907    fn systemd_path_is_under_systemd_user() {
908        let path = systemd_path();
909        let path_str = path.to_string_lossy();
910        assert!(path_str.contains("systemd/user"));
911        assert!(path_str.ends_with("roboticus.service"));
912    }
913
914    #[test]
915    fn read_pid_file_with_invalid_content_returns_error() {
916        let dir = tempfile::tempdir().unwrap();
917        let pid_path = dir.path().join("bad.pid");
918        std::fs::write(&pid_path, "not-a-number").unwrap();
919        assert!(read_pid_file(&pid_path).is_err());
920    }
921
922    #[test]
923    fn read_pid_file_with_whitespace_trims() {
924        let dir = tempfile::tempdir().unwrap();
925        let pid_path = dir.path().join("ws.pid");
926        std::fs::write(&pid_path, "  12345  \n").unwrap();
927        let result = read_pid_file(&pid_path).unwrap();
928        assert_eq!(result, Some(12345));
929    }
930
931    #[test]
932    fn launchd_plist_is_valid_xml() {
933        let plist = launchd_plist("/usr/bin/roboticus", "/etc/roboticus.toml", 9999);
934        assert!(plist.starts_with("<?xml"));
935        assert!(plist.contains("<plist version=\"1.0\">"));
936        assert!(plist.contains("</plist>"));
937        assert!(plist.contains("<string>9999</string>"));
938        assert!(plist.contains("<string>serve</string>"));
939    }
940
941    #[test]
942    fn systemd_unit_has_required_sections() {
943        let unit = systemd_unit("/usr/bin/roboticus", "/etc/roboticus.toml", 8080);
944        assert!(unit.contains("[Unit]"));
945        assert!(unit.contains("[Service]"));
946        assert!(unit.contains("[Install]"));
947        assert!(unit.contains("ExecStart=/usr/bin/roboticus serve -c /etc/roboticus.toml -p 8080"));
948        assert!(unit.contains("Type=simple"));
949    }
950
951    #[test]
952    fn sc_output_not_found_detection() {
953        assert!(sc_output_is_not_found("OpenService FAILED 1060"));
954        assert!(sc_output_is_not_found(
955            "The specified service does not exist as an installed service."
956        ));
957        assert!(!sc_output_is_not_found("SERVICE_NAME: RoboticusAgent"));
958    }
959
960    #[test]
961    fn sc_output_not_active_detection() {
962        assert!(sc_output_is_not_active("ControlService FAILED 1062"));
963        assert!(sc_output_is_not_active("The service has not been started."));
964        assert!(!sc_output_is_not_active("STATE              : 4  RUNNING"));
965    }
966
967    #[test]
968    fn install_daemon_creates_file() {
969        if std::env::consts::OS == "windows" {
970            return;
971        }
972        let dir = tempfile::tempdir().unwrap();
973        let home = dir.path().to_str().unwrap();
974        let bin = dir.path().join("roboticus");
975        std::fs::write(&bin, "").unwrap();
976        let cfg = dir.path().join("roboticus.toml");
977        std::fs::write(&cfg, "").unwrap();
978
979        let result = install_daemon_to(bin.to_str().unwrap(), cfg.to_str().unwrap(), 18789, home);
980        assert!(result.is_ok());
981        let path = result.unwrap();
982        assert!(path.exists());
983    }
984
985    #[test]
986    fn write_and_read_pid_roundtrip() {
987        let dir = tempfile::tempdir().unwrap();
988        let pid_path = dir.path().join("test.pid");
989        write_pid_file(&pid_path).unwrap();
990        assert!(pid_path.exists());
991        let pid = read_pid_file(&pid_path).unwrap().unwrap();
992        assert_eq!(pid, std::process::id());
993        remove_pid_file(&pid_path).unwrap();
994        assert!(!pid_path.exists());
995    }
996
997    #[test]
998    fn parse_windows_daemon_marker_basic() {
999        let input = "name=RoboticusAgent\nmode=user_process\nbinary=C:\\x\\roboticus.exe\nconfig=C:\\x\\roboticus.toml\nport=18789\npid=1234\n";
1000        let parsed = parse_windows_daemon_marker(input).unwrap();
1001        assert_eq!(parsed.binary, "C:\\x\\roboticus.exe");
1002        assert_eq!(parsed.config, "C:\\x\\roboticus.toml");
1003        assert_eq!(parsed.port, 18789);
1004        assert_eq!(parsed.pid, Some(1234));
1005    }
1006
1007    #[test]
1008    fn parse_windows_daemon_marker_without_pid() {
1009        let input = "name=RoboticusAgent\nmode=user_process\nbinary=C:\\x\\roboticus.exe\nconfig=C:\\x\\roboticus.toml\nport=18789\n";
1010        let parsed = parse_windows_daemon_marker(input).unwrap();
1011        assert_eq!(parsed.binary, "C:\\x\\roboticus.exe");
1012        assert_eq!(parsed.config, "C:\\x\\roboticus.toml");
1013        assert_eq!(parsed.port, 18789);
1014        assert_eq!(parsed.pid, None);
1015    }
1016
1017    #[test]
1018    fn parse_windows_daemon_marker_rejects_missing_required_fields() {
1019        let missing_binary =
1020            "name=RoboticusAgent\nmode=user_process\nconfig=C:\\x\\roboticus.toml\nport=18789\n";
1021        assert!(parse_windows_daemon_marker(missing_binary).is_none());
1022        let missing_port = "name=RoboticusAgent\nmode=user_process\nbinary=C:\\x\\roboticus.exe\nconfig=C:\\x\\roboticus.toml\n";
1023        assert!(parse_windows_daemon_marker(missing_port).is_none());
1024    }
1025
1026    #[test]
1027    fn write_and_read_windows_daemon_marker_roundtrip() {
1028        let dir = tempfile::tempdir().unwrap();
1029        let home = dir.path().to_string_lossy().into_owned();
1030        let _home = EnvGuard::set("HOME", &home);
1031
1032        let install = WindowsDaemonInstall {
1033            binary: "C:\\roboticus\\roboticus.exe".into(),
1034            config: "C:\\roboticus\\roboticus.toml".into(),
1035            port: 18789,
1036            pid: Some(4242),
1037        };
1038
1039        write_windows_daemon_marker(&install).unwrap();
1040        let loaded = read_windows_daemon_marker()
1041            .unwrap()
1042            .expect("marker exists");
1043        assert_eq!(loaded.binary, install.binary);
1044        assert_eq!(loaded.config, install.config);
1045        assert_eq!(loaded.port, install.port);
1046        assert_eq!(loaded.pid, install.pid);
1047    }
1048
1049    #[test]
1050    fn read_windows_daemon_marker_returns_none_when_missing() {
1051        let dir = tempfile::tempdir().unwrap();
1052        let home = dir.path().to_string_lossy().into_owned();
1053        let _home = EnvGuard::set("HOME", &home);
1054        assert!(read_windows_daemon_marker().unwrap().is_none());
1055    }
1056
1057    #[test]
1058    fn benign_stop_errors_are_classified() {
1059        let err = RoboticusError::Config("service has not been started".into());
1060        assert!(is_benign_stop_error(&err));
1061        let err = RoboticusError::Config("not loaded".into());
1062        assert!(is_benign_stop_error(&err));
1063        let err = RoboticusError::Config("permission denied".into());
1064        assert!(!is_benign_stop_error(&err));
1065    }
1066
1067    #[test]
1068    fn run_cmd_reports_missing_program_with_context() {
1069        let err = run_cmd("definitely-not-a-real-command", &[]).expect_err("missing command");
1070        assert!(
1071            err.to_string()
1072                .contains("failed to run definitely-not-a-real-command")
1073        );
1074    }
1075
1076    #[test]
1077    fn command_output_reports_missing_program_with_context() {
1078        let err =
1079            command_output("definitely-not-a-real-command", &[]).expect_err("missing command");
1080        assert!(
1081            err.to_string()
1082                .contains("failed to run definitely-not-a-real-command")
1083        );
1084    }
1085
1086    #[test]
1087    fn install_daemon_to_writes_expected_platform_content() {
1088        if std::env::consts::OS == "windows" {
1089            return;
1090        }
1091
1092        let dir = tempfile::tempdir().unwrap();
1093        let home = dir.path().to_str().unwrap();
1094        let bin = dir.path().join("roboticus");
1095        let cfg = dir.path().join("roboticus.toml");
1096        std::fs::write(&bin, "").unwrap();
1097        std::fs::write(&cfg, "").unwrap();
1098
1099        let path = install_daemon_to(bin.to_str().unwrap(), cfg.to_str().unwrap(), 18789, home)
1100            .expect("install daemon file");
1101        let contents = std::fs::read_to_string(path).unwrap();
1102        assert!(contents.contains(bin.to_str().unwrap()));
1103        assert!(contents.contains(cfg.to_str().unwrap()));
1104        assert!(contents.contains("18789"));
1105    }
1106
1107    #[test]
1108    fn fresh_home_reports_not_installed() {
1109        let dir = tempfile::tempdir().unwrap();
1110        let home = dir.path().to_string_lossy().into_owned();
1111        let _home = EnvGuard::set("HOME", &home);
1112
1113        assert!(!is_installed());
1114        let status = daemon_status().unwrap();
1115        assert!(status.to_ascii_lowercase().contains("not installed"));
1116    }
1117
1118    #[test]
1119    fn uninstall_daemon_is_noop_when_not_installed() {
1120        let dir = tempfile::tempdir().unwrap();
1121        let home = dir.path().to_string_lossy().into_owned();
1122        let _home = EnvGuard::set("HOME", &home);
1123        uninstall_daemon().unwrap();
1124    }
1125}