diff options
Diffstat (limited to 'src/gap_pipeline/store.py')
| -rw-r--r-- | src/gap_pipeline/store.py | 113 |
1 files changed, 113 insertions, 0 deletions
diff --git a/src/gap_pipeline/store.py b/src/gap_pipeline/store.py new file mode 100644 index 0000000..ff850b9 --- /dev/null +++ b/src/gap_pipeline/store.py @@ -0,0 +1,113 @@ +"""Immutable, hash-addressed JSON artifacts for GAP runs.""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import tempfile +from pathlib import Path +from typing import Any + +from pydantic import BaseModel + +from .models import ModelCallRecord, StageArtifact + + +def canonical_json(value: Any) -> str: + if isinstance(value, BaseModel): + value = value.model_dump(mode="json") + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + + +def sha256_payload(value: Any) -> str: + return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest() + + +def safe_name(value: str) -> str: + return re.sub(r"[^A-Za-z0-9_.-]+", "_", value).strip("._") or "artifact" + + +class RunStore: + """Writes immutable artifacts and refuses conflicting overwrites.""" + + def __init__(self, root: Path, item_id: str) -> None: + self.root = root + self.item_id = item_id + self.item_root = root / "items" / safe_name(item_id) + for directory in [ + self.item_root, + self.item_root / "calls", + self.item_root / "stages", + self.item_root / "iterations", + ]: + directory.mkdir(parents=True, exist_ok=True) + + def write_json(self, relative_path: str | Path, value: Any) -> Path: + path = self.item_root / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + payload = value.model_dump(mode="json") if isinstance(value, BaseModel) else value + text = json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n" + if path.exists(): + existing = path.read_text(encoding="utf-8") + if existing != text: + raise FileExistsError( + f"refusing to overwrite non-identical artifact {path}" + ) + return path + descriptor, temp_name = tempfile.mkstemp( + prefix=f".{path.name}.", suffix=".tmp", dir=path.parent + ) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temp_name, path) + finally: + if os.path.exists(temp_name): + os.unlink(temp_name) + return path + + def write_input(self, value: Any) -> Path: + return self.write_json("input.json", value) + + def write_config(self, value: Any) -> Path: + return self.write_json("config.json", value) + + def write_call(self, record: ModelCallRecord) -> Path: + return self.write_json( + Path("calls") / f"{safe_name(record.request_id)}.json", record + ) + + def write_stage( + self, + stage: str, + payload: BaseModel | dict[str, Any], + *, + request_id: str | None, + ) -> Path: + raw = payload.model_dump(mode="json") if isinstance(payload, BaseModel) else payload + artifact = StageArtifact( + item_id=self.item_id, + stage=stage, + payload_sha256=sha256_payload(raw), + payload=raw, + request_id=request_id, + ) + return self.write_json(Path("stages") / f"{safe_name(stage)}.json", artifact) + + def write_iteration(self, iteration: int, value: Any) -> Path: + return self.write_json( + Path("iterations") / f"{iteration:02d}.json", + value, + ) + + def write_final(self, value: Any) -> Path: + return self.write_json("final.json", value) |
