oc_chat
Loading...
Searching...
No Matches
pipeline.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/registry/regenerate/pipeline.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/regenerate/test_pipeline.py"
6"""
7The 7-step ``regenerate`` orchestration (Plan 142 P1.4, §5.3).
8
9Purpose
10-------
11Drive scan -> parse/load -> local schema -> canonicalisation-check ->
12``source_hash`` -> cross-record -> temp-DB build + derive-verify +
13verification-stamp publish-gate + WAL-safe atomic swap, returning a result that
14carries the §10 envelope. A later step never begins until the earlier one
15passes; a failure carries the §10 hash-field nullability for its stage.
16
17Responsibilities
18----------------
19- ``regenerate(source_dir, db_path, ...)`` — run the pipeline, build the
20 worktree-local store, never touching the shared main-repo registry.
21
22Diagnostics
23-----------
24Domain: OC_CHAT.MAIN
25Levels:
26 L2 - pipeline lifecycle
27 L3 - per-step progress
28 L4 - swap/verify details
29
30Contracts
31---------
32- Idempotent: identical inputs -> identical source/logical hashes (no
33 hash-contributing row changes on a no-op).
34- Verify-before-USE: the built DB is never swapped/hashed/read until the
35 derive-verify passes; the stamp is written before the atomic swap.
36
37Dependencies
38------------
39- oc_diagnostics (structured debug logging via _dbg)
40- oc_chat.registry.backends.sqlite (SQLiteBackend and RegistrySeed to initialise the temp DB)
41- oc_chat.registry.regenerate.build (build_roadmap inserts the roadmap rows into the temp DB)
42- oc_chat.registry.regenerate.cross_record (validate_cross_record, the step-6 cross-record checks)
43- oc_chat.registry.regenerate.derive_verify (version dedup, derivation and the step-7 derive-verify gate)
44- oc_chat.registry.regenerate.envelope (RegenerateError, the REGEN_* and SRC_STRICT_TYPE codes and build_envelope)
45- oc_chat.registry.regenerate.lock (acquire and release the regenerate lock around the run)
46- oc_chat.registry.regenerate.verified_projection (write and assert the verification stamp before the swap)
47- oc_chat.registry.source.canonical (record loading, frontmatter split, YAML feature guard and the canonicalisation check)
48- oc_chat.registry.source.errors (SourceError caught for the failure envelope, SourceValidationError raised on a promoted item's strict-type failure and SourceScanError to tell the scan stage apart)
49- oc_chat.registry.source.hashing (raw-source-tree, source and logical-state hashes)
50- oc_chat.registry.source.promoted_item (PromotedItem model for promoted item records)
51- oc_chat.registry.source.scanner (scan_source_tree, step 1 of the pipeline)
52- oc_chat.registry.store_scope (stamp_authored_projection so live commands refuse the built store)
53- pydantic (ValidationError from PromotedItem validation)
54- yaml (safe_load of promoted item frontmatter)
55"""
56from __future__ import annotations
57
58import gc
59import hashlib
60import os
61import sqlite3
62import uuid
63from dataclasses import dataclass, field
64from pathlib import Path
65from typing import Any, Optional
66
67import yaml
68from pydantic import ValidationError
69
70from oc_diagnostics import _dbg
71
72from oc_chat.registry.backends.sqlite import RegistrySeed, SQLiteBackend
73from oc_chat.registry.regenerate.build import build_roadmap
74from oc_chat.registry.regenerate.cross_record import validate_cross_record
76 derive_versions,
77 dedup_version_records,
78 verify_versions,
79)
80from oc_chat.registry.regenerate import lock
82 REGEN_BUILD_FAILED,
83 REGEN_SCOPE_CONFLICT,
84 REGEN_SOURCE_MUTATED,
85 REGEN_UNSUPPORTED_BACKEND,
86 SRC_STRICT_TYPE,
87 RegenerateError,
88 build_envelope,
89)
91 assert_verified_projection,
92 write_verification_stamp,
93)
95 _assert_no_yaml_features,
96 format_source_tree,
97 load_record,
98 split_record_text,
99)
100from oc_chat.registry.source.errors import SourceError, SourceValidationError
102 logical_state_hash,
103 raw_source_tree_hash,
104 source_hash,
105)
106from oc_chat.registry.source.scanner import scan_source_tree
107from oc_chat.registry.source.promoted_item import PromotedItem
108from oc_chat.registry.store_scope import stamp_authored_projection
109
110_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
111
112#: Runtime-only tables whose FK violations are tolerated on an authored rebuild
113#: (Option A, §5.3 — the references are live-store integrity, not source).
114_RUNTIME_TABLES = frozenset({"agents", "rounds", "plans", "operation_log"})
115_ROADMAP_TABLES = (
116 "roadmap_render_sections", "roadmap_item_dependencies", "roadmap_item_tracks",
117 "roadmap_items_v3", "roadmap_phases", "roadmap_tracks", "roadmap_versions",
118 "roadmap_eras",
119)
120
121
122@dataclass
124 ok: bool
125 envelope: dict[str, Any]
126 db_path: Optional[Path] = None
127 source_hash: Optional[str] = None
128 logical_state_hash: Optional[str] = None
129 semantic_hash: Optional[str] = None
130 records: int = 0
131 #: R5: the canonical surface for lock reclamations this run (Codex-v6-P2 /
132 #: Fable-H9) — empty on the common no-reclaim path.
133 lock_events: list["lock.LockReclaimEvent"] = field(default_factory=list)
134 error: Optional[BaseException] = field(default=None, repr=False)
135
136
137def _load_promoted(scanned: Any) -> tuple[PromotedItem, str]:
138 raw = scanned.path.read_text(encoding="utf-8")
139 frontmatter, body = split_record_text(raw)
140 # [OP-5] parse-safety parity: run the §3.2 YAML event-scan on the promoted
141 # load path exactly as the top-level ``load_record`` does — no bare
142 # ``safe_load`` (a forbidden alias/anchor/tag in a promoted file is rejected).
143 _assert_no_yaml_features(frontmatter, scanned.rel_path)
144 data = yaml.safe_load(frontmatter) or {}
145 try:
146 model = PromotedItem.model_validate(data)
147 except ValidationError as error:
149 f"{scanned.rel_path}: promoted-item validation failed: {error}",
150 code=SRC_STRICT_TYPE,
151 records=[scanned.rel_path],
152 ) from error
153 if model.id != scanned.stem:
155 f"{scanned.rel_path}: stem {scanned.stem!r} != id {model.id!r}",
156 code=SRC_STRICT_TYPE,
157 records=[scanned.rel_path],
158 )
159 return model, body
160
161
162def _categorize(records: list) -> dict[str, list]:
163 categorized: dict[str, Any] = {
164 "manifest": None, "eras": [], "tracks": [], "versions": [],
165 "phases": [], "items": [], "promoted": [], "framing": [], "notes": [],
166 }
167 for scanned in records:
168 if scanned.kind == "promoted_item":
169 model, body = _load_promoted(scanned)
170 categorized["promoted"].append((scanned, model, body))
171 continue
172 model, body = load_record(scanned)
173 if scanned.kind == "manifest":
174 categorized["manifest"] = model
175 elif scanned.kind == "era":
176 categorized["eras"].append(model)
177 elif scanned.kind == "track":
178 categorized["tracks"].append(model)
179 elif scanned.kind == "version":
180 categorized["versions"].append(model)
181 elif scanned.kind == "framing":
182 # [NEW·V24] source-only projection (no store rows); validated (anchors)
183 # in cross-record + rendered as generated framing (§3.3 / §6).
184 categorized["framing"].append(model)
185 elif scanned.kind == "phase":
186 categorized["phases"].append((model, body))
187 elif scanned.kind == "roadmap_item":
188 categorized["items"].append((model, body))
189 elif scanned.kind == "note":
190 categorized["notes"].append((model, body))
191 return categorized
192
193
194def _init_temp_db(temp_path: Path) -> None:
195 backend = SQLiteBackend(temp_path)
196 backend.initialize(
198 workspace_id=str(uuid.uuid4()),
199 registry_id=str(uuid.uuid4()),
200 workspace_root=str(temp_path.parent),
201 git_common_dir=str(temp_path.parent / ".git"),
202 registry_path=str(temp_path),
203 checkpoint_path=str(temp_path.parent / ".option_c" / "checkpoint.json"),
204 operator_slot="operator-source",
205 hmac_key_id=str(uuid.uuid4()),
206 secret_fingerprint=hashlib.sha256(b"authored-projection").hexdigest(),
207 app_version="regenerate",
208 )
209 )
210
211
212def _clear_roadmap_tables(conn: sqlite3.Connection) -> None:
213 for table in _ROADMAP_TABLES:
214 conn.execute(f"DELETE FROM {table}")
215
216
217def _assert_source_foreign_keys(conn: sqlite3.Connection) -> None:
218 """Run foreign_key_check, tolerating Option A runtime-table references."""
219 for table, _rowid, referenced, _fkid in conn.execute("PRAGMA foreign_key_check").fetchall():
220 if referenced in _RUNTIME_TABLES:
221 continue # Option A: runtime FKs not enforced on an authored rebuild
222 # @invariant: inv.intra_source_fk_resolves (§5.3.1 R — AC §8.32; the intra-source FK
223 # resolution backstop. Option A tolerates runtime-table FKs (the X rows) above.)
224 raise RegenerateError(
225 f"foreign_key_check failed on {table} -> {referenced}",
226 code=REGEN_BUILD_FAILED,
227 records=[str(table)],
228 )
229
230
232 categorized: dict[str, list],
233 db_path: Path,
234 computed_source_hash: str,
235 source_dir: Path,
236 expected_raw_hash: str,
237) -> str:
238 """Step 7: build temp DB -> derive-verify -> stamp -> hash -> WAL-safe swap."""
239 # A same-version roadmap+product changelog pair (agreement-validated in step 6)
240 # collapses to one roadmap_versions row before derive/build/verify (§3.2 / AC §8.38).
241 categorized = {**categorized, "versions": dedup_version_records(categorized.get("versions", []))}
242 temp_path = db_path.with_name(db_path.name + ".regen-tmp.sqlite")
243 for suffix in ("", "-wal", "-shm"):
244 _best_effort_unlink(str(temp_path) + suffix)
245 _init_temp_db(temp_path)
246 derived = derive_versions(categorized.get("versions", []))
247 conn = sqlite3.connect(str(temp_path))
248 try:
249 conn.execute("PRAGMA foreign_keys = OFF")
251 stamp_authored_projection(conn) # refuse live commands (§5.3.1 / AC §8.57)
252 build_roadmap(conn, categorized, derived)
253 verify_versions(conn, categorized.get("versions", [])) # publish-gate
254 computed_logical_hash = logical_state_hash(conn)
255 write_verification_stamp(
256 conn, source_hash=computed_source_hash, logical_state_hash=computed_logical_hash
257 )
258 assert_verified_projection(conn) # must pass before swap
259 # OCT-LINT: disable=no_hardcoded_secrets reason="Placeholder seed values + table names are not secrets"
260 conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
261 finally:
262 conn.close()
263 gc.collect()
264 # R3 (§5.3 validate-before-swap): run integrity_check + foreign_key_check on
265 # the TEMP DB *before* os.replace, so a corrupt/inconsistent build never
266 # lands over the prior good DB. On failure we raise and the swap never runs,
267 # leaving the prior DB + stamps intact.
268 verify_temp = sqlite3.connect(str(temp_path))
269 try:
270 if verify_temp.execute("PRAGMA integrity_check").fetchone()[0] != "ok":
271 raise RegenerateError(
272 "integrity_check failed on temp DB before swap", code=REGEN_BUILD_FAILED
273 )
274 _assert_source_foreign_keys(verify_temp)
275 finally:
276 verify_temp.close()
277 gc.collect()
278 # Best-effort sidecar cleanup (§5.3: tolerate Windows transient locks post-checkpoint).
279 for suffix in ("-wal", "-shm"):
280 _best_effort_unlink(str(db_path) + suffix)
281 _best_effort_unlink(str(temp_path) + suffix)
282 # R5 pre-swap TOCTOU re-check (§5.3): re-hash the raw source tree and abort
283 # *before* the swap if source/ mutated since the step-1 scan — preserving the
284 # prior DB + stamps. The raw re-hash necessarily re-walks the file bytes; what
285 # is forbidden (N-b) is a second scan/parse pass, not this byte re-hash. Pinned
286 # to raw_source_tree_hash (not source_hash/semantic) so a canonical-equivalent
287 # byte edit or a newly-added invalid file is caught as the retryable
288 # REGEN_SOURCE_MUTATED, not an arbitrary source-validation error.
289 if raw_source_tree_hash(source_dir) != expected_raw_hash:
290 raise RegenerateError(
291 "source/ mutated during regenerate (raw source-tree hash changed "
292 "since the pre-build scan)",
293 code=REGEN_SOURCE_MUTATED,
294 hint="re-run regenerate on a quiescent source tree",
295 )
296 os.replace(str(temp_path), str(db_path))
297 # Re-open post-swap is confirmation-only now (the fail-closed gate ran above).
298 verify = sqlite3.connect(str(db_path))
299 try:
300 if verify.execute("PRAGMA integrity_check").fetchone()[0] != "ok":
301 raise RegenerateError("integrity_check failed after swap", code=REGEN_BUILD_FAILED)
302 finally:
303 verify.close()
304 gc.collect()
305 return computed_logical_hash
306
307
308def _best_effort_unlink(path: str) -> None:
309 """Unlink ``path`` tolerating Windows transient locks (§5.3, post-checkpoint)."""
310 try:
311 Path(path).unlink(missing_ok=True)
312 except OSError:
313 pass
314
315
316def regenerate(
317 source_dir: Path,
318 db_path: Path,
319 *,
320 backend: str = "sqlite",
321 scope: str = "authored",
322 lock_path: Optional[Path] = None,
323 force: bool = False,
324) -> RegenerateResult:
325 """Run the 7-step pipeline; build the worktree-local store at ``db_path``.
326
327 ``lock_path`` (R5): the regenerate lock file. The CLI (and any caller wanting
328 same-worktree mutual exclusion) MUST pass the explicit workspace lock path
329 (``<workspace>/.option_c/orchestration/regenerate.lock``, independent of
330 ``--db``). ``lock_path=None`` is a **test/library compatibility default only**
331 — resolved by the side-effect-free rule ``db_path.parent / "regenerate.lock"``
332 (never source_dir-derived, never workspace discovery); it must never silently
333 become the production contract (Codex-R04-P1). ``force`` reclaims a stale lock
334 (logged as a ``LockReclaimEvent``; NEVER skips validation/derive-verify).
335 """
336 func = "regenerate"
337 source_dir = Path(source_dir)
338 db_path = Path(db_path)
339 raw_hash: Optional[str] = None
340 src_hash: Optional[str] = None
341 if backend != "sqlite":
342 error = RegenerateError(
343 f"unsupported backend {backend!r}; v1 ships only sqlite",
344 code=REGEN_UNSUPPORTED_BACKEND,
345 )
346 return RegenerateResult(False, build_envelope(error), error=error)
347 if scope != "authored":
348 # R4 (§5.3 fail-loud, operator 2026-07-05): --scope runtime|all is no
349 # longer silently accepted-and-ignored — refuse with the typed code
350 # (exit 2 via _EXIT_BY_CODE, remapped from the vacated class 4).
351 error = RegenerateError(
352 f"--scope {scope!r} is not supported; regenerate rebuilds only the "
353 "authored projection",
354 code=REGEN_SCOPE_CONFLICT,
355 hint="drop --scope or pass --scope authored",
356 )
357 return RegenerateResult(False, build_envelope(error), error=error)
358 resolved_lock = Path(lock_path) if lock_path is not None else db_path.parent / "regenerate.lock"
359 try:
360 handle = lock.acquire(resolved_lock, force=force)
361 except RegenerateError as error: # REGEN_LOCK_HELD (live-or-inconclusive holder)
362 return RegenerateResult(False, build_envelope(error), error=error)
363 lock_events = [handle.reclaim_event] if handle.reclaim_event is not None else []
364 try:
365 # Steps 1-5 (scan -> parse -> local -> canon-check -> source_hash).
366 records = scan_source_tree(source_dir)
367 raw_hash = raw_source_tree_hash(source_dir)
368 categorized = _categorize(records)
369 format_source_tree(records, check=True) # step 4 canonicalisation check
370 src_hash = source_hash(records)
371 # Step 6 cross-record.
372 validate_cross_record(categorized)
373 # Step 7 build + derive-verify + stamp + pre-swap TOCTOU re-check + swap.
374 logical_hash = _build_and_publish(categorized, db_path, src_hash, source_dir, raw_hash)
375 except (SourceError, RegenerateError) as error:
376 stage = _stage_for(error)
377 envelope = build_envelope(
378 error,
379 raw_source_tree_hash=None if stage == "scan" else raw_hash,
380 source_hash=src_hash if stage in ("cross_record", "build") else None,
381 )
382 return RegenerateResult(False, envelope, error=error, lock_events=lock_events)
383 finally:
384 lock.release(handle)
385 _dbg("OC_CHAT.MAIN", func, "regenerate ok", 2)
386 envelope = build_envelope(
387 None, raw_source_tree_hash=raw_hash, source_hash=src_hash,
388 logical_state_hash=logical_hash,
389 )
390 return RegenerateResult(
391 True, envelope, db_path=db_path, source_hash=src_hash,
392 logical_state_hash=logical_hash, records=len(records),
393 lock_events=lock_events,
394 )
395
396
397def _stage_for(error: BaseException) -> str:
398 from oc_chat.registry.source.errors import SourceScanError
399
400 if isinstance(error, SourceScanError):
401 return "scan"
402 if isinstance(error, RegenerateError):
403 return "build"
404 return "cross_record"
None _assert_source_foreign_keys(sqlite3.Connection conn)
Definition pipeline.py:217
dict[str, list] _categorize(list records)
Definition pipeline.py:162
None _init_temp_db(Path temp_path)
Definition pipeline.py:194
str _build_and_publish(dict[str, list] categorized, Path db_path, str computed_source_hash, Path source_dir, str expected_raw_hash)
Definition pipeline.py:237
str _stage_for(BaseException error)
Definition pipeline.py:397
None _clear_roadmap_tables(sqlite3.Connection conn)
Definition pipeline.py:212
tuple[PromotedItem, str] _load_promoted(Any scanned)
Definition pipeline.py:137