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}