openlatch-client 0.6.5

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! Per-agent evidence that the hook reached this daemon **live**.
//!
//! `<openlatch_dir>/agent-liveness.json` holds, per agent source, the time of
//! the last hook event `ingest_cloudevent` received — the hook path, never the
//! replay of `fallback.jsonl`. An installed entry proves nothing about the hook
//! reaching the daemon; this file is the evidence `doctor` reads instead.
//!
//! ```json
//! { "cursor": "2026-09-30T10:00:00Z" }
//! ```
//!
//! Generic: every known agent is recorded. Written owner-only, at most once a
//! minute per source, off the verdict path.

use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{Duration, Instant};

use chrono::{DateTime, Utc};

/// The file's name under the OpenLatch directory.
pub const FILE_NAME: &str = "agent-liveness.json";

/// At most one write per source in this window.
const WRITE_INTERVAL: Duration = Duration::from_secs(60);

/// The last live hook event per agent source, as recorded on disk. Empty when
/// the file is absent or unreadable — no evidence, which is what doctor reports.
pub fn read(openlatch_dir: &Path) -> BTreeMap<String, DateTime<Utc>> {
    std::fs::read_to_string(openlatch_dir.join(FILE_NAME))
        .ok()
        .and_then(|raw| serde_json::from_str(&raw).ok())
        .unwrap_or_default()
}

/// Records live hook events, throttled per source. `Default` records nothing —
/// the stand-in for tests that build `AppState` without a daemon directory.
#[derive(Default)]
pub struct LivenessRecorder {
    dir: Option<PathBuf>,
    last_write: Mutex<HashMap<String, Instant>>,
}

impl LivenessRecorder {
    pub fn new(openlatch_dir: PathBuf) -> Self {
        Self {
            dir: Some(openlatch_dir),
            last_write: Mutex::default(),
        }
    }

    /// Note a live hook event from `source`. Returns at once; the write, when
    /// one is due, runs on the blocking pool so the verdict never waits on it.
    pub fn record(&self, source: &str) {
        let Some(dir) = self.dir.as_deref() else {
            return;
        };
        if !self.due(source, Instant::now()) {
            return;
        }
        let dir = dir.to_path_buf();
        let source = source.to_string();
        let now = Utc::now();
        tokio::task::spawn_blocking(move || {
            if let Err(e) = write(&dir, &source, now) {
                tracing::debug!(error = %e, "agent liveness: cannot record");
            }
        });
    }

    /// Is a write due for `source` at `now`? Marks it written when it is.
    fn due(&self, source: &str, now: Instant) -> bool {
        let Ok(mut last) = self.last_write.lock() else {
            return false;
        };
        match last.get(source) {
            Some(at) if now.saturating_duration_since(*at) < WRITE_INTERVAL => false,
            _ => {
                last.insert(source.to_string(), now);
                true
            }
        }
    }
}

/// Serialises the read-modify-write below, so two sources due at once cannot
/// each write a file missing the other.
static WRITE_LOCK: Mutex<()> = Mutex::new(());

/// Set `source`'s last live event to `at`, keeping every other source.
fn write(openlatch_dir: &Path, source: &str, at: DateTime<Utc>) -> std::io::Result<()> {
    let _guard = WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
    let mut all = read(openlatch_dir);
    all.insert(source.to_string(), at);
    let body = serde_json::to_string_pretty(&all).map_err(std::io::Error::other)?;
    crate::core::fs_secure::write_owner_only(&openlatch_dir.join(FILE_NAME), &body)
}

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

    #[test]
    fn write_keeps_other_sources_and_read_round_trips() {
        let dir = tempfile::tempdir().unwrap();
        assert!(read(dir.path()).is_empty(), "absent file = no evidence");

        let t1 = "2026-09-30T10:00:00Z".parse().unwrap();
        let t2 = "2026-09-30T11:00:00Z".parse().unwrap();
        write(dir.path(), "claude-code", t1).unwrap();
        write(dir.path(), "cursor", t2).unwrap();

        let all = read(dir.path());
        assert_eq!(all.get("claude-code"), Some(&t1));
        assert_eq!(all.get("cursor"), Some(&t2));
    }

    #[test]
    fn a_corrupt_file_reads_as_no_evidence() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join(FILE_NAME), "{not json").unwrap();
        assert!(read(dir.path()).is_empty());
    }

    #[test]
    fn writes_are_throttled_per_source() {
        let r = LivenessRecorder::new(PathBuf::from("/unused"));
        let t0 = Instant::now();
        assert!(r.due("cursor", t0));
        assert!(!r.due("cursor", t0 + Duration::from_secs(59)));
        assert!(r.due("claude-code", t0), "another source is not throttled");
        assert!(r.due("cursor", t0 + WRITE_INTERVAL));
    }

    #[cfg(unix)]
    #[test]
    fn the_file_is_owner_only() {
        use std::os::unix::fs::PermissionsExt;
        let dir = tempfile::tempdir().unwrap();
        write(dir.path(), "cursor", Utc::now()).unwrap();
        let mode = std::fs::metadata(dir.path().join(FILE_NAME))
            .unwrap()
            .permissions()
            .mode();
        assert_eq!(mode & 0o777, 0o600);
    }
}