agentmux_srv/migrations/
m0002_block_zones_v1.rs1use 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 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 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 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 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 match M0002BlockZonesV1.verify(&ctx) {
119 VerifyOutcome::Mismatch(m) => assert!(m.contains("marker absent"), "{m}"),
120 other => panic!("expected Mismatch, got {other:?}"),
121 }
122 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 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 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 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}