agentmux_srv\backend\storage/
migrations.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! SQL schema setup for WaveStore, FileStore, and the saga log.
5//!
6//! `objects.db` uses a **flat schema**: `run_object_schema` defines the
7//! final table set directly in one idempotent `CREATE TABLE IF NOT EXISTS`
8//! batch. It replaced an 11-step incremental migration chain
9//! (`run_forge_v1` … `run_forge_v11`) — see
10//! `docs/specs/SPEC_SCHEMA_FLATTENING_2026_05_19.md`. The chain was pure
11//! historical accretion: per-version data dirs mean every new version is
12//! born with a fresh `objects.db` and ran the whole chain top-to-bottom
13//! anyway, so the intermediate states were never reachable in production.
14//!
15//! `filestore.db` and `sagas.db` were already single-DDL stores; they keep
16//! their existing schema functions and gain only the `user_version`
17//! tripwire (`stamp_and_check_version`).
18
19use rusqlite::Connection;
20use tracing::warn;
21
22use super::error::StoreError;
23
24/// `user_version` value stamped into `objects.db` after `run_object_schema`.
25/// The flat schema reset the counter to 1 (the pre-flatten chain never set
26/// `user_version`, so legacy files read 0). Bumped per additive migration:
27///   v1 — flat schema baseline
28///   v2 — db_agent_definitions.updated_at
29///   v3 — db_agent_definitions.user_hidden (Phase 2 hide templates,
30///        SPEC_AGENT_PICKER_TWO_TIER_2026_05_24.md Q2 Decision Y)
31///   v4 — db_agents consolidation table (Phase 3a; dual-write only,
32///        reads still on db_agent_definitions / db_agent_instances)
33pub const OBJECT_SCHEMA_VERSION: i64 = 4;
34/// `user_version` value stamped into `filestore.db`.
35pub const FILESTORE_SCHEMA_VERSION: i64 = 1;
36/// `user_version` value stamped into `sagas.db`.
37pub const SAGA_LOG_SCHEMA_VERSION: i64 = 1;
38
39/// Object type table names matching the `db_<otype>` convention.
40const WSTORE_OTYPES: &[&str] = &[
41    "client",
42    "window",
43    "workspace",
44    "tab",
45    "layout",
46    "block",
47    "temp",
48];
49
50/// Legacy `objects.db` table names retired by the de-forge rename, paired
51/// with their replacement. `adopt_legacy_table_names` renames any of these
52/// it finds — the single surviving piece of the old migration chain (it
53/// also subsumes the v11 `db_identities`/`db_memories` rename).
54const LEGACY_TABLE_RENAMES: &[(&str, &str)] = &[
55    ("db_forge_agents", "db_agent_definitions"),
56    ("db_forge_content", "db_agent_content"),
57    ("db_forge_skills", "db_agent_skills"),
58    ("db_forge_history", "db_agent_history"),
59    ("db_forge_agent_identities", "db_agent_identity_links"),
60    ("db_identities", "db_identity_bundles"),
61    ("db_memories", "db_memory_bundles"),
62];
63
64/// Legacy index names that must be dropped after their table is renamed —
65/// `ALTER TABLE … RENAME` keeps indexes attached but under their old names,
66/// which would collide with the flat DDL's `CREATE INDEX`. The flat DDL
67/// recreates each under the new name.
68const LEGACY_INDEX_DROPS: &[&str] = &[
69    "idx_forge_agents_slug",
70    "idx_forge_history_agent_date",
71    "idx_forge_agent_identities_account",
72    "idx_identities_is_blank",
73    "idx_memories_is_blank",
74];
75
76/// Tables retained by the old chain only for a downgrade path the flatten
77/// abandons. Dropped from any legacy DB by the adopt step; never created
78/// by the flat schema. `db_workflow_*` data was already copied into
79/// `db_drone_*` by the old v10 migration, so dropping loses nothing.
80const DEAD_TABLE_DROPS: &[&str] = &[
81    "db_workflow_definitions",
82    "db_workflow_runs",
83    "db_v10_migrated_legacy_defs",
84    "db_v10_migrated_legacy_runs",
85];
86
87/// Initialize (or re-validate) the full `objects.db` schema.
88///
89/// Idempotent — safe on every srv startup. Steps:
90///
91/// 1. `adopt_legacy_table_names` — renames any pre-flatten forge/bundle
92///    tables found (protects dev databases created before the flatten;
93///    see the spec §3/§7) and drops the dead workflow/sentinel tables.
94/// 2. The flat `CREATE TABLE IF NOT EXISTS` batch — the canonical schema.
95/// 3. Seeds the blank Identity / Memory singleton rows.
96///
97/// A database stuck at a pre-v11 intermediate schema cannot be fully
98/// adopted (its tables predate later columns). The adopt step still
99/// renames what it finds; the first query referencing a missing column
100/// then fails loudly with `no such column` — a hard error, not silent
101/// empty state, with the data preserved on disk. Per-version data dirs
102/// make that case unreachable for released builds.
103pub fn run_object_schema(conn: &Connection) -> Result<(), StoreError> {
104    adopt_legacy_table_names(conn)?;
105
106    // ---- Generic WaveObj object tables ----
107    for otype in WSTORE_OTYPES {
108        conn.execute_batch(&format!(
109            "CREATE TABLE IF NOT EXISTS db_{otype} (
110                oid     TEXT PRIMARY KEY,
111                version INTEGER NOT NULL DEFAULT 1,
112                data    TEXT NOT NULL
113            );"
114        ))?;
115    }
116
117    // ---- Agent + identity + memory + drone schema ----
118    conn.execute_batch(
119        "CREATE TABLE IF NOT EXISTS db_agent_definitions (
120            id                   TEXT PRIMARY KEY,
121            slug                 TEXT NOT NULL DEFAULT '',
122            name                 TEXT NOT NULL,
123            icon                 TEXT NOT NULL DEFAULT '✦',
124            provider             TEXT NOT NULL,
125            description          TEXT NOT NULL DEFAULT '',
126            working_directory    TEXT NOT NULL DEFAULT '',
127            shell                TEXT NOT NULL DEFAULT '',
128            provider_flags       TEXT NOT NULL DEFAULT '',
129            auto_start           INTEGER NOT NULL DEFAULT 0,
130            restart_on_crash     INTEGER NOT NULL DEFAULT 0,
131            idle_timeout_minutes INTEGER NOT NULL DEFAULT 0,
132            agent_type           TEXT NOT NULL DEFAULT 'standalone',
133            environment          TEXT NOT NULL DEFAULT '',
134            agent_bus_id         TEXT NOT NULL DEFAULT '',
135            is_seeded            INTEGER NOT NULL DEFAULT 0,
136            accounts             TEXT NOT NULL DEFAULT '',
137            parent_id            TEXT NOT NULL DEFAULT '',
138            branch_label         TEXT NOT NULL DEFAULT '',
139            created_at           INTEGER NOT NULL DEFAULT 0,
140            updated_at           INTEGER NOT NULL DEFAULT 0,
141            user_hidden          INTEGER NOT NULL DEFAULT 0
142        );
143        CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_definitions_slug
144            ON db_agent_definitions(slug);
145
146        CREATE TABLE IF NOT EXISTS db_agent_content (
147            agent_id     TEXT NOT NULL,
148            content_type TEXT NOT NULL,
149            content      TEXT NOT NULL DEFAULT '',
150            updated_at   INTEGER NOT NULL DEFAULT 0,
151            PRIMARY KEY (agent_id, content_type),
152            FOREIGN KEY (agent_id) REFERENCES db_agent_definitions(id) ON DELETE CASCADE
153        );
154
155        CREATE TABLE IF NOT EXISTS db_agent_skills (
156            id          TEXT PRIMARY KEY,
157            agent_id    TEXT NOT NULL,
158            name        TEXT NOT NULL,
159            trigger     TEXT NOT NULL DEFAULT '',
160            skill_type  TEXT NOT NULL DEFAULT 'prompt',
161            description TEXT NOT NULL DEFAULT '',
162            content     TEXT NOT NULL DEFAULT '',
163            created_at  INTEGER NOT NULL DEFAULT 0,
164            FOREIGN KEY (agent_id) REFERENCES db_agent_definitions(id) ON DELETE CASCADE
165        );
166
167        CREATE TABLE IF NOT EXISTS db_agent_history (
168            id           INTEGER PRIMARY KEY AUTOINCREMENT,
169            agent_id     TEXT NOT NULL,
170            session_date TEXT NOT NULL,
171            entry        TEXT NOT NULL,
172            timestamp    INTEGER NOT NULL DEFAULT 0,
173            FOREIGN KEY (agent_id) REFERENCES db_agent_definitions(id) ON DELETE CASCADE
174        );
175        CREATE INDEX IF NOT EXISTS idx_agent_history_agent_date
176            ON db_agent_history(agent_id, session_date);
177
178        CREATE TABLE IF NOT EXISTS db_identity_accounts (
179            id           TEXT PRIMARY KEY,
180            name         TEXT NOT NULL,
181            provider     TEXT NOT NULL,
182            kind         TEXT NOT NULL,
183            display_name TEXT NOT NULL DEFAULT '',
184            secret_ref   TEXT NOT NULL,
185            context      TEXT NOT NULL DEFAULT '{}',
186            status       TEXT NOT NULL DEFAULT 'unknown',
187            created_at   INTEGER NOT NULL DEFAULT 0,
188            updated_at   INTEGER NOT NULL DEFAULT 0
189        );
190        CREATE INDEX IF NOT EXISTS idx_identity_accounts_provider
191            ON db_identity_accounts(provider);
192
193        CREATE TABLE IF NOT EXISTS db_agent_identity_links (
194            agent_id   TEXT NOT NULL,
195            account_id TEXT NOT NULL,
196            provider   TEXT NOT NULL,
197            PRIMARY KEY (agent_id, provider),
198            FOREIGN KEY (agent_id)   REFERENCES db_agent_definitions(id) ON DELETE CASCADE,
199            FOREIGN KEY (account_id) REFERENCES db_identity_accounts(id) ON DELETE CASCADE
200        );
201        CREATE INDEX IF NOT EXISTS idx_agent_identity_links_account
202            ON db_agent_identity_links(account_id);
203
204        CREATE TABLE IF NOT EXISTS db_identity_bundles (
205            id          TEXT PRIMARY KEY,
206            name        TEXT NOT NULL UNIQUE,
207            description TEXT NOT NULL DEFAULT '',
208            is_blank    INTEGER NOT NULL DEFAULT 0,
209            created_at  INTEGER NOT NULL DEFAULT 0,
210            updated_at  INTEGER NOT NULL DEFAULT 0
211        );
212        CREATE INDEX IF NOT EXISTS idx_identity_bundles_is_blank
213            ON db_identity_bundles(is_blank);
214
215        CREATE TABLE IF NOT EXISTS db_identity_bindings (
216            identity_id TEXT NOT NULL,
217            provider    TEXT NOT NULL,
218            account_id  TEXT NOT NULL,
219            PRIMARY KEY (identity_id, provider),
220            FOREIGN KEY (identity_id) REFERENCES db_identity_bundles(id)  ON DELETE CASCADE,
221            FOREIGN KEY (account_id)  REFERENCES db_identity_accounts(id) ON DELETE CASCADE
222        );
223        CREATE INDEX IF NOT EXISTS idx_identity_bindings_account
224            ON db_identity_bindings(account_id);
225
226        CREATE TABLE IF NOT EXISTS db_memory_bundles (
227            id            TEXT PRIMARY KEY,
228            name          TEXT NOT NULL UNIQUE,
229            description   TEXT NOT NULL DEFAULT '',
230            is_blank      INTEGER NOT NULL DEFAULT 0,
231            provider      TEXT NOT NULL DEFAULT '',
232            model         TEXT NOT NULL DEFAULT '',
233            instructions  TEXT NOT NULL DEFAULT '',
234            context_files TEXT NOT NULL DEFAULT '[]',
235            mcp_servers   TEXT NOT NULL DEFAULT '[]',
236            skills        TEXT NOT NULL DEFAULT '[]',
237            created_at    INTEGER NOT NULL DEFAULT 0,
238            updated_at    INTEGER NOT NULL DEFAULT 0
239        );
240        CREATE INDEX IF NOT EXISTS idx_memory_bundles_is_blank
241            ON db_memory_bundles(is_blank);
242
243        CREATE TABLE IF NOT EXISTS db_agent_instances (
244            id                 TEXT PRIMARY KEY,
245            definition_id      TEXT NOT NULL,
246            parent_instance_id TEXT NOT NULL DEFAULT '',
247            block_id           TEXT NOT NULL DEFAULT '',
248            session_id         TEXT NOT NULL DEFAULT '',
249            status             TEXT NOT NULL DEFAULT 'running',
250            github_context     TEXT NOT NULL DEFAULT '',
251            identity_id        TEXT NOT NULL DEFAULT '',
252            memory_id          TEXT NOT NULL DEFAULT '',
253            instance_name      TEXT NOT NULL DEFAULT '',
254            working_directory  TEXT NOT NULL DEFAULT '',
255            display_hidden     INTEGER NOT NULL DEFAULT 0,
256            started_at         INTEGER NOT NULL DEFAULT 0,
257            ended_at           INTEGER NOT NULL DEFAULT 0,
258            created_at         INTEGER NOT NULL DEFAULT 0,
259            FOREIGN KEY (definition_id) REFERENCES db_agent_definitions(id) ON DELETE CASCADE
260        );
261        CREATE INDEX IF NOT EXISTS idx_agent_instances_definition
262            ON db_agent_instances(definition_id);
263        CREATE INDEX IF NOT EXISTS idx_agent_instances_block
264            ON db_agent_instances(block_id);
265        CREATE INDEX IF NOT EXISTS idx_agent_instances_status
266            ON db_agent_instances(status);
267        CREATE INDEX IF NOT EXISTS idx_agent_instances_parent
268            ON db_agent_instances(parent_instance_id);
269        CREATE INDEX IF NOT EXISTS idx_agent_instances_name_recent
270            ON db_agent_instances(instance_name, started_at DESC)
271            WHERE display_hidden = 0 AND instance_name != '';
272
273        -- Phase 3a consolidation: `db_agents` collapses `db_agent_definitions`
274        -- + `db_agent_instances` into one table per
275        -- `docs/specs/SPEC_AGENT_CONCEPT_CONSOLIDATION_2026_05_24.md`.
276        -- WRITE-ONLY in 3a: every old-table mutation dual-writes here, but
277        -- every read still hits the old tables. Phase 3b migrates readers,
278        -- Phase 3c drops the old tables. Column names align with the live
279        -- `db_agent_definitions` shape (provider, working_directory, …) —
280        -- NOT the inline draft in the spec (provider_id, cmd, cmd_args, …) —
281        -- because the existing storage layer never grew the cmd-template
282        -- columns the spec sketched; carrying the names we actually have
283        -- avoids inventing data we don't store. See the PR body for the
284        -- field-by-field mapping.
285        CREATE TABLE IF NOT EXISTS db_agents (
286            id                   TEXT PRIMARY KEY,
287            name                 TEXT NOT NULL,
288            icon                 TEXT NOT NULL DEFAULT '',
289            description          TEXT NOT NULL DEFAULT '',
290
291            -- Template vs user agent
292            is_template          INTEGER NOT NULL DEFAULT 0,
293            parent_template_id   TEXT NOT NULL DEFAULT '',
294
295            -- Provider/cmd config (was on definition; named to match the
296            -- live `db_agent_definitions` columns).
297            provider             TEXT NOT NULL,
298            provider_flags       TEXT NOT NULL DEFAULT '',
299            shell                TEXT NOT NULL DEFAULT '',
300            environment          TEXT NOT NULL DEFAULT '',
301            agent_type           TEXT NOT NULL DEFAULT 'standalone',
302            agent_bus_id         TEXT NOT NULL DEFAULT '',
303            accounts             TEXT NOT NULL DEFAULT '',
304            auto_start           INTEGER NOT NULL DEFAULT 0,
305            restart_on_crash     INTEGER NOT NULL DEFAULT 0,
306            idle_timeout_minutes INTEGER NOT NULL DEFAULT 0,
307            slug                 TEXT NOT NULL DEFAULT '',
308            branch_label         TEXT NOT NULL DEFAULT '',
309
310            -- Bindings (was on instance — only meaningful when is_template=0).
311            -- For template rows these stay empty.
312            identity_id          TEXT NOT NULL DEFAULT '',
313            memory_id            TEXT NOT NULL DEFAULT '',
314            working_directory    TEXT NOT NULL DEFAULT '',
315            github_context       TEXT NOT NULL DEFAULT '',
316            instance_name        TEXT NOT NULL DEFAULT '',
317
318            -- Provenance
319            created_at           INTEGER NOT NULL DEFAULT 0,
320            updated_at           INTEGER NOT NULL DEFAULT 0,
321            is_seeded            INTEGER NOT NULL DEFAULT 0,
322            user_hidden          INTEGER NOT NULL DEFAULT 0
323        );
324        CREATE INDEX IF NOT EXISTS idx_agents_is_template
325            ON db_agents(is_template);
326        CREATE INDEX IF NOT EXISTS idx_agents_parent_template_id
327            ON db_agents(parent_template_id);
328        CREATE INDEX IF NOT EXISTS idx_agents_is_seeded
329            ON db_agents(is_seeded);
330
331        CREATE TABLE IF NOT EXISTS db_drone_definitions (
332            id          TEXT PRIMARY KEY,
333            name        TEXT NOT NULL,
334            description TEXT NOT NULL DEFAULT '',
335            graph       TEXT NOT NULL DEFAULT '{\"nodes\":[],\"edges\":[]}',
336            viewport    TEXT NOT NULL DEFAULT '{\"x\":0,\"y\":0,\"zoom\":1}',
337            created_at  INTEGER NOT NULL DEFAULT 0,
338            updated_at  INTEGER NOT NULL DEFAULT 0
339        );
340        CREATE INDEX IF NOT EXISTS idx_drone_definitions_updated
341            ON db_drone_definitions(updated_at DESC);
342
343        CREATE TABLE IF NOT EXISTS db_drone_runs (
344            id           TEXT PRIMARY KEY,
345            drone_id     TEXT NOT NULL,
346            status       TEXT NOT NULL DEFAULT 'running',
347            started_at   INTEGER NOT NULL DEFAULT 0,
348            ended_at     INTEGER NOT NULL DEFAULT 0,
349            block_states TEXT NOT NULL DEFAULT '{}',
350            output       TEXT NOT NULL DEFAULT '',
351            error        TEXT NOT NULL DEFAULT '',
352            FOREIGN KEY (drone_id) REFERENCES db_drone_definitions(id) ON DELETE CASCADE
353        );
354        CREATE INDEX IF NOT EXISTS idx_drone_runs_drone_started
355            ON db_drone_runs(drone_id, started_at DESC);
356        CREATE INDEX IF NOT EXISTS idx_drone_runs_status
357            ON db_drone_runs(status);",
358    )?;
359
360    // ---- Additive column migrations (schema v2+) ----
361    // The flat CREATE batch above covers fresh databases. These ALTERs
362    // carry an existing database (e.g. a developer's dev DB that persists
363    // across builds) forward. Idempotent: the "duplicate column" error is
364    // swallowed. New additive columns append here + bump OBJECT_SCHEMA_VERSION.
365    //
366    // v2: db_agent_definitions.updated_at — last-modified timestamp
367    //     (created_at already existed; updates now stamp updated_at).
368    // v3: db_agent_definitions.user_hidden — per-user hide flag for
369    //     templates (Phase 2 of the two-tier picker spec, Q2 Decision Y).
370    //     Defaults to 0 (visible) for all existing rows so a migration
371    //     never silently hides previously-visible templates.
372    for stmt in &[
373        "ALTER TABLE db_agent_definitions ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0",
374        "ALTER TABLE db_agent_definitions ADD COLUMN user_hidden INTEGER NOT NULL DEFAULT 0",
375    ] {
376        if let Err(e) = conn.execute_batch(stmt) {
377            let msg = e.to_string();
378            if !msg.contains("duplicate column") {
379                return Err(e.into());
380            }
381        }
382    }
383
384    // ---- Seed blank Identity / Memory singletons ----
385    // The launch UI renders these as the default option in its Identity /
386    // Memory dropdowns. Fixed ids so tests + dev seed data can hard-code
387    // references.
388    conn.execute_batch(
389        "INSERT OR IGNORE INTO db_identity_bundles
390            (id, name, description, is_blank, created_at, updated_at)
391         VALUES ('blank', '__blank__', 'No credentials — use ambient', 1, 0, 0);
392
393         INSERT OR IGNORE INTO db_memory_bundles
394            (id, name, description, is_blank, created_at, updated_at)
395         VALUES ('blank', '__blank__', 'Vanilla CLI — no instructions, no context', 1, 0, 0);",
396    )?;
397
398    Ok(())
399}
400
401/// Rename any pre-flatten `objects.db` tables to their de-forged names and
402/// drop the dead workflow/sentinel tables. Idempotent — on a fresh or
403/// already-flat database every check is a no-op.
404///
405/// This is the single surviving fragment of the old v1–v11 chain: it
406/// exists only to carry a developer's pre-flatten `objects.db` (always at
407/// the post-v11 schema, since v11 is merged) forward without data loss.
408/// SQLite ≥ 3.25 auto-updates foreign-key references in child tables when
409/// a parent table is renamed, so the agent/identity cascades survive.
410fn adopt_legacy_table_names(conn: &Connection) -> Result<(), StoreError> {
411    for (legacy, current) in LEGACY_TABLE_RENAMES {
412        let legacy_exists: i64 = conn.query_row(
413            "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
414            [legacy],
415            |row| row.get(0),
416        )?;
417        if legacy_exists == 0 {
418            continue;
419        }
420        let current_exists: i64 = conn.query_row(
421            "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
422            [current],
423            |row| row.get(0),
424        )?;
425        if current_exists == 1 {
426            // Both present — only reachable on a deliberate
427            // downgrade-roundtrip dev DB (flat build → pre-flatten build,
428            // which re-creates the legacy name → flat build again). The
429            // flatten abandons the downgrade path, but the legacy table
430            // may hold rows the downgraded build wrote. Do NOT drop it —
431            // that would be silent data loss (the bug class behind PR
432            // #933's Codex P1). Leave it on disk and warn loudly so the
433            // developer can recover or delete it manually.
434            warn!(
435                legacy_table = *legacy,
436                current_table = *current,
437                "objects.db has both a legacy table and its de-forged \
438                 replacement — this only happens after a downgrade to a \
439                 pre-flatten build; the legacy table is left untouched \
440                 for manual recovery and is otherwise unused",
441            );
442        } else {
443            conn.execute_batch(&format!("ALTER TABLE {legacy} RENAME TO {current};"))?;
444        }
445    }
446
447    // Drop indexes orphaned by the renames — the flat DDL recreates them
448    // under the new names.
449    for idx in LEGACY_INDEX_DROPS {
450        conn.execute_batch(&format!("DROP INDEX IF EXISTS {idx};"))?;
451    }
452
453    // Drop tables retained only for the abandoned downgrade path.
454    for table in DEAD_TABLE_DROPS {
455        conn.execute_batch(&format!("DROP TABLE IF EXISTS {table};"))?;
456    }
457
458    Ok(())
459}
460
461/// Initialize the FileStore schema. Creates the wave_file and file_data
462/// tables. Already a flat single-DDL store — unaffected by the
463/// `objects.db` flattening.
464pub fn run_filestore_migrations(conn: &Connection) -> Result<(), StoreError> {
465    conn.execute_batch(
466        "CREATE TABLE IF NOT EXISTS db_wave_file (
467            zoneid TEXT NOT NULL,
468            name TEXT NOT NULL,
469            size INTEGER NOT NULL DEFAULT 0,
470            createdts INTEGER NOT NULL DEFAULT 0,
471            modts INTEGER NOT NULL DEFAULT 0,
472            opts TEXT NOT NULL DEFAULT '{}',
473            meta TEXT NOT NULL DEFAULT '{}',
474            PRIMARY KEY (zoneid, name)
475        );
476
477        CREATE TABLE IF NOT EXISTS db_file_data (
478            zoneid TEXT NOT NULL,
479            name TEXT NOT NULL,
480            partidx INTEGER NOT NULL,
481            data BLOB NOT NULL,
482            PRIMARY KEY (zoneid, name, partidx)
483        );",
484    )?;
485    Ok(())
486}
487
488/// Initialize the saga durability schema (`saga` + `saga_step` tables and
489/// their indexes). See `docs/specs/SPEC_SAGA_DURABILITY_2026-05-01.md` §2.2.
490/// Already a flat single-DDL store.
491pub fn run_saga_log_migrations(conn: &Connection) -> Result<(), StoreError> {
492    conn.execute_batch(
493        "CREATE TABLE IF NOT EXISTS saga (
494            saga_id        INTEGER PRIMARY KEY,
495            name           TEXT NOT NULL,
496            state          TEXT NOT NULL,
497            started_at     INTEGER NOT NULL,
498            terminal_at    INTEGER,
499            failure_reason TEXT,
500            input_json     TEXT NOT NULL
501        );
502
503        CREATE TABLE IF NOT EXISTS saga_step (
504            saga_id     INTEGER NOT NULL REFERENCES saga(saga_id),
505            step_index  INTEGER NOT NULL,
506            name        TEXT NOT NULL,
507            state       TEXT NOT NULL,
508            cmd_json    TEXT NOT NULL,
509            output_json TEXT,
510            started_at  INTEGER NOT NULL,
511            ended_at    INTEGER,
512            PRIMARY KEY (saga_id, step_index)
513        );
514
515        CREATE INDEX IF NOT EXISTS saga_state_idx
516            ON saga(state) WHERE state IN ('running', 'compensating');
517        CREATE INDEX IF NOT EXISTS saga_terminal_idx
518            ON saga(terminal_at);",
519    )?;
520    Ok(())
521}
522
523/// `PRAGMA user_version` tripwire (AUDIT_SQLITE_SYSTEMS §8.5).
524///
525/// Compare the file's `user_version` to `current` and refuse to open
526/// the database if it was written by a NEWER binary.
527///
528/// This is the forward-compat **safety lock** from the channels design
529/// (`SPEC_DATA_CHANNELS_2026_05_24.md` §3.3). Within a channel,
530/// multiple released AgentMux versions share one data dir; the lock
531/// keeps an older binary from writing into a schema laid down by a
532/// newer binary. Same discipline as Chrome's profile-too-new check
533/// and Postgres's catalog version mismatch.
534///
535/// **Must be called BEFORE `run_*_schema` / `run_*_migrations`.** The
536/// `run_*` functions include mutating steps (legacy-table renames,
537/// seed inserts, `CREATE TABLE` for new tables the older binary
538/// doesn't have), so if we ran them first and only then checked the
539/// version, a downgraded binary could still alter a newer database
540/// before the error fires — breaking the "reject without touching
541/// disk" invariant the lock is meant to guarantee (codex P1 on PR
542/// #1029).
543///
544/// Read-only: only `PRAGMA user_version` query, no writes.
545/// [`stamp_version`] does the corresponding write AFTER migrations
546/// complete successfully.
547///
548/// Recovery for the user when this returns `SchemaTooNew`: upgrade
549/// AgentMux to a version ≥ `found`, or set
550/// `AGENTMUX_CHANNEL=<other>` to land in a different channel dir.
551/// The data on disk is preserved either way — this function never
552/// modifies the database on the rejected path.
553pub fn check_schema_compat(
554    conn: &Connection,
555    current: i64,
556    db_label: &str,
557) -> Result<(), StoreError> {
558    let found: i64 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
559    if found > current {
560        warn!(
561            db = db_label,
562            found, expected = current,
563            "database user_version is newer than this build — refusing \
564             to open. Upgrade AgentMux or switch channels.",
565        );
566        return Err(StoreError::SchemaTooNew {
567            db: db_label.to_string(),
568            found,
569            expected: current,
570        });
571    }
572    Ok(())
573}
574
575/// Stamp the database's `user_version` PRAGMA to `current`. Called
576/// AFTER `run_*_schema` succeeds, paired with a prior
577/// [`check_schema_compat`] that gated the migrations on the
578/// caller-binary speaking a compatible (or newer) schema version.
579///
580/// Splitting the read from the write makes the safety-lock order
581/// explicit at every call site:
582///
583/// ```ignore
584/// check_schema_compat(&conn, OBJECT_SCHEMA_VERSION, "objects.db")?;
585/// run_object_schema(&conn)?;
586/// stamp_version(&conn, OBJECT_SCHEMA_VERSION)?;
587/// ```
588pub fn stamp_version(conn: &Connection, current: i64) -> Result<(), StoreError> {
589    conn.execute_batch(&format!("PRAGMA user_version = {current};"))?;
590    Ok(())
591}
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596
597    /// Every table the flat `objects.db` schema must contain.
598    const EXPECTED_TABLES: &[&str] = &[
599        "db_client",
600        "db_window",
601        "db_workspace",
602        "db_tab",
603        "db_layout",
604        "db_block",
605        "db_temp",
606        "db_agent_definitions",
607        "db_agent_content",
608        "db_agent_skills",
609        "db_agent_history",
610        "db_identity_accounts",
611        "db_agent_identity_links",
612        "db_identity_bundles",
613        "db_identity_bindings",
614        "db_memory_bundles",
615        "db_agent_instances",
616        "db_agents",
617        "db_drone_definitions",
618        "db_drone_runs",
619    ];
620
621    fn table_exists(conn: &Connection, name: &str) -> bool {
622        let count: i64 = conn
623            .query_row(
624                "SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?1",
625                [name],
626                |row| row.get(0),
627            )
628            .unwrap();
629        count == 1
630    }
631
632    fn index_exists(conn: &Connection, name: &str) -> bool {
633        let count: i64 = conn
634            .query_row(
635                "SELECT count(*) FROM sqlite_master WHERE type='index' AND name=?1",
636                [name],
637                |row| row.get(0),
638            )
639            .unwrap();
640        count == 1
641    }
642
643    #[test]
644    fn test_object_schema_creates_all_tables_and_singletons() {
645        let conn = Connection::open_in_memory().unwrap();
646        conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
647        run_object_schema(&conn).unwrap();
648
649        for table in EXPECTED_TABLES {
650            assert!(table_exists(&conn, table), "{table} should exist");
651        }
652        // De-forged + bundle indexes.
653        for idx in &[
654            "idx_agent_definitions_slug",
655            "idx_agent_history_agent_date",
656            "idx_agent_identity_links_account",
657            "idx_identity_accounts_provider",
658            "idx_identity_bundles_is_blank",
659            "idx_identity_bindings_account",
660            "idx_memory_bundles_is_blank",
661            "idx_agent_instances_definition",
662            "idx_agent_instances_name_recent",
663            "idx_agents_is_template",
664            "idx_agents_parent_template_id",
665            "idx_agents_is_seeded",
666            "idx_drone_definitions_updated",
667            "idx_drone_runs_status",
668        ] {
669            assert!(index_exists(&conn, idx), "{idx} should exist");
670        }
671
672        // Blank singletons seeded.
673        let id_blank: i64 = conn
674            .query_row(
675                "SELECT count(*) FROM db_identity_bundles WHERE id='blank' AND is_blank=1",
676                [],
677                |row| row.get(0),
678            )
679            .unwrap();
680        assert_eq!(id_blank, 1, "blank Identity singleton should be seeded");
681        let mem_blank: i64 = conn
682            .query_row(
683                "SELECT count(*) FROM db_memory_bundles WHERE id='blank' AND is_blank=1",
684                [],
685                |row| row.get(0),
686            )
687            .unwrap();
688        assert_eq!(mem_blank, 1, "blank Memory singleton should be seeded");
689    }
690
691    #[test]
692    fn test_object_schema_idempotent() {
693        let conn = Connection::open_in_memory().unwrap();
694        conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
695        run_object_schema(&conn).unwrap();
696        run_object_schema(&conn).unwrap(); // second pass must not error
697
698        // Singletons stay unique.
699        let id_count: i64 = conn
700            .query_row("SELECT count(*) FROM db_identity_bundles", [], |r| r.get(0))
701            .unwrap();
702        assert_eq!(id_count, 1);
703    }
704
705    #[test]
706    fn test_object_schema_omits_dead_tables() {
707        let conn = Connection::open_in_memory().unwrap();
708        run_object_schema(&conn).unwrap();
709        for dead in DEAD_TABLE_DROPS {
710            assert!(!table_exists(&conn, dead), "{dead} must not be created");
711        }
712        // Legacy forge names are never created either.
713        for (legacy, _) in LEGACY_TABLE_RENAMES {
714            assert!(
715                !table_exists(&conn, legacy),
716                "legacy {legacy} must not be created by the flat schema"
717            );
718        }
719    }
720
721    #[test]
722    fn test_adopt_legacy_renames_forge_tables() {
723        // Simulate a pre-flatten (post-v11) dev DB: legacy forge table
724        // names + a dead workflow table, with seeded rows.
725        let conn = Connection::open_in_memory().unwrap();
726        conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
727        conn.execute_batch(
728            "CREATE TABLE db_forge_agents (
729                id TEXT PRIMARY KEY, slug TEXT NOT NULL DEFAULT '', name TEXT NOT NULL,
730                icon TEXT NOT NULL DEFAULT '✦', provider TEXT NOT NULL,
731                description TEXT NOT NULL DEFAULT '', working_directory TEXT NOT NULL DEFAULT '',
732                shell TEXT NOT NULL DEFAULT '', provider_flags TEXT NOT NULL DEFAULT '',
733                auto_start INTEGER NOT NULL DEFAULT 0, restart_on_crash INTEGER NOT NULL DEFAULT 0,
734                idle_timeout_minutes INTEGER NOT NULL DEFAULT 0,
735                agent_type TEXT NOT NULL DEFAULT 'standalone', environment TEXT NOT NULL DEFAULT '',
736                agent_bus_id TEXT NOT NULL DEFAULT '', is_seeded INTEGER NOT NULL DEFAULT 0,
737                accounts TEXT NOT NULL DEFAULT '',
738                parent_id TEXT NOT NULL DEFAULT '', branch_label TEXT NOT NULL DEFAULT '',
739                created_at INTEGER NOT NULL DEFAULT 0
740            );
741            CREATE UNIQUE INDEX idx_forge_agents_slug ON db_forge_agents(slug);
742            INSERT INTO db_forge_agents (id, slug, name, provider)
743                VALUES ('a1', 'coder', 'Coder', 'claude');
744
745            CREATE TABLE db_workflow_definitions (id TEXT PRIMARY KEY);",
746        )
747        .unwrap();
748
749        run_object_schema(&conn).unwrap();
750
751        // Renamed, data preserved.
752        assert!(table_exists(&conn, "db_agent_definitions"));
753        assert!(!table_exists(&conn, "db_forge_agents"));
754        let name: String = conn
755            .query_row(
756                "SELECT name FROM db_agent_definitions WHERE id='a1'",
757                [],
758                |r| r.get(0),
759            )
760            .unwrap();
761        assert_eq!(name, "Coder");
762        // Old index dropped, new index present.
763        assert!(!index_exists(&conn, "idx_forge_agents_slug"));
764        assert!(index_exists(&conn, "idx_agent_definitions_slug"));
765        // Dead table dropped.
766        assert!(!table_exists(&conn, "db_workflow_definitions"));
767    }
768
769    #[test]
770    fn test_adopt_legacy_is_noop_on_fresh_db() {
771        let conn = Connection::open_in_memory().unwrap();
772        run_object_schema(&conn).unwrap();
773        // Re-running schema (which re-runs adopt) on the already-flat DB
774        // leaves the de-forged tables intact and creates no legacy names.
775        run_object_schema(&conn).unwrap();
776        assert!(table_exists(&conn, "db_agent_definitions"));
777        assert!(!table_exists(&conn, "db_forge_agents"));
778    }
779
780    #[test]
781    fn test_adopt_legacy_both_tables_present_is_non_destructive() {
782        // Downgrade-roundtrip: a flat DB (db_agent_definitions) where a
783        // pre-flatten build later re-created db_forge_agents and wrote a
784        // row. The adopt step must NOT drop the legacy table — silent
785        // data loss is the bug class behind PR #933's Codex P1.
786        let conn = Connection::open_in_memory().unwrap();
787        run_object_schema(&conn).unwrap(); // creates db_agent_definitions
788        conn.execute_batch(
789            "CREATE TABLE db_forge_agents (id TEXT PRIMARY KEY, name TEXT NOT NULL);
790             INSERT INTO db_forge_agents (id, name) VALUES ('downgrade-era', 'Recover Me');",
791        )
792        .unwrap();
793
794        run_object_schema(&conn).unwrap();
795
796        // Legacy table left intact — data recoverable, not dropped.
797        assert!(table_exists(&conn, "db_forge_agents"));
798        let name: String = conn
799            .query_row(
800                "SELECT name FROM db_forge_agents WHERE id='downgrade-era'",
801                [],
802                |r| r.get(0),
803            )
804            .unwrap();
805        assert_eq!(name, "Recover Me");
806        // Flat table still present and authoritative.
807        assert!(table_exists(&conn, "db_agent_definitions"));
808    }
809
810    #[test]
811    fn test_adopt_legacy_fk_cascade_survives_rename() {
812        // A renamed parent must keep cascading into renamed children.
813        let conn = Connection::open_in_memory().unwrap();
814        conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
815        conn.execute_batch(
816            "CREATE TABLE db_forge_agents (
817                id TEXT PRIMARY KEY, slug TEXT NOT NULL DEFAULT '', name TEXT NOT NULL,
818                icon TEXT NOT NULL DEFAULT '✦', provider TEXT NOT NULL,
819                description TEXT NOT NULL DEFAULT '', working_directory TEXT NOT NULL DEFAULT '',
820                shell TEXT NOT NULL DEFAULT '', provider_flags TEXT NOT NULL DEFAULT '',
821                auto_start INTEGER NOT NULL DEFAULT 0, restart_on_crash INTEGER NOT NULL DEFAULT 0,
822                idle_timeout_minutes INTEGER NOT NULL DEFAULT 0,
823                agent_type TEXT NOT NULL DEFAULT 'standalone', environment TEXT NOT NULL DEFAULT '',
824                agent_bus_id TEXT NOT NULL DEFAULT '', is_seeded INTEGER NOT NULL DEFAULT 0,
825                accounts TEXT NOT NULL DEFAULT '',
826                parent_id TEXT NOT NULL DEFAULT '', branch_label TEXT NOT NULL DEFAULT '',
827                created_at INTEGER NOT NULL DEFAULT 0
828            );
829            CREATE TABLE db_forge_content (
830                agent_id TEXT NOT NULL, content_type TEXT NOT NULL,
831                content TEXT NOT NULL DEFAULT '', updated_at INTEGER NOT NULL DEFAULT 0,
832                PRIMARY KEY (agent_id, content_type),
833                FOREIGN KEY (agent_id) REFERENCES db_forge_agents(id) ON DELETE CASCADE
834            );
835            INSERT INTO db_forge_agents (id, name, provider) VALUES ('a1', 'Coder', 'claude');
836            INSERT INTO db_forge_content (agent_id, content_type, content)
837                VALUES ('a1', 'soul', 'hello');",
838        )
839        .unwrap();
840
841        run_object_schema(&conn).unwrap();
842
843        conn.execute("DELETE FROM db_agent_definitions WHERE id='a1'", [])
844            .unwrap();
845        let remaining: i64 = conn
846            .query_row(
847                "SELECT count(*) FROM db_agent_content WHERE agent_id='a1'",
848                [],
849                |r| r.get(0),
850            )
851            .unwrap();
852        assert_eq!(
853            remaining, 0,
854            "FK cascade must survive the forge→agent table rename"
855        );
856    }
857
858    #[test]
859    fn test_user_hidden_column_present_on_fresh_db() {
860        // Schema v3 (Phase 2 hide-templates) adds db_agent_definitions
861        // .user_hidden. A fresh database lands the column via the flat
862        // CREATE statement; an existing-but-stale database lands it via
863        // the additive ALTER below.
864        let conn = Connection::open_in_memory().unwrap();
865        conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
866        run_object_schema(&conn).unwrap();
867
868        // Column exists with the documented default — INSERT without
869        // user_hidden must succeed and read back as 0.
870        conn.execute_batch(
871            "INSERT INTO db_agent_definitions (id, name, provider)
872             VALUES ('a-fresh', 'Fresh', 'claude');",
873        )
874        .unwrap();
875        let hidden: i64 = conn
876            .query_row(
877                "SELECT user_hidden FROM db_agent_definitions WHERE id='a-fresh'",
878                [],
879                |r| r.get(0),
880            )
881            .unwrap();
882        assert_eq!(hidden, 0);
883    }
884
885    #[test]
886    fn test_user_hidden_column_added_to_existing_db_via_alter() {
887        // Simulate an existing dev database created before Phase 2:
888        // db_agent_definitions exists but lacks the user_hidden column.
889        // run_object_schema must ALTER it in, preserving every existing
890        // row at the default 0. Idempotent on subsequent runs.
891        let conn = Connection::open_in_memory().unwrap();
892        conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
893        conn.execute_batch(
894            "CREATE TABLE db_agent_definitions (
895                id TEXT PRIMARY KEY, slug TEXT NOT NULL DEFAULT '',
896                name TEXT NOT NULL, icon TEXT NOT NULL DEFAULT '✦',
897                provider TEXT NOT NULL,
898                description TEXT NOT NULL DEFAULT '',
899                working_directory TEXT NOT NULL DEFAULT '',
900                shell TEXT NOT NULL DEFAULT '',
901                provider_flags TEXT NOT NULL DEFAULT '',
902                auto_start INTEGER NOT NULL DEFAULT 0,
903                restart_on_crash INTEGER NOT NULL DEFAULT 0,
904                idle_timeout_minutes INTEGER NOT NULL DEFAULT 0,
905                agent_type TEXT NOT NULL DEFAULT 'standalone',
906                environment TEXT NOT NULL DEFAULT '',
907                agent_bus_id TEXT NOT NULL DEFAULT '',
908                is_seeded INTEGER NOT NULL DEFAULT 0,
909                accounts TEXT NOT NULL DEFAULT '',
910                parent_id TEXT NOT NULL DEFAULT '',
911                branch_label TEXT NOT NULL DEFAULT '',
912                created_at INTEGER NOT NULL DEFAULT 0
913            );
914            INSERT INTO db_agent_definitions (id, name, provider, is_seeded)
915                VALUES ('pre-existing', 'Old Template', 'claude', 1);",
916        )
917        .unwrap();
918
919        run_object_schema(&conn).unwrap();
920        // Idempotent — second pass must not error and must not
921        // re-default existing rows.
922        run_object_schema(&conn).unwrap();
923
924        let hidden: i64 = conn
925            .query_row(
926                "SELECT user_hidden FROM db_agent_definitions WHERE id='pre-existing'",
927                [],
928                |r| r.get(0),
929            )
930            .unwrap();
931        assert_eq!(
932            hidden, 0,
933            "ALTER must default existing rows to 0 (visible), never to 1",
934        );
935    }
936
937    #[test]
938    fn stamp_version_writes_pragma() {
939        let conn = Connection::open_in_memory().unwrap();
940        stamp_version(&conn, OBJECT_SCHEMA_VERSION).unwrap();
941        let v: i64 = conn
942            .query_row("PRAGMA user_version", [], |r| r.get(0))
943            .unwrap();
944        assert_eq!(v, OBJECT_SCHEMA_VERSION);
945    }
946
947    #[test]
948    fn check_schema_compat_refuses_newer_db_without_writing() {
949        // `SPEC_DATA_CHANNELS_2026_05_24.md` §3.3 safety lock — if the
950        // DB on disk was stamped by a newer AgentMux binary, this one
951        // MUST refuse to open it. The split into
952        // check_schema_compat + stamp_version (codex P1 on #1029)
953        // ensures the check runs BEFORE any migration side effects:
954        // legacy-table rename + seed-insert in `run_object_schema` are
955        // mutating, and if we ran them first we'd partially alter a
956        // newer DB before the error fired.
957        let conn = Connection::open_in_memory().unwrap();
958        conn.execute_batch("PRAGMA user_version = 99;").unwrap();
959        let err = check_schema_compat(&conn, OBJECT_SCHEMA_VERSION, "objects.db")
960            .expect_err("expected refusal");
961        match err {
962            StoreError::SchemaTooNew { db, found, expected } => {
963                assert_eq!(db, "objects.db");
964                assert_eq!(found, 99);
965                assert_eq!(expected, OBJECT_SCHEMA_VERSION);
966            }
967            other => panic!("expected SchemaTooNew, got {other:?}"),
968        }
969        // Crucially, `check_schema_compat` made NO writes. The
970        // `user_version` is still 99. (Before the split — when the
971        // single function combined the check with the write — this
972        // invariant held only by virtue of the early `return Err`
973        // skipping the stamp; with the split the function is
974        // structurally read-only, eliminating the risk that a
975        // future refactor reintroduces a downgrade-corrupt path.)
976        let v: i64 = conn
977            .query_row("PRAGMA user_version", [], |r| r.get(0))
978            .unwrap();
979        assert_eq!(v, 99, "rejected DB must not have its user_version stamp overwritten");
980    }
981
982    #[test]
983    fn check_schema_compat_accepts_equal_or_lower_without_writing() {
984        // check_schema_compat must NEVER write — it just gates
985        // migrations. The on-disk `user_version` is untouched by it,
986        // regardless of whether the verdict is accept or reject.
987        // stamp_version is the only thing that writes, and only after
988        // migrations succeed.
989
990        // Equal: check passes silently.
991        let conn = Connection::open_in_memory().unwrap();
992        conn.execute_batch(&format!("PRAGMA user_version = {};", OBJECT_SCHEMA_VERSION))
993            .unwrap();
994        check_schema_compat(&conn, OBJECT_SCHEMA_VERSION, "objects.db").unwrap();
995        let v: i64 = conn
996            .query_row("PRAGMA user_version", [], |r| r.get(0))
997            .unwrap();
998        assert_eq!(v, OBJECT_SCHEMA_VERSION);
999
1000        // Lower (forward-migration path): check passes, version stays
1001        // at the OLD value — stamp_version is what bumps it after
1002        // migrations.
1003        let conn = Connection::open_in_memory().unwrap();
1004        conn.execute_batch("PRAGMA user_version = 1;").unwrap();
1005        check_schema_compat(&conn, OBJECT_SCHEMA_VERSION, "objects.db").unwrap();
1006        let v: i64 = conn
1007            .query_row("PRAGMA user_version", [], |r| r.get(0))
1008            .unwrap();
1009        assert_eq!(v, 1, "check_schema_compat must not write to user_version");
1010
1011        // Then stamp_version bumps it as the post-migration step.
1012        stamp_version(&conn, OBJECT_SCHEMA_VERSION).unwrap();
1013        let v: i64 = conn
1014            .query_row("PRAGMA user_version", [], |r| r.get(0))
1015            .unwrap();
1016        assert_eq!(v, OBJECT_SCHEMA_VERSION);
1017    }
1018
1019    #[test]
1020    fn test_filestore_migrations_idempotent() {
1021        let conn = Connection::open_in_memory().unwrap();
1022        run_filestore_migrations(&conn).unwrap();
1023        run_filestore_migrations(&conn).unwrap();
1024        assert!(table_exists(&conn, "db_wave_file"));
1025        assert!(table_exists(&conn, "db_file_data"));
1026    }
1027
1028    #[test]
1029    fn test_saga_log_migrations_idempotent() {
1030        let conn = Connection::open_in_memory().unwrap();
1031        run_saga_log_migrations(&conn).unwrap();
1032        run_saga_log_migrations(&conn).unwrap();
1033        assert!(table_exists(&conn, "saga"));
1034        assert!(table_exists(&conn, "saga_step"));
1035    }
1036}