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/__init__.py +3 -0
- dispatchbox/__main__.py +127 -0
- dispatchbox/chat.py +209 -0
- dispatchbox/cron.py +56 -0
- dispatchbox/feishu.py +304 -0
- dispatchbox/hub.py +550 -0
- dispatchbox/net.py +33 -0
- dispatchbox/rules.py +236 -0
- dispatchbox/server.py +480 -0
- dispatchbox/slack.py +251 -0
- dispatchbox/store.py +99 -0
- dispatchbox/worker.py +314 -0
- dispatchbox-0.1.0.dist-info/METADATA +293 -0
- dispatchbox-0.1.0.dist-info/RECORD +18 -0
- dispatchbox-0.1.0.dist-info/WHEEL +5 -0
- dispatchbox-0.1.0.dist-info/entry_points.txt +2 -0
- dispatchbox-0.1.0.dist-info/licenses/LICENSE +21 -0
- dispatchbox-0.1.0.dist-info/top_level.txt +1 -0
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
|