agentmux_srv/server/app_api/global_memory.rs
1// Copyright 2024-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Global Memory MuxAPI bodies, moved out of app_api/mod.rs unchanged
5//! (SPEC_LARGE_FILE_MODULE_ANALYSIS_2026_09_30.md item 9).
6
7use super::*;
8
9/// Caller-supplied provenance for a `globalmemory.write` call — same shape
10/// as `MemoryWriteProvenance` above; kept as a distinct type since callers
11/// are conceptually different (an agent writing to a workspace-wide list
12/// every other agent inherits, not its own private memory) even though the
13/// wire shape is identical.
14pub(crate) struct GlobalMemoryWriteProvenance<'a> {
15 pub source: &'a str,
16 pub detail: &'a str,
17}
18
19/// Creates a new ordinary Global Memory entry, or updates an existing one
20/// by `id`. Backs the `GlobalMemoryWrite` MCP tool.
21pub(crate) fn global_memory_write_impl(
22 state: &AppState,
23 agent_id: &str,
24 written_by_uid: &str,
25 id: Option<&str>,
26 name: &str,
27 content: &str,
28 provenance: Option<GlobalMemoryWriteProvenance<'_>>,
29) -> Result<serde_json::Value, String> {
30 let name = name.trim();
31 if name.is_empty() {
32 return Err("globalmemory.write: name is required".to_string());
33 }
34 // Generous but bounded — Global Memory entries are meant to be readable
35 // instructions text, not arbitrary file storage (memory.write's own
36 // 10MB cap is for markdown notes files; this is an order of magnitude
37 // tighter since every agent's launch has to parse and inject this).
38 const MAX: usize = 1024 * 1024;
39 if content.len() > MAX {
40 return Err(format!("globalmemory.write: content too large ({} bytes, max {MAX})", content.len()));
41 }
42
43 let now = agentmux_common::time::now_ms();
44 let is_new = id.map(|v| v.is_empty()).unwrap_or(true);
45 let bundle = match id {
46 Some(existing_id) if !existing_id.is_empty() => {
47 let mut existing = state.id_store.bundle_get(existing_id)
48 .map_err(|e| format!("globalmemory.write: {e}"))?
49 .ok_or_else(|| format!("globalmemory.write: not found id={existing_id}"))?;
50 // Belt-and-suspenders with bundle_upsert's own guard (see this
51 // section's top-of-block comment) — refuse here too, before
52 // even attempting the write, so the error message is specific
53 // to this API rather than bundle_upsert's generic one.
54 if existing.is_system {
55 return Err(
56 "globalmemory.write: cannot modify a system Global Memory entry through this API"
57 .to_string(),
58 );
59 }
60 // reagent P0, PR #3237: `id` is caller-supplied and bundle ids
61 // for EVERY bundle in the system (not just global ones) are
62 // already enumerable by any agent via PresetList
63 // (bundle_list_impl, unfiltered). Without these two checks, an
64 // agent could target any other agent's private preset — or the
65 // seeded 'blank' singleton — by id, rename/replace its content,
66 // and force it into is_global=true, hijacking it into every
67 // agent's launch instructions workspace-wide. Mirrors
68 // upsertmemory's own already-fixed blank-singleton guard
69 // (agent_handlers/bundle.rs, reagent P1, 2026-05-08) and adds
70 // the is_global check that guard alone doesn't cover: this API
71 // may only ever EDIT an entry that was already a Global Memory
72 // entry BEFORE this call, never silently promote/adopt an
73 // unrelated bundle into one.
74 if existing.is_blank || existing.id == "blank" {
75 return Err("globalmemory.write: cannot mutate the blank Bundle singleton".to_string());
76 }
77 if !existing.is_global {
78 return Err(format!(
79 "globalmemory.write: id={existing_id} is not a Global Memory entry — this API cannot adopt an unrelated bundle, only create a new entry (omit id) or edit an existing Global Memory one"
80 ));
81 }
82 existing.name = name.to_string();
83 existing.instructions = content.to_string();
84 existing.is_global = true;
85 existing.updated_at = now;
86 existing
87 }
88 _ => Bundle {
89 id: uuid::Uuid::new_v4().to_string(),
90 name: name.to_string(),
91 description: String::new(),
92 is_blank: false,
93 is_global: true,
94 provider: String::new(),
95 model: String::new(),
96 instructions: content.to_string(),
97 instructions_by_provider: "{}".to_string(),
98 context_files: "[]".to_string(),
99 mcp_servers: "[]".to_string(),
100 skills: "[]".to_string(),
101 sort_order: 0,
102 is_system: false,
103 created_at: now,
104 updated_at: now,
105 },
106 };
107
108 // `written_by` is the TRUSTED `agent_id` this function received
109 // (agentmux-mcp's own AGENTMUX_AGENT_ID, unforgeable from the agent's
110 // PTY — see this section's top-of-block comment) — never taken from
111 // caller-supplied `source`/`source_detail`, which stay free-form
112 // annotation. bundle_upsert_with_version records both, atomically with
113 // the bundle write itself (codex P2, PR #3237 — see that method's own
114 // doc comment for why two separate calls here would have been unsafe
115 // under concurrent writers). `written_by_uid` is the writer's UID from
116 // the request's `Caller` (identity M4c-1, spec §6.5.9) — its token,
117 // never a body field; `""` when Unattributed.
118 let (source, detail) = match &provenance {
119 Some(p) => (p.source, p.detail),
120 None => ("agent_inferred", "{}"),
121 };
122 let source_detail = if detail.is_empty() { "{}" } else { detail };
123 state.id_store
124 .bundle_upsert_with_version(&bundle, agent_id, written_by_uid, source, source_detail)
125 .map_err(|e| format!("globalmemory.write: {e}"))?;
126
127 // New entries sort LAST, matching Memory → Global's own "+ New section"
128 // behavior (global-bundle-model.ts's saveEdit) — without this, a fresh
129 // bundle_id defaults to sort_order=0 and could land ambiguously
130 // interleaved with any other 0-sort_order row instead of visibly
131 // appended at the end of the order the operator/agent expects.
132 if is_new {
133 let ordinary_ids: Vec<String> = state.id_store.bundle_list_global()
134 .map_err(|e| format!("globalmemory.write: {e}"))?
135 .into_iter()
136 .filter(|b| !b.is_system && b.id != bundle.id)
137 .map(|b| b.id)
138 .chain(std::iter::once(bundle.id.clone()))
139 .collect();
140 if let Err(e) = state.id_store.bundle_reorder(&ordinary_ids) {
141 tracing::warn!(agent_id, bundle_id = %bundle.id, error = %e, "globalmemory.write: append-to-order failed (non-fatal)");
142 }
143 }
144
145 state.broker.publish_event::<crate::backend::mps_events::MemoriesChanged>(&(), vec![], 0);
146 tracing::info!(agent_id, bundle_id = %bundle.id, name = %bundle.name, "globalmemory.write");
147 Ok(json!({ "id": bundle.id, "name": bundle.name }))
148}
149
150/// Every Global Memory entry Memory → Global shows — id/name/updated_at/system,
151/// no content (mirrors `memory_list_impl`'s summary shape), system rows first
152/// as `bundle_list_global` orders them. System-tier rows are listed so an empty
153/// result means "nothing" and not "nothing you may touch" (the pane lists them
154/// too, and their content is already injected into every agent's context); what
155/// stays structural is that read, write and remove refuse a system id. Backs
156/// the `GlobalMemoryList` MCP tool.
157/// docs/specs/SPEC_GLOBAL_MEMORY_LIST_SYSTEM_ROWS_2026_10_02.md
158pub(crate) fn global_memory_list_impl(state: &AppState) -> Result<serde_json::Value, String> {
159 let entries: Vec<_> = state.id_store.bundle_list_global()
160 .map_err(|e| format!("globalmemory.list: {e}"))?
161 .into_iter()
162 .map(|b| json!({ "id": b.id, "name": b.name, "updated_at": b.updated_at, "system": b.is_system }))
163 .collect();
164 Ok(json!({ "entries": entries }))
165}
166
167/// Full content of one ordinary Global Memory entry by id. Refuses a
168/// system-tier id, same reasoning as write. Backs the `GlobalMemoryRead`
169/// MCP tool.
170pub(crate) fn global_memory_read_impl(state: &AppState, id: &str) -> Result<serde_json::Value, String> {
171 let bundle = state.id_store.bundle_get(id)
172 .map_err(|e| format!("globalmemory.read: {e}"))?
173 .ok_or_else(|| format!("globalmemory.read: not found id={id}"))?;
174 if bundle.is_system {
175 return Err("globalmemory.read: cannot read a system Global Memory entry through this API".to_string());
176 }
177 if !bundle.is_global {
178 return Err(format!("globalmemory.read: {id} is not a Global Memory entry"));
179 }
180 Ok(json!({ "id": bundle.id, "name": bundle.name, "content": bundle.instructions }))
181}
182
183/// Demotes a Global Memory entry (clears `is_global`) — matches Memory →
184/// Global's own "Remove" semantics: the bundle row itself survives (still
185/// available in Memory → Bundles), only its Global Memory membership is
186/// cleared. Refuses a system-tier id, the blank singleton, or any id that
187/// wasn't ALREADY a Global Memory entry before this call — same ownership
188/// guards as `global_memory_write_impl`, for the identical reason: bundle
189/// ids are enumerable via `PresetList` regardless of `is_global`. Backs the
190/// `GlobalMemoryRemove` MCP tool.
191pub(crate) fn global_memory_remove_impl(state: &AppState, id: &str) -> Result<serde_json::Value, String> {
192 let mut bundle = state.id_store.bundle_get(id)
193 .map_err(|e| format!("globalmemory.remove: {e}"))?
194 .ok_or_else(|| format!("globalmemory.remove: not found id={id}"))?;
195 if bundle.is_system {
196 return Err("globalmemory.remove: cannot remove a system Global Memory entry through this API".to_string());
197 }
198 // reagent P1, PR #3237 (re-review): same missing-ownership-check bug
199 // already fixed for write above, left unfixed here. Bundle ids for
200 // every bundle are enumerable via PresetList (unfiltered), so without
201 // this an agent could call GlobalMemoryRemove with another agent's
202 // private preset id (or 'blank') — is_global is already false so the
203 // flag write is a no-op, but bundle_upsert still rewrites the row and
204 // bumps updated_at on a bundle this API has no business touching at
205 // all. This API may only ever act on an entry that was already Global
206 // Memory before the call.
207 if bundle.is_blank || bundle.id == "blank" {
208 return Err("globalmemory.remove: cannot mutate the blank Bundle singleton".to_string());
209 }
210 if !bundle.is_global {
211 return Err(format!("globalmemory.remove: id={id} is not a Global Memory entry"));
212 }
213 bundle.is_global = false;
214 bundle.updated_at = agentmux_common::time::now_ms();
215 state.id_store.bundle_upsert(&bundle).map_err(|e| format!("globalmemory.remove: {e}"))?;
216 state.broker.publish_event::<crate::backend::mps_events::MemoriesChanged>(&(), vec![], 0);
217 Ok(json!({ "ok": true }))
218}
219
220/// A version's metadata as JSON — `id`/`content_hash`/`parent_version_id`/
221/// `source`/`source_detail`/`written_by`/`created_at`, no `name`/
222/// `instructions`. Takes the five common-field accessors as plain values
223/// rather than borrowing a concrete struct so both `BundleVersionSummary`
224/// (the `history` list shape) and `BundleVersion` (the full-content shape
225/// `revert`'s freshly-inserted row comes back as) can share this one
226/// formatter without either type needing to convert into the other.
227fn bundle_version_meta_json(
228 id: &str,
229 content_hash: &str,
230 parent_version_id: &Option<String>,
231 source: &str,
232 source_detail: &str,
233 written_by: &str,
234 written_by_uid: &str,
235 created_at: i64,
236) -> serde_json::Value {
237 json!({
238 "id": id,
239 "content_hash": content_hash,
240 "parent_version_id": parent_version_id,
241 "source": source,
242 "source_detail": source_detail,
243 "written_by": written_by,
244 // Identity M4c-2d (spec §6.5.9): the writer's UID beside its name;
245 // empty when the write was Unattributed.
246 "written_by_uid": written_by_uid,
247 "created_at": created_at,
248 })
249}
250
251/// Loads `id`'s bundle and refuses it unless it is (a) not the blank
252/// singleton, (b) not a system-tier entry, and (c) currently a Global Memory
253/// entry (`is_global`) — the exact guard `global_memory_write_impl`/
254/// `global_memory_remove_impl` already apply, reused here rather than
255/// re-derived so `history`/`diff`/`revert` can never diverge from it. Bundle
256/// ids for EVERY bundle in the system (not just global ones) are enumerable
257/// via `PresetList` (reagent P0/P1, PR #3237) — without this, an agent could
258/// point any of these three new routes at another agent's private preset, or
259/// at AgentMux's own system-tier entries, and read/restore content that was
260/// never meant to be reachable through this API.
261fn load_ordinary_global_bundle(state: &AppState, id: &str, verb: &str) -> Result<Bundle, String> {
262 let bundle = state.id_store.bundle_get(id)
263 .map_err(|e| format!("globalmemory.{verb}: {e}"))?
264 .ok_or_else(|| format!("globalmemory.{verb}: not found id={id}"))?;
265 if bundle.is_system {
266 return Err(format!(
267 "globalmemory.{verb}: cannot {verb} a system Global Memory entry through this API"
268 ));
269 }
270 if bundle.is_blank || bundle.id == "blank" {
271 return Err(format!("globalmemory.{verb}: cannot {verb} the blank Bundle singleton"));
272 }
273 if !bundle.is_global {
274 return Err(format!("globalmemory.{verb}: id={id} is not a Global Memory entry"));
275 }
276 Ok(bundle)
277}
278
279/// Version history for one Global Memory entry, newest first — metadata
280/// only, no `name`/`instructions` (mirrors `memory_history_impl`'s summary
281/// shape and `global_memory_list_impl`'s "no content" posture). Refuses a
282/// system-tier, blank, or non-global id via `load_ordinary_global_bundle` —
283/// structurally the same exclusion `list`/`read`/`remove` already apply, so
284/// there is no path here that can ever surface a system-tier row's history.
285/// Backs the `GlobalMemoryHistory` MCP tool.
286pub(crate) fn global_memory_history_impl(state: &AppState, id: &str) -> Result<serde_json::Value, String> {
287 load_ordinary_global_bundle(state, id, "history")?;
288 let versions: Vec<_> = state.id_store.bundle_version_list(id)
289 .map_err(|e| format!("globalmemory.history: store: {e}"))?
290 .iter()
291 .map(|v| bundle_version_meta_json(
292 &v.id, &v.content_hash, &v.parent_version_id, &v.source, &v.source_detail, &v.written_by,
293 &v.written_by_uid, v.created_at,
294 ))
295 .collect();
296 Ok(json!({ "versions": versions }))
297}
298
299/// A line-based diff between two recorded versions of one Global Memory
300/// entry (mirrors `memory_diff_impl`). Both versions must belong to `id` —
301/// reagent P1 in `memory_diff_impl` fixed the identical "diff across two
302/// unrelated owners" gap for native memory; the same check applies here
303/// since a `version_id` alone does not otherwise prove which bundle (and
304/// therefore whether a system-tier one) it came from. A name change is
305/// surfaced as its own two-line entry ahead of the instructions diff, since
306/// `content_hash` is computed over `name` + `instructions` together
307/// (`bundle_versions.rs`'s own `content_hash` doc comment) — a rename with
308/// unchanged body would otherwise produce a "no differences" instructions
309/// diff that silently hid the one thing that actually changed. Backs the
310/// `GlobalMemoryDiff` MCP tool.
311pub(crate) fn global_memory_diff_impl(
312 state: &AppState,
313 id: &str,
314 from_version_id: &str,
315 to_version_id: &str,
316) -> Result<serde_json::Value, String> {
317 load_ordinary_global_bundle(state, id, "diff")?;
318 let from = state.id_store.bundle_version_get(from_version_id)
319 .map_err(|e| format!("globalmemory.diff: store: {e}"))?
320 .ok_or_else(|| format!("globalmemory.diff: version {from_version_id} not found"))?;
321 let to = state.id_store.bundle_version_get(to_version_id)
322 .map_err(|e| format!("globalmemory.diff: store: {e}"))?
323 .ok_or_else(|| format!("globalmemory.diff: version {to_version_id} not found"))?;
324 if from.bundle_id != id || to.bundle_id != id {
325 return Err(format!("globalmemory.diff: one or both versions do not belong to {id}"));
326 }
327 Ok(json!({ "diff": bundle_version_diff(&from, &to) }))
328}
329
330/// The diff text `global_memory_diff_impl` returns — a `- name:`/`+ name:`
331/// pair when the name changed (see that function's doc comment for why),
332/// then `line_diff` over the instructions. Shared with Memory → Global's
333/// `globalmemory:diff` WebSocket command (`agent_handlers/bundle.rs`) so the
334/// MCP tool and the UI can never render the same pair of versions two ways.
335pub(crate) fn bundle_version_diff(
336 from: &crate::backend::storage::BundleVersion,
337 to: &crate::backend::storage::BundleVersion,
338) -> String {
339 let mut diff = String::new();
340 if from.name != to.name {
341 diff.push_str(&format!("- name: {}\n+ name: {}\n", from.name, to.name));
342 }
343 diff.push_str(&crate::server::native_memory_handlers::line_diff(&from.instructions, &to.instructions));
344 diff
345}
346
347/// Restores a Global Memory entry's live `name`/`instructions` to a prior
348/// recorded version — same "revert never rewrites history, it records a new
349/// version" semantics as `memory_revert_impl` (`source: "revert"`, chained
350/// onto whatever the current latest version is). Reuses
351/// `Store::bundle_upsert_with_version` — the SAME primitive
352/// `global_memory_write_impl` already uses — so the live row and its new
353/// version are written atomically, in one transaction, exactly as that
354/// method's own doc comment requires (codex P2, PR #3237). `agent_id` is the
355/// TRUSTED caller identity (an agent's real `AGENTMUX_AGENT_ID`, resolved by
356/// `agentmux-mcp` from its own env, never caller-suppliable) recorded as
357/// `written_by` — never inferred from anything in the request body.
358/// `written_by_uid` is recorded beside it: the request's `Caller` UID
359/// (identity M4c-1), `""` when Unattributed. Backs the `GlobalMemoryRevert`
360/// MCP tool.
361pub(crate) fn global_memory_revert_impl(
362 state: &AppState,
363 agent_id: &str,
364 written_by_uid: &str,
365 id: &str,
366 version_id: &str,
367) -> Result<serde_json::Value, String> {
368 let mut bundle = load_ordinary_global_bundle(state, id, "revert")?;
369 let target = state.id_store.bundle_version_get(version_id)
370 .map_err(|e| format!("globalmemory.revert: store: {e}"))?
371 .ok_or_else(|| format!("globalmemory.revert: version {version_id} not found"))?;
372 if target.bundle_id != id {
373 return Err(format!("globalmemory.revert: version {version_id} does not belong to {id}"));
374 }
375
376 bundle.name = target.name.clone();
377 bundle.instructions = target.instructions.clone();
378 bundle.updated_at = agentmux_common::time::now_ms();
379
380 let detail = json!({ "reverted_to": version_id }).to_string();
381 let new_version = state.id_store
382 .bundle_upsert_with_version(&bundle, agent_id, written_by_uid, "revert", &detail)
383 .map_err(|e| format!("globalmemory.revert: {e}"))?
384 // `bundle.is_global` is guaranteed true by `load_ordinary_global_bundle`
385 // above, and `bundle_upsert_with_version` only ever returns `None` when
386 // `is_global` is false — so this branch is unreachable in practice, but
387 // failing loudly beats silently reporting success with no new version.
388 .ok_or_else(|| "globalmemory.revert: no version was recorded (unexpected: entry is not global)".to_string())?;
389
390 state.broker.publish_event::<crate::backend::mps_events::MemoriesChanged>(&(), vec![], 0);
391 tracing::info!(agent_id, bundle_id = %id, version_id, "globalmemory.revert");
392 Ok(json!({ "version": bundle_version_meta_json(
393 &new_version.id,
394 &new_version.content_hash,
395 &new_version.parent_version_id,
396 &new_version.source,
397 &new_version.source_detail,
398 &new_version.written_by,
399 &new_version.written_by_uid,
400 new_version.created_at,
401 ) }))
402}