Skip to main content

supercode_harness/
orchestrator.rs

1//! The orchestrator's home, its daemon lease, and the service unit the
2//! operator verbs print (ORC-7).
3//!
4//! supercode does not perform orchestration; the `supercode-orchestrator`
5//! package does (`docs/ORCHESTRATOR-IR.md` §0.1). This module holds the three
6//! facts supercode's own surfaces need about it:
7//!
8//! * **where its state lives** — `SUPERCODE_ORCHESTRATOR_HOME`, default
9//!   `~/.supercode/orchestrator`, resolved once in
10//!   [`crate::HarnessHomes::orchestrator`]; the root folder IS the `default`
11//!   profile and `profiles/<name>/` are the named ones
12//!   (`docs/ORCHESTRATOR-IR.md` §6).
13//! * **whether the daemon is up** — the lease file `<home>/orchestrator.lock`,
14//!   plus a liveness signal on the pid it names. A lock whose process is gone
15//!   is stale, never "up".
16//! * **what a service unit for it would say, and whether it is installed** —
17//!   [`service_unit`] renders the launchd plist / systemd unit `setup` writes
18//!   under `<home>/service/`; [`install_service`], [`uninstall_service`] and
19//!   [`service_status`] drive `launchctl` / `systemctl --user` over it.
20//!
21//! The lease FILE is written by the daemon itself (`bin/orchestrator.mjs`), not
22//! by this crate: one writer means a service-managed daemon reports the same
23//! lease a foreground one does, and a lock left by a process that is gone is
24//! the daemon's own signal to replay (§4.7).
25//!
26//! Reading the folder is every existing ORCH reader's job: `jobs`, `runs`,
27//! `routes`, `channels`, `triggers`, `profiles` and session discovery point
28//! their Hermes-shaped code paths at these same profile folders.
29
30use std::path::{Path, PathBuf};
31
32use serde::{Deserialize, Serialize};
33
34/// Lease file the daemon writes while it serves a home, relative to the home.
35pub const LOCK_FILE: &str = "orchestrator.lock";
36
37/// Directory `setup` writes the rendered service unit into.
38pub const SERVICE_DIR: &str = "service";
39
40/// The daemon entry inside the `sdk/orchestrator` package.
41pub const DAEMON_ENTRY: &str = "bin/orchestrator.mjs";
42
43/// launchd label / systemd unit name for the orchestrator daemon.
44pub const SERVICE_NAME: &str = "ai.volter.supercode.orchestrator";
45
46/// The service label for one home: [`SERVICE_NAME`] with a stable hash of the home's path, so two homes on one
47/// machine are two services (FNV-1a: a stable label, not a secret).
48pub fn service_name(root: &Path) -> String {
49    let mut hash = 0xcbf29ce484222325_u64;
50    for byte in root.to_string_lossy().bytes() {
51        hash ^= u64::from(byte);
52        hash = hash.wrapping_mul(0x100000001b3);
53    }
54    format!("{SERVICE_NAME}-{hash:016x}")
55}
56
57/// What a unit's daemon (and every worker it starts) runs with: the installing shell's `PATH`, where the harness
58/// CLIs are (launchd and systemd start a unit with a minimal one), and `HOME`.
59fn unit_environment() -> (String, String) {
60    let path = std::env::var("PATH")
61        .ok()
62        .filter(|path| !path.trim().is_empty())
63        .unwrap_or_else(|| "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin".into());
64    (path, std::env::var("HOME").unwrap_or_default())
65}
66
67/// The lease `<home>/orchestrator.lock` holds: which process is serving this
68/// home, and since when.
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct Lease {
71    /// Daemon process id.
72    pub pid: u32,
73    /// RFC3339 instant the daemon recorded at startup.
74    pub started_at: String,
75    /// The home the daemon was started against.
76    pub root: PathBuf,
77    /// The machine the daemon runs on. A home on a volume another machine
78    /// mounted names that machine's pid, which says nothing about this one.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub host: Option<String>,
81    /// That machine's boot (Linux's `boot_id`), where it has one: a pid from
82    /// before a restart names nothing now.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub boot: Option<String>,
85}
86
87impl Lease {
88    /// Whether the daemon this lease names is alive: on this machine, since
89    /// this boot, and its pid a live process. A lease from elsewhere is never
90    /// live here, so nothing here is signalled on its word.
91    pub fn is_live(&self) -> bool {
92        let here = this_host();
93        if self
94            .host
95            .as_deref()
96            .is_some_and(|host| Some(host) != here.0.as_deref())
97        {
98            return false;
99        }
100        if let (Some(boot), Some(now)) = (self.boot.as_deref(), here.1.as_deref()) {
101            if boot != now {
102                return false;
103            }
104        }
105        pid_is_live(self.pid)
106    }
107}
108
109/// This machine's name and, on Linux, its boot id: what a lease is compared with.
110pub fn this_host() -> (Option<String>, Option<String>) {
111    let boot = std::fs::read_to_string("/proc/sys/kernel/random/boot_id")
112        .ok()
113        .map(|text| text.trim().to_string())
114        .filter(|text| !text.is_empty());
115    (hostname(), boot)
116}
117
118fn hostname() -> Option<String> {
119    #[cfg(unix)]
120    {
121        let mut buffer = [0u8; 256];
122        // SAFETY: the buffer outlives the call and its length is passed.
123        let status = unsafe { libc::gethostname(buffer.as_mut_ptr().cast(), buffer.len()) };
124        if status != 0 {
125            return None;
126        }
127        let end = buffer
128            .iter()
129            .position(|byte| *byte == 0)
130            .unwrap_or(buffer.len());
131        String::from_utf8(buffer[..end].to_vec())
132            .ok()
133            .filter(|name| !name.is_empty())
134    }
135    #[cfg(not(unix))]
136    {
137        std::env::var("COMPUTERNAME").ok()
138    }
139}
140
141/// Why an operator verb could not do its work.
142#[derive(Debug, thiserror::Error)]
143pub enum OrchestratorError {
144    /// No lease file, or one that no longer names a live process.
145    #[error("the orchestrator is not running for `{0}` (no live lease at `{1}`)", root.display(), lock.display())]
146    NotRunning {
147        /// The home that was asked about.
148        root: PathBuf,
149        /// Where its lease would be.
150        lock: PathBuf,
151    },
152    /// A lease exists and its process is alive.
153    #[error("the orchestrator is already running for `{}` (pid {pid})", root.display())]
154    AlreadyRunning {
155        /// The home that was asked about.
156        root: PathBuf,
157        /// The live daemon's pid.
158        pid: u32,
159    },
160    /// The Node daemon entry could not be located.
161    #[error("no orchestrator daemon entry found (looked for `{DAEMON_ENTRY}` under: {searched})")]
162    NoDaemonEntry {
163        /// The candidate roots that were searched, joined.
164        searched: String,
165    },
166    /// The lease file could not be read or written.
167    #[error("orchestrator lease `{}`: {source}", path.display())]
168    Lease {
169        /// The lease path.
170        path: PathBuf,
171        /// The underlying I/O failure.
172        source: std::io::Error,
173    },
174    /// A service manager refused, or there is none on this platform.
175    #[error("orchestrator service: {action} failed: {detail}")]
176    Service {
177        /// What was attempted (`install`, `uninstall`).
178        action: &'static str,
179        /// What the service manager (or this module) said about it.
180        detail: String,
181    },
182}
183
184/// The lease path for one home.
185pub fn lock_path(root: &Path) -> PathBuf {
186    root.join(LOCK_FILE)
187}
188
189/// Read the lease, whether or not its process is still alive.
190pub fn read_lease(root: &Path) -> Option<Lease> {
191    let text = std::fs::read_to_string(lock_path(root)).ok()?;
192    serde_json::from_str(&text).ok()
193}
194
195/// Write the lease for a running daemon.
196pub fn write_lease(root: &Path, lease: &Lease) -> Result<(), OrchestratorError> {
197    let path = lock_path(root);
198    if let Some(parent) = path.parent() {
199        std::fs::create_dir_all(parent).map_err(|source| OrchestratorError::Lease {
200            path: path.clone(),
201            source,
202        })?;
203    }
204    let text = serde_json::to_string_pretty(lease).unwrap_or_default();
205    std::fs::write(&path, format!("{text}\n")).map_err(|source| OrchestratorError::Lease {
206        path: path.clone(),
207        source,
208    })
209}
210
211/// Remove the lease file. A missing file is not an error — `stop` is
212/// idempotent by design.
213pub fn clear_lease(root: &Path) -> Result<(), OrchestratorError> {
214    let path = lock_path(root);
215    match std::fs::remove_file(&path) {
216        Ok(()) => Ok(()),
217        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
218        Err(source) => Err(OrchestratorError::Lease { path, source }),
219    }
220}
221
222/// Whether `pid` is a live process this user can signal.
223///
224/// `kill(pid, 0)` is the liveness question POSIX answers without touching the
225/// process. On a non-unix target there is no equivalent that does not start
226/// something, so the lease alone is the answer.
227pub fn pid_is_live(pid: u32) -> bool {
228    #[cfg(unix)]
229    {
230        if pid == 0 {
231            return false;
232        }
233        // SAFETY: signal 0 performs error checking only; it delivers nothing.
234        unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
235    }
236    #[cfg(not(unix))]
237    {
238        let _ = pid;
239        true
240    }
241}
242
243/// The lease of a daemon that is actually alive right now.
244pub fn live_lease(root: &Path) -> Option<Lease> {
245    read_lease(root).filter(Lease::is_live)
246}
247
248/// Ask the daemon to stop: SIGTERM to the leased pid, then clear the lease.
249///
250/// The daemon's own SIGTERM handler is what closes its adapters; this never
251/// escalates to SIGKILL and never signals anything but the pid the lease
252/// names.
253pub fn stop(root: &Path) -> Result<Lease, OrchestratorError> {
254    let Some(lease) = live_lease(root) else {
255        return Err(OrchestratorError::NotRunning {
256            root: root.to_path_buf(),
257            lock: lock_path(root),
258        });
259    };
260    #[cfg(unix)]
261    // SAFETY: the pid comes from this home's own lease and is known live.
262    unsafe {
263        libc::kill(lease.pid as libc::pid_t, libc::SIGTERM);
264    }
265    clear_lease(root)?;
266    Ok(lease)
267}
268
269/// Locate the Node daemon entry (`sdk/orchestrator/bin/orchestrator.mjs`).
270///
271/// Candidates, in order: `SUPERCODE_ORCHESTRATOR_ENTRY` (an explicit
272/// override, which is also how a test points at a fake), the repo checkout
273/// the running binary sits in, the checkout it was built from, and the
274/// published package's `supercode-orchestrator` command on PATH (what `npm
275/// install -g @volter/supercode-orchestrator` puts there), as the Teams entry
276/// is found. A binary built from a checkout runs that checkout's daemon, never
277/// an older installed one. The current directory is never a candidate: the
278/// code a binary runs does not change with where it is run.
279pub fn daemon_entry() -> Result<PathBuf, OrchestratorError> {
280    let mut searched = Vec::new();
281    if let Some(explicit) = std::env::var_os("SUPERCODE_ORCHESTRATOR_ENTRY") {
282        let path = PathBuf::from(explicit);
283        if path.is_file() {
284            return Ok(path);
285        }
286        searched.push(path.display().to_string());
287    }
288    let mut roots: Vec<PathBuf> = Vec::new();
289    if let Ok(exe) = std::env::current_exe() {
290        // target/<profile>/supercode → the workspace root is two levels up.
291        roots.extend(exe.ancestors().skip(1).take(4).map(Path::to_path_buf));
292    }
293    // A locally built binary's target directory can live anywhere (a shared
294    // cargo build dir, another volume), so the checkout it was built from
295    // follows. On an installed binary this path simply does not exist and is
296    // skipped like any other miss.
297    if let Some(workspace) = Path::new(env!("CARGO_MANIFEST_DIR")).ancestors().nth(2) {
298        roots.push(workspace.to_path_buf());
299    }
300    for root in roots {
301        let candidate = root.join("sdk/orchestrator").join(DAEMON_ENTRY);
302        if candidate.is_file() {
303            return Ok(candidate);
304        }
305        searched.push(candidate.display().to_string());
306    }
307    // An installed binary has no checkout beside it: the orchestrator is its
308    // own package, and npm links its daemon entry onto PATH by that name.
309    for dir in std::env::var_os("PATH")
310        .iter()
311        .flat_map(std::env::split_paths)
312    {
313        let command = dir.join("supercode-orchestrator");
314        if let Ok(entry) = std::fs::canonicalize(&command) {
315            if entry.is_file() && entry.ends_with(DAEMON_ENTRY) {
316                return Ok(entry);
317            }
318        }
319    }
320    searched.push("supercode-orchestrator on PATH".to_string());
321    Err(OrchestratorError::NoDaemonEntry {
322        searched: searched.join(", "),
323    })
324}
325
326/// A rendered service unit: what `setup` prints and writes.
327#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
328pub struct ServiceUnit {
329    /// `launchd`, `systemd` or `schtasks`.
330    pub kind: &'static str,
331    /// Where `setup` writes the rendered text under `<home>/service/`.
332    pub path: PathBuf,
333    /// The unit text itself.
334    pub text: String,
335    /// The command an operator (or ORC-10) runs to install it.
336    pub install_command: String,
337    /// Files the unit reads, written beside it (a Windows task's environment file).
338    #[serde(skip)]
339    pub files: Vec<(PathBuf, String)>,
340}
341
342/// Render the per-platform service unit for one home.
343///
344/// Nothing is installed here: ORC-10 owns installation. `setup` writes this
345/// text under `<home>/service/` so the operator can read exactly what would
346/// be installed before anything registers a daemon.
347pub fn service_unit(root: &Path, entry: &Path, node: &str) -> ServiceUnit {
348    let root_display = root.display().to_string();
349    let entry_display = entry.display().to_string();
350    let name = service_name(root);
351    let (env_path, env_home) = unit_environment();
352    if cfg!(target_os = "macos") {
353        let path = root.join(SERVICE_DIR).join(format!("{name}.plist"));
354        let text = format!(
355            r#"<?xml version="1.0" encoding="UTF-8"?>
356<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
357<plist version="1.0">
358<dict>
359  <key>Label</key><string>{name}</string>
360  <key>EnvironmentVariables</key>
361  <dict>
362    <key>PATH</key><string>{env_path}</string>
363    <key>HOME</key><string>{env_home}</string>
364  </dict>
365  <key>ProgramArguments</key>
366  <array>
367    <string>{node}</string>
368    <string>{entry_display}</string>
369    <string>--root</string>
370    <string>{root_display}</string>
371  </array>
372  <key>RunAtLoad</key><true/>
373  <key>KeepAlive</key><true/>
374  <key>StandardOutPath</key><string>{root_display}/service/orchestrator.out.log</string>
375  <key>StandardErrorPath</key><string>{root_display}/service/orchestrator.err.log</string>
376</dict>
377</plist>
378"#
379        );
380        let install = format!("launchctl bootstrap gui/$(id -u) {}", path.display());
381        ServiceUnit {
382            kind: "launchd",
383            path,
384            text,
385            install_command: install,
386            files: Vec::new(),
387        }
388    } else {
389        let path = root.join(SERVICE_DIR).join(format!("{name}.service"));
390        let text = format!(
391            "[Unit]\n\
392             Description=supercode orchestrator ({root_display})\n\
393             After=network.target\n\
394             \n\
395             [Service]\n\
396             Environment=PATH={env_path}\n\
397             Environment=HOME={env_home}\n\
398             ExecStart={node} {entry_display} --root {root_display}\n\
399             Restart=on-failure\n\
400             KillSignal=SIGTERM\n\
401             \n\
402             [Install]\n\
403             WantedBy=default.target\n"
404        );
405        let install = format!(
406            "systemctl --user link {} && systemctl --user enable --now {name}",
407            path.display()
408        );
409        ServiceUnit {
410            kind: "systemd",
411            path,
412            text,
413            install_command: install,
414            files: Vec::new(),
415        }
416    }
417}
418
419/// What the platform's service manager says about the orchestrator unit.
420#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
421pub struct ServiceState {
422    /// `launchd`, `systemd`, or `none` where neither is available.
423    pub kind: &'static str,
424    /// The label / unit name asked about.
425    pub label: String,
426    /// Whether the service manager holds the unit at all.
427    pub installed: bool,
428    /// The daemon's pid, where the service manager knows it.
429    pub pid: Option<u32>,
430    /// The manager's own words, or why the question could not be asked.
431    pub detail: String,
432}
433
434/// launchd and systemd start a unit with a minimal `PATH`, so the unit names
435/// the interpreter absolutely wherever one can be resolved.
436pub fn absolute_program(program: &str) -> String {
437    resolve_program(program).display().to_string()
438}
439
440/// `program` found on `PATH`, or `program` unchanged when it is a path or is not found. On Windows a bare name
441/// is also tried as `.exe`, `.cmd` and `.bat`: `Command::new("npm")` finds only `npm.exe`, and npm, Claude Code
442/// and Codex installed through npm are `.cmd` shims.
443pub fn resolve_program(program: &str) -> PathBuf {
444    resolve_program_in(
445        program,
446        std::env::var_os("PATH").as_deref(),
447        std::env::var("PATHEXT").ok().as_deref(),
448        cfg!(windows),
449    )
450}
451
452/// [`resolve_program`] over a given search path. On Windows only what PATHEXT names runs, in its order: npm
453/// installs an extensionless shell script beside each `.cmd` shim, and that script fails to start (os error
454/// 193). A name that already has an extension is taken as is.
455fn resolve_program_in(
456    program: &str,
457    path: Option<&std::ffi::OsStr>,
458    pathext: Option<&str>,
459    windows: bool,
460) -> PathBuf {
461    if program.contains('/') || (windows && program.contains('\\')) {
462        return PathBuf::from(program);
463    }
464    let extensions: Vec<String> = if windows && Path::new(program).extension().is_none() {
465        pathext
466            .unwrap_or(".COM;.EXE;.BAT;.CMD")
467            .split(';')
468            .filter(|extension| !extension.is_empty())
469            .map(str::to_ascii_lowercase)
470            .collect()
471    } else {
472        vec![String::new()]
473    };
474    for dir in path.map(std::env::split_paths).into_iter().flatten() {
475        for extension in &extensions {
476            let candidate = dir.join(format!("{program}{extension}"));
477            if candidate.is_file() {
478                return candidate;
479            }
480        }
481    }
482    PathBuf::from(program)
483}
484
485/// Run a service-manager command and return (success, stdout+stderr).
486/// A rendered unit's label: its file's name without the extension (`<label>.plist`, `<label>.service`).
487#[allow(dead_code)]
488fn unit_label(unit: &ServiceUnit) -> String {
489    unit.path
490        .file_stem()
491        .map(|stem| stem.to_string_lossy().into_owned())
492        .unwrap_or_default()
493}
494
495fn run_tool(program: &str, args: &[&str]) -> Result<(bool, String), std::io::Error> {
496    let output = std::process::Command::new(program).args(args).output()?;
497    let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
498    text.push_str(&String::from_utf8_lossy(&output.stderr));
499    Ok((output.status.success(), text.trim().to_string()))
500}
501
502#[cfg(target_os = "macos")]
503fn gui_domain() -> String {
504    // SAFETY: `getuid` reads this process's own real user id and cannot fail.
505    format!("gui/{}", unsafe { libc::getuid() })
506}
507
508/// Ask the platform's service manager about the orchestrator unit.
509///
510/// Never starts or installs anything: `status` calls this on every run.
511pub fn service_status(root: &Path) -> ServiceState {
512    platform_status(root)
513}
514
515#[cfg(target_os = "macos")]
516fn platform_status(root: &Path) -> ServiceState {
517    let label = service_name(root);
518    let target = format!("{}/{label}", gui_domain());
519    match run_tool("launchctl", &["print", &target]) {
520        Ok((true, text)) => ServiceState {
521            kind: "launchd",
522            label,
523            installed: true,
524            pid: field_of(&text, "pid = ").and_then(|value| value.parse().ok()),
525            detail: field_of(&text, "state = ").unwrap_or_else(|| "loaded".into()),
526        },
527        Ok((false, _)) => ServiceState {
528            kind: "launchd",
529            label,
530            installed: false,
531            pid: None,
532            detail: format!("not bootstrapped in {}", gui_domain()),
533        },
534        Err(error) => ServiceState {
535            kind: "launchd",
536            label,
537            installed: false,
538            pid: None,
539            detail: format!("launchctl unavailable: {error}"),
540        },
541    }
542}
543
544#[cfg(all(unix, not(target_os = "macos")))]
545fn platform_status(root: &Path) -> ServiceState {
546    let label = service_name(root);
547    match run_tool("systemctl", &["--user", "is-active", &label]) {
548        Ok((active, text)) => {
549            let known = run_tool("systemctl", &["--user", "is-enabled", &label])
550                .map(|(ok, _)| ok)
551                .unwrap_or(false);
552            ServiceState {
553                kind: "systemd",
554                label,
555                installed: active || known,
556                pid: None,
557                detail: if text.is_empty() {
558                    "unknown".into()
559                } else {
560                    text
561                },
562            }
563        }
564        Err(error) => ServiceState {
565            kind: "systemd",
566            label,
567            installed: false,
568            pid: None,
569            detail: format!("systemctl unavailable: {error}"),
570        },
571    }
572}
573
574#[cfg(not(unix))]
575fn platform_status(root: &Path) -> ServiceState {
576    ServiceState {
577        kind: "none",
578        label: service_name(root),
579        installed: false,
580        pid: None,
581        detail: "no service manager on this platform".into(),
582    }
583}
584
585/// `key = value` out of a service manager's block output.
586#[cfg(target_os = "macos")]
587fn field_of(text: &str, key: &str) -> Option<String> {
588    text.lines()
589        .find_map(|line| line.trim().strip_prefix(key))
590        .map(|value| value.trim().to_string())
591}
592
593/// Render the unit, hand it to the platform's service manager, and start it.
594///
595/// Refuses a label the manager already holds rather than replacing it: two
596/// homes share one label, so an install that silently took it over would point
597/// a running service at a different folder.
598pub fn install_service(
599    root: &Path,
600    entry: &Path,
601    node: &str,
602) -> Result<(ServiceUnit, ServiceState), OrchestratorError> {
603    let existing = service_status(root);
604    if existing.installed {
605        return Err(OrchestratorError::Service {
606            action: "install",
607            detail: format!(
608                "`{}` is already installed ({}); `supercode orchestrator setup --uninstall` first",
609                existing.label, existing.detail
610            ),
611        });
612    }
613    let unit = service_unit(root, entry, &absolute_program(node));
614    write_unit(&unit)?;
615    platform_install(&unit)?;
616    Ok((unit, service_status(root)))
617}
618
619#[cfg(target_os = "macos")]
620fn platform_install(unit: &ServiceUnit) -> Result<(), OrchestratorError> {
621    let path = unit.path.display().to_string();
622    let (ok, text) =
623        run_tool("launchctl", &["bootstrap", &gui_domain(), &path]).map_err(|error| {
624            OrchestratorError::Service {
625                action: "install",
626                detail: format!("launchctl: {error}"),
627            }
628        })?;
629    if !ok {
630        return Err(OrchestratorError::Service {
631            action: "install",
632            detail: format!("launchctl bootstrap {}: {text}", gui_domain()),
633        });
634    }
635    Ok(())
636}
637
638/// Untested on this box (the receipt is macOS); these are the commands
639/// `service_unit` has always printed as its `install_command`.
640#[cfg(all(unix, not(target_os = "macos")))]
641fn platform_install(unit: &ServiceUnit) -> Result<(), OrchestratorError> {
642    let path = unit.path.display().to_string();
643    for args in [
644        vec!["--user", "link", path.as_str()],
645        vec!["--user", "enable", "--now", unit_label(unit).as_str()],
646    ] {
647        let (ok, text) =
648            run_tool("systemctl", &args).map_err(|error| OrchestratorError::Service {
649                action: "install",
650                detail: format!("systemctl: {error}"),
651            })?;
652        if !ok {
653            return Err(OrchestratorError::Service {
654                action: "install",
655                detail: format!("systemctl {}: {text}", args.join(" ")),
656            });
657        }
658    }
659    Ok(())
660}
661
662#[cfg(not(unix))]
663fn platform_install(_unit: &ServiceUnit) -> Result<(), OrchestratorError> {
664    Err(OrchestratorError::Service {
665        action: "install",
666        detail: "no service manager on this platform".into(),
667    })
668}
669
670/// Stop and unregister the unit, and remove the rendered file `setup` wrote.
671///
672/// Idempotent: a unit the manager does not hold is not an error, because the
673/// state the operator asked for is the state they get.
674pub fn uninstall_service(root: &Path) -> Result<ServiceState, OrchestratorError> {
675    platform_uninstall(root)?;
676    let unit_path = root.join(SERVICE_DIR).join(unit_file_name(root));
677    match std::fs::remove_file(&unit_path) {
678        Ok(()) => {}
679        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
680        Err(source) => {
681            return Err(OrchestratorError::Lease {
682                path: unit_path,
683                source,
684            })
685        }
686    }
687    // `launchctl bootout` returns before the job is torn down, so the state
688    // this reports is the settled one, not the manager mid-teardown.
689    let mut state = service_status(root);
690    for _ in 0..40 {
691        if !state.installed {
692            break;
693        }
694        std::thread::sleep(std::time::Duration::from_millis(100));
695        state = service_status(root);
696    }
697    Ok(state)
698}
699
700#[cfg(target_os = "macos")]
701fn platform_uninstall(root: &Path) -> Result<(), OrchestratorError> {
702    let target = format!("{}/{}", gui_domain(), service_name(root));
703    let (ok, text) = run_tool("launchctl", &["bootout", &target]).map_err(|error| {
704        OrchestratorError::Service {
705            action: "uninstall",
706            detail: format!("launchctl: {error}"),
707        }
708    })?;
709    // `bootout` on a label nobody holds says so and exits non-zero.
710    if !ok && !text.contains("No such process") && !text.contains("not find") {
711        return Err(OrchestratorError::Service {
712            action: "uninstall",
713            detail: format!("launchctl bootout {target}: {text}"),
714        });
715    }
716    Ok(())
717}
718
719#[cfg(all(unix, not(target_os = "macos")))]
720fn platform_uninstall(root: &Path) -> Result<(), OrchestratorError> {
721    let _ = run_tool(
722        "systemctl",
723        &["--user", "disable", "--now", &service_name(root)],
724    );
725    Ok(())
726}
727
728#[cfg(not(unix))]
729fn platform_uninstall(_root: &Path) -> Result<(), OrchestratorError> {
730    Ok(())
731}
732
733/// The file name `service_unit` renders for this platform.
734fn unit_file_name(root: &Path) -> String {
735    if cfg!(target_os = "macos") {
736        format!("{}.plist", service_name(root))
737    } else {
738        format!("{}.service", service_name(root))
739    }
740}
741
742/// Write a rendered unit under `<home>/service/`.
743pub fn write_unit(unit: &ServiceUnit) -> Result<(), OrchestratorError> {
744    if let Some(parent) = unit.path.parent() {
745        std::fs::create_dir_all(parent).map_err(|source| OrchestratorError::Lease {
746            path: unit.path.clone(),
747            source,
748        })?;
749    }
750    std::fs::write(&unit.path, &unit.text).map_err(|source| OrchestratorError::Lease {
751        path: unit.path.clone(),
752        source,
753    })
754}