from __future__ import annotations import hashlib import json import os import tempfile from contextlib import suppress from pathlib import Path from typing import TYPE_CHECKING, Final from redstack.ports._types import ReportReceipt, RunReport from redstack.ports.run_report_sink import ReportWriteError class JsonRunReportSinkAdapter: """Single-use, deterministic JSON writer for a ``RunReport``. Constructed by the pipeline composition root with the output path; one :meth:`write` per run; no persistent handle. """ __slots__ = ("_output_path", "_report_schema_version", "_float_decimals") def __init__( self, output_path: Path, *, report_schema_version: str = "1.0", float_decimals: int = 6, ) -> None: """Bind the sink to its output target and serialization precision. Args: output_path: Resolved ``run_report.json`` target path. report_schema_version: Version stamped into the report so consumers can check compatibility. float_decimals: Fixed rounding precision for all serialized floats; pins byte-stable output. Must be non-negative. """ if float_decimals < 0: raise ReportWriteError( f"float_decimals must be non-negative, got {float_decimals}" ) self._output_path: Final[Path] = output_path self._report_schema_version: Final[str] = report_schema_version self._float_decimals: Final[int] = float_decimals @property def output_path(self) -> Path: """The bound output path (audit-only).""" return self._output_path def _fixed(self, value: float) -> float: """Round a float to the fixed precision for deterministic output.""" return round(float(value), self._float_decimals) def _build(self, report: RunReport) -> dict[str, object]: """Project the structural ``RunReport`` into a JSON-native dict. Accessing the Protocol's properties surfaces any structurally missing field as a programming error (``AttributeError``), per the contract. """ repro = report.reproducible audit = report.audit budget = report.budget reproducible: dict[str, object] = { "code_version": repro.code_version, "config_hash": repro.config_hash, "manifest_hash": repro.manifest_hash, "artifact_hashes": dict(repro.artifact_hashes), "input_file_sha256": repro.input_file_sha256, "candidate_count": repro.candidate_count, "output_sha256": repro.output_sha256, "honeypot_count_top100": repro.honeypot_count_top100, "honeypot_rate": self._fixed(repro.honeypot_rate), "eligibility_summary": dict(repro.eligibility_summary), "score_distribution_digest": repro.score_distribution_digest, } audit_block: dict[str, object] = { "run_id": audit.run_id, "started_at": audit.started_at, "ended_at": audit.ended_at, "host_label": audit.host_label, } timings: dict[str, float] = { stage: self._fixed(ms) for stage, ms in report.timings.items() } budget_block: dict[str, object] = { "limit_seconds": self._fixed(budget.limit_seconds), "used_seconds": self._fixed(budget.used_seconds), "within_budget": budget.within_budget, "peak_rss_mb": self._fixed(budget.peak_rss_mb), } return { "report_schema_version": self._report_schema_version, "reproducible": reproducible, "audit": audit_block, "timings": timings, "budget": budget_block, } def _serialize(self, report: RunReport) -> bytes: """Serialize the report deterministically: sorted keys, compact, UTF-8.""" payload = self._build(report) text = json.dumps( payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False ) return text.encode("utf-8") def _atomic_write(self, data: bytes) -> None: """Write ``data`` via temp file in the target directory + ``os.replace``. Raises: ReportWriteError: any IO failure; the temp file is removed so no partial report is left behind. """ parent = self._output_path.parent try: fd, tmp_name = tempfile.mkstemp( dir=parent, prefix=".tmp_run_report_", suffix=".json" ) except OSError as exc: raise ReportWriteError( f"cannot create temp file in {parent!s}: {exc}" ) from exc tmp_path = Path(tmp_name) try: with os.fdopen(fd, "wb") as handle: handle.write(data) handle.flush() os.fsync(handle.fileno()) os.replace(tmp_path, self._output_path) except OSError as exc: with suppress(OSError): tmp_path.unlink() raise ReportWriteError( f"cannot write run report to {self._output_path!s}: {exc}" ) from exc def write(self, report: RunReport) -> ReportReceipt: """Serialize ``report`` to JSON atomically and return a receipt. Raises: ReportWriteError: an IO error during the atomic write. """ data = self._serialize(report) report_sha256 = hashlib.sha256(data).hexdigest() self._atomic_write(data) return ReportReceipt(bytes_written=len(data), report_sha256=report_sha256) if TYPE_CHECKING: from redstack.ports.run_report_sink import RunReportSinkPort # Compile-time structural conformance to the frozen port surface. _PORT_CONFORMANCE: type[RunReportSinkPort] = JsonRunReportSinkAdapter __all__: tuple[str, ...] = ("JsonRunReportSinkAdapter",)