dispatchbox 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dispatchbox/server.py ADDED
@@ -0,0 +1,480 @@
1
+ """HTTP layer for the hub: JSON API for sources and workers, GitHub webhook adapter, and a small web UI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ import html
8
+ import json
9
+ import logging
10
+ import secrets
11
+ import threading
12
+ import time
13
+ from datetime import datetime
14
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
15
+ from typing import Any
16
+ from urllib.parse import parse_qs, urlparse
17
+
18
+ from .hub import Hub, HubError
19
+
20
+ log = logging.getLogger("dispatchbox.server")
21
+
22
+ MAX_BODY = 2 * 1024 * 1024
23
+ PROXY_HEADERS = ("X-Forwarded-For", "X-Real-IP", "Forwarded", "CF-Connecting-IP", "X-Forwarded-Host")
24
+ MAX_WAIT = 60.0
25
+
26
+
27
+ class DispatchboxServer(ThreadingHTTPServer):
28
+ daemon_threads = True
29
+ allow_reuse_address = True
30
+
31
+ def __init__(self, addr: tuple[str, int], hub: Hub, token: str, github_secret: str = ""):
32
+ super().__init__(addr, Handler)
33
+ self.hub = hub
34
+ self.token = token
35
+ self.github_secret = github_secret
36
+ self.ui_cookie = hmac.new(token.encode(), b"dispatchbox-ui", hashlib.sha256).hexdigest()
37
+ self.csrf = hmac.new(token.encode(), b"dispatchbox-csrf", hashlib.sha256).hexdigest()
38
+ self._stop = threading.Event()
39
+
40
+ def start_background(self) -> None:
41
+ def loop() -> None:
42
+ last_reap = 0.0
43
+ while not self._stop.is_set():
44
+ try:
45
+ self.hub.tick_cron()
46
+ if time.monotonic() - last_reap > 15:
47
+ self.hub.reap()
48
+ last_reap = time.monotonic()
49
+ except Exception:
50
+ log.exception("background loop error")
51
+ self._stop.wait(1.0)
52
+
53
+ threading.Thread(target=loop, name="dispatchbox-bg", daemon=True).start()
54
+
55
+ def shutdown(self) -> None:
56
+ self._stop.set()
57
+ super().shutdown()
58
+
59
+
60
+ class Handler(BaseHTTPRequestHandler):
61
+ server: DispatchboxServer
62
+ protocol_version = "HTTP/1.1"
63
+
64
+ def log_message(self, fmt: str, *args: Any) -> None:
65
+ log.debug("%s %s", self.address_string(), fmt % args)
66
+
67
+ # ------------------------------------------------------------------ helpers
68
+
69
+ @property
70
+ def hub(self) -> Hub:
71
+ return self.server.hub
72
+
73
+ def _send(self, status: int, body: bytes = b"", ctype: str = "application/json", headers: dict[str, str] | None = None) -> None:
74
+ self.send_response(status)
75
+ self.send_header("Content-Type", ctype + ("; charset=utf-8" if ctype.startswith(("text/", "application/json")) else ""))
76
+ self.send_header("Content-Length", str(len(body)))
77
+ self.send_header("X-Content-Type-Options", "nosniff")
78
+ for k, v in (headers or {}).items():
79
+ self.send_header(k, v)
80
+ self.end_headers()
81
+ if body and self.command != "HEAD":
82
+ self.wfile.write(body)
83
+
84
+ def _json(self, status: int, obj: Any) -> None:
85
+ self._send(status, json.dumps(obj, ensure_ascii=False, default=str).encode())
86
+
87
+ def _html(self, status: int, page: str, headers: dict[str, str] | None = None) -> None:
88
+ self._send(status, page.encode(), "text/html", headers)
89
+
90
+ def _body(self) -> bytes:
91
+ n = int(self.headers.get("Content-Length") or 0)
92
+ if n > MAX_BODY:
93
+ raise HubError(413, "body too large")
94
+ return self.rfile.read(n) if n else b""
95
+
96
+ def _json_body(self) -> dict[str, Any]:
97
+ raw = self._body()
98
+ try:
99
+ obj = json.loads(raw or b"{}")
100
+ except json.JSONDecodeError as e:
101
+ raise HubError(400, f"invalid JSON: {e}") from e
102
+ if not isinstance(obj, dict):
103
+ raise HubError(400, "body must be a JSON object")
104
+ return obj
105
+
106
+ def _api_authed(self) -> bool:
107
+ auth = self.headers.get("Authorization", "")
108
+ return auth.startswith("Bearer ") and hmac.compare_digest(auth[7:].strip(), self.server.token)
109
+
110
+ def _is_loopback(self) -> bool:
111
+ """A direct connection from this machine, not one relayed by a tunnel or reverse proxy.
112
+
113
+ Tunnels (cloudflared, ngrok) and proxies connect from 127.0.0.1 too, so a loopback address alone
114
+ proves nothing. They add forwarding headers; a remote client adding them only makes us stricter.
115
+ When the hub has a public URL it is meant to be reached from outside, so loopback is never trusted.
116
+ """
117
+ if self.hub.public_url:
118
+ return False
119
+ if any(self.headers.get(h) for h in PROXY_HEADERS):
120
+ return False
121
+ return self.client_address[0] in {"127.0.0.1", "::1", "::ffff:127.0.0.1"}
122
+
123
+ def _ui_authed(self) -> bool:
124
+ if self._is_loopback() or self._api_authed():
125
+ return True
126
+ for part in self.headers.get("Cookie", "").split(";"):
127
+ k, _, v = part.strip().partition("=")
128
+ if k == "dispatchbox_ui" and hmac.compare_digest(v, self.server.ui_cookie):
129
+ return True
130
+ return False
131
+
132
+ def _require_api(self) -> None:
133
+ if not self._api_authed():
134
+ raise HubError(401, "missing or wrong bearer token")
135
+
136
+ # ------------------------------------------------------------------ dispatch
137
+
138
+ def do_GET(self) -> None:
139
+ self._dispatch("GET")
140
+
141
+ def do_HEAD(self) -> None:
142
+ self._dispatch("GET")
143
+
144
+ def do_POST(self) -> None:
145
+ self._dispatch("POST")
146
+
147
+ def _dispatch(self, method: str) -> None:
148
+ url = urlparse(self.path)
149
+ parts = [p for p in url.path.split("/") if p]
150
+ query = {k: v[-1] for k, v in parse_qs(url.query).items()}
151
+ try:
152
+ self._route(method, parts, query)
153
+ except HubError as e:
154
+ if parts[:1] in (["api"], ["events"], ["claim"], ["hooks"]) or self.headers.get("Accept", "").startswith("application/json"):
155
+ self._json(e.status, {"error": str(e)})
156
+ else:
157
+ self._html(e.status, page("Error", f"<p>{html.escape(str(e))}</p><p><a href='/'>Back</a></p>"))
158
+ except BrokenPipeError:
159
+ pass
160
+ except Exception as e:
161
+ log.exception("request failed")
162
+ self._json(500, {"error": f"internal error: {e.__class__.__name__}"})
163
+
164
+ def _route(self, method: str, parts: list[str], q: dict[str, str]) -> None:
165
+ if parts == ["healthz"]:
166
+ return self._json(200, {"ok": True})
167
+
168
+ # ---- sources
169
+ if method == "POST" and parts == ["events"]:
170
+ self._require_api()
171
+ return self._json(200, self.hub.ingest(self._json_body()))
172
+ if method == "POST" and parts == ["hooks", "github"]:
173
+ return self._github()
174
+ if method == "POST" and len(parts) == 2 and parts[0] == "hooks" and parts[1] in self.hub.adapters:
175
+ body = self._body()
176
+ headers = {k.lower(): v for k, v in self.headers.items()}
177
+ status, extra, payload = self.hub.adapters[parts[1]].handle(headers, body)
178
+ return self._send(status, payload, extra.pop("Content-Type", "application/json"), extra)
179
+
180
+ # ---- workers
181
+ if method == "GET" and parts == ["claim"]:
182
+ self._require_api()
183
+ wait = min(float(q.get("wait", 0) or 0), MAX_WAIT)
184
+ runners = q["runners"].split(",") if q.get("runners") else None
185
+ task = self.hub.claim(q.get("worker", ""), wait=wait, runners=runners)
186
+ return self._send(204) if task is None else self._json(200, task)
187
+ if method == "POST" and len(parts) == 3 and parts[0] == "runs" and parts[2] in {"heartbeat", "result"}:
188
+ self._require_api()
189
+ body = self._json_body()
190
+ run_id = _int(parts[1])
191
+ if parts[2] == "heartbeat":
192
+ self.hub.heartbeat(run_id, str(body.get("worker", "")))
193
+ return self._json(200, {"ok": True})
194
+ return self._json(200, self.hub.finish(run_id, str(body.get("worker", "")), body))
195
+
196
+ # ---- decisions (API or UI form)
197
+ if method == "POST" and len(parts) == 3 and parts[0] == "runs" and parts[2] == "decision":
198
+ run_id = _int(parts[1])
199
+ if self.headers.get("Content-Type", "").startswith("application/json"):
200
+ self._require_api()
201
+ body = self._json_body()
202
+ return self._json(200, self.hub.decide(run_id, str(body.get("choice", "")), str(body.get("note", "")),
203
+ by=str(body.get("by", "api"))))
204
+ if not self._ui_authed():
205
+ raise HubError(401, "sign in first")
206
+ form = {k: v[-1] for k, v in parse_qs(self._body().decode()).items()}
207
+ if not hmac.compare_digest(form.get("csrf", ""), self.server.csrf):
208
+ raise HubError(403, "bad form token, reload the page")
209
+ self.hub.decide(run_id, form.get("choice", ""), form.get("note", ""), by="web")
210
+ return self._send(303, headers={"Location": f"/runs/{run_id}"})
211
+
212
+ # ---- read API
213
+ if method == "GET" and parts[:1] == ["api"]:
214
+ self._require_api()
215
+ if parts == ["api", "runs"]:
216
+ return self._json(200, self.hub.list_runs(_int(q.get("limit", "100")), q.get("status")))
217
+ if len(parts) == 3 and parts[1] == "runs":
218
+ run = self.hub.get_run(_int(parts[2]))
219
+ return self._json(200, run) if run else self._json(404, {"error": "not found"})
220
+ if parts == ["api", "events"]:
221
+ return self._json(200, self.hub.list_events(_int(q.get("limit", "100"))))
222
+ if parts == ["api", "stats"]:
223
+ return self._json(200, self.hub.stats())
224
+
225
+ # ---- web UI
226
+ if parts == ["login"]:
227
+ return self._login(method)
228
+ if method == "GET" and parts in ([], ["runs"]):
229
+ if not self._ui_authed():
230
+ return self._send(303, headers={"Location": "/login"})
231
+ return self._html(200, render_index(self.hub, q.get("status")))
232
+ if method == "GET" and len(parts) == 2 and parts[0] == "runs":
233
+ if not self._ui_authed():
234
+ return self._send(303, headers={"Location": "/login"})
235
+ run = self.hub.get_run(_int(parts[1]))
236
+ if not run:
237
+ raise HubError(404, "run not found")
238
+ return self._html(200, render_run(run, self.server.csrf))
239
+
240
+ raise HubError(404, "not found")
241
+
242
+ def _login(self, method: str) -> None:
243
+ if method == "POST":
244
+ form = {k: v[-1] for k, v in parse_qs(self._body().decode()).items()}
245
+ if hmac.compare_digest(form.get("token", ""), self.server.token):
246
+ cookie = f"dispatchbox_ui={self.server.ui_cookie}; HttpOnly; SameSite=Strict; Path=/; Max-Age=2592000"
247
+ return self._send(303, headers={"Location": "/", "Set-Cookie": cookie})
248
+ return self._html(401, page("Sign in", LOGIN_FORM + "<p class='err'>Wrong token.</p>"))
249
+ return self._html(200, page("Sign in", LOGIN_FORM))
250
+
251
+ def _github(self) -> None:
252
+ body = self._body()
253
+ secret = self.server.github_secret
254
+ if not secret:
255
+ raise HubError(404, "GitHub webhook is not configured (set DISPATCHBOX_GITHUB_SECRET)")
256
+ sig = self.headers.get("X-Hub-Signature-256", "")
257
+ expected = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
258
+ if not hmac.compare_digest(sig, expected):
259
+ raise HubError(401, "bad GitHub signature")
260
+ kind = self.headers.get("X-GitHub-Event", "")
261
+ if kind == "ping":
262
+ return self._json(200, {"ok": True, "pong": True})
263
+ try:
264
+ payload = json.loads(body)
265
+ except json.JSONDecodeError as e:
266
+ raise HubError(400, "invalid JSON") from e
267
+ event = github_event(kind, payload, self.headers.get("X-GitHub-Delivery", ""))
268
+ if event is None:
269
+ return self._json(200, {"ignored": kind})
270
+ return self._json(200, self.hub.ingest(event))
271
+
272
+
273
+ def _int(s: str) -> int:
274
+ try:
275
+ return int(s)
276
+ except (TypeError, ValueError) as e:
277
+ raise HubError(400, f"not a number: {s!r}") from e
278
+
279
+
280
+ # ---------------------------------------------------------------------- GitHub adapter
281
+
282
+ def _clip(s: Any, n: int = 2000) -> str:
283
+ s = "" if s is None else str(s)
284
+ return s if len(s) <= n else s[:n] + "..."
285
+
286
+
287
+ def github_event(kind: str, p: dict[str, Any], delivery: str) -> dict[str, Any] | None:
288
+ """Turn a GitHub webhook into a dispatchbox event. Returns None for kinds we don't map."""
289
+ repo = (p.get("repository") or {}).get("full_name", "")
290
+ action = p.get("action", "")
291
+ sender = (p.get("sender") or {}).get("login", "")
292
+ base = {"source": "github", "payload": {"repo": repo, "action": action, "sender": sender}}
293
+ if delivery:
294
+ base["dedupe_key"] = f"github:{delivery}"
295
+
296
+ if kind == "pull_request":
297
+ pr = p.get("pull_request") or {}
298
+ base["payload"].update({"number": pr.get("number"), "title": pr.get("title"), "body": _clip(pr.get("body")),
299
+ "head_ref": (pr.get("head") or {}).get("ref"), "head_sha": (pr.get("head") or {}).get("sha"),
300
+ "base_ref": (pr.get("base") or {}).get("ref"), "draft": pr.get("draft"),
301
+ "merged": pr.get("merged")})
302
+ return {**base, "type": f"pr.{action}", "subject": f"{repo}#{pr.get('number')}",
303
+ "summary": _clip(pr.get("title"), 200), "link": pr.get("html_url", "")}
304
+ if kind == "issues":
305
+ issue = p.get("issue") or {}
306
+ base["payload"].update({"number": issue.get("number"), "title": issue.get("title"), "body": _clip(issue.get("body")),
307
+ "labels": [l.get("name") for l in issue.get("labels") or []]})
308
+ return {**base, "type": f"issue.{action}", "subject": f"{repo}#{issue.get('number')}",
309
+ "summary": _clip(issue.get("title"), 200), "link": issue.get("html_url", "")}
310
+ if kind in {"issue_comment", "pull_request_review_comment"}:
311
+ issue = p.get("issue") or p.get("pull_request") or {}
312
+ c = p.get("comment") or {}
313
+ base["payload"].update({"number": issue.get("number"), "comment": _clip(c.get("body")),
314
+ "is_pr": "pull_request" in issue or kind == "pull_request_review_comment"})
315
+ return {**base, "type": f"comment.{action}", "subject": f"{repo}#{issue.get('number')}",
316
+ "summary": _clip(c.get("body"), 200), "link": c.get("html_url", "")}
317
+ if kind == "push":
318
+ commits = p.get("commits") or []
319
+ base["payload"].update({"ref": p.get("ref"), "after": p.get("after"),
320
+ "commits": [_clip(c.get("message"), 200) for c in commits[:20]]})
321
+ return {**base, "type": "push", "subject": f"{repo}:{p.get('ref', '')}",
322
+ "summary": f"{len(commits)} commit(s) by {sender}", "link": p.get("compare", "")}
323
+ if kind == "workflow_run":
324
+ run = p.get("workflow_run") or {}
325
+ if action != "completed":
326
+ return None
327
+ base["payload"].update({"workflow": run.get("name"), "conclusion": run.get("conclusion"),
328
+ "head_branch": run.get("head_branch"), "head_sha": run.get("head_sha")})
329
+ return {**base, "type": f"ci.{run.get('conclusion')}", "subject": f"{repo}:{run.get('head_branch')}",
330
+ "summary": f"{run.get('name')} {run.get('conclusion')}", "link": run.get("html_url", "")}
331
+ return None
332
+
333
+
334
+ # ---------------------------------------------------------------------- web UI
335
+
336
+ STYLE = """
337
+ :root{--bg:#fafaf9;--fg:#1c1917;--muted:#78716c;--line:#e7e5e4;--card:#fff;--accent:#2563eb;
338
+ --ok:#15803d;--bad:#b91c1c;--wait:#b45309;--run:#2563eb}
339
+ @media (prefers-color-scheme:dark){:root{--bg:#0c0a09;--fg:#e7e5e4;--muted:#a8a29e;--line:#292524;--card:#1c1917;
340
+ --accent:#60a5fa;--ok:#4ade80;--bad:#f87171;--wait:#fbbf24;--run:#60a5fa}}
341
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 -apple-system,system-ui,sans-serif}
342
+ main{max-width:1000px;margin:0 auto;padding:24px 16px}a{color:var(--accent);text-decoration:none}
343
+ h1{font-size:20px;margin:0 0 16px}h2{font-size:15px;margin:24px 0 8px}
344
+ .chips{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}.chip{padding:4px 10px;border:1px solid var(--line);border-radius:999px;background:var(--card)}
345
+ table{width:100%;border-collapse:collapse;background:var(--card);border:1px solid var(--line);border-radius:8px;overflow:hidden}
346
+ th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--line);vertical-align:top}th{color:var(--muted);font-weight:500}
347
+ .s{font-weight:600}.s-succeeded{color:var(--ok)}.s-failed,.s-lost{color:var(--bad)}.s-awaiting_decision{color:var(--wait)}
348
+ .s-running,.s-queued{color:var(--run)}.s-decided{color:var(--muted)}
349
+ .muted{color:var(--muted)}pre{white-space:pre-wrap;word-break:break-word;background:var(--card);border:1px solid var(--line);
350
+ border-radius:8px;padding:12px;max-height:420px;overflow:auto;font-size:12px}
351
+ .card{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:16px}
352
+ button{font:inherit;padding:8px 14px;border-radius:6px;border:1px solid var(--accent);background:var(--accent);color:#fff;cursor:pointer;margin:4px 8px 4px 0}
353
+ input{font:inherit;padding:8px;border:1px solid var(--line);border-radius:6px;background:var(--bg);color:var(--fg);width:100%;max-width:420px}
354
+ .err{color:var(--bad)}.scroll{overflow-x:auto}
355
+ """
356
+
357
+ LOGIN_FORM = """<form method="post" action="/login" class="card"><p>Paste the hub token to open the dashboard.</p>
358
+ <input type="password" name="token" autofocus><p><button>Sign in</button></p></form>"""
359
+
360
+
361
+ def page(title: str, body: str, refresh: int = 0) -> str:
362
+ meta = f'<meta http-equiv="refresh" content="{refresh}">' if refresh else ""
363
+ return (f"<!doctype html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'>"
364
+ f"{meta}<title>{html.escape(title)} · dispatchbox</title><style>{STYLE}</style></head><body><main>{body}</main></body></html>")
365
+
366
+
367
+ def _ts(t: float | None) -> str:
368
+ return datetime.fromtimestamp(t).strftime("%m-%d %H:%M:%S") if t else ""
369
+
370
+
371
+ def _dur(run: dict[str, Any]) -> str:
372
+ if run.get("claimed_at") and run.get("finished_at"):
373
+ s = int(run["finished_at"] - run["claimed_at"])
374
+ return f"{s // 60}m{s % 60:02d}s" if s >= 60 else f"{s}s"
375
+ return ""
376
+
377
+
378
+ def _cost(run: dict[str, Any]) -> str:
379
+ return "" if run.get("cost_usd") is None else f"${run['cost_usd']:.4f}"
380
+
381
+
382
+ def _usage(run: dict[str, Any]) -> str:
383
+ parts = []
384
+ if run.get("cost_usd") is not None:
385
+ parts.append(_cost(run))
386
+ if run.get("input_tokens") is not None or run.get("output_tokens") is not None:
387
+ parts.append(f"{run.get('input_tokens') or 0:,} in / {run.get('output_tokens') or 0:,} out")
388
+ return " · ".join(parts)
389
+
390
+
391
+ def render_index(hub: Hub, status: str | None) -> str:
392
+ stats = hub.stats()
393
+ chips = "".join(
394
+ f"<a class='chip' href='/?status={html.escape(k)}'><span class='s s-{html.escape(k)}'>{html.escape(k)}</span> {v}</a>"
395
+ for k, v in sorted(stats.items())
396
+ )
397
+ chips = f"<a class='chip' href='/'>all</a>{chips}"
398
+ rows = []
399
+ for r in hub.list_runs(200, status):
400
+ rows.append(
401
+ f"<tr><td><a href='/runs/{r['id']}'>#{r['id']}</a></td><td>{html.escape(r['rule'])}</td>"
402
+ f"<td class='s s-{r['status']}'>{html.escape(r['status'])}</td><td>{html.escape(r.get('claimed_by') or '')}</td>"
403
+ f"<td class='muted'>{_ts(r['created_at'])}</td><td class='muted'>{_dur(r)}</td>"
404
+ f"<td class='muted'>{_cost(r)}</td>"
405
+ f"<td>{html.escape((r.get('summary') or '')[:140])}</td></tr>"
406
+ )
407
+ table = ("<div class='scroll'><table><tr><th>Run</th><th>Rule</th><th>Status</th><th>Worker</th><th>Created</th>"
408
+ "<th>Took</th><th>Cost</th><th>Summary</th></tr>" + "".join(rows) + "</table></div>") if rows else "<p class='muted'>No runs yet.</p>"
409
+ events = hub.list_events(30)
410
+ ev_rows = "".join(
411
+ f"<tr><td>{e['id']}</td><td class='muted'>{_ts(e['received_at'])}</td><td>{html.escape(e['source'])}/{html.escape(e['type'])}</td>"
412
+ f"<td>{html.escape(e['subject'])}</td><td>{html.escape(e['status'])}</td><td class='muted'>{html.escape(e['note'])}</td></tr>"
413
+ for e in events
414
+ )
415
+ ev_table = ("<div class='scroll'><table><tr><th>#</th><th>Received</th><th>Event</th><th>Subject</th><th>Status</th><th></th></tr>"
416
+ + ev_rows + "</table></div>") if events else "<p class='muted'>No events yet.</p>"
417
+ return page("Runs", f"<h1>dispatchbox</h1><div class='chips'>{chips}</div>{table}<h2>Recent events</h2>{ev_table}", refresh=10)
418
+
419
+
420
+ def render_run(run: dict[str, Any], csrf: str) -> str:
421
+ esc = html.escape
422
+ spec = run["spec"]
423
+ meta = [
424
+ ("Status", f"<span class='s s-{run['status']}'>{esc(run['status'])}</span>"),
425
+ ("Rule", esc(run["rule"])), ("Runner", esc(f"{spec.get('runner')} {spec.get('model') or ''}")),
426
+ ("Worker", esc(run.get("claimed_by") or "")), ("Directory", esc(spec.get("cwd") or "")),
427
+ ("Group", esc(run["group_key"])), ("Created", _ts(run["created_at"])), ("Took", _dur(run)),
428
+ ("Exit code", esc(str(run.get("exit_code") if run.get("exit_code") is not None else ""))),
429
+ ("Session", esc(run.get("session_id") or "")),
430
+ ("Usage", esc(_usage(run))),
431
+ ("Attempt", esc(str(run.get("attempt") or 1))),
432
+ ]
433
+ if run.get("retry_of"):
434
+ meta.append(("Retry of", f"<a href='/runs/{run['retry_of']}'>#{run['retry_of']}</a>"))
435
+ if run.get("parent_run"):
436
+ meta.append(("Continues", f"<a href='/runs/{run['parent_run']}'>#{run['parent_run']}</a>"))
437
+ meta_html = "<table>" + "".join(f"<tr><th>{k}</th><td>{v}</td></tr>" for k, v in meta) + "</table>"
438
+
439
+ decision_html = ""
440
+ req = run.get("decision_request")
441
+ if req:
442
+ opts = req.get("options") or []
443
+ if run["status"] == "awaiting_decision":
444
+ buttons = "".join(f"<button name='choice' value='{esc(o, quote=True)}'>{esc(o)}</button>" for o in opts)
445
+ free = "" if opts else "<p><input name='choice' placeholder='Your decision'></p><button>Send</button>"
446
+ decision_html = (
447
+ f"<h2>Needs your decision</h2><div class='card'><p><b>{esc(req.get('question', ''))}</b></p>"
448
+ f"<p class='muted'>{esc(req.get('context', '') or '')}</p>"
449
+ f"<form method='post' action='/runs/{run['id']}/decision'><input type='hidden' name='csrf' value='{csrf}'>"
450
+ f"<p><input name='note' placeholder='Optional note for the agent'></p>{buttons}{free}</form></div>"
451
+ )
452
+ else:
453
+ d = run.get("decision") or {}
454
+ decision_html = (f"<h2>Decision</h2><div class='card'><p>{esc(req.get('question', ''))}</p>"
455
+ f"<p><b>{esc(str(d.get('choice', '')))}</b> <span class='muted'>{esc(str(d.get('note', '')))}</span></p></div>")
456
+
457
+ events = "".join(
458
+ f"<li>[{esc(e['source'])}/{esc(e['type'])}] {esc(e['subject'])}: {esc(e['summary'])}"
459
+ + (f" <a href='{esc(e['link'], quote=True)}' rel='noreferrer'>open</a>" if e.get("link", "").startswith(("http://", "https://")) else "")
460
+ + "</li>"
461
+ for e in run.get("events", [])
462
+ )
463
+ body = (
464
+ f"<p><a href='/'>← all runs</a></p><h1>Run #{run['id']}</h1>"
465
+ f"<p>{esc(run.get('summary') or '')}</p>{decision_html}<h2>Details</h2>{meta_html}"
466
+ f"<h2>Events</h2><ul>{events}</ul>"
467
+ f"<h2>Prompt</h2><pre>{esc(run.get('prompt') or '(not claimed yet)')}</pre>"
468
+ f"<h2>Output (tail)</h2><pre>{esc(run.get('output_tail') or '')}</pre>"
469
+ )
470
+ refresh = 5 if run["status"] in {"queued", "running"} else 0
471
+ return page(f"Run #{run['id']}", body, refresh)
472
+
473
+
474
+ def serve(hub: Hub, host: str, port: int, token: str | None, github_secret: str = "") -> DispatchboxServer:
475
+ if not token:
476
+ token = secrets.token_urlsafe(24)
477
+ log.warning("No DISPATCHBOX_TOKEN set. Generated one for this process: %s", token)
478
+ srv = DispatchboxServer((host, port), hub, token, github_secret)
479
+ srv.start_background()
480
+ return srv