1use serde_json::Value;
2use std::io::Write;
3use std::path::{Path, PathBuf};
4
5mod lock;
6mod resume;
7mod switch;
8
9#[cfg(test)]
10pub(super) use lock::LOCK_GUARD_FILE;
11pub use lock::{MigrationLock, LOCK_FILE};
12pub use switch::{Phase, Recovery, SwitchState, PHASES};
13
14pub const STATE_FILE: &str = "migration-state.json";
26
27pub const STATE_TEMP_FILE: &str = "migration-state.json.tmp";
32
33pub const STATE_FORMAT_VERSION: u32 = 3;
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
58#[serde(tag = "stage", rename_all = "snake_case")]
59pub enum CollectionProgress {
60 Facts {
62 cursor: Option<u64>,
64 },
65 Edges,
67 Complete,
69}
70
71impl CollectionProgress {
72 fn may_follow(self, previous: Self) -> bool {
83 match (previous, self) {
84 (Self::Facts { cursor: before }, Self::Facts { cursor: after }) => {
85 match (before, after) {
86 (Some(before), Some(after)) => after >= before,
87 (Some(_), None) => false,
88 (None, _) => true,
89 }
90 }
91 (Self::Facts { .. } | Self::Edges, Self::Edges)
92 | (Self::Edges | Self::Complete, Self::Complete) => true,
93 _ => false,
94 }
95 }
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
106pub struct MigrationState {
107 pub format_version: u32,
109 pub phase: Phase,
111 pub source_path: PathBuf,
113 pub source_fingerprint: String,
115 pub target_model: String,
117 pub target_dimension: usize,
119 pub progress: std::collections::BTreeMap<String, CollectionProgress>,
124 pub embedder_witness: Option<String>,
138}
139
140impl MigrationState {
141 pub fn may_resume(
152 &self,
153 source_path: &Path,
154 source_fingerprint: &str,
155 target_model: &str,
156 target_dimension: usize,
157 ) -> Result<(), String> {
158 validate_current_state_version(self)?;
159 validate_state_semantics(self)?;
160 validate_migration_identity(
161 source_path,
162 source_fingerprint,
163 target_model,
164 target_dimension,
165 )
166 .map_err(|reason| {
167 format!("cannot resume against an invalid requested identity: {reason}")
168 })?;
169 resume::validate_resume_source(self, source_path)?;
170 resume::validate_resume_fingerprint(self, source_fingerprint)?;
171 resume::validate_resume_model(self, target_model)?;
172 resume::validate_resume_dimension(self, target_dimension)?;
173 Ok(())
174 }
175
176 pub fn read(workspace: &Path) -> Result<Option<Self>, String> {
187 let Some(value) = read_state_value(workspace)? else {
188 return Ok(None);
189 };
190 let version = serialized_state_version(&value)?;
191 validate_serialized_state_version(version)?;
192 let state: Self = serde_json::from_value(value).map_err(|err| {
193 format!("{STATE_FILE} is version {version} but does not parse: {err}")
194 })?;
195 validate_state_semantics(&state)
196 .map_err(|reason| format!("{STATE_FILE} has invalid semantics: {reason}"))?;
197 Ok(Some(state))
198 }
199
200 pub fn write(&self, workspace: &Path, lock: &MigrationLock) -> Result<(), String> {
212 lock.verify_workspace(workspace)?;
213 validate_current_state_version(self)?;
214 validate_state_semantics(self)?;
215 let existing = validate_existing_state(workspace)?;
216 validate_state_update(existing.as_ref(), self)?;
217 let body = serde_json::to_string_pretty(self)
218 .map_err(|err| format!("cannot serialise the migration state: {err}"))?;
219 lock.verify_workspace(workspace)?;
223 commit_state_with(
224 workspace,
225 body.as_bytes(),
226 promote_state,
227 state_durability_barrier,
228 )
229 }
230}
231
232fn read_state_value(workspace: &Path) -> Result<Option<Value>, String> {
233 let path = workspace.join(STATE_FILE);
234 let raw = match std::fs::read_to_string(path) {
235 Ok(raw) => raw,
236 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
237 Err(err) => return Err(format!("cannot read {STATE_FILE}: {err}")),
238 };
239 serde_json::from_str(&raw)
240 .map(Some)
241 .map_err(|err| format!("{STATE_FILE} is not readable JSON: {err}"))
242}
243
244fn serialized_state_version(value: &Value) -> Result<u64, String> {
245 value
246 .get("format_version")
247 .and_then(Value::as_u64)
248 .ok_or_else(|| format!("{STATE_FILE} carries no format_version"))
249}
250
251fn validate_serialized_state_version(version: u64) -> Result<(), String> {
252 if version == u64::from(STATE_FORMAT_VERSION) {
253 return Ok(());
254 }
255 let action = if version < u64::from(STATE_FORMAT_VERSION) {
256 "This older state predates per-collection rebuild progress; start a fresh diagnosis."
257 } else {
258 "Use the version that wrote it."
259 };
260 Err(format!(
261 "{STATE_FILE} is version {version} and this build requires version {STATE_FORMAT_VERSION}. Refusing incompatible migration semantics. {action}"
262 ))
263}
264
265fn validate_current_state_version(state: &MigrationState) -> Result<(), String> {
266 if state.format_version == STATE_FORMAT_VERSION {
267 return Ok(());
268 }
269 let action = if state.format_version < STATE_FORMAT_VERSION {
270 "This older state predates per-collection rebuild progress. Start a fresh diagnosis."
271 } else {
272 "Use the version that wrote it."
273 };
274 Err(format!(
275 "this migration state is version {} and this build requires version {}. \
276 Resuming across incompatible state semantics is unsafe. {action}",
277 state.format_version, STATE_FORMAT_VERSION,
278 ))
279}
280
281fn validate_state_semantics(state: &MigrationState) -> Result<(), String> {
282 validate_migration_identity(
283 &state.source_path,
284 &state.source_fingerprint,
285 &state.target_model,
286 state.target_dimension,
287 )?;
288 validate_progress_keys(state)?;
289 validate_phase_against_progress(state)?;
290 validate_embedder_witness(state)
291}
292
293fn validate_embedder_witness(state: &MigrationState) -> Result<(), String> {
296 let Some(witness) = &state.embedder_witness else {
297 return Ok(());
298 };
299 let digest = witness
300 .strip_prefix("sha256:")
301 .filter(|digest| digest.len() == 64)
302 .filter(|digest| {
303 digest
304 .bytes()
305 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
306 });
307 if digest.is_none() {
308 return Err(
309 "embedder_witness must be exactly 'sha256:' followed by 64 lowercase hexadecimal \
310 characters"
311 .to_owned(),
312 );
313 }
314 Ok(())
315}
316
317fn validate_progress_keys(state: &MigrationState) -> Result<(), String> {
319 for name in super::enumeration::AGENT_COLLECTIONS {
320 if !state.progress.contains_key(*name) {
321 return Err(format!(
322 "progress carries no entry for collection '{name}'; a resume would \
323 silently skip its rebuild"
324 ));
325 }
326 }
327 for name in state.progress.keys() {
328 if !super::enumeration::AGENT_COLLECTIONS.contains(&name.as_str()) {
329 return Err(format!(
330 "progress tracks '{name}', which is not an agent collection; this \
331 journal describes work nobody will do"
332 ));
333 }
334 }
335 Ok(())
336}
337
338fn validate_phase_against_progress(state: &MigrationState) -> Result<(), String> {
342 if state.phase == Phase::Prepared {
343 return Ok(());
344 }
345 for (name, progress) in &state.progress {
346 if *progress != CollectionProgress::Complete {
347 return Err(format!(
348 "phase {:?} asserts the rebuild is finished, but collection \
349 '{name}' stands at {progress:?}; the phase cannot leave \
350 {:?} while any collection is unfinished",
351 state.phase,
352 Phase::Prepared,
353 ));
354 }
355 }
356 Ok(())
357}
358
359fn validate_migration_identity(
360 source_path: &Path,
361 source_fingerprint: &str,
362 target_model: &str,
363 target_dimension: usize,
364) -> Result<(), String> {
365 if !source_path.is_absolute()
366 || source_path
367 .components()
368 .any(|component| matches!(component, std::path::Component::ParentDir))
369 {
370 return Err(
371 "source_path must be an absolute normalized path produced by diagnosis".to_owned(),
372 );
373 }
374 let digest = source_fingerprint
375 .strip_prefix("sha256-tree-v2:")
376 .filter(|digest| digest.len() == 64)
377 .filter(|digest| {
378 digest
379 .bytes()
380 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
381 });
382 if digest.is_none() {
383 return Err(
384 "source_fingerprint must be exactly 'sha256-tree-v2:' followed by 64 lowercase hexadecimal characters"
385 .to_owned(),
386 );
387 }
388 if target_model.trim().is_empty() {
389 return Err("target_model must not be empty".to_owned());
390 }
391 if target_dimension == 0 {
392 return Err("target_dimension must be greater than zero".to_owned());
393 }
394 Ok(())
395}
396
397fn validate_state_update(
398 existing: Option<&MigrationState>,
399 candidate: &MigrationState,
400) -> Result<(), String> {
401 let Some(existing) = existing else {
402 if candidate.phase != Phase::Prepared {
403 return Err(format!(
404 "a new migration journal must start at {:?}, not {:?}; refusing to invent skipped work",
405 Phase::Prepared,
406 candidate.phase
407 ));
408 }
409 return Ok(());
410 };
411
412 let immutable_drift = if existing.source_path != candidate.source_path {
413 Some(format!(
414 "source_path changed from '{}' to '{}'",
415 existing.source_path.display(),
416 candidate.source_path.display()
417 ))
418 } else if existing.source_fingerprint != candidate.source_fingerprint {
419 Some(format!(
420 "source_fingerprint changed from '{}' to '{}'",
421 existing.source_fingerprint, candidate.source_fingerprint
422 ))
423 } else if existing.target_model != candidate.target_model {
424 Some(format!(
425 "target_model changed from '{}' to '{}'",
426 existing.target_model, candidate.target_model
427 ))
428 } else if existing.target_dimension != candidate.target_dimension {
429 Some(format!(
430 "target_dimension changed from {} to {}",
431 existing.target_dimension, candidate.target_dimension
432 ))
433 } else if existing.embedder_witness != candidate.embedder_witness {
434 Some(format!(
435 "embedder_witness changed from {:?} to {:?} — either the embedder's \
436 output drifted under a stable model name, or the resolved regime \
437 flipped between runs; both make the replayed and the remaining \
438 batches incompatible",
439 existing.embedder_witness, candidate.embedder_witness
440 ))
441 } else {
442 None
443 };
444 if let Some(drift) = immutable_drift {
445 return Err(format!(
446 "refusing to rewrite migration identity: {drift}. Start a fresh migration instead"
447 ));
448 }
449 if !candidate.phase.may_follow(existing.phase) {
450 return Err(format!(
451 "refusing migration phase transition from {:?} to {:?}: journal updates may be idempotent or advance exactly one phase, never regress or skip work",
452 existing.phase, candidate.phase
453 ));
454 }
455 validate_progress_advance(existing, candidate)
456}
457
458fn validate_progress_advance(
463 existing: &MigrationState,
464 candidate: &MigrationState,
465) -> Result<(), String> {
466 for (name, after) in &candidate.progress {
467 let Some(before) = existing.progress.get(name) else {
468 continue;
469 };
470 if !after.may_follow(*before) {
471 return Err(format!(
472 "refusing progress regression on collection '{name}': the journal \
473 records {before:?} and the update asserts {after:?}; a rebuild \
474 journal may repeat or advance, never regress"
475 ));
476 }
477 }
478 Ok(())
479}
480
481fn validate_existing_state(workspace: &Path) -> Result<Option<MigrationState>, String> {
482 let path = workspace.join(STATE_FILE);
483 let metadata = match std::fs::symlink_metadata(&path) {
484 Ok(metadata) => metadata,
485 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
486 Err(err) => return Err(format!("cannot inspect existing {STATE_FILE}: {err}")),
487 };
488 if metadata.file_type().is_symlink() || !metadata.is_file() {
489 return Err(format!(
490 "refusing to replace {STATE_FILE}: {} is a symlink, directory, or special file",
491 path.display()
492 ));
493 }
494 MigrationState::read(workspace)?
495 .map(Some)
496 .ok_or_else(|| format!("{STATE_FILE} disappeared while it was being validated"))
497}
498
499pub(super) fn commit_state_with<P, B>(
500 workspace: &Path,
501 body: &[u8],
502 promote: P,
503 durability_barrier: B,
504) -> Result<(), String>
505where
506 P: FnOnce(&Path, &Path) -> std::io::Result<()>,
507 B: FnOnce(&Path, &Path) -> std::io::Result<()>,
508{
509 let temporary = workspace.join(STATE_TEMP_FILE);
510 let final_path = workspace.join(STATE_FILE);
511 let mut file = match std::fs::OpenOptions::new()
512 .write(true)
513 .create_new(true)
514 .open(&temporary)
515 {
516 Ok(file) => file,
517 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
518 return Err(format!(
519 "refusing to overwrite pre-existing {STATE_TEMP_FILE} at {}: it may be evidence of an interrupted state write; inspect and remove that exact file manually",
520 temporary.display()
521 ));
522 }
523 Err(err) => return Err(format!("cannot create {STATE_TEMP_FILE}: {err}")),
524 };
525
526 let write_result = (|| {
527 file.write_all(body)?;
528 file.flush()?;
529 file.sync_all()
530 })();
531 drop(file);
532 if let Err(err) = write_result {
533 return cleanup_uncommitted_temp(
534 &temporary,
535 format!("cannot write and sync {STATE_TEMP_FILE}: {err}"),
536 );
537 }
538
539 if let Err(err) = promote(&temporary, &final_path) {
540 return cleanup_uncommitted_temp(
541 &temporary,
542 format!("cannot atomically promote {STATE_TEMP_FILE} to {STATE_FILE}: {err}"),
543 );
544 }
545 durability_barrier(workspace, &final_path).map_err(|err| {
546 format!(
547 "{STATE_FILE} was replaced and is visible, but its durability could not be confirmed: {err}. Do not retry blindly; inspect the state before continuing"
548 )
549 })
550}
551
552fn cleanup_uncommitted_temp(temporary: &Path, primary: String) -> Result<(), String> {
553 match std::fs::remove_file(temporary) {
554 Ok(()) => Err(primary),
555 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Err(primary),
556 Err(err) => Err(format!(
557 "{primary}; additionally, cannot remove {}: {err}",
558 temporary.display()
559 )),
560 }
561}
562
563#[cfg(unix)]
564fn promote_state(temporary: &Path, final_path: &Path) -> std::io::Result<()> {
565 std::fs::rename(temporary, final_path)
569}
570
571#[cfg(windows)]
572fn promote_state(temporary: &Path, final_path: &Path) -> std::io::Result<()> {
573 atomicwrites::replace_atomic(temporary, final_path)
574}
575
576#[cfg(not(any(unix, windows)))]
577fn promote_state(_temporary: &Path, _final_path: &Path) -> std::io::Result<()> {
578 Err(std::io::Error::new(
579 std::io::ErrorKind::Unsupported,
580 "durable migration-state replacement is supported only on Unix and Windows",
581 ))
582}
583
584#[cfg(unix)]
585fn state_durability_barrier(workspace: &Path, _final_path: &Path) -> std::io::Result<()> {
586 std::fs::File::open(workspace)?.sync_all()
587}
588
589#[cfg(windows)]
590fn state_durability_barrier(_workspace: &Path, _final_path: &Path) -> std::io::Result<()> {
591 Ok(())
593}
594
595#[cfg(not(any(unix, windows)))]
596fn state_durability_barrier(_workspace: &Path, _final_path: &Path) -> std::io::Result<()> {
597 Err(std::io::Error::new(
598 std::io::ErrorKind::Unsupported,
599 "no durable migration-state barrier is defined for this platform",
600 ))
601}