1use super::*;
9
10#[derive(serde::Deserialize, Default, ts_rs::TS)]
11#[ts(export, export_to = "../../../frontend/types/rpc/")]
12pub(super) struct BundleImportReq {
13 #[serde(default)]
14 #[ts(optional)]
15 zip_base64: Option<String>,
16 #[serde(default)]
17 #[ts(optional)]
18 files: Option<Vec<FileEntry>>,
19}
20
21#[derive(Debug, Clone, serde::Serialize, ts_rs::TS)]
23#[ts(export, export_to = "../../../frontend/types/rpc/")]
24pub(crate) struct BundleImportResponse {
25 pub(crate) bundle_id: String,
26 pub(crate) imported_skill_ids: Vec<String>,
27 pub(crate) skipped_skills: Vec<String>,
28 pub(crate) resolved_requirement_ids: Vec<String>,
29 pub(crate) unresolved_requirements: Vec<crate::backend::rpc_types::BundleImportUnresolvedRequirement>,
30 pub(crate) warnings: Vec<String>,
31}
32
33pub(super) fn register_bundle_import(engine: &Arc<WshRpcEngine>, state: &AppState) {
46 let id_store = state.id_store.clone();
47 let identity_store = state.identity_store.clone();
48 let mstore = state.mstore.clone();
49 let broker = state.broker.clone();
50 engine.register_typed(
51 COMMAND_BUNDLE_IMPORT,
52 move |req: BundleImportReq, _ctx| {
53 let id_store = id_store.clone();
54 let identity_store = identity_store.clone();
55 let mstore = mstore.clone();
56 let broker = broker.clone();
57 async move {
58 let (files, mut warnings): (Vec<crate::backend::bundle_import::BundleImportFile>, Vec<String>) =
59 match (req.zip_base64, req.files) {
60 (Some(_), Some(_)) => {
61 return Err(
62 "bundle.import: exactly one of zip_base64/files must be present, got both"
63 .to_string(),
64 );
65 }
66 (None, None) => {
67 return Err(
68 "bundle.import: exactly one of zip_base64/files must be present, got neither"
69 .to_string(),
70 );
71 }
72 (Some(b64), None) => {
73 use base64::Engine as _;
74 let zip_bytes = base64::engine::general_purpose::STANDARD
75 .decode(&b64)
76 .map_err(|e| format!("bundle.import: invalid zip_base64: {e}"))?;
77 crate::backend::bundle_import::unzip_bundle_import(&zip_bytes)
78 .map_err(|e| format!("bundle.import: {e}"))?
79 }
80 (None, Some(files)) => {
81 let raw_files: Vec<crate::backend::bundle_import::BundleImportFile> = files
89 .into_iter()
90 .map(|f| crate::backend::bundle_import::BundleImportFile {
91 path: f.path,
92 content: f.content,
93 })
94 .collect();
95 crate::backend::bundle_import::enforce_raw_files_caps(raw_files)
96 .map_err(|e| format!("bundle.import: {e}"))?
97 }
98 };
99
100 let parsed = crate::backend::bundle_import::parse_bundle_import(&files)
101 .map_err(|e| format!("bundle.import: {e}"))?;
102 warnings.extend(parsed.warnings.clone());
103
104 let resolved = resolve_account_requirements(&id_store, &parsed.requirements)
114 .map_err(|e| format!("bundle.import: {e}"))?;
115 let resolved_requirement_ids: Vec<String> =
116 resolved.iter().filter(|r| r.resolved()).map(|r| r.id.clone()).collect();
117 let unresolved_requirements: Vec<crate::backend::rpc_types::BundleImportUnresolvedRequirement> = resolved
118 .iter()
119 .filter(|r| !r.resolved())
120 .map(|r| crate::backend::rpc_types::BundleImportUnresolvedRequirement {
121 id: r.id.clone(),
122 provider: r.provider.clone(),
123 env: r.env.clone(),
124 match_count: r.match_count,
125 })
126 .collect();
127
128 let rollback_skills = |ids: &[String]| -> Vec<String> {
151 ids.iter()
152 .filter_map(|id| mstore.skill_delete(&identity_store, id).err().map(|e| format!("{id}: {e}")))
153 .collect()
154 };
155
156 let now = now_ms();
157 let mut imported_skill_ids: Vec<String> = Vec::new();
158 let mut skipped_skills = parsed.skipped_skills.clone();
159 for skill in &parsed.skills {
160 let row = crate::backend::storage::Skill {
161 id: uuid::Uuid::new_v4().to_string(),
162 name: skill.slug.clone(),
163 trigger: skill.slug.clone(),
164 skill_type: crate::backend::agent_config::SKILL_TYPE_AGENT_SKILL.to_string(),
165 description: skill.description.clone(),
166 content: skill.content.clone(),
167 is_global: true,
168 created_at: now,
169 updated_at: now,
170 };
171 match identity_store.skill_upsert_unique_global(&row) {
172 Ok(()) => imported_skill_ids.push(row.id),
173 Err(crate::backend::storage::error::StoreError::Other(msg))
174 if msg.contains("already exists") =>
175 {
176 warnings.push(format!("skill \"{}\": {msg}", skill.slug));
177 skipped_skills.push(skill.slug.clone());
178 }
179 Err(e) => {
180 let rollback_errors = rollback_skills(&imported_skill_ids);
187 let mut msg = format!(
188 "bundle.import: failed to create skill \"{}\": {e}",
189 skill.slug
190 );
191 if !rollback_errors.is_empty() {
192 msg.push_str(&format!(
193 "; additionally, rollback of {} previously-created skill(s) failed and may have left orphaned global skill row(s): {}",
194 rollback_errors.len(),
195 rollback_errors.join("; ")
196 ));
197 }
198 return Err(msg);
199 }
200 }
201 }
202
203 let memory = Bundle {
204 id: uuid::Uuid::new_v4().to_string(),
205 name: parsed.name,
206 description: parsed.description,
207 is_blank: false,
208 is_global: false,
212 provider: parsed.provider,
213 model: parsed.model,
214 instructions: parsed.instructions,
215 instructions_by_provider: serde_json::to_string(&parsed.instructions_by_provider)
218 .unwrap_or_else(|_| "{}".to_string()),
219 context_files: serde_json::to_string(
225 &parsed.context_files.iter().map(|cf| json!({"path": cf.path, "content": cf.content})).collect::<Vec<_>>(),
226 )
227 .unwrap_or_else(|_| "[]".to_string()),
228 mcp_servers: "[]".to_string(),
230 skills: serde_json::to_string(&imported_skill_ids)
231 .unwrap_or_else(|_| "[]".to_string()),
232 sort_order: 0,
233 created_at: now,
234 updated_at: now,
235 is_system: false,
236 };
237 if let Err(e) = id_store.bundle_upsert(&memory) {
238 let rollback_errors = rollback_skills(&imported_skill_ids);
242 let mut msg = format!("bundle.import: {e}");
243 if !rollback_errors.is_empty() {
244 msg.push_str(&format!(
245 "; additionally, rollback of {} previously-created skill(s) failed and may have left orphaned global skill row(s): {}",
246 rollback_errors.len(),
247 rollback_errors.join("; ")
248 ));
249 }
250 return Err(msg);
251 }
252
253 warnings.extend(bind_imported_components(
256 &mstore,
257 &id_store,
258 &identity_store,
259 &memory.id,
260 &imported_skill_ids,
261 ));
262 warnings.extend(mcp_servers_not_imported_warning(&parsed.mcp_servers));
263
264 broker.publish_event::<crate::backend::mps_events::MemoriesChanged>(&(), vec![], 0);
265 if !imported_skill_ids.is_empty() {
266 broker.publish_event::<crate::backend::mps_events::SkillsChanged>(&(), vec![], 0);
267 }
268
269 Ok(BundleImportResponse {
270 bundle_id: memory.id,
271 imported_skill_ids,
272 skipped_skills,
273 resolved_requirement_ids,
274 unresolved_requirements,
275 warnings,
276 })
277 }
278 },
279 );
280}
281
282pub(super) const MAX_ABF_FILE_SIZE_BYTES: u64 = 100 * 1024 * 1024;
286
287pub(super) fn read_abf_file_path(path: &str) -> Result<Vec<u8>, String> {
296 use std::io::Read;
297
298 #[cfg(unix)]
299 let mut file = {
300 use std::os::unix::fs::OpenOptionsExt;
301 std::fs::OpenOptions::new()
302 .read(true)
303 .custom_flags(libc::O_NOFOLLOW)
304 .open(path)
305 .map_err(|e| format!("{path}: failed to open: {e}"))?
306 };
307 #[cfg(windows)]
308 let mut file = {
309 use std::os::windows::fs::OpenOptionsExt;
310 const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
320 std::fs::OpenOptions::new()
321 .read(true)
322 .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
323 .open(path)
324 .map_err(|e| format!("{path}: failed to open: {e}"))?
325 };
326 #[cfg(not(any(unix, windows)))]
327 let mut file = std::fs::File::open(path).map_err(|e| format!("{path}: failed to open: {e}"))?;
328
329 let metadata = file.metadata().map_err(|e| format!("{path}: failed to stat: {e}"))?;
330 #[cfg(windows)]
331 if metadata.file_type().is_symlink() {
332 return Err(format!("{path}: refusing to follow a symlink"));
333 }
334 if !metadata.is_file() {
335 return Err(format!("{path}: not a regular file"));
336 }
337 if metadata.len() > MAX_ABF_FILE_SIZE_BYTES {
338 return Err(format!(
339 "{path}: size ({} bytes) exceeds the limit ({MAX_ABF_FILE_SIZE_BYTES} bytes)",
340 metadata.len()
341 ));
342 }
343
344 let mut buf: Vec<u8> = Vec::new();
345 let mut limited = (&mut file).take(MAX_ABF_FILE_SIZE_BYTES + 1);
346 limited.read_to_end(&mut buf).map_err(|e| format!("{path}: failed to read: {e}"))?;
347 if buf.len() as u64 > MAX_ABF_FILE_SIZE_BYTES {
348 return Err(format!("{path}: exceeds the size limit while reading"));
349 }
350 Ok(buf)
351}
352
353pub(super) fn preview_commit_warning_budget() -> crate::backend::bundle_import::WarningBudget {
357 crate::backend::bundle_import::WarningBudget::bounded(200, 300)
358}
359
360pub(super) const MAX_PROJECT_INSTRUCTION_PREVIEW_CHARS: usize = 4_000;
376
377pub(super) fn bound_warnings_for_response(warnings: Vec<String>) -> (Vec<String>, bool) {
378 const MAX_COMBINED_WARNINGS: usize = 200;
379 if warnings.len() <= MAX_COMBINED_WARNINGS {
380 (warnings, false)
381 } else {
382 let mut kept: Vec<String> = warnings.into_iter().take(MAX_COMBINED_WARNINGS).collect();
383 kept.push("... additional warnings not shown".to_string());
384 (kept, true)
385 }
386}
387
388#[derive(Debug)]
395pub(super) struct ResolvedImportInput {
396 pub(crate) files: Vec<crate::backend::bundle_import::BundleImportFile>,
397 pub(crate) intake_warnings: Vec<String>,
398 pub(crate) content_digest: String,
399 pub(crate) widgets: Vec<crate::backend::bundle_widgets::BundleWidget>,
402}
403
404fn split_widgets(
407 raw: &[u8],
408 files: &mut Vec<crate::backend::bundle_import::BundleImportFile>,
409 warnings: &mut Vec<String>,
410) -> Result<Vec<crate::backend::bundle_widgets::BundleWidget>, String> {
411 use crate::backend::bundle_widgets as bw;
412 files.retain(|f| !bw::is_widget_path(&f.path));
413 warnings.retain(|w| !bw::is_widget_warning(w));
414 bw::read_from_zip(raw)
415}
416
417pub(super) fn resolve_import_input(
418 file_path: Option<String>,
419 zip_base64: Option<String>,
420 files: Option<Vec<FileEntry>>,
421 warning_budget: crate::backend::bundle_import::WarningBudget,
422) -> Result<ResolvedImportInput, String> {
423 use crate::backend::bundle_import as bi;
424 let provided = [file_path.is_some(), zip_base64.is_some(), files.is_some()]
425 .iter()
426 .filter(|p| **p)
427 .count();
428 if provided != 1 {
429 return Err(format!(
430 "exactly one of file_path/zip_base64/files must be present, got {provided}"
431 ));
432 }
433 if let Some(path) = file_path {
434 let raw = read_abf_file_path(&path)?;
435 let content_digest = bi::content_digest_raw_bytes(bi::ImportInputMode::FilePath, &raw);
436 let (mut files, mut intake_warnings) = bi::unzip_bundle_import_with_budget(&raw, warning_budget)?;
437 let widgets = split_widgets(&raw, &mut files, &mut intake_warnings)?;
438 return Ok(ResolvedImportInput { files, intake_warnings, content_digest, widgets });
439 }
440 if let Some(b64) = zip_base64 {
441 use base64::Engine as _;
442 let raw = base64::engine::general_purpose::STANDARD
443 .decode(&b64)
444 .map_err(|e| format!("invalid zip_base64: {e}"))?;
445 let content_digest = bi::content_digest_raw_bytes(bi::ImportInputMode::ZipBase64, &raw);
446 let (mut files, mut intake_warnings) = bi::unzip_bundle_import_with_budget(&raw, warning_budget)?;
447 let widgets = split_widgets(&raw, &mut files, &mut intake_warnings)?;
448 return Ok(ResolvedImportInput { files, intake_warnings, content_digest, widgets });
449 }
450 let files = files.expect("exactly one input already validated present");
451 let raw_files: Vec<bi::BundleImportFile> = files
452 .into_iter()
453 .map(|f| bi::BundleImportFile { path: f.path, content: f.content })
454 .collect();
455 let (capped_files, intake_warnings) = bi::enforce_raw_files_caps_with_budget(raw_files, warning_budget)?;
456 let content_digest = bi::content_digest_files(&capped_files);
457 Ok(ResolvedImportInput { files: capped_files, intake_warnings, content_digest, widgets: vec![] })
459}
460
461pub(super) async fn bundle_import_preview_impl(
468 id_store: &crate::backend::storage::store::Store,
469 identity_store: &crate::backend::storage::store::Store,
470 mstore: &crate::backend::storage::store::Store,
471 req: PreviewReq,
472) -> Result<crate::backend::rpc_types::BundleImportPreviewResponse, String> {
473 use crate::backend::bundle_import as bi;
474 use crate::backend::rpc_types::{
475 BundleImportContextFilePreview, BundleImportMcpServerDisplay,
476 BundleImportMcpServerPreview, BundleImportPreviewResponse,
477 BundleImportProjectInstructionPreview, BundleImportRequirementPreview,
478 BundleImportSkillPreview,
479 };
480
481 let resolved = resolve_import_input(req.file_path, req.zip_base64, req.files, preview_commit_warning_budget())
482 .map_err(|e| format!("bundle.import.preview: {e}"))?;
483
484 let parsed = bi::parse_bundle_import_with_budget(&resolved.files, preview_commit_warning_budget())
485 .map_err(|e| format!("bundle.import.preview: {e}"))?;
486
487 let widgets = preview_widgets(&resolved);
488 let mut all_warnings = resolved.intake_warnings;
489 all_warnings.extend(parsed.warnings);
490
491 let global_slugs: std::collections::HashSet<String> = mstore
493 .skill_list_global(identity_store)
494 .map_err(|e| format!("bundle.import.preview: {e}"))?
495 .into_iter()
496 .map(|item| item.skill.name)
497 .collect();
498 let in_bundle_dupes = bi::duplicate_in_bundle_slugs(&parsed.skills);
499
500 let skills_json: Vec<BundleImportSkillPreview> = parsed
501 .skills
502 .iter()
503 .map(|skill| {
504 let collision = bi::classify_skill_collision(&skill.slug, &global_slugs, &in_bundle_dupes);
505 BundleImportSkillPreview {
506 source_dir: skill.source_dir.clone(),
507 slug: bounded_display(&skill.slug),
508 description: bounded_display(&skill.description),
509 collision: collision.to_string(),
510 }
511 })
512 .collect();
513
514 let mcp_servers_json: Vec<BundleImportMcpServerPreview> = parsed
515 .mcp_servers
516 .iter()
517 .map(|m| {
518 let display = bi::mcp_server_display(&m.config);
523 BundleImportMcpServerPreview {
524 source_path: m.source_path.clone(),
525 display: BundleImportMcpServerDisplay {
526 name: display.get("name").and_then(|v| v.as_str()).map(str::to_string),
527 command: display.get("command").and_then(|v| v.as_str()).map(str::to_string),
528 },
529 }
530 })
531 .collect();
532
533 let (instructions_preview, instructions_truncated, instructions_total_chars) =
534 bi::bounded_instructions_preview(&parsed.instructions);
535
536 let context_files_json: Vec<BundleImportContextFilePreview> = parsed
537 .context_files
538 .iter()
539 .map(|cf| BundleImportContextFilePreview {
540 id: cf.id,
541 display_path: bounded_display(&cf.path),
542 size_bytes: cf.content.len(),
543 })
544 .collect();
545
546 let resolved_requirements = resolve_account_requirements(id_store, &parsed.requirements)
547 .map_err(|e| format!("bundle.import.preview: {e}"))?;
548 let requirements_json: Vec<BundleImportRequirementPreview> = resolved_requirements
549 .iter()
550 .map(|r| BundleImportRequirementPreview {
551 id: bounded_display(&r.id),
552 provider: bounded_display(&r.provider),
553 env: bounded_display(&r.env),
554 resolved: r.resolved(),
555 match_count: r.match_count,
556 })
557 .collect();
558
559 let existing_names: std::collections::HashSet<String> = id_store
562 .bundle_list()
563 .map_err(|e| format!("bundle.import.preview: {e}"))?
564 .into_iter()
565 .map(|b| b.name)
566 .collect();
567 let name_collision = existing_names.contains(&parsed.name);
568
569 let project_instructions_json: Vec<BundleImportProjectInstructionPreview> = parsed
577 .project_instructions
578 .iter()
579 .map(|pi| {
580 let total_chars = pi.content.chars().count();
581 let truncated = total_chars > MAX_PROJECT_INSTRUCTION_PREVIEW_CHARS;
582 let preview: String = if truncated {
583 pi.content.chars().take(MAX_PROJECT_INSTRUCTION_PREVIEW_CHARS).collect()
584 } else {
585 pi.content.clone()
586 };
587 BundleImportProjectInstructionPreview {
588 path: bounded_display(&pi.path),
589 file: bounded_display(&pi.file),
590 content_hash: bounded_display(&pi.content_hash),
591 owner: bounded_display(&pi.owner),
592 content_preview: preview,
593 content_truncated: truncated,
594 content_total_chars: total_chars,
595 }
596 })
597 .collect();
598
599 let (warnings, warnings_truncated) = bound_warnings_for_response(all_warnings);
600
601 Ok(BundleImportPreviewResponse {
602 widgets,
603 project_instructions: project_instructions_json,
604 name: parsed.name,
605 description: bounded_display(&parsed.description),
606 instructions_preview,
607 instructions_truncated,
608 instructions_total_chars,
609 context_files: context_files_json,
610 skills: skills_json,
611 mcp_servers: mcp_servers_json,
612 requirements: requirements_json,
613 warnings,
614 warnings_truncated,
615 name_collision,
616 content_digest: resolved.content_digest,
617 })
618}
619
620pub(super) fn register_bundle_import_preview(engine: &Arc<WshRpcEngine>, state: &AppState) {
621 let id_store = state.id_store.clone();
622 let identity_store = state.identity_store.clone();
623 let mstore = state.mstore.clone();
624 engine.register_typed(
625 COMMAND_BUNDLE_IMPORT_PREVIEW,
626 move |req: PreviewReq, _ctx| {
627 let id_store = id_store.clone();
628 let identity_store = identity_store.clone();
629 let mstore = mstore.clone();
630 async move {
631 bundle_import_preview_impl(&id_store, &identity_store, &mstore, req).await
632 }
633 },
634 );
635}
636
637
638
639
640pub(super) async fn bundle_import_commit_impl(
650 id_store: &crate::backend::storage::store::Store,
651 identity_store: &crate::backend::storage::store::Store,
652 mstore: &crate::backend::storage::store::Store,
653 broker: &crate::backend::mps::Broker,
654 req: CommitReq,
655) -> Result<crate::backend::rpc_types::BundleImportCommitResponse, String> {
656 use crate::backend::bundle_import as bi;
657 use crate::backend::rpc_types::{BundleImportCommitResponse, BundleImportUnresolvedRequirement};
658
659 let resolved = resolve_import_input(req.file_path, req.zip_base64, req.files, preview_commit_warning_budget())
660 .map_err(|e| format!("bundle.import.commit: {e}"))?;
661
662 if resolved.content_digest != req.expected_content_digest {
669 return Err(
670 "bundle.import.commit: content changed since preview (digest mismatch) — re-select and preview again"
671 .to_string(),
672 );
673 }
674
675 let parsed = bi::parse_bundle_import_with_budget(&resolved.files, preview_commit_warning_budget())
676 .map_err(|e| format!("bundle.import.commit: {e}"))?;
677
678 let mut warnings = resolved.intake_warnings;
679 warnings.extend(parsed.warnings.clone());
680
681 let bundle_name = bi::bound_bundle_name(&req.bundle_name.unwrap_or_else(|| parsed.name.clone()));
689
690 let instructions = if req.include_instructions { parsed.instructions.clone() } else { String::new() };
691 let instructions_by_provider = if req.include_instructions {
696 parsed.instructions_by_provider.clone()
697 } else {
698 std::collections::HashMap::new()
699 };
700
701 let selected_context_files: Vec<serde_json::Value> = parsed
704 .context_files
705 .iter()
706 .filter(|cf| req.include_context_files.contains(&cf.id))
707 .map(|cf| json!({ "path": cf.path, "content": cf.content }))
708 .collect();
709
710 let resolved_reqs = resolve_account_requirements(id_store, &parsed.requirements)
711 .map_err(|e| format!("bundle.import.commit: {e}"))?;
712 let resolved_requirement_ids: Vec<String> =
716 resolved_reqs.iter().filter(|r| r.resolved()).map(|r| bounded_display(&r.id)).collect();
717 let unresolved_requirements: Vec<BundleImportUnresolvedRequirement> = resolved_reqs
718 .iter()
719 .filter(|r| !r.resolved())
720 .map(|r| BundleImportUnresolvedRequirement {
721 id: bounded_display(&r.id),
722 provider: bounded_display(&r.provider),
723 env: bounded_display(&r.env),
724 match_count: r.match_count,
725 })
726 .collect();
727
728 let global_slugs: std::collections::HashSet<String> = mstore
738 .skill_list_global(identity_store)
739 .map_err(|e| format!("bundle.import.commit: {e}"))?
740 .into_iter()
741 .map(|item| item.skill.name)
742 .collect();
743 let in_bundle_dupes = bi::duplicate_in_bundle_slugs(&parsed.skills);
744
745 let rollback_skills = |ids: &[String]| -> Vec<String> {
746 ids.iter()
747 .filter_map(|id| mstore.skill_delete(identity_store, id).err().map(|e| format!("{id}: {e}")))
748 .collect()
749 };
750
751 let now = now_ms();
752 let mut imported_skill_ids: Vec<String> = Vec::new();
753 let mut skipped_skills: Vec<String> = Vec::new();
754
755 let mut seen_source_dirs: std::collections::HashSet<&str> = std::collections::HashSet::new();
766 let deduped_skill_selections: Vec<&SkillSelection> = req
767 .include_skills
768 .iter()
769 .filter(|s| seen_source_dirs.insert(s.source_dir.as_str()))
770 .take(bi::MAX_IMPORTED_SKILLS)
771 .collect();
772
773 for selection in deduped_skill_selections {
774 let Some(skill) = parsed.skills.iter().find(|s| s.source_dir == selection.source_dir) else {
775 continue; };
777 let collision = bi::classify_skill_collision(&skill.slug, &global_slugs, &in_bundle_dupes);
778 let non_empty_rename = selection.import_as.as_deref().filter(|s| !s.is_empty());
779
780 if collision != "none" && non_empty_rename.is_none() {
781 skipped_skills.push(bounded_display(&skill.slug));
784 continue;
785 }
786 let effective_slug = non_empty_rename.map(|s| s.to_string()).unwrap_or_else(|| skill.slug.clone());
787
788 let row = crate::backend::storage::Skill {
789 id: uuid::Uuid::new_v4().to_string(),
790 name: effective_slug.clone(),
791 trigger: effective_slug.clone(),
792 skill_type: crate::backend::agent_config::SKILL_TYPE_AGENT_SKILL.to_string(),
793 description: skill.description.clone(),
794 content: skill.content.clone(),
795 is_global: true,
796 created_at: now,
797 updated_at: now,
798 };
799 match identity_store.skill_upsert_unique_global(&row) {
800 Ok(()) => imported_skill_ids.push(row.id),
801 Err(crate::backend::storage::error::StoreError::Other(msg))
802 if msg.contains("already exists") =>
803 {
804 warnings.push(format!("skill \"{}\": already exists", bounded_display(&effective_slug)));
819 skipped_skills.push(bounded_display(&effective_slug));
820 }
821 Err(e) => {
822 let rollback_errors = rollback_skills(&imported_skill_ids);
823 let mut msg = format!(
824 "bundle.import.commit: failed to create skill \"{effective_slug}\": {e}"
825 );
826 if !rollback_errors.is_empty() {
827 msg.push_str(&format!(
828 "; additionally, rollback of {} previously-created skill(s) failed and may have left orphaned global skill row(s): {}",
829 rollback_errors.len(),
830 rollback_errors.join("; ")
831 ));
832 }
833 return Err(msg);
834 }
835 }
836 }
837
838 let memory = Bundle {
839 id: uuid::Uuid::new_v4().to_string(),
840 name: bundle_name,
841 description: parsed.description,
842 is_blank: false,
843 is_global: false,
844 provider: parsed.provider,
850 model: parsed.model,
851 instructions,
852 instructions_by_provider: serde_json::to_string(&instructions_by_provider)
853 .unwrap_or_else(|_| "{}".to_string()),
854 context_files: serde_json::to_string(&selected_context_files)
855 .unwrap_or_else(|_| "[]".to_string()),
856 mcp_servers: "[]".to_string(),
857 skills: serde_json::to_string(&imported_skill_ids)
858 .unwrap_or_else(|_| "[]".to_string()),
859 sort_order: 0,
860 created_at: now,
861 updated_at: now,
862 is_system: false,
863 };
864 if let Err(e) = id_store.bundle_upsert(&memory) {
865 let rollback_errors = rollback_skills(&imported_skill_ids);
866 let mut msg = format!("bundle.import.commit: {e}");
867 if !rollback_errors.is_empty() {
868 msg.push_str(&format!(
869 "; additionally, rollback of {} previously-created skill(s) failed and may have left orphaned global skill row(s): {}",
870 rollback_errors.len(),
871 rollback_errors.join("; ")
872 ));
873 }
874 return Err(msg);
875 }
876
877 warnings.extend(bind_imported_components(
880 &mstore,
881 &id_store,
882 &identity_store,
883 &memory.id,
884 &imported_skill_ids,
885 ));
886 warnings.extend(mcp_servers_not_imported_warning(&parsed.mcp_servers));
887
888 broker.publish_event::<crate::backend::mps_events::MemoriesChanged>(&(), vec![], 0);
889 if !imported_skill_ids.is_empty() {
890 broker.publish_event::<crate::backend::mps_events::SkillsChanged>(&(), vec![], 0);
891 }
892
893 let (bounded_warnings, warnings_truncated) = bound_warnings_for_response(warnings);
896
897 Ok(BundleImportCommitResponse {
898 widgets: vec![],
899 bundle_id: memory.id,
900 imported_skill_ids,
901 skipped_skills,
902 resolved_requirement_ids,
903 unresolved_requirements,
904 warnings: bounded_warnings,
905 warnings_truncated,
906 })
907}
908
909pub(super) fn register_bundle_import_commit(engine: &Arc<WshRpcEngine>, state: &AppState) {
910 let id_store = state.id_store.clone();
911 let identity_store = state.identity_store.clone();
912 let mstore = state.mstore.clone();
913 let broker = state.broker.clone();
914 let app = state.clone();
915 engine.register_typed(
916 COMMAND_BUNDLE_IMPORT_COMMIT,
917 move |req: CommitReq, _ctx| {
918 let id_store = id_store.clone();
919 let identity_store = identity_store.clone();
920 let mstore = mstore.clone();
921 let broker = broker.clone();
922 let app = app.clone();
923 async move {
924 let widgets_input = (!req.include_widgets.is_empty())
925 .then(|| (req.file_path.clone(), req.zip_base64.clone(), req.expected_content_digest.clone(), req.include_widgets.clone()));
926 let mut resp = bundle_import_commit_impl(&id_store, &identity_store, &mstore, &broker, req).await?;
927 if let Some((file_path, zip_base64, digest, include)) = widgets_input {
928 resp.widgets = install_bundle_widgets(&app, file_path, zip_base64, &digest, &include).await;
929 }
930 Ok(resp)
931 }
932 },
933 );
934}
935
936fn preview_widgets(resolved: &ResolvedImportInput) -> Vec<crate::backend::bundle_widgets::BundleImportWidgetPreview> {
938 use crate::backend::bundle_widgets as bw;
939 if resolved.widgets.is_empty() {
940 return vec![];
941 }
942 let manifest = resolved.files.iter().find(|f| f.path == crate::backend::bundle_import::MANIFEST_FILE).map(|f| f.content.as_str());
943 let declared = bw::declared(manifest);
944 let svc = crate::backend::widget_packages::service();
945 let installed = svc.map(|s| s.list()).unwrap_or_default();
946 resolved
947 .widgets
948 .iter()
949 .map(|w| {
950 bw::preview(w, &declared, &installed, |id, signer| match svc {
951 Some(s) => s.describe_signature(id, signer),
952 None => crate::backend::widget_signature::describe(id, signer, &Default::default()),
953 })
954 })
955 .collect()
956}
957
958async fn install_bundle_widgets(
964 app: &AppState,
965 file_path: Option<String>,
966 zip_base64: Option<String>,
967 digest: &str,
968 include: &[String],
969) -> Vec<crate::backend::rpc_types::BundleImportWidgetResult> {
970 use crate::backend::bundle_widgets as bw;
971 use crate::backend::rpc_types::BundleImportWidgetResult;
972 use crate::backend::widget_packages::{self as wp, WidgetState};
973 let failed = |id: &str, e: String| BundleImportWidgetResult { id: id.to_string(), status: "failed".into(), error: Some(e) };
974 let Some(svc) = wp::service() else {
975 return include.iter().map(|id| failed(id, "widget packages aren't available on this AgentMux".into())).collect();
976 };
977 let resolved = match tokio::task::spawn_blocking(move || resolve_import_input(file_path, zip_base64, None, preview_commit_warning_budget())).await {
978 Ok(Ok(r)) if r.content_digest == digest => r,
979 Ok(Ok(_)) => return include.iter().map(|id| failed(id, "the bundle changed since the preview".into())).collect(),
980 Ok(Err(e)) => return include.iter().map(|id| failed(id, e.clone())).collect(),
981 Err(e) => return include.iter().map(|id| failed(id, e.to_string())).collect(),
982 };
983 let previews = preview_widgets(&resolved);
984 let name = resolved
985 .files
986 .iter()
987 .find(|f| f.path == crate::backend::bundle_import::MANIFEST_FILE)
988 .and_then(|f| serde_json::from_str::<serde_json::Value>(&f.content).ok())
989 .and_then(|v| v.get("name").and_then(|n| n.as_str()).map(str::to_string))
990 .unwrap_or_else(|| "a bundle".into());
991 let mut results = Vec::new();
992 let mut asking = Vec::new();
993 for id in include {
994 let (Some(w), Some(p)) = (resolved.widgets.iter().find(|w| &w.id == id), previews.iter().find(|p| &p.id == id)) else {
995 results.push(failed(id, "the bundle doesn't carry it".into()));
996 continue;
997 };
998 if let Some(e) = &p.error {
999 results.push(failed(id, e.clone()));
1000 continue;
1001 }
1002 if p.same_as_installed {
1003 results.push(BundleImportWidgetResult { id: id.clone(), status: "unchanged".into(), error: None });
1004 continue;
1005 }
1006 let (dir, widget) = (svc.widgets_dir.clone(), w.clone());
1007 match tokio::task::spawn_blocking(move || bw::install(&dir, &widget)).await {
1008 Ok(Ok(_)) => {
1009 asking.push(id.clone());
1010 results.push(BundleImportWidgetResult { id: id.clone(), status: "waiting".into(), error: None });
1011 }
1012 Ok(Err(e)) => results.push(failed(id, e)),
1013 Err(e) => results.push(failed(id, e.to_string())),
1014 }
1015 }
1016 if !asking.is_empty() {
1017 let packages = wp::refresh_off_thread(&app.config_watcher, &app.event_bus, &app.broker).await;
1018 let asker = format!("The bundle \u{201c}{name}\u{201d}");
1019 for pkg in packages.iter().filter(|p| asking.contains(&p.id) && p.state != WidgetState::Approved) {
1020 drop(crate::backend::widget_requests::requests().ask(crate::backend::widget_requests::WidgetInstallRequest::of(pkg, &asker)));
1023 }
1024 crate::server::widget_agent_handlers::publish_requests(app);
1025 }
1026 results
1027}