agentmux_srv/backend/agent_session/migrations/v1_blocks.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! One-time migration: per-block zones → per-agent zones.
5//!
6//! Gated by the migration framework (`db_migrations`, via
7//! `migrations::m0002_block_zones_v1`), NOT by its own marker file — see
8//! `migrate_block_zones_v1`'s doc comment for what the marker is still for.
9
10use std::collections::HashMap;
11use std::path::Path;
12use std::sync::Arc;
13
14use crate::backend::obj::Block;
15use crate::backend::storage::filestore::FileStore;
16use crate::backend::storage::store::Store;
17
18use super::super::helpers::{now_ms, write_zone_file};
19use super::super::session_io::SNAPSHOT_FILE;
20use super::super::zone_naming::{agent_archive_zone, agent_current_zone, is_valid_definition_id};
21
22/// Marker file written under the data dir when the migration completes.
23///
24/// It is **evidence, not a gate**: `m0000_bootstrap` reads it (together with
25/// [`block_zones_look_incomplete`]) to stamp `0002` as already applied on an
26/// install that ran this migration before the framework existed, and
27/// `m0002`'s doctor check reports it. The migration itself no longer
28/// short-circuits on its presence — SPEC_MIGRATION_SYSTEM_HARDENING_2026_08_03
29/// Phase 2: a marker whose mere *existence* is trusted as "done" is exactly
30/// the shape that left `0007`'s target table empty (§1.2/F1); a restored
31/// `objects.db` beside a stale flag would have skipped every block's
32/// conversation for good. Whether work is needed is now decided from the
33/// data, and the work is content-idempotent, so running twice is safe
34/// without a flag.
35pub const MIGRATION_MARKER_V1: &str = "migration_agent_zones_v1.flag";
36
37/// Stats from `migrate_block_zones_v1`. Logged at INFO at startup.
38#[derive(Debug, Clone, Default)]
39pub struct MigrationStats {
40 pub blocks_scanned: usize,
41 pub archives_written: usize,
42 /// Block snapshots whose bytes already existed as an archive for the
43 /// same agent — a re-run over migrated data, written nothing.
44 pub archives_already_present: usize,
45 pub current_zones_seeded: usize,
46 pub skipped_no_snapshot: usize,
47 pub failures: usize,
48}
49
50/// The agent definition id a block carries, if it is an agent block with a
51/// valid one. Stored under `agentId` (current shape, set by `agent.open` +
52/// the frontend launch flow) or the legacy `agent:id`.
53fn agent_definition_id(block: &Block) -> Option<&str> {
54 let view = block.meta.get("view").and_then(|v| v.as_str()).unwrap_or("");
55 if view != "agent" {
56 return None;
57 }
58 let def_id = block
59 .meta
60 .get("agentId")
61 .and_then(|v| v.as_str())
62 .or_else(|| block.meta.get("agent:id").and_then(|v| v.as_str()))
63 .unwrap_or("");
64 is_valid_definition_id(def_id).then_some(def_id)
65}
66
67/// Every archive snapshot already stored for `def_id`, by bytes. Read once
68/// per definition so the dedupe below is O(archives) per agent, not per
69/// block. Enumerates zone ids directly rather than through `list_archives`,
70/// which caps at 100 rows and reads previews this has no use for.
71fn existing_archive_snapshots(filestore: &FileStore, def_id: &str) -> Vec<Vec<u8>> {
72 let prefix = format!("agent:{}:archive:", def_id);
73 let Ok(zones) = filestore.get_all_zone_ids() else {
74 return Vec::new();
75 };
76 zones
77 .iter()
78 .filter(|z| z.starts_with(&prefix))
79 .filter_map(|z| filestore.read_file(z, SNAPSHOT_FILE).ok().flatten())
80 .collect()
81}
82
83/// Read-only content check: is there an agent block whose per-block
84/// snapshot has bytes, while its agent's `:current` zone is still empty?
85/// That is the state this migration exists to fix, so `true` means it has
86/// not (fully) run on this data — regardless of what any marker says.
87///
88/// Used by `m0000_bootstrap` (never stamp `0002` off the flag alone) and by
89/// `m0002`'s `verify()`. Never writes. `Err` when the blocks table cannot be
90/// read at all — that is not "complete", and the callers must not treat it
91/// as such (codex P1 on #3070): bootstrap fails rather than stamps, the
92/// doctor reports an error.
93pub fn block_zones_look_incomplete(mstore: &Store, filestore: &FileStore) -> Result<bool, String> {
94 let blocks = mstore.get_all::<Block>().map_err(|e| format!("read blocks: {e}"))?;
95 let mut current_populated: HashMap<String, bool> = HashMap::new();
96 for block in &blocks {
97 let Some(def_id) = agent_definition_id(block) else { continue };
98 let has_snapshot = matches!(filestore.stat(&block.oid, SNAPSHOT_FILE), Ok(Some(f)) if f.size > 0);
99 if !has_snapshot {
100 continue;
101 }
102 let populated = *current_populated.entry(def_id.to_string()).or_insert_with(|| {
103 matches!(filestore.stat(&agent_current_zone(def_id), SNAPSHOT_FILE), Ok(Some(f)) if f.size > 0)
104 });
105 if !populated {
106 return Ok(true);
107 }
108 }
109 Ok(false)
110}
111
112/// One-shot migration of per-block agent session zones to per-agent
113/// zones. Content-idempotent: an archive is only written when no archive
114/// for that agent already holds the same bytes, and `:current` is only
115/// seeded when empty — so running twice (or over a data dir that a stale
116/// [`MIGRATION_MARKER_V1`] wrongly calls done) writes nothing twice. The
117/// framework's `db_migrations` row is the gate; the marker is written at the
118/// end as evidence only.
119///
120/// Failure mode: per-block errors are logged + counted and do not abort the
121/// run. A `get_all::<Block>` failure is different — nothing was scanned — and
122/// is returned as `Err` so `m0002::up` fails the migration instead of
123/// letting the runner record it applied around a scan that never happened
124/// (codex P1 on #3070; before Phase 2 this returned default stats and `up`
125/// reported `Ok`).
126pub fn migrate_block_zones_v1(
127 mstore: &Arc<Store>,
128 filestore: &Arc<FileStore>,
129 data_dir: &Path,
130) -> Result<MigrationStats, String> {
131 let marker_path = data_dir.join(MIGRATION_MARKER_V1);
132 let mut stats = MigrationStats::default();
133
134 let blocks: Vec<Block> = mstore
135 .get_all::<Block>()
136 .map_err(|e| format!("agent_session migration: read blocks: {e}"))?;
137
138 // Track the most-recently-modified block snapshot per definition_id.
139 // Value: (modts_ms, snapshot_bytes).
140 let mut per_def_latest: HashMap<String, (i64, Vec<u8>)> = HashMap::new();
141 // Archive bytes already on disk per definition, loaded on first use and
142 // extended as this run writes, so two blocks with identical snapshots
143 // within one run also collapse to one archive.
144 let mut known_archives: HashMap<String, Vec<Vec<u8>>> = HashMap::new();
145
146 for block in &blocks {
147 let Some(def_id) = agent_definition_id(block) else { continue };
148 stats.blocks_scanned += 1;
149
150 // Read the per-block snapshot. Both missing and zero-byte are
151 // "skip" — no point archiving an empty snapshot.
152 let snapshot_stat = match filestore.stat(&block.oid, SNAPSHOT_FILE) {
153 Ok(Some(f)) => f,
154 Ok(None) => {
155 stats.skipped_no_snapshot += 1;
156 continue;
157 }
158 Err(e) => {
159 tracing::warn!(
160 block_id = %block.oid,
161 error = %e,
162 "agent_session migration: stat failed; skipping"
163 );
164 stats.failures += 1;
165 continue;
166 }
167 };
168 if snapshot_stat.size == 0 {
169 stats.skipped_no_snapshot += 1;
170 continue;
171 }
172
173 let snapshot_bytes = match filestore.read_file(&block.oid, SNAPSHOT_FILE) {
174 Ok(Some(b)) => b,
175 _ => {
176 stats.failures += 1;
177 continue;
178 }
179 };
180
181 // 2) (tracked before 1 so a deduped archive still competes for
182 // `:current`) Track the most-recently-modified per definition so
183 // we can seed the `:current` zone after the scan.
184 let entry = per_def_latest
185 .entry(def_id.to_string())
186 .or_insert_with(|| (0, Vec::new()));
187 if snapshot_stat.modts > entry.0 {
188 *entry = (snapshot_stat.modts, snapshot_bytes.clone());
189 }
190
191 // 1) Backfill an archive zone keyed on the block snapshot's
192 // createdts (closest available proxy for "when this
193 // conversation started"). Falls back to modts when
194 // createdts is missing/zero. Skipped when an archive with these
195 // exact bytes already exists for this agent — the re-run case.
196 let archives = known_archives
197 .entry(def_id.to_string())
198 .or_insert_with(|| existing_archive_snapshots(filestore, def_id));
199 if archives.iter().any(|a| *a == snapshot_bytes) {
200 stats.archives_already_present += 1;
201 continue;
202 }
203 let mut archive_ts: u64 = if snapshot_stat.createdts > 0 {
204 snapshot_stat.createdts as u64
205 } else if snapshot_stat.modts > 0 {
206 snapshot_stat.modts as u64
207 } else {
208 now_ms()
209 };
210 // Avoid collisions when multiple block zones share the same
211 // createdts (test fixtures, second-precision rounding, etc.):
212 // bump the timestamp by 1ms until the archive zone is unique.
213 loop {
214 let candidate = agent_archive_zone(def_id, archive_ts);
215 let occupied = matches!(
216 filestore.stat(&candidate, SNAPSHOT_FILE),
217 Ok(Some(_))
218 );
219 if !occupied {
220 break;
221 }
222 archive_ts += 1;
223 }
224 let archive_zone = agent_archive_zone(def_id, archive_ts);
225 if let Err(e) = write_zone_file(filestore, &archive_zone, SNAPSHOT_FILE, &snapshot_bytes) {
226 tracing::warn!(
227 block_id = %block.oid,
228 definition_id = %def_id,
229 error = %e,
230 "agent_session migration: archive write failed"
231 );
232 stats.failures += 1;
233 continue;
234 }
235 archives.push(snapshot_bytes);
236 stats.archives_written += 1;
237 }
238
239 // 3) Seed `:current` for each definition from its
240 // most-recently-modified per-block snapshot. If a `:current`
241 // zone is already populated (a prior run, or live data), skip — we
242 // don't want to overwrite it.
243 for (def_id, (_modts, bytes)) in per_def_latest {
244 let current_zone = agent_current_zone(&def_id);
245 let already = matches!(
246 filestore.stat(¤t_zone, SNAPSHOT_FILE),
247 Ok(Some(f)) if f.size > 0
248 );
249 if already {
250 continue;
251 }
252 match write_zone_file(filestore, ¤t_zone, SNAPSHOT_FILE, &bytes) {
253 Ok(()) => {
254 stats.current_zones_seeded += 1;
255 }
256 Err(e) => {
257 tracing::warn!(
258 definition_id = %def_id,
259 error = %e,
260 "agent_session migration: current-zone seed failed"
261 );
262 stats.failures += 1;
263 }
264 }
265 }
266
267 // Write the marker — evidence for m0000's pre-framework stamping and
268 // the doctor, never consulted by this function (see its doc comment).
269 if let Err(e) = std::fs::write(&marker_path, b"v1\n") {
270 tracing::warn!(
271 marker = %marker_path.display(),
272 error = %e,
273 "agent_session migration: marker write failed"
274 );
275 }
276
277 tracing::info!(
278 blocks_scanned = stats.blocks_scanned,
279 archives_written = stats.archives_written,
280 archives_already_present = stats.archives_already_present,
281 current_zones_seeded = stats.current_zones_seeded,
282 skipped_no_snapshot = stats.skipped_no_snapshot,
283 failures = stats.failures,
284 "agent_session migration: complete"
285 );
286
287 Ok(stats)
288}