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/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