Skip to main content

agentmux_srv/migrations/
m0030_backfill_bundle_component_refs.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Backfill `db_bundle_skills_ref` from the inline `db_bundles.skills` JSON
5//! column.
6//!
7//! **Skills only, since bundles stopped carrying MCP servers**
8//! (`SPEC_BUNDLE_CONTENTS_MEMORY_NOT_MCP_2026_10_07.md` §3.1). This also
9//! carried the inline `mcp_servers` column into `db_bundle_mcp_ref`;
10//! `m0036_drop_bundle_mcp` now deletes those refs and clears that column, so
11//! a store that hasn't run this yet would only have the servers dropped again
12//! straight after. That half went with the store code it called.
13//!
14//! Phase 0b of `SPEC_INSTRUCTION_AND_MEMORY_PORTABILITY_2026_09_09.md`. The
15//! two stores were never synced: binding a skill in Memory → Skills wrote only a
16//! ref row, while ABF export read only the inline column, so a bundle could
17//! run with components it did not export and export components it did not run
18//! with (§3.4). The ref tables become authoritative; this carries the inline
19//! data across so nothing is lost when export stops reading the columns.
20//!
21//! **Ships in the same release as the read switch.** Export reading refs
22//! before this has run would make every pre-existing bundle export empty.
23//!
24//! Two stores, deliberately, as of when this migration was written.
25//! `db_bundles` lives in the shared store (`run_shared_store_schema`), while
26//! `db_skills`/`db_mcp_servers` and both ref tables lived in the channel
27//! store alone (`run_object_schema` in `migrations.rs`) — the ref tables had
28//! to sit next to the catalog tables they carried foreign keys to, which is
29//! also why they never had an FK to `db_bundles` (the comment above
30//! `db_bundle_skills_ref` in `run_object_schema`). So this reads bundles from the shared store
31//! and writes refs into the channel store, resolving the former exactly the
32//! way `m0021` does. **Now stale in one respect** (Phase 2 of
33//! `SPEC_DURABLE_BINDINGS_2026_09_10.md`, `m0032_drop_catalog_fk_from_ref_tables`):
34//! `db_skills`/`db_mcp_servers` are authoritatively `identity_store` and the
35//! ref tables no longer carry the FK described above at all — this
36//! migration itself is unaffected (it ran, or runs, entirely within the
37//! world this comment describes, since it always operated on `mstore`'s own
38//! local catalog copy) and is left as a historical record of why the two
39//! stores were split, not a claim about the current schema.
40//!
41//! Idempotent: `INSERT OR IGNORE` on the ref table. Re-running changes
42//! nothing.
43//!
44//! Best-effort per bundle. One malformed `skills` blob must not abort a
45//! migration that is otherwise carrying real data across — the alternative
46//! leaves the store half-converted with the read switch already live. Every
47//! skip is logged with the bundle id.
48
49use std::sync::Arc;
50
51use crate::backend::storage::store::Store;
52
53use super::{Migration, MigrationContext, MigrationError, MigrationScope, VerifyOutcome};
54
55pub struct M0030BackfillBundleComponentRefs;
56
57/// Where `db_bundles` actually lives — the shared store, falling back to the
58/// channel store when it cannot be opened.
59///
60/// Same resolution, and the same never-hard-fail posture, as
61/// `m0021_backfill_agent_bundles::resolve_bundle_store`. An unusable shared
62/// store means "not today" rather than a failed boot.
63fn resolve_bundle_store(ctx: &MigrationContext, mstore: &Arc<Store>) -> Arc<Store> {
64    match Store::open_shared(&ctx.shared_store_path) {
65        Ok(shared) => Arc::new(shared),
66        Err(e) => {
67            tracing::warn!(
68                error = %e,
69                path = %ctx.shared_store_path.display(),
70                "backfill_bundle_component_refs: shared store unavailable, falling back to channel store"
71            );
72            mstore.clone()
73        }
74    }
75}
76
77/// Carry one bundle's inline skills across into the ref table.
78///
79/// Returns how many were bound. Never returns `Err` for bad data — see the
80/// module doc on why one bad bundle must not stop the rest.
81fn backfill_one_bundle(
82    mstore: &Store,
83    bundle_store: &Store,
84    bundle_id: &str,
85    skills_json: &str,
86) -> usize {
87    let mut skills_bound = 0usize;
88
89    // ---- skills: the inline column holds bare ids into db_skills ----
90    match serde_json::from_str::<Vec<String>>(skills_json) {
91        Ok(ids) => {
92            for id in ids {
93                // A skill id whose row is already gone has nothing to bind;
94                // the ref tables cannot represent it and the export could
95                // never have rendered it either.
96                match mstore.skill_get(&id) {
97                    // `mstore` also serves as `catalog` here: this migration
98                    // pre-dates Phase 2 of SPEC_DURABLE_BINDINGS_2026_09_10.md's
99                    // catalog redirect, back when `db_skills` genuinely lived
100                    // on the channel store alone — see this module's own doc
101                    // comment on why `db_skills` lives here and `db_bundles`
102                    // lives in `bundle_store`.
103                    Ok(Some(_)) => match mstore.bundle_skill_bind(mstore, bundle_store, bundle_id, &id) {
104                        Ok(()) => skills_bound += 1,
105                        Err(e) => tracing::warn!(
106                            bundle_id, skill_id = %id, error = %e,
107                            "backfill_bundle_component_refs: skill bind failed"
108                        ),
109                    },
110                    Ok(None) => tracing::warn!(
111                        bundle_id, skill_id = %id,
112                        "backfill_bundle_component_refs: inline skill id has no catalog row — dropped"
113                    ),
114                    Err(e) => tracing::warn!(
115                        bundle_id, skill_id = %id, error = %e,
116                        "backfill_bundle_component_refs: skill lookup failed"
117                    ),
118                }
119            }
120        }
121        Err(e) if !skills_json.trim().is_empty() && skills_json.trim() != "[]" => {
122            tracing::warn!(
123                bundle_id, error = %e,
124                "backfill_bundle_component_refs: malformed inline skills JSON — nothing carried across"
125            );
126        }
127        Err(_) => {} // genuinely blank is not an error
128    }
129
130    skills_bound
131}
132
133impl Migration for M0030BackfillBundleComponentRefs {
134    fn id(&self) -> &'static str {
135        "0030_backfill_bundle_component_refs"
136    }
137
138    fn scope(&self) -> MigrationScope {
139        MigrationScope::Channel
140    }
141
142    fn description(&self) -> &'static str {
143        "Carry inline bundle skills across into the bundle ref table"
144    }
145
146    fn up(&self, ctx: &MigrationContext) -> Result<(), MigrationError> {
147        if !ctx.channel_store_path.exists() {
148            return Ok(());
149        }
150        let mstore = Arc::new(Store::open(&ctx.channel_store_path).map_err(|e| {
151            MigrationError(format!("backfill_bundle_component_refs: open mstore: {e}"))
152        })?);
153        let bundle_store = resolve_bundle_store(ctx, &mstore);
154
155        let bundles = bundle_store.bundle_list().map_err(|e| {
156            MigrationError(format!("backfill_bundle_component_refs: list bundles: {e}"))
157        })?;
158
159        let mut total_skills = 0usize;
160        for bundle in &bundles {
161            total_skills += backfill_one_bundle(&mstore, &bundle_store, &bundle.id, &bundle.skills);
162        }
163
164        tracing::info!(
165            bundles = bundles.len(),
166            skills_bound = total_skills,
167            "backfill_bundle_component_refs: complete"
168        );
169        Ok(())
170    }
171
172    fn verify(&self, ctx: &MigrationContext) -> VerifyOutcome {
173        // Verifying "every inline component has a ref" needs both stores, and
174        // the shared one may legitimately be unavailable (see
175        // resolve_bundle_store). Report what we can rather than claiming a
176        // mismatch we cannot substantiate.
177        match super::runner::open_readonly(&ctx.channel_store_path) {
178            Ok(Some(conn)) => {
179                let skills: i64 = conn
180                    .query_row("SELECT COUNT(*) FROM db_bundle_skills_ref", [], |r| r.get(0))
181                    .unwrap_or(0);
182                VerifyOutcome::Ok(format!("{skills} skill ref(s)"))
183            }
184            Ok(None) => VerifyOutcome::Ok("no channel store".to_string()),
185            Err(e) => VerifyOutcome::Error(e),
186        }
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use crate::backend::storage::skills::Skill;
194
195    fn store() -> Store {
196        // One in-memory store serves as both mstore and bundle store:
197        // `run_object_schema` creates db_bundles alongside the ref tables, so
198        // both sides resolve. The cross-store rule itself is pinned by
199        // `mcp_servers.rs::bind_checks_bundle_existence_in_id_store_not_self`.
200        Store::open_in_memory().unwrap()
201    }
202
203    fn insert_bundle(s: &Store, id: &str, skills: &str) {
204        let bundle = crate::backend::storage::store::Bundle {
205            id: id.to_string(),
206            name: format!("Bundle {id}"),
207            description: String::new(),
208            is_blank: false,
209            is_global: false,
210            provider: String::new(),
211            model: String::new(),
212            instructions: String::new(),
213            instructions_by_provider: "{}".to_string(),
214            context_files: "[]".to_string(),
215            mcp_servers: "[]".to_string(),
216            skills: skills.to_string(),
217            sort_order: 0,
218            created_at: 1,
219            updated_at: 1,
220            is_system: false,
221        };
222        s.bundle_upsert(&bundle).unwrap();
223    }
224
225    fn insert_skill(s: &Store, id: &str, name: &str) {
226        let skill = Skill {
227            id: id.to_string(),
228            name: name.to_string(),
229            trigger: name.to_string(),
230            skill_type: crate::backend::agent_config::SKILL_TYPE_AGENT_SKILL.to_string(),
231            description: String::new(),
232            content: "body".to_string(),
233            is_global: true,
234            created_at: 1,
235            updated_at: 1,
236        };
237        s.skill_upsert_unique_global(&skill).unwrap();
238    }
239
240    #[test]
241    fn carries_inline_skills_into_the_ref_table() {
242        let s = store();
243        insert_skill(&s, "skill-1", "Deploy");
244        insert_bundle(&s, "bundle-1", r#"["skill-1"]"#);
245
246        assert_eq!(backfill_one_bundle(&s, &s, "bundle-1", r#"["skill-1"]"#), 1);
247        let bound_skills: Vec<_> = s
248            .bundle_skill_list(&s, "bundle-1")
249            .unwrap()
250            .into_iter()
251            .filter(|i| i.bound_to_bundle)
252            .collect();
253        assert_eq!(bound_skills.len(), 1, "skill must be bound to the bundle");
254
255        // Re-running binds the same ref again, idempotently.
256        backfill_one_bundle(&s, &s, "bundle-1", r#"["skill-1"]"#);
257        let count = s
258            .bundle_skill_list(&s, "bundle-1")
259            .unwrap()
260            .into_iter()
261            .filter(|i| i.bound_to_bundle)
262            .count();
263        assert_eq!(count, 1);
264    }
265
266    #[test]
267    fn a_malformed_blob_is_logged_not_fatal() {
268        let s = store();
269        insert_bundle(&s, "bundle-1", "not an array at all");
270        assert_eq!(backfill_one_bundle(&s, &s, "bundle-1", "not an array at all"), 0);
271    }
272
273    #[test]
274    fn an_inline_skill_id_with_no_catalog_row_is_dropped_not_fatal() {
275        let s = store();
276        insert_bundle(&s, "bundle-1", r#"["ghost"]"#);
277        assert_eq!(backfill_one_bundle(&s, &s, "bundle-1", r#"["ghost"]"#), 0);
278    }
279}