Skip to main content

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(&current_zone, SNAPSHOT_FILE),
247            Ok(Some(f)) if f.size > 0
248        );
249        if already {
250            continue;
251        }
252        match write_zone_file(filestore, &current_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}