Skip to main content

agentmux_srv/migrations/
m0029_drop_legacy_agent_tables.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Drop `db_agent_definitions` and `db_agent_instances` — the two legacy
5//! tables the agent-concept consolidation retires.
6//!
7//! Agent-concept consolidation, Phase 3e (final)
8//! (`SPEC_AGENT_ARCHITECTURE_2026_05_27.md`). Both tables have had no live
9//! reader or writer since PR 2 (instance flip, #3080) and PR 4 (definition
10//! flip, #3092); PR 3 (#3088) re-pointed the six agent-child tables' FK off
11//! `db_agent_definitions` onto `db_agents`, so nothing on disk still
12//! depends on either legacy table by the time this runs. There is nothing
13//! left to migrate — this is a straight `DROP TABLE IF EXISTS`, no data to
14//! carry forward.
15//!
16//! `run_object_schema` no longer declares either table (see
17//! `OBJECT_SCHEMA_VERSION`'s v32 doc comment) — that has to ship in the
18//! SAME release as this migration, since `run_object_schema` runs on every
19//! `Store::open()` immediately after migrations, and `CREATE TABLE IF NOT
20//! EXISTS` would otherwise silently recreate what this migration just
21//! dropped, empty, on the very next boot.
22//!
23//! Idempotent (`DROP TABLE IF EXISTS`) and channel-scoped, matching every
24//! other migration in this series.
25
26use rusqlite::Connection;
27
28use crate::backend::storage::store::Store;
29
30use super::{Migration, MigrationContext, MigrationError, MigrationScope, VerifyOutcome};
31
32pub struct M0029DropLegacyAgentTables;
33
34fn table_exists(conn: &Connection, table: &str) -> Result<bool, String> {
35    conn.query_row(
36        "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)",
37        [table],
38        |row| row.get(0),
39    )
40    .map_err(|e| format!("probe {table}: {e}"))
41}
42
43pub(super) fn drop_legacy_tables(conn: &Connection) -> Result<(), String> {
44    conn.execute_batch(
45        "DROP TABLE IF EXISTS db_agent_definitions;
46         DROP TABLE IF EXISTS db_agent_instances;",
47    )
48    .map_err(|e| format!("drop legacy agent tables: {e}"))
49}
50
51impl Migration for M0029DropLegacyAgentTables {
52    fn id(&self) -> &'static str { "0029_drop_legacy_agent_tables" }
53    fn scope(&self) -> MigrationScope { MigrationScope::Channel }
54    fn description(&self) -> &'static str {
55        "Drop db_agent_definitions and db_agent_instances — retired by the agent-concept consolidation"
56    }
57
58    fn up(&self, ctx: &MigrationContext) -> Result<(), MigrationError> {
59        if !ctx.channel_store_path.exists() {
60            return Ok(());
61        }
62        // Store::open runs the migration chain's own schema setup first
63        // (including `adopt_legacy_table_names` — an install old enough to
64        // still carry `db_forge_agents` gets it renamed to
65        // `db_agent_definitions` here, before the drop below removes it;
66        // see OBJECT_SCHEMA_VERSION's v32 doc comment for why that ordering
67        // is safe rather than a special case).
68        drop(
69            Store::open(&ctx.channel_store_path)
70                .map_err(|e| MigrationError(format!("drop_legacy_agent_tables: open mstore: {e}")))?,
71        );
72        let conn = Connection::open(&ctx.channel_store_path)
73            .map_err(|e| MigrationError(format!("drop_legacy_agent_tables: open: {e}")))?;
74        drop_legacy_tables(&conn).map_err(|e| MigrationError(format!("drop_legacy_agent_tables: {e}")))?;
75        tracing::info!("m0029_drop_legacy_agent_tables: complete");
76        Ok(())
77    }
78
79    fn verify(&self, ctx: &MigrationContext) -> VerifyOutcome {
80        let conn = match super::runner::open_readonly(&ctx.channel_store_path) {
81            Ok(Some(c)) => c,
82            Ok(None) => return VerifyOutcome::Ok("no channel store on this data dir".into()),
83            Err(e) => return VerifyOutcome::Error(e),
84        };
85        for table in ["db_agent_definitions", "db_agent_instances"] {
86            match table_exists(&conn, table) {
87                Ok(false) => {}
88                Ok(true) => return VerifyOutcome::Mismatch(format!("{table} still exists")),
89                Err(e) => return VerifyOutcome::Error(e),
90            }
91        }
92        VerifyOutcome::Ok("both legacy agent tables are gone".into())
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn drops_both_tables_and_is_idempotent() {
102        let dir = tempfile::tempdir().unwrap();
103        let path = dir.path().join("objects.db");
104        drop(Store::open(&path).unwrap());
105        let conn = Connection::open(&path).unwrap();
106        // A fresh store no longer creates either table at all (v32) — stand
107        // up the legacy shape by hand so this test exercises a real DROP,
108        // not a no-op.
109        conn.execute_batch(
110            "CREATE TABLE db_agent_definitions (id TEXT PRIMARY KEY);
111             CREATE TABLE db_agent_instances (id TEXT PRIMARY KEY);
112             INSERT INTO db_agent_definitions (id) VALUES ('leftover');
113             INSERT INTO db_agent_instances (id) VALUES ('leftover');",
114        )
115        .unwrap();
116
117        drop_legacy_tables(&conn).unwrap();
118        assert!(!table_exists(&conn, "db_agent_definitions").unwrap());
119        assert!(!table_exists(&conn, "db_agent_instances").unwrap());
120
121        // Idempotent — a second run against an already-dropped store must
122        // not error.
123        drop_legacy_tables(&conn).unwrap();
124    }
125
126    #[test]
127    fn a_fresh_store_with_neither_table_is_a_no_op() {
128        let conn = Connection::open_in_memory().unwrap();
129        drop_legacy_tables(&conn).unwrap();
130    }
131
132    #[test]
133    fn verify_confirms_absence_and_flags_a_survivor() {
134        let dir = tempfile::tempdir().unwrap();
135        let path = dir.path().join("objects.db");
136        let ctx = MigrationContext {
137            home: dir.path().to_path_buf(),
138            data_dir: dir.path().join("data"),
139            shared_store_path: dir.path().join("shared").join("store.db"),
140            channel_store_path: path.clone(),
141        };
142        // No store on disk at all — nothing to verify, not a mismatch.
143        assert!(matches!(M0029DropLegacyAgentTables.verify(&ctx), VerifyOutcome::Ok(_)));
144
145        drop(Store::open(&path).unwrap());
146        // A fresh store already has neither table (v32) — verify passes.
147        assert!(matches!(M0029DropLegacyAgentTables.verify(&ctx), VerifyOutcome::Ok(_)));
148
149        // A survivor is flagged, not silently accepted.
150        let conn = Connection::open(&path).unwrap();
151        conn.execute_batch("CREATE TABLE db_agent_definitions (id TEXT PRIMARY KEY);").unwrap();
152        match M0029DropLegacyAgentTables.verify(&ctx) {
153            VerifyOutcome::Mismatch(detail) => assert!(detail.contains("db_agent_definitions")),
154            other => panic!("expected Mismatch, got {other:?}"),
155        }
156    }
157}