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}