agentmux_srv/backend/
agent_bundle_sidecar.rs1use serde::{Deserialize, Serialize};
17
18use crate::backend::storage::error::StoreError;
19use crate::backend::storage::filestore::FileStore;
20
21const FILE: &str = "bundle.json";
22
23#[derive(Debug, Default, Serialize, Deserialize)]
24struct Sidecar {
25 #[serde(default)]
26 memory_id: String,
27 #[serde(flatten)]
28 extra: serde_json::Map<String, serde_json::Value>,
29}
30
31fn zone(uid: &str) -> Option<String> {
32 crate::backend::agent_session::is_valid_definition_id(uid).then(|| format!("agent-uid:{uid}:bundle"))
33}
34
35pub(crate) fn get(fs: &FileStore, uid: &str) -> Result<Option<String>, StoreError> {
37 let Some(zone) = zone(uid) else { return Ok(None) };
38 let Some(bytes) = fs.read_files_consistent(&zone, &[FILE])?.pop().flatten() else { return Ok(None) };
39 let s: Sidecar = serde_json::from_slice(&bytes).map_err(|e| StoreError::Other(format!("bundle sidecar unreadable: {e}")))?;
40 Ok(Some(s.memory_id).filter(|m| !m.is_empty()))
41}
42
43pub(crate) fn claim(fs: &FileStore, uid: &str, memory_id: &str) -> Result<String, StoreError> {
47 claim_unless_live(fs, uid, memory_id, &|_| true)
48}
49
50pub(crate) fn claim_unless_live(
58 fs: &FileStore,
59 uid: &str,
60 memory_id: &str,
61 is_live: &dyn Fn(&str) -> bool,
62) -> Result<String, StoreError> {
63 let Some(zone) = zone(uid) else { return Ok(memory_id.to_string()) };
64 fs.zone_txn(&zone, |z| {
65 let mut s: Sidecar = match z.read(FILE)? {
66 Some(b) => serde_json::from_slice(&b).map_err(|e| StoreError::Other(format!("bundle sidecar unreadable: {e}")))?,
67 None => Sidecar::default(),
68 };
69 if !s.memory_id.is_empty() && is_live(&s.memory_id) {
70 return Ok(s.memory_id);
71 }
72 s.memory_id = memory_id.to_string();
73 z.put(FILE, &serde_json::to_vec(&s).map_err(|e| StoreError::Other(e.to_string()))?)?;
74 Ok(s.memory_id)
75 })
76}
77
78pub(crate) fn record(uid: &str, memory_id: &str) {
81 let Some(fs) = crate::backend::agent_session::global_transcript_store() else { return };
82 if let Err(e) = claim(fs, uid, memory_id) {
83 tracing::warn!(uid, error = %e, "agent bundle sidecar not recorded");
84 }
85}
86
87pub(crate) fn open_global() -> Option<FileStore> {
90 let dir = crate::registry::resolve_shared_transcripts_dir()?;
91 std::fs::create_dir_all(&dir).ok()?;
92 FileStore::open(&dir.join("filestore.db")).ok()
93}
94
95#[cfg(test)]
96mod tests {
97 use super::*;
98
99 #[test]
100 fn the_first_recorded_bundle_wins() {
101 let fs = FileStore::open_in_memory().unwrap();
102 assert_eq!(get(&fs, "agent-a").unwrap(), None);
103 assert_eq!(claim(&fs, "agent-a", "b1").unwrap(), "b1");
104 assert_eq!(claim(&fs, "agent-a", "b2").unwrap(), "b1", "another channel's later bundle doesn't replace it");
105 assert_eq!(get(&fs, "agent-a").unwrap().as_deref(), Some("b1"));
106 assert_eq!(get(&fs, "agent-b").unwrap(), None);
107 }
108
109 #[test]
110 fn a_recorded_bundle_the_caller_cant_find_is_replaced() {
111 let fs = FileStore::open_in_memory().unwrap();
112 assert_eq!(claim(&fs, "agent-a", "invisible").unwrap(), "invisible");
113 let live = |id: &str| id != "invisible";
114 assert_eq!(claim_unless_live(&fs, "agent-a", "b2", &live).unwrap(), "b2");
115 assert_eq!(get(&fs, "agent-a").unwrap().as_deref(), Some("b2"));
116 assert_eq!(claim_unless_live(&fs, "agent-a", "b3", &live).unwrap(), "b2");
118 }
119}