openlatch-client 0.6.19

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! `init`'s last model-relay step: read the daemon's per-agent readiness verdict
//! (`model_relay::readiness`) and print one line per agent. Nothing is asked and nothing is
//! restarted.

use std::path::Path;
use std::time::Duration;

use crate::cli::output::OutputConfig;
use crate::model_relay::readiness::{self, ReadinessFile, Verdict};

/// How long `init` waits for every wired agent's verdict. The daemon recomputes them every two
/// seconds while it is young, and the preflight lands first.
const READINESS_WAIT: Duration = Duration::from_secs(20);

/// How often the file is re-read while waiting.
const POLL: Duration = Duration::from_millis(500);

/// Read the readiness file until every agent in `agents` has a verdict, or `wait` runs out. The
/// last read either way; `None` when the daemon wrote nothing in time.
pub(crate) fn wait_for_verdicts(
    ol_dir: &Path,
    agents: &[&str],
    wait: Duration,
    poll: Duration,
) -> Option<ReadinessFile> {
    let deadline = std::time::Instant::now() + wait;
    loop {
        let file = readiness::read(ol_dir);
        let complete = file
            .as_ref()
            .is_some_and(|f| agents.iter().all(|a| f.agents.contains_key(*a)));
        if complete || std::time::Instant::now() >= deadline {
            return file;
        }
        std::thread::sleep(poll);
    }
}

/// The line `init` prints for one agent's verdict. A session or app opened before OpenLatch is
/// not a warning: every new one is captured, so the agent reads as ready.
pub(crate) fn verdict_line(display: &str, v: &Verdict) -> String {
    match v {
        Verdict::Captured { .. } => format!("{display}: model calls captured"),
        Verdict::Ready | Verdict::RestartNeeded { .. } => {
            format!("{display}: ready — its next model call is captured")
        }
        Verdict::Failed { why, fix } => format!("{display}: not captured — {why}. {fix}"),
    }
}

/// Wait for the verdicts of `agents` (`(agent type, display name)`) and print one line each.
pub(crate) fn settle(
    ol_dir: &Path,
    agents: &[(&'static str, &'static str)],
    output: &OutputConfig,
) {
    if agents.is_empty() {
        return;
    }
    let names: Vec<&str> = agents.iter().map(|(a, _)| *a).collect();
    let Some(file) = wait_for_verdicts(ol_dir, &names, READINESS_WAIT, POLL) else {
        output.print_substep(
            "Model capture: no verdict yet — run `openlatch doctor` in a moment to confirm.",
        );
        return;
    };
    for (agent, display) in agents {
        match file.agents.get(*agent) {
            Some(entry) => output.print_substep(&verdict_line(display, &entry.verdict)),
            None => output.print_substep(&format!(
                "{display}: still being checked — run `openlatch doctor` in a moment."
            )),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model_relay::readiness::AgentReadiness;
    use std::collections::BTreeMap;

    #[test]
    fn only_a_real_failure_reads_as_not_captured() {
        let restart = Verdict::RestartNeeded {
            app: "Cursor".into(),
            how: "Quit and reopen Cursor.".into(),
            by_openlatch: false,
        };
        let captured = Verdict::Captured {
            at: chrono::Utc::now(),
        };
        for v in [Verdict::Ready, captured, restart] {
            let line = verdict_line("Cursor", &v);
            assert!(
                !line.contains("not captured") && !line.contains("restart"),
                "{line}"
            );
        }
        assert!(verdict_line(
            "Cursor",
            &Verdict::Failed {
                why: "x".into(),
                fix: "y".into()
            }
        )
        .contains("not captured"));
    }

    #[test]
    fn the_wait_returns_once_every_agent_has_a_verdict() {
        let dir = tempfile::tempdir().unwrap();
        assert!(wait_for_verdicts(
            dir.path(),
            &["cursor"],
            Duration::from_millis(50),
            Duration::from_millis(10)
        )
        .is_none());
        let mut agents = BTreeMap::new();
        agents.insert(
            "cursor".to_string(),
            AgentReadiness {
                verdict: Verdict::Ready,
                wired_since: chrono::Utc::now(),
            },
        );
        readiness::write_if_changed(dir.path(), agents);
        let start = std::time::Instant::now();
        let f = wait_for_verdicts(
            dir.path(),
            &["cursor"],
            Duration::from_secs(5),
            Duration::from_millis(10),
        )
        .expect("written");
        assert!(
            start.elapsed() < Duration::from_secs(1),
            "no wait once complete"
        );
        assert_eq!(f.agents["cursor"].verdict, Verdict::Ready);
        let partial = wait_for_verdicts(
            dir.path(),
            &["cursor", "cline"],
            Duration::from_millis(50),
            Duration::from_millis(10),
        )
        .expect("partial");
        assert!(!partial.agents.contains_key("cline"));
    }
}