416 workspace_root: Path,
418 provider: Callable[[], list[Any]],
421 """Run pipeline steps 3-13 over the discovered workspace's source tree. Returns
422 the SUCCESS payload; raises ``MigrateError`` (routed to exit 2) on any refusal.
423 The caller (handler) owns steps 0-2 (semver shape, root resolution, the lock)."""
424 func =
"run_migration"
425 source_root = Path(source_root).resolve()
426 journal_root = Path(workspace_root) / _JOURNAL_RELATIVE
427 target_journal_dir = journal_root / target
429 _dbg(
"OC_CHAT.MAIN", func, f
"migrate -> {target} at {source_root}", 2)
434 _dbg(
"OC_CHAT.MAIN", func,
"not a git worktree", 2)
436 f
"{source_root} is not inside a git worktree; a schema rewrite needs a "
438 code=SRC_TREE_NOT_VERSIONED,
439 hint=
"run migrate inside a git worktree so a rollback point exists",
442 dirty = sorted(line[3:].split(
" -> ")[-1]
for line
in status.splitlines()
if line.strip())
443 _dbg(
"OC_CHAT.MAIN", func, f
"dirty tree ({len(dirty)} paths)", 3)
445 f
"resolved source root {source_root} has uncommitted changes; refusing a "
446 "destructive migrate (no rollback point)",
449 hint=
"commit or stash changes under the resolved source root before 'migrate'",
456 records = scan_source_tree(source_root)
461 raw_hash: Optional[str] =
None
462 src_hash: Optional[str] =
None
463 candidate_sha: dict[str, str] = {}
464 candidate_len: dict[str, int] = {}
465 candidate_mode: dict[str, Optional[int]] = {}
466 loaded: list[tuple] = []
468 raw_hash = raw_source_tree_hash(source_root)
469 for scanned
in records:
470 model, body = load_record(scanned)
471 raw_bytes = scanned.path.read_bytes()
472 candidate_sha[scanned.rel_path] =
_sha256(raw_bytes)
473 candidate_len[scanned.rel_path] = len(raw_bytes)
474 candidate_mode[scanned.rel_path] =
_file_mode(scanned.path)
475 loaded.append((scanned, model, body))
477 format_source_tree(records, check=
True)
478 src_hash = source_hash(records)
479 except SourceError
as error:
481 setattr(error,
"raw_source_tree_hash", raw_hash)
482 setattr(error,
"source_hash",
None)
487 write_set: list[tuple] = []
489 for scanned, model, body
in loaded:
491 if record_version == target_tuple:
494 if record_version > target_tuple:
495 _dbg(
"OC_CHAT.MAIN", func, f
"downgrade request {scanned.rel_path}", 3)
497 f
"record {scanned.rel_path} is at {model.schema_version} > target {target}; "
498 "source migrations are one-way",
499 code=SRC_NO_MIGRATION,
500 records=[scanned.rel_path],
501 hint=
"source migrations are one-way; use git to reverse",
502 raw_source_tree_hash=raw_hash,
503 source_hash=src_hash,
505 write_set.append((scanned, model, body))
507 _dbg(
"OC_CHAT.MAIN", func,
"no-op (all records == target)", 2)
512 raw_migrations = provider()
513 migrations = [SourceMigration.model_validate(raw)
for raw
in raw_migrations]
514 except Exception
as exc:
518 resolved: dict[str, list[SourceMigration]] = {}
519 for scanned, model, body
in write_set:
520 chains =
_find_chains(model.schema_version, scanned.kind, target, migrations)
522 _dbg(
"OC_CHAT.MAIN", func, f
"no chain for {scanned.rel_path}", 3)
524 f
"no complete migration chain to {target} for {scanned.rel_path}",
525 code=SRC_NO_MIGRATION,
526 records=[scanned.rel_path],
527 hint=
"no source-migration path exists for the requested target",
528 raw_source_tree_hash=raw_hash,
529 source_hash=src_hash,
532 _dbg(
"OC_CHAT.MAIN", func, f
"ambiguous chain for {scanned.rel_path}", 3)
534 f
"ambiguous migration namespace: {len(chains)} complete chains to "
535 f
"{target} for {scanned.rel_path}",
536 code=SRC_MIGRATION_INVALID,
537 records=[scanned.rel_path],
538 hint=
"two migration paths reach the target; the namespace is ambiguous",
539 raw_source_tree_hash=raw_hash,
540 source_hash=src_hash,
542 resolved[scanned.rel_path] = chains[0]
545 outputs: dict[str, tuple] = {}
546 entries_by_path: dict[str, Any] = {}
547 for scanned, model, body
in write_set:
548 chain = resolved[scanned.rel_path]
550 first =
_run_chain(chain, load_record(scanned)[0])
551 second =
_run_chain(chain, load_record(scanned)[0])
552 canon_first = format_record_text(first, body).encode(
"utf-8")
553 canon_second = format_record_text(second, body).encode(
"utf-8")
554 entries_by_path[scanned.rel_path] =
_terminal_entry(chain, load_record(scanned)[0])
555 except MigrateError
as error:
556 setattr(error,
"raw_source_tree_hash", raw_hash)
557 setattr(error,
"source_hash", src_hash)
559 except Exception
as exc:
561 if canon_first != canon_second:
563 f
"migration determinism self-check failed for {scanned.rel_path}",
564 code=SRC_MIGRATION_INVALID, records=[scanned.rel_path],
565 hint=
"the migration is not deterministic for identical input",
566 raw_source_tree_hash=raw_hash, source_hash=src_hash,
568 if getattr(first,
"schema_version",
None) != target:
570 f
"migration does not stamp its declared target on {scanned.rel_path}",
571 code=SRC_MIGRATION_INVALID, records=[scanned.rel_path],
572 hint=
"the terminal output must carry schema_version == the target",
573 raw_source_tree_hash=raw_hash, source_hash=src_hash,
576 if not isinstance(first, expected_cls):
578 f
"terminal output for {scanned.rel_path} is not a {expected_cls.__name__}",
579 code=SRC_MIGRATION_INVALID, records=[scanned.rel_path],
580 hint=
"the terminal output must be the record's kind's current model",
581 raw_source_tree_hash=raw_hash, source_hash=src_hash,
583 outputs[scanned.rel_path] = (first, canon_first,
_sha256(canon_first))
590 header = journal_mod.header_dict(target, str(source_root), run_id)
591 _disk_preflight(source_root, journal_root, header, write_set, candidate_len,
592 candidate_mode, outputs, raw_hash, src_hash)
596 source_root=source_root, target=target, target_journal_dir=target_journal_dir,
597 header=header, write_set=write_set, outputs=outputs, candidate_sha=candidate_sha,
598 candidate_mode=candidate_mode, unchanged=unchanged, raw_hash=raw_hash, src_hash=src_hash,
692 candidate_mode, outputs, raw_hash, src_hash) -> None:
693 """Step 10: EXACT projected-journal bytes (not a scalar multiplier) + the
694 per-volume free-space bounds. Reached only with >=1 write; a low-disk no-op or
695 fail-closed classification never reaches here."""
696 func =
"_disk_preflight"
698 journal_mod.PreimageRecord(
699 rel_path=scanned.rel_path,
700 preimage_bytes=b
"\0" * candidate_len[scanned.rel_path],
701 file_mode=candidate_mode[scanned.rel_path],
703 for scanned, _model, _body
in write_set
705 postimage_shas = [outputs[scanned.rel_path][2]
for scanned, _m, _b
in write_set]
706 projected_journal = journal_mod.project_journal_bytes(header, preimages, postimage_shas)
707 journal_required = int(projected_journal * _DISK_HEADROOM)
709 largest_temp = max(len(outputs[scanned.rel_path][1])
for scanned, _m, _b
in write_set)
711 max(0, len(outputs[scanned.rel_path][1]) - candidate_len[scanned.rel_path])
712 for scanned, _m, _b
in write_set
714 source_required = int((largest_temp + net_growth) * _DISK_HEADROOM)
716 journal_root.mkdir(parents=
True, exist_ok=
True)
722 journal_free < journal_required
723 or source_free < source_required
724 or (same_volume
and source_free < journal_required + source_required)
727 _dbg(
"OC_CHAT.MAIN", func,
"disk preflight failed", 1, override=
True)
729 "insufficient free disk space for the migrate journal + source rewrite",
730 code=SRC_JOURNAL_WRITE_FAILED,
731 hint=
"no source writes were made; free disk space / fix permissions and retry",
732 raw_source_tree_hash=raw_hash,
733 source_hash=src_hash,
777def _write_phase(*, source_root, target, target_journal_dir, header, write_set,
778 outputs, candidate_sha, candidate_mode, unchanged, raw_hash, src_hash) -> dict:
779 func =
"_write_phase"
780 run_id = header[
"run_id"]
781 writer = journal_mod.JournalWriter(target_journal_dir, run_id, header)
787 for scanned, _model, _body
in write_set:
788 rel = scanned.rel_path
789 current = (source_root / rel).read_bytes()
790 if _sha256(current) != candidate_sha[rel]:
792 f
"{rel} was mutated between the scan and the pre-journal recompare",
793 code=SRC_SOURCE_MUTATED, records=[rel],
794 hint=
"a source file changed after the clean-tree check; re-run migrate",
796 preimages.append(journal_mod.build_preimage(rel, current, candidate_mode[rel]))
797 writer.build(preimages)
800 writer.cleanup_partial()
802 except OSError
as exc:
803 writer.cleanup_partial()
805 "the migration journal could not be built/published before the first write",
806 code=SRC_JOURNAL_WRITE_FAILED,
807 hint=
"no source writes were made; free disk space / fix permissions and retry",
808 raw_source_tree_hash=raw_hash, source_hash=src_hash,
811 _dbg(
"OC_CHAT.MAIN", func, f
"journal published; migrating {len(write_set)} record(s)", 2)
814 written: list[str] = []
815 in_flight: Optional[tuple] =
None
817 for scanned, _model, _body
in write_set:
818 rel = scanned.rel_path
819 path = source_root / rel
820 _canon_bytes, postimage_sha = outputs[rel][1], outputs[rel][2]
821 current = path.read_bytes()
822 if _sha256(current) != candidate_sha[rel]:
824 f
"{rel} was mutated before its os.replace (per-file TOCTOU)",
825 code=SRC_SOURCE_MUTATED, records=[rel],
826 hint=
"a source file changed after the clean-tree check; re-run migrate",
828 tmp = path.parent / f
"{path.name}.{uuid.uuid4().hex}.tmp"
829 in_flight = (rel, postimage_sha)
831 with open(tmp,
"wb")
as fileobj:
832 fileobj.write(_canon_bytes)
835 writer.planned_postimage(rel, postimage_sha)
838 _dbg(
"OC_CHAT.MAIN", func, f
"wrote {rel}", 3)
840 except OSError
as exc:
843 f
"source write failed for {rel}",
844 code=SRC_MIGRATION_WRITE_FAILED, records=[rel],
849 except MigrateError
as error:
851 error, writer, source_root, written, candidate_sha, in_flight, raw_hash, src_hash,
855 _dbg(
"OC_CHAT.MAIN", func,
"unexpected backstop failure — attempting rollback", 1,
858 outcome, _remaining =
_rollback(writer, source_root, written, candidate_sha, in_flight)
859 if outcome ==
"complete":
860 writer.resolve(journal_mod.OUTCOME_ROLLED_BACK)
867 journal_unresolved =
False
871 writer.resolve(journal_mod.OUTCOME_SUCCESS)
872 journal_path = str(writer.archive())
874 journal_unresolved =
True
875 journal_path = str(writer.final_path)
876 hint = (
"journal could not be marked resolved; the next migrate for this source "
877 "root (any target) will refuse until it is manually archived")
878 _dbg(
"OC_CHAT.MAIN", func,
"resolved-append failed (outcome unchanged)", 1, override=
True)
879 _dbg(
"OC_CHAT.MAIN", func, f
"migrate ok ({len(migrated)} migrated, {unchanged} unchanged)", 2)
880 return _success_payload(target, migrated, unchanged, journal_path, journal_unresolved, hint)
884 in_flight, raw_hash, src_hash) -> None:
885 """Populate the failure envelope's recovery fields after a caught post-first-
886 write failure: auto-rollback folded from the DISK journal, then set
887 ``rollback``/``journal_path``/``remaining_paths``/hashes per the failure-stage
888 table row for the primary code."""
889 func =
"_finalize_post_write_failure"
890 outcome, remaining =
_rollback(writer, source_root, written, candidate_sha, in_flight)
891 remaining_paths = [rel
for rel, _observed
in remaining]
892 error.rollback = outcome
893 if outcome ==
"failed":
895 error.raw_source_tree_hash =
None
896 error.source_hash =
None
897 error.remaining_paths = remaining_paths
898 error.journal_path = str(writer.final_path)
899 _dbg(
"OC_CHAT.MAIN", func, f
"rollback FAILED; {len(remaining_paths)} remaining", 1,
903 if error.code == SRC_MIGRATION_WRITE_FAILED:
904 restored_raw = raw_source_tree_hash(source_root)
905 restored_src = source_hash(scan_source_tree(source_root))
906 if restored_raw == raw_hash
and restored_src == src_hash:
907 error.raw_source_tree_hash = raw_hash
908 error.source_hash = src_hash
911 error.rollback =
"failed"
912 error.raw_source_tree_hash =
None
913 error.source_hash =
None
914 error.remaining_paths = written
915 error.journal_path = str(writer.final_path)
916 _dbg(
"OC_CHAT.MAIN", func,
"post-rollback hash mismatch -> failed", 1, override=
True)
921 error.raw_source_tree_hash =
None
922 error.source_hash =
None
942def _rollback(writer, source_root, written, candidate_sha, in_flight) -> tuple:
943 """``planned_postimage``-guarded auto-rollback, folded from the DISK journal
944 (a manual crash-recovery round-trip). For each written path: restore the
945 preimage ONLY if the on-disk bytes hash to the journalled ``planned_postimage``;
946 current == preimage ⇒ already-restored no-op; neither ⇒ a foreign edit — do NOT
947 overwrite (mark failed, keep the journal). Restores replay net-space-freeing-
948 first. Returns ``(outcome, remaining)`` where remaining = ``[(rel, observed)]``."""
950 _dbg(
"OC_CHAT.MAIN", func,
"rollback start", 2)
951 parsed = journal_mod.read_journal(writer.final_path)
952 targets: list[tuple] = []
953 for entry
in parsed.entries:
954 planned = entry.planned_postimage_sha
955 if planned
is None and in_flight
is not None and entry.rel_path == in_flight[0]:
956 planned = in_flight[1]
959 targets.append((entry, planned))
961 def _freed(item: tuple) -> int:
962 entry, _planned = item
963 path = source_root / entry.rel_path
965 current_len = len(path.read_bytes())
968 return len(journal_mod.preimage_bytes_of(entry)) - current_len
970 targets.sort(key=_freed)
971 remaining: list[tuple] = []
972 for entry, planned
in targets:
974 path = source_root / rel
975 preimage = journal_mod.preimage_bytes_of(entry)
976 preimage_sha =
_sha256(preimage)
978 current = path.read_bytes()
980 remaining.append((rel,
None))
983 if current_sha == preimage_sha:
985 if current_sha != planned:
986 _dbg(
"OC_CHAT.MAIN", func, f
"foreign edit on {rel} — refusing overwrite", 1,
988 remaining.append((rel, current_sha))
990 tmp = path.parent / f
"{path.name}.{uuid.uuid4().hex}.rb.tmp"
992 reverify = path.read_bytes()
993 if _sha256(reverify) != planned:
994 remaining.append((rel,
_sha256(reverify)))
997 with open(tmp,
"wb")
as fileobj:
998 fileobj.write(preimage)
1002 file_mode = entry.preimage.get(
"file_mode")
1003 if file_mode
is not None:
1005 os.chmod(path, file_mode)
1008 _dbg(
"OC_CHAT.MAIN", func, f
"restored {rel}", 3)
1010 remaining.append((rel, current_sha))
1013 outcome =
"failed" if remaining
else "complete"
1014 _dbg(
"OC_CHAT.MAIN", func, f
"rollback {outcome}", 2)
1015 return outcome, remaining