openlatch-client 0.1.18

OpenLatch runtime enforcement node — the capture-and-enforce client for the AI Operations Platform
use std::path::Path;
use std::time::Duration;

use notify::{EventKind, RecommendedWatcher, RecursiveMode};
use notify_debouncer_full::{new_debouncer, DebounceEventResult, Debouncer, RecommendedCache};
use tokio::sync::mpsc;

use super::reconciler::request::ReconcileRequest;

/// Tamper-plane debounce window. Short enough to feel reactive yet long
/// enough that debouncer-full's file-id correlation collapses Vim /
/// JetBrains / VS Code atomic-save patterns into a single logical event.
const TAMPER_DEBOUNCE_MS: u64 = 500;

pub fn spawn_watcher(
    settings_path: &Path,
    tx: mpsc::Sender<ReconcileRequest>,
) -> Result<Debouncer<RecommendedWatcher, RecommendedCache>, notify::Error> {
    let dir = settings_path
        .parent()
        .expect("settings.json has a parent directory")
        .to_path_buf();
    let target_name = settings_path
        .file_name()
        .expect("settings.json has a filename")
        .to_os_string();

    let mut debouncer = new_debouncer(
        Duration::from_millis(TAMPER_DEBOUNCE_MS),
        None,
        move |res: DebounceEventResult| {
            if let Ok(events) = res {
                let hit = events
                    .iter()
                    .any(|ev| event_targets_settings(ev, &target_name));
                if hit && tx.try_send(ReconcileRequest::Fs).is_err() {
                    // Reconcile channel is full or closed. Fail-open: the
                    // 30s poll fallback will catch up. Per architecture.md.
                    tracing::warn!("reconcile channel full — skipping fs-triggered reconcile");
                }
            }
        },
    )?;

    debouncer.watch(&dir, RecursiveMode::NonRecursive)?;

    Ok(debouncer)
}

fn event_targets_settings(
    ev: &notify_debouncer_full::DebouncedEvent,
    target_name: &std::ffi::OsStr,
) -> bool {
    let is_relevant_kind = matches!(
        ev.event.kind,
        EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
    );
    if !is_relevant_kind {
        return false;
    }
    ev.event
        .paths
        .iter()
        .any(|p| p.file_name() == Some(target_name))
}

pub fn spawn_poll_fallback(tx: mpsc::Sender<ReconcileRequest>) -> tokio::task::JoinHandle<()> {
    tokio::spawn(async move {
        let mut interval = tokio::time::interval(Duration::from_secs(30));
        interval.tick().await;

        loop {
            interval.tick().await;
            if tx.send(ReconcileRequest::Poll).await.is_err() {
                break;
            }
        }
    })
}