use std::collections::BTreeMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::error::{OlError, ERR_STATE_FILE_CORRUPT, ERR_STATE_FILE_WRITE_FAILED};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct Entry {
#[serde(default)]
prior: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
endpoint: Option<EndpointRecord>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum SlotValue {
Absent,
Null,
Text(String),
}
impl SlotValue {
pub fn is_unset(&self) -> bool {
match self {
Self::Absent | Self::Null => true,
Self::Text(t) => t.trim().is_empty(),
}
}
pub fn text(&self) -> Option<&str> {
match self {
Self::Text(t) if !t.trim().is_empty() => Some(t.as_str()),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SlotState {
Pending,
Wired,
Released,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReleasedBy {
Teardown,
Wiring,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Proof {
Traffic,
EditorSave,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EndpointRecord {
pub port: u16,
pub origin: String,
pub prior: SlotValue,
pub last_written: Option<String>,
pub file: std::path::PathBuf,
pub state: SlotState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub released_by: Option<ReleasedBy>,
pub changed_at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub proven_at: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub proven_by: Option<Proof>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub family: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pending_event: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub misconfigured_event: Option<String>,
}
impl EndpointRecord {
pub fn is_live(&self) -> bool {
matches!(self.state, SlotState::Pending | SlotState::Wired)
}
pub fn served_since_written(&self, last_request_unix: Option<u64>) -> bool {
last_request_unix.is_some_and(|at| at >= self.changed_at)
}
}
type Records = BTreeMap<String, Entry>;
fn record_path() -> PathBuf {
crate::config::openlatch_dir().join("model-relay-endpoints.json")
}
fn load() -> Result<Option<Records>, OlError> {
let raw = match std::fs::read_to_string(record_path()) {
Ok(raw) => raw,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(OlError::new(
ERR_STATE_FILE_CORRUPT,
format!("cannot read {}: {e}", record_path().display()),
))
}
};
serde_json::from_str(&raw).map(Some).map_err(|e| {
OlError::new(
ERR_STATE_FILE_CORRUPT,
format!("{} is not valid JSON: {e}", record_path().display()),
)
})
}
fn store(records: &Records) -> Result<(), OlError> {
let path = record_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
OlError::new(
ERR_STATE_FILE_WRITE_FAILED,
format!("Cannot create the OpenLatch directory: {e}"),
)
})?;
}
let content = serde_json::to_string_pretty(records).map_err(|e| {
OlError::new(
ERR_STATE_FILE_WRITE_FAILED,
format!("Cannot serialize the model relay endpoint record: {e}"),
)
})?;
crate::fs_secure::write_preserving_mode(&path, content.as_bytes()).map_err(|e| {
OlError::new(
ERR_STATE_FILE_WRITE_FAILED,
format!("Cannot write the model relay endpoint record: {e}"),
)
})?;
let _ = crate::fs_secure::restrict_to_owner(&path);
Ok(())
}
const LOCK_STALE_AFTER: std::time::Duration = std::time::Duration::from_secs(30);
fn mutate<T>(f: impl FnOnce(&mut Records) -> T) -> Result<T, OlError> {
let path = record_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
OlError::new(
ERR_STATE_FILE_WRITE_FAILED,
format!("Cannot create the OpenLatch directory: {e}"),
)
})?;
}
let lock = path.with_extension("json.lock");
crate::fs_secure::with_lockfile(&lock, LOCK_STALE_AFTER, || {
let mut records = load()?.unwrap_or_default();
let before = records.clone();
let out = f(&mut records);
if !same_records(&before, &records) {
store(&records)?;
}
Ok(out)
})
.map_err(|e| {
OlError::new(
ERR_STATE_FILE_WRITE_FAILED,
format!("Cannot lock the model relay endpoint record: {e}"),
)
})?
}
fn same_records(a: &Records, b: &Records) -> bool {
serde_json::to_string(a).ok() == serde_json::to_string(b).ok()
}
pub fn endpoint_records(prefix: &str) -> Result<Vec<(String, EndpointRecord)>, OlError> {
Ok(load()?
.unwrap_or_default()
.into_iter()
.filter(|(key, _)| key.starts_with(prefix))
.filter_map(|(key, entry)| entry.endpoint.map(|rec| (key, rec)))
.collect())
}
pub fn prior_records(prefix: &str) -> Result<Vec<(String, Option<String>)>, OlError> {
Ok(load()?
.unwrap_or_default()
.into_iter()
.filter(|(key, entry)| key.starts_with(prefix) && entry.endpoint.is_none())
.map(|(key, entry)| (key, entry.prior))
.collect())
}
pub fn put_endpoint(key: &str, record: EndpointRecord) -> Result<(), OlError> {
mutate(|records| {
records.insert(
key.to_string(),
Entry {
prior: None,
endpoint: Some(record),
},
);
})
}
pub fn update_endpoint(
key: &str,
change: impl FnOnce(&mut EndpointRecord),
) -> Result<Option<EndpointRecord>, OlError> {
mutate(|records| {
let rec = records.get_mut(key)?.endpoint.as_mut()?;
change(rec);
Some(rec.clone())
})
}
pub fn allocate_port(
key: &str,
block: std::ops::RangeInclusive<u16>,
records: &[(String, EndpointRecord)],
) -> Option<u16> {
if let Some((_, own)) = records.iter().find(|(k, _)| k == key) {
if block.contains(&own.port) {
return Some(own.port);
}
}
let live = |port: u16| {
records
.iter()
.any(|(k, r)| k != key && r.port == port && r.is_live())
};
let named = |port: u16| records.iter().any(|(k, r)| k != key && r.port == port);
if let Some(port) = block.clone().find(|p| !named(*p)) {
return Some(port);
}
block.filter(|p| !live(*p)).min_by_key(|p| {
records
.iter()
.filter(|(_, r)| r.port == *p)
.map(|(_, r)| r.changed_at)
.max()
.unwrap_or(0)
})
}
pub fn record(agent: &str, prior: Option<String>) -> Result<(), OlError> {
mutate(|records| {
records.insert(
agent.to_string(),
Entry {
prior,
endpoint: None,
},
);
})
}
pub fn peek(agent: &str) -> Option<Option<String>> {
match load() {
Ok(Some(records)) => records.get(agent).map(|e| e.prior.clone()),
Ok(None) => None,
Err(e) => {
tracing::warn!(
agent,
code = %e.code,
error = %e.message,
"model_relay endpoint record unreadable — treating as no prior rather than \
blocking the teardown"
);
None
}
}
}
pub fn forget(agent: &str) {
if let Err(e) = mutate(|records| {
records.remove(agent);
}) {
tracing::warn!(agent, error = %e.message, "could not clear the record");
}
}
pub fn take(agent: &str) -> Option<Option<String>> {
match load() {
Ok(Some(_)) => {}
Ok(None) => return None,
Err(e) => {
tracing::warn!(
agent,
code = %e.code,
error = %e.message,
"model relay endpoint record unreadable — no prior will be restored"
);
return None;
}
}
match mutate(|records| records.remove(agent)) {
Ok(entry) => entry.map(|e| e.prior),
Err(e) => {
tracing::warn!(
code = %e.code,
error = %e.message,
agent,
"could not clear the recorded prior model endpoint"
);
load().ok().flatten()?.get(agent).map(|e| e.prior.clone())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn with_openlatch_dir<T>(f: impl FnOnce() -> T) -> T {
let _guard = crate::config::OPENLATCH_DIR_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().expect("tempdir");
let prev = std::env::var_os("OPENLATCH_DIR");
std::env::set_var("OPENLATCH_DIR", tmp.path());
let out = f();
match prev {
Some(v) => std::env::set_var("OPENLATCH_DIR", v),
None => std::env::remove_var("OPENLATCH_DIR"),
}
out
}
#[test]
fn take_distinguishes_an_absent_record_from_a_recorded_absence() {
with_openlatch_dir(|| {
assert_eq!(take("claude-code"), None, "nothing recorded yet");
record("claude-code", None).expect("record");
assert_eq!(take("claude-code"), Some(None), "a recorded absence");
assert_eq!(take("claude-code"), None, "take removes the entry");
record("codex-cli", Some("corporate-gateway".into())).expect("record");
assert_eq!(take("codex-cli"), Some(Some("corporate-gateway".into())));
assert_eq!(take("codex-cli"), None, "and removes that one too");
});
}
fn record_on(port: u16, state: SlotState, changed_at: u64) -> EndpointRecord {
EndpointRecord {
port,
origin: "http://127.0.0.1:11434/".into(),
prior: SlotValue::Absent,
last_written: Some(format!("http://127.0.0.1:{port}")),
file: std::path::PathBuf::from("/tmp/globalState.json"),
state,
released_by: None,
changed_at,
proven_at: None,
family: None,
pending_event: None,
proven_by: None,
misconfigured_event: None,
}
}
#[test]
fn records_v1_entries_still_parse() {
with_openlatch_dir(|| {
std::fs::write(
record_path(),
r#"{"claude-code":{"prior":"https://gw.example"},"codex-cli":{"prior":null}}"#,
)
.expect("seed v1");
put_endpoint(
"cline:gs:shared:ollamaBaseUrl",
record_on(7601, SlotState::Wired, 1),
)
.expect("put");
assert_eq!(peek("claude-code"), Some(Some("https://gw.example".into())));
assert_eq!(peek("codex-cli"), Some(None));
let endpoints = endpoint_records("cline:").expect("read");
assert_eq!(endpoints.len(), 1);
assert_eq!(endpoints[0].1.port, 7601);
assert!(
endpoint_records("claude").expect("read").is_empty(),
"a v1 entry is not an endpoint"
);
});
}
#[test]
fn slot_values_round_trip_in_their_original_representation() {
for v in [
SlotValue::Absent,
SlotValue::Null,
SlotValue::Text(String::new()),
SlotValue::Text("https://gw.corp/anthropic".into()),
] {
let json = serde_json::to_string(&v).expect("ser");
assert_eq!(serde_json::from_str::<SlotValue>(&json).expect("de"), v);
}
assert!(SlotValue::Absent.is_unset() && SlotValue::Null.is_unset());
assert!(SlotValue::Text(" ".into()).is_unset());
assert!(!SlotValue::Text("http://x".into()).is_unset());
}
#[test]
fn ports_are_sticky_and_never_reassigned_live() {
let block = 7601..=7604;
let records = vec![
("a".to_string(), record_on(7601, SlotState::Wired, 1)),
("b".to_string(), record_on(7602, SlotState::Pending, 1)),
];
assert_eq!(allocate_port("a", block.clone(), &records), Some(7601));
assert_eq!(allocate_port("b", block.clone(), &records), Some(7602));
assert_eq!(allocate_port("c", block.clone(), &records), Some(7603));
let moved = vec![("a".to_string(), record_on(9000, SlotState::Wired, 1))];
assert_eq!(allocate_port("a", block, &moved), Some(7601));
}
#[test]
fn a_tombstoned_port_is_reused_only_when_the_block_is_exhausted() {
let block = 7601..=7603;
let mut records = vec![
("a".to_string(), record_on(7601, SlotState::Released, 5)),
("b".to_string(), record_on(7602, SlotState::Wired, 1)),
];
assert_eq!(
allocate_port("c", block.clone(), &records),
Some(7603),
"a never-named port first"
);
records.push(("c".to_string(), record_on(7603, SlotState::Wired, 1)));
assert_eq!(
allocate_port("d", block.clone(), &records),
Some(7601),
"then the released one"
);
records[0].1.state = SlotState::Wired;
assert_eq!(allocate_port("d", block, &records), None, "all live: none");
}
#[test]
fn concurrent_record_writers_keep_both_entries() {
with_openlatch_dir(|| {
std::thread::scope(|scope| {
for i in 0..8u16 {
scope.spawn(move || {
put_endpoint(
&format!("cline:pj:p{i}"),
record_on(7601 + i, SlotState::Wired, 1),
)
.expect("put");
});
}
});
assert_eq!(endpoint_records("cline:pj:").expect("read").len(), 8);
});
}
#[test]
fn records_are_per_agent() {
with_openlatch_dir(|| {
record("claude-code", Some("https://gw.example".into())).expect("record");
record("codex-cli", Some("corporate-gateway".into())).expect("record");
assert_eq!(take("claude-code"), Some(Some("https://gw.example".into())));
assert_eq!(
take("codex-cli"),
Some(Some("corporate-gateway".into())),
"the other agent's record must survive the first take"
);
});
}
}