openlatch-client 0.1.18

OpenLatch runtime enforcement node — the capture-and-enforce client for the AI Operations Platform
pub mod launchd;
pub mod systemd;
/// In-process task supervision — restarts subsystems *inside* the daemon, the
/// same job the OS supervisors above do for the process itself. Never reached
/// by the lean `openlatch-hook` build: the whole `core` module is
/// `#[cfg(feature = "full-cli")]` in `src/lib.rs`, which is what keeps
/// `cargo build --no-default-features --bin openlatch-hook` unchanged.
pub mod task;
pub mod task_scheduler;

use crate::error::OlError;

pub const ERR_SUPERVISION_UNSUPPORTED_OS: &str = "OL-1950";
pub const ERR_SUPERVISION_INSTALL_FAILED: &str = "OL-1951";
pub const ERR_SUPERVISION_BOOTSTRAP_FAILED: &str = "OL-1952";
/// A `start` / `stop` / `restart` handed to the OS supervisor failed — the
/// unit is registered but `systemctl` / `launchctl` / `schtasks` refused the
/// control verb. Callers fall back to acting on the process directly, which
/// is the pre-supervision behaviour, so this is a degraded path and not a
/// hard failure.
pub const ERR_SUPERVISION_CONTROL_FAILED: &str = "OL-1953";

/// Generation number of the generated supervisor artifacts (unit / plist /
/// task XML).
///
/// Every generator stamps `openlatch-unit-version: <N>` into its output and
/// [`unit_is_current`] compares an on-disk artifact against it. Bump this
/// whenever the generated restart semantics change, so `openlatch doctor` can
/// tell "installed, correct" from "installed years ago with the old
/// `Restart=on-failure` that never fired". Rewriting the artifact is
/// deliberately left to the user via `openlatch supervision install` —
/// silently rewriting an OS-registered unit under someone is not this
/// command's business.
///
/// | Version | Change |
/// |---|---|
/// | 1 | Original units: `Restart=on-failure`, `KeepAlive{SuccessfulExit=false}` — neither fired, because the daemon exited 0 even when it crashed. |
/// | 2 | Restart-always semantics: systemd `Restart=always`/`RestartSec=2`/`StartLimitIntervalSec=0`, launchd bare `KeepAlive`/`ThrottleInterval 2`, Task Scheduler `RestartOnFailure` aligned to the same cadence. |
/// | 3 | systemd `RestartPreventExitStatus=5`, so an already-running refusal (`OL-1501` → exit 5) ends the job instead of being restarted forever. **systemd only** — launchd has no per-exit-code equivalent to `KeepAlive` and Task Scheduler never restarts a completed run, so their artifacts are byte-identical to v2 apart from this marker. They are re-stamped anyway: the marker is one constant for all three backends, and a per-backend version buys nothing but a way for them to disagree. |
pub const UNIT_VERSION: u32 = 3;

/// The version marker line embedded in every generated artifact.
pub fn unit_version_marker() -> String {
    format!("openlatch-unit-version: {UNIT_VERSION}")
}

/// Does `contents` carry the current [`UNIT_VERSION`] marker?
///
/// `false` for both a stale generation and an artifact predating the marker
/// entirely — either way the answer to "is this what we generate today" is no.
pub fn unit_is_current(contents: &str) -> bool {
    contents.contains(&unit_version_marker())
}

#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SupervisorKind {
    Launchd,
    Systemd,
    TaskScheduler,
    None,
}

#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SupervisionMode {
    Active,
    Deferred,
    Disabled,
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SupervisionConfig {
    pub mode: SupervisionMode,
    pub backend: SupervisorKind,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub disabled_reason: Option<String>,
}

impl Default for SupervisionConfig {
    fn default() -> Self {
        Self {
            mode: SupervisionMode::Disabled,
            backend: SupervisorKind::None,
            disabled_reason: Some("not_initialized".into()),
        }
    }
}

/// Did the user ASK for there to be no OS supervisor?
///
/// `user_opt_out` (`--no-persistence` / `supervision disable`),
/// `foreground_session` and `no_start` are all things someone typed. Everything
/// else — `unsupported_os`, `headless_install_no_gui`, `not_initialized`, an
/// install that deferred — is a state the machine landed in.
///
/// The distinction is the whole content of the report: an absence you chose is
/// not a finding, and an absence you did not choose is not a failure either. One
/// function so `doctor` and the daemon log can never classify the same reason
/// differently.
pub fn absence_is_deliberate(reason: Option<&str>) -> bool {
    matches!(
        reason,
        Some("user_opt_out" | "foreground_session" | "no_start")
    )
}

pub trait Supervisor: Send + Sync {
    fn kind(&self) -> SupervisorKind;
    fn install(&self, binary_path: &std::path::Path) -> Result<(), OlError>;
    fn uninstall(&self) -> Result<(), OlError>;
    fn status(&self) -> Result<SupervisorStatus, OlError>;

    /// Start the supervised daemon now. Idempotent — a daemon that is already
    /// running stays running and this returns `Ok`.
    fn start(&self) -> Result<(), OlError>;

    /// Stop the supervised daemon and keep it stopped.
    ///
    /// The distinction that matters: the supervisor must record this as an
    /// *intentional* stop. Killing the process behind the supervisor's back —
    /// which is what `POST /shutdown` and SIGTERM do — reads as a death, and
    /// `Restart=always` / `KeepAlive` brings the daemon back within seconds.
    /// The unit stays registered, so the daemon returns at the next login.
    fn stop(&self) -> Result<(), OlError>;

    /// Stop-then-start in one supervisor-mediated step, without the window
    /// where the supervisor would race a caller-spawned replacement.
    fn restart(&self) -> Result<(), OlError>;
}

#[derive(Debug, Clone)]
pub struct SupervisorStatus {
    pub installed: bool,
    pub running: bool,
    /// `true` when the installed artifact carries the current
    /// [`UNIT_VERSION`] marker. `false` means a unit installed by an older
    /// release is still in place — it is registered and probably running, but
    /// with the restart semantics of that release. `openlatch doctor` reports
    /// the drift and points at `openlatch supervision install`.
    ///
    /// Meaningless (and always `false`) when `installed` is `false`.
    pub unit_current: bool,
    pub description: String,
}

pub fn select_supervisor() -> Option<Box<dyn Supervisor>> {
    select_supervisor_impl()
}

/// The OS supervisor when it — and not the calling process — owns the daemon's
/// lifecycle.
///
/// Returns `Some` only when the config says supervision is [`SupervisionMode::Active`]
/// **and** this OS has a supervisor. Every CLI path that would otherwise spawn
/// or kill the daemon itself asks this first.
///
/// The reason is that a registered supervisor is not a passive bystander. With
/// `Restart=always` (systemd) or `KeepAlive` (launchd), a daemon that this
/// process stops comes back within `RestartSec`, and a daemon this process
/// spawns races the one the supervisor already started — both binding the same
/// port, one of them losing. That is two owners with no arbitration, and it is
/// how `openlatch init` produced a 130-restart loop: `install()` started the
/// daemon via `enable --now`, then `init` spawned a second one, and whichever
/// lost the race exited 0 into `Restart=always`.
///
/// Callers treat a `Some` that then fails its control verb as a *degraded*
/// signal, not a hard error: a config claiming `active` next to a unit someone
/// deleted by hand must still let `openlatch stop` fall back to stopping the
/// process directly.
pub fn lifecycle_owner(cfg: &SupervisionConfig) -> Option<Box<dyn Supervisor>> {
    if cfg.mode != SupervisionMode::Active {
        return None;
    }
    select_supervisor()
}

#[cfg(target_os = "macos")]
fn select_supervisor_impl() -> Option<Box<dyn Supervisor>> {
    Some(Box::new(launchd::LaunchdSupervisor::new()))
}

#[cfg(target_os = "linux")]
fn select_supervisor_impl() -> Option<Box<dyn Supervisor>> {
    if systemd::is_systemd_available() {
        Some(Box::new(systemd::SystemdSupervisor::new()))
    } else {
        None
    }
}

#[cfg(target_os = "windows")]
fn select_supervisor_impl() -> Option<Box<dyn Supervisor>> {
    Some(Box::new(task_scheduler::TaskSchedulerSupervisor::new()))
}

#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn select_supervisor_impl() -> Option<Box<dyn Supervisor>> {
    None
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_supervision_config_is_disabled() {
        let c = SupervisionConfig::default();
        assert_eq!(c.mode, SupervisionMode::Disabled);
        assert_eq!(c.backend, SupervisorKind::None);
    }

    #[test]
    fn select_supervisor_returns_something_on_this_os() {
        let sup = select_supervisor();
        #[cfg(any(target_os = "macos", target_os = "windows"))]
        assert!(sup.is_some());
        let _ = sup;
    }
}