feat: fold bot-bottle-orchestrator into bot_bottle/orchestrator subpackage
Moves the orchestrator into bot_bottle/orchestrator/ so one install gets everything. Entry point is now `python -m bot_bottle.orchestrator run`. - Add bot_bottle/orchestrator/ with all 14 modules (verbatim move; internal imports were already relative, so no changes inside orchestrator modules) - Rewrite bootstrap.py: remove the lazy bot_bottle import guard, use direct relative imports from ..contrib.* - Add bot_bottle/contrib/forge/base.py: ScopedForge (read-anywhere / write-scoped) - Add bot_bottle/contrib/gitea/client.py: GiteaClient + GiteaForge (urllib.request only) - Add bot_bottle/contrib/gitea/forge_state.py: ForgeState + SqliteForgeStateStore - Add tests/unit/orchestrator/ (82 tests: 63 migrated + 19 new for contrib modules) Closes #321
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user