1use super::*;
13
14use crate::backend::rpc_types::BundleImportFileEntry as FileEntry;
19
20use crate::backend::rpc_types::{
25 BundleImportSkillSelection as SkillSelection,
26 CommandBundleImportCommitData as CommitReq,
27 CommandBundleImportPreviewData as PreviewReq,
28};
29
30mod components;
31mod export;
32mod export_for_agent;
33mod import;
34mod import_for_agent;
35
36#[cfg(test)]
37mod tests;
38
39use components::*;
40use export::*;
41use export_for_agent::*;
42use import::*;
43use import_for_agent::*;
44
45pub(crate) use components::purge_bundle_component_refs;
47pub(super) use components::resolve_bundle_components;
48pub(crate) use components::MEMORY_NOT_EXPORTED_WARNING;
49
50static BUNDLE_IMPORT_FOR_AGENT_LOCKS: std::sync::LazyLock<
57 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
58> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
59
60fn bundle_import_for_agent_lock(agent_id: &str) -> Arc<tokio::sync::Mutex<()>> {
61 let mut locks = BUNDLE_IMPORT_FOR_AGENT_LOCKS.lock().unwrap_or_else(|e| e.into_inner());
62 locks
63 .entry(agent_id.to_string())
64 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
65 .clone()
66}
67
68pub fn register(engine: &Arc<WshRpcEngine>, state: &AppState) {
69 register_bundle_list(engine, state);
70 register_bundle_get(engine, state);
71 register_bundle_upsert(engine, state);
72 register_bundle_delete(engine, state);
73 register_bundle_self_get(engine, state);
74 register_bundle_export(engine, state);
75 register_bundle_import(engine, state);
76 register_bundle_import_preview(engine, state);
77 register_bundle_import_commit(engine, state);
78 register_bundle_export_for_agent(engine, state);
79 register_bundle_export_for_agent_with_history(engine, state);
80 register_bundle_import_for_agent(engine, state);
81 register_bundle_validate(engine, state);
82 register_agent_project_instructions(engine, state);
83}
84
85pub(super) fn normalize_bundle_upsert_input(mut data: serde_json::Value) -> serde_json::Value {
96 if let serde_json::Value::Object(ref mut map) = data {
97 match map.get("id") {
98 Some(serde_json::Value::String(_)) => {}
99 _ => {
100 map.insert("id".to_string(), serde_json::Value::String(String::new()));
101 }
102 }
103 for key in ["context_files", "mcp_servers", "skills"] {
104 if let Some(v) = map.get(key) {
105 if v.is_array() {
106 let encoded =
107 serde_json::to_string(v).unwrap_or_else(|_| "[]".to_string());
108 map.insert(key.to_string(), serde_json::Value::String(encoded));
109 }
110 }
111 }
112 }
113 data
114}
115
116#[derive(Debug, Clone, serde::Serialize, ts_rs::TS)]
123#[ts(export, export_to = "../../../frontend/types/rpc/")]
124pub(crate) struct BundleListItem {
125 pub(crate) id: String,
126 pub(crate) name: String,
127 pub(crate) description: String,
128 pub(crate) provider: String,
129 pub(crate) model: String,
130 pub(crate) is_blank: bool,
131 #[ts(type = "number")]
132 pub(crate) updated_at: i64,
133}
134
135#[derive(Debug, Clone, serde::Serialize, ts_rs::TS)]
138#[ts(export, export_to = "../../../frontend/types/rpc/")]
139pub(crate) struct BundleListResponse {
140 pub(crate) bundles: Vec<BundleListItem>,
141 pub(crate) presets: Vec<BundleListItem>,
142}
143
144fn register_bundle_list(engine: &Arc<WshRpcEngine>, state: &AppState) {
145 let state = state.clone();
146 engine.register_typed(COMMAND_BUNDLE_LIST, move |_req: Option<NoArgsReq>, _ctx| {
147 let state = state.clone();
148 async move { bundle_list_impl(&state).await }
149 });
150}
151
152#[derive(serde::Deserialize, Default, ts_rs::TS)]
153#[ts(export, export_to = "../../../frontend/types/rpc/")]
154pub(super) struct BundleGetReq {
155 #[serde(default)]
156 #[ts(as = "Option<String>", optional)]
157 id: String,
158 #[serde(default)]
159 #[ts(as = "Option<String>", optional)]
160 name: String,
161}
162
163fn register_bundle_get(engine: &Arc<WshRpcEngine>, state: &AppState) {
164 let state = state.clone();
165 engine.register_typed(COMMAND_BUNDLE_GET, move |req: BundleGetReq, _ctx| {
166 let state = state.clone();
167 async move { bundle_get_impl(&state, &req.id, &req.name).await }
168 });
169}
170
171fn register_bundle_validate(engine: &Arc<WshRpcEngine>, _state: &AppState) {
186 engine.register_typed(COMMAND_BUNDLE_VALIDATE, |data: serde_json::Value, _ctx| async move {
189 bundle_validate_impl(data)
190 });
191}
192
193fn register_bundle_upsert(engine: &Arc<WshRpcEngine>, state: &AppState) {
194 let make = |state: &AppState| {
195 let id_store = state.id_store.clone();
196 let broker = state.broker.clone();
197 move |data: serde_json::Value, _ctx: RpcContext| {
200 let id_store = id_store.clone();
201 let broker = broker.clone();
202 async move {
203 let mut memory: Bundle =
204 serde_json::from_value(normalize_bundle_upsert_input(data))
205 .map_err(|e| format!("bundle.upsert: {e}"))?;
206
207 if memory.id == "blank" || memory.id.starts_with("seed-") || memory.is_blank {
210 return Err("FORBIDDEN: cannot mutate a protected bundle".to_string());
211 }
212 if !memory.id.is_empty() {
215 if let Some(existing) = id_store.bundle_get(&memory.id)
216 .map_err(|e| format!("bundle.upsert: {e}"))?
217 {
218 if existing.is_global {
219 return Err("FORBIDDEN: cannot mutate a global bundle".to_string());
220 }
221 }
225 }
226 if memory.id.is_empty() {
227 memory.id = uuid::Uuid::new_v4().to_string();
228 }
229 memory.is_global = false;
231 memory.sort_order = 0;
232
233 let now = agentmux_common::time::now_ms();
234 if memory.created_at == 0 { memory.created_at = now; }
235 memory.updated_at = now;
236
237 id_store.bundle_upsert(&memory)
238 .map_err(|e| format!("bundle.upsert: {e}"))?;
239 broker.publish_event::<crate::backend::mps_events::MemoriesChanged>(&(), vec![], 0);
240 Ok(memory)
241 }
242 }
243 };
244 engine.register_typed(COMMAND_BUNDLE_UPSERT, make(state));
245}
246
247fn register_bundle_delete(engine: &Arc<WshRpcEngine>, state: &AppState) {
248 let make = |state: &AppState| {
249 let id_store = state.id_store.clone();
250 let mstore = state.mstore.clone();
251 let broker = state.broker.clone();
252 move |req: CommandDeleteBundleData, _ctx: RpcContext| {
254 let id_store = id_store.clone();
255 let mstore = mstore.clone();
256 let broker = broker.clone();
257 async move {
258 if req.id == "blank" {
259 return Err("FORBIDDEN: cannot delete a seeded bundle".to_string());
260 }
261
262 match id_store.bundle_delete(&req.id) {
263 Ok(deleted) => {
264 if deleted {
265 purge_bundle_component_refs(&mstore, &req.id);
266 broker.publish_event::<crate::backend::mps_events::MemoriesChanged>(&(), vec![], 0);
267 }
268 Ok(DeleteBundleResult { deleted })
269 }
270 Err(crate::backend::storage::error::StoreError::Other(msg))
271 if msg.contains("seed") || msg.contains("seeded") =>
272 {
273 Err(format!("FORBIDDEN: cannot delete a seeded bundle"))
274 }
275 Err(e) => Err(format!("bundle.delete: {e}")),
276 }
277 }
278 }
279 };
280 engine.register_typed(COMMAND_BUNDLE_DELETE, make(state));
281}
282
283#[derive(serde::Deserialize, ts_rs::TS)]
284#[ts(export, export_to = "../../../frontend/types/rpc/")]
285pub(super) struct BundleSelfGetReq { agent_id: String }
286
287fn register_bundle_self_get(engine: &Arc<WshRpcEngine>, state: &AppState) {
288 let state = state.clone();
289 engine.register_typed(COMMAND_BUNDLE_SELF_GET, move |req: BundleSelfGetReq, ctx| {
290 let state = state.clone();
291 async move {
292 check_s1(&state.mstore, &ctx, &req.agent_id)?;
293 bundle_self_get_impl(&state, &req.agent_id).await
294 }
295 });
296}
297
298fn resolve_agent_for_s1(
312 state: &AppState,
313 agent_id: &str,
314) -> Result<crate::backend::storage::AgentDefinition, String> {
315 let def_id = resolve_agent_definition_id(state, agent_id)?;
316 state
317 .mstore
318 .agent_def_get(&def_id)
319 .map_err(|e| e.to_string())?
320 .ok_or_else(|| format!("no agent with slug or id {agent_id}"))
321}
322
323#[derive(serde::Deserialize, ts_rs::TS)]
324#[ts(export, export_to = "../../../frontend/types/rpc/")]
325pub(super) struct AgentProjectInstructionsReq {
326 agent_id: String,
327}
328
329#[derive(Debug, Clone, serde::Serialize, ts_rs::TS)]
333#[ts(export, export_to = "../../../frontend/types/rpc/")]
334pub(super) struct AgentProjectInstructionFile {
335 path: String,
336 exists: bool,
337 #[ts(type = "number")]
338 size_bytes: u64,
339 content_hash: String,
340 owner: String,
342 content: String,
343 truncated: bool,
344 error: Option<String>,
345 change: crate::backend::storage::project_instructions::InstructionChange,
346 #[ts(type = "number | null")]
348 last_observed_at: Option<i64>,
349}
350
351#[derive(Debug, Clone, serde::Serialize, ts_rs::TS)]
353#[ts(export, export_to = "../../../frontend/types/rpc/")]
354pub(super) struct AgentProjectInstructionsResponse {
355 provider: String,
356 working_directory: String,
357 files: Vec<AgentProjectInstructionFile>,
358}
359
360fn register_agent_project_instructions(engine: &Arc<WshRpcEngine>, state: &AppState) {
374 let make = |state: AppState| {
375 move |req: AgentProjectInstructionsReq, ctx: RpcContext| {
376 let state = state.clone();
377 async move {
378 check_s1(&state.mstore, &ctx, &req.agent_id)?;
379
380 let agent = resolve_agent_for_s1(&state, &req.agent_id)
381 .map_err(|e| format!("agent.project_instructions: {e}"))?;
382
383 let work_dir = crate::backend::project_instructions::effective_working_dir(
388 &agent.working_directory,
389 &agent.name,
390 );
391 let files = crate::backend::project_instructions::resolve_project_instructions(
392 &agent.provider,
393 &work_dir,
394 );
395
396 let previous = state
404 .mstore
405 .project_instructions_list(&agent.id)
406 .unwrap_or_default();
407 let mut enriched: Vec<AgentProjectInstructionFile> = files
408 .iter()
409 .map(|f| {
410 let prev = previous.iter().find(|p| p.path == f.path);
411 let change = crate::backend::storage::project_instructions::classify_change(
412 prev,
413 f.exists,
414 &f.content_hash,
415 );
416 AgentProjectInstructionFile {
417 path: f.path.clone(),
418 exists: f.exists,
419 size_bytes: f.size_bytes,
420 content_hash: f.content_hash.clone(),
421 owner: match f.owner {
422 crate::backend::project_instructions::InstructionOwner::Agentmux => "agentmux",
423 crate::backend::project_instructions::InstructionOwner::Foreign => "foreign",
424 }
425 .to_string(),
426 content: f.content.clone(),
427 truncated: f.truncated,
428 error: f.error.clone(),
429 change,
430 last_observed_at: prev.map(|p| p.observed_at),
431 }
432 })
433 .collect();
434
435 for prev in previous.iter().filter(|p| !files.iter().any(|f| f.path == p.path)) {
443 let change = crate::backend::storage::project_instructions::classify_change(
444 Some(prev),
445 false,
446 "",
447 );
448 enriched.push(AgentProjectInstructionFile {
449 path: prev.path.clone(),
450 exists: false,
451 size_bytes: 0,
452 content_hash: String::new(),
453 owner: prev.owner.clone(),
454 content: String::new(),
455 truncated: false,
456 error: None,
457 change,
458 last_observed_at: Some(prev.observed_at),
459 });
460 }
461
462 Ok(AgentProjectInstructionsResponse {
463 provider: agent.provider,
464 working_directory: work_dir,
465 files: enriched,
466 })
467 }
468 }
469 };
470 engine.register_typed(COMMAND_AGENT_PROJECT_INSTRUCTIONS, make(state.clone()));
471}
472
473struct RequirementResolution {
486 id: String,
487 provider: String,
488 env: String,
489 match_count: usize,
490}
491
492impl RequirementResolution {
493 fn resolved(&self) -> bool {
494 self.match_count == 1
495 }
496}
497
498fn resolve_account_requirements(
499 id_store: &crate::backend::storage::store::Store,
500 requirements: &[crate::backend::bundle_import::AccountRequirement],
501) -> Result<Vec<RequirementResolution>, String> {
502 let mut results: Vec<RequirementResolution> = Vec::new();
503 let mut match_count_by_provider: std::collections::HashMap<&str, usize> =
504 std::collections::HashMap::new();
505 for requirement in requirements {
506 let match_count = match match_count_by_provider.get(requirement.provider.as_str()) {
507 Some(&n) => n,
508 None => {
509 let n = id_store
513 .identity_list(Some(&requirement.provider))
514 .map_err(|e| format!("account lookup failed: {e}"))?
515 .len();
516 match_count_by_provider.insert(requirement.provider.as_str(), n);
517 n
518 }
519 };
520 results.push(RequirementResolution {
521 id: requirement.id.clone(),
522 provider: requirement.provider.clone(),
523 env: requirement.env.clone(),
524 match_count,
525 });
526 }
527 Ok(results)
528}
529
530fn bounded_display(s: &str) -> String {
538 crate::backend::bundle_import::truncate_display(s, crate::backend::bundle_import::MAX_DISPLAY_FIELD_CHARS)
539}
540
541pub(crate) async fn bundle_list_impl(state: &AppState) -> Result<BundleListResponse, String> {
542 let memories = state.id_store.bundle_list().map_err(|e| format!("bundle.list: {e}"))?;
543 let bundles: Vec<BundleListItem> = memories.into_iter().map(|m| BundleListItem {
544 id: m.id, name: m.name, description: m.description,
545 provider: m.provider, model: m.model, is_blank: m.is_blank, updated_at: m.updated_at,
546 }).collect();
547 Ok(BundleListResponse { presets: bundles.clone(), bundles })
553}
554
555pub(crate) async fn bundle_get_impl(
556 state: &AppState,
557 id: &str,
558 name: &str,
559) -> Result<Bundle, String> {
560 let memory = if !id.is_empty() {
561 state.id_store.bundle_get(id).map_err(|e| format!("bundle.get: {e}"))?
562 .ok_or_else(|| format!("bundle.get: not found id={id}"))?
563 } else if !name.is_empty() {
564 let all = state.id_store.bundle_list().map_err(|e| format!("bundle.get: {e}"))?;
565 all.into_iter().filter(|m| m.name == name).max_by_key(|m| m.updated_at)
566 .ok_or_else(|| format!("bundle.get: not found name={name}"))?
567 } else {
568 return Err("bundle.get: provide id or name".to_string());
569 };
570 Ok(memory)
571}
572
573pub(crate) fn bundle_validate_impl(
584 data: serde_json::Value,
585) -> Result<crate::backend::bundle_validate::ValidationReport, String> {
586 let memory: Bundle = serde_json::from_value(bundle::normalize_bundle_upsert_input(data))
587 .map_err(|e| format!("bundle.validate: {e}"))?;
588 Ok(crate::backend::bundle_validate::validate_bundle(&memory))
589}
590
591pub(crate) async fn bundle_self_get_impl<'o>(
592 state: &AppState,
593 owner: impl Into<SelfOwner<'o>>,
594) -> Result<Bundle, String> {
595 let agent_id = match owner.into() {
596 SelfOwner::Slug(slug) => slug,
597 SelfOwner::Uid(uid) => {
601 let row = SelfOwner::caller_row(uid, &state.mstore)
602 .map_err(|e| format!("bundle.self.get: {e}"))?;
603 let launch = state.mstore.instance_get(uid)
607 .map_err(|e| format!("bundle.self.get: {e}"))?;
608 let memory_id = launch
609 .map(|i| i.memory_id)
610 .filter(|m| !m.is_empty())
611 .or_else(|| {
612 crate::backend::agent_registry_lookup::find_active_record_by_slug_and_definition(
613 &row.slug, &row.id,
614 )
615 .and_then(|rec| rec.data.memory_id)
616 });
617 return bundle_self_get_by_memory_id(state, memory_id);
618 }
619 };
620 let instance = state.mstore.instance_get_by_slug(agent_id)
621 .map_err(|e| format!("bundle.self.get: {e}"))?;
622 let memory_id = instance.as_ref()
632 .and_then(|i| if i.memory_id.is_empty() { None } else { Some(i.memory_id.clone()) })
633 .or_else(|| {
634 crate::server::native_memory_handlers::find_active_registry_record_by_slug(agent_id)
635 .and_then(|rec| rec.data.memory_id)
636 });
637 bundle_self_get_by_memory_id(state, memory_id)
638}
639
640fn bundle_self_get_by_memory_id(
642 state: &AppState,
643 memory_id: Option<String>,
644) -> Result<Bundle, String> {
645 let memory = if let Some(mid) = memory_id {
646 state.id_store.bundle_get(&mid).map_err(|e| format!("bundle.self.get: {e}"))?
647 .ok_or_else(|| format!("bundle.self.get: memory_id {mid} not found"))?
648 } else {
649 let all = state.id_store.bundle_list().map_err(|e| format!("bundle.self.get: {e}"))?;
652 let blank_id = all.into_iter().find(|m| m.is_blank).map(|m| m.id)
653 .ok_or_else(|| "bundle.self.get: blank singleton not found".to_string())?;
654 state.id_store.bundle_get(&blank_id).map_err(|e| format!("bundle.self.get: {e}"))?
655 .ok_or_else(|| "bundle.self.get: blank singleton row missing".to_string())?
656 };
657 Ok(memory)
658}
659
660#[derive(Debug, Clone, Copy)]
665pub(crate) enum SelfOwner<'a> {
666 Uid(&'a str),
673 Slug(&'a str),
677}
678
679impl<'a> From<&'a str> for SelfOwner<'a> {
680 fn from(slug: &'a str) -> Self {
681 Self::Slug(slug)
682 }
683}
684
685impl<'a> From<&'a String> for SelfOwner<'a> {
686 fn from(slug: &'a String) -> Self {
687 Self::Slug(slug)
688 }
689}
690
691impl<'a> SelfOwner<'a> {
692 pub(crate) fn of(
698 caller: Option<&'a crate::server::caller::Caller>,
699 slug: &'a str,
700 by_name_counter: &'static str,
701 ) -> Self {
702 match caller.and_then(crate::server::caller::Caller::uid) {
703 Some(uid) => Self::Uid(uid),
704 None => {
705 crate::backend::agent_resolve::record_uid_fallback(by_name_counter);
706 Self::Slug(slug)
707 }
708 }
709 }
710
711 pub(super) fn label(self) -> &'a str {
713 match self {
714 Self::Uid(v) | Self::Slug(v) => v,
715 }
716 }
717
718 pub(crate) fn caller_row(
722 uid: &str,
723 mstore: &crate::backend::storage::store::Store,
724 ) -> Result<crate::backend::storage::AgentDefinition, String> {
725 mstore
726 .agent_def_get(uid)
727 .map_err(|e| format!("store: {e}"))?
728 .ok_or_else(|| format!("calling agent {uid} not found"))
729 }
730
731 pub(super) fn dir(self, mstore: &crate::backend::storage::store::Store) -> Result<std::path::PathBuf, String> {
733 match self {
734 Self::Uid(uid) => {
735 let agent = Self::caller_row(uid, mstore).map_err(|e| format!("memory: {e}"))?;
736 crate::server::native_memory_handlers::memory_dir_for_agent_by_id(mstore, &agent)
737 .ok_or_else(|| format!("memory: memory directory for agent {uid} not found"))
738 }
739 Self::Slug(slug) => crate::server::native_memory_handlers::memory_dir_for_agent(mstore, slug),
740 }
741 }
742
743 pub(super) fn dir_for_write(self, mstore: &crate::backend::storage::store::Store) -> Result<std::path::PathBuf, String> {
746 let id = self.owner_id(mstore).map_err(|e| format!("memory: {e}"))?;
747 match mstore.agent_def_get(&id).map_err(|e| format!("memory: store: {e}"))? {
748 Some(agent) => crate::server::native_memory_handlers::memory_dir_for_write_by_id(mstore, &agent)
749 .map_err(|e| format!("memory: {e}")),
750 None => crate::server::native_memory_handlers::memory_dir_from_spawn(&id).ok_or_else(|| {
753 format!(
754 "memory: agent {} has no local row and no spawn on record, so its memory directory \
755 can't be verified for a write",
756 self.label()
757 )
758 }),
759 }
760 }
761
762 pub(super) fn owner_id(self, mstore: &crate::backend::storage::store::Store) -> Result<String, String> {
765 match self {
766 Self::Uid(uid) => Self::caller_row(uid, mstore).map(|row| row.id),
767 Self::Slug(slug) => crate::server::native_memory_handlers::resolve_agent_uuid(mstore, slug),
768 }
769 }
770}