Skip to main content

agentmux_srv/migrations/
m0028_agent_child_tables_repoint_fk.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Re-point six agent-child tables' `agent_id` foreign key from
5//! `db_agent_definitions(id)` to `db_agents(id)`.
6//!
7//! Agent-concept consolidation, Phase 3c
8//! (`SPEC_AGENT_ARCHITECTURE_2026_05_27.md`). `db_agent_content`,
9//! `db_agent_skills`, `db_agent_history`, `db_agent_identity_links`,
10//! `db_agent_skills_ref`, and `db_agent_mcp_ref` all carry an `agent_id`
11//! that has, until now, only ever been a `db_agent_definitions` id — so a
12//! template-launched agent (a `db_agents` row with no definition row of its
13//! own) could not hold any of these directly. That FK is the reason
14//! `listrecentsessions` (PR 2, #3080) had to fall back to the launch's
15//! TEMPLATE for identity-link lookups instead of the agent's own row.
16//!
17//! Every existing `db_agent_definitions` row already has a `db_agents`
18//! mirror at the SAME id (the dual-write PR 1 established and PR 2 kept
19//! current, backstopped by the gap-repair this migration also runs), so
20//! re-pointing the FK does not orphan a single existing row: every
21//! `agent_id` value already in these tables satisfies the new target.
22//! What it unlocks is new rows keyed by a launch id that has no
23//! `db_agent_definitions` counterpart at all.
24//!
25//! SQLite has no `ALTER TABLE ... DROP CONSTRAINT` / redefine-FK — changing
26//! a foreign key's target means rebuilding the table: rename, recreate with
27//! the new FK, copy, drop the renamed original, recreate any secondary
28//! indexes. Wrapped in one transaction per table with `foreign_keys=OFF`
29//! for the swap (SQLite defers FK enforcement of the NEW schema to
30//! `PRAGMA foreign_key_check`, which this migration runs before committing
31//! rather than trusting each swap silently).
32//!
33//! Tolerates a table whose FK already targets `db_agents` (a fresh install:
34//! `run_object_schema` creates the six tables with the new FK directly, so
35//! this migration has nothing to do there) and a store where a table is
36//! missing entirely (Phase 3c-or-later: nothing to repoint). Idempotent.
37
38use rusqlite::Connection;
39
40use crate::backend::storage::store::Store;
41
42use super::{Migration, MigrationContext, MigrationError, MigrationScope, VerifyOutcome};
43
44pub struct M0028AgentChildTablesRepointFk;
45
46/// One table this migration may need to rebuild.
47struct 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
129/// True if `table`'s `agent_id` FK already targets `db_agents` — either it
130/// was rebuilt by an earlier run of this migration, or it's a fresh install
131/// whose base schema already declares the new target. `Ok(true)` for a
132/// missing table too: nothing to repoint is the same as already done.
133fn 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)) // column 2 = "table" (the referenced table)
149        .and_then(|rows| rows.collect::<Result<Vec<_>, _>>())
150        .map_err(|e| format!("foreign_key_list {table}: {e}"))?;
151    // The agent_id FK is the one that used to say db_agent_definitions;
152    // account_id/skill_id/mcp_id FKs on the same table are untouched by
153    // this migration and must not affect the verdict.
154    Ok(!targets.iter().any(|t| t == "db_agent_definitions"))
155}
156
157/// Rebuild one table so its `agent_id` FK targets `db_agents` instead of
158/// `db_agent_definitions`. Rows whose `agent_id` has no `db_agents` row at
159/// all are dropped (logged) rather than left to violate the new FK — the
160/// gap-repair pass immediately before this loop is what makes that count
161/// zero in the overwhelmingly common case.
162fn 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        // Confirm the rebuild is internally consistent before committing —
203        // cheap (this table only) and turns a mistake in this migration
204        // into a hard failure instead of a silently corrupt swap.
205        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
226/// Frozen copy of `agents_consolidate::repair_def_gaps` as of v30 (codex P2
227/// on #3088): a migration must produce the same rows on every machine it
228/// ever runs on, so it cannot call a live helper whose behavior a later
229/// release may change (`migrations/mod.rs`'s "migrations freeze copies of
230/// live logic" doc) — doubly so here, since the Phase 4 plan
231/// (`SPEC_AGENT_ARCHITECTURE_2026_05_27.md`) schedules
232/// `agents_consolidate.rs` itself for removal, which would strand a live
233/// call the moment that PR lands. Finds every `db_agent_definitions` row
234/// with no matching `db_agents` id (written between the Phase 3a marker and
235/// Phase 3b dual-write landing) and projects one, `INSERT OR IGNORE` so it
236/// is safe to call every time this migration runs. Returns the number
237/// inserted. `Ok(0)` if `db_agent_definitions` doesn't exist at all —
238/// dropped entirely as of v32 (`OBJECT_SCHEMA_VERSION`'s v32 doc comment);
239/// nothing to repair a gap in when there's no source table left to have one.
240fn 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        // Store::open lays down the base schema (fresh tables already carry
375        // the new FK) before the raw connection below touches anything.
376        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    /// The six tables in their PRE-repoint shape (FK on
414    /// `db_agent_definitions`), on top of a store whose base schema is the
415    /// REAL current one (`Store::open`) — everything else (`db_agents`,
416    /// `db_accounts`, `db_skills`, `db_mcp_servers`) is exactly what
417    /// production has. `db_agent_definitions` is gone from the base schema
418    /// as of v32 (`OBJECT_SCHEMA_VERSION`'s v32 doc comment), so this
419    /// fixture stands it up by hand — the full column set
420    /// `frozen_repair_def_gaps`'s SELECT reads, so its full-column
421    /// read/write still works unmodified. Only the six tables under test
422    /// are dropped and recreated with the OLD FK to simulate a
423    /// pre-migration store.
424    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        // Deleting the db_agents row must still cascade to all six now that
546        // the FK targets it directly.
547        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    /// The actual unblock this migration exists for: a template-LAUNCH row
555    /// (a `db_agents` id with no `db_agent_definitions` counterpart) can now
556    /// hold an identity link of its own — impossible before the repoint.
557    #[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        // Before the repoint this INSERT would fail: no db_agent_definitions
570        // row named 'launch-1' could ever exist for a template launch.
571        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    /// A row whose `agent_id` matches neither a definition nor a `db_agents`
579    /// row (a pre-existing inconsistency, not something this migration
580    /// should be able to create) is dropped rather than left to violate the
581    /// new FK, and the count is reported.
582    #[test]
583    fn a_row_with_no_matching_db_agents_id_is_dropped_and_counted() {
584        let (_dir, conn) = legacy_store();
585        // A content row whose definition (and any db_agents mirror) is
586        // simply gone — an inconsistency that predates this migration and
587        // could only exist today via a manual edit or a gap in FK
588        // enforcement, so the insert itself needs FK checks off to set up.
589        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    /// A definition written after the Phase 3a marker but before its
602    /// `db_agents` mirror existed (the same gap `m0025` repairs) must not
603    /// lose its content to the orphan filter — `repoint()` runs gap-repair
604    /// first specifically to prevent this.
605    #[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        // 'def-b' has NO db_agents row — the exact gap m0025 exists for
620        // (a definition written after the Phase 3a marker but before its
621        // mirror caught up).
622
623        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    /// A fresh channel: `run_object_schema` has already created all six
632    /// tables with the new FK, so there is nothing left for THIS migration
633    /// to rebuild — the `TABLES` loop finds every one of them already
634    /// pointing at `db_agents`. `db_agent_definitions` itself doesn't even
635    /// exist here (dropped from the base schema entirely as of v32,
636    /// `OBJECT_SCHEMA_VERSION`'s v32 doc comment) — `frozen_repair_def_gaps`
637    /// tolerating that absence is what this test actually exercises.
638    #[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}