"""Forge sidecar: the agent's only door to the forge. The agent calls the sidecar over a line-delimited JSON-RPC AF_UNIX socket; the sidecar dispatches to an injected `forge` (already scope-wrapped by bootstrap) and holds the token, so the agent never sees a credential or a forge endpoint. Every call is appended to a semantic operation log (the provenance raw material). `signal_done` additionally drops an event file in the queue dir the orchestrator drains. `dispatch` is pure and testable; `serve` wraps it in a socket server. """ from __future__ import annotations import dataclasses import json import socketserver import uuid from collections.abc import Callable from datetime import datetime from pathlib import Path from typing import Any _READ_METHODS = {"read_issue", "read_pr", "read_comments"} _WRITE_METHODS = {"post_comment", "update_description"} def _iso_now() -> str: return datetime.now().astimezone().isoformat(timespec="seconds") def _jsonable(value: Any) -> Any: if dataclasses.is_dataclass(value) and not isinstance(value, type): return dataclasses.asdict(value) if isinstance(value, list): return [_jsonable(v) for v in value] return value class OpLog: """Append-only JSONL log of semantic forge operations.""" def __init__(self, path: Path, *, now: Callable[[], str] = _iso_now) -> None: self._path = path self._now = now path.parent.mkdir(parents=True, exist_ok=True) def record(self, op: str, target: int | None, detail: str) -> None: entry = {"at": self._now(), "op": op, "target": target, "detail": detail} with self._path.open("a", encoding="utf-8") as fh: fh.write(json.dumps(entry) + "\n") def read(self) -> list[dict[str, Any]]: if not self._path.exists(): return [] return [ json.loads(line) for line in self._path.read_text(encoding="utf-8").splitlines() if line.strip() ] def write_done_event(queue_dir: Path, event: dict[str, Any]) -> Path: """Atomically drop a done-signal event file in the queue dir.""" queue_dir.mkdir(parents=True, exist_ok=True) path = queue_dir / f"done-{uuid.uuid4().hex}.json" tmp = path.with_suffix(".json.tmp") tmp.write_text(json.dumps(event), encoding="utf-8") tmp.replace(path) return path def drain_done_events(queue_dir: Path) -> list[dict[str, Any]]: """Read and remove every queued done-signal event.""" if not queue_dir.is_dir(): return [] events: list[dict[str, Any]] = [] for path in sorted(queue_dir.glob("done-*.json")): try: events.append(json.loads(path.read_text(encoding="utf-8"))) except (OSError, ValueError): continue finally: path.unlink(missing_ok=True) return events class ForgeSidecar: """Dispatches sidecar protocol calls to the forge, logging each and relaying `signal_done` to the queue dir. `run_key` is the (owner, repo, issue_number) the run is bound to.""" def __init__( self, *, forge: object, op_log: OpLog, queue_dir: Path, run_key: tuple[str, str, int], ) -> None: self._forge = forge self._log = op_log self._queue_dir = queue_dir self._owner, self._repo, self._issue = run_key def dispatch(self, method: str, params: dict[str, Any]) -> dict[str, Any]: try: result = self._invoke(method, params) except Exception as exc: # noqa: BLE001 — surface as JSON-RPC error self._log.record(method, params.get("number"), f"error: {exc}") return {"ok": False, "error": str(exc)} return {"ok": True, "result": result} def _invoke(self, method: str, params: dict[str, Any]) -> Any: if method in _READ_METHODS: number = int(params["number"]) result = getattr(self._forge, method)(number) self._log.record(method, number, "ok") return _jsonable(result) if method in _WRITE_METHODS: number = int(params["number"]) getattr(self._forge, method)(number, params["body"]) self._log.record(method, number, "ok") return None if method == "signal_done": status = str(params.get("status", "")) summary = str(params.get("summary", "")) self._log.record("signal_done", None, f"{status}: {summary}") write_done_event( self._queue_dir, { "owner": self._owner, "repo": self._repo, "issue_number": self._issue, "status": status, "summary": summary, }, ) return None raise ValueError(f"unknown method: {method}") class _Handler(socketserver.StreamRequestHandler): def handle(self) -> None: line = self.rfile.readline() if not line: return try: req = json.loads(line) except ValueError: self.wfile.write(b'{"ok": false, "error": "invalid json"}\n') return resp = self.server.sidecar.dispatch( # type: ignore[attr-defined] str(req.get("method", "")), dict(req.get("params", {})) ) self.wfile.write((json.dumps(resp) + "\n").encode()) class _Server(socketserver.ThreadingUnixStreamServer): def __init__(self, socket_path: str, sidecar: ForgeSidecar) -> None: super().__init__(socket_path, _Handler) self.sidecar = sidecar def serve(sidecar: ForgeSidecar, socket_path: Path) -> _Server: """Bind a threaded AF_UNIX server for `sidecar`. Caller runs `serve_forever()` (or `handle_request()` in tests) and closes it.""" if socket_path.exists(): socket_path.unlink() socket_path.parent.mkdir(parents=True, exist_ok=True) return _Server(str(socket_path), sidecar)