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 published
273/// package's `supercode-orchestrator` command on PATH (what `npm install -g
274/// @volter/supercode-orchestrator` puts there), the repo checkout the
275/// running binary sits in, and the checkout it was built from. The current
276/// directory is never a candidate: the code a binary runs does not change
277/// with where it is run.
278pub fn daemon_entry() -> Result<PathBuf, OrchestratorError> {
279    let mut searched = Vec::new();
280    if let Some(explicit) = std::env::var_os("SUPERCODE_ORCHESTRATOR_ENTRY") {
281        let path = PathBuf::from(explicit);
282        if path.is_file() {
283            return Ok(path);
284        }
285        searched.push(path.display().to_string());
286    }
287    // An installed binary has no checkout beside it: the orchestrator is its
288    // own package, and npm links its daemon entry onto PATH by that name.
289    for dir in std::env::var_os("PATH")
290        .iter()
291        .flat_map(std::env::split_paths)
292    {
293        let command = dir.join("supercode-orchestrator");
294        if let Ok(entry) = std::fs::canonicalize(&command) {
295            if entry.is_file() && entry.ends_with(DAEMON_ENTRY) {
296                return Ok(entry);
297            }
298        }
299    }
300    searched.push("supercode-orchestrator on PATH".to_string());
301    let mut roots: Vec<PathBuf> = Vec::new();
302    if let Ok(exe) = std::env::current_exe() {
303        // target/<profile>/supercode → the workspace root is two levels up.
304        roots.extend(exe.ancestors().skip(1).take(4).map(Path::to_path_buf));
305    }
306    // A locally built binary's target directory can live anywhere (a shared
307    // cargo build dir, another volume), so the checkout it was built from is
308    // the last candidate. On an installed binary this path simply does not
309    // exist and is skipped like any other miss.
310    if let Some(workspace) = Path::new(env!("CARGO_MANIFEST_DIR")).ancestors().nth(2) {
311        roots.push(workspace.to_path_buf());
312    }
313    for root in roots {
314        let candidate = root.join("sdk/orchestrator").join(DAEMON_ENTRY);
315        if candidate.is_file() {
316            return Ok(candidate);
317        }
318        searched.push(candidate.display().to_string());
319    }
320    Err(OrchestratorError::NoDaemonEntry {
321        searched: searched.join(", "),
322    })
323}
324
325/// A rendered service unit: what `setup` prints and writes.
326#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
327pub struct ServiceUnit {
328    /// `launchd`, `systemd` or `schtasks`.
329    pub kind: &'static str,
330    /// Where `setup` writes the rendered text under `<home>/service/`.
331    pub path: PathBuf,
332    /// The unit text itself.
333    pub text: String,
334    /// The command an operator (or ORC-10) runs to install it.
335    pub install_command: String,
336    /// Files the unit reads, written beside it (a Windows task's environment file).
337    #[serde(skip)]
338    pub files: Vec<(PathBuf, String)>,
339}
340
341/// Render the per-platform service unit for one home.
342///
343/// Nothing is installed here: ORC-10 owns installation. `setup` writes this
344/// text under `<home>/service/` so the operator can read exactly what would
345/// be installed before anything registers a daemon.
346pub fn service_unit(root: &Path, entry: &Path, node: &str) -> ServiceUnit {
347    let root_display = root.display().to_string();
348    let entry_display = entry.display().to_string();
349    let name = service_name(root);
350    let (env_path, env_home) = unit_environment();
351    if cfg!(target_os = "macos") {
352        let path = root.join(SERVICE_DIR).join(format!("{name}.plist"));
353        let text = format!(
354            r#"<?xml version="1.0" encoding="UTF-8"?>
355<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
356<plist version="1.0">
357<dict>
358  <key>Label</key><string>{name}</string>
359  <key>EnvironmentVariables</key>
360  <dict>
361    <key>PATH</key><string>{env_path}</string>
362    <key>HOME</key><string>{env_home}</string>
363  </dict>
364  <key>ProgramArguments</key>
365  <array>
366    <string>{node}</string>
367    <string>{entry_display}</string>
368    <string>--root</string>
369    <string>{root_display}</string>
370  </array>
371  <key>RunAtLoad</key><true/>
372  <key>KeepAlive</key><true/>
373  <key>StandardOutPath</key><string>{root_display}/service/orchestrator.out.log</string>
374  <key>StandardErrorPath</key><string>{root_display}/service/orchestrator.err.log</string>
375</dict>
376</plist>
377"#
378        );
379        let install = format!("launchctl bootstrap gui/$(id -u) {}", path.display());
380        ServiceUnit {
381            kind: "launchd",
382            path,
383            text,
384            install_command: install,
385            files: Vec::new(),
386        }
387    } else {
388        let path = root.join(SERVICE_DIR).join(format!("{name}.service"));
389        let text = format!(
390            "[Unit]\n\
391             Description=supercode orchestrator ({root_display})\n\
392             After=network.target\n\
393             \n\
394             [Service]\n\
395             Environment=PATH={env_path}\n\
396             Environment=HOME={env_home}\n\
397             ExecStart={node} {entry_display} --root {root_display}\n\
398             Restart=on-failure\n\
399             KillSignal=SIGTERM\n\
400             \n\
401             [Install]\n\
402             WantedBy=default.target\n"
403        );
404        let install = format!(
405            "systemctl --user link {} && systemctl --user enable --now {name}",
406            path.display()
407        );
408        ServiceUnit {
409            kind: "systemd",
410            path,
411            text,
412            install_command: install,
413            files: Vec::new(),
414        }
415    }
416}
417
418/// What the platform's service manager says about the orchestrator unit.
419#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
420pub struct ServiceState {
421    /// `launchd`, `systemd`, or `none` where neither is available.
422    pub kind: &'static str,
423    /// The label / unit name asked about.
424    pub label: String,
425    /// Whether the service manager holds the unit at all.
426    pub installed: bool,
427    /// The daemon's pid, where the service manager knows it.
428    pub pid: Option<u32>,
429    /// The manager's own words, or why the question could not be asked.
430    pub detail: String,
431}
432
433/// launchd and systemd start a unit with a minimal `PATH`, so the unit names
434/// the interpreter absolutely wherever one can be resolved.
435pub fn absolute_program(program: &str) -> String {
436    resolve_program(program).display().to_string()
437}
438
439/// `program` found on `PATH`, or `program` unchanged when it is a path or is not found. On Windows a bare name
440/// is also tried as `.exe`, `.cmd` and `.bat`: `Command::new("npm")` finds only `npm.exe`, and npm, Claude Code
441/// and Codex installed through npm are `.cmd` shims.
442pub fn resolve_program(program: &str) -> PathBuf {
443    resolve_program_in(
444        program,
445        std::env::var_os("PATH").as_deref(),
446        std::env::var("PATHEXT").ok().as_deref(),
447        cfg!(windows),
448    )
449}
450
451/// [`resolve_program`] over a given search path. On Windows only what PATHEXT names runs, in its order: npm
452/// installs an extensionless shell script beside each `.cmd` shim, and that script fails to start (os error
453/// 193). A name that already has an extension is taken as is.
454fn resolve_program_in(
455    program: &str,
456    path: Option<&std::ffi::OsStr>,
457    pathext: Option<&str>,
458    windows: bool,
459) -> PathBuf {
460    if program.contains('/') || (windows && program.contains('\\')) {
461        return PathBuf::from(program);
462    }
463    let extensions: Vec<String> = if windows && Path::new(program).extension().is_none() {
464        pathext
465            .unwrap_or(".COM;.EXE;.BAT;.CMD")
466            .split(';')
467            .filter(|extension| !extension.is_empty())
468            .map(str::to_ascii_lowercase)
469            .collect()
470    } else {
471        vec![String::new()]
472    };
473    for dir in path.map(std::env::split_paths).into_iter().flatten() {
474        for extension in &extensions {
475            let candidate = dir.join(format!("{program}{extension}"));
476            if candidate.is_file() {
477                return candidate;
478            }
479        }
480    }
481    PathBuf::from(program)
482}
483
484/// Run a service-manager command and return (success, stdout+stderr).
485/// A rendered unit's label: its file's name without the extension (`<label>.plist`, `<label>.service`).
486#[allow(dead_code)]
487fn unit_label(unit: &ServiceUnit) -> String {
488    unit.path
489        .file_stem()
490        .map(|stem| stem.to_string_lossy().into_owned())
491        .unwrap_or_default()
492}
493
494fn run_tool(program: &str, args: &[&str]) -> Result<(bool, String), std::io::Error> {
495    let output = std::process::Command::new(program).args(args).output()?;
496    let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
497    text.push_str(&String::from_utf8_lossy(&output.stderr));
498    Ok((output.status.success(), text.trim().to_string()))
499}
500
501#[cfg(target_os = "macos")]
502fn gui_domain() -> String {
503    // SAFETY: `getuid` reads this process's own real user id and cannot fail.
504    format!("gui/{}", unsafe { libc::getuid() })
505}
506
507/// Ask the platform's service manager about the orchestrator unit.
508///
509/// Never starts or installs anything: `status` calls this on every run.
510pub fn service_status(root: &Path) -> ServiceState {
511    platform_status(root)
512}
513
514#[cfg(target_os = "macos")]
515fn platform_status(root: &Path) -> ServiceState {
516    let label = service_name(root);
517    let target = format!("{}/{label}", gui_domain());
518    match run_tool("launchctl", &["print", &target]) {
519        Ok((true, text)) => ServiceState {
520            kind: "launchd",
521            label,
522            installed: true,
523            pid: field_of(&text, "pid = ").and_then(|value| value.parse().ok()),
524            detail: field_of(&text, "state = ").unwrap_or_else(|| "loaded".into()),
525        },
526        Ok((false, _)) => ServiceState {
527            kind: "launchd",
528            label,
529            installed: false,
530            pid: None,
531            detail: format!("not bootstrapped in {}", gui_domain()),
532        },
533        Err(error) => ServiceState {
534            kind: "launchd",
535            label,
536            installed: false,
537            pid: None,
538            detail: format!("launchctl unavailable: {error}"),
539        },
540    }
541}
542
543#[cfg(all(unix, not(target_os = "macos")))]
544fn platform_status(root: &Path) -> ServiceState {
545    let label = service_name(root);
546    match run_tool("systemctl", &["--user", "is-active", &label]) {
547        Ok((active, text)) => {
548            let known = run_tool("systemctl", &["--user", "is-enabled", &label])
549                .map(|(ok, _)| ok)
550                .unwrap_or(false);
551            ServiceState {
552                kind: "systemd",
553                label,
554                installed: active || known,
555                pid: None,
556                detail: if text.is_empty() {
557                    "unknown".into()
558                } else {
559                    text
560                },
561            }
562        }
563        Err(error) => ServiceState {
564            kind: "systemd",
565            label,
566            installed: false,
567            pid: None,
568            detail: format!("systemctl unavailable: {error}"),
569        },
570    }
571}
572
573#[cfg(not(unix))]
574fn platform_status(root: &Path) -> ServiceState {
575    ServiceState {
576        kind: "none",
577        label: service_name(root),
578        installed: false,
579        pid: None,
580        detail: "no service manager on this platform".into(),
581    }
582}
583
584/// `key = value` out of a service manager's block output.
585#[cfg(target_os = "macos")]
586fn field_of(text: &str, key: &str) -> Option<String> {
587    text.lines()
588        .find_map(|line| line.trim().strip_prefix(key))
589        .map(|value| value.trim().to_string())
590}
591
592/// Render the unit, hand it to the platform's service manager, and start it.
593///
594/// Refuses a label the manager already holds rather than replacing it: two
595/// homes share one label, so an install that silently took it over would point
596/// a running service at a different folder.
597pub fn install_service(
598    root: &Path,
599    entry: &Path,
600    node: &str,
601) -> Result<(ServiceUnit, ServiceState), OrchestratorError> {
602    let existing = service_status(root);
603    if existing.installed {
604        return Err(OrchestratorError::Service {
605            action: "install",
606            detail: format!(
607                "`{}` is already installed ({}); `supercode orchestrator setup --uninstall` first",
608                existing.label, existing.detail
609            ),
610        });
611    }
612    let unit = service_unit(root, entry, &absolute_program(node));
613    write_unit(&unit)?;
614    platform_install(&unit)?;
615    Ok((unit, service_status(root)))
616}
617
618#[cfg(target_os = "macos")]
619fn platform_install(unit: &ServiceUnit) -> Result<(), OrchestratorError> {
620    let path = unit.path.display().to_string();
621    let (ok, text) =
622        run_tool("launchctl", &["bootstrap", &gui_domain(), &path]).map_err(|error| {
623            OrchestratorError::Service {
624                action: "install",
625                detail: format!("launchctl: {error}"),
626            }
627        })?;
628    if !ok {
629        return Err(OrchestratorError::Service {
630            action: "install",
631            detail: format!("launchctl bootstrap {}: {text}", gui_domain()),
632        });
633    }
634    Ok(())
635}
636
637/// Untested on this box (the receipt is macOS); these are the commands
638/// `service_unit` has always printed as its `install_command`.
639#[cfg(all(unix, not(target_os = "macos")))]
640fn platform_install(unit: &ServiceUnit) -> Result<(), OrchestratorError> {
641    let path = unit.path.display().to_string();
642    for args in [
643        vec!["--user", "link", path.as_str()],
644        vec!["--user", "enable", "--now", unit_label(unit).as_str()],
645    ] {
646        let (ok, text) =
647            run_tool("systemctl", &args).map_err(|error| OrchestratorError::Service {
648                action: "install",
649                detail: format!("systemctl: {error}"),
650            })?;
651        if !ok {
652            return Err(OrchestratorError::Service {
653                action: "install",
654                detail: format!("systemctl {}: {text}", args.join(" ")),
655            });
656        }
657    }
658    Ok(())
659}
660
661#[cfg(not(unix))]
662fn platform_install(_unit: &ServiceUnit) -> Result<(), OrchestratorError> {
663    Err(OrchestratorError::Service {
664        action: "install",
665        detail: "no service manager on this platform".into(),
666    })
667}
668
669/// Stop and unregister the unit, and remove the rendered file `setup` wrote.
670///
671/// Idempotent: a unit the manager does not hold is not an error, because the
672/// state the operator asked for is the state they get.
673pub fn uninstall_service(root: &Path) -> Result<ServiceState, OrchestratorError> {
674    platform_uninstall(root)?;
675    let unit_path = root.join(SERVICE_DIR).join(unit_file_name(root));
676    match std::fs::remove_file(&unit_path) {
677        Ok(()) => {}
678        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
679        Err(source) => {
680            return Err(OrchestratorError::Lease {
681                path: unit_path,
682                source,
683            })
684        }
685    }
686    // `launchctl bootout` returns before the job is torn down, so the state
687    // this reports is the settled one, not the manager mid-teardown.
688    let mut state = service_status(root);
689    for _ in 0..40 {
690        if !state.installed {
691            break;
692        }
693        std::thread::sleep(std::time::Duration::from_millis(100));
694        state = service_status(root);
695    }
696    Ok(state)
697}
698
699#[cfg(target_os = "macos")]
700fn platform_uninstall(root: &Path) -> Result<(), OrchestratorError> {
701    let target = format!("{}/{}", gui_domain(), service_name(root));
702    let (ok, text) = run_tool("launchctl", &["bootout", &target]).map_err(|error| {
703        OrchestratorError::Service {
704            action: "uninstall",
705            detail: format!("launchctl: {error}"),
706        }
707    })?;
708    // `bootout` on a label nobody holds says so and exits non-zero.
709    if !ok && !text.contains("No such process") && !text.contains("not find") {
710        return Err(OrchestratorError::Service {
711            action: "uninstall",
712            detail: format!("launchctl bootout {target}: {text}"),
713        });
714    }
715    Ok(())
716}
717
718#[cfg(all(unix, not(target_os = "macos")))]
719fn platform_uninstall(root: &Path) -> Result<(), OrchestratorError> {
720    let _ = run_tool(
721        "systemctl",
722        &["--user", "disable", "--now", &service_name(root)],
723    );
724    Ok(())
725}
726
727#[cfg(not(unix))]
728fn platform_uninstall(_root: &Path) -> Result<(), OrchestratorError> {
729    Ok(())
730}
731
732/// The file name `service_unit` renders for this platform.
733fn unit_file_name(root: &Path) -> String {
734    if cfg!(target_os = "macos") {
735        format!("{}.plist", service_name(root))
736    } else {
737        format!("{}.service", service_name(root))
738    }
739}
740
741/// Write a rendered unit under `<home>/service/`.
742pub fn write_unit(unit: &ServiceUnit) -> Result<(), OrchestratorError> {
743    if let Some(parent) = unit.path.parent() {
744        std::fs::create_dir_all(parent).map_err(|source| OrchestratorError::Lease {
745            path: unit.path.clone(),
746            source,
747        })?;
748    }
749    std::fs::write(&unit.path, &unit.text).map_err(|source| OrchestratorError::Lease {
750        path: unit.path.clone(),
751        source,
752    })
753}