Skip to main content

agentmux_srv/server/app_api/bundle/
import_for_agent.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `bundle.import_for_agent`: apply an ABF to an existing agent definition,
5//! serialised per agent by `bundle_import_for_agent_lock`.
6
7use super::*;
8
9#[derive(serde::Deserialize, Default, ts_rs::TS)]
10#[ts(export, export_to = "../../../frontend/types/rpc/", rename = "BundleImportForAgentReq")]
11pub(super) struct ImportForAgentReq {
12    pub(crate) agent_id: String,
13    #[serde(default)]
14    #[ts(optional)]
15    pub(crate) file_path: Option<String>,
16    #[serde(default)]
17    #[ts(optional)]
18    pub(crate) zip_base64: Option<String>,
19    #[serde(default)]
20    #[ts(optional)]
21    pub(crate) files: Option<Vec<FileEntry>>,
22}
23
24/// Response for `bundle.import_for_agent`.
25#[derive(Debug, Clone, serde::Serialize, ts_rs::TS)]
26#[ts(export, export_to = "../../../frontend/types/rpc/")]
27pub(crate) struct BundleImportForAgentResponse {
28    pub(crate) bundle_id: String,
29    #[ts(type = "number")]
30    pub(crate) memory_files_written: usize,
31    pub(crate) skipped_skills: Vec<String>,
32    pub(crate) warnings: Vec<String>,
33}
34
35/// `bundle.import_for_agent` — ABF v0.2 §2.3. The only import path that
36/// processes a `components.memory` key; the generic `bundle.import`
37/// explicitly skips it (see `parse_bundle_import_with_budget`'s own
38/// components.memory guard). Unlike `bundle.import`/`bundle.import.commit`,
39/// this is a single-step import — no preview phase — since a
40/// memory-bearing import has its own unconditional safety net instead:
41/// the target agent must have ZERO existing `db_agent_native_memory` rows,
42/// or the whole import is rejected before any write happens. Simpler than
43/// skip/rename/replace conflict resolution, and sufficient for v0.2 (see
44/// the spec's revision note for why merge semantics are an explicit
45/// follow-up, not designed here). Extracted from the RPC closure into a
46/// directly-callable, directly-testable function, matching
47/// `bundle_import_preview_impl`'s pattern.
48pub(super) async fn bundle_import_for_agent_impl(
49    id_store: &crate::backend::storage::store::Store,
50    identity_store: &crate::backend::storage::store::Store,
51    mstore: &crate::backend::storage::store::Store,
52    req: ImportForAgentReq,
53) -> Result<BundleImportForAgentResponse, String> {
54    use crate::backend::bundle_import as bi;
55
56    let agent = mstore
57        .agent_def_get(&req.agent_id)
58        .map_err(|e| format!("bundle.import_for_agent: {e}"))?
59        .ok_or_else(|| format!("bundle.import_for_agent: no agent with id {}", req.agent_id))?;
60
61    // reagent P2, PR #2527: serialize the whole "check zero rows, then
62    // write" sequence per agent — without this, two concurrent
63    // bundle.import_for_agent calls for the same agent can both pass the
64    // zero-rows check before either writes, both proceeding and defeating
65    // the "can't destroy existing memory" invariant. Held until the
66    // function returns (guard drops at every exit path), mirroring
67    // agent_open.rs's AGENT_OPEN_LOCKS precedent — same same-process-only
68    // scope note applies (can't see a genuinely different AgentMux
69    // instance/channel racing the same agent_id).
70    let import_lock = bundle_import_for_agent_lock(&agent.id);
71    let _import_guard = import_lock.lock().await;
72
73    // reagent P0, PR #2527: the "zero existing memory" guard below only
74    // consults db_agent_native_memory (the mirror) — a live file that was
75    // written autonomously but never viewed through the Stash Bundle tab
76    // is NOT in the mirror yet, so the check would pass and the write
77    // loop further down would silently overwrite it via fs::rename. Same
78    // class of bug bundle.export_for_agent already had to guard against
79    // (see refresh_memory_mirror_from_live_fs's own doc comment) — the
80    // fix is the same: refresh from the live FS before trusting the
81    // mirror's row count. A missing/unresolvable working directory is not
82    // an error here (nothing to refresh from); the emptiness check below
83    // still runs against whatever the mirror already has.
84    // Only a verified directory is refreshed from: an unverified guess can be
85    // another agent's folder, and refreshing from it would write that
86    // agent's files into this one's mirror (SPEC_MEMORY_FOLLOWS_THE_AGENT
87    // §2.1.2).
88    let resolved = crate::server::native_memory_handlers::resolve_memory_dir_by_id(mstore, &agent);
89    let verified = resolved.as_ref().is_some_and(|r| {
90        r.provenance != crate::server::native_memory_handlers::MemoryDirProvenance::Unverified
91    });
92    let memory_dir = resolved.map(|r| r.path);
93    let mut warnings: Vec<String> = Vec::new();
94    if let Some(dir) = memory_dir.as_ref().filter(|_| verified) {
95        if let Err(e) = crate::server::native_memory_handlers::refresh_memory_mirror_from_live_fs(&agent.id, dir, id_store) {
96            warnings.push(format!("native memory refresh failed (checking mirror as-is): {e}"));
97        }
98    }
99
100    // Reject BEFORE any parsing/decoding work — a target with existing
101    // memory is a hard stop, not something worth spending
102    // decompression/parse cost on first.
103    let existing = id_store
104        .agent_native_memory_list_meta(&agent.id)
105        .map_err(|e| format!("bundle.import_for_agent: {e}"))?;
106    if !existing.is_empty() {
107        return Err(format!(
108            "bundle.import_for_agent: agent {} already has {} native memory file(s) — import into an agent with no existing memory, or clear it first",
109            req.agent_id,
110            existing.len()
111        ));
112    }
113
114    let resolved = resolve_import_input(req.file_path, req.zip_base64, req.files, bi::WarningBudget::unbounded())
115        .map_err(|e| format!("bundle.import_for_agent: {e}"))?;
116    let parsed = bi::parse_bundle_import(&resolved.files)
117        .map_err(|e| format!("bundle.import_for_agent: {e}"))?;
118
119    warnings.extend(resolved.intake_warnings);
120    // reagent P1, PR #2527: parse_bundle_import unconditionally warns
121    // that components.memory is "present but ignored" (correct for
122    // bundle.import/.preview/.commit, which really do ignore it) — but
123    // THIS function handles memory itself, further down. Filtering the
124    // exact shared-constant string out here (rather than duplicating a
125    // literal that could drift) stops every successful memory-bearing
126    // import from falsely claiming its memory was ignored.
127    warnings.extend(parsed.warnings.iter().filter(|w| w.as_str() != bi::MEMORY_COMPONENT_IGNORED_WARNING).cloned());
128
129    // reagent P2, PR #2527: this RPC exists specifically to transfer
130    // memory — if the manifest actually has a memory component but this
131    // agent has no resolvable working directory (and therefore no memory
132    // dir to write into), failing fast here (before any skill/bundle
133    // writes) is correct; silently succeeding with
134    // memory_files_written: 0 would drop the one thing the caller asked
135    // for. A bundle with NO memory component is unaffected — that's a
136    // normal bundle.import_for_agent call that just happens not to need
137    // a memory dir.
138    let manifest_memory_paths = resolved_memory_paths(&resolved.files);
139    if !manifest_memory_paths.is_empty() && memory_dir.is_none() {
140        return Err(format!(
141            "bundle.import_for_agent: agent {} has no working directory configured; cannot import its memory",
142            req.agent_id
143        ));
144    }
145    // Importing memory writes files, so the directory must be verified, not
146    // a blank-working-dir guess (SPEC_MEMORY_FOLLOWS_THE_AGENT_2026_09_24.md §2.1.2).
147    let memory_dir = if manifest_memory_paths.is_empty() {
148        memory_dir
149    } else {
150        Some(
151            crate::server::native_memory_handlers::memory_dir_for_write_by_id(mstore, &agent)
152                .map_err(|e| format!("bundle.import_for_agent: {e}"))?,
153        )
154    };
155
156    // Normal bundle components, exactly as bundle.import creates them —
157    // a memory-bearing import still creates a reusable bundle row
158    // alongside the agent-scoped memory write below.
159    let bundle_id = uuid::Uuid::new_v4().to_string();
160    // Mirrors bundle.import's own skill-creation block exactly (global,
161    // not bound to any agent; skill_upsert_unique_global is the real API
162    // — a genuine name conflict is a per-skill warning, any other error
163    // aborts the whole RPC).
164    let now = now_ms();
165    let mut imported_skill_ids: Vec<String> = Vec::new();
166    // reagent P2, PR #2527: mirrors bundle.import's own rollback_skills
167    // closure — without it, a later failure (the bundle upsert below)
168    // leaves already-created global skill rows orphaned with no way for
169    // the caller to know cleanup may be needed.
170    let rollback_skills = |ids: &[String]| -> Vec<String> {
171        ids.iter()
172            .filter_map(|id| mstore.skill_delete(identity_store, id).err().map(|e| format!("{id}: {e}")))
173            .collect()
174    };
175    for skill in &parsed.skills {
176        let row = crate::backend::storage::Skill {
177            id: uuid::Uuid::new_v4().to_string(),
178            name: skill.slug.clone(),
179            trigger: skill.slug.clone(),
180            skill_type: crate::backend::agent_config::SKILL_TYPE_AGENT_SKILL.to_string(),
181            description: skill.description.clone(),
182            content: skill.content.clone(),
183            is_global: true,
184            created_at: now,
185            updated_at: now,
186        };
187        match identity_store.skill_upsert_unique_global(&row) {
188            Ok(()) => imported_skill_ids.push(row.id),
189            Err(e) if e.to_string().contains("already exists") => {
190                warnings.push(format!("skill \"{}\" already exists; skipped", skill.slug));
191            }
192            Err(e) => {
193                let rollback_errors = rollback_skills(&imported_skill_ids);
194                let mut msg = format!("bundle.import_for_agent: failed to create skill \"{}\": {e}", skill.slug);
195                if !rollback_errors.is_empty() {
196                    msg.push_str(&format!(
197                        "; additionally, rollback of {} previously-created skill(s) failed and may have left orphaned global skill row(s): {}",
198                        rollback_errors.len(),
199                        rollback_errors.join("; ")
200                    ));
201                }
202                return Err(msg);
203            }
204        }
205    }
206    let memory = crate::backend::storage::store::Bundle {
207        id: bundle_id.clone(),
208        name: parsed.name,
209        description: parsed.description,
210        is_blank: false,
211        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)
216            .unwrap_or_else(|_| "{}".to_string()),
217        context_files: serde_json::to_string(
218            &parsed.context_files.iter().map(|cf| json!({"path": cf.path, "content": cf.content})).collect::<Vec<_>>(),
219        ).unwrap_or_else(|_| "[]".to_string()),
220        // A bundle carries no MCP servers (see the warning below).
221        mcp_servers: "[]".to_string(),
222        skills: serde_json::to_string(&imported_skill_ids).unwrap_or_else(|_| "[]".to_string()),
223        sort_order: 0,
224        created_at: now,
225        updated_at: now,
226        is_system: false,
227    };
228    if let Err(e) = id_store.bundle_upsert(&memory) {
229        let rollback_errors = rollback_skills(&imported_skill_ids);
230        let mut msg = format!("bundle.import_for_agent: {e}");
231        if !rollback_errors.is_empty() {
232            msg.push_str(&format!(
233                "; additionally, rollback of {} previously-created skill(s) failed and may have left orphaned global skill row(s): {}",
234                rollback_errors.len(),
235                rollback_errors.join("; ")
236            ));
237        }
238        return Err(msg);
239    }
240
241    // The bundle row now exists, so its skills can be bound. Ref tables are
242    // authoritative — without this the imported bundle would export empty.
243    warnings.extend(bind_imported_components(
244        mstore,
245        id_store,
246        identity_store,
247        &bundle_id,
248        &imported_skill_ids,
249    ));
250    warnings.extend(mcp_servers_not_imported_warning(&parsed.mcp_servers));
251
252    // Bundle files: write through the SAME dual-write path
253    // agent:memory:write_file uses (live FS via memory_dir_for_cwd, then
254    // the mirror) — writing only to db_agent_native_memory would leave
255    // this content visible in Stash while invisible to the actual
256    // running agent (see the spec's revision note).
257    let mut memory_files_written = 0usize;
258    if let Some(memory_dir) = &memory_dir {
259        if let Err(e) = std::fs::create_dir_all(memory_dir) {
260            warnings.push(format!("native memory: mkdir failed: {e}"));
261        }
262    }
263    if let Some(memory_dir) = memory_dir {
264        for path in manifest_memory_paths {
265            // reagent P1, PR #2527 (third round): every skip in this loop
266            // must warn — this RPC's whole reason to exist is transferring
267            // memory, and a silent drop here (bundle_id/skills already
268            // created, memory_files_written silently undercounting)
269            // directly contradicts that. Matches the sibling
270            // invalid-filename branch just below, which already warned.
271            let Some(filename) = path.strip_prefix("memory/") else {
272                warnings.push(format!("{path}: components.memory path is not under memory/; skipped"));
273                continue;
274            };
275            if crate::server::native_memory_handlers::validate_memory_filename(filename).is_err() {
276                warnings.push(format!("memory/{filename}: not a valid memory filename; skipped"));
277                continue;
278            }
279            let Some(content) = resolved.files.iter().find(|f| f.path == path).map(|f| f.content.clone()) else {
280                warnings.push(format!("{path}: referenced in components.memory but not found among the bundle's files; skipped"));
281                continue;
282            };
283            // Into the agent's memory record first, as every AgentMux write
284            // (SPEC_MEMORY_FOLLOWS_THE_AGENT_2026_09_24.md §2.1.1).
285            crate::backend::memory_reconcile::record_agentmux_write(
286                &agent.id,
287                &memory_dir,
288                filename,
289                content.as_bytes(),
290                "bundle-import",
291                "",
292            );
293            let dest = memory_dir.join(filename);
294            let tmp = memory_dir.join(format!(".{filename}.{}.tmp", uuid::Uuid::new_v4()));
295            let write_result = std::fs::write(&tmp, &content)
296                .and_then(|_| std::fs::rename(&tmp, &dest));
297            if let Err(e) = write_result {
298                let _ = std::fs::remove_file(&tmp);
299                warnings.push(format!("memory/{filename}: write failed: {e}"));
300                continue;
301            }
302            let dest_meta = std::fs::metadata(&dest).ok();
303            let size_bytes = dest_meta.as_ref().map(|m| m.len() as i64).unwrap_or(content.len() as i64);
304            let mtime_ms = dest_meta
305                .and_then(|m| m.modified().ok())
306                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
307                .map(|d| d.as_millis() as i64)
308                .unwrap_or(0);
309            let metadata_type = crate::server::native_memory_handlers::parse_memory_frontmatter_type(&content);
310            if let Err(e) = id_store.agent_native_memory_upsert(
311                &agent.id,
312                filename,
313                &content,
314                metadata_type.as_deref(),
315                &dest.to_string_lossy(),
316                size_bytes,
317                mtime_ms,
318            ) {
319                warnings.push(format!("memory/{filename}: mirror upsert failed: {e}"));
320            } else {
321                memory_files_written += 1;
322            }
323        }
324    }
325
326    Ok(BundleImportForAgentResponse {
327        bundle_id,
328        memory_files_written,
329        skipped_skills: parsed.skipped_skills,
330        warnings,
331    })
332}
333
334pub(super) fn register_bundle_import_for_agent(engine: &Arc<WshRpcEngine>, state: &AppState) {
335    let id_store = state.id_store.clone();
336    let identity_store = state.identity_store.clone();
337    let mstore = state.mstore.clone();
338    engine.register_typed(
339        COMMAND_BUNDLE_IMPORT_FOR_AGENT,
340        move |req: ImportForAgentReq, _ctx| {
341            let id_store = id_store.clone();
342            let identity_store = identity_store.clone();
343            let mstore = mstore.clone();
344            async move { bundle_import_for_agent_impl(&id_store, &identity_store, &mstore, req).await }
345        },
346    );
347}
348
349/// Every `memory/*` path listed under `components.memory` in a parsed
350/// import's manifest (`bundle.json`, or `armory.json` before ABF v0.3) — mirrors how `components.instructions`/
351/// `components.skills` are read elsewhere in this file, kept local to
352/// `bundle.import_for_agent` since no other handler needs it.
353pub(super) fn resolved_memory_paths(files: &[crate::backend::bundle_import::BundleImportFile]) -> Vec<String> {
354    let Some(name) = crate::backend::bundle_import::manifest_file_name(|n| files.iter().any(|f| f.path == n)) else {
355        return Vec::new();
356    };
357    let Some(manifest_file) = files.iter().find(|f| f.path == name) else {
358        return Vec::new();
359    };
360    let Ok(manifest) = serde_json::from_str::<serde_json::Value>(&manifest_file.content) else {
361        return Vec::new();
362    };
363    manifest["components"]["memory"]
364        .as_array()
365        .map(|arr| arr.iter().filter_map(|v| v.as_str().map(str::to_string)).collect())
366        .unwrap_or_default()
367}