1use rusqlite::Connection;
39
40use crate::backend::storage::store::Store;
41
42use super::{Migration, MigrationContext, MigrationError, MigrationScope, VerifyOutcome};
43
44pub struct M0028AgentChildTablesRepointFk;
45
46struct TableSpec {
48 name: &'static str,
49 create_sql: &'static str,
50 index_sql: &'static [&'static str],
51}
52
53const TABLES: &[TableSpec] = &[
54 TableSpec {
55 name: "db_agent_content",
56 create_sql: "CREATE TABLE db_agent_content (
57 agent_id TEXT NOT NULL,
58 content_type TEXT NOT NULL,
59 content TEXT NOT NULL DEFAULT '',
60 updated_at INTEGER NOT NULL DEFAULT 0,
61 PRIMARY KEY (agent_id, content_type),
62 FOREIGN KEY (agent_id) REFERENCES db_agents(id) ON DELETE CASCADE
63 )",
64 index_sql: &[],
65 },
66 TableSpec {
67 name: "db_agent_skills",
68 create_sql: "CREATE TABLE db_agent_skills (
69 id TEXT PRIMARY KEY,
70 agent_id TEXT NOT NULL,
71 name TEXT NOT NULL,
72 trigger TEXT NOT NULL DEFAULT '',
73 skill_type TEXT NOT NULL DEFAULT 'prompt',
74 description TEXT NOT NULL DEFAULT '',
75 content TEXT NOT NULL DEFAULT '',
76 created_at INTEGER NOT NULL DEFAULT 0,
77 FOREIGN KEY (agent_id) REFERENCES db_agents(id) ON DELETE CASCADE
78 )",
79 index_sql: &[],
80 },
81 TableSpec {
82 name: "db_agent_history",
83 create_sql: "CREATE TABLE db_agent_history (
84 id INTEGER PRIMARY KEY AUTOINCREMENT,
85 agent_id TEXT NOT NULL,
86 session_date TEXT NOT NULL,
87 entry TEXT NOT NULL,
88 timestamp INTEGER NOT NULL DEFAULT 0,
89 FOREIGN KEY (agent_id) REFERENCES db_agents(id) ON DELETE CASCADE
90 )",
91 index_sql: &["CREATE INDEX idx_agent_history_agent_date ON db_agent_history(agent_id, session_date)"],
92 },
93 TableSpec {
94 name: "db_agent_identity_links",
95 create_sql: "CREATE TABLE db_agent_identity_links (
96 agent_id TEXT NOT NULL,
97 account_id TEXT NOT NULL,
98 provider TEXT NOT NULL,
99 PRIMARY KEY (agent_id, provider),
100 FOREIGN KEY (agent_id) REFERENCES db_agents(id) ON DELETE CASCADE,
101 FOREIGN KEY (account_id) REFERENCES db_accounts(id) ON DELETE CASCADE
102 )",
103 index_sql: &["CREATE INDEX idx_agent_identity_links_account ON db_agent_identity_links(account_id)"],
104 },
105 TableSpec {
106 name: "db_agent_skills_ref",
107 create_sql: "CREATE TABLE db_agent_skills_ref (
108 agent_id TEXT NOT NULL,
109 skill_id TEXT NOT NULL,
110 PRIMARY KEY (agent_id, skill_id),
111 FOREIGN KEY (agent_id) REFERENCES db_agents(id) ON DELETE CASCADE,
112 FOREIGN KEY (skill_id) REFERENCES db_skills(id) ON DELETE CASCADE
113 )",
114 index_sql: &[],
115 },
116 TableSpec {
117 name: "db_agent_mcp_ref",
118 create_sql: "CREATE TABLE db_agent_mcp_ref (
119 agent_id TEXT NOT NULL,
120 mcp_id TEXT NOT NULL,
121 PRIMARY KEY (agent_id, mcp_id),
122 FOREIGN KEY (agent_id) REFERENCES db_agents(id) ON DELETE CASCADE,
123 FOREIGN KEY (mcp_id) REFERENCES db_mcp_servers(id) ON DELETE CASCADE
124 )",
125 index_sql: &[],
126 },
127];
128
129fn already_targets_db_agents(conn: &Connection, table: &str) -> Result<bool, String> {
134 let exists: bool = conn
135 .query_row(
136 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)",
137 [table],
138 |row| row.get(0),
139 )
140 .map_err(|e| format!("probe {table}: {e}"))?;
141 if !exists {
142 return Ok(true);
143 }
144 let mut stmt = conn
145 .prepare(&format!("PRAGMA foreign_key_list({table})"))
146 .map_err(|e| format!("foreign_key_list {table}: {e}"))?;
147 let targets: Vec<String> = stmt
148 .query_map([], |row| row.get::<_, String>(2)) .and_then(|rows| rows.collect::<Result<Vec<_>, _>>())
150 .map_err(|e| format!("foreign_key_list {table}: {e}"))?;
151 Ok(!targets.iter().any(|t| t == "db_agent_definitions"))
155}
156
157fn rebuild_table(conn: &Connection, spec: &TableSpec) -> Result<usize, String> {
163 let old_name = format!("{}_pre_repoint_fk", spec.name);
164 conn.execute_batch("PRAGMA foreign_keys=OFF;")
165 .map_err(|e| format!("{}: foreign_keys off: {e}", spec.name))?;
166 let result: Result<usize, String> = (|| {
167 conn.execute_batch("BEGIN;").map_err(|e| format!("{}: begin: {e}", spec.name))?;
168 conn.execute(&format!("ALTER TABLE {} RENAME TO {old_name}", spec.name), [])
169 .map_err(|e| format!("{}: rename: {e}", spec.name))?;
170 conn.execute(spec.create_sql, [])
171 .map_err(|e| format!("{}: recreate: {e}", spec.name))?;
172 let columns: String = {
173 let mut stmt = conn
174 .prepare(&format!("PRAGMA table_info({old_name})"))
175 .map_err(|e| format!("{}: table_info: {e}", spec.name))?;
176 let cols: Vec<String> = stmt
177 .query_map([], |row| row.get::<_, String>(1))
178 .and_then(|rows| rows.collect::<Result<Vec<_>, _>>())
179 .map_err(|e| format!("{}: table_info: {e}", spec.name))?;
180 cols.join(", ")
181 };
182 let dropped = conn
183 .query_row(
184 &format!("SELECT COUNT(*) FROM {old_name} WHERE agent_id NOT IN (SELECT id FROM db_agents)"),
185 [],
186 |row| row.get::<_, i64>(0),
187 )
188 .map_err(|e| format!("{}: count orphans: {e}", spec.name))?;
189 conn.execute(
190 &format!(
191 "INSERT INTO {} ({columns}) SELECT {columns} FROM {old_name} WHERE agent_id IN (SELECT id FROM db_agents)",
192 spec.name
193 ),
194 [],
195 )
196 .map_err(|e| format!("{}: copy: {e}", spec.name))?;
197 conn.execute(&format!("DROP TABLE {old_name}"), [])
198 .map_err(|e| format!("{}: drop old: {e}", spec.name))?;
199 for idx in spec.index_sql {
200 conn.execute(idx, []).map_err(|e| format!("{}: recreate index: {e}", spec.name))?;
201 }
202 let mut check_stmt = conn
206 .prepare(&format!("PRAGMA foreign_key_check({})", spec.name))
207 .map_err(|e| format!("{}: foreign_key_check: {e}", spec.name))?;
208 let violations = check_stmt
209 .query_map([], |_| Ok(()))
210 .map_err(|e| format!("{}: foreign_key_check: {e}", spec.name))?
211 .count();
212 if violations > 0 {
213 return Err(format!("{}: {violations} foreign key violation(s) after rebuild", spec.name));
214 }
215 conn.execute_batch("COMMIT;").map_err(|e| format!("{}: commit: {e}", spec.name))?;
216 Ok(dropped as usize)
217 })();
218 if result.is_err() {
219 let _ = conn.execute_batch("ROLLBACK;");
220 }
221 conn.execute_batch("PRAGMA foreign_keys=ON;")
222 .map_err(|e| format!("{}: foreign_keys on: {e}", spec.name))?;
223 result
224}
225
226fn frozen_repair_def_gaps(conn: &mut Connection) -> Result<usize, String> {
241 let defs_exist: bool = conn
242 .query_row(
243 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'db_agent_definitions')",
244 [],
245 |row| row.get(0),
246 )
247 .map_err(|e| format!("gap-repair: probe db_agent_definitions: {e}"))?;
248 if !defs_exist {
249 return Ok(0);
250 }
251 let mut stmt = conn
252 .prepare(
253 "SELECT d.id, d.name, d.icon, d.provider, d.description,
254 d.working_directory, d.shell, d.provider_flags, d.auto_start,
255 d.restart_on_crash, d.idle_timeout_minutes, d.created_at,
256 d.agent_type, d.environment, d.agent_bus_id, d.is_seeded,
257 d.accounts, d.parent_id, d.branch_label, d.updated_at,
258 d.slug, d.user_hidden
259 FROM db_agent_definitions d
260 LEFT JOIN db_agents a ON a.id = d.id
261 WHERE a.id IS NULL",
262 )
263 .map_err(|e| format!("gap-repair: prepare: {e}"))?;
264 #[allow(clippy::type_complexity)]
265 let missing: Vec<(
266 String, String, String, String, String, String, String, String,
267 i64, i64, i64, i64, String, String, String, i64, String, String,
268 String, i64, String, i64,
269 )> = stmt
270 .query_map([], |row| {
271 Ok((
272 row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?,
273 row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?,
274 row.get::<_, String>(6)?, row.get::<_, String>(7)?, row.get::<_, i64>(8)?,
275 row.get::<_, i64>(9)?, row.get::<_, i64>(10)?, row.get::<_, i64>(11)?,
276 row.get::<_, String>(12)?, row.get::<_, String>(13)?, row.get::<_, String>(14)?,
277 row.get::<_, i64>(15)?, row.get::<_, String>(16)?, row.get::<_, String>(17)?,
278 row.get::<_, String>(18)?, row.get::<_, i64>(19)?, row.get::<_, String>(20)?,
279 row.get::<_, i64>(21)?,
280 ))
281 })
282 .and_then(|rows| rows.collect::<Result<Vec<_>, _>>())
283 .map_err(|e| format!("gap-repair: read db_agent_definitions: {e}"))?;
284 drop(stmt);
285 if missing.is_empty() {
286 return Ok(0);
287 }
288
289 let tx = conn.transaction().map_err(|e| format!("gap-repair: begin: {e}"))?;
290 let mut inserted = 0usize;
291 for (
292 id, name, icon, provider, description, working_directory, shell,
293 provider_flags, auto_start, restart_on_crash, idle_timeout_minutes,
294 created_at, agent_type, environment, agent_bus_id, is_seeded,
295 accounts, parent_id, branch_label, updated_at, slug, user_hidden,
296 ) in &missing
297 {
298 let is_template = if *is_seeded == 1 { 1_i64 } else { 0_i64 };
299 let parent_template_id = if *is_seeded == 1 { String::new() } else { parent_id.clone() };
300 let affected = tx
301 .execute(
302 "INSERT OR IGNORE INTO db_agents (
303 id, name, icon, description,
304 is_template, parent_template_id,
305 provider, provider_flags, shell, environment,
306 agent_type, agent_bus_id, accounts,
307 auto_start, restart_on_crash, idle_timeout_minutes,
308 slug, branch_label,
309 identity_id, memory_id, working_directory, github_context,
310 instance_name,
311 created_at, updated_at, is_seeded, user_hidden
312 ) VALUES (
313 ?1, ?2, ?3, ?4,
314 ?5, ?6,
315 ?7, ?8, ?9, ?10,
316 ?11, ?12, ?13,
317 ?14, ?15, ?16,
318 ?17, ?18,
319 '', '', ?19, '',
320 '',
321 ?20, ?21, ?22, ?23
322 )",
323 rusqlite::params![
324 id, name, icon, description,
325 is_template, parent_template_id,
326 provider, provider_flags, shell, environment,
327 agent_type, agent_bus_id, accounts,
328 auto_start, restart_on_crash, idle_timeout_minutes,
329 slug, branch_label,
330 working_directory,
331 created_at, updated_at, is_seeded, user_hidden,
332 ],
333 )
334 .map_err(|e| format!("gap-repair: insert {id}: {e}"))?;
335 if affected > 0 {
336 inserted += 1;
337 }
338 }
339 tx.commit().map_err(|e| format!("gap-repair: commit: {e}"))?;
340 Ok(inserted)
341}
342
343pub(super) fn repoint(conn: &mut Connection) -> Result<usize, String> {
344 frozen_repair_def_gaps(conn)?;
345 let mut orphans_dropped = 0usize;
346 for spec in TABLES {
347 if already_targets_db_agents(conn, spec.name)? {
348 continue;
349 }
350 let dropped = rebuild_table(conn, spec)?;
351 if dropped > 0 {
352 tracing::warn!(
353 table = spec.name,
354 dropped,
355 "m0028_agent_child_tables_repoint_fk: dropped row(s) whose agent_id matched no db_agents row"
356 );
357 }
358 orphans_dropped += dropped;
359 }
360 Ok(orphans_dropped)
361}
362
363impl Migration for M0028AgentChildTablesRepointFk {
364 fn id(&self) -> &'static str { "0028_agent_child_tables_repoint_fk" }
365 fn scope(&self) -> MigrationScope { MigrationScope::Channel }
366 fn description(&self) -> &'static str {
367 "Re-point db_agent_content/skills/history/identity_links/skills_ref/mcp_ref's agent_id FK from db_agent_definitions to db_agents"
368 }
369
370 fn up(&self, ctx: &MigrationContext) -> Result<(), MigrationError> {
371 if !ctx.channel_store_path.exists() {
372 return Ok(());
373 }
374 drop(
377 Store::open(&ctx.channel_store_path)
378 .map_err(|e| MigrationError(format!("agent_child_tables_repoint_fk: open mstore: {e}")))?,
379 );
380 let mut conn = Connection::open(&ctx.channel_store_path)
381 .map_err(|e| MigrationError(format!("agent_child_tables_repoint_fk: open: {e}")))?;
382 let dropped = repoint(&mut conn).map_err(|e| MigrationError(format!("agent_child_tables_repoint_fk: {e}")))?;
383 tracing::info!(orphans_dropped = dropped, "m0028_agent_child_tables_repoint_fk: complete");
384 Ok(())
385 }
386
387 fn verify(&self, ctx: &MigrationContext) -> VerifyOutcome {
388 let conn = match super::runner::open_readonly(&ctx.channel_store_path) {
389 Ok(Some(c)) => c,
390 Ok(None) => return VerifyOutcome::Ok("no channel store on this data dir".into()),
391 Err(e) => return VerifyOutcome::Error(e),
392 };
393 for spec in TABLES {
394 match already_targets_db_agents(&conn, spec.name) {
395 Ok(true) => {}
396 Ok(false) => {
397 return VerifyOutcome::Mismatch(format!(
398 "{}'s agent_id FK still targets db_agent_definitions",
399 spec.name
400 ))
401 }
402 Err(e) => return VerifyOutcome::Error(e),
403 }
404 }
405 VerifyOutcome::Ok("every agent-child table's agent_id FK targets db_agents".into())
406 }
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412
413 fn legacy_store() -> (tempfile::TempDir, Connection) {
425 let dir = tempfile::tempdir().unwrap();
426 let path = dir.path().join("objects.db");
427 drop(Store::open(&path).unwrap());
428 let conn = Connection::open(&path).unwrap();
429 conn.execute_batch(
430 "CREATE TABLE db_agent_definitions (
431 id TEXT PRIMARY KEY, slug TEXT NOT NULL DEFAULT '', name TEXT NOT NULL DEFAULT '',
432 icon TEXT NOT NULL DEFAULT '', provider TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
433 working_directory TEXT NOT NULL DEFAULT '', shell TEXT NOT NULL DEFAULT '',
434 provider_flags TEXT NOT NULL DEFAULT '', auto_start INTEGER NOT NULL DEFAULT 0,
435 restart_on_crash INTEGER NOT NULL DEFAULT 0, idle_timeout_minutes INTEGER NOT NULL DEFAULT 0,
436 created_at INTEGER NOT NULL DEFAULT 0, agent_type TEXT NOT NULL DEFAULT '',
437 environment TEXT NOT NULL DEFAULT '', agent_bus_id TEXT NOT NULL DEFAULT '',
438 is_seeded INTEGER NOT NULL DEFAULT 0, accounts TEXT NOT NULL DEFAULT '',
439 parent_id TEXT NOT NULL DEFAULT '', branch_label TEXT NOT NULL DEFAULT '',
440 updated_at INTEGER NOT NULL DEFAULT 0, user_hidden INTEGER NOT NULL DEFAULT 0
441 );
442 PRAGMA foreign_keys=OFF;
443 DROP TABLE db_agent_content;
444 DROP TABLE db_agent_skills;
445 DROP TABLE db_agent_history;
446 DROP TABLE db_agent_identity_links;
447 DROP TABLE db_agent_skills_ref;
448 DROP TABLE db_agent_mcp_ref;
449
450 CREATE TABLE db_agent_content (
451 agent_id TEXT NOT NULL, content_type TEXT NOT NULL,
452 content TEXT NOT NULL DEFAULT '', updated_at INTEGER NOT NULL DEFAULT 0,
453 PRIMARY KEY (agent_id, content_type),
454 FOREIGN KEY (agent_id) REFERENCES db_agent_definitions(id) ON DELETE CASCADE
455 );
456 CREATE TABLE db_agent_skills (
457 id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, name TEXT NOT NULL,
458 trigger TEXT NOT NULL DEFAULT '', skill_type TEXT NOT NULL DEFAULT 'prompt',
459 description TEXT NOT NULL DEFAULT '', content TEXT NOT NULL DEFAULT '',
460 created_at INTEGER NOT NULL DEFAULT 0,
461 FOREIGN KEY (agent_id) REFERENCES db_agent_definitions(id) ON DELETE CASCADE
462 );
463 CREATE TABLE db_agent_history (
464 id INTEGER PRIMARY KEY AUTOINCREMENT, agent_id TEXT NOT NULL,
465 session_date TEXT NOT NULL, entry TEXT NOT NULL, timestamp INTEGER NOT NULL DEFAULT 0,
466 FOREIGN KEY (agent_id) REFERENCES db_agent_definitions(id) ON DELETE CASCADE
467 );
468 CREATE INDEX idx_agent_history_agent_date ON db_agent_history(agent_id, session_date);
469 CREATE TABLE db_agent_identity_links (
470 agent_id TEXT NOT NULL, account_id TEXT NOT NULL, provider TEXT NOT NULL,
471 PRIMARY KEY (agent_id, provider),
472 FOREIGN KEY (agent_id) REFERENCES db_agent_definitions(id) ON DELETE CASCADE,
473 FOREIGN KEY (account_id) REFERENCES db_accounts(id) ON DELETE CASCADE
474 );
475 CREATE INDEX idx_agent_identity_links_account ON db_agent_identity_links(account_id);
476 CREATE TABLE db_agent_skills_ref (
477 agent_id TEXT NOT NULL, skill_id TEXT NOT NULL,
478 PRIMARY KEY (agent_id, skill_id),
479 FOREIGN KEY (agent_id) REFERENCES db_agent_definitions(id) ON DELETE CASCADE,
480 FOREIGN KEY (skill_id) REFERENCES db_skills(id) ON DELETE CASCADE
481 );
482 CREATE TABLE db_agent_mcp_ref (
483 agent_id TEXT NOT NULL, mcp_id TEXT NOT NULL,
484 PRIMARY KEY (agent_id, mcp_id),
485 FOREIGN KEY (agent_id) REFERENCES db_agent_definitions(id) ON DELETE CASCADE,
486 FOREIGN KEY (mcp_id) REFERENCES db_mcp_servers(id) ON DELETE CASCADE
487 );
488 PRAGMA foreign_keys=ON;",
489 )
490 .unwrap();
491 (dir, conn)
492 }
493
494 #[test]
495 fn every_table_ends_up_targeting_db_agents_and_data_survives() {
496 let (_dir, conn) = legacy_store();
497 conn.execute_batch(
498 "INSERT INTO db_agent_definitions (
499 id, slug, name, icon, provider, description, working_directory, shell,
500 provider_flags, auto_start, restart_on_crash, idle_timeout_minutes,
501 created_at, agent_type, environment, agent_bus_id, is_seeded, accounts,
502 parent_id, branch_label, updated_at, user_hidden
503 ) VALUES ('def-a', 'def-a', 'Def A', '', 'claude', '', '', '', '', 0, 0, 0,
504 1, 'standalone', '', '', 0, '', '', '', 1, 0);
505 INSERT INTO db_agents (id, is_template, name, provider, created_at, updated_at, is_seeded)
506 VALUES ('def-a', 0, 'Def A', 'claude', 1, 1, 0);
507 INSERT INTO db_accounts (id, name, provider, kind, secret_ref) VALUES ('acct-1', 'Acct', 'claude', 'oauth', 'ref');
508 INSERT INTO db_skills (id, name) VALUES ('sk-1', 'Greet');
509 INSERT INTO db_mcp_servers (id, name) VALUES ('mcp-1', 'Server');
510 INSERT INTO db_agent_content (agent_id, content_type, content) VALUES ('def-a', 'agentmd', 'be helpful');
511 INSERT INTO db_agent_skills (id, agent_id, name) VALUES ('s1', 'def-a', 'greet');
512 INSERT INTO db_agent_history (agent_id, session_date, entry) VALUES ('def-a', '2026-09-07', 'did a thing');
513 INSERT INTO db_agent_identity_links (agent_id, account_id, provider) VALUES ('def-a', 'acct-1', 'claude');
514 INSERT INTO db_agent_skills_ref (agent_id, skill_id) VALUES ('def-a', 'sk-1');
515 INSERT INTO db_agent_mcp_ref (agent_id, mcp_id) VALUES ('def-a', 'mcp-1');",
516 )
517 .unwrap();
518
519 let mut conn = conn;
520 assert_eq!(repoint(&mut conn).unwrap(), 0, "no orphans in this fixture");
521
522 for spec in TABLES {
523 assert!(
524 already_targets_db_agents(&conn, spec.name).unwrap(),
525 "{} should target db_agents after repoint",
526 spec.name
527 );
528 }
529 let content: String = conn
530 .query_row("SELECT content FROM db_agent_content WHERE agent_id = 'def-a'", [], |r| r.get(0))
531 .unwrap();
532 assert_eq!(content, "be helpful", "data must survive the rebuild");
533 let link_account: String = conn
534 .query_row("SELECT account_id FROM db_agent_identity_links WHERE agent_id = 'def-a'", [], |r| r.get(0))
535 .unwrap();
536 assert_eq!(link_account, "acct-1");
537 let skill_ref: i64 = conn
538 .query_row(
539 "SELECT COUNT(*) FROM db_agent_skills_ref WHERE agent_id = 'def-a' AND skill_id = 'sk-1'",
540 [],
541 |r| r.get(0),
542 )
543 .unwrap();
544 assert_eq!(skill_ref, 1);
545 conn.execute("DELETE FROM db_agents WHERE id = 'def-a'", []).unwrap();
548 let remaining: i64 = conn
549 .query_row("SELECT COUNT(*) FROM db_agent_content WHERE agent_id = 'def-a'", [], |r| r.get(0))
550 .unwrap();
551 assert_eq!(remaining, 0, "deleting the agent row must cascade through the new FK");
552 }
553
554 #[test]
558 fn a_launch_only_agent_row_can_hold_its_own_identity_link_after_repoint() {
559 let (_dir, conn) = legacy_store();
560 conn.execute_batch(
561 "INSERT INTO db_agents (id, is_template, name, provider, created_at, updated_at, is_seeded, parent_template_id)
562 VALUES ('launch-1', 0, 'Launch', 'claude', 1, 1, 0, 'tpl-1');
563 INSERT INTO db_accounts (id, name, provider, kind, secret_ref) VALUES ('acct-1', 'Acct', 'claude', 'oauth', 'ref');",
564 )
565 .unwrap();
566 let mut conn = conn;
567 repoint(&mut conn).unwrap();
568
569 conn.execute(
572 "INSERT INTO db_agent_identity_links (agent_id, account_id, provider) VALUES ('launch-1', 'acct-1', 'claude')",
573 [],
574 )
575 .expect("a bare db_agents row must be able to hold its own identity link now");
576 }
577
578 #[test]
583 fn a_row_with_no_matching_db_agents_id_is_dropped_and_counted() {
584 let (_dir, conn) = legacy_store();
585 conn.execute_batch(
590 "PRAGMA foreign_keys=OFF;
591 INSERT INTO db_agent_content (agent_id, content_type, content) VALUES ('ghost', 'agentmd', 'orphaned');
592 PRAGMA foreign_keys=ON;",
593 )
594 .unwrap();
595 let mut conn = conn;
596 assert_eq!(repoint(&mut conn).unwrap(), 1);
597 let n: i64 = conn.query_row("SELECT COUNT(*) FROM db_agent_content", [], |r| r.get(0)).unwrap();
598 assert_eq!(n, 0);
599 }
600
601 #[test]
606 fn gap_repair_runs_first_so_an_unmirrored_definitions_content_survives() {
607 let (_dir, conn) = legacy_store();
608 conn.execute_batch(
609 "INSERT INTO db_agent_definitions (
610 id, slug, name, icon, provider, description, working_directory, shell,
611 provider_flags, auto_start, restart_on_crash, idle_timeout_minutes,
612 created_at, agent_type, environment, agent_bus_id, is_seeded, accounts,
613 parent_id, branch_label, updated_at, user_hidden
614 ) VALUES ('def-b', 'def-b', 'Unmirrored', '', 'claude', '', '', '', '', 0, 0, 0,
615 1, 'standalone', '', '', 0, '', '', '', 1, 0);
616 INSERT INTO db_agent_content (agent_id, content_type, content) VALUES ('def-b', 'agentmd', 'still here');",
617 )
618 .unwrap();
619 let mut conn = conn;
624 assert_eq!(repoint(&mut conn).unwrap(), 0, "gap-repair must have projected def-b before the orphan filter ran");
625 let content: String = conn
626 .query_row("SELECT content FROM db_agent_content WHERE agent_id = 'def-b'", [], |r| r.get(0))
627 .unwrap();
628 assert_eq!(content, "still here");
629 }
630
631 #[test]
639 fn a_fresh_schema_with_no_agent_child_rows_at_all_is_a_no_op() {
640 let dir = tempfile::tempdir().unwrap();
641 let path = dir.path().join("objects.db");
642 drop(Store::open(&path).unwrap());
643 let mut conn = Connection::open(&path).unwrap();
644 assert_eq!(repoint(&mut conn).unwrap(), 0);
645 for spec in TABLES {
646 assert!(already_targets_db_agents(&conn, spec.name).unwrap());
647 }
648 }
649}