1use rusqlite::Connection;
50
51use crate::backend::storage::store::Store;
52use crate::registry;
53
54use super::{Migration, MigrationContext, MigrationError, MigrationScope, VerifyOutcome};
55
56pub struct M0033NarrowSkillGlobalUniquenessIndex;
57
58struct GlobalSkillRow {
60 id: String,
61 name: String,
62 created_at: i64,
63}
64
65fn collapse_and_reindex(identity_conn: &Connection, channel_mstore: Option<&Store>) -> Result<usize, String> {
77 let mut stmt = identity_conn
78 .prepare("SELECT id, name, created_at FROM db_skills WHERE is_global = 1 ORDER BY name, created_at ASC, id ASC")
79 .map_err(|e| format!("prepare global skills scan: {e}"))?;
80 let rows: Vec<GlobalSkillRow> = stmt
81 .query_map([], |row| {
82 Ok(GlobalSkillRow {
83 id: row.get(0)?,
84 name: row.get(1)?,
85 created_at: row.get(2)?,
86 })
87 })
88 .and_then(|rows| rows.collect::<Result<Vec<_>, _>>())
89 .map_err(|e| format!("read global skills: {e}"))?;
90 drop(stmt);
91
92 let mut groups: std::collections::BTreeMap<String, Vec<GlobalSkillRow>> = std::collections::BTreeMap::new();
95 for row in rows {
96 groups.entry(row.name.clone()).or_default().push(row);
97 }
98
99 let mut collapsed = 0usize;
100 for (_, mut members) in groups {
101 if members.len() < 2 {
102 continue;
103 }
104 members.sort_by(|a, b| a.created_at.cmp(&b.created_at).then_with(|| a.id.cmp(&b.id)));
109 let survivor_id = members[0].id.clone();
110 for loser in &members[1..] {
111 if let Some(mstore) = channel_mstore {
112 mstore
113 .skill_rewrite_ref_id(&loser.id, &survivor_id)
114 .map_err(|e| format!("rewrite refs for collapsed skill {}: {e}", loser.id))?;
115 }
116 identity_conn
117 .execute("DELETE FROM db_skills WHERE id = ?1", [&loser.id])
118 .map_err(|e| format!("delete collapsed skill {}: {e}", loser.id))?;
119 collapsed += 1;
120 }
121 }
122
123 identity_conn
126 .execute_batch(
127 "CREATE UNIQUE INDEX IF NOT EXISTS idx_ids_skills_global_name
128 ON db_skills(name) WHERE is_global = 1;
129 DROP INDEX IF EXISTS idx_ids_skills_global_name_type;",
130 )
131 .map_err(|e| format!("swap uniqueness index: {e}"))?;
132
133 Ok(collapsed)
134}
135
136impl Migration for M0033NarrowSkillGlobalUniquenessIndex {
137 fn id(&self) -> &'static str {
138 "0033_narrow_skill_global_uniqueness_index"
139 }
140
141 fn scope(&self) -> MigrationScope {
142 MigrationScope::Global
143 }
144
145 fn description(&self) -> &'static str {
146 "Collapse any global skill rows sharing a name across skill_type, then narrow the global-uniqueness index to name alone"
147 }
148
149 fn up(&self, ctx: &MigrationContext) -> Result<(), MigrationError> {
150 let Some(path) = registry::resolve_identity_store_path() else {
151 return Ok(());
154 };
155 if !path.exists() {
156 return Ok(());
159 }
160 if let Some(parent) = path.parent() {
161 std::fs::create_dir_all(parent)
162 .map_err(|e| MigrationError(format!("narrow_skill_global_uniqueness_index: create dir: {e}")))?;
163 }
164 let conn = Connection::open(&path)
165 .map_err(|e| MigrationError(format!("narrow_skill_global_uniqueness_index: open identity store: {e}")))?;
166 let has_table: bool = conn
167 .query_row(
168 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'db_skills')",
169 [],
170 |row| row.get(0),
171 )
172 .map_err(|e| MigrationError(format!("narrow_skill_global_uniqueness_index: probe db_skills: {e}")))?;
173 if !has_table {
174 return Ok(());
178 }
179
180 let channel_mstore = if ctx.channel_store_path.exists() {
181 Some(
182 Store::open(&ctx.channel_store_path)
183 .map_err(|e| MigrationError(format!("narrow_skill_global_uniqueness_index: open mstore: {e}")))?,
184 )
185 } else {
186 None
187 };
188
189 let collapsed = collapse_and_reindex(&conn, channel_mstore.as_ref())
190 .map_err(|e| MigrationError(format!("narrow_skill_global_uniqueness_index: {e}")))?;
191 tracing::info!(collapsed, "m0033_narrow_skill_global_uniqueness_index: complete");
192 Ok(())
193 }
194
195 fn verify(&self, _ctx: &MigrationContext) -> VerifyOutcome {
196 let Some(path) = registry::resolve_identity_store_path() else {
197 return VerifyOutcome::Ok("identity store path unresolved".to_string());
198 };
199 let conn = match super::runner::open_readonly(&path) {
200 Ok(Some(c)) => c,
201 Ok(None) => return VerifyOutcome::Ok("identity store not yet created".to_string()),
202 Err(e) => return VerifyOutcome::Error(e),
203 };
204 let dupes: i64 = match conn.query_row(
205 "SELECT COUNT(*) FROM (
206 SELECT name FROM db_skills WHERE is_global = 1 GROUP BY name HAVING COUNT(*) > 1
207 )",
208 [],
209 |row| row.get(0),
210 ) {
211 Ok(n) => n,
212 Err(rusqlite::Error::SqliteFailure(_, Some(msg))) if msg.contains("no such table") => 0,
213 Err(e) => return VerifyOutcome::Error(format!("count duplicate global skill names: {e}")),
214 };
215 if dupes > 0 {
216 return VerifyOutcome::Mismatch(format!("{dupes} global skill name(s) still have more than one row"));
217 }
218 VerifyOutcome::Ok("no duplicate global skill names remain".to_string())
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225
226 fn legacy_identity_conn() -> (tempfile::TempDir, Connection) {
233 let dir = tempfile::tempdir().unwrap();
234 let path = dir.path().join("identity-store.db");
235 let conn = Connection::open(&path).unwrap();
236 conn.execute_batch(
237 "CREATE TABLE db_skills (
238 id TEXT PRIMARY KEY,
239 name TEXT NOT NULL,
240 trigger TEXT NOT NULL DEFAULT '',
241 skill_type TEXT NOT NULL DEFAULT 'prompt',
242 description TEXT NOT NULL DEFAULT '',
243 content TEXT NOT NULL DEFAULT '',
244 is_global INTEGER NOT NULL DEFAULT 0,
245 created_at INTEGER NOT NULL DEFAULT 0,
246 updated_at INTEGER NOT NULL DEFAULT 0
247 );
248 CREATE UNIQUE INDEX idx_ids_skills_global_name_type
249 ON db_skills(name, skill_type) WHERE is_global = 1;",
250 )
251 .unwrap();
252 (dir, conn)
253 }
254
255 fn insert_skill(conn: &Connection, id: &str, name: &str, skill_type: &str, created_at: i64) {
256 conn.execute(
257 "INSERT INTO db_skills (id, name, skill_type, is_global, created_at, updated_at)
258 VALUES (?1, ?2, ?3, 1, ?4, ?4)",
259 rusqlite::params![id, name, skill_type, created_at],
260 )
261 .unwrap();
262 }
263
264 #[test]
265 fn two_colliding_global_skills_are_collapsed_to_the_earliest_created_at_row() {
266 let (_dir, conn) = legacy_identity_conn();
267 insert_skill(&conn, "newer", "Deploy", "prompt", 200);
268 insert_skill(&conn, "older", "Deploy", "agent-skill", 100);
269
270 let collapsed = collapse_and_reindex(&conn, None).unwrap();
271 assert_eq!(collapsed, 1);
272
273 let remaining: Vec<String> = {
274 let mut stmt = conn.prepare("SELECT id FROM db_skills").unwrap();
275 stmt.query_map([], |r| r.get::<_, String>(0)).unwrap().map(|r| r.unwrap()).collect()
276 };
277 assert_eq!(remaining, vec!["older".to_string()], "the earliest created_at row must survive");
278 }
279
280 #[test]
281 fn a_larger_group_of_collisions_collapses_to_one_survivor() {
282 let (_dir, conn) = legacy_identity_conn();
283 insert_skill(&conn, "c", "Deploy", "prompt", 300);
284 insert_skill(&conn, "a", "Deploy", "agent-skill", 100);
285 insert_skill(&conn, "b", "Deploy", "shell", 200);
286
287 let collapsed = collapse_and_reindex(&conn, None).unwrap();
288 assert_eq!(collapsed, 2, "must not assume exactly two colliding rows");
289
290 let remaining: i64 = conn.query_row("SELECT COUNT(*) FROM db_skills", [], |r| r.get(0)).unwrap();
291 assert_eq!(remaining, 1);
292 let survivor: String = conn.query_row("SELECT id FROM db_skills", [], |r| r.get(0)).unwrap();
293 assert_eq!(survivor, "a");
294 }
295
296 #[test]
297 fn ties_in_created_at_are_broken_deterministically_by_id() {
298 let (_dir, conn) = legacy_identity_conn();
299 insert_skill(&conn, "zzz", "Deploy", "prompt", 100);
300 insert_skill(&conn, "aaa", "Deploy", "agent-skill", 100);
301
302 collapse_and_reindex(&conn, None).unwrap();
303 let survivor: String = conn.query_row("SELECT id FROM db_skills", [], |r| r.get(0)).unwrap();
304 assert_eq!(survivor, "aaa", "the lexicographically smaller id must win a created_at tie");
305 }
306
307 #[test]
308 fn the_current_channels_ref_rows_are_rewritten_to_the_survivor() {
309 let (_dir, conn) = legacy_identity_conn();
310 insert_skill(&conn, "loser", "Deploy", "prompt", 200);
311 insert_skill(&conn, "winner", "Deploy", "agent-skill", 100);
312
313 let mstore = Store::open_in_memory().unwrap();
314 {
321 let raw = mstore.conn().lock().unwrap();
322 raw.execute("INSERT INTO db_agents (id, name, provider) VALUES ('agent-1', 'A', 'claude')", [])
323 .unwrap();
324 raw.execute("INSERT INTO db_agent_skills_ref (agent_id, skill_id) VALUES ('agent-1', 'loser')", [])
325 .unwrap();
326 }
327
328 collapse_and_reindex(&conn, Some(&mstore)).unwrap();
329
330 assert!(mstore.skill_is_bound_to("agent-1", "winner").unwrap(), "the ref must now point at the survivor");
331 assert!(!mstore.skill_is_bound_to("agent-1", "loser").unwrap());
332 }
333
334 #[test]
335 fn the_new_index_rejects_a_fresh_same_name_insert_after_the_swap() {
336 let (_dir, conn) = legacy_identity_conn();
337 insert_skill(&conn, "existing", "Deploy", "prompt", 100);
338
339 collapse_and_reindex(&conn, None).unwrap();
340
341 let result = conn.execute(
342 "INSERT INTO db_skills (id, name, skill_type, is_global, created_at, updated_at)
343 VALUES ('new-one', 'Deploy', 'agent-skill', 1, 200, 200)",
344 [],
345 );
346 assert!(
347 result.is_err(),
348 "the new name-only index must reject a same-name insert even with a different skill_type"
349 );
350 }
351
352 #[test]
353 fn a_store_with_no_collisions_is_a_clean_no_op() {
354 let (_dir, conn) = legacy_identity_conn();
355 insert_skill(&conn, "s1", "Deploy", "prompt", 100);
356 insert_skill(&conn, "s2", "Review", "prompt", 100);
357
358 let collapsed = collapse_and_reindex(&conn, None).unwrap();
359 assert_eq!(collapsed, 0);
360
361 let remaining: i64 = conn.query_row("SELECT COUNT(*) FROM db_skills", [], |r| r.get(0)).unwrap();
362 assert_eq!(remaining, 2, "no-collision rows must be untouched");
363
364 let has_new_index: bool = conn
367 .query_row(
368 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='index' AND name='idx_ids_skills_global_name')",
369 [],
370 |r| r.get(0),
371 )
372 .unwrap();
373 assert!(has_new_index);
374 }
375
376 use crate::test_support::ISOLATED_AUTH_ENV_LOCK as ENV_LOCK;
383
384 fn clear_env() {
385 std::env::remove_var("AGENTMUX_HOME_OVERRIDE");
386 std::env::remove_var("AGENTMUX_SHARED_DIR");
387 std::env::remove_var("AGENTMUX_ISOLATED_AUTH");
388 std::env::remove_var("AGENTMUX_CHANNEL");
389 std::env::remove_var("AGENTMUX_INSTANCE_DIR");
390 }
391
392 #[test]
393 fn a_non_existent_identity_store_path_is_a_no_op() {
394 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
395 clear_env();
396 let tmp = tempfile::tempdir().unwrap();
397 std::env::set_var("AGENTMUX_HOME_OVERRIDE", tmp.path());
398
399 let ctx = MigrationContext {
400 home: tmp.path().to_path_buf(),
401 data_dir: tmp.path().join("data"),
402 shared_store_path: tmp.path().join("shared").join("store.db"),
403 channel_store_path: tmp.path().join("data").join("db").join("does-not-exist.db"),
404 };
405 assert!(M0033NarrowSkillGlobalUniquenessIndex.up(&ctx).is_ok());
407 clear_env();
408 }
409
410 #[test]
417 fn up_end_to_end_collapses_rewrites_refs_and_swaps_the_index() {
418 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
419 clear_env();
420 let tmp = tempfile::tempdir().unwrap();
421 std::env::set_var("AGENTMUX_HOME_OVERRIDE", tmp.path());
422
423 let identity_path = registry::resolve_identity_store_path().unwrap();
424 std::fs::create_dir_all(identity_path.parent().unwrap()).unwrap();
425 {
426 let conn = Connection::open(&identity_path).unwrap();
427 conn.execute_batch(
428 "CREATE TABLE db_skills (
429 id TEXT PRIMARY KEY, name TEXT NOT NULL, trigger TEXT NOT NULL DEFAULT '',
430 skill_type TEXT NOT NULL DEFAULT 'prompt', description TEXT NOT NULL DEFAULT '',
431 content TEXT NOT NULL DEFAULT '', is_global INTEGER NOT NULL DEFAULT 0,
432 created_at INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL DEFAULT 0
433 );
434 CREATE UNIQUE INDEX idx_ids_skills_global_name_type
435 ON db_skills(name, skill_type) WHERE is_global = 1;",
436 )
437 .unwrap();
438 insert_skill(&conn, "loser", "Deploy", "prompt", 200);
439 insert_skill(&conn, "winner", "Deploy", "agent-skill", 100);
440 }
441
442 let channel_store_path = tmp.path().join("data").join("db").join("objects.db");
443 std::fs::create_dir_all(channel_store_path.parent().unwrap()).unwrap();
444 {
445 let mstore = Store::open(&channel_store_path).unwrap();
446 let raw = mstore.conn().lock().unwrap();
450 raw.execute("INSERT INTO db_agents (id, name, provider) VALUES ('agent-1', 'A', 'claude')", [])
451 .unwrap();
452 raw.execute("INSERT INTO db_agent_skills_ref (agent_id, skill_id) VALUES ('agent-1', 'loser')", [])
453 .unwrap();
454 }
455
456 let ctx = MigrationContext {
457 home: tmp.path().to_path_buf(),
458 data_dir: tmp.path().join("data"),
459 shared_store_path: tmp.path().join("shared").join("store.db"),
460 channel_store_path,
461 };
462 M0033NarrowSkillGlobalUniquenessIndex.up(&ctx).unwrap();
463
464 let identity_conn = Connection::open(&identity_path).unwrap();
465 let remaining: i64 = identity_conn.query_row("SELECT COUNT(*) FROM db_skills", [], |r| r.get(0)).unwrap();
466 assert_eq!(remaining, 1, "the loser row must be gone from the identity store");
467 let survivor: String = identity_conn.query_row("SELECT id FROM db_skills", [], |r| r.get(0)).unwrap();
468 assert_eq!(survivor, "winner");
469
470 let mstore = Store::open(&ctx.channel_store_path).unwrap();
471 assert!(mstore.skill_is_bound_to("agent-1", "winner").unwrap(), "the ref must now point at the survivor");
472 assert!(!mstore.skill_is_bound_to("agent-1", "loser").unwrap());
473
474 assert!(
475 matches!(M0033NarrowSkillGlobalUniquenessIndex.verify(&ctx), VerifyOutcome::Ok(_)),
476 "no duplicate names remain, so verify() must pass"
477 );
478
479 clear_env();
480 }
481}