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 {
prior: Option<String>,
}
type Records = BTreeMap<String, Entry>;
fn record_path() -> PathBuf {
crate::config::openlatch_dir().join("boundary-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 boundary endpoint record: {e}"),
)
})?;
let tmp = path.with_extension("json.openlatch-tmp");
std::fs::write(&tmp, &content).map_err(|e| {
OlError::new(
ERR_STATE_FILE_WRITE_FAILED,
format!("Cannot write the boundary endpoint record: {e}"),
)
})?;
std::fs::rename(&tmp, &path).map_err(|e| {
let _ = std::fs::remove_file(&tmp);
OlError::new(
ERR_STATE_FILE_WRITE_FAILED,
format!("Cannot replace the boundary endpoint record: {e}"),
)
})?;
let _ = crate::fs_secure::restrict_to_owner(&path);
Ok(())
}
pub fn record(agent: &str, prior: Option<String>) -> Result<(), OlError> {
let mut records = load()?.unwrap_or_default();
records.insert(agent.to_string(), Entry { prior });
store(&records)
}
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,
"boundary endpoint record unreadable — treating as no prior rather than \
blocking the teardown"
);
None
}
}
}
pub fn forget(agent: &str) {
match load() {
Ok(Some(mut records)) => {
if records.remove(agent).is_some() {
if let Err(e) = store(&records) {
tracing::warn!(agent, error = %e.message, "could not clear the record");
}
}
}
Ok(None) => {}
Err(e) => tracing::warn!(agent, error = %e.message, "could not clear the record"),
}
}
pub fn take(agent: &str) -> Option<Option<String>> {
let mut records = match load() {
Ok(Some(r)) => r,
Ok(None) => return None,
Err(e) => {
tracing::warn!(
agent,
code = %e.code,
error = %e.message,
"boundary endpoint record unreadable — no prior will be restored"
);
return None;
}
};
let entry = records.remove(agent)?;
if let Err(e) = store(&records) {
tracing::warn!(
code = %e.code,
error = %e.message,
agent,
"could not clear the recorded prior model endpoint"
);
}
Some(entry.prior)
}
#[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");
});
}
#[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"
);
});
}
}