Skip to main content

agentmux_srv/server/agent_handlers/
bundle.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4
5use std::sync::Arc;
6
7use serde_json::json;
8
9use crate::backend::rpc::engine::WshRpcEngine;
10use crate::backend::rpc_types::{
11    COMMAND_LIST_MEMORIES, COMMAND_GET_MEMORY,
12    COMMAND_GET_AGENT_BUNDLES, COMMAND_SET_AGENT_BUNDLES,
13    AgentBundlesResult, CommandGetAgentBundlesData, CommandSetAgentBundlesData,
14    COMMAND_UPSERT_MEMORY, COMMAND_DELETE_MEMORY, COMMAND_REORDER_GLOBAL_BRAIN,
15    COMMAND_UPSERT_SYSTEM_MEMORY, COMMAND_DELETE_SYSTEM_MEMORY,
16    COMMAND_GET_CLAUDE_GLOBAL_CONFIG,
17    COMMAND_GLOBAL_MEMORY_HISTORY, COMMAND_GLOBAL_MEMORY_DIFF, COMMAND_GLOBAL_MEMORY_REVERT,
18    COMMAND_GLOBAL_MEMORY_IMPORT_SOURCES, COMMAND_GLOBAL_MEMORY_IMPORT, COMMAND_GLOBAL_MEMORY_SECTIONS,
19    CommandGlobalMemoryImportSourcesData, CommandGlobalMemoryImportData, CommandGlobalMemorySectionsData,
20    CommandGetBundleData, CommandDeleteBundleData, DeleteBundleResult, CommandReorderGlobalBundlesData,
21    CommandListBundlesData, CommandGetClaudeGlobalConfigData, ReorderGlobalBundlesResult,
22    CommandUpsertBundleData, CommandGlobalMemoryHistoryData, CommandGlobalMemoryDiffData,
23    CommandGlobalMemoryRevertData, GlobalMemoryDiffResult, GlobalMemoryHistoryResult,
24    GlobalMemoryRevertResult, GlobalMemoryVersionMeta,
25};
26use crate::backend::storage::store::{Bundle, Store};
27use crate::backend::storage::{BundleVersion, BundleVersionSummary};
28
29use super::super::AppState;
30
31pub fn register(engine: &Arc<WshRpcEngine>, state: &AppState) {
32    register_global_memory_import(engine, state);
33    register_global_memory_sections(engine, state);
34    register_agent_bundles(engine, state);
35    // ---- Bundle CRUD ----
36
37    let mstore = state.id_store.clone();
38    engine.register_typed(
39        COMMAND_LIST_MEMORIES,
40        move |_req: CommandListBundlesData, _ctx| {
41            let mstore = mstore.clone();
42            async move {
43                let memories = mstore
44                    .bundle_list()
45                    .map_err(|e| format!("listmemories: {e}"))?;
46                Ok(memories)
47            }
48        },
49    );
50
51    let mstore = state.id_store.clone();
52    engine.register_typed(
53        COMMAND_GET_MEMORY,
54        move |cmd: CommandGetBundleData, _ctx| {
55            let mstore = mstore.clone();
56            async move {
57                match mstore
58                    .bundle_get(&cmd.id)
59                    .map_err(|e| format!("getmemory: {e}"))?
60                {
61                    Some(m) => Ok(m),
62                    None => Err(format!("getmemory: not found id={}", cmd.id)),
63                }
64            }
65        },
66    );
67
68    let mstore = state.id_store.clone();
69    let broker = state.broker.clone();
70    engine.register_typed(
71        COMMAND_UPSERT_MEMORY,
72        move |req: CommandUpsertBundleData, _ctx| {
73            let mstore = mstore.clone();
74            let broker = broker.clone();
75            async move {
76                let CommandUpsertBundleData { bundle: mut memory, base_sha256 } = req;
77                // Guard on BOTH client-supplied is_blank AND id == "blank".
78                // Without the id check a caller could send
79                // {id:"blank", is_blank:false, name:"evil"} and the
80                // ON CONFLICT(id) DO UPDATE path would rename/re-describe
81                // the seeded singleton. (reagent P1, 2026-05-08).
82                if memory.is_blank || memory.id == "blank" {
83                    return Err("upsertmemory: cannot mutate the blank singleton".to_string());
84                }
85                if memory.id.is_empty() {
86                    memory.id = uuid::Uuid::new_v4().to_string();
87                }
88                let now = agentmux_common::time::now_ms();
89                if memory.created_at == 0 {
90                    memory.created_at = now;
91                }
92                memory.updated_at = now;
93                // bundle_upsert_with_version, not bundle_upsert — this is
94                // the actual Memory → Global save path (frontend's
95                // UpsertBundleCommand -> "upsertmemory"; also used by the
96                // per-agent Bundle editor, which is fine: versioning only
97                // fires when memory.is_global is true either way). Without
98                // this, a human edit in Memory → Global was invisible to
99                // db_bundle_versions entirely — only agent-originated
100                // writes through GlobalMemoryWrite recorded a version, so a
101                // future history/diff view would present a false sequence
102                // with operator edits silently missing from it (codex P2,
103                // PR #3237). "armory-ui" as written_by: this handler runs
104                // over the frontend's authenticated WebSocket connection,
105                // which has no per-request trusted AGENT identity the way
106                // the REST/MCP path does (see BundleVersion::written_by's
107                // own doc comment) — a human via the UI is the only thing
108                // this code path can honestly claim. No writer UID either
109                // (identity M4c-1): the WebSocket is always Unattributed.
110                // `base_sha256` (SPEC_MEMORY_FOLLOWS_THE_AGENT_2026_09_24.md
111                // §2.4): the Memory editor says which content its draft
112                // started from, so a concurrent write (an agent's
113                // GlobalMemoryWrite, another window) becomes a `conflict:`
114                // refusal instead of being silently overwritten. Absent =
115                // the unconditional save every other caller still gets.
116                mstore
117                    .bundle_upsert_with_version_based(&memory, "armory-ui", "", "human", "{}", base_sha256.as_deref())
118                    .map_err(|e| format!("upsertmemory: {e}"))?;
119                broker.publish_event::<crate::backend::mps_events::MemoriesChanged>(&(), vec![], 0);
120                Ok(memory)
121            }
122        },
123    );
124
125    let id_store = state.id_store.clone();
126    let component_store = state.mstore.clone();
127    let broker = state.broker.clone();
128    engine.register_typed(
129        COMMAND_DELETE_MEMORY,
130        move |cmd: CommandDeleteBundleData, _ctx| {
131            let id_store = id_store.clone();
132            let component_store = component_store.clone();
133            let broker = broker.clone();
134            async move {
135                let deleted = id_store
136                    .bundle_delete(&cmd.id)
137                    .map_err(|e| format!("deletememory: {e}"))?;
138                if deleted {
139                    // This is the delete Memory actually calls
140                    // (`rpc-api/bundle.ts`), so the ref purge has to live here
141                    // too — fixing only `bundle.delete` left the product path
142                    // orphaning refs (Codex, PR #3153). Shared helper, not a
143                    // second copy of the logic.
144                    crate::server::app_api::bundle::purge_bundle_component_refs(
145                        &component_store,
146                        &cmd.id,
147                    );
148                    broker.publish_event::<crate::backend::mps_events::MemoriesChanged>(&(), vec![], 0);
149                }
150                Ok(DeleteBundleResult { deleted })
151            }
152        },
153    );
154
155    let mstore = state.id_store.clone();
156    let broker = state.broker.clone();
157    engine.register_typed(
158        COMMAND_REORDER_GLOBAL_BRAIN,
159        move |cmd: CommandReorderGlobalBundlesData, _ctx| {
160            let mstore = mstore.clone();
161            let broker = broker.clone();
162            async move {
163                let updated = mstore
164                    .bundle_reorder(&cmd.ids)
165                    .map_err(|e| format!("reorderglobalbrain: {e}"))?;
166                broker.publish_event::<crate::backend::mps_events::MemoriesChanged>(&(), vec![], 0);
167                Ok(ReorderGlobalBundlesResult { updated })
168            }
169        },
170    );
171
172    // ---- System-tier Global Bundle — see
173    // docs/specs/SPEC_GLOBAL_MEMORY_SYSTEM_TIER_2026_08_24.md. Deliberately
174    // separate commands from the four above (never wired to any MCP tool)
175    // so the ordinary Global Bundle editor and every other generic
176    // bundle-writing surface can never reach an is_system row.
177
178    let mstore = state.id_store.clone();
179    let broker = state.broker.clone();
180    engine.register_typed(
181        COMMAND_UPSERT_SYSTEM_MEMORY,
182        move |req: CommandUpsertBundleData, _ctx| {
183            let mstore = mstore.clone();
184            let broker = broker.clone();
185            async move {
186                let CommandUpsertBundleData { bundle: mut memory, base_sha256 } = req;
187                if memory.id.is_empty() {
188                    memory.id = uuid::Uuid::new_v4().to_string();
189                }
190                let now = agentmux_common::time::now_ms();
191                if memory.created_at == 0 {
192                    memory.created_at = now;
193                }
194                memory.updated_at = now;
195                // bundle_upsert_system_if_changed atomically compares against
196                // the current row and skips recording an "armory-ui" version
197                // for a byte-identical save (operator opens a seeded entry
198                // and hits Save without changing anything — the UI does not
199                // suppress this request) — recording one would permanently
200                // mark the row as human-owned even though nothing actually
201                // changed, so a future Operator Config manifest update would
202                // be skipped forever instead of just once. A REAL edit is
203                // still stamped written_by="armory-ui" as before, so the
204                // next startup's reseed correctly leaves it alone. The
205                // read-decide-write happens in one transaction, not as a
206                // separate bundle_get + conditional call here — an earlier
207                // revision raced a concurrent writer (e.g. another AgentMux
208                // instance's own reseed) between the read and the write.
209                // Codex P1/P2, ReAgent P2, PR #3244.
210                // Same optional base check as `upsertmemory` above.
211                mstore
212                    .bundle_upsert_system_if_changed_based(&memory, "armory-ui", "human", "{}", base_sha256.as_deref())
213                    .map_err(|e| format!("upsertsystemmemory: {e}"))?;
214                broker.publish_event::<crate::backend::mps_events::MemoriesChanged>(&(), vec![], 0);
215                // Return the row actually persisted, not the client-supplied
216                // struct — bundle_upsert_system hardcodes
217                // is_blank/is_global/is_system server-side regardless of
218                // what `memory` carried (e.g. the frontend's saveSystemEdit
219                // sends only id/name/instructions, so `memory.is_global`/
220                // `is_system` deserialize to false via #[serde(default)]).
221                // Echoing `memory` back would misreport both to any caller
222                // that trusts the response instead of refetching. reagent
223                // P2, PR #2782.
224                let saved = mstore
225                    .bundle_get(&memory.id)
226                    .map_err(|e| format!("upsertsystemmemory: {e}"))?
227                    .ok_or_else(|| format!("upsertsystemmemory: row {} vanished after upsert", memory.id))?;
228                Ok(saved)
229            }
230        },
231    );
232
233    let mstore = state.id_store.clone();
234    let broker = state.broker.clone();
235    engine.register_typed(
236        COMMAND_DELETE_SYSTEM_MEMORY,
237        move |cmd: CommandDeleteBundleData, _ctx| {
238            let mstore = mstore.clone();
239            let broker = broker.clone();
240            async move {
241                let deleted = mstore
242                    .bundle_delete_system(&cmd.id)
243                    .map_err(|e| format!("deletesystemmemory: {e}"))?;
244                if deleted {
245                    broker.publish_event::<crate::backend::mps_events::MemoriesChanged>(&(), vec![], 0);
246                }
247                Ok(DeleteBundleResult { deleted })
248            }
249        },
250    );
251
252    // ---- Read-only: the CLAUDE.md at AgentMux's shared Claude provider
253    // config dir — see docs/specs/SPEC_SURFACE_CLAUDE_GLOBAL_CONFIG_2026_08_24.md
254    // §5 (post-review revision). No parameters (fixed path, not
255    // caller-supplied), no write counterpart.
256    engine.register_typed(
257        COMMAND_GET_CLAUDE_GLOBAL_CONFIG,
258        move |_req: CommandGetClaudeGlobalConfigData, _ctx| async move {
259            let claude_dir = resolve_shared_claude_provider_dir();
260            read_claude_global_config(&claude_dir)
261                .map_err(|e| format!("getclaudeglobalconfig: {e}"))
262        },
263    );
264
265    // ---- Global Memory version history for Memory → Global
266    // (SPEC_MEMORY_FOLLOWS_THE_AGENT_2026_09_24.md §2.3). Same rows, same
267    // diff, same "revert records a new version" rule as the MCP tools in
268    // app_api/mod.rs (global_memory_{history,diff,revert}_impl) — the
269    // difference is the guard: Memory → Global can already edit and delete
270    // system-tier entries through upsertsystemmemory/deletesystemmemory, so
271    // their history is not hidden from it the way it is from agents.
272
273    let mstore = state.id_store.clone();
274    engine.register_typed(
275        COMMAND_GLOBAL_MEMORY_HISTORY,
276        move |cmd: CommandGlobalMemoryHistoryData, _ctx| {
277            let mstore = mstore.clone();
278            async move { global_memory_ui_history(&mstore, &cmd.id) }
279        },
280    );
281
282    let mstore = state.id_store.clone();
283    engine.register_typed(
284        COMMAND_GLOBAL_MEMORY_DIFF,
285        move |cmd: CommandGlobalMemoryDiffData, _ctx| {
286            let mstore = mstore.clone();
287            async move { global_memory_ui_diff(&mstore, &cmd) }
288        },
289    );
290
291    let mstore = state.id_store.clone();
292    let broker = state.broker.clone();
293    engine.register_typed(
294        COMMAND_GLOBAL_MEMORY_REVERT,
295        move |cmd: CommandGlobalMemoryRevertData, _ctx| {
296            let mstore = mstore.clone();
297            let broker = broker.clone();
298            async move {
299                let result = global_memory_ui_revert(&mstore, &cmd)?;
300                broker.publish_event::<crate::backend::mps_events::MemoriesChanged>(&(), vec![], 0);
301                Ok(result)
302            }
303        },
304    );
305}
306
307/// `getagentbundles` / `setagentbundles`: an agent's Bundles list, the
308/// bundles picked for it in order (`Store::agent_bundle_ids`). Its own bundle
309/// isn't in it: launch always puts that first. Window-scoped, like
310/// `setagentcontent`: the new-agent form, the launch modal and the Stash call
311/// these before the agent runs.
312fn register_agent_bundles(engine: &WshRpcEngine, state: &AppState) {
313    let mstore = state.mstore.clone();
314    engine.register_typed(COMMAND_GET_AGENT_BUNDLES, move |cmd: CommandGetAgentBundlesData, _ctx| {
315        let mstore = mstore.clone();
316        async move {
317            let bundle_ids = mstore
318                .agent_bundle_ids(&cmd.agent_id)
319                .map_err(|e| format!("getagentbundles: {e}"))?;
320            Ok(AgentBundlesResult { bundle_ids, own_bundle_id: own_bundle_id(&mstore, &cmd.agent_id) })
321        }
322    });
323    let mstore = state.mstore.clone();
324    engine.register_typed(COMMAND_SET_AGENT_BUNDLES, move |cmd: CommandSetAgentBundlesData, _ctx| {
325        let mstore = mstore.clone();
326        async move {
327            let bundle_ids = mstore
328                .agent_bundles_set(&cmd.agent_id, &cmd.bundle_ids)
329                .map_err(|e| format!("setagentbundles: {e}"))?;
330            Ok(AgentBundlesResult { bundle_ids, own_bundle_id: own_bundle_id(&mstore, &cmd.agent_id) })
331        }
332    });
333}
334
335fn own_bundle_id(mstore: &Store, agent_id: &str) -> String {
336    mstore.agent_def_get(agent_id).ok().flatten().map(|def| def.memory_id).unwrap_or_default()
337}
338
339/// Memory → Global's "Bring Global Memory from…" step for an isolated channel
340/// (SPEC_MEMORY_FOLLOWS_THE_AGENT_2026_09_24.md §2.1.6). Registered with the
341/// others below.
342/// `globalmemory:sections`: the Global Memory block an agent's startup file
343/// carries, from the same store (`id_store`) and formatter the startup-file
344/// writers use — so memory reinjection re-delivers it byte for byte,
345/// Operator Config included (SPEC_GLOBAL_MEMORY_DELIVERY_2026_09_27.md §7 P1).
346pub(crate) fn register_global_memory_sections(engine: &WshRpcEngine, state: &AppState) {
347    let id_store = state.id_store.clone();
348    let mstore = state.mstore.clone();
349    engine.register_typed(
350        COMMAND_GLOBAL_MEMORY_SECTIONS,
351        move |cmd: CommandGlobalMemorySectionsData, _ctx| {
352            let store = id_store.clone();
353            let mstore = mstore.clone();
354            async move {
355                let bundles = store.bundle_list_global().map_err(|e| format!("globalmemory:sections: {e}"))?;
356                // The same entries the pane's startup file got: Operator
357                // Config targeted at another agent kind is left out.
358                let agent_mode = cmd
359                    .block_id
360                    .as_deref()
361                    .map_or_else(|| "host".to_string(), |b| crate::backend::operator_config_seed::agent_mode_of_block(&mstore, b));
362                let bundles = crate::backend::operator_config_seed::global_bundles_for_agent(bundles, &agent_mode);
363                Ok::<Vec<crate::backend::storage::GlobalMemorySection>, String>(
364                    crate::backend::storage::global_bundle_sections(&bundles),
365                )
366            }
367        },
368    );
369}
370
371pub(crate) fn register_global_memory_import(engine: &WshRpcEngine, state: &AppState) {
372    let id_store = state.id_store.clone();
373    engine.register_typed(
374        COMMAND_GLOBAL_MEMORY_IMPORT_SOURCES,
375        move |_cmd: CommandGlobalMemoryImportSourcesData, _ctx| {
376            let store = id_store.clone();
377            async move {
378                let fs = crate::backend::agent_session::global_transcript_store()
379                    .ok_or_else(|| "globalmemory:import_sources: record store unavailable".to_string())?;
380                tokio::task::spawn_blocking(move || crate::backend::global_memory_record::import_sources(fs, &store))
381                    .await
382                    .map_err(|e| format!("globalmemory:import_sources: {e}"))?
383                    .map_err(|e| format!("globalmemory:import_sources: {e}"))
384            }
385        },
386    );
387    let id_store = state.id_store.clone();
388    let broker = state.broker.clone();
389    engine.register_typed(
390        COMMAND_GLOBAL_MEMORY_IMPORT,
391        move |cmd: CommandGlobalMemoryImportData, _ctx| {
392            let store = id_store.clone();
393            let broker = broker.clone();
394            async move {
395                let fs = crate::backend::agent_session::global_transcript_store()
396                    .ok_or_else(|| "globalmemory:import: record store unavailable".to_string())?;
397                let report = tokio::task::spawn_blocking(move || {
398                    crate::backend::global_memory_record::import(fs, &store, &cmd.list_id, cmd.index)
399                })
400                .await
401                .map_err(|e| format!("globalmemory:import: {e}"))?
402                .map_err(|e| format!("globalmemory:import: {e}"))?;
403                broker.publish_event::<crate::backend::mps_events::MemoriesChanged>(&(), vec![], 0);
404                Ok(report)
405            }
406        },
407    );
408}
409
410/// Loads a Global Memory entry for Memory → Global's history commands: it must
411/// exist, be `is_global`, and not be the blank singleton. System-tier rows
412/// are allowed (see the comment above the registrations).
413fn load_global_bundle_for_ui(store: &Store, id: &str, verb: &str) -> Result<Bundle, String> {
414    let bundle = store
415        .bundle_get(id)
416        .map_err(|e| format!("globalmemory:{verb}: {e}"))?
417        .ok_or_else(|| format!("globalmemory:{verb}: not found id={id}"))?;
418    if bundle.is_blank || bundle.id == "blank" {
419        return Err(format!("globalmemory:{verb}: cannot {verb} the blank Bundle singleton"));
420    }
421    if !bundle.is_global {
422        return Err(format!("globalmemory:{verb}: id={id} is not a Global Memory entry"));
423    }
424    Ok(bundle)
425}
426
427fn version_summary_meta(v: BundleVersionSummary) -> GlobalMemoryVersionMeta {
428    GlobalMemoryVersionMeta {
429        id: v.id,
430        content_hash: v.content_hash,
431        parent_version_id: v.parent_version_id,
432        source: v.source,
433        source_detail: v.source_detail,
434        written_by: v.written_by,
435        written_by_uid: v.written_by_uid,
436        created_at: v.created_at,
437    }
438}
439
440fn version_meta(v: BundleVersion) -> GlobalMemoryVersionMeta {
441    GlobalMemoryVersionMeta {
442        id: v.id,
443        content_hash: v.content_hash,
444        parent_version_id: v.parent_version_id,
445        source: v.source,
446        source_detail: v.source_detail,
447        written_by: v.written_by,
448        written_by_uid: v.written_by_uid,
449        created_at: v.created_at,
450    }
451}
452
453fn global_memory_ui_history(store: &Store, id: &str) -> Result<GlobalMemoryHistoryResult, String> {
454    load_global_bundle_for_ui(store, id, "history")?;
455    let versions = store
456        .bundle_version_list(id)
457        .map_err(|e| format!("globalmemory:history: store: {e}"))?
458        .into_iter()
459        .map(version_summary_meta)
460        .collect();
461    Ok(GlobalMemoryHistoryResult { versions })
462}
463
464fn global_memory_ui_diff(store: &Store, cmd: &CommandGlobalMemoryDiffData) -> Result<GlobalMemoryDiffResult, String> {
465    load_global_bundle_for_ui(store, &cmd.id, "diff")?;
466    let get = |vid: &str| {
467        store
468            .bundle_version_get(vid)
469            .map_err(|e| format!("globalmemory:diff: store: {e}"))?
470            .ok_or_else(|| format!("globalmemory:diff: version {vid} not found"))
471    };
472    let from = get(&cmd.from_version_id)?;
473    let to = get(&cmd.to_version_id)?;
474    // Both versions must belong to this entry — a version id alone does not
475    // prove which bundle it came from (same check as the MCP diff).
476    if from.bundle_id != cmd.id || to.bundle_id != cmd.id {
477        return Err(format!("globalmemory:diff: one or both versions do not belong to {}", cmd.id));
478    }
479    Ok(GlobalMemoryDiffResult { diff: crate::server::app_api::bundle_version_diff(&from, &to) })
480}
481
482/// Restores `name` + `instructions` from `target_version_id` as a NEW
483/// version (`source: "revert"`, `written_by: "armory-ui"`), through the same
484/// atomic upsert-plus-version primitive each tier's ordinary save uses.
485fn global_memory_ui_revert(store: &Store, cmd: &CommandGlobalMemoryRevertData) -> Result<GlobalMemoryRevertResult, String> {
486    let mut bundle = load_global_bundle_for_ui(store, &cmd.id, "revert")?;
487    let target = store
488        .bundle_version_get(&cmd.target_version_id)
489        .map_err(|e| format!("globalmemory:revert: store: {e}"))?
490        .ok_or_else(|| format!("globalmemory:revert: version {} not found", cmd.target_version_id))?;
491    if target.bundle_id != cmd.id {
492        return Err(format!(
493            "globalmemory:revert: version {} does not belong to {}",
494            cmd.target_version_id, cmd.id
495        ));
496    }
497    bundle.name = target.name;
498    bundle.instructions = target.instructions;
499    bundle.updated_at = agentmux_common::time::now_ms();
500    let detail = json!({ "reverted_to": cmd.target_version_id }).to_string();
501    let version = if bundle.is_system {
502        // Reverting to text AgentMux itself seeded hands the entry back to
503        // the seeder, so it keeps receiving Operator Config updates;
504        // recording it as a human edit would opt it out of them for good
505        // (`bundle_reseed_system_if_owned` skips any non-seeder last writer).
506        let seeder = crate::backend::operator_config_seed::WRITTEN_BY;
507        let written_by = if target.written_by == seeder { seeder } else { "armory-ui" };
508        store.bundle_upsert_system_if_changed(&bundle, written_by, "revert", &detail)
509    } else {
510        store.bundle_upsert_with_version(&bundle, "armory-ui", "", "revert", &detail)
511    }
512    .map_err(|e| format!("globalmemory:revert: {e}"))?;
513    tracing::info!(bundle_id = %cmd.id, version_id = %cmd.target_version_id, "globalmemory:revert");
514    Ok(GlobalMemoryRevertResult { version: version.map(version_meta) })
515}
516
517/// The directory a spawned Claude agent's `CLAUDE_CONFIG_DIR` env var
518/// points at by DEFAULT (agents not bound to an account — the common
519/// case; an agent bound to an account uses that account's own dir, which
520/// this does not cover). Mirrors `agent_open.rs`'s own `auth_dir`
521/// resolution exactly — `DataPaths::provider_auth_dir("claude")`, with the
522/// identical `~/.agentmux/shared/providers/claude` fallback when
523/// `DataPaths::from_env()` fails — so the path shown here is genuinely the
524/// one AgentMux itself uses when launching a Claude agent, not a guess.
525/// codex P1, PR #2794: the original version read `~/.claude/CLAUDE.md`
526/// directly, which `SPEC_PROVIDER_ISOLATION_2026_06_20.md`
527/// §5b confirms is NOT what a `CLAUDE_CONFIG_DIR`-redirected spawned agent
528/// actually loads as its "user CLAUDE.md" — `CLAUDE_CONFIG_DIR` relocates
529/// Claude Code's entire home, `<CLAUDE_CONFIG_DIR>/CLAUDE.md` included.
530fn resolve_shared_claude_provider_dir() -> std::path::PathBuf {
531    agentmux_common::DataPaths::from_env()
532        .map(|p| p.provider_auth_dir("claude"))
533        .unwrap_or_else(|| {
534            crate::backend::base::get_home_dir()
535                .join(".agentmux")
536                .join("shared")
537                .join("providers")
538                .join("claude")
539        })
540}
541
542/// The resolved shared-provider-dir's `CLAUDE.md` path + content,
543/// read-only. `claude_dir` is injected (not resolved internally) so this
544/// is testable against a tempdir. `content: None, exists: false` for a
545/// genuinely missing file (the common case — no AgentMux-spawned Claude
546/// agent on this host has one today, confirmed 2026-08-24) — real I/O
547/// errors (permission denied, etc.) still propagate as `Err`, not
548/// silently folded into "missing."
549#[derive(serde::Serialize, serde::Deserialize, ts_rs::TS)]
550#[ts(export, export_to = "../../../frontend/types/rpc/")]
551pub struct ClaudeGlobalConfig {
552    pub path: String,
553    /// Genuinely `string | null`, not an optional property: a plain
554    /// `Option<String>` with no `skip_serializing_if`, so the key is always
555    /// present and carries `null` when the file does not exist. That
556    /// distinction is load-bearing here — `exists: false` with
557    /// `content: null` is the meaningful "no file" answer, not an absent key.
558    pub content: Option<String>,
559    pub exists: bool,
560}
561
562fn read_claude_global_config(claude_dir: &std::path::Path) -> std::io::Result<ClaudeGlobalConfig> {
563    let path = claude_dir.join("CLAUDE.md");
564    let (content, exists) = match std::fs::read_to_string(&path) {
565        Ok(c) => (Some(c), true),
566        Err(e) if e.kind() == std::io::ErrorKind::NotFound => (None, false),
567        Err(e) => return Err(e),
568    };
569    Ok(ClaudeGlobalConfig { path: path.to_string_lossy().into_owned(), content, exists })
570}
571
572// Covers getclaudeglobalconfig (resolve_shared_claude_provider_dir).
573// The sibling getclaudehostconfig (~/.claude) handler was removed
574// 2026-09-01 — SPEC_ARMORY_DROP_HOST_CLI_CONFIG_BLOCK_2026_09_01.md.
575// See docs/specs/SPEC_SURFACE_CLAUDE_GLOBAL_CONFIG_2026_08_24.md §5.
576#[cfg(test)]
577mod claude_global_config_tests {
578    use super::*;
579
580    #[test]
581    fn returns_content_and_exists_true_when_the_file_is_present() {
582        let dir = tempfile::tempdir().unwrap();
583        std::fs::write(dir.path().join("CLAUDE.md"), "# My global rules\n").unwrap();
584
585        let result = read_claude_global_config(dir.path()).unwrap();
586        assert!(result.exists);
587        assert_eq!(result.content.as_deref(), Some("# My global rules\n"));
588        assert_eq!(result.path, dir.path().join("CLAUDE.md").to_string_lossy());
589    }
590
591    #[test]
592    fn returns_none_content_and_exists_false_when_the_file_is_missing_not_an_error() {
593        let dir = tempfile::tempdir().unwrap();
594        // Deliberately no CLAUDE.md written — the common case on a host
595        // where the user never created one.
596
597        let result = read_claude_global_config(dir.path()).unwrap();
598        assert!(!result.exists);
599        assert!(result.content.is_none());
600        // The path is still reported even when nothing exists there yet —
601        // useful information on its own (SPEC_SURFACE_CLAUDE_GLOBAL_CONFIG_2026_08_24.md §2.3).
602        assert_eq!(result.path, dir.path().join("CLAUDE.md").to_string_lossy());
603    }
604
605    #[test]
606    fn returns_content_for_an_empty_file_not_treated_as_missing() {
607        let dir = tempfile::tempdir().unwrap();
608        std::fs::write(dir.path().join("CLAUDE.md"), "").unwrap();
609
610        let result = read_claude_global_config(dir.path()).unwrap();
611        assert!(result.exists);
612        assert_eq!(result.content.as_deref(), Some(""));
613    }
614}
615
616#[cfg(test)]
617mod delete_memory_tests {
618    use super::*;
619    use crate::backend::rpc_types::{RpcMessage, COMMAND_DELETE_MEMORY, COMMAND_DELETE_SYSTEM_MEMORY};
620    use crate::server::tests::test_state;
621
622    fn seed_memory(state: &AppState, id: &str, is_system: bool) {
623        let now = agentmux_common::time::now_ms();
624        let memory = Bundle {
625            id: id.to_string(),
626            name: "Test Bundle".to_string(),
627            description: String::new(),
628            is_blank: false,
629            is_global: false,
630            provider: String::new(),
631            model: String::new(),
632            instructions: String::new(),
633            instructions_by_provider: "{}".to_string(),
634            context_files: "[]".to_string(),
635            mcp_servers: "[]".to_string(),
636            skills: "[]".to_string(),
637            sort_order: 0,
638            created_at: now,
639            updated_at: now,
640            is_system,
641        };
642        if is_system {
643            state.id_store.bundle_upsert_system(&memory).unwrap();
644        } else {
645            state.id_store.bundle_upsert(&memory).unwrap();
646        }
647    }
648
649    async fn send(engine: &Arc<WshRpcEngine>, output_rx: &mut tokio::sync::mpsc::UnboundedReceiver<RpcMessage>, command: &str, id: &str) -> RpcMessage {
650        engine.handle_message(RpcMessage {
651            command: command.to_string(),
652            reqid: format!("req-{command}"),
653            data: Some(serde_json::json!({ "id": id })),
654            ..Default::default()
655        });
656        tokio::time::timeout(std::time::Duration::from_secs(2), output_rx.recv())
657            .await
658            .unwrap()
659            .unwrap()
660    }
661
662    /// The wire behaviour a caller depends on is unchanged by the migration:
663    /// deleting a real row answers `{ deleted: true }`, and the schema now
664    /// records the exact type names ts-rs generated bindings from.
665    #[tokio::test]
666    async fn deletememory_deletes_a_real_row_and_reports_it() {
667        let state = test_state();
668        seed_memory(&state, "mem-1", false);
669        let (engine, mut output_rx) = WshRpcEngine::new();
670        register(&engine, &state);
671
672        let resp = send(&engine, &mut output_rx, COMMAND_DELETE_MEMORY, "mem-1").await;
673        assert!(resp.error.is_empty(), "unexpected error: {}", resp.error);
674        let result: DeleteBundleResult = serde_json::from_value(resp.data.expect("expected result data")).unwrap();
675        assert!(result.deleted);
676
677        assert!(state.id_store.bundle_get("mem-1").unwrap().is_none());
678    }
679
680    /// Deleting an id that never existed is not an error — it answers
681    /// `{ deleted: false }`, same as the pre-migration `json!({"deleted": ..})`
682    /// body did.
683    #[tokio::test]
684    async fn deletememory_reports_false_for_an_unknown_id() {
685        let state = test_state();
686        let (engine, mut output_rx) = WshRpcEngine::new();
687        register(&engine, &state);
688
689        let resp = send(&engine, &mut output_rx, COMMAND_DELETE_MEMORY, "does-not-exist").await;
690        assert!(resp.error.is_empty(), "unexpected error: {}", resp.error);
691        let result: DeleteBundleResult = serde_json::from_value(resp.data.expect("expected result data")).unwrap();
692        assert!(!result.deleted);
693    }
694
695    #[tokio::test]
696    async fn deletesystemmemory_deletes_a_real_system_row_and_reports_it() {
697        let state = test_state();
698        seed_memory(&state, "sysmem-1", true);
699        let (engine, mut output_rx) = WshRpcEngine::new();
700        register(&engine, &state);
701
702        let resp = send(&engine, &mut output_rx, COMMAND_DELETE_SYSTEM_MEMORY, "sysmem-1").await;
703        assert!(resp.error.is_empty(), "unexpected error: {}", resp.error);
704        let result: DeleteBundleResult = serde_json::from_value(resp.data.expect("expected result data")).unwrap();
705        assert!(result.deleted);
706    }
707
708    /// Both commands are recorded in the engine's schema with their exact
709    /// type names, and share ONE response type (DeleteBundleResult) the
710    /// same way they already shared CommandDeleteBundleData as a request.
711    #[tokio::test]
712    async fn register_typed_records_both_delete_commands_sharing_one_response_type() {
713        let state = test_state();
714        let (engine, _rx) = WshRpcEngine::new();
715        register(&engine, &state);
716        let schema = engine.schema_json();
717        let rows = schema.as_array().unwrap();
718        for cmd in [COMMAND_DELETE_MEMORY, COMMAND_DELETE_SYSTEM_MEMORY] {
719            let row = rows
720                .iter()
721                .find(|r| r["command"] == cmd)
722                .unwrap_or_else(|| panic!("{cmd} missing from the schema"));
723            assert_eq!(row["requestName"], "CommandDeleteBundleData");
724            assert_eq!(row["responseName"], "DeleteBundleResult");
725        }
726        // The rest of the bundle CRUD surface is now migrated too. This block
727        // previously asserted these five were ABSENT from the schema, which was
728        // a statement about a temporary state ("not migrated yet"), not an
729        // invariant -- so migrating them correctly made it fail. It now asserts
730        // what each one actually records, which is the thing worth pinning.
731        //
732        // `upsertmemory`/`upsertsystemmemory` take `CommandUpsertBundleData`:
733        // the storage `Bundle` itself, flattened (the upsert body IS the
734        // entity), plus an optional `base_sha256`. Only `id` and `name` are
735        // required on the wire (every other field has a serde default), which
736        // is what `BundleUpsertInput` expresses on the frontend side.
737        for (cmd, req, resp) in [
738            (COMMAND_GET_MEMORY, "CommandGetBundleData", "Bundle"),
739            (COMMAND_UPSERT_MEMORY, "CommandUpsertBundleData", "Bundle"),
740            (COMMAND_UPSERT_SYSTEM_MEMORY, "CommandUpsertBundleData", "Bundle"),
741            (COMMAND_GLOBAL_MEMORY_HISTORY, "CommandGlobalMemoryHistoryData", "GlobalMemoryHistoryResult"),
742            (COMMAND_GLOBAL_MEMORY_DIFF, "CommandGlobalMemoryDiffData", "GlobalMemoryDiffResult"),
743            (COMMAND_GLOBAL_MEMORY_REVERT, "CommandGlobalMemoryRevertData", "GlobalMemoryRevertResult"),
744            (
745                COMMAND_REORDER_GLOBAL_BRAIN,
746                "CommandReorderGlobalBundlesData",
747                "ReorderGlobalBundlesResult",
748            ),
749            (
750                COMMAND_GET_CLAUDE_GLOBAL_CONFIG,
751                "CommandGetClaudeGlobalConfigData",
752                "ClaudeGlobalConfig",
753            ),
754        ] {
755            let row = rows
756                .iter()
757                .find(|r| r["command"] == cmd)
758                .unwrap_or_else(|| panic!("{cmd} missing from the schema"));
759            assert_eq!(row["requestName"], req, "{cmd} request");
760            assert_eq!(row["responseName"], resp, "{cmd} response");
761        }
762
763        // `listmemories` answers with `Vec<Bundle>`. `short_name` deliberately
764        // leaves a generic whole (truncating `Vec<a::B>` at the last `::`
765        // would yield `B>`, which names nothing), so match on the shape rather
766        // than pinning the full crate path.
767        let list = rows
768            .iter()
769            .find(|r| r["command"] == COMMAND_LIST_MEMORIES)
770            .expect("listmemories missing from the schema");
771        assert_eq!(list["requestName"], "CommandListBundlesData");
772        let list_resp = list["responseName"].as_str().unwrap_or_default();
773        assert!(
774            list_resp.starts_with("alloc::vec::Vec<") && list_resp.ends_with("::Bundle>"),
775            "listmemories should answer with a Vec of Bundle, got {list_resp}"
776        );
777    }
778}
779
780// Conditional saves (`base_sha256`) and Memory → Global's history
781// commands — SPEC_MEMORY_FOLLOWS_THE_AGENT_2026_09_24.md §2.3/§2.4.
782#[cfg(test)]
783mod global_memory_ui_tests {
784    use super::*;
785    use crate::backend::rpc_types::RpcMessage;
786    use crate::server::tests::test_state;
787
788    async fn call(
789        engine: &Arc<WshRpcEngine>,
790        rx: &mut tokio::sync::mpsc::UnboundedReceiver<RpcMessage>,
791        command: &str,
792        data: serde_json::Value,
793    ) -> RpcMessage {
794        let reqid = format!("req-{}", uuid::Uuid::new_v4());
795        engine.handle_message(RpcMessage {
796            command: command.to_string(),
797            reqid: reqid.clone(),
798            data: Some(data),
799            ..Default::default()
800        });
801        let resp = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
802            .await
803            .unwrap()
804            .unwrap();
805        assert_eq!(resp.resid, reqid);
806        resp
807    }
808
809    async fn ok<T: serde::de::DeserializeOwned>(
810        engine: &Arc<WshRpcEngine>,
811        rx: &mut tokio::sync::mpsc::UnboundedReceiver<RpcMessage>,
812        command: &str,
813        data: serde_json::Value,
814    ) -> T {
815        let resp = call(engine, rx, command, data).await;
816        assert!(resp.error.is_empty(), "{command}: unexpected error: {}", resp.error);
817        serde_json::from_value(resp.data.unwrap_or(serde_json::Value::Null)).unwrap()
818    }
819
820    fn hash(name: &str, instructions: &str) -> String {
821        crate::backend::storage::bundle_versions::content_hash(name, instructions)
822    }
823
824    /// Creates an ordinary Global Memory entry through the real save path
825    /// and returns its id.
826    async fn create_entry(
827        engine: &Arc<WshRpcEngine>,
828        rx: &mut tokio::sync::mpsc::UnboundedReceiver<RpcMessage>,
829        name: &str,
830        instructions: &str,
831    ) -> String {
832        let saved: Bundle = ok(engine, rx, COMMAND_UPSERT_MEMORY, json!({
833            "id": "", "name": name, "is_global": true, "instructions": instructions,
834        }))
835        .await;
836        saved.id
837    }
838
839    #[tokio::test]
840    async fn upsertmemory_with_a_matching_base_saves() {
841        let state = test_state();
842        let (engine, mut rx) = WshRpcEngine::new();
843        register(&engine, &state);
844        let id = create_entry(&engine, &mut rx, "Rules", "v1").await;
845
846        let resp = call(&engine, &mut rx, COMMAND_UPSERT_MEMORY, json!({
847            "id": id, "name": "Rules", "is_global": true, "instructions": "v2",
848            "base_sha256": hash("Rules", "v1"),
849        }))
850        .await;
851        assert!(resp.error.is_empty(), "unexpected error: {}", resp.error);
852        assert_eq!(state.id_store.bundle_get(&id).unwrap().unwrap().instructions, "v2");
853    }
854
855    #[tokio::test]
856    async fn upsertmemory_with_a_stale_base_is_a_conflict_and_changes_nothing() {
857        let state = test_state();
858        let (engine, mut rx) = WshRpcEngine::new();
859        register(&engine, &state);
860        let id = create_entry(&engine, &mut rx, "Rules", "v1").await;
861        // Someone else saves while the human's draft is still based on v1.
862        ok::<Bundle>(&engine, &mut rx, COMMAND_UPSERT_MEMORY, json!({
863            "id": id, "name": "Rules", "is_global": true, "instructions": "agent edit",
864        }))
865        .await;
866        let versions_before = state.id_store.bundle_version_list(&id).unwrap().len();
867
868        let resp = call(&engine, &mut rx, COMMAND_UPSERT_MEMORY, json!({
869            "id": id, "name": "Rules", "is_global": true, "instructions": "human draft",
870            "base_sha256": hash("Rules", "v1"),
871        }))
872        .await;
873        assert!(resp.error.contains("conflict:"), "expected a conflict, got {:?}", resp.error);
874        assert_eq!(state.id_store.bundle_get(&id).unwrap().unwrap().instructions, "agent edit");
875        assert_eq!(state.id_store.bundle_version_list(&id).unwrap().len(), versions_before);
876    }
877
878    #[tokio::test]
879    async fn upsertmemory_without_a_base_behaves_as_before() {
880        let state = test_state();
881        let (engine, mut rx) = WshRpcEngine::new();
882        register(&engine, &state);
883        let id = create_entry(&engine, &mut rx, "Rules", "v1").await;
884        ok::<Bundle>(&engine, &mut rx, COMMAND_UPSERT_MEMORY, json!({
885            "id": id, "name": "Rules", "is_global": true, "instructions": "v2",
886        }))
887        .await;
888        assert_eq!(state.id_store.bundle_get(&id).unwrap().unwrap().instructions, "v2");
889    }
890
891    #[tokio::test]
892    async fn upsertsystemmemory_honours_the_base_too() {
893        let state = test_state();
894        let (engine, mut rx) = WshRpcEngine::new();
895        register(&engine, &state);
896        let saved: Bundle = ok(&engine, &mut rx, COMMAND_UPSERT_SYSTEM_MEMORY, json!({
897            "id": "sys-base", "name": "Policy", "instructions": "v1",
898        }))
899        .await;
900        let resp = call(&engine, &mut rx, COMMAND_UPSERT_SYSTEM_MEMORY, json!({
901            "id": saved.id, "name": "Policy", "instructions": "draft",
902            "base_sha256": hash("Policy", "something else"),
903        }))
904        .await;
905        assert!(resp.error.contains("conflict:"), "expected a conflict, got {:?}", resp.error);
906        assert_eq!(state.id_store.bundle_get("sys-base").unwrap().unwrap().instructions, "v1");
907
908        ok::<Bundle>(&engine, &mut rx, COMMAND_UPSERT_SYSTEM_MEMORY, json!({
909            "id": "sys-base", "name": "Policy", "instructions": "v2",
910            "base_sha256": hash("Policy", "v1"),
911        }))
912        .await;
913        assert_eq!(state.id_store.bundle_get("sys-base").unwrap().unwrap().instructions, "v2");
914    }
915
916    #[tokio::test]
917    async fn history_diff_and_revert_round_trip() {
918        let state = test_state();
919        let (engine, mut rx) = WshRpcEngine::new();
920        register(&engine, &state);
921        let id = create_entry(&engine, &mut rx, "Rules", "line one\n").await;
922        ok::<Bundle>(&engine, &mut rx, COMMAND_UPSERT_MEMORY, json!({
923            "id": id, "name": "Rules", "is_global": true, "instructions": "line one\nline two\n",
924        }))
925        .await;
926
927        let history: GlobalMemoryHistoryResult =
928            ok(&engine, &mut rx, COMMAND_GLOBAL_MEMORY_HISTORY, json!({ "id": id })).await;
929        assert_eq!(history.versions.len(), 2);
930        let (newest, oldest) = (history.versions[0].clone(), history.versions[1].clone());
931        assert_eq!(newest.written_by, "armory-ui");
932        // The latest version's hash is what the editor sends as its base.
933        assert_eq!(newest.content_hash, hash("Rules", "line one\nline two\n"));
934
935        let diff: GlobalMemoryDiffResult = ok(&engine, &mut rx, COMMAND_GLOBAL_MEMORY_DIFF, json!({
936            "id": id, "from_version_id": oldest.id, "to_version_id": newest.id,
937        }))
938        .await;
939        assert_eq!(diff.diff, "  line one\n+ line two\n");
940
941        let reverted: GlobalMemoryRevertResult = ok(&engine, &mut rx, COMMAND_GLOBAL_MEMORY_REVERT, json!({
942            "id": id, "target_version_id": oldest.id,
943        }))
944        .await;
945        let v = reverted.version.expect("an ordinary revert always records a version");
946        assert_eq!(v.source, "revert");
947        assert_eq!(v.written_by, "armory-ui");
948        assert!(v.source_detail.contains(&oldest.id));
949        assert_eq!(state.id_store.bundle_get(&id).unwrap().unwrap().instructions, "line one\n");
950        assert_eq!(state.id_store.bundle_version_list(&id).unwrap().len(), 3, "revert appends, never rewrites");
951    }
952
953    #[tokio::test]
954    async fn diff_refuses_a_version_from_another_entry() {
955        let state = test_state();
956        let (engine, mut rx) = WshRpcEngine::new();
957        register(&engine, &state);
958        let a = create_entry(&engine, &mut rx, "A", "a").await;
959        let b = create_entry(&engine, &mut rx, "B", "b").await;
960        let va = state.id_store.bundle_version_list(&a).unwrap()[0].id.clone();
961        let vb = state.id_store.bundle_version_list(&b).unwrap()[0].id.clone();
962        let resp = call(&engine, &mut rx, COMMAND_GLOBAL_MEMORY_DIFF, json!({
963            "id": a, "from_version_id": va, "to_version_id": vb,
964        }))
965        .await;
966        assert!(resp.error.contains("do not belong"), "got {:?}", resp.error);
967        let resp = call(&engine, &mut rx, COMMAND_GLOBAL_MEMORY_REVERT, json!({
968            "id": a, "target_version_id": vb,
969        }))
970        .await;
971        assert!(resp.error.contains("does not belong"), "got {:?}", resp.error);
972    }
973
974    #[tokio::test]
975    async fn history_refuses_a_non_global_bundle() {
976        let state = test_state();
977        let (engine, mut rx) = WshRpcEngine::new();
978        register(&engine, &state);
979        let saved: Bundle = ok(&engine, &mut rx, COMMAND_UPSERT_MEMORY, json!({
980            "id": "", "name": "Private preset", "is_global": false, "instructions": "x",
981        }))
982        .await;
983        let resp = call(&engine, &mut rx, COMMAND_GLOBAL_MEMORY_HISTORY, json!({ "id": saved.id })).await;
984        assert!(resp.error.contains("not a Global Memory entry"), "got {:?}", resp.error);
985    }
986
987    // Unlike the MCP tools, Memory → Global sees system-tier history — it can
988    // already edit those entries. A no-op revert of one records nothing.
989    #[tokio::test]
990    async fn system_entries_have_history_and_a_no_op_revert_records_nothing() {
991        let state = test_state();
992        let (engine, mut rx) = WshRpcEngine::new();
993        register(&engine, &state);
994        for body in ["v1", "v2"] {
995            ok::<Bundle>(&engine, &mut rx, COMMAND_UPSERT_SYSTEM_MEMORY, json!({
996                "id": "sys-hist", "name": "Policy", "instructions": body,
997            }))
998            .await;
999        }
1000        let history: GlobalMemoryHistoryResult =
1001            ok(&engine, &mut rx, COMMAND_GLOBAL_MEMORY_HISTORY, json!({ "id": "sys-hist" })).await;
1002        assert_eq!(history.versions.len(), 2);
1003
1004        let latest = history.versions[0].id.clone();
1005        let reverted: GlobalMemoryRevertResult = ok(&engine, &mut rx, COMMAND_GLOBAL_MEMORY_REVERT, json!({
1006            "id": "sys-hist", "target_version_id": latest,
1007        }))
1008        .await;
1009        assert!(reverted.version.is_none());
1010
1011        let oldest = history.versions[1].id.clone();
1012        let reverted: GlobalMemoryRevertResult = ok(&engine, &mut rx, COMMAND_GLOBAL_MEMORY_REVERT, json!({
1013            "id": "sys-hist", "target_version_id": oldest,
1014        }))
1015        .await;
1016        assert_eq!(reverted.version.expect("a real revert records a version").source, "revert");
1017        let row = state.id_store.bundle_get("sys-hist").unwrap().unwrap();
1018        assert_eq!(row.instructions, "v1");
1019        assert!(row.is_system, "reverting must not demote a system entry");
1020    }
1021
1022    // Reverting a system entry to the text AgentMux seeded hands it back to
1023    // the seeder: later Operator Config updates apply again.
1024    #[tokio::test]
1025    async fn reverting_a_system_entry_to_seeded_text_restores_seeder_ownership() {
1026        use crate::backend::operator_config_seed::WRITTEN_BY;
1027        let state = test_state();
1028        let (engine, mut rx) = WshRpcEngine::new();
1029        register(&engine, &state);
1030        let mut seeded = Bundle {
1031            id: "sys-own".into(),
1032            name: "Policy".into(),
1033            description: String::new(),
1034            is_blank: false,
1035            is_global: true,
1036            provider: String::new(),
1037            model: String::new(),
1038            instructions: "seeded v1".into(),
1039            instructions_by_provider: "{}".into(),
1040            context_files: "[]".into(),
1041            mcp_servers: "[]".into(),
1042            skills: "[]".into(),
1043            sort_order: 0,
1044            created_at: 1,
1045            updated_at: 1,
1046            is_system: true,
1047        };
1048        state.id_store.bundle_reseed_system_if_owned(&seeded, WRITTEN_BY, "test_seed", "{}").unwrap();
1049        ok::<Bundle>(&engine, &mut rx, COMMAND_UPSERT_SYSTEM_MEMORY, json!({
1050            "id": "sys-own", "name": "Policy", "instructions": "local edit",
1051        }))
1052        .await;
1053
1054        let history: GlobalMemoryHistoryResult =
1055            ok(&engine, &mut rx, COMMAND_GLOBAL_MEMORY_HISTORY, json!({ "id": "sys-own" })).await;
1056        let seeded_version = history.versions.iter().find(|v| v.written_by == WRITTEN_BY).unwrap().id.clone();
1057        let reverted: GlobalMemoryRevertResult = ok(&engine, &mut rx, COMMAND_GLOBAL_MEMORY_REVERT, json!({
1058            "id": "sys-own", "target_version_id": seeded_version,
1059        }))
1060        .await;
1061        assert_eq!(reverted.version.unwrap().written_by, WRITTEN_BY);
1062
1063        seeded.instructions = "seeded v2".into();
1064        let outcome = state.id_store.bundle_reseed_system_if_owned(&seeded, WRITTEN_BY, "test_seed", "{}").unwrap();
1065        assert!(!matches!(outcome, crate::backend::storage::BundleReseedOutcome::SkippedLocalEdit), "{outcome:?}");
1066        assert_eq!(state.id_store.bundle_get("sys-own").unwrap().unwrap().instructions, "seeded v2");
1067    }
1068}