Skip to main content

agentmux_srv/migrations/
m0002_block_zones_v1.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::sync::Arc;
5use crate::backend::storage::store::Store;
6use crate::backend::storage::filestore::FileStore;
7use super::{Migration, MigrationContext, MigrationError, MigrationScope, VerifyOutcome};
8
9pub struct M0002BlockZonesV1;
10
11impl Migration for M0002BlockZonesV1 {
12    fn id(&self) -> &'static str { "0002_block_zones_v1" }
13    fn scope(&self) -> MigrationScope { MigrationScope::Channel }
14    fn description(&self) -> &'static str { "Migrate per-block agent session zones to per-agent zones" }
15
16    fn up(&self, ctx: &MigrationContext) -> Result<(), MigrationError> {
17        if !ctx.channel_store_path.exists() {
18            return Ok(());
19        }
20        let mstore = Arc::new(
21            Store::open(&ctx.channel_store_path)
22                .map_err(|e| MigrationError(format!("block_zones_v1: open mstore: {}", e)))?,
23        );
24        let filestore_path = ctx.data_dir.join("db").join("filestore.db");
25        let filestore = Arc::new(
26            FileStore::open(&filestore_path)
27                .map_err(|e| MigrationError(format!("block_zones_v1: open filestore: {}", e)))?,
28        );
29        // A scan that could not even read the blocks table is a failed
30        // migration, not an applied one (codex P1 on #3070).
31        crate::backend::agent_session::migrate_block_zones_v1(&mstore, &filestore, &ctx.data_dir)
32            .map_err(|e| MigrationError(format!("block_zones_v1: {}", e)))?;
33        Ok(())
34    }
35
36    /// Doctor check, content-based since Phase 2 of
37    /// SPEC_MIGRATION_SYSTEM_HARDENING_2026_08_03: the post-condition is
38    /// "no agent block still holds a snapshot whose agent `:current` zone is
39    /// empty" (`block_zones_look_incomplete`), read through the same
40    /// read-only connections `--verify` uses everywhere. The marker file is
41    /// reported as evidence only — Phase 1b's version of this check trusted
42    /// its presence, which is the F1 shape Phase 2 exists to remove.
43    fn verify(&self, ctx: &MigrationContext) -> VerifyOutcome {
44        if !ctx.channel_store_path.exists() {
45            return VerifyOutcome::Ok("no channel store on this data dir; nothing to migrate".into());
46        }
47        let filestore_path = ctx.data_dir.join("db").join("filestore.db");
48        if !filestore_path.exists() {
49            return VerifyOutcome::Ok("no filestore on this data dir; no block snapshots to migrate".into());
50        }
51        // Read-only openers, like every other verifier: no WAL pragma, no
52        // schema setup, no version stamp, works on a read-only backup
53        // (codex P2 on #3070).
54        let mstore = match Store::open_read_only(&ctx.channel_store_path) {
55            Ok(s) => s,
56            Err(e) => return VerifyOutcome::Error(format!("open channel store read-only: {}", e)),
57        };
58        let filestore = match FileStore::open_read_only(&filestore_path) {
59            Ok(f) => f,
60            Err(e) => return VerifyOutcome::Error(format!("open filestore read-only: {}", e)),
61        };
62        let marker = ctx.data_dir.join(crate::backend::agent_session::MIGRATION_MARKER_V1);
63        let marker_note = if marker.exists() { "marker present" } else { "marker absent" };
64        match crate::backend::agent_session::block_zones_look_incomplete(&mstore, &filestore) {
65            Ok(true) => VerifyOutcome::Mismatch(format!(
66                "an agent block still holds a snapshot whose agent :current zone is empty — recorded applied, zones not migrated ({})",
67                marker_note
68            )),
69            Ok(false) => VerifyOutcome::Ok(format!("every block snapshot has a populated agent :current zone ({})", marker_note)),
70            // Could not observe the blocks at all — that is not "complete".
71            Err(e) => VerifyOutcome::Error(format!("{} ({})", e, marker_note)),
72        }
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79    use crate::backend::agent_session::{agent_current_zone, MIGRATION_MARKER_V1};
80    use crate::backend::obj::{Block, MetaMapType};
81    use crate::backend::storage::filestore::{FileMeta, FileOpts, FileStore};
82
83    fn data_dir_with_orphaned_block() -> (tempfile::TempDir, MigrationContext) {
84        let dir = tempfile::tempdir().unwrap();
85        let data_dir = dir.path().join("data");
86        let db = data_dir.join("db");
87        std::fs::create_dir_all(&db).unwrap();
88        let ctx = MigrationContext {
89            home: dir.path().to_path_buf(),
90            data_dir: data_dir.clone(),
91            shared_store_path: dir.path().join("shared").join("store.db"),
92            channel_store_path: db.join("objects.db"),
93        };
94        let mstore = Store::open(&ctx.channel_store_path).unwrap();
95        let filestore = FileStore::open(&db.join("filestore.db")).unwrap();
96        let mut meta = MetaMapType::new();
97        meta.insert("view".to_string(), serde_json::json!("agent"));
98        meta.insert("agentId".to_string(), serde_json::json!("def-v"));
99        let mut block = Block {
100            oid: "block-v".to_string(),
101            parentoref: String::new(),
102            version: 1,
103            runtimeopts: None,
104            stickers: None,
105            meta,
106            subblockids: None,
107        };
108        mstore.insert(&mut block).unwrap();
109        filestore.make_file("block-v", "output.state.json", FileMeta::default(), FileOpts::default()).unwrap();
110        filestore.write_file("block-v", "output.state.json", br#"{"nodes":[]}"#).unwrap();
111        (dir, ctx)
112    }
113
114    #[test]
115    fn verify_is_content_based_and_reports_the_marker_only_as_a_note() {
116        let (_dir, ctx) = data_dir_with_orphaned_block();
117        // Orphaned snapshot, no marker: Mismatch.
118        match M0002BlockZonesV1.verify(&ctx) {
119            VerifyOutcome::Mismatch(m) => assert!(m.contains("marker absent"), "{m}"),
120            other => panic!("expected Mismatch, got {other:?}"),
121        }
122        // A stale marker changes nothing about the verdict.
123        std::fs::write(ctx.data_dir.join(MIGRATION_MARKER_V1), b"v1\n").unwrap();
124        match M0002BlockZonesV1.verify(&ctx) {
125            VerifyOutcome::Mismatch(m) => assert!(m.contains("marker present"), "{m}"),
126            other => panic!("expected Mismatch, got {other:?}"),
127        }
128        // Once :current is populated the data is complete, marker or not.
129        let filestore = FileStore::open(&ctx.data_dir.join("db").join("filestore.db")).unwrap();
130        let zone = agent_current_zone("def-v");
131        filestore.make_file(&zone, "output.state.json", FileMeta::default(), FileOpts::default()).unwrap();
132        filestore.write_file(&zone, "output.state.json", br#"{"nodes":[]}"#).unwrap();
133        std::fs::remove_file(ctx.data_dir.join(MIGRATION_MARKER_V1)).unwrap();
134        assert!(matches!(M0002BlockZonesV1.verify(&ctx), VerifyOutcome::Ok(_)));
135    }
136
137    #[test]
138    fn verify_opens_both_stores_read_only_and_never_creates_them() {
139        let dir = tempfile::tempdir().unwrap();
140        let data_dir = dir.path().join("data");
141        std::fs::create_dir_all(data_dir.join("db")).unwrap();
142        let ctx = MigrationContext {
143            home: dir.path().to_path_buf(),
144            data_dir: data_dir.clone(),
145            shared_store_path: dir.path().join("shared").join("store.db"),
146            channel_store_path: data_dir.join("db").join("objects.db"),
147        };
148        assert!(matches!(M0002BlockZonesV1.verify(&ctx), VerifyOutcome::Ok(_)));
149        assert!(!ctx.channel_store_path.exists(), "verify must not create the channel store");
150        // A channel store without a filestore: still nothing to migrate, and
151        // no filestore appears.
152        drop(Store::open(&ctx.channel_store_path).unwrap());
153        assert!(matches!(M0002BlockZonesV1.verify(&ctx), VerifyOutcome::Ok(_)));
154        assert!(!data_dir.join("db").join("filestore.db").exists(), "verify must not create the filestore");
155    }
156
157    #[test]
158    fn verify_reports_an_unreadable_blocks_table_as_an_error_not_complete() {
159        let (_dir, ctx) = data_dir_with_orphaned_block();
160        // Drop the blocks table: the scan cannot run, so "complete" cannot be
161        // claimed (codex P1 on #3070).
162        let conn = rusqlite::Connection::open(&ctx.channel_store_path).unwrap();
163        conn.execute_batch("DROP TABLE db_block;").unwrap();
164        drop(conn);
165        assert!(matches!(M0002BlockZonesV1.verify(&ctx), VerifyOutcome::Error(_)));
166    }
167}