from dataclasses import dataclass from pathlib import Path from viano_bug_analysis.lifecycle import LifecycleError, State from viano_bug_analysis.lifecycle.checkpoints import checkpoint_chain from viano_bug_analysis.lifecycle.storage import json_bytes from viano_bug_analysis.package import PackageReader, PackageReadError from viano_bug_analysis.package.validator import ARCHIVE_ERRORS from .assembler import CaseAssembler, CaseBuilderLimits from .contracts import read_json from .errors import CaseBuildError from .summary import render_summary @dataclass(frozen=True) class CaseBuildResult: package: Path case_ref: str summary_ref: str analysis_revision: int reused: bool class CaseBuilder: def __init__(self, store, limits: CaseBuilderLimits | None = None): self.store = store self.assembler = CaseAssembler(limits) def build(self, case_id: str, package_id: str) -> CaseBuildResult: try: latest = self.store.latest(case_id, package_id) if latest is None: raise CaseBuildError("CASE_NOT_FOUND", "Case must be admitted before construction") with PackageReader(latest, self.store.limits) as reader: manifest = reader.manifest revision = int(manifest["analysis_revision"]) revision = revision or 1 complete_id = f"case-builder:v1:revision:{revision}:complete" completed = next((cp for _, cp in checkpoint_chain(reader) if cp["step_id"] == complete_id), None) if completed: assembled = self.assembler.assemble(reader) details = completed["details"] if details.get("input_request_hash") != assembled["inputs"]["request_hash"]: raise CaseBuildError("STALE_CASE", "Inputs changed after case completion; reopen as a new analysis revision") case_ref, summary_ref = details.get("case_ref"), details.get("summary_ref") stored = read_json(reader, case_ref, "case", self.assembler.limits.max_case_bytes) summary = render_summary(stored).encode("utf-8") if len(summary) > self.assembler.limits.max_summary_bytes: raise CaseBuildError("SUMMARY_LIMIT", "Summary exceeds configured byte limit") with reader._archive.open(summary_ref) as stream: stored_summary = reader.validator._read_bounded(stream, min(self.assembler.limits.max_summary_bytes, self.store.limits.max_file_bytes)) if reader.manifest["artifacts"].get("case") != case_ref or reader.manifest["artifacts"].get("case_summary") != summary_ref or stored_summary != summary: raise CaseBuildError("CASE_OUTPUT_CHANGED", "Confirmed case pointers or summary no longer match") # Repair reconstructible metadata after a crash, without recomputing a commit. recovered = self.store.advance(case_id, package_id, State.BUILDING_CASE, step_id=complete_id, details=details, additions={case_ref: json_bytes(stored), summary_ref: summary}, artifacts={"case": case_ref, "case_summary": summary_ref}) if recovered != latest: raise CaseBuildError("GENERATION_CHANGED", "Case changed while confirmed output was checked") return CaseBuildResult(latest, case_ref, summary_ref, revision, True) if manifest["state"] not in {State.VALIDATED, State.REOPENED, State.BUILDING_CASE}: raise CaseBuildError("INVALID_BUILD_STATE", "CaseBuilder requires VALIDATED, REOPENED or BUILDING_CASE") source_generation = int(manifest["package_generation"]) if manifest["state"] != State.BUILDING_CASE: latest = self.store.advance(case_id, package_id, State.BUILDING_CASE, step_id=f"case-builder:v1:revision:{revision}:start", details={"operation": "case-building", "builder_version": "1"}, expected_generation=source_generation) with PackageReader(latest, self.store.limits) as reader: if any(cp["step_id"] == complete_id for _, cp in checkpoint_chain(reader)): return self.build(case_id, package_id) case = self.assembler.assemble(reader) revision = case["case"]["analysis_revision"] case_ref = f"case/revisions/{revision:04d}/case.json" summary_ref = f"case/revisions/{revision:04d}/case_summary.md" summary = render_summary(case).encode("utf-8") if len(summary) > self.assembler.limits.max_summary_bytes: raise CaseBuildError("SUMMARY_LIMIT", "Summary exceeds configured byte limit") details = {"operation": "case-built", "builder_version": "1", "case_ref": case_ref, "summary_ref": summary_ref, "input_request_hash": case["inputs"]["request_hash"]} output = self.store.advance(case_id, package_id, State.BUILDING_CASE, step_id=f"case-builder:v1:revision:{revision}:complete", details=details, additions={case_ref: json_bytes(case), summary_ref: summary}, artifacts={"case": case_ref, "case_summary": summary_ref}, expected_generation=int(reader.manifest["package_generation"])) return CaseBuildResult(output, case_ref, summary_ref, revision, False) except (PackageReadError, LifecycleError) as error: raise CaseBuildError(getattr(error, "code", "PACKAGE_ERROR"), str(error)) from error except ARCHIVE_ERRORS + (TypeError, KeyError, RecursionError) as error: raise CaseBuildError("CASE_BUILD_ERROR", str(error)) from error