Skip to main content

agentmux_srv/backend/
global_memory_record.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! AgentMux's own record of Global Memory
5//! (SPEC_MEMORY_FOLLOWS_THE_AGENT_2026_09_24.md §2.1.6, phase M4).
6//!
7//! Global Memory lives in `db_bundles` (`is_global = 1`) in the channel's
8//! identity store: per channel on an isolated channel, `shared/store.db`
9//! otherwise. This record, in the global transcript store, is what it can
10//! later follow agents from — into a new isolated channel's import step, and
11//! to other machines (M5). `db_bundles` stays the cache every reader uses;
12//! nothing here changes what an agent receives.
13//!
14//! Zone `global-memory:<scope>`, `<scope>` being `shared` for channels that
15//! share their identity store and `channel:<id>` for an isolated one — the
16//! same boundary isolation draws today, so an isolated test channel's edits
17//! never reach the real record.
18//! - `log.jsonl`: `entry` events (a version of an entry, by its stable id —
19//!   the bundle id; a tombstone when it stops being Global Memory) and
20//!   `order` events (the full id list).
21//! - `blob/<sha>`: `{name, instructions}` per distinct version.
22//! - `heads.json`: each entry's head, and the current order.
23//!
24//! **Write-through.** The store's Global Memory write methods tell an
25//! observer after each change ([`attach`]); the observer only queues the
26//! change, and a worker thread reads the committed row and records it —
27//! never blocking a write. The record only appends when content differs, so
28//! several srv processes on one store record each change once. The source is
29//! the latest `db_bundle_versions` row's when that matches the content.
30//!
31//! **Older builds** write `db_bundles` without it; each start compares every
32//! entry with the record and records what differs as `legacy-build` (as
33//! `baseline` the first time).
34
35use 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
48/// This channel's Global Memory scope: `shared`, or `channel:<id>` when it
49/// is isolated.
50pub(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    // A channel id is free text; keep the zone name to safe characters.
60    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    /// `content_hash(name, instructions)`; `None` for a tombstone.
73    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    /// The scope this record is for, as written — its zone name is a hash
108    /// when the channel id has characters a zone name can't.
109    #[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/// An entry's `(name, instructions)` at a version's hash.
144#[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/// Every version of an entry, oldest first.
151#[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
164/// Who wrote what an entry now holds: the latest version row, when it holds
165/// this content.
166fn 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
174/// Record entry `id` as the store now has it, if the record differs: a new
175/// version while it is Global Memory, a tombstone once it isn't. `source`
176/// overrides the attribution (the startup comparison). Returns whether
177/// anything was recorded.
178pub(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
229/// Record the Global Memory order if it differs from the record's.
230pub(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
245/// At start: record every entry and the order as the store has them —
246/// `baseline` the first time, `legacy-build` for what an older build (or a
247/// change the worker never reached) left different. Returns how many
248/// entries were recorded.
249pub(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// ── Import into an isolated channel (§2.1.6) ────────────────────────────
265//
266// An isolated channel keeps its own Global Memory, on purpose: destructive
267// testing there can't touch the real store. So a new one starts
268// empty of the entries people added elsewhere. Memory → Global offers to bring
269// them in from another scope's record — never silently.
270
271#[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/// Another scope whose record holds entries this channel lacks.
279#[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    /// `shared`, or `channel:<id>`.
284    pub scope: String,
285    /// Its live, non-system entries this channel doesn't have, in its order.
286    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    /// This channel's scope. Sources are offered only to an isolated one.
294    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    /// Added under a new name: another entry here already had theirs.
303    pub renamed: usize,
304}
305
306struct IssuedImport {
307    zones: Vec<(String, String)>, // (zone, scope)
308    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
319/// The live, non-system entries of the record in `zone` that `store` doesn't
320/// have as Global Memory, in that record's order.
321fn 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
340/// The other scopes this channel could import Global Memory from, under a
341/// new `list_id`. Empty unless this channel is isolated.
342pub(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
374/// Bring source `index` of list `list_id` into this channel's Global Memory:
375/// the entries it still lacks, each keeping its id, after the ones here. An
376/// entry already here is never overwritten; a name another entry here has is
377/// taken as `<name> (<id short>)`.
378pub(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
415/// What the worker is asked to do.
416enum 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
436/// Handle for queueing the start-up comparison once seeding is done.
437pub(crate) struct Recorder {
438    tx: mpsc::Sender<Job>,
439}
440
441impl Recorder {
442    /// Queue the start-up comparison (after this start's own seeding, so
443    /// seeded entries keep their seeder's attribution).
444    pub(crate) fn sync_all(&self) {
445        let _ = self.tx.send(Job::SyncAll);
446    }
447}
448
449/// Start recording `store`'s Global Memory into `fs`: attach the observer
450/// and start the worker thread.
451pub(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;