from __future__ import annotations import json from dataclasses import dataclass from datetime import datetime from pathlib import Path from typing import Any from zoneinfo import ZoneInfo ROOT = Path(__file__).resolve().parents[1] PLAN_PATH = ROOT / "implementation" / "WORK_PLAN.json" STATE_PATH = ROOT / "BUILD_STATE.json" ALLOWED_PACKET_STATUSES = { "pending", "in_progress", "completed", "blocked", "failed", } class PackError(RuntimeError): """Raised when the design-pack state is structurally invalid.""" def load_json(path: Path) -> Any: try: return json.loads(path.read_text(encoding="utf-8")) except FileNotFoundError as exc: raise PackError(f"missing file: {path.relative_to(ROOT)}") from exc except json.JSONDecodeError as exc: raise PackError( f"invalid JSON in {path.relative_to(ROOT)}: " f"line {exc.lineno}, column {exc.colno}: {exc.msg}" ) from exc def load_plan() -> dict[str, Any]: value = load_json(PLAN_PATH) if not isinstance(value, dict) or not isinstance(value.get("packets"), list): raise PackError("implementation/WORK_PLAN.json must contain a packets array") return value def load_state() -> dict[str, Any]: value = load_json(STATE_PATH) if not isinstance(value, dict) or not isinstance(value.get("packets"), dict): raise PackError("BUILD_STATE.json must contain a packets object") return value def packet_map(plan: dict[str, Any]) -> dict[str, dict[str, Any]]: result: dict[str, dict[str, Any]] = {} for packet in plan["packets"]: packet_id = packet.get("id") if isinstance(packet, dict) else None if not isinstance(packet_id, str) or not packet_id: raise PackError("every work-plan packet requires a non-empty id") if packet_id in result: raise PackError(f"duplicate work packet id: {packet_id}") result[packet_id] = packet return result @dataclass(frozen=True) class PacketSelection: packet: dict[str, Any] | None reason: str def select_next_packet(plan: dict[str, Any], state: dict[str, Any]) -> PacketSelection: packets = packet_map(plan) statuses = state.get("packets", {}) unknown = set(statuses) - set(packets) missing = set(packets) - set(statuses) if unknown or missing: raise PackError( f"work-plan/state mismatch; unknown={sorted(unknown)}, missing={sorted(missing)}" ) for packet in plan["packets"]: packet_id = packet["id"] if statuses[packet_id] == "in_progress": return PacketSelection(packet, "resume in-progress packet") for packet in plan["packets"]: packet_id = packet["id"] if statuses[packet_id] not in {"pending", "failed"}: continue dependencies = packet.get("deps", []) if all(statuses.get(dep) == "completed" for dep in dependencies): reason = ( "retry failed packet" if statuses[packet_id] == "failed" else "first ready pending packet" ) return PacketSelection(packet, reason) blocked = [pid for pid, status in statuses.items() if status == "blocked"] unfinished = [pid for pid, status in statuses.items() if status != "completed"] if not unfinished: return PacketSelection(None, "all packets completed") if blocked: return PacketSelection(None, f"no ready packet; blocked packets: {', '.join(blocked)}") return PacketSelection(None, "no packet is ready; dependency or state inconsistency") def brussels_now() -> str: return datetime.now(ZoneInfo("Europe/Brussels")).isoformat(timespec="seconds") def relative(path: Path) -> str: return path.resolve().relative_to(ROOT.resolve()).as_posix()