from __future__ import annotations import hashlib import json import shutil import time from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass, field from enum import StrEnum from pathlib import Path from typing import TYPE_CHECKING, Protocol from redstack.domain.errors import ArtifactContractError, DomainError from redstack.pipelines.offline.graph import OfflineExecutionGraph from redstack.pipelines.offline.registry import ( OfflineArtifactRegistry, ValidationOutcome, ) if TYPE_CHECKING: from redstack.pipelines.offline.context import OfflinePipelineContext __all__: tuple[str, ...] = ( "StageStatus", "ArtifactPayload", "StageResult", "StageCallable", "StageReceipt", "OfflinePipelineRunner", "RunnerError", ) class RunnerError(DomainError): """A runner-level orchestration failure (checkpoint corruption, IO).""" class StageStatus(StrEnum): """The terminal disposition of a stage in a single ``run()`` invocation.""" EXECUTED = "executed" SKIPPED = "skipped" QUARANTINED = "quarantined" FAILED = "failed" # An artifact payload is the parsed, registry-validatable view of one produced # artifact: for JSON/YAML the decoded mapping; for npy/parquet the header/schema # metadata the stage reports. The runner never re-reads bytes to validate — # stages hand back exactly what the registry validator inspects, plus the raw # bytes' sha256 the stage already computed when it wrote the artifact. @dataclass(frozen=True, slots=True) class ArtifactPayload: """One artifact produced by a stage, ready for registry validation + hashing. Attributes: key: The registry key this artifact satisfies. sha256: The hex sha256 the stage computed over the written bytes (the value that lands in ``MANIFEST.json``). The runner trusts the stage's hash for the receipt; O17 re-streams it during packaging. bytes_written: Size on disk, recorded for the manifest/report. validation_view: The structural payload the registry validator inspects (decoded mapping or extracted npy/parquet metadata). """ key: str sha256: str bytes_written: int validation_view: Mapping[str, object] @dataclass(frozen=True, slots=True) class StageResult: """What a stage callable returns: its produced artifacts + metrics. Attributes: artifacts: The artifacts the stage produced this run, each registry-keyed. metrics: Deterministic stage metrics for the build report (census stats, honeypot counts, NDCG, cluster quality, …). Must be JSON-serializable and reproducible; excludes wall-clock. """ artifacts: tuple[ArtifactPayload, ...] metrics: Mapping[str, object] = field(default_factory=dict) class StageCallable(Protocol): """The injected stage contract the runner orchestrates (Part 1). A stage is ``f(ctx, upstream) -> StageResult``: pure given the context's ports + injected entropy/clock. ``upstream`` exposes the receipts of the stage's dependencies so a stage can read its inputs' identities (hashes) without the runner knowing stage internals. ``stage_version`` is the stage's own semver, part of its staleness key — bump it to force recompute on a behavior change. """ @property def stage_id(self) -> str: ... @property def stage_version(self) -> str: ... def __call__( self, ctx: OfflinePipelineContext, upstream: Mapping[str, StageReceipt], ) -> StageResult: ... @dataclass(frozen=True, slots=True) class StageReceipt: stage_id: str stage_version: str staleness_key: str artifact_hashes: Mapping[str, str] artifact_bytes: Mapping[str, int] metrics: Mapping[str, object] = field(default_factory=dict) status: StageStatus = StageStatus.EXECUTED wall_ms: float = 0.0 def reproducible_view(self) -> dict[str, object]: """Return the deterministic subset for checkpoint serialization.""" return { "stage_id": self.stage_id, "stage_version": self.stage_version, "staleness_key": self.staleness_key, "artifact_hashes": dict(sorted(self.artifact_hashes.items())), "artifact_bytes": dict(sorted(self.artifact_bytes.items())), "metrics": _canonical_jsonable(self.metrics), } def _canonical_jsonable(value: object) -> object: """Round-trip ``value`` through canonical JSON to normalize for hashing.""" return json.loads(json.dumps(value, sort_keys=True, default=str)) def _sha256_hex(text: str) -> str: """Return the hex sha256 of a UTF-8 string.""" return hashlib.sha256(text.encode("utf-8")).hexdigest() MonotonicClock = Callable[[], float] @dataclass(frozen=True, slots=True) class OfflinePipelineRunner: """Executes an offline plan with resume, checkpointing, and quarantine. Stateless across invocations except for the on-disk checkpoint store under ``ctx.checkpoints_root``; two runs with identical inputs produce identical receipts' reproducible views (the audit ``wall_ms`` differs and is ignored). Attributes: graph: The dependency DAG providing topo order + downstream closure. registry: The artifact catalog; every produced artifact is validated against it before its receipt is written. continue_on_error: If ``True``, a *non-critical* stage failure is flagged and the build continues; a critical failure always aborts. clock: Injected monotonic clock for stage timing (audit only). Defaults to ``time.perf_counter``, the highest-resolution monotonic clock available, consistent with :class:`~redstack.observability .timing.StageTimer` and the online pipeline's per-stage timing. """ graph: OfflineExecutionGraph registry: OfflineArtifactRegistry continue_on_error: bool = False clock: MonotonicClock = time.perf_counter # ------------------------------------------------------------------ # # Staleness # # ------------------------------------------------------------------ # def _staleness_key( self, stage: StageCallable, upstream: Mapping[str, StageReceipt], config_hash: str, ) -> str: """Compute ``hash(input_hashes + stage_version + config_slice)`` (Part 11). Input hashes are the produced-artifact sha256s of *this stage's direct dependencies*, taken in deterministic id order, so the key is stable and order-independent. The ``config_slice`` is the context's canonical ``config_hash`` — a config change invalidates dependent stages. """ parts: list[str] = [stage.stage_id, stage.stage_version, config_hash] for dep_id in sorted(self.graph.dependencies(stage.stage_id)): receipt = upstream.get(dep_id) if receipt is None: # Dependency produced no receipt (e.g. skipped non-critical # branch): contribute a stable sentinel so the key still differs # from a present-dependency key. parts.append(f"{dep_id}=∅") continue for key, digest in sorted(receipt.artifact_hashes.items()): parts.append(f"{dep_id}:{key}={digest}") return _sha256_hex("\u0000".join(parts)) # ------------------------------------------------------------------ # # Checkpoint persistence # # ------------------------------------------------------------------ # def _checkpoint_path(self, ctx: OfflinePipelineContext, stage_id: str) -> Path: return ctx.checkpoints_root / f"{stage_id}.receipt.json" def _load_checkpoint( self, ctx: OfflinePipelineContext, stage_id: str ) -> StageReceipt | None: """Load a persisted receipt, or ``None`` if absent/corrupt. Checkpoint corruption is treated as "recompute" (Part 11: "checkpoint corruption → recompute"), not a hard failure: a malformed sidecar simply yields ``None`` so the stage re-runs. """ path = self._checkpoint_path(ctx, stage_id) if not path.is_file(): return None try: raw = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return None if not isinstance(raw, dict): return None try: return StageReceipt( stage_id=str(raw["stage_id"]), stage_version=str(raw["stage_version"]), staleness_key=str(raw["staleness_key"]), artifact_hashes=dict(raw["artifact_hashes"]), artifact_bytes=dict(raw["artifact_bytes"]), metrics=dict(raw.get("metrics", {})), status=StageStatus.SKIPPED, wall_ms=0.0, ) except (KeyError, TypeError, ValueError): return None def _persist_checkpoint( self, ctx: OfflinePipelineContext, receipt: StageReceipt ) -> None: """Atomically persist a receipt's reproducible view as a sidecar.""" ctx.checkpoints_root.mkdir(parents=True, exist_ok=True) path = self._checkpoint_path(ctx, receipt.stage_id) tmp = path.with_suffix(".json.tmp") payload = json.dumps( receipt.reproducible_view(), sort_keys=True, separators=(",", ":"), ensure_ascii=False, ) try: tmp.write_text(payload, encoding="utf-8") tmp.replace(path) except OSError as exc: msg = f"failed to persist checkpoint for {receipt.stage_id!r}: {exc}" raise RunnerError(msg) from exc # ------------------------------------------------------------------ # # Validation + quarantine # # ------------------------------------------------------------------ # def _validate_outputs(self, result: StageResult) -> None: """Validate every produced artifact against the registry; raise on breach. A validator rejection is an invariant breach (Offline-testing §8: "validated against the registry before manifesting"), so it raises ``ArtifactContractError`` naming the offending key + reason — the hard fail path. The happy path returns ``None``. """ for payload in result.artifacts: outcome: ValidationOutcome = self.registry.validate( payload.key, payload.validation_view ) if not outcome.ok: msg = ( f"artifact {payload.key!r} failed registry validation: " f"{outcome.reason}" ) raise ArtifactContractError(msg) def _quarantine( self, ctx: OfflinePipelineContext, stage_id: str ) -> None: """Move a failed stage's partial outputs out of the manifested tree. Best-effort: any artifact whose relative path is owned by ``stage_id`` is moved under ``quarantine_root//`` so it can never be hashed into ``MANIFEST.json`` (Part 11: "partial/failed outputs are quarantined"). Missing files are ignored — a stage may have failed before writing anything. """ owned = [ spec for spec in self.registry.specs if stage_id in spec.owner_stages ] if not owned: return dest_root = ctx.quarantine_root / stage_id dest_root.mkdir(parents=True, exist_ok=True) for spec in owned: src = ctx.artifacts_root / spec.relative_path if not src.exists(): continue dest = dest_root / Path(spec.relative_path).name try: shutil.move(str(src), str(dest)) except OSError: # Quarantine is defence-in-depth; never let it mask the original # stage error. A failed move leaves the partial file, which O17's # required-key/coherence pass will still reject. continue # ------------------------------------------------------------------ # # Plan + run # # ------------------------------------------------------------------ # def plan(self, only: Sequence[str] | None = None) -> tuple[str, ...]: """Return the deterministic execution order, optionally restricted. With ``only`` given, the plan is the topo-ordered downstream closure of those stages (their transitive dependents), so a forced re-run of an upstream stage drags its closure (Part 11). Without it, the full topo order is returned. Cycle detection already happened at graph construction. """ full = self.graph.topo_order() if only is None: return full closure = self.graph.downstream_closure(only) return tuple(sid for sid in full if sid in closure) def run( self, ctx: OfflinePipelineContext, stages: Mapping[str, StageCallable], *, force: Sequence[str] | None = None, ) -> dict[str, StageReceipt]: """Execute the plan, skipping up-to-date stages, and return receipts. Args: ctx: The immutable build context (ports + roots + provenance). stages: ``stage_id -> callable`` for every stage in the plan. A plan stage absent from this mapping is a programming error. force: Stage ids to recompute regardless of checkpoint freshness; their downstream closure is forced stale too. Returns: ``stage_id -> StageReceipt`` for every planned stage (executed, skipped, or — under ``continue_on_error`` — flagged failed). Raises: RunnerError: a planned stage has no registered callable, or checkpoint IO failed. ArtifactContractError: a produced artifact failed registry validation. Exception: re-raised from a *critical* stage after quarantine. """ order = self.plan() forced = self.graph.downstream_closure(force) if force else frozenset() receipts: dict[str, StageReceipt] = {} reran: set[str] = set() for stage_id in order: stage = stages.get(stage_id) if stage is None: msg = f"no stage callable registered for planned stage {stage_id!r}" raise RunnerError(msg) upstream = { dep: receipts[dep] for dep in self.graph.dependencies(stage_id) if dep in receipts } staleness_key = self._staleness_key(stage, upstream, ctx.config_hash) checkpoint = self._load_checkpoint(ctx, stage_id) upstream_reran = any( dep in reran for dep in self.graph.dependencies(stage_id) ) is_forced = stage_id in forced is_fresh = ( checkpoint is not None and checkpoint.staleness_key == staleness_key and checkpoint.stage_version == stage.stage_version and not upstream_reran and not is_forced ) if is_fresh and checkpoint is not None: receipts[stage_id] = checkpoint # status == SKIPPED continue receipts[stage_id] = self._execute_stage( ctx, stage, upstream, staleness_key ) if receipts[stage_id].status is StageStatus.EXECUTED: reran.add(stage_id) return receipts def _execute_stage( self, ctx: OfflinePipelineContext, stage: StageCallable, upstream: Mapping[str, StageReceipt], staleness_key: str, ) -> StageReceipt: """Run one stage, validate + checkpoint on success, quarantine on failure. Encapsulates the per-stage failure policy: validator breach and critical stage exceptions propagate (after quarantine); a non-critical failure under ``continue_on_error`` is captured as a ``FAILED`` receipt and the build continues. """ started = self.clock() try: result = stage(ctx, upstream) self._validate_outputs(result) except ArtifactContractError: self._quarantine(ctx, stage.stage_id) raise except Exception: self._quarantine(ctx, stage.stage_id) node = self.graph.node(stage.stage_id) if node.critical or not self.continue_on_error: raise return StageReceipt( stage_id=stage.stage_id, stage_version=stage.stage_version, staleness_key=staleness_key, artifact_hashes={}, artifact_bytes={}, metrics={}, status=StageStatus.FAILED, wall_ms=(self.clock() - started) * 1000.0, ) receipt = StageReceipt( stage_id=stage.stage_id, stage_version=stage.stage_version, staleness_key=staleness_key, artifact_hashes={a.key: a.sha256 for a in result.artifacts}, artifact_bytes={a.key: a.bytes_written for a in result.artifacts}, metrics=result.metrics, status=StageStatus.EXECUTED, wall_ms=(self.clock() - started) * 1000.0, ) self._persist_checkpoint(ctx, receipt) return receipt