"""Forge state persistence (PRD forge-native-integration, chunk 2). The orchestrator tracks one record per forge-targeted issue so it can map an incoming webhook back to the bottle handling it, drive the freeze / rehydrate loop, and run the watchdog. State lives on disk and survives orchestrator restarts: ~/.bot-bottle/forge///issue-.json Writes are atomic (`os.replace`) so a crash mid-write never leaves a truncated record. """ from __future__ import annotations import json import os from dataclasses import asdict, dataclass, field, fields from typing import Any from pathlib import Path from ...supervise import bot_bottle_root _FORGE_SUBDIR = "forge" # Lifecycle: a bottle is launched (running), frozen on the done signal, # and destroyed when the PR closes. STATUS_RUNNING = "running" STATUS_FROZEN = "frozen" STATUS_DESTROYED = "destroyed" @dataclass class ForgeState: """One forge-targeted issue's bottle lifecycle record.""" owner: str repo: str issue_number: int slug: str agent_name: str bottle_names: list[str] = field(default_factory=list) backend_name: str = "" agent_git_user: str = "" pr_number: int | None = None status: str = STATUS_RUNNING last_checkin_at: str = "" def to_json(self) -> str: return json.dumps(asdict(self), indent=2, sort_keys=True) @classmethod def from_dict(cls, data: dict[str, Any]) -> "ForgeState": # Tolerate unknown keys (forward-compat) by filtering to fields. known = {f.name for f in fields(cls)} return cls(**{k: v for k, v in data.items() if k in known}) def _forge_root() -> Path: return bot_bottle_root() / _FORGE_SUBDIR def forge_state_path(owner: str, repo: str, issue_number: int) -> Path: return _forge_root() / owner / repo / f"issue-{issue_number}.json" def write_forge_state(state: ForgeState) -> None: """Persist `state` atomically. Creates parent dirs as needed.""" path = forge_state_path(state.owner, state.repo, state.issue_number) path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_suffix(".json.tmp") tmp.write_text(state.to_json()) os.replace(tmp, path) def read_forge_state(owner: str, repo: str, issue_number: int) -> ForgeState | None: """Load state for one issue, or None when no record exists.""" path = forge_state_path(owner, repo, issue_number) try: data = json.loads(path.read_text()) except FileNotFoundError: return None return ForgeState.from_dict(data) def delete_forge_state(owner: str, repo: str, issue_number: int) -> None: """Remove an issue's record. Missing file is success (idempotent).""" path = forge_state_path(owner, repo, issue_number) path.unlink(missing_ok=True) def all_forge_states() -> list[ForgeState]: """Every persisted record, for the orchestrate-status table and the watchdog sweep. Unreadable files are skipped rather than aborting the whole listing.""" root = _forge_root() if not root.is_dir(): return [] states: list[ForgeState] = [] for path in sorted(root.glob("*/*/issue-*.json")): try: states.append(ForgeState.from_dict(json.loads(path.read_text()))) except (OSError, ValueError, TypeError): continue return states