sys-buddy 1.2.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.
- sys_buddy/__init__.py +22 -0
- sys_buddy/admin.py +358 -0
- sys_buddy/api.py +844 -0
- sys_buddy/audit.py +34 -0
- sys_buddy/cli.py +419 -0
- sys_buddy/config.py +68 -0
- sys_buddy/contracts.py +200 -0
- sys_buddy/db.py +310 -0
- sys_buddy/gui.py +270 -0
- sys_buddy/gui_app.html +1389 -0
- sys_buddy/http_middleware.py +94 -0
- sys_buddy/identity.py +115 -0
- sys_buddy/join.html +456 -0
- sys_buddy/middleware.py +137 -0
- sys_buddy/onboarding.py +509 -0
- sys_buddy/pairing.py +293 -0
- sys_buddy/readiness.py +302 -0
- sys_buddy/rules.py +107 -0
- sys_buddy/server.py +61 -0
- sys_buddy/service.py +467 -0
- sys_buddy/slack.py +55 -0
- sys_buddy/state.py +1364 -0
- sys_buddy/todos.py +730 -0
- sys_buddy/tools.py +722 -0
- sys_buddy/ui.html +1346 -0
- sys_buddy/updates.py +110 -0
- sys_buddy-1.2.0.dist-info/METADATA +250 -0
- sys_buddy-1.2.0.dist-info/RECORD +31 -0
- sys_buddy-1.2.0.dist-info/WHEEL +4 -0
- sys_buddy-1.2.0.dist-info/entry_points.txt +2 -0
- sys_buddy-1.2.0.dist-info/licenses/LICENSE +21 -0
sys_buddy/api.py
ADDED
|
@@ -0,0 +1,844 @@
|
|
|
1
|
+
"""Read-only HTTP API for the dashboard (SPEC §11), with server-side scoping.
|
|
2
|
+
|
|
3
|
+
Guiding principle (SPEC §0): **the broker enforces, agents/clients request.** Viewer
|
|
4
|
+
scoping lives here, not in the browser. A buddy's ``viewer_token`` is bound to one
|
|
5
|
+
task, so ``/api/tasks`` returns exactly that one task and ``/api/task/{id}`` refuses
|
|
6
|
+
any other id with 403. The client never filters for security — the server only ever
|
|
7
|
+
hands back what the token permits (SPEC §9, §12 "two corrections to the prototype").
|
|
8
|
+
|
|
9
|
+
Every route is read-only, and stays that way (DECISIONS D11): the dashboard surfaces
|
|
10
|
+
state and tells the human what to type — it never acts. The viewer token is
|
|
11
|
+
read-scoped (D7), so a leaked ``?v=`` link must only ever be able to LOOK; a single
|
|
12
|
+
write route here would be the first crack in that. Host ACTIONS live in the CLI
|
|
13
|
+
(``sys-buddy todo drop``) and the desktop app, never behind this origin.
|
|
14
|
+
|
|
15
|
+
The query logic is factored into ``_``-prefixed helpers that take an open connection
|
|
16
|
+
so they can be unit-tested without a running HTTP server (see ``tests/test_api.py``);
|
|
17
|
+
the ``async def`` handlers are thin shells that resolve the viewer token, enforce
|
|
18
|
+
scope, open/close a connection, and serialise.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import json
|
|
25
|
+
import re
|
|
26
|
+
import time
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from starlette.responses import (
|
|
30
|
+
HTMLResponse,
|
|
31
|
+
JSONResponse,
|
|
32
|
+
RedirectResponse,
|
|
33
|
+
StreamingResponse,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
from . import identity, readiness, service, todos
|
|
37
|
+
from .config import Config
|
|
38
|
+
from .db import connect
|
|
39
|
+
from .identity import ViewerIdentity
|
|
40
|
+
|
|
41
|
+
# HTTP verb set is small; the event ``kind`` set is fixed by the state machine.
|
|
42
|
+
# ``todo`` is ONE kind carrying the specific action inside its detail (todos._event),
|
|
43
|
+
# so the dashboard's filter vocabulary stays fixed as the todo actions grow.
|
|
44
|
+
_EVENT_KINDS = {"task", "transition", "lock", "deploy", "test", "slack", "token", "todo"}
|
|
45
|
+
|
|
46
|
+
# Fallback for the UI's ``⟨api123⟩`` chip on rows written before the
|
|
47
|
+
# ``messages.todo_id`` column existed: scrape "todo #N" from the body. New rows
|
|
48
|
+
# carry the id in the column and never reach this; a matched id is validated against
|
|
49
|
+
# the task's real todos either way (see ``_messages_for``), so a stale "#999" gets
|
|
50
|
+
# no chip.
|
|
51
|
+
_TODO_REF_RE = re.compile(r"\btodos?\s*#(\d+)", re.IGNORECASE)
|
|
52
|
+
|
|
53
|
+
# Sort buckets for the todo panel. A PENDING todo is the only thing on that screen
|
|
54
|
+
# blocking a human — it is a request, not progress — so it sorts to the top; a dropped
|
|
55
|
+
# one no longer counts toward the task, so it sinks. Everything in between keeps
|
|
56
|
+
# creation order, which is the order the humans discussed it in.
|
|
57
|
+
_TODO_ORDER = {todos.PENDING: 0, todos.DROPPED: 2}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# --------------------------------------------------------------------------- #
|
|
61
|
+
# formatting helpers
|
|
62
|
+
# --------------------------------------------------------------------------- #
|
|
63
|
+
def _hhmm(ts: float | None) -> str:
|
|
64
|
+
"""A wall-clock ``HH:MM`` for the timeline/thread (mono in the design)."""
|
|
65
|
+
if ts is None:
|
|
66
|
+
return ""
|
|
67
|
+
return time.strftime("%H:%M", time.localtime(ts))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _time_ago(ts: float | None, *, now: float | None = None) -> str:
|
|
71
|
+
"""A coarse "time ago" for the task-list ``last`` column.
|
|
72
|
+
|
|
73
|
+
Coarse on purpose: the list refreshes every ~3s and only needs a glanceable
|
|
74
|
+
recency, not a precise duration. ``now`` is injectable so the derivation is
|
|
75
|
+
deterministic under test.
|
|
76
|
+
"""
|
|
77
|
+
if ts is None:
|
|
78
|
+
return ""
|
|
79
|
+
delta = (now if now is not None else time.time()) - ts
|
|
80
|
+
if delta < 5:
|
|
81
|
+
return "just now"
|
|
82
|
+
if delta < 60:
|
|
83
|
+
return f"{int(delta)}s ago"
|
|
84
|
+
if delta < 3600:
|
|
85
|
+
return f"{int(delta // 60)}m ago"
|
|
86
|
+
if delta < 86400:
|
|
87
|
+
return f"{int(delta // 3600)}h ago"
|
|
88
|
+
return f"{int(delta // 86400)}d ago"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _render_detail(kind: str, detail: dict) -> str:
|
|
92
|
+
"""Render a short human string for the event log from ``detail_json``.
|
|
93
|
+
|
|
94
|
+
The state machine writes a fixed detail shape per kind (see the task's
|
|
95
|
+
EVENT-LOG CONVENTION); we mirror those shapes here. Unknown/partial details
|
|
96
|
+
fall back to a compact JSON dump so a new event kind is still legible rather
|
|
97
|
+
than blank.
|
|
98
|
+
"""
|
|
99
|
+
if kind == "transition":
|
|
100
|
+
return f"{detail.get('from', '?')} → {detail.get('to', '?')}"
|
|
101
|
+
if kind == "lock":
|
|
102
|
+
signed = detail.get("signed") or []
|
|
103
|
+
who = f" ({', '.join(signed)})" if signed else ""
|
|
104
|
+
return f"Contract v{detail.get('version', '?')} locked{who}"
|
|
105
|
+
if kind == "deploy":
|
|
106
|
+
return detail.get("text", "deployed")
|
|
107
|
+
if kind == "test":
|
|
108
|
+
passed = detail.get("pass")
|
|
109
|
+
strike = detail.get("strike")
|
|
110
|
+
if passed:
|
|
111
|
+
return "Tests passed"
|
|
112
|
+
return f"Tests failed (strike {strike})" if strike is not None else "Tests failed"
|
|
113
|
+
if kind == "todo":
|
|
114
|
+
# One event kind, the action inside it (todos._event). Read the action back out
|
|
115
|
+
# so the log reads as a sentence instead of a JSON dump.
|
|
116
|
+
action = (detail.get("action") or "todo").removeprefix("todo_")
|
|
117
|
+
title = detail.get("title")
|
|
118
|
+
text = f"Todo #{detail.get('todo_id', '?')}"
|
|
119
|
+
if title:
|
|
120
|
+
text += f" '{title}'"
|
|
121
|
+
text += f" {action}"
|
|
122
|
+
if detail.get("by"):
|
|
123
|
+
text += f" by {detail['by']}"
|
|
124
|
+
if detail.get("reason"):
|
|
125
|
+
text += f": {detail['reason']}"
|
|
126
|
+
return text
|
|
127
|
+
# Everything else (slack, token, task, resolved, …) carries free-form detail;
|
|
128
|
+
# surface a text/message field if present, else a compact JSON dump.
|
|
129
|
+
return detail.get("text") or detail.get("message") or json.dumps(detail)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# --------------------------------------------------------------------------- #
|
|
133
|
+
# viewer resolution + scoping (server-side — the whole point of §11)
|
|
134
|
+
# --------------------------------------------------------------------------- #
|
|
135
|
+
def viewer_block(viewer: ViewerIdentity) -> dict:
|
|
136
|
+
"""The ``viewer`` object echoed to the UI so it can render a static scope badge.
|
|
137
|
+
|
|
138
|
+
A buddy also gets ``task_id`` (the one task they may see); a host does not,
|
|
139
|
+
because host means "all tasks". This is a *reflection* of the token's scope,
|
|
140
|
+
never a control the client can change (SPEC §12 correction #1).
|
|
141
|
+
"""
|
|
142
|
+
block: dict = {"mode": "host" if viewer.is_host else "buddy", "label": viewer.label}
|
|
143
|
+
if not viewer.is_host:
|
|
144
|
+
block["task_id"] = viewer.task_id
|
|
145
|
+
return block
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def viewer_can_see(viewer: ViewerIdentity, task_id: str) -> bool:
|
|
149
|
+
"""True iff this token is allowed to read ``task_id``. Host sees all; buddy one."""
|
|
150
|
+
return viewer.is_host or viewer.task_id == task_id
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _last_activity(conn, task_id: str, created_at: float) -> float:
|
|
154
|
+
"""Latest message/event timestamp for a task, falling back to task creation.
|
|
155
|
+
|
|
156
|
+
Falls back so a freshly created task with no traffic still shows *something*
|
|
157
|
+
sensible in the list's ``last`` column instead of an empty cell.
|
|
158
|
+
"""
|
|
159
|
+
row = conn.execute(
|
|
160
|
+
"""
|
|
161
|
+
SELECT MAX(t) AS last FROM (
|
|
162
|
+
SELECT created_at AS t FROM messages WHERE task_id = ?
|
|
163
|
+
UNION ALL
|
|
164
|
+
SELECT created_at AS t FROM events WHERE task_id = ?
|
|
165
|
+
)
|
|
166
|
+
""",
|
|
167
|
+
(task_id, task_id),
|
|
168
|
+
).fetchone()
|
|
169
|
+
return row["last"] if row and row["last"] is not None else created_at
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _list_tasks_for(conn, viewer: ViewerIdentity, *, now: float | None = None) -> list[dict]:
|
|
173
|
+
"""The task list the token is allowed to see (SPEC §11 ``/api/tasks``).
|
|
174
|
+
|
|
175
|
+
Scoping is a SQL ``WHERE``, not a post-filter: a buddy's query selects only
|
|
176
|
+
their one task, so no other task's existence is even observable to them.
|
|
177
|
+
"""
|
|
178
|
+
if viewer.is_host:
|
|
179
|
+
rows = conn.execute(
|
|
180
|
+
"SELECT id, title, state, mode, roles_json, strikes, created_at FROM tasks ORDER BY created_at"
|
|
181
|
+
).fetchall()
|
|
182
|
+
else:
|
|
183
|
+
rows = conn.execute(
|
|
184
|
+
"SELECT id, title, state, mode, roles_json, strikes, created_at FROM tasks WHERE id = ?",
|
|
185
|
+
(viewer.task_id,),
|
|
186
|
+
).fetchall()
|
|
187
|
+
|
|
188
|
+
out = []
|
|
189
|
+
for r in rows:
|
|
190
|
+
row = {
|
|
191
|
+
"id": r["id"],
|
|
192
|
+
"title": r["title"],
|
|
193
|
+
"state": r["state"],
|
|
194
|
+
"mode": r["mode"] or "contract",
|
|
195
|
+
"roles": json.loads(r["roles_json"]),
|
|
196
|
+
"last": _time_ago(_last_activity(conn, r["id"], r["created_at"]), now=now),
|
|
197
|
+
"strikes": r["strikes"],
|
|
198
|
+
}
|
|
199
|
+
# The list row carries the same rollup as the task view ("2/6 verified ⚠1") so a
|
|
200
|
+
# host can triage without opening anything. ABSENT — not empty — for a task with
|
|
201
|
+
# no todos, so a pre-todo row serialises byte-identically to before.
|
|
202
|
+
roll = todos.rollup(conn, r["id"])
|
|
203
|
+
if roll is not None:
|
|
204
|
+
row["todo_rollup"] = roll
|
|
205
|
+
out.append(row)
|
|
206
|
+
return out
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# --------------------------------------------------------------------------- #
|
|
210
|
+
# task detail building blocks
|
|
211
|
+
# --------------------------------------------------------------------------- #
|
|
212
|
+
def _times_for(conn, task_id: str, created_at: float) -> dict:
|
|
213
|
+
"""``times[state]`` = HH:MM the task entered that state.
|
|
214
|
+
|
|
215
|
+
``open`` is the task's own creation time; every other state comes from the
|
|
216
|
+
``to`` field of a ``transition`` event. Only states actually reached appear
|
|
217
|
+
(so ``verified``/``stuck`` are naturally optional).
|
|
218
|
+
"""
|
|
219
|
+
times = {"open": _hhmm(created_at)}
|
|
220
|
+
rows = conn.execute(
|
|
221
|
+
"SELECT detail_json, created_at FROM events WHERE task_id = ? AND kind = 'transition' ORDER BY id",
|
|
222
|
+
(task_id,),
|
|
223
|
+
).fetchall()
|
|
224
|
+
for r in rows:
|
|
225
|
+
detail = json.loads(r["detail_json"])
|
|
226
|
+
to = detail.get("to")
|
|
227
|
+
if to:
|
|
228
|
+
times[to] = _hhmm(r["created_at"])
|
|
229
|
+
return times
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _contract_for(conn, task_id: str, *, todo_id: int | None = None) -> dict:
|
|
233
|
+
"""The contract block: versions, the default to show, and per-version data.
|
|
234
|
+
|
|
235
|
+
``default`` is the latest *locked* version (that's the one agents integrate
|
|
236
|
+
against), or the latest version if none is locked yet, or ``None`` when no
|
|
237
|
+
contract has been proposed. Empty state: ``exists=False`` with empty
|
|
238
|
+
``versions``/``data`` so the UI can render its "awaiting contract" panel.
|
|
239
|
+
|
|
240
|
+
``todo_id=None`` means the TASK-level chain (``contracts.todo_id IS NULL``) — every
|
|
241
|
+
contract that existed before todos, and every task that never grows one. Passing a
|
|
242
|
+
todo id gives that deliverable's own chain, which is what the right-hand panel
|
|
243
|
+
renders when a todo is selected: same shape, same renderer, one level down. The two
|
|
244
|
+
are kept apart on purpose — folding six todos' contracts into the task card would
|
|
245
|
+
show a jumble of unrelated shapes under a single "API contract" heading.
|
|
246
|
+
"""
|
|
247
|
+
if todo_id is None:
|
|
248
|
+
contracts = conn.execute(
|
|
249
|
+
"SELECT id, version, spec_json, status FROM contracts "
|
|
250
|
+
"WHERE task_id = ? AND todo_id IS NULL ORDER BY version",
|
|
251
|
+
(task_id,),
|
|
252
|
+
).fetchall()
|
|
253
|
+
else:
|
|
254
|
+
contracts = conn.execute(
|
|
255
|
+
"SELECT id, version, spec_json, status FROM contracts "
|
|
256
|
+
"WHERE task_id = ? AND todo_id = ? ORDER BY version",
|
|
257
|
+
(task_id, todo_id),
|
|
258
|
+
).fetchall()
|
|
259
|
+
if not contracts:
|
|
260
|
+
return {"exists": False, "versions": [], "default": None, "data": {}}
|
|
261
|
+
|
|
262
|
+
versions = []
|
|
263
|
+
data: dict = {}
|
|
264
|
+
latest_vid = None
|
|
265
|
+
latest_locked_vid = None
|
|
266
|
+
for c in contracts:
|
|
267
|
+
vid = f"v{c['version']}"
|
|
268
|
+
locked = c["status"] == "locked"
|
|
269
|
+
versions.append({"id": vid, "locked": locked})
|
|
270
|
+
latest_vid = vid
|
|
271
|
+
if locked:
|
|
272
|
+
latest_locked_vid = vid
|
|
273
|
+
|
|
274
|
+
signed = conn.execute(
|
|
275
|
+
"""
|
|
276
|
+
SELECT a.role AS role, s.signed_at AS signed_at
|
|
277
|
+
FROM contract_signatures s
|
|
278
|
+
JOIN agents a ON a.id = s.agent_id
|
|
279
|
+
WHERE s.contract_id = ?
|
|
280
|
+
ORDER BY s.signed_at
|
|
281
|
+
""",
|
|
282
|
+
(c["id"],),
|
|
283
|
+
).fetchall()
|
|
284
|
+
spec = json.loads(c["spec_json"])
|
|
285
|
+
data[vid] = {
|
|
286
|
+
"locked": locked,
|
|
287
|
+
"signed": [{"role": s["role"], "time": _hhmm(s["signed_at"])} for s in signed],
|
|
288
|
+
"endpoints": spec.get("endpoints", []),
|
|
289
|
+
"staging_url": spec.get("staging_url"),
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
"exists": True,
|
|
294
|
+
"versions": versions,
|
|
295
|
+
"default": latest_locked_vid or latest_vid,
|
|
296
|
+
"data": data,
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _todos_for(conn, task_id: str) -> list[dict]:
|
|
301
|
+
"""The todo panel: every todo on the task, each with its own contract block.
|
|
302
|
+
|
|
303
|
+
The wire shape is ``todos.to_dict`` verbatim (so the dashboard and the agents'
|
|
304
|
+
``get_todos`` never drift) plus two view-only additions: ``time`` for the mono
|
|
305
|
+
HH:MM the rest of the dashboard uses, and ``contract`` — this deliverable's own
|
|
306
|
+
chain in exactly the shape the task card already renders.
|
|
307
|
+
|
|
308
|
+
Nothing is withheld by stage: a todo is a title, a scope and a party list, with no
|
|
309
|
+
``staging_url`` equivalent to protect until agreement (its contract block does the
|
|
310
|
+
withholding that needs doing, at the task level, as before).
|
|
311
|
+
"""
|
|
312
|
+
out = []
|
|
313
|
+
for t in todos.get_todos(conn, task_id):
|
|
314
|
+
d = dict(t)
|
|
315
|
+
d["time"] = _hhmm(t["created_at"])
|
|
316
|
+
d["contract"] = _contract_for(conn, task_id, todo_id=t["id"])
|
|
317
|
+
out.append(d)
|
|
318
|
+
out.sort(key=lambda t: (_TODO_ORDER.get(t["status"], 1), t["id"]))
|
|
319
|
+
return out
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _messages_for(conn, task_id: str) -> list[dict]:
|
|
323
|
+
"""Agent messages for the thread, oldest-first, with role joined in.
|
|
324
|
+
|
|
325
|
+
``body`` is the decoded ``body_json``. Bodies are stored by the messaging
|
|
326
|
+
core as a JSON string (``service.post_message`` does ``json.dumps(body)``), so
|
|
327
|
+
the common case decodes to a plain string. We also tolerate a dict body
|
|
328
|
+
(forward-compat: a structured envelope may carry ``code``/``strike``) and lift
|
|
329
|
+
those optional fields when present.
|
|
330
|
+
|
|
331
|
+
``strike`` is otherwise derived for ``test_result`` messages by zipping them,
|
|
332
|
+
in order, to the ``test`` events the state machine writes 1:1 for each — so a
|
|
333
|
+
failing test shows "strike N" without trusting anything in the free-form body.
|
|
334
|
+
|
|
335
|
+
``todo`` is the id of the deliverable a message belongs to, when it names one — the
|
|
336
|
+
UI's ``⟨api123⟩`` chip. ONE thread per task is deliberate (six threads would
|
|
337
|
+
fragment a conversation that is genuinely one conversation, and you would lose "we
|
|
338
|
+
discussed refunds while building payments"), so the chip is how a message is
|
|
339
|
+
attributed to a deliverable rather than filed under it. Absent when the message
|
|
340
|
+
belongs to no todo.
|
|
341
|
+
|
|
342
|
+
Broker-authored notifications (``service.BROKER_TYPES``, e.g. ``contract_locked``)
|
|
343
|
+
are attributed to the ``broker`` role rather than to the agent row that triggered
|
|
344
|
+
them: the human thread must not show broker words in a peer's voice. They render
|
|
345
|
+
once, as a single bubble — the ``lock`` EVENT is what draws the thread divider, and
|
|
346
|
+
the two are 1:1, so nothing is duplicated.
|
|
347
|
+
"""
|
|
348
|
+
# Strikes recorded by the broker for each test cycle, in order.
|
|
349
|
+
test_strikes = [
|
|
350
|
+
json.loads(e["detail_json"]).get("strike")
|
|
351
|
+
for e in conn.execute(
|
|
352
|
+
"SELECT detail_json FROM events WHERE task_id = ? AND kind = 'test' ORDER BY id",
|
|
353
|
+
(task_id,),
|
|
354
|
+
).fetchall()
|
|
355
|
+
]
|
|
356
|
+
|
|
357
|
+
rows = conn.execute(
|
|
358
|
+
"""
|
|
359
|
+
SELECT m.id, m.type, m.body_json, m.to_role, m.todo_id, m.created_at, a.role AS role
|
|
360
|
+
FROM messages m
|
|
361
|
+
JOIN agents a ON a.id = m.from_agent_id
|
|
362
|
+
WHERE m.task_id = ?
|
|
363
|
+
ORDER BY m.id
|
|
364
|
+
""",
|
|
365
|
+
(task_id,),
|
|
366
|
+
).fetchall()
|
|
367
|
+
|
|
368
|
+
out = []
|
|
369
|
+
test_idx = 0
|
|
370
|
+
todo_ids: set[int] | None = None # loaded lazily: a pre-todo task never queries it
|
|
371
|
+
for r in rows:
|
|
372
|
+
body = json.loads(r["body_json"])
|
|
373
|
+
role = service.BROKER_ROLE if r["type"] in service.BROKER_TYPES else r["role"]
|
|
374
|
+
msg: dict = {"id": r["id"], "role": role, "type": r["type"], "to_role": r["to_role"], "time": _hhmm(r["created_at"]), "ts": r["created_at"]}
|
|
375
|
+
if isinstance(body, dict):
|
|
376
|
+
msg["body"] = body.get("text") or body.get("body") or ""
|
|
377
|
+
if "code" in body:
|
|
378
|
+
msg["code"] = body["code"]
|
|
379
|
+
if "strike" in body:
|
|
380
|
+
msg["strike"] = body["strike"]
|
|
381
|
+
else:
|
|
382
|
+
msg["body"] = body
|
|
383
|
+
|
|
384
|
+
if r["type"] == "test_result" and "strike" not in msg:
|
|
385
|
+
if test_idx < len(test_strikes):
|
|
386
|
+
strike = test_strikes[test_idx]
|
|
387
|
+
if strike is not None:
|
|
388
|
+
msg["strike"] = strike
|
|
389
|
+
test_idx += 1
|
|
390
|
+
|
|
391
|
+
# Which deliverable this message belongs to (the UI's ⟨todo⟩ chip). The
|
|
392
|
+
# authoritative source is the ``messages.todo_id`` column, set at post time.
|
|
393
|
+
# A row written before that column existed carries NULL, so we fall back to
|
|
394
|
+
# scraping "todo #N" from any body that references one. Either candidate is
|
|
395
|
+
# validated against this TASK's real todos, so a stale/foreign "#999" gets no
|
|
396
|
+
# chip rather than one pointing at nothing.
|
|
397
|
+
cand = r["todo_id"]
|
|
398
|
+
if cand is None:
|
|
399
|
+
ref = _TODO_REF_RE.search(str(msg.get("body") or ""))
|
|
400
|
+
cand = int(ref.group(1)) if ref is not None else None
|
|
401
|
+
if cand is not None:
|
|
402
|
+
if todo_ids is None:
|
|
403
|
+
todo_ids = {
|
|
404
|
+
row["id"]
|
|
405
|
+
for row in conn.execute(
|
|
406
|
+
"SELECT id FROM todos WHERE task_id = ?", (task_id,)
|
|
407
|
+
).fetchall()
|
|
408
|
+
}
|
|
409
|
+
if cand in todo_ids:
|
|
410
|
+
msg["todo"] = cand
|
|
411
|
+
out.append(msg)
|
|
412
|
+
return out
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _events_for(conn, task_id: str, filter: str = "all") -> list[dict]:
|
|
416
|
+
"""The event log as ``[[time, kind, detail], ...]``, oldest-first.
|
|
417
|
+
|
|
418
|
+
``filter`` narrows to one ``kind``; anything outside the known set (or
|
|
419
|
+
``all``/empty) means no filter. Returns ``[]`` for a task with no events.
|
|
420
|
+
"""
|
|
421
|
+
if filter and filter != "all" and filter in _EVENT_KINDS:
|
|
422
|
+
rows = conn.execute(
|
|
423
|
+
"SELECT kind, detail_json, created_at FROM events WHERE task_id = ? AND kind = ? ORDER BY id",
|
|
424
|
+
(task_id, filter),
|
|
425
|
+
).fetchall()
|
|
426
|
+
else:
|
|
427
|
+
rows = conn.execute(
|
|
428
|
+
"SELECT kind, detail_json, created_at FROM events WHERE task_id = ? ORDER BY id",
|
|
429
|
+
(task_id,),
|
|
430
|
+
).fetchall()
|
|
431
|
+
# 4th element is the raw created_at (float) so the client can sort the thread by
|
|
432
|
+
# true creation time, not just minute precision. Existing consumers use [0:3].
|
|
433
|
+
return [[_hhmm(r["created_at"]), r["kind"], _render_detail(r["kind"], json.loads(r["detail_json"])), r["created_at"]] for r in rows]
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _agents_for(conn, task_id: str) -> list[dict]:
|
|
437
|
+
"""Live agents on the task (revoked_at IS NULL), with their pre-flight readiness.
|
|
438
|
+
|
|
439
|
+
``ready`` is a bool so the UI can padlock any agent that hasn't yet passed
|
|
440
|
+
``submit_readiness``. ``readiness_status`` (pending/passed/failed) distinguishes a
|
|
441
|
+
FAILED attempt from a not-yet-attempted one — ``ready`` alone can't — and
|
|
442
|
+
``readiness_report`` carries the per-question results (parsed) so the human can see
|
|
443
|
+
WHY it failed and coach the agent to retry.
|
|
444
|
+
|
|
445
|
+
``listening`` is presence: the agent is parked in ``wait_for_message`` right now.
|
|
446
|
+
It's computed here (``listening_until > now``) rather than stored as a boolean, so
|
|
447
|
+
a broker crash that never cleared the stamp lapses on its own. ``listening_since``
|
|
448
|
+
is the streak start, for the dashboard's "listening — 42m".
|
|
449
|
+
"""
|
|
450
|
+
rows = conn.execute(
|
|
451
|
+
"SELECT name, role, ready, readiness_status, readiness_report, "
|
|
452
|
+
"listening_until, listening_since "
|
|
453
|
+
"FROM agents WHERE task_id = ? AND revoked_at IS NULL ORDER BY id",
|
|
454
|
+
(task_id,),
|
|
455
|
+
).fetchall()
|
|
456
|
+
now = time.time()
|
|
457
|
+
out = []
|
|
458
|
+
for r in rows:
|
|
459
|
+
try:
|
|
460
|
+
report = json.loads(r["readiness_report"]) if r["readiness_report"] else None
|
|
461
|
+
except (ValueError, TypeError):
|
|
462
|
+
report = None
|
|
463
|
+
out.append({
|
|
464
|
+
"name": r["name"],
|
|
465
|
+
"role": r["role"],
|
|
466
|
+
"ready": bool(r["ready"]),
|
|
467
|
+
"readiness_status": r["readiness_status"] or "pending",
|
|
468
|
+
"readiness_report": report,
|
|
469
|
+
"listening": service.is_listening(r["listening_until"], now),
|
|
470
|
+
"listening_since": r["listening_since"],
|
|
471
|
+
})
|
|
472
|
+
return out
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _task_detail(conn, task_id: str) -> dict | None:
|
|
476
|
+
"""Full per-task payload (SPEC §11 ``/api/task/{id}``), or ``None`` if absent.
|
|
477
|
+
|
|
478
|
+
Composes the building blocks above. Callers must already have checked viewer
|
|
479
|
+
scope; this function is scope-agnostic (it's reused by both host and buddy).
|
|
480
|
+
|
|
481
|
+
The three ``todo*`` keys are ADDITIVE and OMITTED ENTIRELY for a task that has no
|
|
482
|
+
todos, so every pre-todo task serialises byte-identically to before this feature —
|
|
483
|
+
that is what keeps the deployed ``ui.html`` working, and it is the same "the single
|
|
484
|
+
switch" property ``todos.has_todos`` gives the tools. In the other direction,
|
|
485
|
+
``ui.html`` is served from disk and can be NEWER than the running ``api.py`` across a
|
|
486
|
+
restart, so the page must treat all three as optional (``d.todos || []``) exactly as
|
|
487
|
+
it already falls back on the thread's raw ``ts``.
|
|
488
|
+
"""
|
|
489
|
+
t = conn.execute(
|
|
490
|
+
"SELECT id, title, state, mode, roles_json, strikes, created_at FROM tasks WHERE id = ?",
|
|
491
|
+
(task_id,),
|
|
492
|
+
).fetchone()
|
|
493
|
+
if t is None:
|
|
494
|
+
return None
|
|
495
|
+
detail = {
|
|
496
|
+
"id": t["id"],
|
|
497
|
+
"title": t["title"],
|
|
498
|
+
"state": t["state"],
|
|
499
|
+
"mode": t["mode"] or "contract",
|
|
500
|
+
"roles": json.loads(t["roles_json"]),
|
|
501
|
+
"strikes": t["strikes"],
|
|
502
|
+
"times": _times_for(conn, task_id, t["created_at"]),
|
|
503
|
+
"contract": _contract_for(conn, task_id),
|
|
504
|
+
"messages": _messages_for(conn, task_id),
|
|
505
|
+
"events": _events_for(conn, task_id),
|
|
506
|
+
"agents": _agents_for(conn, task_id),
|
|
507
|
+
"readiness_preview": readiness.preview_questions(),
|
|
508
|
+
}
|
|
509
|
+
todo_list = _todos_for(conn, task_id)
|
|
510
|
+
if todo_list:
|
|
511
|
+
# `has_todos` is the stepper switch and is NOT `len(todos) > 0`: a task whose
|
|
512
|
+
# todos were all DROPPED runs on its own state machine again (todos.has_todos),
|
|
513
|
+
# but the dropped rows stay visible so the human reads a decision rather than
|
|
514
|
+
# finding a hole. `todo_rollup` is None in exactly that case.
|
|
515
|
+
detail["has_todos"] = todos.has_todos(conn, task_id)
|
|
516
|
+
detail["todos"] = todo_list
|
|
517
|
+
detail["todo_rollup"] = todos.rollup(conn, task_id)
|
|
518
|
+
return detail
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
# --------------------------------------------------------------------------- #
|
|
522
|
+
# live-stream change detection (SSE — see the /api/stream route)
|
|
523
|
+
# --------------------------------------------------------------------------- #
|
|
524
|
+
def _change_tokens(conn, viewer: ViewerIdentity) -> tuple[str, dict[str, str]]:
|
|
525
|
+
"""Opaque change-detection tokens for the SSE stream, scoped to the viewer.
|
|
526
|
+
|
|
527
|
+
Returns ``(list_token, {task_id: task_token})``:
|
|
528
|
+
|
|
529
|
+
* ``list_token`` fingerprints the viewer's VISIBLE task list — its membership
|
|
530
|
+
plus each task's state/strikes and latest activity — so it moves whenever a
|
|
531
|
+
task appears, changes state, or gains new traffic (drives the ``tasks`` event).
|
|
532
|
+
* each ``task_token`` fingerprints one task's DETAIL — state/strikes, latest
|
|
533
|
+
activity, message/event/agent counts, agent readiness, and contract
|
|
534
|
+
version/lock/signature state — so it moves whenever that task's detail
|
|
535
|
+
changes (drives the ``task`` event).
|
|
536
|
+
|
|
537
|
+
Pure and connection-taking like the sibling ``_``-helpers, and built ONLY from
|
|
538
|
+
the existing query helpers so the viewer scoping/visibility matches the JSON
|
|
539
|
+
routes exactly (no duplicated query logic). Tokens are opaque strings; only
|
|
540
|
+
this module ever computes or compares them.
|
|
541
|
+
"""
|
|
542
|
+
list_parts: list[str] = []
|
|
543
|
+
task_tokens: dict[str, str] = {}
|
|
544
|
+
for t in _list_tasks_for(conn, viewer):
|
|
545
|
+
tid = t["id"]
|
|
546
|
+
row = conn.execute("SELECT created_at FROM tasks WHERE id = ?", (tid,)).fetchone()
|
|
547
|
+
created_at = row["created_at"] if row else 0.0
|
|
548
|
+
last = _last_activity(conn, tid, created_at)
|
|
549
|
+
|
|
550
|
+
# List-level fingerprint: membership + coarse per-task state (NOT the
|
|
551
|
+
# wall-clock "last ago" string, which would churn every second). The rollup
|
|
552
|
+
# counts join it so the row's "2/6 verified ⚠1" refreshes live; the suffix is
|
|
553
|
+
# absent for a task with no todos, leaving pre-todo tokens unchanged.
|
|
554
|
+
roll = t.get("todo_rollup")
|
|
555
|
+
roll_part = (
|
|
556
|
+
f"|{roll['verified']}/{roll['total']}|{roll['pending']}|{roll['stuck']}|{roll['state']}"
|
|
557
|
+
if roll else ""
|
|
558
|
+
)
|
|
559
|
+
list_parts.append(f"{tid}|{t['state']}|{t['strikes']}|{last!r}{roll_part}")
|
|
560
|
+
|
|
561
|
+
# Detail-level fingerprint: reuse the same helpers the /api/task route
|
|
562
|
+
# composes from, then reduce to counts + the fields the UI actually renders.
|
|
563
|
+
contract = _contract_for(conn, tid)
|
|
564
|
+
fingerprint = {
|
|
565
|
+
"state": t["state"],
|
|
566
|
+
"strikes": t["strikes"],
|
|
567
|
+
"last": last,
|
|
568
|
+
"messages": len(_messages_for(conn, tid)),
|
|
569
|
+
"events": len(_events_for(conn, tid)),
|
|
570
|
+
# `listening` is in the fingerprint (the bool, never the raw expiry) so the
|
|
571
|
+
# live presence dot flips over SSE. It moves only on a start/stop — unlike
|
|
572
|
+
# the timestamp, which would churn the token every tick.
|
|
573
|
+
"agents": [
|
|
574
|
+
[a["role"], a["ready"], a["readiness_status"], a["listening"]]
|
|
575
|
+
for a in _agents_for(conn, tid)
|
|
576
|
+
],
|
|
577
|
+
"contract": [
|
|
578
|
+
[v["id"], v["locked"], len(contract["data"][v["id"]]["signed"])]
|
|
579
|
+
for v in contract["versions"]
|
|
580
|
+
],
|
|
581
|
+
}
|
|
582
|
+
# Todos move the detail token on every part the panel renders — a new acceptance,
|
|
583
|
+
# a decline, a repropose, a march step, a stuck flag, a drop. Added as a key only
|
|
584
|
+
# when the task HAS todos, so a pre-todo task's token is byte-identical to before.
|
|
585
|
+
task_todos = _todos_for(conn, tid)
|
|
586
|
+
if task_todos:
|
|
587
|
+
fingerprint["todos"] = [
|
|
588
|
+
[
|
|
589
|
+
d["id"], d["version"], d["status"], d["state"], d["stuck"],
|
|
590
|
+
d["accepted_by"], d["declined_by"], d["parties"],
|
|
591
|
+
d["contract_versions"], d["locked_versions"], d["drop_consents"],
|
|
592
|
+
]
|
|
593
|
+
for d in task_todos
|
|
594
|
+
]
|
|
595
|
+
task_tokens[tid] = json.dumps(fingerprint, sort_keys=True)
|
|
596
|
+
|
|
597
|
+
return json.dumps(sorted(list_parts)), task_tokens
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
async def _sse_events(
|
|
601
|
+
request,
|
|
602
|
+
viewer: ViewerIdentity,
|
|
603
|
+
*,
|
|
604
|
+
poll: float = 1.0,
|
|
605
|
+
ping_every: float = 15.0,
|
|
606
|
+
idle_timeout: float = 30 * 60.0,
|
|
607
|
+
):
|
|
608
|
+
"""Async generator of SSE frames for one viewer's ``/api/stream`` connection.
|
|
609
|
+
|
|
610
|
+
Each iteration reads the viewer's current change tokens from the LOCAL db
|
|
611
|
+
(cheap; never crosses the tunnel), diffs against the last-seen set, and yields
|
|
612
|
+
one frame per changed channel::
|
|
613
|
+
|
|
614
|
+
event: tasks\\n
|
|
615
|
+
data: {"token": "..."}\\n\\n # visible list changed
|
|
616
|
+
|
|
617
|
+
event: task\\n
|
|
618
|
+
data: {"id": "...", "token": "..."}\\n\\n # one task's detail changed
|
|
619
|
+
|
|
620
|
+
plus a bare ``: ping`` comment roughly every ``ping_every`` seconds so idle
|
|
621
|
+
proxies/tunnels don't drop the socket. The current tokens are captured as the
|
|
622
|
+
baseline on entry and are NOT emitted, so a freshly-opened stream stays silent
|
|
623
|
+
until something actually changes (the client does its own catch-up fetch on
|
|
624
|
+
open — SSE contract §"send nothing until the first real change"). The loop
|
|
625
|
+
exits when the client disconnects, or after ``idle_timeout`` seconds with no
|
|
626
|
+
emitted change (a still-present browser just auto-reconnects). ``poll`` /
|
|
627
|
+
``ping_every`` / ``idle_timeout`` are keyword knobs so the loop can be driven
|
|
628
|
+
deterministically under test.
|
|
629
|
+
"""
|
|
630
|
+
conn = connect()
|
|
631
|
+
try:
|
|
632
|
+
last_list, last_tasks = _change_tokens(conn, viewer)
|
|
633
|
+
finally:
|
|
634
|
+
conn.close()
|
|
635
|
+
|
|
636
|
+
last_ping = time.monotonic()
|
|
637
|
+
last_change = time.monotonic()
|
|
638
|
+
while True:
|
|
639
|
+
if await request.is_disconnected():
|
|
640
|
+
break
|
|
641
|
+
now = time.monotonic()
|
|
642
|
+
if now - last_change >= idle_timeout:
|
|
643
|
+
break
|
|
644
|
+
|
|
645
|
+
conn = connect()
|
|
646
|
+
try:
|
|
647
|
+
list_token, task_tokens = _change_tokens(conn, viewer)
|
|
648
|
+
finally:
|
|
649
|
+
conn.close()
|
|
650
|
+
|
|
651
|
+
changed = False
|
|
652
|
+
if list_token != last_list:
|
|
653
|
+
last_list = list_token
|
|
654
|
+
changed = True
|
|
655
|
+
yield f"event: tasks\ndata: {json.dumps({'token': list_token})}\n\n"
|
|
656
|
+
for tid, tok in task_tokens.items():
|
|
657
|
+
if last_tasks.get(tid) != tok:
|
|
658
|
+
changed = True
|
|
659
|
+
yield f"event: task\ndata: {json.dumps({'id': tid, 'token': tok})}\n\n"
|
|
660
|
+
# Reassigning (rather than updating) drops tokens for tasks that vanished.
|
|
661
|
+
last_tasks = task_tokens
|
|
662
|
+
|
|
663
|
+
if changed:
|
|
664
|
+
last_change = now
|
|
665
|
+
if now - last_ping >= ping_every:
|
|
666
|
+
last_ping = now
|
|
667
|
+
yield ": ping\n\n"
|
|
668
|
+
|
|
669
|
+
await asyncio.sleep(poll)
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
# --------------------------------------------------------------------------- #
|
|
673
|
+
# HTTP plumbing
|
|
674
|
+
# --------------------------------------------------------------------------- #
|
|
675
|
+
def _request_token(request) -> str:
|
|
676
|
+
"""Resolve the viewer token, most-secure source first:
|
|
677
|
+
|
|
678
|
+
1. the ``sb_view`` HttpOnly cookie (set on the first ``/ui`` load — JS can't read
|
|
679
|
+
it and it never rides in a URL, so it can't leak via history/Referer/logs),
|
|
680
|
+
2. an ``Authorization: Bearer`` header (API clients),
|
|
681
|
+
3. the ``?v=`` query param (the bootstrap link, before the cookie is set).
|
|
682
|
+
"""
|
|
683
|
+
cookie = request.cookies.get("sb_view")
|
|
684
|
+
if cookie:
|
|
685
|
+
return cookie
|
|
686
|
+
auth = request.headers.get("authorization", "")
|
|
687
|
+
if auth[:7].lower() == "bearer ":
|
|
688
|
+
return auth[7:].strip()
|
|
689
|
+
return request.query_params.get("v", "") or ""
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _resolve(request, conn) -> ViewerIdentity | None:
|
|
693
|
+
return identity.resolve_viewer_token(conn, _request_token(request))
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
# --------------------------------------------------------------------------- #
|
|
697
|
+
# route registration
|
|
698
|
+
# --------------------------------------------------------------------------- #
|
|
699
|
+
def register_api_routes(mcp, cfg: Config) -> None:
|
|
700
|
+
"""Register the read-only dashboard routes on the FastMCP app.
|
|
701
|
+
|
|
702
|
+
Every route below is ``methods=["GET"]`` and that is a HARD invariant, not a
|
|
703
|
+
coincidence (D11): there is no POST/PUT/PATCH/DELETE on this surface, including for
|
|
704
|
+
the host's todo drop. A host acts through ``sys-buddy todo drop`` or the desktop app,
|
|
705
|
+
whose window loads from disk on a trusted origin; the dashboard's ``[Drop todo]``
|
|
706
|
+
only ever deep-links there, the way ``gui._DashApi.new_task`` already does.
|
|
707
|
+
|
|
708
|
+
``cfg`` is accepted for symmetry with ``register_tools`` and future use (e.g.
|
|
709
|
+
mode-dependent behaviour); the routes themselves are mode-independent.
|
|
710
|
+
"""
|
|
711
|
+
|
|
712
|
+
@mcp.custom_route("/api/tasks", methods=["GET"])
|
|
713
|
+
async def api_tasks(request):
|
|
714
|
+
conn = connect()
|
|
715
|
+
try:
|
|
716
|
+
viewer = _resolve(request, conn)
|
|
717
|
+
if viewer is None:
|
|
718
|
+
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
|
719
|
+
return JSONResponse(
|
|
720
|
+
{"viewer": viewer_block(viewer), "tasks": _list_tasks_for(conn, viewer)}
|
|
721
|
+
)
|
|
722
|
+
finally:
|
|
723
|
+
conn.close()
|
|
724
|
+
|
|
725
|
+
@mcp.custom_route("/api/task/{id}", methods=["GET"])
|
|
726
|
+
async def api_task(request):
|
|
727
|
+
task_id = request.path_params["id"]
|
|
728
|
+
conn = connect()
|
|
729
|
+
try:
|
|
730
|
+
viewer = _resolve(request, conn)
|
|
731
|
+
if viewer is None:
|
|
732
|
+
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
|
733
|
+
if not viewer_can_see(viewer, task_id):
|
|
734
|
+
return JSONResponse({"error": "forbidden"}, status_code=403)
|
|
735
|
+
detail = _task_detail(conn, task_id)
|
|
736
|
+
if detail is None:
|
|
737
|
+
return JSONResponse({"error": "not found"}, status_code=404)
|
|
738
|
+
return JSONResponse(detail)
|
|
739
|
+
finally:
|
|
740
|
+
conn.close()
|
|
741
|
+
|
|
742
|
+
@mcp.custom_route("/api/task/{id}/events", methods=["GET"])
|
|
743
|
+
async def api_task_events(request):
|
|
744
|
+
task_id = request.path_params["id"]
|
|
745
|
+
filter = request.query_params.get("filter", "all")
|
|
746
|
+
conn = connect()
|
|
747
|
+
try:
|
|
748
|
+
viewer = _resolve(request, conn)
|
|
749
|
+
if viewer is None:
|
|
750
|
+
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
|
751
|
+
if not viewer_can_see(viewer, task_id):
|
|
752
|
+
return JSONResponse({"error": "forbidden"}, status_code=403)
|
|
753
|
+
# 404 the unknown task so a buddy can't distinguish "no such task" via events.
|
|
754
|
+
exists = conn.execute("SELECT 1 FROM tasks WHERE id = ?", (task_id,)).fetchone()
|
|
755
|
+
if exists is None:
|
|
756
|
+
return JSONResponse({"error": "not found"}, status_code=404)
|
|
757
|
+
return JSONResponse(_events_for(conn, task_id, filter))
|
|
758
|
+
finally:
|
|
759
|
+
conn.close()
|
|
760
|
+
|
|
761
|
+
@mcp.custom_route("/api/stream", methods=["GET"])
|
|
762
|
+
async def api_stream(request):
|
|
763
|
+
"""Server-Sent-Events feed of change notifications for the dashboard.
|
|
764
|
+
|
|
765
|
+
Same viewer-cookie auth as the other ``/api/*`` routes — an unauthenticated
|
|
766
|
+
request gets the SAME 401, never an open stream. Once authorised, hand back
|
|
767
|
+
a long-lived ``text/event-stream`` whose frames tell the client *what* to
|
|
768
|
+
refetch (``tasks`` / ``task``), never the data itself; the actual reads still
|
|
769
|
+
go through the token-scoped JSON routes. See ``_sse_events`` for the frame
|
|
770
|
+
format and the connection lifecycle.
|
|
771
|
+
"""
|
|
772
|
+
conn = connect()
|
|
773
|
+
try:
|
|
774
|
+
viewer = _resolve(request, conn)
|
|
775
|
+
finally:
|
|
776
|
+
conn.close()
|
|
777
|
+
if viewer is None:
|
|
778
|
+
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
|
779
|
+
return StreamingResponse(
|
|
780
|
+
_sse_events(request, viewer),
|
|
781
|
+
media_type="text/event-stream",
|
|
782
|
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
@mcp.custom_route("/api/version", methods=["GET"])
|
|
786
|
+
async def api_version(request):
|
|
787
|
+
"""The version THIS broker process is running — the one number nothing else
|
|
788
|
+
can supply.
|
|
789
|
+
|
|
790
|
+
Deliberately UNAUTHENTICATED: it exposes only a version string (no task data),
|
|
791
|
+
and the two readers that need it — the desktop app deciding whether to nag a
|
|
792
|
+
restart, and the dashboard banner — may have no viewer token in hand. It stays
|
|
793
|
+
GET-only like every other route here (D11).
|
|
794
|
+
|
|
795
|
+
Why an endpoint at all, when ``__version__`` is importable? Because the caller
|
|
796
|
+
is usually a DIFFERENT process from this broker (the GUI attaches to whatever
|
|
797
|
+
broker is already on the port; the dashboard is a browser page; a remote/Docker
|
|
798
|
+
broker shares no memory at all). Reading a local ``__version__`` reports what is
|
|
799
|
+
INSTALLED, not what is RUNNING here — and a broker still serving last week's code
|
|
800
|
+
after an upgrade is exactly the drift this endpoint exists to expose.
|
|
801
|
+
"""
|
|
802
|
+
from sys_buddy import __version__
|
|
803
|
+
|
|
804
|
+
return JSONResponse({"version": __version__})
|
|
805
|
+
|
|
806
|
+
@mcp.custom_route("/ui", methods=["GET"])
|
|
807
|
+
async def ui(request):
|
|
808
|
+
"""Serve the packaged single-file dashboard.
|
|
809
|
+
|
|
810
|
+
The page is inert and reads data only from ``/api/*`` (token-scoped). A
|
|
811
|
+
dashboard link arrives as ``/ui?v=<token>``; we move that token into an
|
|
812
|
+
HttpOnly cookie and redirect to a clean ``/ui`` so the secret leaves the URL
|
|
813
|
+
after the first hop (out of browser history, Referer, and proxy logs). The
|
|
814
|
+
page's own ``/api/*`` fetches then authenticate via the cookie automatically.
|
|
815
|
+
"""
|
|
816
|
+
secure = (cfg.public_url or "").lower().startswith("https://")
|
|
817
|
+
v = request.query_params.get("v")
|
|
818
|
+
if v:
|
|
819
|
+
resp = RedirectResponse(url="/ui", status_code=302)
|
|
820
|
+
resp.set_cookie(
|
|
821
|
+
"sb_view", v, max_age=7 * 24 * 3600, path="/",
|
|
822
|
+
httponly=True, samesite="strict", secure=secure,
|
|
823
|
+
)
|
|
824
|
+
resp.headers["Referrer-Policy"] = "no-referrer"
|
|
825
|
+
return resp
|
|
826
|
+
html = (Path(__file__).parent / "ui.html").read_text(encoding="utf-8")
|
|
827
|
+
resp = HTMLResponse(html)
|
|
828
|
+
resp.headers["Referrer-Policy"] = "no-referrer"
|
|
829
|
+
return resp
|
|
830
|
+
|
|
831
|
+
@mcp.custom_route("/join", methods=["GET"])
|
|
832
|
+
async def join(request):
|
|
833
|
+
"""Serve the packaged single-file buddy onboarding page.
|
|
834
|
+
|
|
835
|
+
A pre-token entry point, so — like ``/pair`` — it is UNAUTHENTICATED: it is
|
|
836
|
+
where a buddy lands with an invite code (carried in the URL fragment, never
|
|
837
|
+
reaching the server) to redeem it. Simpler than ``/ui``: no ``?v=`` cookie
|
|
838
|
+
dance, just the static page. ``Referrer-Policy: no-referrer`` keeps the
|
|
839
|
+
landing URL out of any onward Referer header.
|
|
840
|
+
"""
|
|
841
|
+
html = (Path(__file__).parent / "join.html").read_text(encoding="utf-8")
|
|
842
|
+
resp = HTMLResponse(html)
|
|
843
|
+
resp.headers["Referrer-Policy"] = "no-referrer"
|
|
844
|
+
return resp
|