openlatch-provider 0.2.2

Self-service onboarding CLI + runtime daemon for OpenLatch Editors and Providers
//! Normalized `ProcessSpec` — a Rust-friendly view of a binding's
//! `process:` manifest block with all the defaults applied. Decouples the
//! rest of the supervisor from typify-generated shape churn.
//!
//! Source of truth: `schemas/manifest-process.schema.json`.

use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::Duration;

use crate::error::{OlError, OL_4300_PROCESS_SPEC_INVALID};
use crate::generated::{ManifestBinding, ManifestProcess, ManifestProcessRestart};

/// Restart policy. Mirrors the schema enum 1:1.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RestartPolicy {
    /// Respawn on any exit (including clean `exit 0`).
    Always,
    /// Respawn only on non-zero exit, signal, or liveness failure.
    OnFailure,
    /// Never respawn.
    Never,
}

impl RestartPolicy {
    /// Decide whether a non-startup exit should trigger a restart.
    /// `clean_exit` is true when the child returned `exit 0`.
    pub fn should_restart(self, clean_exit: bool) -> bool {
        match self {
            RestartPolicy::Always => true,
            RestartPolicy::OnFailure => !clean_exit,
            RestartPolicy::Never => false,
        }
    }
}

impl From<Option<ManifestProcessRestart>> for RestartPolicy {
    fn from(value: Option<ManifestProcessRestart>) -> Self {
        match value {
            Some(ManifestProcessRestart::Always) | None => RestartPolicy::Always,
            Some(ManifestProcessRestart::OnFailure) => RestartPolicy::OnFailure,
            Some(ManifestProcessRestart::No) => RestartPolicy::Never,
        }
    }
}

/// Effective health-check configuration after defaults.
#[derive(Debug, Clone)]
pub struct HealthCheckSpec {
    pub path: String,
    pub port: u16,
    pub startup_period: Duration,
    pub startup_timeout: Duration,
    pub liveness_period: Duration,
    pub liveness_failure_threshold: u32,
    pub liveness_timeout: Duration,
}

/// Effective restart-rate-limit window after defaults.
#[derive(Debug, Clone, Copy)]
pub struct RestartLimitSpec {
    pub max_restarts: u32,
    pub window: Duration,
}

/// Fully-normalized supervised-process specification for a single binding.
#[derive(Debug, Clone)]
pub struct ProcessSpec {
    /// The binding id (`bnd_…`) — used as the key for telemetry, logs, PID
    /// files, and admin RPCs.
    pub binding_id: String,
    /// The tool slug — used as the human-readable tag in `tools status` and
    /// the `tracing` target.
    pub tool_slug: String,
    pub command: Vec<String>,
    pub cwd: PathBuf,
    pub env: HashMap<String, String>,
    pub restart: RestartPolicy,
    pub start_timeout: Duration,
    pub kill_timeout: Duration,
    pub health: HealthCheckSpec,
    pub restart_limit: RestartLimitSpec,
}

impl ProcessSpec {
    /// Build a normalized spec from the manifest binding. `manifest_dir` is
    /// the directory of `openlatch.yaml` — used to resolve a relative `cwd`.
    /// `binding_id` comes from the platform's view (it's not in the
    /// manifest YAML, only the `(tool, provider)` pair is).
    pub fn from_manifest(
        binding_id: impl Into<String>,
        binding: &ManifestBinding,
        manifest_dir: &Path,
    ) -> Result<Self, OlError> {
        let tool_slug = binding.tool.to_string();
        let p: &ManifestProcess = &binding.process;

        let command: Vec<String> = p.command.iter().map(|c| c.to_string()).collect();
        if command.is_empty() {
            return Err(OlError::new(
                OL_4300_PROCESS_SPEC_INVALID,
                format!(
                    "binding `{tool_slug}` has an empty process.command (JSON Schema \
                     enforces minItems=1; this is a typify shape mismatch — please report)"
                ),
            ));
        }
        if command[0].is_empty() {
            return Err(OlError::new(
                OL_4300_PROCESS_SPEC_INVALID,
                format!("binding `{tool_slug}`: process.command[0] (the program) is empty"),
            ));
        }

        let cwd = match &p.cwd {
            Some(path) => {
                let raw = PathBuf::from(path);
                if raw.is_absolute() {
                    raw
                } else {
                    manifest_dir.join(raw)
                }
            }
            None => manifest_dir.to_path_buf(),
        };

        let port_u64 = u64::from(p.health_check.http.port);
        let port: u16 = port_u64.try_into().map_err(|_| {
            OlError::new(
                OL_4300_PROCESS_SPEC_INVALID,
                format!(
                    "binding `{tool_slug}`: process.health_check.http.port {port_u64} \
                     exceeds u16::MAX (65535)"
                ),
            )
        })?;

        let path = p
            .health_check
            .http
            .path
            .as_ref()
            .map(|p| p.to_string())
            .unwrap_or_else(|| "/healthz".to_string());

        let health = HealthCheckSpec {
            path,
            port,
            startup_period: ms_or(p.health_check.http.startup_period_ms, 1_000),
            startup_timeout: ms_or(p.health_check.http.startup_timeout_ms, 2_000),
            liveness_period: ms_or(p.health_check.http.liveness_period_ms, 10_000),
            liveness_failure_threshold: p
                .health_check
                .http
                .liveness_failure_threshold
                .map(|n| u64::from(n).min(u32::MAX as u64) as u32)
                .unwrap_or(3),
            liveness_timeout: ms_or(p.health_check.http.liveness_timeout_ms, 2_000),
        };

        let restart_limit = RestartLimitSpec {
            max_restarts: p
                .restart_policy
                .as_ref()
                .and_then(|rp| rp.max_restarts)
                .map(|n| u64::from(n).min(u32::MAX as u64) as u32)
                .unwrap_or(5),
            window: p
                .restart_policy
                .as_ref()
                .and_then(|rp| rp.window_ms)
                .map(ms_to_duration)
                .unwrap_or_else(|| Duration::from_secs(60)),
        };

        Ok(Self {
            binding_id: binding_id.into(),
            tool_slug,
            command,
            cwd,
            env: p.env.clone(),
            restart: p.restart.into(),
            start_timeout: ms_or(p.start_timeout_ms, 30_000),
            kill_timeout: ms_or(p.kill_timeout_ms, 5_000),
            health,
            restart_limit,
        })
    }

    /// URL the supervisor uses for /healthz probes.
    pub fn health_url(&self) -> String {
        format!("http://127.0.0.1:{}{}", self.health.port, self.health.path)
    }
}

fn ms_or(value: Option<i64>, default_ms: u64) -> Duration {
    match value {
        Some(n) if n > 0 => Duration::from_millis(n as u64),
        _ => Duration::from_millis(default_ms),
    }
}

fn ms_to_duration(value: i64) -> Duration {
    if value <= 0 {
        Duration::from_millis(0)
    } else {
        Duration::from_millis(value as u64)
    }
}

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

    #[test]
    fn restart_policy_default_is_always() {
        let p: RestartPolicy = None.into();
        assert_eq!(p, RestartPolicy::Always);
        assert!(p.should_restart(true));
        assert!(p.should_restart(false));
    }

    #[test]
    fn restart_policy_on_failure_respects_clean_exit() {
        assert!(!RestartPolicy::OnFailure.should_restart(true));
        assert!(RestartPolicy::OnFailure.should_restart(false));
    }

    #[test]
    fn restart_policy_never_does_not_restart() {
        assert!(!RestartPolicy::Never.should_restart(true));
        assert!(!RestartPolicy::Never.should_restart(false));
    }
}