1use 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 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 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 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 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 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 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 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 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 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
307fn 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
339pub(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 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
410fn 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 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
482fn 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 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
517fn 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#[derive(serde::Serialize, serde::Deserialize, ts_rs::TS)]
550#[ts(export, export_to = "../../../frontend/types/rpc/")]
551pub struct ClaudeGlobalConfig {
552 pub path: String,
553 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#[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 let result = read_claude_global_config(dir.path()).unwrap();
598 assert!(!result.exists);
599 assert!(result.content.is_none());
600 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 #[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 #[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 #[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 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 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#[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 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 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 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 #[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 #[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}