oc_chat
Loading...
Searching...
No Matches
runner.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/registry/source_migrations/runner.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/test_source_migrate_runner.py"
6"""
7The destructive ``roadmap source migrate`` runner (Plan 142m) — pipeline steps
83-13.
9
10Purpose
11-------
12Execute the pinned, ordered migrate pipeline over a discovered workspace's own
13source tree: git-worktree + clean-tree preconditions, the root-level unresolved-
14journal refusal, the lstat-level input path-integrity guard, whole-tree version
15classification, migration-module discovery + ABI validation + chain resolution +
16the whole-chain determinism self-check + terminal-stamp validation, the output
17path-integrity pass (path-stability), the dual-volume disk preflight, the per-run
18JSONL preimage journal published BEFORE the first source write, per-file atomic
19temp+``os.replace`` with a TOCTOU re-compare + ``planned_postimage`` anchor, and
20the ``planned_postimage``-guarded auto-rollback folded from the DISK journal. Every
21refusal fails closed with ZERO writes; a caught post-write failure auto-rolls-back.
22
23Steps 0-2 (semver-shape validation, root resolution, lock acquire/release) live in
24the CLI handler (``cli/source.py``); this runner owns steps 3-13 and raises the
25``MigrateError`` refusal class (routed to exit 2 via ``_EXIT_BY_CODE``) — or, for a
26genuinely unexpected non-IO bug, re-raises after attempting rollback (the disclosed
27backstop residual).
28
29Responsibilities
30----------------
31- Run the ordered pipeline (steps 3-13) with the pinned first-refusal semantics.
32- Discover + ABI-validate migration modules; resolve + execute the migration chain.
33- Journal every write-set preimage, write atomically, and auto-roll-back on failure.
34
35Contracts
36---------
37- Every refusal fails closed with ZERO source writes (fail-closed everywhere).
38- The runner NEVER hand-edits a generated view and NEVER writes under
39 ``registry/source_migrations/`` (the journal is gitignored runtime state).
40- Every expected ``OSError``/``PermissionError`` is wrapped at-site to a durable
41 ``SRC_*`` code; only a genuinely unexpected non-IO bug reaches the backstop.
42
43Diagnostics
44-----------
45Domain: OC_CHAT.MAIN
46Levels:
47 L1 - SYSTEM/SECURITY-class refusals + the backstop (override=True)
48 L2 - journal open/close, migration start/end, rollback start/end
49 L3 - per-record classification, per-path write/rollback, VALIDATION refusals
50 L4 - module load
51
52Dependencies
53------------
54- oc_diagnostics (structured debug logging via _dbg)
55- oc_chat.registry.source.scanner (scan_source_tree - the candidate records)
56- oc_chat.registry.source.canonical (load_record, format_record_text and format_source_tree - parse, re-emit and canonical check)
57- oc_chat.registry.source.hashing (raw_source_tree_hash and source_hash - the tree hashes before the run and after rollback)
58- oc_chat.registry.source_migrations.abi (SourceMigration and MigrationResult - validating migrations and their output)
59- oc_chat.registry.source_migrations.journal (the per-run preimage journal writer and reader)
60- oc_chat.registry.source_migrations.errors (MigrateError - the refusal class)
61- oc_chat.registry.clock (RealClock - the timestamp in the run id)
62- oc_chat.registry.regenerate.envelope (the SRC_* refusal codes)
63- oc_chat.registry.source.errors (SourceError - a parse or canonical failure during classification)
64- oc_chat.registry.source.promoted_item (lazy; PromotedItem - the expected model class for promoted records)
65- option_c_registry_contracts (lazy; SOURCE_ENTITIES - the expected model class for every other record kind)
66"""
67from __future__ import annotations
68
69import hashlib
70import importlib
71import os
72import re
73import shutil
74import stat
75import subprocess
76import uuid
77from pathlib import Path
78from typing import Any, Callable, Optional
79
80from oc_diagnostics import _dbg
81
82from oc_chat.registry.clock import RealClock
84 SRC_JOURNAL_UNRESOLVED,
85 SRC_JOURNAL_WRITE_FAILED,
86 SRC_MIGRATION_INVALID,
87 SRC_MIGRATION_OUTPUT_INVALID,
88 SRC_MIGRATION_TARGET_INVALID,
89 SRC_MIGRATION_WRITE_FAILED,
90 SRC_NO_MIGRATION,
91 SRC_SOURCE_MUTATED,
92 SRC_TREE_DIRTY,
93 SRC_TREE_NOT_VERSIONED,
94 SRC_UNSAFE_PATH,
95)
96from oc_chat.registry.source.canonical import format_record_text, load_record
97from oc_chat.registry.source.errors import SourceError
98from oc_chat.registry.source.hashing import raw_source_tree_hash, source_hash
99from oc_chat.registry.source.scanner import scan_source_tree
100from oc_chat.registry.source_migrations import journal as journal_mod
102 MigrationResult,
103 SourceMigration,
104)
105from oc_chat.registry.source_migrations.errors import MigrateError
106
107_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
108
109#: Canonical SemVer core, ``re.fullmatch`` (NEVER ``re.match`` — prefix semantics
110#: accept ``1.2.03``/``1.2.3-alpha``): each component ``0|[1-9][0-9]*`` — no
111#: leading zeroes/signs/whitespace/prerelease/build (step 0).
112_TARGET_SEMVER_RE = re.compile(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)")
113#: The migration-module discovery allowlist (§8a): support/``_private``/dunder
114#: stems never match, so an unfiltered import can never pull ``abi``/``journal``/…
115#: (which expose no ``MIGRATION``) into discovery.
116DISCOVERY_STEM_RE = re.compile(
117 r"v(0|[1-9][0-9]*)_(0|[1-9][0-9]*)_(0|[1-9][0-9]*)_[A-Za-z0-9_]+"
118)
119_JOURNAL_RELATIVE = Path(".option_c") / "orchestration" / "source_migrations" / ".journal"
120_DISK_HEADROOM = 1.5
121
122
123# --- IO seams (monkeypatched by tests for ordering / failure injection) -------
124def _replace(src: Path, dst: Path) -> None:
125 """Atomic ``os.replace`` seam (overridable for ordering/failure injection)."""
126 os.replace(src, dst)
127
128
129def _fsync(fileobj: "object") -> None:
130 """fsync a file object (overridable seam)."""
131 os.fsync(fileobj.fileno()) # type: ignore[attr-defined]
132
133
134def _disk_usage(path: Path):
135 """``shutil.disk_usage`` seam (per-path monkeypatchable for the preflight);
136 returns the ``(total, used, free)`` named tuple."""
137 return shutil.disk_usage(str(path))
138
139
140def _new_run_id() -> str:
141 """A unique per-run journal id (timestamp + uuid) — fixed-injected in tests so
142 a version-only filename can never overwrite an unresolved recovery journal."""
143 stamp = RealClock().now_wall()
144 safe = re.sub(r"[^0-9A-Za-z]", "", stamp)
145 return f"{safe}-{uuid.uuid4().hex}"
146
147
148# --- semver -------------------------------------------------------------------
149def validate_target_semver(raw: Any) -> str:
150 """Step 0: validate the ``--to-schema-version`` SHAPE at handler entry —
151 canonical SemVer core via ``fullmatch``. Refuse a non-semver argument with
152 ``SRC_MIGRATION_TARGET_INVALID`` (all-null hashes; nothing examined)."""
153 func = "validate_target_semver"
154 if not isinstance(raw, str) or _TARGET_SEMVER_RE.fullmatch(raw) is None:
155 _dbg("OC_CHAT.MAIN", func, f"invalid target {raw!r}", 3)
156 raise MigrateError(
157 f"--to-schema-version {raw!r} is not a valid source-record semver",
158 code=SRC_MIGRATION_TARGET_INVALID,
159 # OCT-LINT: disable=no_hardcoded_secrets reason="SRC_* codes, semver regex, and hints are the spec contract"
160 hint="not a valid source-record semver (MAJOR.MINOR.PATCH)",
161 )
162 return raw
163
164
165def _version_tuple(version: str) -> tuple[int, ...]:
166 """Numeric per-field tuple comparison — never string compare (``0.10.0`` vs
167 ``0.2.0``). Pads to three components."""
168 parts = [int(component) for component in version.split(".")]
169 while len(parts) < 3:
170 parts.append(0)
171 return tuple(parts)
172
173
174# --- git precondition (steps 3-4) ---------------------------------------------
175def _git_status_porcelain(root: Path) -> Optional[str]:
176 """``git status --porcelain .`` under ``root``, or ``None`` if ``root`` is not
177 inside a git worktree / ``git`` is missing / ``git status`` failed. UNLIKE
178 ``format``, migrate treats EVERY ``None`` as fail-closed
179 ``SRC_TREE_NOT_VERSIONED`` (a rewrite whose rollback point cannot be PROVEN has
180 none)."""
181 try:
182 inside = subprocess.run(
183 ["git", "rev-parse", "--is-inside-work-tree"],
184 cwd=str(root), capture_output=True, text=True, check=False,
185 )
186 except (OSError, ValueError):
187 return None
188 if inside.returncode != 0 or inside.stdout.strip() != "true":
189 return None
190 result = subprocess.run(
191 ["git", "status", "--porcelain", "."],
192 cwd=str(root), capture_output=True, text=True, check=False,
193 )
194 if result.returncode != 0:
195 return None
196 return result.stdout
197
198
199# --- path integrity (step 6b) -------------------------------------------------
200def _is_reparse_point(lstat_result: os.stat_result) -> bool:
201 file_attribute_reparse_point = 0x400
202 attrs = getattr(lstat_result, "st_file_attributes", 0)
203 return bool(attrs & file_attribute_reparse_point)
204
205
206def _path_integrity_guard(root: Path, records: list) -> None:
207 """Step 6b: an ``lstat``-level pass (no content read) over every candidate —
208 fail closed ``SRC_UNSAFE_PATH`` on a symlink / junction / reparse-point /
209 hardlink / resolved-outside-root entry (a destructive writer requirement)."""
210 func = "_path_integrity_guard"
211 root_resolved = Path(root).resolve()
212 for scanned in records:
213 path = scanned.path
214 lstat_result = path.lstat()
215 reason: Optional[str] = None
216 if stat.S_ISLNK(lstat_result.st_mode):
217 reason = "symlink"
218 elif _is_reparse_point(lstat_result):
219 reason = "reparse-point/junction"
220 elif stat.S_ISREG(lstat_result.st_mode) and lstat_result.st_nlink > 1:
221 reason = "hardlink"
222 elif not path.resolve().is_relative_to(root_resolved):
223 reason = "resolves outside the source root"
224 if reason is not None:
225 _dbg("OC_CHAT.MAIN", func, f"unsafe path {scanned.rel_path}: {reason}", 1, override=True)
226 raise MigrateError(
227 f"unsafe candidate path {scanned.rel_path}: {reason}",
228 code=SRC_UNSAFE_PATH,
229 records=[scanned.rel_path],
230 hint="remove the symlink/junction/hardlink under source/ before migrate",
231 )
232
233
234# --- migration discovery (production default) ---------------------------------
236 """The directory production discovery scans — ``registry/source_migrations/``.
237 A seam (NOT the factory) so a production-namespace test can point discovery at
238 an isolated dir without polluting the shipped package."""
239 return Path(__file__).resolve().parent
240
241
242def _import_migration_module(path: Path) -> Any:
243 """Import a discovered migration file by spec (decoupled from the package
244 import machinery); any import-time exception propagates to the 8a trust
245 boundary and wraps to ``SRC_MIGRATION_INVALID``."""
246 import importlib.util
247
248 spec = importlib.util.spec_from_file_location(f"_oc_source_migration_{path.stem}", path)
249 if spec is None or spec.loader is None:
250 raise ImportError(f"cannot load migration module {path}")
251 module = importlib.util.module_from_spec(spec)
252 spec.loader.exec_module(module)
253 return module
254
255
257 """The default discovery seam: import ONLY module stems matching the allowlist
258 regex and collect their ``MIGRATION`` attribute. Support modules never match
259 the regex, so the shipped empty-scaffolding path discovers ZERO migrations."""
260 func = "production_migration_provider"
261 package_dir = _production_package_dir()
262 raw_migrations: list[Any] = []
263 for entry in sorted(package_dir.glob("*.py")):
264 match = DISCOVERY_STEM_RE.fullmatch(entry.stem)
265 if match is None:
266 continue
267 module = _import_migration_module(entry)
268 migration = getattr(module, "MIGRATION")
269 # Stem-consistency: a shipped module's declared target/domain MUST match
270 # its filename (a contradiction is a latent bug worth refusing — wrapped to
271 # SRC_MIGRATION_INVALID by the 8a trust boundary).
272 stem_version = ".".join(match.group(1, 2, 3))
273 stem_domain = entry.stem.split("_", 3)[3]
274 declared_target = getattr(migration, "target_schema_version", None)
275 declared_domain = getattr(migration, "domain", None)
276 if declared_target != stem_version or declared_domain != stem_domain:
277 raise ValueError(
278 f"migration module {entry.stem} declares target/domain "
279 f"({declared_target}/{declared_domain}) contradicting its filename "
280 f"({stem_version}/{stem_domain})"
281 )
282 raw_migrations.append(migration)
283 _dbg("OC_CHAT.MAIN", func, f"discovered {len(raw_migrations)} migration module(s)", 2)
284 return raw_migrations
285
286
287# --- chain resolution (step 8b) -----------------------------------------------
289 start_version: str, domain: str, target: str, migrations: list[SourceMigration]
290) -> list[list[SourceMigration]]:
291 """Every simple chain (strictly increasing version) from ``start_version`` to
292 ``target`` over the domain-matching validated migrations. Zero ⇒ no path; more
293 than one ⇒ ambiguous."""
294 start = _version_tuple(start_version)
295 goal = _version_tuple(target)
296 applicable = [m for m in migrations if m.domain == domain]
297 chains: list[list[SourceMigration]] = []
298
299 def _walk(current: tuple, path: list, visited: set) -> None:
300 if current == goal:
301 chains.append(list(path))
302 return
303 for migration in applicable:
304 produces = _version_tuple(migration.target_schema_version)
305 low = _version_tuple(migration.min_source_schema_version)
306 high = _version_tuple(migration.max_source_schema_version)
307 if low <= current <= high and produces > current and produces not in visited:
308 _walk(produces, path + [migration], visited | {produces})
309
310 _walk(start, [], {start})
311 return chains
312
313
314def _run_chain(chain: list[SourceMigration], model: Any) -> Any:
315 """Execute the resolved chain over ``model`` (value-level threading — hop N's
316 output model feeds hop N+1 directly, no re-serialize). Enforces the 142m
317 replace-only / single-entry / ``old_path is None`` ABI guard per hop
318 (``SRC_MIGRATION_INVALID``). Returns the terminal model. Migration-module
319 exceptions are wrapped by the 8c call site (the trust boundary)."""
320 current = model
321 for migration in chain:
322 result = migration.upgrade(current)
323 validated = MigrationResult.model_validate(result)
324 if len(validated.entries) != 1:
325 raise MigrateError(
326 f"replace-only ABI: expected exactly one entry, got {len(validated.entries)}",
327 code=SRC_MIGRATION_INVALID,
328 )
329 entry = validated.entries[0]
330 if entry.action != "replace":
331 raise MigrateError(
332 f"replace-only ABI: unsupported action {entry.action!r} (142m ships replace only)",
333 code=SRC_MIGRATION_INVALID,
334 )
335 if entry.old_path is not None:
336 raise MigrateError(
337 "replace-only ABI: old_path must be None for a replace",
338 code=SRC_MIGRATION_INVALID,
339 )
340 if entry.model is None:
341 raise MigrateError(
342 "replace-only ABI: a replace entry must carry a model",
343 code=SRC_MIGRATION_INVALID,
344 )
345 current = entry.model
346 return current
347
348
349def _terminal_entry(chain: list[SourceMigration], model: Any) -> Any:
350 """Re-run the chain capturing the TERMINAL hop's single entry (for the step-9
351 output path-integrity pass). Only the terminal output is written — intermediate
352 hop outputs are consumed via ``.model`` only (no re-serialize)."""
353 current = model
354 entry = None
355 for migration in chain:
356 validated = MigrationResult.model_validate(migration.upgrade(current))
357 entry = validated.entries[0]
358 current = entry.model
359 return entry
360
361
362def _model_class_for(scanned: Any) -> type:
363 """The record's KIND's current parsed model class (kind-stable check)."""
364 from option_c_registry_contracts import SOURCE_ENTITIES
365
366 from oc_chat.registry.source.promoted_item import PromotedItem
367
368 if scanned.kind == "promoted_item":
369 return PromotedItem
370 return SOURCE_ENTITIES[scanned.kind]
371
372
373# --- helpers ------------------------------------------------------------------
374def _sha256(data: bytes) -> str:
375 return hashlib.sha256(data).hexdigest()
376
377
378def _record_id(model: Any) -> str:
379 return (
380 getattr(model, "id", None)
381 or getattr(model, "slug", None)
382 or getattr(model, "track_code", None)
383 or "?"
384 )
385
386
387def _safe_unlink(path: Path) -> None:
388 try:
389 path.unlink(missing_ok=True)
390 except OSError:
391 pass
392
393
394def _wrap_module_failure(exc: Exception, records: list[str], raw: Optional[str],
395 src: Optional[str]) -> MigrateError:
396 """The migration-module trust boundary: ANY exception escaping module code
397 (import / ABI validation / determinism / execution) wraps to
398 ``SRC_MIGRATION_INVALID`` (never a runner traceback)."""
399 func = "_wrap_module_failure"
400 # OCT-LINT: disable=no_hardcoded_secrets reason="SRC_* codes, semver regex, and hints are the spec contract"
401 _dbg("OC_CHAT.MAIN", func, f"module failure -> SRC_MIGRATION_INVALID: {exc}", 3)
402 return MigrateError(
403 f"invalid migration module: {type(exc).__name__}: {exc}",
404 code=SRC_MIGRATION_INVALID,
405 records=records,
406 raw_source_tree_hash=raw,
407 source_hash=src,
408 hint="fix the discovered migration module (ABI / determinism / execution)",
409 )
410
411
412# --- the pipeline (steps 3-13) ------------------------------------------------
414 *,
415 source_root: Path,
416 workspace_root: Path,
417 target: str,
418 provider: Callable[[], list[Any]],
419 quiet: bool = False,
420) -> dict:
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
428 target_tuple = _version_tuple(target)
429 _dbg("OC_CHAT.MAIN", func, f"migrate -> {target} at {source_root}", 2)
430
431 # Steps 3-4: git worktree? clean tree?
432 status = _git_status_porcelain(source_root)
433 if status is None:
434 _dbg("OC_CHAT.MAIN", func, "not a git worktree", 2)
435 raise MigrateError(
436 f"{source_root} is not inside a git worktree; a schema rewrite needs a "
437 "rollback point",
438 code=SRC_TREE_NOT_VERSIONED,
439 hint="run migrate inside a git worktree so a rollback point exists",
440 )
441 if status.strip():
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)
444 raise MigrateError(
445 f"resolved source root {source_root} has uncommitted changes; refusing a "
446 "destructive migrate (no rollback point)",
447 code=SRC_TREE_DIRTY,
448 records=dirty,
449 hint="commit or stash changes under the resolved source root before 'migrate'",
450 )
451
452 # Step 5: no unresolved journal for this ROOT (any target, any header identity).
453 _refuse_on_unresolved_journal(journal_root, source_root)
454
455 # Step 6a: enumerate candidates (walk + classify, no content reads).
456 records = scan_source_tree(source_root)
457 # Step 6b: candidate path-integrity (lstat-level; raw tree never read yet).
458 _path_integrity_guard(source_root, records)
459
460 # Step 6c: raw hash + parse + canonical + per-candidate content sha.
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] = []
467 try:
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))
476 from oc_chat.registry.source.canonical import format_source_tree
477 format_source_tree(records, check=True)
478 src_hash = source_hash(records)
479 except SourceError as error:
480 # 6c parse/canonical failure: raw present, source null (existing nullability).
481 setattr(error, "raw_source_tree_hash", raw_hash)
482 setattr(error, "source_hash", None)
483 raise
484
485 # Step 7: VERSION classification only (no discovery). all==target -> no-op
486 # RIGHT HERE (structural idempotence, before any disk check).
487 write_set: list[tuple] = []
488 unchanged = 0
489 for scanned, model, body in loaded:
490 record_version = _version_tuple(model.schema_version)
491 if record_version == target_tuple:
492 unchanged += 1
493 continue
494 if record_version > target_tuple:
495 _dbg("OC_CHAT.MAIN", func, f"downgrade request {scanned.rel_path}", 3)
496 raise MigrateError(
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,
504 )
505 write_set.append((scanned, model, body))
506 if not write_set:
507 _dbg("OC_CHAT.MAIN", func, "no-op (all records == target)", 2)
508 return _success_payload(target, [], unchanged)
509
510 # Step 8a: discovery + per-module ABI validation (trust boundary).
511 try:
512 raw_migrations = provider()
513 migrations = [SourceMigration.model_validate(raw) for raw in raw_migrations]
514 except Exception as exc: # noqa: BLE001 — the trust boundary wraps EVERYTHING
515 raise _wrap_module_failure(exc, [], raw_hash, src_hash) from exc
516
517 # Step 8b: chain-resolve per write-set record over the VALIDATED module set.
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)
521 if not chains:
522 _dbg("OC_CHAT.MAIN", func, f"no chain for {scanned.rel_path}", 3)
523 raise MigrateError(
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,
530 )
531 if len(chains) > 1:
532 _dbg("OC_CHAT.MAIN", func, f"ambiguous chain for {scanned.rel_path}", 3)
533 raise MigrateError(
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,
541 )
542 resolved[scanned.rel_path] = chains[0]
543
544 # Step 8c: whole-chain determinism self-check + terminal-stamp validation.
545 outputs: dict[str, tuple] = {} # rel_path -> (model, canon_bytes, postimage_sha)
546 entries_by_path: dict[str, Any] = {} # rel_path -> the TERMINAL entry
547 for scanned, model, body in write_set:
548 chain = resolved[scanned.rel_path]
549 try:
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)
558 raise
559 except Exception as exc: # noqa: BLE001 — trust boundary (module execution)
560 raise _wrap_module_failure(exc, [scanned.rel_path], raw_hash, src_hash) from exc
561 if canon_first != canon_second:
562 raise MigrateError(
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,
567 )
568 if getattr(first, "schema_version", None) != target:
569 raise MigrateError(
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,
574 )
575 expected_cls = _model_class_for(scanned)
576 if not isinstance(first, expected_cls):
577 raise MigrateError(
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,
582 )
583 outputs[scanned.rel_path] = (first, canon_first, _sha256(canon_first))
584
585 # Step 9: output path-integrity pass (path-stability).
586 _validate_output_paths(source_root, write_set, entries_by_path, records, raw_hash, src_hash)
587
588 # Step 10: dual-volume disk preflight (>=1 write only).
589 run_id = _new_run_id()
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)
593
594 # Steps 11-13: journal + writes + rollback/resolve.
595 return _write_phase(
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,
599 )
600
601
602def _refuse_on_unresolved_journal(journal_root: Path, source_root: Path) -> None:
603 """Step 5: refuse to start on ANY unresolved published journal in this
604 workspace's ``.journal/`` — ANY target, ANY header identity (a mismatch means a
605 MOVED workspace; the hint names both identities)."""
606 func = "_refuse_on_unresolved_journal"
607 unresolved = journal_mod.scan_unresolved(journal_root)
608 if not unresolved:
609 return
610 offending = unresolved[0]
611 recorded_root = offending.source_root
612 _dbg("OC_CHAT.MAIN", func, f"unresolved journal {offending.path.name}", 1, override=True)
613 if recorded_root and Path(recorded_root) != source_root:
614 hint = (
615 f"journal was recorded for {recorded_root}; workspace appears moved — "
616 "inspect before archiving"
617 )
618 else:
619 hint = "resolve or archive the recovery journal (inspect it) before migrate"
620 raise MigrateError(
621 f"an unresolved recovery journal exists ({offending.path.name}); refusing to start",
622 code=SRC_JOURNAL_UNRESOLVED,
623 records=[str(offending.path)],
624 hint=hint,
625 journal_path=str(offending.path),
626 )
627
628
629def _validate_output_paths(source_root, write_set, entries_by_path, records,
630 raw_hash, src_hash) -> None:
631 """Step 9: every OUTPUT path is relative + normalized + under the source root +
632 path-stable (== the originating record's ``rel_path``) + non-duplicate + not an
633 overwrite of a non-candidate. Any violation ⇒ ``SRC_MIGRATION_OUTPUT_INVALID``,
634 ZERO writes (a distinct code from the input-tree ``SRC_UNSAFE_PATH``)."""
635 func = "_validate_output_paths"
636 root_resolved = Path(source_root).resolve()
637 candidate_rels = {scanned.rel_path for scanned in records}
638 seen: set[str] = set()
639 for scanned, model, body in write_set:
640 entry = entries_by_path[scanned.rel_path] # the TERMINAL (written) entry
641 output_path = entry.output_path
642 reason = _output_path_reason(output_path, scanned.rel_path, root_resolved,
643 candidate_rels, seen)
644 if reason is not None:
645 _dbg("OC_CHAT.MAIN", func, f"invalid output {output_path!r}: {reason}", 3)
646 raise MigrateError(
647 f"migration output path {output_path!r} is invalid: {reason}",
648 code=SRC_MIGRATION_OUTPUT_INVALID,
649 records=[scanned.rel_path],
650 hint="a migration output path must equal the record's own rel_path (142m)",
651 raw_source_tree_hash=raw_hash,
652 source_hash=src_hash,
653 )
654 seen.add(output_path)
655
656
657def _output_path_reason(output_path, rel_path, root_resolved, candidate_rels, seen):
658 if not isinstance(output_path, str) or output_path == "":
659 return "empty or non-string path"
660 if "\\" in output_path or output_path.startswith("/") or _is_absolute(output_path):
661 return "absolute or alternate-separator path"
662 parts = output_path.split("/")
663 if ".." in parts or "." in parts:
664 return "contains a '..'/'.' segment"
665 resolved = (root_resolved / output_path).resolve()
666 if not resolved.is_relative_to(root_resolved):
667 return "resolves outside the source root"
668 if output_path in seen:
669 return "duplicate output path"
670 if output_path != rel_path:
671 # A path-changing replace is a rename/create in disguise (leaves the old
672 # record behind / overwrites another candidate / escapes the allowlist).
673 return "path-changing replace (must equal the record's rel_path — path-stable)"
674 return None
675
676
677def _is_absolute(output_path: str) -> bool:
678 import ntpath
679 import posixpath
680
681 return ntpath.isabs(output_path) or posixpath.isabs(output_path)
682
683
684def _file_mode(path: Path) -> Optional[int]:
685 try:
686 return stat.S_IMODE(os.stat(path).st_mode)
687 except OSError:
688 return None
689
690
691def _disk_preflight(source_root, journal_root, header, write_set, candidate_len,
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"
697 preimages = [
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],
702 )
703 for scanned, _model, _body in write_set
704 ]
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)
708
709 largest_temp = max(len(outputs[scanned.rel_path][1]) for scanned, _m, _b in write_set)
710 net_growth = sum(
711 max(0, len(outputs[scanned.rel_path][1]) - candidate_len[scanned.rel_path])
712 for scanned, _m, _b in write_set
713 )
714 source_required = int((largest_temp + net_growth) * _DISK_HEADROOM)
715
716 journal_root.mkdir(parents=True, exist_ok=True)
717 journal_free = _disk_usage(journal_root).free
718 source_free = _disk_usage(source_root).free
719 same_volume = _same_volume(journal_root, source_root)
720
721 failed = (
722 journal_free < journal_required
723 or source_free < source_required
724 or (same_volume and source_free < journal_required + source_required)
725 )
726 if failed:
727 _dbg("OC_CHAT.MAIN", func, "disk preflight failed", 1, override=True)
728 raise MigrateError(
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,
734 )
735
736
737def _same_volume(path_a: Path, path_b: Path) -> bool:
738 try:
739 return os.stat(path_a).st_dev == os.stat(path_b).st_dev
740 except OSError:
741 return False
742
743
744def _success_payload(target, migrated, unchanged, journal_path=None,
745 journal_unresolved=False, hint=None) -> dict:
746 payload: dict = {
747 "ok": True,
748 "target_schema_version": target,
749 "records_migrated": migrated,
750 "records_unchanged": unchanged,
751 }
752 if journal_path is not None:
753 payload["journal_path"] = journal_path
754 if journal_unresolved:
755 payload["journal_unresolved"] = True
756 if hint is not None:
757 payload["hint"] = hint
758 return payload
759
760
761def _migrated_list(write_set, outputs, target) -> list[dict]:
762 """The SUCCESS payload ``records_migrated`` — ``from`` = the 6c scanned
763 ``schema_version``, ``to`` = the VALIDATED terminal output field (never the CLI
764 argument echoed back). Sorted by rel_path for determinism."""
765 migrated = []
766 for scanned, model, _body in sorted(write_set, key=lambda item: item[0].rel_path):
767 terminal = outputs[scanned.rel_path][0]
768 migrated.append({
769 "id": _record_id(model),
770 "from": model.schema_version,
771 "to": terminal.schema_version,
772 })
773 return migrated
774
775
776# --- steps 11-13: journal + writes + rollback/resolve -------------------------
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)
782
783 # Step 11: build preimages (re-read WRITE-SET + TOCTOU vs 6c sha) + publish
784 # BEFORE the first source write.
785 preimages = []
786 try:
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]:
791 raise MigrateError(
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",
795 )
796 preimages.append(journal_mod.build_preimage(rel, current, candidate_mode[rel]))
797 writer.build(preimages)
798 writer.publish()
799 except MigrateError:
800 writer.cleanup_partial() # S5-R11-P3-2: cleanup on the mutation exit too
801 raise
802 except OSError as exc:
803 writer.cleanup_partial()
804 raise MigrateError(
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,
809 ) from exc
810
811 _dbg("OC_CHAT.MAIN", func, f"journal published; migrating {len(write_set)} record(s)", 2)
812
813 # Step 12: per-file temp-write -> planned_postimage -> TOCTOU -> replace -> applied.
814 written: list[str] = []
815 in_flight: Optional[tuple] = None
816 try:
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]:
823 raise MigrateError(
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",
827 )
828 tmp = path.parent / f"{path.name}.{uuid.uuid4().hex}.tmp"
829 in_flight = (rel, postimage_sha)
830 try:
831 with open(tmp, "wb") as fileobj:
832 fileobj.write(_canon_bytes)
833 fileobj.flush()
834 _fsync(fileobj)
835 writer.planned_postimage(rel, postimage_sha) # durable anchor BEFORE replace
836 _replace(tmp, path)
837 written.append(rel)
838 _dbg("OC_CHAT.MAIN", func, f"wrote {rel}", 3)
839 writer.applied(rel)
840 except OSError as exc:
841 _safe_unlink(tmp)
842 raise MigrateError(
843 f"source write failed for {rel}",
844 code=SRC_MIGRATION_WRITE_FAILED, records=[rel],
845 ) from exc
846 finally:
847 _safe_unlink(tmp)
848 in_flight = None
849 except MigrateError as error:
851 error, writer, source_root, written, candidate_sha, in_flight, raw_hash, src_hash,
852 )
853 raise
854 except Exception: # noqa: BLE001 — the backstop: rollback, log L1, re-raise
855 _dbg("OC_CHAT.MAIN", func, "unexpected backstop failure — attempting rollback", 1,
856 override=True)
857 try:
858 outcome, _remaining = _rollback(writer, source_root, written, candidate_sha, in_flight)
859 if outcome == "complete":
860 writer.resolve(journal_mod.OUTCOME_ROLLED_BACK)
861 except Exception: # noqa: BLE001 — best-effort; the original error re-raises
862 pass
863 raise
864
865 # Step 13: clean completion -> terminal resolved event (binding) + archive.
866 migrated = _migrated_list(write_set, outputs, target)
867 journal_unresolved = False
868 hint = None
869 journal_path = None
870 try:
871 writer.resolve(journal_mod.OUTCOME_SUCCESS)
872 journal_path = str(writer.archive())
873 except OSError:
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)
881
882
883def _finalize_post_write_failure(error, writer, source_root, written, candidate_sha,
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":
894 # Recovery state: tree is partially migrated; hashes would mislead.
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,
900 override=True)
901 return
902 # outcome == "complete": resolve the journal (rolled_back) then decide hashes.
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 # rollback receipt: bytes restored
908 error.source_hash = src_hash
909 else:
910 # The recompute-and-assert failed: downgrade to rollback: failed.
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)
917 return
918 else:
919 # SRC_SOURCE_MUTATED (or any other): a foreign edit persists past rollback,
920 # so the tree is original-except-that-edit — NOT any hashable state.
921 error.raw_source_tree_hash = None
922 error.source_hash = None
923 _resolve_after_rollback(writer, error)
924
925
926def _resolve_after_rollback(writer, error) -> None:
927 """Append the terminal ``resolved`` (rolled_back) event; a resolve-append
928 failure NEVER changes the outcome — it surfaces as ``journal_unresolved``."""
929 try:
930 writer.resolve(journal_mod.OUTCOME_ROLLED_BACK)
931 error.journal_path = str(writer.archive())
932 except OSError:
933 error.journal_unresolved = True
934 error.journal_path = str(writer.final_path)
935 error.hint = (
936 (error.hint + " ; " if error.hint else "")
937 + "journal could not be marked resolved; the next migrate will refuse until "
938 "it is manually archived"
939 )
940
941
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)]``."""
949 func = "_rollback"
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] # the in-flight path whose event lines are missing
957 if planned is None:
958 continue # this path was never (about to be) replaced
959 targets.append((entry, planned))
960
961 def _freed(item: tuple) -> int:
962 entry, _planned = item
963 path = source_root / entry.rel_path
964 try:
965 current_len = len(path.read_bytes())
966 except OSError:
967 current_len = 0
968 return len(journal_mod.preimage_bytes_of(entry)) - current_len # frees most first
969
970 targets.sort(key=_freed)
971 remaining: list[tuple] = []
972 for entry, planned in targets:
973 rel = entry.rel_path
974 path = source_root / rel
975 preimage = journal_mod.preimage_bytes_of(entry)
976 preimage_sha = _sha256(preimage)
977 try:
978 current = path.read_bytes()
979 except OSError:
980 remaining.append((rel, None))
981 continue
982 current_sha = _sha256(current)
983 if current_sha == preimage_sha:
984 continue # already restored / crash-before-replace no-op (idempotent)
985 if current_sha != planned:
986 _dbg("OC_CHAT.MAIN", func, f"foreign edit on {rel} — refusing overwrite", 1,
987 override=True)
988 remaining.append((rel, current_sha))
989 continue
990 tmp = path.parent / f"{path.name}.{uuid.uuid4().hex}.rb.tmp"
991 try:
992 reverify = path.read_bytes() # rollback's own pre-replace TOCTOU re-verify
993 if _sha256(reverify) != planned:
994 remaining.append((rel, _sha256(reverify)))
995 _safe_unlink(tmp)
996 continue
997 with open(tmp, "wb") as fileobj:
998 fileobj.write(preimage)
999 fileobj.flush()
1000 _fsync(fileobj)
1001 _replace(tmp, path)
1002 file_mode = entry.preimage.get("file_mode")
1003 if file_mode is not None:
1004 try:
1005 os.chmod(path, file_mode) # best-effort (Windows: partial)
1006 except OSError:
1007 pass
1008 _dbg("OC_CHAT.MAIN", func, f"restored {rel}", 3)
1009 except OSError:
1010 remaining.append((rel, current_sha))
1011 finally:
1012 _safe_unlink(tmp)
1013 outcome = "failed" if remaining else "complete"
1014 _dbg("OC_CHAT.MAIN", func, f"rollback {outcome}", 2)
1015 return outcome, remaining
tuple[int,...] _version_tuple(str version)
Definition runner.py:165
dict _success_payload(target, migrated, unchanged, journal_path=None, journal_unresolved=False, hint=None)
Definition runner.py:745
list[list[SourceMigration]] _find_chains(str start_version, str domain, str target, list[SourceMigration] migrations)
Definition runner.py:290
None _disk_preflight(source_root, journal_root, header, write_set, candidate_len, candidate_mode, outputs, raw_hash, src_hash)
Definition runner.py:692
tuple _rollback(writer, source_root, written, candidate_sha, in_flight)
Definition runner.py:942
Any _terminal_entry(list[SourceMigration] chain, Any model)
Definition runner.py:349
MigrateError _wrap_module_failure(Exception exc, list[str] records, Optional[str] raw, Optional[str] src)
Definition runner.py:395
_output_path_reason(output_path, rel_path, root_resolved, candidate_rels, seen)
Definition runner.py:657
bool _is_reparse_point(os.stat_result lstat_result)
Definition runner.py:200
None _replace(Path src, Path dst)
Definition runner.py:124
None _path_integrity_guard(Path root, list records)
Definition runner.py:206
Any _run_chain(list[SourceMigration] chain, Any model)
Definition runner.py:314
dict _write_phase(*, source_root, target, target_journal_dir, header, write_set, outputs, candidate_sha, candidate_mode, unchanged, raw_hash, src_hash)
Definition runner.py:778
None _validate_output_paths(source_root, write_set, entries_by_path, records, raw_hash, src_hash)
Definition runner.py:630
Optional[int] _file_mode(Path path)
Definition runner.py:684
bool _is_absolute(str output_path)
Definition runner.py:677
dict run_migration(*, Path source_root, Path workspace_root, str target, Callable[[], list[Any]] provider, bool quiet=False)
Definition runner.py:420
None _resolve_after_rollback(writer, error)
Definition runner.py:926
Optional[str] _git_status_porcelain(Path root)
Definition runner.py:175
bool _same_volume(Path path_a, Path path_b)
Definition runner.py:737
list[dict] _migrated_list(write_set, outputs, target)
Definition runner.py:761
None _refuse_on_unresolved_journal(Path journal_root, Path source_root)
Definition runner.py:602
None _finalize_post_write_failure(error, writer, source_root, written, candidate_sha, in_flight, raw_hash, src_hash)
Definition runner.py:884