1use std::collections::{BTreeMap, HashSet};
36use std::sync::mpsc;
37use std::sync::Arc;
38
39use serde::{Deserialize, Serialize};
40
41use crate::backend::storage::error::StoreError;
42use crate::backend::storage::filestore::FileStore;
43use crate::backend::storage::store::{GlobalMemoryChange, Store};
44
45const LOG_FILE: &str = "log.jsonl";
46const HEADS_FILE: &str = "heads.json";
47
48pub(crate) fn scope() -> String {
51 if agentmux_common::isolated_auth_enabled() {
52 format!("channel:{}", crate::backend::reactive::registry::local_channel_id())
53 } else {
54 "shared".to_string()
55 }
56}
57
58fn zone(scope: &str) -> String {
59 if scope.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, ':' | '-' | '_' | '.')) {
61 format!("global-memory:{scope}")
62 } else {
63 format!("global-memory:h{}", &crate::backend::memory_record::sha256_hex(scope.as_bytes())[..32])
64 }
65}
66
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
68pub(crate) struct EntryVersion {
69 pub entry_id: String,
70 pub version: String,
71 pub parent: Option<String>,
72 pub sha256: Option<String>,
74 pub name: String,
75 #[serde(default)]
76 pub is_system: bool,
77 pub source: String,
78 #[serde(default)]
79 pub source_detail: String,
80 #[serde(default)]
81 pub written_by: String,
82 #[serde(default)]
83 pub written_by_uid: String,
84 pub created_at_ms: i64,
85}
86
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88#[serde(tag = "event", rename_all = "snake_case")]
89enum Event {
90 Entry(EntryVersion),
91 Order { ids: Vec<String>, at_ms: i64 },
92}
93
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95pub(crate) struct EntryHead {
96 pub version: String,
97 pub sha256: Option<String>,
98 pub name: String,
99 #[serde(default)]
100 pub is_system: bool,
101 #[serde(flatten)]
102 pub extra: serde_json::Map<String, serde_json::Value>,
103}
104
105#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
106pub(crate) struct Heads {
107 #[serde(default, skip_serializing_if = "String::is_empty")]
110 pub scope: String,
111 #[serde(default)]
112 pub entries: BTreeMap<String, EntryHead>,
113 #[serde(default)]
114 pub order: Vec<String>,
115 #[serde(flatten)]
116 pub extra: serde_json::Map<String, serde_json::Value>,
117}
118
119#[derive(Serialize, Deserialize)]
120struct Body {
121 name: String,
122 instructions: String,
123}
124
125fn read_heads(bytes: Option<Vec<u8>>) -> Result<Heads, StoreError> {
126 match bytes {
127 None => Ok(Heads::default()),
128 Some(b) => serde_json::from_slice(&b)
129 .map_err(|e| StoreError::Other(format!("global memory record: heads.json unreadable: {e}"))),
130 }
131}
132
133fn line(event: &Event) -> Result<Vec<u8>, StoreError> {
134 let mut l = serde_json::to_vec(event).map_err(|e| StoreError::Other(e.to_string()))?;
135 l.push(b'\n');
136 Ok(l)
137}
138
139pub(crate) fn heads(fs: &FileStore, scope: &str) -> Result<Heads, StoreError> {
140 read_heads(fs.read_files_consistent(&zone(scope), &[HEADS_FILE])?.pop().flatten())
141}
142
143#[cfg(test)]
145pub(crate) fn body(fs: &FileStore, scope: &str, sha256: &str) -> Result<Option<(String, String)>, StoreError> {
146 let got = fs.read_files_consistent(&zone(scope), &[&format!("blob/{sha256}")])?.pop().flatten();
147 Ok(got.and_then(|b| serde_json::from_slice::<Body>(&b).ok()).map(|b| (b.name, b.instructions)))
148}
149
150#[cfg(test)]
152pub(crate) fn history(fs: &FileStore, scope: &str, entry_id: &str) -> Result<Vec<EntryVersion>, StoreError> {
153 let log = fs.read_files_consistent(&zone(scope), &[LOG_FILE])?.pop().flatten().unwrap_or_default();
154 Ok(log
155 .split(|b| *b == b'\n')
156 .filter_map(|l| serde_json::from_slice::<Event>(l).ok())
157 .filter_map(|e| match e {
158 Event::Entry(v) if v.entry_id == entry_id => Some(v),
159 _ => None,
160 })
161 .collect())
162}
163
164fn attribution(store: &Store, id: &str, sha: &str) -> (String, String, String, String) {
167 let latest = store.bundle_version_list(id).ok().and_then(|v| v.into_iter().next());
168 match latest {
169 Some(v) if v.content_hash == sha => (v.source, v.source_detail, v.written_by, v.written_by_uid),
170 _ => ("agentmux".into(), String::new(), String::new(), String::new()),
171 }
172}
173
174pub(crate) fn sync_entry(fs: &FileStore, store: &Store, scope: &str, id: &str, source: Option<&str>) -> Result<bool, StoreError> {
179 let row = store.bundle_get(id)?.filter(|b| b.is_global);
180 let wanted = row.as_ref().map(|b| (crate::backend::storage::bundle_versions::content_hash(&b.name, &b.instructions), b));
181 let (src, detail, by, by_uid) = match (&wanted, source) {
182 (_, Some(s)) => (s.to_string(), String::new(), String::new(), String::new()),
183 (Some((sha, _)), None) => attribution(store, id, sha),
184 (None, None) => ("agentmux".into(), "removed from Global Memory".into(), String::new(), String::new()),
185 };
186 fs.zone_txn(&zone(scope), |z| {
187 let mut heads = read_heads(z.read(HEADS_FILE)?)?;
188 let head = heads.entries.get(id);
189 let same = match (&wanted, head) {
190 (Some((sha, b)), Some(h)) => h.sha256.as_deref() == Some(sha.as_str()) && h.is_system == b.is_system,
191 (None, Some(h)) => h.sha256.is_none(),
192 (None, None) => true,
193 (Some(_), None) => false,
194 };
195 if same {
196 return Ok(false);
197 }
198 let (sha, name, is_system) = match &wanted {
199 Some((sha, b)) => {
200 let blob = serde_json::to_vec(&Body { name: b.name.clone(), instructions: b.instructions.clone() })
201 .map_err(|e| StoreError::Other(e.to_string()))?;
202 z.put_if_absent(&format!("blob/{sha}"), &blob)?;
203 (Some(sha.clone()), b.name.clone(), b.is_system)
204 }
205 None => (None, head.map(|h| h.name.clone()).unwrap_or_default(), head.is_some_and(|h| h.is_system)),
206 };
207 let v = EntryVersion {
208 entry_id: id.to_string(),
209 version: format!("g_{}", uuid::Uuid::new_v4().simple()),
210 parent: head.map(|h| h.version.clone()),
211 sha256: sha.clone(),
212 name: name.clone(),
213 is_system,
214 source: src.clone(),
215 source_detail: detail.clone(),
216 written_by: by.clone(),
217 written_by_uid: by_uid.clone(),
218 created_at_ms: agentmux_common::time::now_ms(),
219 };
220 z.append_lines(LOG_FILE, &line(&Event::Entry(v.clone()))?)?;
221 let extra = head.map(|h| h.extra.clone()).unwrap_or_default();
222 heads.entries.insert(id.to_string(), EntryHead { version: v.version, sha256: sha, name, is_system, extra });
223 heads.scope = scope.to_string();
224 z.put(HEADS_FILE, &serde_json::to_vec(&heads).map_err(|e| StoreError::Other(e.to_string()))?)?;
225 Ok(true)
226 })
227}
228
229pub(crate) fn sync_order(fs: &FileStore, store: &Store, scope: &str) -> Result<bool, StoreError> {
231 let ids: Vec<String> = store.bundle_list_global()?.into_iter().map(|b| b.id).collect();
232 fs.zone_txn(&zone(scope), |z| {
233 let mut heads = read_heads(z.read(HEADS_FILE)?)?;
234 if heads.order == ids {
235 return Ok(false);
236 }
237 z.append_lines(LOG_FILE, &line(&Event::Order { ids: ids.clone(), at_ms: agentmux_common::time::now_ms() })?)?;
238 heads.order = ids.clone();
239 heads.scope = scope.to_string();
240 z.put(HEADS_FILE, &serde_json::to_vec(&heads).map_err(|e| StoreError::Other(e.to_string()))?)?;
241 Ok(true)
242 })
243}
244
245pub(crate) fn sync_all(fs: &FileStore, store: &Store, scope: &str) -> Result<usize, StoreError> {
250 let first = heads(fs, scope)?.entries.is_empty();
251 let source = if first { "baseline" } else { "legacy-build" };
252 let mut ids: HashSet<String> = store.bundle_list_global()?.into_iter().map(|b| b.id).collect();
253 ids.extend(heads(fs, scope)?.entries.into_iter().filter(|(_, h)| h.sha256.is_some()).map(|(id, _)| id));
254 let mut n = 0;
255 for id in ids {
256 if sync_entry(fs, store, scope, &id, Some(source))? {
257 n += 1;
258 }
259 }
260 sync_order(fs, store, scope)?;
261 Ok(n)
262}
263
264#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
272#[ts(export, export_to = "../../../frontend/types/rpc/", rename = "GlobalMemoryImportEntry")]
273pub(crate) struct ImportEntry {
274 pub entry_id: String,
275 pub name: String,
276}
277
278#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
280#[ts(export, export_to = "../../../frontend/types/rpc/", rename = "GlobalMemoryImportSource")]
281pub(crate) struct ImportSource {
282 pub index: usize,
283 pub scope: String,
285 pub missing: Vec<ImportEntry>,
287}
288
289#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
290#[ts(export, export_to = "../../../frontend/types/rpc/", rename = "GlobalMemoryImportSources")]
291pub(crate) struct ImportSources {
292 pub list_id: String,
293 pub scope: String,
295 pub sources: Vec<ImportSource>,
296}
297
298#[derive(Debug, Default, Clone, PartialEq, Serialize, ts_rs::TS)]
299#[ts(export, export_to = "../../../frontend/types/rpc/", rename = "GlobalMemoryImportReport")]
300pub(crate) struct ImportReport {
301 pub added: usize,
302 pub renamed: usize,
304}
305
306struct IssuedImport {
307 zones: Vec<(String, String)>, at: std::time::Instant,
309}
310
311fn issued_imports() -> &'static std::sync::Mutex<std::collections::HashMap<String, IssuedImport>> {
312 static ISSUED: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, IssuedImport>>> =
313 std::sync::OnceLock::new();
314 ISSUED.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
315}
316
317const IMPORT_LIST_TTL: std::time::Duration = std::time::Duration::from_secs(30 * 60);
318
319fn missing_from(fs: &FileStore, store: &Store, zone: &str) -> Result<(String, Vec<(String, EntryHead)>), StoreError> {
322 let heads = read_heads(fs.read_files_consistent(zone, &[HEADS_FILE])?.pop().flatten())?;
323 let label = if heads.scope.is_empty() { zone.trim_start_matches("global-memory:").to_string() } else { heads.scope.clone() };
324 let mut ids: Vec<String> = heads.order.iter().filter(|id| heads.entries.contains_key(*id)).cloned().collect();
325 ids.extend(heads.entries.keys().filter(|id| !heads.order.contains(id)).cloned());
326 let mut out = Vec::new();
327 for id in ids {
328 let Some(h) = heads.entries.get(&id) else { continue };
329 if h.sha256.is_none() || h.is_system {
330 continue;
331 }
332 if store.bundle_get(&id)?.is_some_and(|b| b.is_global) {
333 continue;
334 }
335 out.push((id, h.clone()));
336 }
337 Ok((label, out))
338}
339
340pub(crate) fn import_sources_in(fs: &FileStore, store: &Store, own_scope: &str) -> Result<ImportSources, StoreError> {
343 let mut sources = Vec::new();
344 let mut zones = Vec::new();
345 if own_scope.starts_with("channel:") {
346 let own_zone = zone(own_scope);
347 let mut all: Vec<String> =
348 fs.get_all_zone_ids()?.into_iter().filter(|z| z.starts_with("global-memory:") && *z != own_zone).collect();
349 all.sort_by_key(|z| (z != "global-memory:shared", z.clone()));
350 for z in all {
351 let (scope, missing) = missing_from(fs, store, &z)?;
352 if missing.is_empty() {
353 continue;
354 }
355 sources.push(ImportSource {
356 index: sources.len(),
357 scope: scope.clone(),
358 missing: missing.into_iter().map(|(entry_id, h)| ImportEntry { entry_id, name: h.name }).collect(),
359 });
360 zones.push((z, scope));
361 }
362 }
363 let list_id = uuid::Uuid::new_v4().simple().to_string();
364 let mut map = issued_imports().lock().unwrap_or_else(|e| e.into_inner());
365 map.retain(|_, i| i.at.elapsed() < IMPORT_LIST_TTL);
366 map.insert(list_id.clone(), IssuedImport { zones, at: std::time::Instant::now() });
367 Ok(ImportSources { list_id, scope: own_scope.to_string(), sources })
368}
369
370pub(crate) fn import_sources(fs: &FileStore, store: &Store) -> Result<ImportSources, StoreError> {
371 import_sources_in(fs, store, &scope())
372}
373
374pub(crate) fn import(fs: &FileStore, store: &Store, list_id: &str, index: usize) -> Result<ImportReport, StoreError> {
379 let (zone, scope) = {
380 let map = issued_imports().lock().unwrap_or_else(|e| e.into_inner());
381 let list = map
382 .get(list_id)
383 .filter(|l| l.at.elapsed() < IMPORT_LIST_TTL)
384 .ok_or_else(|| StoreError::Other("unknown-list: the list expired; list again".into()))?;
385 list.zones.get(index).cloned().ok_or_else(|| StoreError::Other("bad-choice".into()))?
386 };
387 let mut report = ImportReport::default();
388 let mut next_order = store.bundle_list_global()?.iter().map(|b| b.sort_order).max().unwrap_or(0) + 1;
389 for (id, head) in missing_from(fs, store, &zone)?.1 {
390 let Some(sha) = head.sha256.as_deref() else { continue };
391 let Some(bytes) = fs.read_files_consistent(&zone, &[&format!("blob/{sha}")])?.pop().flatten() else { continue };
392 let Ok(b) = serde_json::from_slice::<Body>(&bytes) else { continue };
393 let taken = store.bundle_list()?.into_iter().any(|x| x.name == b.name && x.id != id);
394 let name = if taken {
395 report.renamed += 1;
396 store.resolve_unique_bundle_name(&format!("{} ({})", b.name, &id[..id.len().min(8)]))?
397 } else {
398 b.name.clone()
399 };
400 let mut bundle: crate::backend::storage::store::Bundle = serde_json::from_value(serde_json::json!({
401 "id": id, "name": name, "instructions": b.instructions, "is_global": true,
402 "mcp_servers": "[]", "skills": "[]", "sort_order": next_order,
403 }))
404 .map_err(|e| StoreError::Other(e.to_string()))?;
405 let now = agentmux_common::time::now_ms();
406 bundle.created_at = now;
407 bundle.updated_at = now;
408 store.bundle_upsert_with_version(&bundle, "armory-ui", "", "import", &format!("from {scope}"))?;
409 next_order += 1;
410 report.added += 1;
411 }
412 Ok(report)
413}
414
415enum Job {
417 Change(GlobalMemoryChange),
418 SyncAll,
419}
420
421fn process(fs: &FileStore, store: &Store, scope: &str, job: Job) {
422 let r = match job {
423 Job::Change(GlobalMemoryChange::Entry(id)) => sync_entry(fs, store, scope, &id, None).map(|_| ()),
424 Job::Change(GlobalMemoryChange::Order) => sync_order(fs, store, scope).map(|_| ()),
425 Job::SyncAll => sync_all(fs, store, scope).map(|n| {
426 if n > 0 {
427 tracing::info!(scope, recorded = n, "global memory record: start-up comparison recorded entries");
428 }
429 }),
430 };
431 if let Err(e) = r {
432 tracing::warn!(scope, error = %e, "global memory record: not recorded; the next start compares again");
433 }
434}
435
436pub(crate) struct Recorder {
438 tx: mpsc::Sender<Job>,
439}
440
441impl Recorder {
442 pub(crate) fn sync_all(&self) {
445 let _ = self.tx.send(Job::SyncAll);
446 }
447}
448
449pub(crate) fn attach(fs: Arc<FileStore>, store: Arc<Store>) -> Recorder {
452 let (tx, rx) = mpsc::channel::<Job>();
453 let scope = scope();
454 let worker_store = store.clone();
455 let spawned = std::thread::Builder::new().name("global-memory-record".into()).spawn(move || {
456 while let Ok(job) = rx.recv() {
457 process(&fs, &worker_store, &scope, job);
458 }
459 });
460 if let Err(e) = spawned {
461 tracing::warn!(error = %e, "global memory record: worker not started; nothing is recorded this run");
462 }
463 let observer_tx = tx.clone();
464 store.set_global_memory_observer(Arc::new(move |change| {
465 let _ = observer_tx.send(Job::Change(change));
466 }));
467 Recorder { tx }
468}
469
470#[cfg(test)]
471mod tests;