Skip to main content

agentmux_srv/migrations/
m0000_bootstrap.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Bootstrap: stamp all pre-framework migrations as applied for existing users.
5//!
6//! Any user who has launched AgentMux before the migration framework existed
7//! will have the old startup-embedded migrations already applied (via the
8//! marker-file system). This step reads those marker files and stamps the
9//! corresponding migration IDs into `db_migrations` so the runner skips them.
10//!
11//! For migrations without a deterministic marker file, we check for the
12//! presence of the data they produce. If we cannot determine whether a
13//! migration ran, we leave it unstamped so it runs (all ported migrations are
14//! idempotent and safe to re-run).
15//!
16//! This migration itself is idempotent: on second run the `db_migrations` rows
17//! are already present and `migration_is_applied` returns true before we even
18//! get here.
19
20use crate::backend::storage::store::Store;
21use crate::registry::{resolve_shared_definitions_dir, resolve_shared_registry_dir, resolve_shared_transcripts_dir};
22
23use super::{Migration, MigrationContext, MigrationError, MigrationScope};
24
25pub struct M0000Bootstrap;
26
27impl Migration for M0000Bootstrap {
28    fn id(&self) -> &'static str { "0000_bootstrap_migration_state" }
29    fn scope(&self) -> MigrationScope { MigrationScope::Global }
30    fn description(&self) -> &'static str {
31        "Stamp pre-framework migrations as applied for existing installs"
32    }
33
34    fn up(&self, ctx: &MigrationContext) -> Result<(), MigrationError> {
35        let shared = Store::open_shared(&ctx.shared_store_path)
36            .map_err(|e| MigrationError(format!("bootstrap: open shared store: {}", e)))?;
37
38        // Channel store for channel-scoped migration stamps. May not exist on a
39        // fresh install — in that case all channel migrations are no-ops anyway.
40        let channel_store = if ctx.channel_store_path.exists() {
41            Some(
42                Store::open(&ctx.channel_store_path)
43                    .map_err(|e| MigrationError(format!("bootstrap: open channel store: {}", e)))?,
44            )
45        } else {
46            None
47        };
48
49        // Stamp helpers — global into shared, channel into channel store.
50        let stamp_global = |id: &str| {
51            let _ = shared.migration_mark_applied(id, "global", 0);
52        };
53        let stamp_channel = |id: &str| {
54            if let Some(ref cs) = channel_store {
55                let _ = cs.migration_mark_applied(id, "channel", 0);
56            }
57        };
58
59        // ── Global: legacy data dir migration ────────────────────────────────
60        // Proxy: agents/ exists only for real prior-usage installs.
61        // Runner creates home/shared/ (making home exist) before m0000 runs,
62        // so ctx.home.exists() is no longer a reliable proxy.
63        if ctx.home.join("agents").exists() {
64            stamp_global("0001_legacy_data_dir");
65        }
66
67        // ── Channel: agent zone migration ────────────────────────────────────
68        // marker = data_dir/migration_agent_zones_v1.flag
69        //
70        // Phase 2 hardening (SPEC_MIGRATION_SYSTEM_HARDENING_2026_08_03): the
71        // flag alone is not proof the zones were migrated — a restored
72        // objects.db/filestore beside a stale flag is the same F1 shape as
73        // 0007's. Stamp only when the data agrees: no agent block still has
74        // a snapshot whose agent `:current` zone is empty. A data dir with no
75        // filestore has nothing to migrate and stamps on the flag as before.
76        if ctx.data_dir.join(crate::backend::agent_session::MIGRATION_MARKER_V1).exists() {
77            let filestore_path = ctx.data_dir.join("db").join("filestore.db");
78            let looks_incomplete = match (&channel_store, filestore_path.exists()) {
79                (Some(cs), true) => {
80                    let filestore = crate::backend::storage::filestore::FileStore::open(&filestore_path)
81                        .map_err(|e| MigrationError(format!("bootstrap: open filestore: {}", e)))?;
82                    // An unreadable blocks table is a failed bootstrap, not
83                    // a stamp (codex P1 on #3070).
84                    crate::backend::agent_session::block_zones_look_incomplete(cs, &filestore)
85                        .map_err(|e| MigrationError(format!("bootstrap: verify block_zones: {}", e)))?
86                }
87                _ => false,
88            };
89            if !looks_incomplete {
90                stamp_channel("0002_block_zones_v1");
91            }
92        }
93
94        // ── Channel: template promotion ──────────────────────────────────────
95        // Proxy: agents/ exists only for real prior-usage installs.
96        // Runner always creates objects.db before m0000 runs, so
97        // objects_db.exists() is no longer a reliable proxy.
98        if ctx.home.join("agents").exists() {
99            stamp_channel("0003_template_sessions_v1");
100        }
101
102        // ── Global: registry instance migration ──────────────────────────────
103        if let Some(registry_root) = resolve_shared_registry_dir() {
104            if registry_root.join(".migrated_from_sqlite").exists() {
105                stamp_global("0004_registry_from_sqlite");
106            }
107            if registry_root.join(".backfilled_source_bases").exists() {
108                stamp_global("0005_registry_source_bases");
109            }
110        }
111
112        // ── Global: definition registry migration ────────────────────────────
113        if let Some(def_root) = resolve_shared_definitions_dir() {
114            if def_root.join(".migrated_definitions").exists() {
115                stamp_global("0006_definitions_global");
116            }
117        }
118
119        // ── Channel: agents consolidate ──────────────────────────────────────
120        // marker = data_dir/migration_agents_consolidate_v1.flag
121        //
122        // Phase 0a hardening: existence of the marker alone is no longer
123        // trusted as proof the backfill actually populated db_agents (a
124        // rebuilt/restored objects.db sitting next to a stale flag file
125        // would otherwise get permanently stamped "applied" with zero rows
126        // written — see docs/specs/SPEC_MIGRATION_SYSTEM_HARDENING_2026_08_03.md
127        // §1.2/Phase 0a). If the channel store looks incomplete, leave this
128        // unstamped so the real migration (m0007) runs and does the work.
129        if ctx.data_dir.join("migration_agents_consolidate_v1.flag").exists() {
130            let looks_incomplete = channel_store
131                .as_ref()
132                .map(|cs| cs.agents_consolidate_looks_incomplete())
133                .transpose()
134                .map_err(|e| MigrationError(format!("bootstrap: verify agents_consolidate: {}", e)))?
135                .unwrap_or(false);
136            if !looks_incomplete {
137                stamp_channel("0007_agents_consolidate");
138            }
139        }
140
141        // NOTE: 0008_default_bundle is intentionally NOT stamped here. Its
142        // body is now a documented no-op (Phase 4c of
143        // SPEC_PRESET_TO_BUNDLE_REFACTOR_2026_07_02.md deleted the
144        // identity::migration module it used to call into, since
145        // db_identity_bundles/db_identity_bindings were dropped), so leaving
146        // it unstamped is harmless either way — the runner just marks it
147        // applied via the normal m0008 registry pass instead.
148
149        // ── Global: transcript backfill ──────────────────────────────────────
150        if let Some(transcripts_dir) = resolve_shared_transcripts_dir() {
151            if transcripts_dir.join(".transcripts_backfilled").exists() {
152                stamp_global("0009_transcript_backfill");
153            }
154        }
155
156        // ── Global: session id backfill ──────────────────────────────────────
157        // No marker; stamp if registry migration already ran (its marker confirms
158        // session_ids backfill also ran — both ran in the same startup sequence).
159        if let Some(registry_root) = resolve_shared_registry_dir() {
160            if registry_root.join(".migrated_from_sqlite").exists() {
161                stamp_global("0010_session_ids");
162            }
163        }
164
165        // ── Global: shared store backfill ────────────────────────────────────
166        let has_accounts = shared
167            .identity_list(None)
168            .map(|v| !v.is_empty())
169            .unwrap_or(false);
170        if has_accounts {
171            stamp_global("0011_shared_store_backfill");
172        }
173
174        Ok(())
175    }
176}