code-coordinator 0.5.46__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.
Files changed (295) hide show
  1. code_coordinator-0.5.46.dist-info/METADATA +625 -0
  2. code_coordinator-0.5.46.dist-info/RECORD +295 -0
  3. code_coordinator-0.5.46.dist-info/WHEEL +5 -0
  4. code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
  5. code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
  6. code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
  7. coord/__init__.py +176 -0
  8. coord/_board_mapping.py +229 -0
  9. coord/acceptance.py +468 -0
  10. coord/acceptance_drivers.py +632 -0
  11. coord/agent.py +7517 -0
  12. coord/agent_app.py +1555 -0
  13. coord/agent_update.py +417 -0
  14. coord/agents/opencode/.gitignore +13 -0
  15. coord/agents/opencode/agents/work.md +129 -0
  16. coord/agents/opencode/routing.jsonc +49 -0
  17. coord/audit.py +301 -0
  18. coord/auto_loop.py +1440 -0
  19. coord/board_bool_guard.py +72 -0
  20. coord/board_service.py +141 -0
  21. coord/board_wire.py +309 -0
  22. coord/brain.py +581 -0
  23. coord/branch_model.py +214 -0
  24. coord/cargo_cache.py +258 -0
  25. coord/ci_github.py +386 -0
  26. coord/ci_store.py +560 -0
  27. coord/claim.py +353 -0
  28. coord/cli.py +454 -0
  29. coord/client.py +610 -0
  30. coord/commands/__init__.py +1 -0
  31. coord/commands/_common.py +329 -0
  32. coord/commands/acceptance.py +916 -0
  33. coord/commands/agent_ops.py +1339 -0
  34. coord/commands/audit.py +131 -0
  35. coord/commands/chat.py +320 -0
  36. coord/commands/dispatch.py +1780 -0
  37. coord/commands/dispatch_workers.py +4894 -0
  38. coord/commands/drive.py +616 -0
  39. coord/commands/drive_queue.py +1203 -0
  40. coord/commands/gate_a.py +217 -0
  41. coord/commands/gates.py +89 -0
  42. coord/commands/issues.py +681 -0
  43. coord/commands/lifecycle.py +513 -0
  44. coord/commands/merge.py +1900 -0
  45. coord/commands/milestone.py +2081 -0
  46. coord/commands/plan_followup.py +1243 -0
  47. coord/commands/plans.py +156 -0
  48. coord/commands/release.py +2232 -0
  49. coord/commands/report.py +341 -0
  50. coord/commands/review.py +1523 -0
  51. coord/commands/scorecard.py +252 -0
  52. coord/commands/sessions.py +1930 -0
  53. coord/commands/setup.py +576 -0
  54. coord/commands/status.py +2089 -0
  55. coord/commands/terminal.py +385 -0
  56. coord/commands/test_gate.py +775 -0
  57. coord/commands/tui.py +288 -0
  58. coord/comments.py +718 -0
  59. coord/config.py +3032 -0
  60. coord/conflict_fix.py +633 -0
  61. coord/dao.py +483 -0
  62. coord/dashboard/__init__.py +0 -0
  63. coord/dashboard/fixture.py +376 -0
  64. coord/dashboard/index.html +658 -0
  65. coord/dashboard/server.py +1894 -0
  66. coord/dashboard/terminal.py +382 -0
  67. coord/dashboard/webapp/.gitignore +9 -0
  68. coord/dashboard/webapp/components.json +17 -0
  69. coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
  70. coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
  71. coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
  72. coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
  73. coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
  74. coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
  75. coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
  76. coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
  77. coord/dashboard/webapp/dist/icons/icon.svg +5 -0
  78. coord/dashboard/webapp/dist/index.html +38 -0
  79. coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
  80. coord/dashboard/webapp/dist/sw.js +1 -0
  81. coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
  82. coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
  83. coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
  84. coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
  85. coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
  86. coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
  87. coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
  88. coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
  89. coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
  90. coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
  91. coord/dashboard/webapp/eslint.config.js +20 -0
  92. coord/dashboard/webapp/index.html +37 -0
  93. coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
  94. coord/dashboard/webapp/package-lock.json +10584 -0
  95. coord/dashboard/webapp/package.json +63 -0
  96. coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
  97. coord/dashboard/webapp/playwright.config.ts +93 -0
  98. coord/dashboard/webapp/postcss.config.js +6 -0
  99. coord/dashboard/webapp/public/icons/icon-192.png +0 -0
  100. coord/dashboard/webapp/public/icons/icon-512.png +0 -0
  101. coord/dashboard/webapp/public/icons/icon.svg +5 -0
  102. coord/dashboard/webapp/src/App.tsx +140 -0
  103. coord/dashboard/webapp/src/api/client.ts +199 -0
  104. coord/dashboard/webapp/src/api/generated.ts +176 -0
  105. coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
  106. coord/dashboard/webapp/src/components/Detail.tsx +800 -0
  107. coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
  108. coord/dashboard/webapp/src/components/Home.tsx +435 -0
  109. coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
  110. coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
  111. coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
  112. coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
  113. coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
  114. coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
  115. coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
  116. coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
  117. coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
  118. coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
  119. coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
  120. coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
  121. coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
  122. coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
  123. coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
  124. coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
  125. coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
  126. coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
  127. coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
  128. coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
  129. coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
  130. coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
  131. coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
  132. coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
  133. coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
  134. coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
  135. coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
  136. coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
  137. coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
  138. coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
  139. coord/dashboard/webapp/src/index.css +210 -0
  140. coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
  141. coord/dashboard/webapp/src/lib/utils.ts +6 -0
  142. coord/dashboard/webapp/src/main.tsx +46 -0
  143. coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
  144. coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
  145. coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
  146. coord/dashboard/webapp/src/realtime/connection.ts +227 -0
  147. coord/dashboard/webapp/src/realtime/events.ts +100 -0
  148. coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
  149. coord/dashboard/webapp/src/routes/paths.ts +92 -0
  150. coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
  151. coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
  152. coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
  153. coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
  154. coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
  155. coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
  156. coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
  157. coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
  158. coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
  159. coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
  160. coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
  161. coord/dashboard/webapp/src/shell/railItems.ts +105 -0
  162. coord/dashboard/webapp/src/shell/shellState.ts +174 -0
  163. coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
  164. coord/dashboard/webapp/src/test-setup.ts +41 -0
  165. coord/dashboard/webapp/src/vite-env.d.ts +2 -0
  166. coord/dashboard/webapp/tailwind.config.js +140 -0
  167. coord/dashboard/webapp/tsconfig.json +25 -0
  168. coord/dashboard/webapp/tsconfig.node.json +11 -0
  169. coord/dashboard/webapp/vite.config.ts +71 -0
  170. coord/db.py +1076 -0
  171. coord/dead_end.py +332 -0
  172. coord/deploy/README.md +33 -0
  173. coord/deploy/coord-agent.service +89 -0
  174. coord/deploy/coord-db-backup.service +60 -0
  175. coord/deploy/coord-db-backup.sh +74 -0
  176. coord/deploy/coord-db-backup.timer +18 -0
  177. coord/deploy/coord-drive-queue.service +117 -0
  178. coord/deploy/coord-drive-queue.timer +39 -0
  179. coord/deploy/coord-notify.service +48 -0
  180. coord/deploy/coord-notify.timer +24 -0
  181. coord/deploy/coord-release-propagate.service +83 -0
  182. coord/deploy/coord-release-propagate.timer +38 -0
  183. coord/deploy/coord-release-window.service +119 -0
  184. coord/deploy/coord-release-window.timer +36 -0
  185. coord/deploy/coord-serve.service +82 -0
  186. coord/deploy/coord-web-dist-build.service +43 -0
  187. coord/deploy/coord-web-dist-build.timer +36 -0
  188. coord/deploy/coord-web.service +125 -0
  189. coord/deploy_manifest.py +80 -0
  190. coord/deploy_units.py +384 -0
  191. coord/deps.py +115 -0
  192. coord/diagnose.py +1623 -0
  193. coord/dispatch.py +1009 -0
  194. coord/dist_name.py +123 -0
  195. coord/drive.py +3101 -0
  196. coord/drive_queue.py +2298 -0
  197. coord/drive_state.py +870 -0
  198. coord/events.py +381 -0
  199. coord/failure_class.py +914 -0
  200. coord/filelock.py +168 -0
  201. coord/fleet_config_health.py +300 -0
  202. coord/freshness.py +206 -0
  203. coord/gate_a.py +469 -0
  204. coord/gate_b.py +411 -0
  205. coord/gate_snapshot.py +385 -0
  206. coord/gates.py +582 -0
  207. coord/github_ops.py +1954 -0
  208. coord/goal.py +125 -0
  209. coord/graph_health.py +348 -0
  210. coord/health/__init__.py +69 -0
  211. coord/health/aggregate.py +129 -0
  212. coord/health/checks/__init__.py +13 -0
  213. coord/health/checks/agent_install.py +280 -0
  214. coord/health/checks/cargo_targets.py +171 -0
  215. coord/health/checks/claude_binary.py +65 -0
  216. coord/health/checks/deploy_lane_facts.py +458 -0
  217. coord/health/checks/disk.py +99 -0
  218. coord/health/checks/fleet_board.py +89 -0
  219. coord/health/checks/fleet_deploy_lanes.py +469 -0
  220. coord/health/checks/fleet_phantom.py +69 -0
  221. coord/health/checks/fleet_unit_drift.py +151 -0
  222. coord/health/checks/graph.py +192 -0
  223. coord/health/checks/plan_usage.py +88 -0
  224. coord/health/checks/repo_state.py +161 -0
  225. coord/health/checks/spawned_coord.py +465 -0
  226. coord/health/checks/timer_active.py +254 -0
  227. coord/health/checks/toolchain.py +547 -0
  228. coord/health/checks/unit_drift.py +648 -0
  229. coord/health/checks/unit_enablement.py +171 -0
  230. coord/health/checks/worktrees.py +96 -0
  231. coord/health/cli.py +121 -0
  232. coord/health/context.py +106 -0
  233. coord/health/fleet_snapshot.py +477 -0
  234. coord/health/models.py +250 -0
  235. coord/health/pypi.py +231 -0
  236. coord/health/registry.py +240 -0
  237. coord/health/render.py +82 -0
  238. coord/health/units.py +60 -0
  239. coord/hooks.py +106 -0
  240. coord/housekeeping.py +204 -0
  241. coord/interactive.py +4286 -0
  242. coord/issue_store.py +1496 -0
  243. coord/liveness_auditor.py +293 -0
  244. coord/machine_pause.py +755 -0
  245. coord/merge_queue.py +4681 -0
  246. coord/milestone_chat.py +600 -0
  247. coord/milestone_dispatch.py +943 -0
  248. coord/milestone_gate.py +709 -0
  249. coord/milestone_order.py +840 -0
  250. coord/mock_author.py +334 -0
  251. coord/models.py +891 -0
  252. coord/network.py +269 -0
  253. coord/new_issue_chat.py +229 -0
  254. coord/notify.py +3226 -0
  255. coord/openapi.py +404 -0
  256. coord/overlap_fence.py +133 -0
  257. coord/parentage.py +200 -0
  258. coord/parentage_github.py +58 -0
  259. coord/pipeline.py +481 -0
  260. coord/plan_parser.py +266 -0
  261. coord/plans.py +543 -0
  262. coord/platform_paths.py +43 -0
  263. coord/pr_body_lint.py +67 -0
  264. coord/prereqs.py +533 -0
  265. coord/progress.py +425 -0
  266. coord/providers/__init__.py +683 -0
  267. coord/providers/base.py +218 -0
  268. coord/providers/claude.py +284 -0
  269. coord/providers/claude_pty.py +610 -0
  270. coord/providers/opencode.py +896 -0
  271. coord/reconcile.py +2233 -0
  272. coord/refine_chat.py +485 -0
  273. coord/release_cordon.py +525 -0
  274. coord/release_propagate.py +1176 -0
  275. coord/release_verify.py +777 -0
  276. coord/release_window.py +322 -0
  277. coord/reports.py +1643 -0
  278. coord/revalidate.py +1101 -0
  279. coord/review.py +3317 -0
  280. coord/scorecard.py +484 -0
  281. coord/serve_app.py +7192 -0
  282. coord/skills/update-issue/SKILL.md +93 -0
  283. coord/smoke.py +1030 -0
  284. coord/split_work.py +210 -0
  285. coord/stage_projection.py +650 -0
  286. coord/state.py +5720 -0
  287. coord/test_author.py +1064 -0
  288. coord/test_chat.py +352 -0
  289. coord/test_orchestrator.py +494 -0
  290. coord/test_report.py +178 -0
  291. coord/tui_release.py +271 -0
  292. coord/usage.py +753 -0
  293. coord/usage_limits.py +358 -0
  294. coord/usage_rollup.py +709 -0
  295. coord/worker_events.py +954 -0
coord/events.py ADDED
@@ -0,0 +1,381 @@
1
+ """SSE event source for the coordinator and live-log streaming for agents.
2
+
3
+ The dashboard (#9) mounts these on its HTTP server. Two things live here:
4
+
5
+ 1. `EventSource` — fan-out hub. Producers call `publish(event_type, data)`;
6
+ subscribers each get their own `asyncio.Queue`, so a slow client never
7
+ blocks a fast one. Events carry a monotonic `id`, and a small ring buffer
8
+ lets reconnecting clients resume via `Last-Event-ID`.
9
+
10
+ 2. `sse_response(source, last_event_id=...)` — wraps the source as a Starlette
11
+ `StreamingResponse` with the right headers (`text/event-stream`, no-cache,
12
+ keepalive).
13
+
14
+ 3. `stream_assignment_log(agent, assignment_id)` — async generator that tails
15
+ the assignment log file and yields SSE-formatted chunks. The event `id` is
16
+ the byte offset, which doubles as a `since` cursor for resume.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import json
23
+ import time
24
+ from collections import deque
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+ from typing import Any, AsyncIterator
28
+
29
+ from starlette.requests import Request
30
+ from starlette.responses import StreamingResponse
31
+ from starlette.routing import Route
32
+
33
+
34
+ # ── Event types (the wire vocabulary) ───────────────────────────────────────
35
+
36
+ ASSIGNMENT_STARTED = "assignment_started"
37
+ ASSIGNMENT_COMPLETED = "assignment_completed"
38
+ ASSIGNMENT_FAILED = "assignment_failed"
39
+ MACHINE_CONNECTED = "machine_connected"
40
+ MACHINE_DISCONNECTED = "machine_disconnected"
41
+ BOARD_UPDATED = "board_updated"
42
+
43
+ KNOWN_EVENT_TYPES = frozenset({
44
+ ASSIGNMENT_STARTED,
45
+ ASSIGNMENT_COMPLETED,
46
+ ASSIGNMENT_FAILED,
47
+ MACHINE_CONNECTED,
48
+ MACHINE_DISCONNECTED,
49
+ BOARD_UPDATED,
50
+ })
51
+
52
+
53
+ # ── Tunables ────────────────────────────────────────────────────────────────
54
+
55
+ DEFAULT_HISTORY_SIZE = 256
56
+ DEFAULT_QUEUE_MAXSIZE = 128
57
+ DEFAULT_KEEPALIVE_SECONDS = 15.0
58
+ LOG_POLL_INTERVAL = 0.25
59
+ LOG_CHUNK_SIZE = 4096
60
+
61
+
62
+ @dataclass
63
+ class Event:
64
+ """One thing that happened, ready to serialize as SSE."""
65
+
66
+ id: int
67
+ type: str
68
+ data: Any
69
+ created_at: float = field(default_factory=time.time)
70
+
71
+ def to_sse(self) -> str:
72
+ # SSE requires data: prefix on every line. JSON-encode the payload so
73
+ # multi-line strings and structured data both round-trip cleanly.
74
+ payload = json.dumps(self.data, default=str)
75
+ lines = [f"id: {self.id}", f"event: {self.type}"]
76
+ for chunk in payload.splitlines() or [""]:
77
+ lines.append(f"data: {chunk}")
78
+ return "\n".join(lines) + "\n\n"
79
+
80
+
81
+ class EventSource:
82
+ """In-memory pub/sub. One instance per coordinator process.
83
+
84
+ Subscribers each get an independent `asyncio.Queue`. If a queue fills, the
85
+ oldest event for that subscriber is dropped — bounded memory beats global
86
+ backpressure here. A short ring buffer of recent events lets reconnecting
87
+ clients resume from `Last-Event-ID`.
88
+ """
89
+
90
+ def __init__(
91
+ self,
92
+ *,
93
+ history_size: int = DEFAULT_HISTORY_SIZE,
94
+ queue_maxsize: int = DEFAULT_QUEUE_MAXSIZE,
95
+ ) -> None:
96
+ self._next_id = 1
97
+ self._history: deque[Event] = deque(maxlen=history_size)
98
+ self._subscribers: set[asyncio.Queue[Event]] = set()
99
+ self._queue_maxsize = queue_maxsize
100
+ self._lock = asyncio.Lock()
101
+
102
+ # ── Producer side ──────────────────────────────────────────────────────
103
+
104
+ def publish(self, event_type: str, data: Any) -> Event:
105
+ """Record an event and fan it out to current subscribers.
106
+
107
+ Called from the same event loop that owns the queues. Synchronous —
108
+ producers don't await — so this is cheap to call from board mutation
109
+ sites. If a subscriber's queue is full we drop its oldest event so we
110
+ can still enqueue the new one.
111
+ """
112
+ event = Event(id=self._next_id, type=event_type, data=data)
113
+ self._next_id += 1
114
+ self._history.append(event)
115
+ for queue in list(self._subscribers):
116
+ self._offer(queue, event)
117
+ return event
118
+
119
+ @staticmethod
120
+ def _offer(queue: asyncio.Queue[Event], event: Event) -> None:
121
+ try:
122
+ queue.put_nowait(event)
123
+ except asyncio.QueueFull:
124
+ try:
125
+ queue.get_nowait()
126
+ except asyncio.QueueEmpty:
127
+ return
128
+ try:
129
+ queue.put_nowait(event)
130
+ except asyncio.QueueFull:
131
+ # Concurrent subscriber pop won the race; give up silently.
132
+ return
133
+
134
+ # ── Consumer side ──────────────────────────────────────────────────────
135
+
136
+ async def subscribe(
137
+ self,
138
+ *,
139
+ last_event_id: int | None = None,
140
+ ) -> AsyncIterator[Event]:
141
+ """Yield events as they're published. Catches up via `last_event_id`.
142
+
143
+ The returned async generator must be closed (via `aclose()` or by
144
+ exiting `async for`) to release the subscriber slot. Reconnecting
145
+ clients pass their last seen id; we replay from history if we still
146
+ have it, otherwise they get only new events from now on.
147
+ """
148
+ queue: asyncio.Queue[Event] = asyncio.Queue(maxsize=self._queue_maxsize)
149
+ async with self._lock:
150
+ self._subscribers.add(queue)
151
+ backfill = self._backfill(last_event_id)
152
+ try:
153
+ for event in backfill:
154
+ yield event
155
+ while True:
156
+ event = await queue.get()
157
+ yield event
158
+ finally:
159
+ async with self._lock:
160
+ self._subscribers.discard(queue)
161
+
162
+ def _backfill(self, last_event_id: int | None) -> list[Event]:
163
+ if last_event_id is None:
164
+ return []
165
+ return [e for e in self._history if e.id > last_event_id]
166
+
167
+ @property
168
+ def subscriber_count(self) -> int:
169
+ return len(self._subscribers)
170
+
171
+ @property
172
+ def next_id(self) -> int:
173
+ return self._next_id
174
+
175
+
176
+ # ── SSE response helpers ────────────────────────────────────────────────────
177
+
178
+ def _parse_last_event_id(request: Request) -> int | None:
179
+ raw = request.headers.get("last-event-id")
180
+ if raw is None:
181
+ return None
182
+ try:
183
+ return int(raw)
184
+ except ValueError:
185
+ return None
186
+
187
+
188
+ def sse_response(
189
+ source: EventSource,
190
+ request: Request,
191
+ *,
192
+ keepalive_seconds: float = DEFAULT_KEEPALIVE_SECONDS,
193
+ ) -> StreamingResponse:
194
+ """Wrap an EventSource as a Starlette streaming SSE response."""
195
+
196
+ last_event_id = _parse_last_event_id(request)
197
+
198
+ async def gen() -> AsyncIterator[bytes]:
199
+ # Initial retry hint and a comment so the client knows we're open even
200
+ # before any event is published.
201
+ yield b"retry: 2000\n\n"
202
+ async for chunk in _multiplex(
203
+ source.subscribe(last_event_id=last_event_id),
204
+ keepalive_seconds=keepalive_seconds,
205
+ request=request,
206
+ ):
207
+ yield chunk
208
+
209
+ return StreamingResponse(
210
+ gen(),
211
+ media_type="text/event-stream",
212
+ headers={
213
+ "Cache-Control": "no-cache, no-transform",
214
+ "X-Accel-Buffering": "no",
215
+ "Connection": "keep-alive",
216
+ },
217
+ )
218
+
219
+
220
+ def build_events_route(
221
+ source: EventSource,
222
+ *,
223
+ path: str = "/events",
224
+ keepalive_seconds: float = DEFAULT_KEEPALIVE_SECONDS,
225
+ ) -> Route:
226
+ """Convenience: a Starlette Route mounting `source` at `path` as SSE.
227
+
228
+ The dashboard server (#9) mounts this on its app. Kept here so the wire
229
+ format (headers, keepalive cadence, `Last-Event-ID` handling) lives in one
230
+ place — callers shouldn't have to reimplement it.
231
+ """
232
+
233
+ async def handler(request: Request) -> StreamingResponse:
234
+ return sse_response(source, request, keepalive_seconds=keepalive_seconds)
235
+
236
+ return Route(path, handler, methods=["GET"])
237
+
238
+
239
+ # ── Board-state event helpers (called by board mutation sites) ──────────────
240
+
241
+ def publish_assignment_started(source: EventSource, assignment: Any) -> Event:
242
+ return source.publish(ASSIGNMENT_STARTED, _assignment_payload(assignment))
243
+
244
+
245
+ def publish_assignment_completed(source: EventSource, assignment: Any) -> Event:
246
+ return source.publish(ASSIGNMENT_COMPLETED, _assignment_payload(assignment))
247
+
248
+
249
+ def publish_assignment_failed(source: EventSource, assignment: Any) -> Event:
250
+ return source.publish(ASSIGNMENT_FAILED, _assignment_payload(assignment))
251
+
252
+
253
+ def publish_machine_connected(source: EventSource, machine_name: str) -> Event:
254
+ return source.publish(MACHINE_CONNECTED, {"machine": machine_name})
255
+
256
+
257
+ def publish_machine_disconnected(source: EventSource, machine_name: str) -> Event:
258
+ return source.publish(MACHINE_DISCONNECTED, {"machine": machine_name})
259
+
260
+
261
+ def publish_board_updated(source: EventSource, summary: dict) -> Event:
262
+ return source.publish(BOARD_UPDATED, summary)
263
+
264
+
265
+ def _assignment_payload(assignment: Any) -> dict:
266
+ """Coerce an Assignment-like object to a JSON-safe dict for the wire."""
267
+ if hasattr(assignment, "to_dict"):
268
+ return assignment.to_dict()
269
+ if hasattr(assignment, "__dict__"):
270
+ return {k: v for k, v in vars(assignment).items() if not k.startswith("_")}
271
+ if isinstance(assignment, dict):
272
+ return assignment
273
+ return {"value": str(assignment)}
274
+
275
+
276
+ async def _multiplex(
277
+ events: AsyncIterator[Event],
278
+ *,
279
+ keepalive_seconds: float,
280
+ request: Request,
281
+ ) -> AsyncIterator[bytes]:
282
+ """Yield SSE bytes from `events`, sending a keepalive comment when idle.
283
+
284
+ Also bails out if the client disconnects, so we don't leak subscribers.
285
+ """
286
+ aiter = events.__aiter__()
287
+ pending: asyncio.Task | None = None
288
+ try:
289
+ while True:
290
+ if await request.is_disconnected():
291
+ return
292
+ if pending is None:
293
+ pending = asyncio.create_task(aiter.__anext__())
294
+ try:
295
+ event = await asyncio.wait_for(
296
+ asyncio.shield(pending),
297
+ timeout=keepalive_seconds,
298
+ )
299
+ except asyncio.TimeoutError:
300
+ yield b": keepalive\n\n"
301
+ continue
302
+ except StopAsyncIteration:
303
+ pending = None
304
+ return
305
+ pending = None
306
+ yield event.to_sse().encode("utf-8")
307
+ finally:
308
+ if pending is not None:
309
+ pending.cancel()
310
+ try:
311
+ await pending
312
+ except (asyncio.CancelledError, StopAsyncIteration, Exception):
313
+ pass
314
+ await aiter.aclose()
315
+
316
+
317
+ # ── Live log streaming for agent /stream/<id> ───────────────────────────────
318
+
319
+ async def stream_assignment_log(
320
+ log_path: Path,
321
+ *,
322
+ is_active: "callable",
323
+ request: Request,
324
+ start_offset: int = 0,
325
+ poll_interval: float = LOG_POLL_INTERVAL,
326
+ keepalive_seconds: float = DEFAULT_KEEPALIVE_SECONDS,
327
+ ) -> AsyncIterator[bytes]:
328
+ """Tail `log_path` and yield SSE-formatted bytes.
329
+
330
+ `is_active()` returns True while the assignment is still RUNNING — we keep
331
+ polling for new bytes; once it returns False we drain anything that landed
332
+ after the last read and then send a terminal `event: end`. The event `id`
333
+ on each chunk is the byte offset after that chunk, so reconnecting clients
334
+ can pass `Last-Event-ID` to resume mid-stream.
335
+ """
336
+ offset = max(0, int(start_offset))
337
+ last_yield = time.monotonic()
338
+ yield b"retry: 2000\n\n"
339
+ while True:
340
+ if await request.is_disconnected():
341
+ return
342
+ chunk, offset = _read_log_chunk(log_path, offset)
343
+ if chunk:
344
+ yield _format_log_event(offset, chunk)
345
+ last_yield = time.monotonic()
346
+ continue
347
+ if not is_active():
348
+ # One final drain after the worker exits — bytes can arrive
349
+ # between our last read and the status flip.
350
+ chunk, offset = _read_log_chunk(log_path, offset)
351
+ if chunk:
352
+ yield _format_log_event(offset, chunk)
353
+ yield f"id: {offset}\nevent: end\ndata: {{}}\n\n".encode("utf-8")
354
+ return
355
+ if time.monotonic() - last_yield >= keepalive_seconds:
356
+ yield b": keepalive\n\n"
357
+ last_yield = time.monotonic()
358
+ await asyncio.sleep(poll_interval)
359
+
360
+
361
+ def _read_log_chunk(log_path: Path, offset: int) -> tuple[str, int]:
362
+ if not log_path.exists():
363
+ return "", offset
364
+ try:
365
+ with open(log_path, "rb") as f:
366
+ f.seek(offset)
367
+ data = f.read(LOG_CHUNK_SIZE)
368
+ except OSError:
369
+ return "", offset
370
+ if not data:
371
+ return "", offset
372
+ return data.decode("utf-8", errors="replace"), offset + len(data)
373
+
374
+
375
+ def _format_log_event(offset: int, text: str) -> bytes:
376
+ lines = [f"id: {offset}", "event: log"]
377
+ for line in text.splitlines() or [""]:
378
+ lines.append(f"data: {line}")
379
+ if text.endswith("\n"):
380
+ lines.append("data: ")
381
+ return ("\n".join(lines) + "\n\n").encode("utf-8")