92 lines
6.0 KiB
Python
92 lines
6.0 KiB
Python
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
|