Starting a phase cuts phase/<stem> from the newest main it can see and works the list into it: each member branched from the phase's tip, run headless, merged back when its checks are green, the next one started. At the end one PR into main, for a human. The human gate moves from every card to the phase boundary, and the promise survives: the board merges into a branch it created, inside a scope you opened. The runner is a beat, not an agent — everything it decides is already structured state, and an agent paid to poll would be the wrong tool at the wrong price. It holds no registry of where a phase is. Two durable things carry the memory, and the board already writes both: git, where a member is finished when its branch is contained in the phase branch, and the card, which grows a ## Phase log the runner adds one line to per decision. The log is what tells "this member has run and it ended badly" from "the phase has not reached it yet" — without it a restarted board would relaunch a run that died. Containment alone is not enough to call a member merged: a clean exit that committed nothing leaves an empty branch that is contained. The card has to have settled into review/ too, or a broken launch would hide exactly where it always tries to. Five conditions halt, each already a visible state on the card, and a halt is written once and then held. Running the phase again is the person's decision and is what clears it — the run is scoped to its own log line, so a member whose run died is launchable again. A dependency that has not landed is a wait, not a halt. Merges are additive throughout: main into the phase branch on every beat so a long run does not drift into one enormous conflict, members into it as they go green, nothing rebased and nothing force-pushed. A conflict aborts, leaves the branch as it was, and halts naming the files that collided. The actor rule decides who runs it, written where it already lives: the phase card's assignee. A replica renders the phase and advances nothing. Reachable through /api/phase/run and the ticker; the header chip and the card actions are a separate card. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
254 lines
11 KiB
Python
254 lines
11 KiB
Python
"""HTTP surface: the page, the JSON API, and the SSE stream."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import queue
|
|
import re
|
|
import subprocess
|
|
import time
|
|
from html import escape
|
|
from http.server import BaseHTTPRequestHandler
|
|
from urllib.parse import parse_qs, unquote, urlparse
|
|
|
|
import agents
|
|
import commands
|
|
import config
|
|
import drive
|
|
import events
|
|
import github
|
|
import phases
|
|
import state
|
|
import sync
|
|
import taskfiles
|
|
|
|
|
|
def state_payload() -> dict:
|
|
with state.LOCK:
|
|
sessions = sorted(
|
|
(dict(m) for m in state.SESSIONS.values()),
|
|
key=lambda m: m.get("last") or m.get("started") or 0, reverse=True)
|
|
board_events = list(state.BOARD_EVENTS[-80:])
|
|
return {
|
|
"board": taskfiles.collect(),
|
|
"project": config.PROJECT,
|
|
"sessions": sessions,
|
|
"agents": agents.list_public(),
|
|
"prs": github.public_state(),
|
|
# what the last pass of the phase runner saw: per running phase
|
|
# card, its branch and each member's state
|
|
"phases": phases.public_state(),
|
|
"drive": drive.public(),
|
|
"hasDriver": config.driver_path() is not None,
|
|
"branches": github.task_branches(),
|
|
"commands": config.commands(),
|
|
"commandRuns": commands.public(),
|
|
# cards this board is midway through merging and cleaning up: the
|
|
# busy state renders from here, not from what a tab happened to click
|
|
"completing": state.completing_public(),
|
|
"checks": config.checks(),
|
|
# who this board is, so a card can tell "yours" from "someone
|
|
# else's". Empty outside team mode: nothing claims anything there.
|
|
"me": taskfiles.actor_name() if config.COMMIT_MOVES else "",
|
|
"archivedCount": taskfiles.archived_count(),
|
|
"sync": sync.status(),
|
|
"boardEvents": board_events,
|
|
"now": time.time(),
|
|
}
|
|
|
|
|
|
_TITLE = re.compile(rb"<title>.*?</title>", re.DOTALL)
|
|
|
|
|
|
def page_bytes() -> bytes:
|
|
"""board.html with the project's name rendered into its <title>, so the
|
|
tab reads right on first paint rather than after the first state load."""
|
|
html = (config.CORE / "board.html").read_bytes()
|
|
title = escape(f"{config.PROJECT} · bench").encode("utf-8")
|
|
return _TITLE.sub(lambda _: b"<title>" + title + b"</title>", html, count=1)
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def log_message(self, fmt, *args): # quieter console
|
|
pass
|
|
|
|
def _send(self, code: int, body: bytes, content_type: str) -> None:
|
|
self.send_response(code)
|
|
self.send_header("Content-Type", content_type)
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def _json(self, code: int, payload: dict) -> None:
|
|
self._send(code, json.dumps(payload).encode("utf-8"), "application/json")
|
|
|
|
def do_GET(self) -> None:
|
|
url = urlparse(self.path)
|
|
path = url.path
|
|
if path in ("/", "/index.html", "/board.html"):
|
|
if not (config.CORE / "board.html").is_file():
|
|
self._send(500, b"board.html is missing", "text/plain")
|
|
return
|
|
self._send(200, page_bytes(), "text/html; charset=utf-8")
|
|
elif path == "/api/tasks":
|
|
self._json(200, taskfiles.collect())
|
|
elif path == "/api/state":
|
|
self._json(200, state_payload())
|
|
elif path == "/api/session":
|
|
sid = (parse_qs(url.query).get("id") or [""])[0]
|
|
with state.LOCK:
|
|
meta = dict(state.SESSIONS.get(sid) or {})
|
|
self._json(200, {"meta": meta, "events": events.session_events(sid)})
|
|
elif path == "/api/diff":
|
|
agent_id = (parse_qs(url.query).get("agent") or [""])[0]
|
|
try:
|
|
self._json(200, agents.agent_diff(agent_id))
|
|
except (ValueError, subprocess.SubprocessError, OSError) as exc:
|
|
self._json(409, {"error": str(exc)})
|
|
elif path == "/api/stream":
|
|
self._stream()
|
|
elif path.startswith("/files/"):
|
|
self._extra_file(path)
|
|
else:
|
|
self._send(404, b"not found", "text/plain")
|
|
|
|
_MIME = {".html": "text/html; charset=utf-8",
|
|
".md": "text/markdown; charset=utf-8",
|
|
".txt": "text/plain; charset=utf-8",
|
|
".json": "application/json",
|
|
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
|
".svg": "image/svg+xml", ".pdf": "application/pdf"}
|
|
|
|
def _extra_file(self, path: str) -> None:
|
|
"""Serve plans/ and reference/ files so the UI can open them."""
|
|
parts = path.split("/", 3)
|
|
if len(parts) != 4 or parts[2] not in ("plans", "reference"):
|
|
self._send(404, b"not found", "text/plain")
|
|
return
|
|
name = unquote(parts[3])
|
|
if "/" in name or ".." in name or name.startswith("."):
|
|
self._send(404, b"not found", "text/plain")
|
|
return
|
|
file_path = config.TM_ROOT / parts[2] / name
|
|
if not file_path.is_file():
|
|
self._send(404, b"not found", "text/plain")
|
|
return
|
|
ctype = self._MIME.get(file_path.suffix.lower(), "application/octet-stream")
|
|
self._send(200, file_path.read_bytes(), ctype)
|
|
|
|
def _stream(self) -> None:
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/event-stream")
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.end_headers()
|
|
q: queue.Queue = queue.Queue(maxsize=500)
|
|
with state.LOCK:
|
|
state.CLIENTS.add(q)
|
|
try:
|
|
self.wfile.write(b"retry: 2000\n\n")
|
|
self.wfile.flush()
|
|
while True:
|
|
try:
|
|
msg = q.get(timeout=15)
|
|
self.wfile.write(f"data: {msg}\n\n".encode("utf-8"))
|
|
except queue.Empty:
|
|
self.wfile.write(b": ping\n\n")
|
|
self.wfile.flush()
|
|
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
pass
|
|
finally:
|
|
with state.LOCK:
|
|
state.CLIENTS.discard(q)
|
|
|
|
def _read_body(self) -> dict:
|
|
length = int(self.headers.get("Content-Length") or 0)
|
|
return json.loads(self.rfile.read(length) or b"{}")
|
|
|
|
def do_POST(self) -> None:
|
|
path = self.path.split("?")[0]
|
|
try:
|
|
if path == "/api/move":
|
|
payload = self._read_body()
|
|
task = taskfiles.move_task(payload["file"], payload["from"], payload["to"])
|
|
self._json(200, {"task": task})
|
|
elif path == "/api/events":
|
|
events.ingest_event(self._read_body())
|
|
self._json(200, {"ok": True})
|
|
elif path == "/api/agent/start":
|
|
payload = self._read_body()
|
|
# takeover: the second, deliberate click on a card someone
|
|
# else holds — never the default a stale card face sends
|
|
agent = agents.start_agent(payload["file"], payload["stage"],
|
|
bool(payload.get("takeover")))
|
|
self._json(200, {"agent": agent})
|
|
elif path == "/api/agent/review":
|
|
payload = self._read_body()
|
|
agent = agents.start_review(payload["file"], payload["stage"])
|
|
self._json(200, {"agent": agent})
|
|
elif path == "/api/agent/review-pr":
|
|
payload = self._read_body()
|
|
agent = agents.start_pr_review(payload["file"], payload["stage"])
|
|
self._json(200, {"agent": agent})
|
|
elif path == "/api/agent/act-pr":
|
|
payload = self._read_body()
|
|
agent = agents.start_pr_fix(payload["file"], payload["stage"])
|
|
self._json(200, {"agent": agent})
|
|
elif path == "/api/phase/run":
|
|
payload = self._read_body()
|
|
# takeover carries the same meaning it does for a launch:
|
|
# the deliberate second click on someone else's card
|
|
self._json(200, {"phase": phases.start_phase(
|
|
payload["file"], payload["stage"],
|
|
bool(payload.get("takeover")))})
|
|
elif path == "/api/pr/open":
|
|
payload = self._read_body()
|
|
self._json(200, {"url": github.open_pr_now(payload["file"])})
|
|
elif path == "/api/pr/copilot":
|
|
payload = self._read_body()
|
|
url = github.request_copilot(payload["file"])
|
|
self._json(200, {"url": url})
|
|
elif path == "/api/task/complete":
|
|
payload = self._read_body()
|
|
self._json(200, github.complete_task(payload["file"], payload["from"]))
|
|
elif path == "/api/archive":
|
|
payload = self._read_body()
|
|
result = taskfiles.archive_task(payload["file"], payload["from"])
|
|
with state.LOCK:
|
|
state.LAST_ARCHIVED = result
|
|
state.record_board_event({
|
|
"kind": "move", "file": result["file"], "from": result["from"],
|
|
"to": "archive", "actor": "you",
|
|
"summary": f"{result['file']} archived (from {result['from']}/) — ⌘Z brings it back"})
|
|
state.broadcast({"type": "board"})
|
|
self._json(200, result)
|
|
elif path == "/api/unarchive":
|
|
with state.LOCK:
|
|
last = state.LAST_ARCHIVED
|
|
state.LAST_ARCHIVED = None
|
|
if not last:
|
|
raise ValueError("nothing to unarchive — the undo covers the last archive this board made")
|
|
result = taskfiles.unarchive_task(last["file"], last["from"])
|
|
state.record_board_event({
|
|
"kind": "move", "file": result["file"], "from": "archive",
|
|
"to": result["to"], "actor": "you",
|
|
"summary": f"{result['file']} brought back to {result['to']}/"})
|
|
state.broadcast({"type": "board"})
|
|
self._json(200, result)
|
|
elif path == "/api/command/run":
|
|
payload = self._read_body()
|
|
self._json(200, commands.run(payload["name"], payload["file"]))
|
|
elif path == "/api/drive/start":
|
|
payload = self._read_body()
|
|
self._json(200, {"drive": drive.start(payload["file"])})
|
|
elif path == "/api/drive/stop":
|
|
self._json(200, {"drive": drive.stop()})
|
|
elif path == "/api/agent/stop":
|
|
payload = self._read_body()
|
|
agent = agents.stop_agent(payload["id"])
|
|
self._json(200, {"agent": agent})
|
|
else:
|
|
self._send(404, b"not found", "text/plain")
|
|
except (KeyError, ValueError, json.JSONDecodeError, OSError) as exc:
|
|
self._json(409, {"error": str(exc)})
|