Skip to main content

agentmux_srv/server/app_api/bundle/
export.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `bundle.export`: serialise one stored bundle to an ABF archive.
5
6use super::*;
7
8/// `bundle.export` — Armory Bundle Format (ABF) exporter, Phase 1 of
9/// docs/specs/REPORT_ARMORY_BUNDLE_STANDARD_RESEARCH_2026_07_16.md /
10/// https://docs.agentmux.ai/abf/. Window-scoped (no `check_s1`) like the
11/// rest of `bundle.*` — bundles aren't agent-specific. Reads the bundle
12/// from `id_store` and resolves its referenced skill ids against `mstore`
13/// (skills live in a different Store instance than bundles — see
14/// `Store::skill_get` callers elsewhere in this codebase), then hands both
15/// to the pure `bundle_export::export_bundle`. `format: "zip"` returns a
16/// base64-encoded archive; anything else (including omitted) returns the
17/// raw file list for the caller to write out itself.
18#[derive(serde::Deserialize, Default, ts_rs::TS)]
19#[ts(export, export_to = "../../../frontend/types/rpc/", rename = "BundleExportReq")]
20pub(super) struct ExportReq {
21    pub(crate) id: String,
22    #[serde(default)]
23    #[ts(as = "Option<String>", optional)]
24    pub(crate) format: String,
25    /// Installed sandboxed widgets to carry, by id (zip only;
26    /// SPEC_WIDGET_SHARING_2026_10_10.md §3.4).
27    #[serde(default)]
28    #[ts(as = "Option<Vec<String>>", optional)]
29    pub(crate) widgets: Vec<String>,
30}
31
32/// Response for `bundle.export` and `bundle.export_for_agent`: the file list,
33/// or with `format: "zip"` the archive instead of it.
34#[derive(Debug, Clone, serde::Serialize, ts_rs::TS)]
35#[ts(export, export_to = "../../../frontend/types/rpc/")]
36pub(crate) struct BundleExportResponse {
37    pub(crate) root_slug: String,
38    /// Absent with `format: "zip"`.
39    #[serde(skip_serializing_if = "Option::is_none")]
40    #[ts(optional)]
41    pub(crate) files: Option<Vec<crate::backend::bundle_export::BundleExportFile>>,
42    pub(crate) skipped_skills: Vec<String>,
43    pub(crate) warnings: Vec<String>,
44    pub(crate) missing_skill_ids: Vec<String>,
45    /// Present only with `format: "zip"`.
46    #[serde(skip_serializing_if = "Option::is_none")]
47    #[ts(optional)]
48    pub(crate) zip_base64: Option<String>,
49}
50
51pub(super) fn register_bundle_export(engine: &Arc<WshRpcEngine>, state: &AppState) {
52    let id_store = state.id_store.clone();
53    let mstore = state.mstore.clone();
54    let identity_store = state.identity_store.clone();
55    engine.register_typed(
56        COMMAND_BUNDLE_EXPORT,
57        move |req: ExportReq, _ctx| {
58            let id_store = id_store.clone();
59            let mstore = mstore.clone();
60            let identity_store = identity_store.clone();
61            async move { bundle_export_impl(&id_store, &mstore, &identity_store, req) }
62        },
63    );
64}
65
66/// The agent-less `bundle.export`, extracted from its handler closure so the
67/// warning contract in §5.1 can be asserted at the RPC boundary rather than
68/// only on its helper — matching `bundle_export_for_agent_impl` and
69/// `bundle_export_for_agent_with_history_impl`, which were already shaped this
70/// way for the same reason.
71pub(super) fn bundle_export_impl(
72    id_store: &crate::backend::storage::store::Store,
73    mstore: &crate::backend::storage::store::Store,
74    identity_store: &crate::backend::storage::store::Store,
75    req: ExportReq,
76) -> Result<BundleExportResponse, String> {
77    let bundle = id_store
78        .bundle_get(&req.id)
79        .map_err(|e| format!("bundle.export: {e}"))?
80        .ok_or_else(|| format!("bundle.export: no bundle with id {}", req.id))?;
81
82    // Components come from the ref tables, which are authoritative for what a
83    // bundle contains -- see resolve_bundle_components.
84    let components = resolve_bundle_components(mstore, identity_store, &bundle.id)
85        .map_err(|e| format!("bundle.export: {e}"))?;
86
87    // `missing_skill_ids` predates the ref tables: the inline column stored
88    // bare ids, so an id whose skill row had been deleted had to be resolved
89    // and reported (Codex P2, PR #2325). A ref cannot be dangling the same
90    // way -- `managed_list` joins through the catalog, and `skill_delete`
91    // purges refs -- so this is now always empty. Kept in the response because
92    // it is part of the RPC's shape and callers may still read it.
93    let missing_skill_ids: Vec<String> = Vec::new();
94
95    let export = crate::backend::bundle_export::export_bundle(&bundle, &components.skills);
96
97    let mut all_warnings = export.warnings.clone();
98
99    // Export/import symmetry -- see MEMORY_NOT_EXPORTED_WARNING.
100    if bound_agent_has_native_memory(id_store, mstore, &bundle.id) {
101        all_warnings.push(MEMORY_NOT_EXPORTED_WARNING.to_string());
102    }
103
104    if !req.widgets.is_empty() && req.format != "zip" {
105        return Err("bundle.export: widgets travel only in a zip (format: \"zip\")".to_string());
106    }
107    if req.format == "zip" {
108        let zip_bytes = if req.widgets.is_empty() {
109            crate::backend::bundle_export::zip_bundle_export(&export)
110        } else {
111            let svc = crate::backend::widget_packages::service().ok_or("bundle.export: widget packages aren't available")?;
112            let mut widgets = Vec::new();
113            for id in &req.widgets {
114                let (hash, files) = svc.approved_package(id).map_err(|e| format!("bundle.export: {e}"))?;
115                widgets.push((id.clone(), hash, files));
116            }
117            crate::backend::bundle_widgets::zip_export_with_widgets(&export, &widgets)
118        }
119        .map_err(|e| format!("bundle.export: {e}"))?;
120        use base64::Engine as _;
121        let encoded = base64::engine::general_purpose::STANDARD.encode(&zip_bytes);
122        return Ok(BundleExportResponse {
123            root_slug: export.root_slug,
124            files: None,
125            skipped_skills: export.skipped_skills,
126            warnings: all_warnings,
127            missing_skill_ids,
128            zip_base64: Some(encoded),
129        });
130    }
131
132    Ok(BundleExportResponse {
133        root_slug: export.root_slug,
134        files: Some(export.files),
135        skipped_skills: export.skipped_skills,
136        warnings: all_warnings,
137        missing_skill_ids,
138        zip_base64: None,
139    })
140}