openlatch-provider 0.0.0

Self-service onboarding CLI + runtime daemon for OpenLatch Editors and Providers
//! Hot-reload — pull a fresh manifest, re-resolve routes, atomic-swap the
//! routing table.
//!
//! Three trigger paths converge here (Codex review #12):
//!   - SIGHUP on Unix (cfg(unix))
//!   - `notify` file-watcher on cross-platform `<slug>.yaml` changes
//!   - `POST /v1/admin/reload` over the local-only admin port
//!
//! In-flight requests during a reload are unaffected: the route table is
//! exposed via [`std::sync::Arc`] + [`std::sync::Mutex`]<Arc<…>> swap, so
//! every request handler clones a snapshot Arc at the start of its work and
//! holds it across the call. The old Arc is dropped when the last in-flight
//! handler releases it.
//!
//! Reload **failures** (invalid new manifest, unknown bindings, missing
//! secrets) leave the running route table untouched and surface an error to
//! the caller. Service is never disrupted by a bad edit.

use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};

use crate::api::editor::EditorBindingRow;
use crate::auth::binding_secrets::BindingSecretStore;
use crate::error::OlError;
use crate::runtime::multi_tool::{resolve_routes, RouteTable};

/// Wrap `Arc<RouteTable>` behind a Mutex so writers can swap atomically and
/// readers can `clone()` cheaply. (We avoid `arc-swap` to keep the
/// stdlib-only blast-radius — the `Mutex<Arc<...>>` swap is read-rare /
/// write-rarer and a sub-µs lock contention here is well under our 25 ms
/// pipeline budget.)
#[derive(Clone)]
pub struct SharedRoutes {
    inner: Arc<Mutex<Arc<RouteTable>>>,
}

impl SharedRoutes {
    pub fn new(initial: RouteTable) -> Self {
        Self {
            inner: Arc::new(Mutex::new(Arc::new(initial))),
        }
    }

    /// Cheap snapshot. Cloned once per request and held across the whole
    /// pipeline; subsequent reloads create a new Arc for new requests but
    /// don't disturb the snapshot held here.
    pub fn snapshot(&self) -> Arc<RouteTable> {
        self.inner.lock().expect("routes mutex poisoned").clone()
    }

    /// Atomic swap. Old Arc is dropped when its last clone releases.
    pub fn store(&self, new_table: RouteTable) {
        let mut guard = self.inner.lock().expect("routes mutex poisoned");
        *guard = Arc::new(new_table);
    }
}

/// Inputs required to compute a fresh route table.
pub struct ReloadInputs<'a> {
    pub manifest_path: &'a Path,
    pub live_bindings: &'a [EditorBindingRow],
    pub secret_store: &'a dyn BindingSecretStore,
    pub manifest_secret_ids_fallback: Option<Vec<String>>,
}

/// Compute a fresh routing table — common core for the three triggers.
///
/// `manifest_secret_ids_fallback` lets the caller provide secret IDs if the
/// keyring backend (which can't enumerate entries) is in use; the file
/// backend's `list_known()` works directly.
pub fn build_route_table(inputs: &ReloadInputs<'_>) -> Result<RouteTable, OlError> {
    let manifest = crate::manifest::load(inputs.manifest_path)?;
    let mut known: Vec<String> = inputs.secret_store.list_known()?;
    if let Some(extra) = &inputs.manifest_secret_ids_fallback {
        for id in extra {
            if !known.contains(id) {
                // Probe the store directly — keyring's list_known returns
                // empty but retrieve() will succeed if the secret is present.
                if inputs.secret_store.retrieve(id).is_ok() {
                    known.push(id.clone());
                }
            }
        }
    }
    resolve_routes(&manifest, inputs.live_bindings, &known)
}

/// Convenience wrapper: build + swap. Returns the count of routes after the
/// swap.
pub fn reload_into(routes: &SharedRoutes, inputs: &ReloadInputs<'_>) -> Result<usize, OlError> {
    let table = build_route_table(inputs)?;
    let n = table.len();
    routes.store(table);
    Ok(n)
}

/// Helper: own a fresh path buffer for the reload-trigger task. Tasks need a
/// `'static` path because they're spawned onto tokio.
pub fn owned_path(path: &Path) -> PathBuf {
    path.to_path_buf()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::auth::binding_secrets::FileBindingSecretStore;
    use secrecy::SecretString;
    use tempfile::TempDir;

    #[test]
    fn shared_routes_snapshot_is_independent_of_subsequent_swaps() {
        let routes = SharedRoutes::new(RouteTable::empty());
        let snap = routes.snapshot();
        assert_eq!(snap.len(), 0);

        // Swap — snap still points at the empty table.
        routes.store(RouteTable::empty());
        assert_eq!(snap.len(), 0);
    }

    #[test]
    fn build_route_table_uses_file_store_list_known() {
        let tmp = TempDir::new().unwrap();
        let store = FileBindingSecretStore::new(tmp.path(), "mach_test");
        store
            .store("bnd_42", SecretString::from("whsec_AAAA".to_string()))
            .unwrap();
        let known = store.list_known().unwrap();
        assert_eq!(known, vec!["bnd_42".to_string()]);
    }
}