use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub const READINESS_FILE: &str = "relay-readiness.json";
pub const TRAFFIC_FILE: &str = "relay-traffic.json";
pub const VERSION: u32 = 1;
pub fn readiness_path(ol_dir: &Path) -> PathBuf {
ol_dir.join("state").join(READINESS_FILE)
}
pub fn traffic_path(ol_dir: &Path) -> PathBuf {
ol_dir.join("state").join(TRAFFIC_FILE)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "verdict", rename_all = "kebab-case")]
pub enum Verdict {
Captured { at: DateTime<Utc> },
Ready,
RestartNeeded {
app: String,
how: String,
#[serde(default)]
by_openlatch: bool,
},
Failed { why: String, fix: String },
}
impl Verdict {
pub fn label(&self) -> &'static str {
match self {
Self::Captured { .. } => "captured",
Self::Ready => "ready",
Self::RestartNeeded { .. } => "restart-needed",
Self::Failed { .. } => "failed",
}
}
pub fn is_ok(&self) -> bool {
matches!(self, Self::Captured { .. } | Self::Ready)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Restart {
ByOpenLatch,
QuitAndReopen,
NewSession,
}
#[derive(Debug, Clone)]
pub struct SurfaceDecl {
pub name: &'static str,
pub roots: Vec<PathBuf>,
pub(crate) counts: fn(&crate::hooks::cline_app::process::ExeProcess) -> bool,
pub picks_up_live: bool,
pub restart: Restart,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Age {
Started(SystemTime),
Predates,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RunningSurface {
pub name: &'static str,
pub age: Age,
pub picks_up_live: bool,
pub restart: Restart,
}
#[derive(Debug, Clone)]
pub struct Facts {
pub path: crate::model_relay::preflight::Verdict,
pub delivery: Result<(), (String, String)>,
pub wired_since: SystemTime,
pub captured_at: Option<SystemTime>,
pub running: Result<Vec<RunningSurface>, String>,
}
pub fn judge(f: &Facts) -> Option<Verdict> {
use crate::model_relay::preflight::Verdict as Path;
match &f.path {
Path::Pending => return None,
Path::Failed(why) if crate::daemon::wiring_tamper::is_refusal(why) => {
return Some(Verdict::RestartNeeded {
app: "the agent".into(),
how: "Quit and reopen it: it reads trusted certificates once per launch.".into(),
by_openlatch: false,
})
}
Path::Failed(why) => {
return Some(Verdict::Failed {
why: why.clone(),
fix: "Run `openlatch doctor` for the cause and its fix.".into(),
})
}
Path::Ok => {}
}
if let Err((why, fix)) = &f.delivery {
return Some(Verdict::Failed {
why: why.clone(),
fix: fix.clone(),
});
}
if let Some(v) = restart_needed(f.running.as_deref().unwrap_or(&[]), f.wired_since) {
return Some(v);
}
Some(match f.captured_at.filter(|t| *t >= f.wired_since) {
Some(at) => Verdict::Captured { at: at.into() },
None => Verdict::Ready,
})
}
fn restart_needed(running: &[RunningSurface], wired_since: SystemTime) -> Option<Verdict> {
let mut stale: Vec<&RunningSurface> = running
.iter()
.filter(|s| {
!s.picks_up_live
&& match s.age {
Age::Started(t) => t < wired_since,
Age::Predates => true,
}
})
.collect();
if stale.is_empty() {
return None;
}
stale.sort_by_key(|s| (s.restart as u8, s.name));
stale.dedup_by_key(|s| s.name);
let app = stale.iter().map(|s| s.name).collect::<Vec<_>>().join(", ");
let how = stale.iter().map(|s| how(s)).collect::<Vec<_>>().join(" ");
Some(Verdict::RestartNeeded {
app,
how,
by_openlatch: stale.iter().any(|s| s.restart == Restart::ByOpenLatch),
})
}
fn how(s: &RunningSurface) -> String {
match s.restart {
Restart::ByOpenLatch => format!(
"OpenLatch restarts the {} for you; an open conversation reconnects to it.",
s.name
),
Restart::QuitAndReopen => format!(
"Quit and reopen {}: it was open before OpenLatch and reads its settings at launch.",
s.name
),
Restart::NewSession => format!(
"Start a new {}: one opened before OpenLatch keeps going direct.",
s.name
),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AgentReadiness {
#[serde(flatten)]
pub verdict: Verdict,
pub wired_since: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReadinessFile {
pub version: u32,
pub computed_at: DateTime<Utc>,
pub agents: BTreeMap<String, AgentReadiness>,
}
pub fn read(ol_dir: &Path) -> Option<ReadinessFile> {
let body = std::fs::read_to_string(readiness_path(ol_dir)).ok()?;
serde_json::from_str::<ReadinessFile>(&body)
.ok()
.filter(|f| f.version == VERSION)
}
pub fn write_if_changed(ol_dir: &Path, agents: BTreeMap<String, AgentReadiness>) -> bool {
if read(ol_dir).is_some_and(|f| f.agents == agents) {
return false;
}
let file = ReadinessFile {
version: VERSION,
computed_at: Utc::now(),
agents,
};
match crate::core::fs_secure::write_json_state(&readiness_path(ol_dir), &file) {
Ok(()) => true,
Err(e) => {
tracing::warn!(error = %e, "could not write the relay readiness file");
false
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Traffic {
pub captured_at: Option<DateTime<Utc>>,
pub wired_since: DateTime<Utc>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TrafficFile {
pub version: u32,
pub agents: BTreeMap<String, Traffic>,
}
pub fn read_traffic(ol_dir: &Path) -> BTreeMap<String, Traffic> {
std::fs::read_to_string(traffic_path(ol_dir))
.ok()
.and_then(|b| serde_json::from_str::<TrafficFile>(&b).ok())
.filter(|f| f.version == VERSION)
.map(|f| f.agents)
.unwrap_or_default()
}
pub fn write_traffic(ol_dir: &Path, agents: &BTreeMap<String, Traffic>) -> std::io::Result<()> {
crate::core::fs_secure::write_json_state(
&traffic_path(ol_dir),
&TrafficFile {
version: VERSION,
agents: agents.clone(),
},
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model_relay::preflight::Verdict as Path;
use std::time::Duration;
fn t(secs: u64) -> SystemTime {
SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000 + secs)
}
fn facts() -> Facts {
Facts {
path: Path::Ok,
delivery: Ok(()),
wired_since: t(100),
captured_at: None,
running: Ok(vec![]),
}
}
fn surface(name: &'static str, started: u64, live: bool, restart: Restart) -> RunningSurface {
RunningSurface {
name,
age: Age::Started(t(started)),
picks_up_live: live,
restart,
}
}
#[test]
fn a_pending_preflight_has_no_verdict() {
let f = Facts {
path: Path::Pending,
..facts()
};
assert_eq!(judge(&f), None);
}
#[test]
fn a_failed_path_is_failed() {
let f = Facts {
path: Path::Failed("upstream unreachable".into()),
..facts()
};
assert!(
matches!(judge(&f), Some(Verdict::Failed { why, .. }) if why == "upstream unreachable")
);
}
#[test]
fn a_failed_delivery_is_failed_with_its_fix() {
let f = Facts {
delivery: Err(("not ours".into(), "run doctor --fix".into())),
..facts()
};
assert_eq!(
judge(&f),
Some(Verdict::Failed {
why: "not ours".into(),
fix: "run doctor --fix".into()
})
);
}
#[test]
fn proven_and_nothing_running_is_ready() {
assert_eq!(judge(&facts()), Some(Verdict::Ready));
}
#[test]
fn a_surface_started_after_the_wiring_is_ready() {
let f = Facts {
running: Ok(vec![surface("Cursor", 200, false, Restart::QuitAndReopen)]),
..facts()
};
assert_eq!(judge(&f), Some(Verdict::Ready));
}
#[test]
fn an_older_surface_that_picks_it_up_live_is_ready() {
let f = Facts {
running: Ok(vec![surface("Codex", 50, true, Restart::NewSession)]),
..facts()
};
assert_eq!(judge(&f), Some(Verdict::Ready));
}
#[test]
fn an_older_surface_that_cannot_is_restart_needed() {
let f = Facts {
running: Ok(vec![surface("Cursor", 50, false, Restart::QuitAndReopen)]),
..facts()
};
match judge(&f) {
Some(Verdict::RestartNeeded {
app, by_openlatch, ..
}) => {
assert_eq!(app, "Cursor");
assert!(!by_openlatch);
}
other => panic!("{other:?}"),
}
}
#[test]
fn restart_needed_wins_over_an_earlier_capture() {
let f = Facts {
captured_at: Some(t(150)),
running: Ok(vec![surface(
"Claude Code session",
50,
false,
Restart::NewSession,
)]),
..facts()
};
assert_eq!(judge(&f).map(|v| v.label()), Some("restart-needed"));
}
#[test]
fn a_stale_hub_is_offered_as_openlatch_s_restart() {
let hub = surface("Cline.app hub", 50, false, Restart::ByOpenLatch);
let f = Facts {
running: Ok(vec![hub.clone()]),
..facts()
};
assert!(matches!(
judge(&f),
Some(Verdict::RestartNeeded {
by_openlatch: true,
..
})
));
let f = Facts {
running: Ok(vec![
hub,
surface("VS Code", 50, false, Restart::QuitAndReopen),
]),
..facts()
};
match judge(&f) {
Some(Verdict::RestartNeeded {
app, by_openlatch, ..
}) => {
assert_eq!(app, "Cline.app hub, VS Code");
assert!(by_openlatch, "the hub is still OpenLatch's to restart");
}
other => panic!("{other:?}"),
}
}
#[test]
fn a_surface_known_to_predate_the_wiring_is_restart_needed() {
let f = Facts {
running: Ok(vec![RunningSurface {
name: "Cline.app hub",
age: Age::Predates,
picks_up_live: false,
restart: Restart::ByOpenLatch,
}]),
..facts()
};
assert!(matches!(
judge(&f),
Some(Verdict::RestartNeeded {
by_openlatch: true,
..
})
));
}
#[test]
fn an_unreadable_process_table_never_asks() {
let f = Facts {
running: Err("no table".into()),
..facts()
};
assert_eq!(judge(&f), Some(Verdict::Ready));
}
#[test]
fn traffic_since_the_wiring_is_captured_and_older_traffic_is_not() {
let f = Facts {
captured_at: Some(t(150)),
..facts()
};
assert_eq!(judge(&f).map(|v| v.label()), Some("captured"));
let f = Facts {
captured_at: Some(t(50)),
..facts()
};
assert_eq!(judge(&f), Some(Verdict::Ready));
}
#[test]
fn the_file_round_trips_and_is_written_only_on_a_change() {
let dir = tempfile::tempdir().unwrap();
let mut agents = BTreeMap::new();
agents.insert(
"cursor".to_string(),
AgentReadiness {
verdict: Verdict::RestartNeeded {
app: "Cursor".into(),
how: "Quit and reopen Cursor.".into(),
by_openlatch: false,
},
wired_since: t(100).into(),
},
);
assert!(write_if_changed(dir.path(), agents.clone()));
assert!(!write_if_changed(dir.path(), agents.clone()));
let read_back = read(dir.path()).expect("written");
assert_eq!(read_back.agents, agents);
let raw = std::fs::read_to_string(readiness_path(dir.path())).unwrap();
assert!(raw.contains("\"verdict\": \"restart-needed\""), "{raw}");
}
#[test]
fn another_version_reads_as_absent() {
let dir = tempfile::tempdir().unwrap();
crate::core::fs_secure::write_json_state(
&readiness_path(dir.path()),
&serde_json::json!({"version": 99, "computed_at": "2026-10-13T00:00:00Z", "agents": {}}),
)
.unwrap();
assert!(read(dir.path()).is_none());
}
#[test]
fn the_traffic_ledger_round_trips() {
let dir = tempfile::tempdir().unwrap();
assert!(read_traffic(dir.path()).is_empty());
let mut m = BTreeMap::new();
m.insert(
"claude-code".to_string(),
Traffic {
captured_at: Some(t(150).into()),
wired_since: t(100).into(),
},
);
write_traffic(dir.path(), &m).unwrap();
assert_eq!(read_traffic(dir.path()), m);
}
}