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
@@ -0,0 +1,1894 @@
1
+ """Web dashboard HTTP server — lightweight UI for phone-accessible coordination."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import subprocess
8
+ import time
9
+ from concurrent.futures import ThreadPoolExecutor
10
+ from pathlib import Path
11
+
12
+ import httpx
13
+ from starlette.applications import Starlette
14
+ from starlette.requests import Request
15
+ from starlette.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse
16
+ from starlette.routing import Mount, Route, WebSocketRoute
17
+ from starlette.staticfiles import StaticFiles
18
+ from starlette.websockets import WebSocket, WebSocketDisconnect
19
+
20
+ from coord import __version__
21
+ from coord.config import Config
22
+ from coord.dashboard.fixture import FixtureServer
23
+ from coord.dashboard.terminal import (
24
+ SessionAttacher,
25
+ TmuxSessionAttacher,
26
+ resolve_session_target,
27
+ )
28
+ from coord.dispatch import AGENT_PORT
29
+ from coord.events import (
30
+ ASSIGNMENT_COMPLETED,
31
+ ASSIGNMENT_FAILED,
32
+ BOARD_UPDATED,
33
+ EventSource,
34
+ build_events_route,
35
+ )
36
+ from coord.board_service import read_board, write_board
37
+ from coord.models import Assignment
38
+ from coord.network import check_all, fetch_status
39
+ from coord.openapi import build_spec, dataclass_schema, openapi_and_docs_routes
40
+ from coord.pipeline import PipelineView
41
+ from coord.state import load_proposals
42
+
43
+ DASHBOARD_DIR = Path(__file__).parent
44
+ # Built React webapp lives here after `npm run build` inside coord/dashboard/webapp/.
45
+ # When this directory is absent the server falls back to the legacy index.html so
46
+ # that existing behaviour (and the test suite) is completely unaffected.
47
+ WEBAPP_DIST = DASHBOARD_DIR / "webapp" / "dist"
48
+
49
+ # How often (seconds) the background poller queries agent servers.
50
+ _POLL_INTERVAL = 30.0
51
+ # How long (seconds) an assignment must be running with no agent record before
52
+ # it is flagged as possibly stuck.
53
+ _STUCK_THRESHOLD = 300.0 # 5 minutes
54
+
55
+ # #1217 fix iteration 1: api_sessions' per-machine tmux sweep timing knobs.
56
+ # A sweep taking at least this long looks like it hit the SSH ConnectTimeout
57
+ # (i.e. the machine is unreachable) rather than a normal fast tmux query.
58
+ _SESSIONS_SLOW_THRESHOLD = 3.0 # seconds; a healthy sweep is normally <1s
59
+ # Once a machine looks unreachable, skip re-probing it for this long — caps
60
+ # how often we pay the full SSH ConnectTimeout for a chronically down machine
61
+ # on every ~4s dashboard poll.
62
+ _SESSIONS_COOLDOWN = 20.0 # seconds before a down machine is re-probed
63
+
64
+ # #2066: PipelineView.current_stage values that represent genuinely finished
65
+ # work with no pending action — safe for api_pipeline's recency cutoff to age
66
+ # out. Every other current_stage ("coding", "review_running", "review_done",
67
+ # "smoke_running", "smoke_passed", "merge_ready", "merging") is live pipeline
68
+ # state — either still in progress or waiting on a human gate click — and
69
+ # must never be dropped by age alone, however stale its timestamps look.
70
+ # See coord/pipeline.py's compute_pipeline() for the full current_stage set.
71
+ _PIPELINE_QUIESCENT_STAGES = frozenset(
72
+ {"done", "merged", "failed", "review_failed", "smoke_failed"}
73
+ )
74
+
75
+ # Bug 1 fix: distinct event type for cancelled assignments so they are not
76
+ # bucketed as FAILED on the client. Not yet in coord.events — defined here
77
+ # until a shared constants refactor can move it.
78
+ ASSIGNMENT_CANCELLED = "assignment_cancelled"
79
+ # #448: advisory (0-commit clean exit) is neither a green completion nor a
80
+ # red failure — it's a "needs attention" state. Route it to a distinct
81
+ # event so the dashboard can style it appropriately (warning, not failure).
82
+ ASSIGNMENT_ADVISORY = "assignment_advisory"
83
+ # #846: an assignment running past its wall-clock threshold, or thrashing
84
+ # through fix/review rounds without converging (coord.notify.attention_signal
85
+ # — same detection core as the coordinator's GitHub-comment backstop).
86
+ # Detection + surfacing only.
87
+ ASSIGNMENT_NEEDS_ATTENTION = "assignment_needs_attention"
88
+
89
+
90
+ def _fetch_agent_status(host: str, port: int = AGENT_PORT, timeout: float = 5.0) -> dict | None:
91
+ """Synchronous agent /status fetch — safe to call from a thread executor."""
92
+ try:
93
+ resp = httpx.get(f"http://{host}:{port}/status", timeout=timeout)
94
+ resp.raise_for_status()
95
+ return resp.json()
96
+ except Exception:
97
+ return None
98
+
99
+
100
+ async def _poll_once(
101
+ config: Config,
102
+ event_source: EventSource,
103
+ seen_terminal: set[str],
104
+ orphaned_since: dict[str, float],
105
+ *,
106
+ board=None,
107
+ now: float | None = None,
108
+ stuck_threshold: float = _STUCK_THRESHOLD,
109
+ needs_attention_seen: set[str] | None = None,
110
+ ) -> list[dict]:
111
+ """One iteration of the background agent poller.
112
+
113
+ Queries each machine's agent server, publishes ``assignment_completed`` /
114
+ ``assignment_failed`` / ``assignment_cancelled`` SSE events on transitions,
115
+ and returns a list of possibly-stuck assignment info dicts.
116
+
117
+ Also publishes ``ASSIGNMENT_NEEDS_ATTENTION`` (#846) — a live counterpart
118
+ to the coordinator's GitHub-comment backstop — the first time a running
119
+ assignment trips the shared ``coord.notify.attention_signal`` wall-clock
120
+ / non-convergence check. *needs_attention_seen* is caller-owned dedupe
121
+ state (mirrors *seen_terminal*) so the toast fires once per assignment,
122
+ not every poll interval; pass ``None`` to skip this check entirely (e.g.
123
+ from callers that don't track dedupe state, such as older tests).
124
+
125
+ Extracted to module level so unit tests can drive it directly without
126
+ standing up a full HTTP server.
127
+ """
128
+ if board is None:
129
+ board = read_board()
130
+ if now is None:
131
+ now = time.time()
132
+
133
+ running = {
134
+ a.assignment_id: a
135
+ for a in board.active
136
+ if a.status == "running"
137
+ and a.assignment_id
138
+ and a.assignment_id not in seen_terminal
139
+ }
140
+ if not running:
141
+ return []
142
+
143
+ if needs_attention_seen is not None:
144
+ from coord.notify import attention_signal # noqa: PLC0415
145
+
146
+ for aid, assignment in running.items():
147
+ if aid in needs_attention_seen:
148
+ continue
149
+ reason, detail = attention_signal(
150
+ assignment_type=assignment.type,
151
+ status=assignment.status,
152
+ dispatched_at=assignment.dispatched_at,
153
+ review_iteration=assignment.review_iteration,
154
+ config=config,
155
+ now=now,
156
+ provider_name=assignment.provider_name,
157
+ review_of_assignment_id=assignment.review_of_assignment_id,
158
+ )
159
+ if reason is None:
160
+ continue
161
+ needs_attention_seen.add(aid)
162
+ event_source.publish(ASSIGNMENT_NEEDS_ATTENTION, {
163
+ "assignment_id": aid,
164
+ "repo_name": assignment.repo_name,
165
+ "issue_number": assignment.issue_number,
166
+ "issue_title": assignment.issue_title,
167
+ "machine_name": assignment.machine_name,
168
+ "reason": reason,
169
+ "detail": detail,
170
+ })
171
+
172
+ machines_by_name = {m.name: m for m in config.machines}
173
+ needed_machines = {a.machine_name for a in running.values()}
174
+
175
+ loop = asyncio.get_running_loop()
176
+ agent_data: dict[str, dict] = {}
177
+ for mname in needed_machines:
178
+ machine = machines_by_name.get(mname)
179
+ if machine:
180
+ data = await loop.run_in_executor(
181
+ None, _fetch_agent_status, machine.host
182
+ )
183
+ if data:
184
+ agent_data[mname] = data
185
+
186
+ possibly_stuck: list[dict] = []
187
+
188
+ for aid, assignment in running.items():
189
+ mname = assignment.machine_name
190
+ data = agent_data.get(mname)
191
+ if data is None:
192
+ # Agent unreachable — don't flag as stuck yet.
193
+ orphaned_since.pop(aid, None)
194
+ continue
195
+
196
+ active_ids = {e.get("id") for e in data.get("active", []) if e.get("id")}
197
+ completed_by_id = {
198
+ e.get("id"): e
199
+ for e in data.get("completed", [])
200
+ if e.get("id")
201
+ }
202
+
203
+ if aid in active_ids:
204
+ # Still running — clear any orphaned flag.
205
+ orphaned_since.pop(aid, None)
206
+ elif aid in completed_by_id:
207
+ # Terminal transition detected.
208
+ seen_terminal.add(aid)
209
+ orphaned_since.pop(aid, None)
210
+ entry = completed_by_id[aid]
211
+ stats: dict = {}
212
+ for k in ("num_turns", "total_cost_usd", "exit_code", "last_tool", "stop_reason"):
213
+ v = entry.get(k)
214
+ if v is not None:
215
+ stats[k] = v
216
+ payload = {
217
+ "assignment_id": aid,
218
+ "repo_name": assignment.repo_name,
219
+ "issue_number": assignment.issue_number,
220
+ "issue_title": assignment.issue_title,
221
+ "machine_name": mname,
222
+ "stats": stats,
223
+ "status": entry.get("status"), # attached so client can inspect
224
+ }
225
+ status = entry.get("status")
226
+ # Bug 1 fix: three-way branch — cancelled must not fire FAILED.
227
+ # #448: advisory routes to a distinct event so the dashboard does
228
+ # not paint a 0-commit clean exit as a failure.
229
+ if status == "done":
230
+ event_source.publish(ASSIGNMENT_COMPLETED, payload)
231
+ elif status == "cancelled":
232
+ event_source.publish(ASSIGNMENT_CANCELLED, payload)
233
+ elif status == "advisory":
234
+ payload["zero_commit_reason"] = entry.get("zero_commit_reason")
235
+ event_source.publish(ASSIGNMENT_ADVISORY, payload)
236
+ else: # "failed" and any other unexpected terminal status
237
+ payload["exit_code"] = entry.get("exit_code")
238
+ event_source.publish(ASSIGNMENT_FAILED, payload)
239
+ else:
240
+ # Not in active OR completed on the agent.
241
+ dispatched_ago = now - (assignment.dispatched_at or 0)
242
+ if dispatched_ago > stuck_threshold:
243
+ if aid not in orphaned_since:
244
+ orphaned_since[aid] = now
245
+ possibly_stuck.append({
246
+ "assignment_id": aid,
247
+ "repo_name": assignment.repo_name,
248
+ "issue_number": assignment.issue_number,
249
+ "machine_name": mname,
250
+ "dispatched_ago_seconds": int(dispatched_ago),
251
+ })
252
+
253
+ # Prune orphaned_since entries that are no longer in the running set.
254
+ for aid in list(orphaned_since):
255
+ if aid not in running:
256
+ del orphaned_since[aid]
257
+
258
+ if needs_attention_seen is not None:
259
+ for aid in list(needs_attention_seen):
260
+ if aid not in running:
261
+ needs_attention_seen.discard(aid)
262
+
263
+ return possibly_stuck
264
+
265
+
266
+ def openapi_spec() -> dict:
267
+ """#757: the dashboard's OpenAPI 3 document.
268
+
269
+ ``GET /api/board`` and ``GET /api/pipeline`` are fully specified via
270
+ :func:`coord.openapi.dataclass_schema` over ``coord.models.Assignment`` /
271
+ ``coord.pipeline.PipelineView``. #1550's TS codegen (``scripts/codegen.py``)
272
+ reads its ``components/schemas`` straight from *this* spec — not from the
273
+ dataclasses directly — so the generated TS types describe exactly what the
274
+ server declares it serves, and the existing ``declared_routes(app.routes)
275
+ == spec_routes(spec)`` test (``tests/test_openapi.py``) transitively
276
+ guarantees they can't drift from the real route table either. The
277
+ action-style ``POST /api/pipeline/action`` endpoint documents its
278
+ ``action`` enum but leaves the response loosely typed since each action
279
+ returns a distinct ad-hoc shape.
280
+
281
+ Public (no leading underscore) because ``scripts/codegen.py`` imports it
282
+ as its source of truth; ``coord/agent_app.py`` and ``coord/serve_app.py``
283
+ keep their own ``_openapi_spec()`` private since nothing outside those
284
+ modules consumes them (yet).
285
+ """
286
+ components: dict = {}
287
+ assignment_ref = dataclass_schema(Assignment, components)
288
+ pipeline_view_ref = dataclass_schema(PipelineView, components)
289
+ board_response = {
290
+ "type": "object",
291
+ "properties": {
292
+ "round_number": {"type": "integer"},
293
+ "active": {"type": "array", "items": assignment_ref},
294
+ "completed": {"type": "array", "items": assignment_ref},
295
+ },
296
+ }
297
+ ok_response = {"type": "object", "properties": {"ok": {"type": "boolean"}}}
298
+ session_response = {
299
+ "type": "object",
300
+ "properties": {
301
+ "session_id": {"type": "string", "description": "assignment_id (== the /ws/terminal/{session_id} path param)"},
302
+ "session_name": {"type": "string", "description": "the tmux session name, coord-<session_id>"},
303
+ "machine": {"type": ["string", "null"], "description": "machine name from coordinator.yml"},
304
+ "host": {"type": ["string", "null"], "description": "the machine's Tailscale host"},
305
+ "repo": {"type": ["string", "null"]},
306
+ "issue": {"type": ["integer", "null"]},
307
+ "issue_title": {"type": ["string", "null"]},
308
+ "stage": {"type": ["string", "null"], "description": "assignment type — work/review/smoke/fix/plan/merge/..."},
309
+ "status": {"type": ["string", "null"], "description": "assignment status — running/done/failed/advisory/..."},
310
+ "attached": {"type": "boolean", "description": "is a client currently attached to the tmux session"},
311
+ "pane_dead": {"type": "boolean", "description": "claude has exited but the tmux session is still up"},
312
+ },
313
+ }
314
+ paths = {
315
+ "/": {
316
+ "get": {
317
+ "summary": "Dashboard SPA (or legacy single-file UI) index page",
318
+ "responses": {"200": {"description": "text/html"}},
319
+ }
320
+ },
321
+ "/api/board": {
322
+ "get": {
323
+ "summary": "Recent board state: active assignments + last 20 completed",
324
+ "responses": {
325
+ "200": {
326
+ "description": "OK",
327
+ "content": {"application/json": {"schema": board_response}},
328
+ }
329
+ },
330
+ }
331
+ },
332
+ "/api/machines": {
333
+ "get": {
334
+ "summary": "Machine reachability + live agent /status per machine",
335
+ "responses": {"200": {"description": "OK"}},
336
+ }
337
+ },
338
+ "/api/proposals": {
339
+ "get": {
340
+ "summary": "Pending brain proposals awaiting approve/reject",
341
+ "responses": {"200": {"description": "OK"}},
342
+ }
343
+ },
344
+ "/api/approve": {
345
+ "post": {
346
+ "summary": "Dispatch one or more proposals by id",
347
+ "requestBody": {
348
+ "required": True,
349
+ "content": {
350
+ "application/json": {
351
+ "schema": {
352
+ "type": "object",
353
+ "properties": {
354
+ "ids": {"type": "array", "items": {"type": "integer"}},
355
+ "briefings": {"type": "object"},
356
+ },
357
+ "required": ["ids"],
358
+ }
359
+ }
360
+ },
361
+ },
362
+ "responses": {
363
+ "200": {"description": "OK"},
364
+ "400": {"description": "ids must be a non-empty list"},
365
+ "404": {"description": "No matching proposals"},
366
+ },
367
+ }
368
+ },
369
+ "/api/reject": {
370
+ "post": {
371
+ "summary": "Discard one or more proposals by id",
372
+ "requestBody": {
373
+ "required": True,
374
+ "content": {
375
+ "application/json": {
376
+ "schema": {
377
+ "type": "object",
378
+ "properties": {
379
+ "ids": {"type": "array", "items": {"type": "integer"}},
380
+ },
381
+ "required": ["ids"],
382
+ }
383
+ }
384
+ },
385
+ },
386
+ "responses": {
387
+ "200": {"description": "OK"},
388
+ "400": {"description": "ids must be a non-empty list"},
389
+ },
390
+ }
391
+ },
392
+ "/api/diff/{id}": {
393
+ "get": {
394
+ "summary": "PR/branch diff for an assignment (gh pr diff, falls back to compare)",
395
+ "parameters": [_dashboard_path_param("id", "assignment id")],
396
+ "responses": {
397
+ "200": {"description": "OK"},
398
+ "404": {"description": "Assignment/branch/repo not found"},
399
+ "500": {"description": "gh lookup failed"},
400
+ },
401
+ }
402
+ },
403
+ "/api/chat": {
404
+ "post": {
405
+ "summary": "Stream a chat reply about current board state (SSE)",
406
+ "requestBody": {
407
+ "required": True,
408
+ "content": {
409
+ "application/json": {
410
+ "schema": {
411
+ "type": "object",
412
+ "properties": {"message": {"type": "string"}},
413
+ "required": ["message"],
414
+ }
415
+ }
416
+ },
417
+ },
418
+ "responses": {
419
+ "200": {"description": "text/event-stream"},
420
+ "400": {"description": "message required / unsupported provider"},
421
+ },
422
+ }
423
+ },
424
+ "/api/sessions": {
425
+ "get": {
426
+ "summary": (
427
+ "Live coord-* interactive tmux sessions the phone can attach "
428
+ "to via GET /ws/terminal/{session_id} (#1066)"
429
+ ),
430
+ "responses": {
431
+ "200": {
432
+ "description": "OK",
433
+ "content": {
434
+ "application/json": {
435
+ "schema": {"type": "array", "items": session_response}
436
+ }
437
+ },
438
+ }
439
+ },
440
+ }
441
+ },
442
+ "/api/pipeline": {
443
+ "get": {
444
+ "summary": "PipelineView for every type='work' assignment",
445
+ "description": (
446
+ "#2066: bounded by default to active work plus terminal work "
447
+ "finished within COORD_BOARD_RETENTION_DAYS (default 14). Pass "
448
+ "?include=all to get the full, unbounded history. Sorted "
449
+ "newest-first by finished_at (falling back to dispatched_at)."
450
+ ),
451
+ "parameters": [
452
+ {
453
+ "name": "include",
454
+ "in": "query",
455
+ "required": False,
456
+ "schema": {"type": "string", "enum": ["all"]},
457
+ "description": "Pass 'all' to bypass the default recency window.",
458
+ }
459
+ ],
460
+ "responses": {
461
+ "200": {
462
+ "description": "OK",
463
+ "content": {
464
+ "application/json": {
465
+ "schema": {"type": "array", "items": pipeline_view_ref}
466
+ }
467
+ },
468
+ }
469
+ },
470
+ }
471
+ },
472
+ "/api/pipeline/action": {
473
+ "post": {
474
+ "summary": "Advance an assignment through a pipeline gate",
475
+ "requestBody": {
476
+ "required": True,
477
+ "content": {
478
+ "application/json": {
479
+ "schema": {
480
+ "type": "object",
481
+ "properties": {
482
+ "assignment_id": {"type": "string"},
483
+ "action": {
484
+ "type": "string",
485
+ "enum": [
486
+ "dispatch_review", "dispatch_smoke", "enqueue",
487
+ "merge", "post_findings", "unstick",
488
+ "test-verdict", "record-review-verdict",
489
+ "retry", "dispatch_fix",
490
+ ],
491
+ },
492
+ },
493
+ "required": ["assignment_id", "action"],
494
+ }
495
+ }
496
+ },
497
+ },
498
+ "responses": {
499
+ "200": {
500
+ "description": "OK",
501
+ "content": {"application/json": {"schema": ok_response}},
502
+ },
503
+ "400": {"description": "Missing/unknown field"},
504
+ "404": {"description": "Assignment not found"},
505
+ "501": {"description": "Action not yet implemented"},
506
+ },
507
+ }
508
+ },
509
+ "/events": {
510
+ "get": {
511
+ "summary": "Server-sent-event stream of board/assignment events",
512
+ "responses": {"200": {"description": "text/event-stream"}},
513
+ }
514
+ },
515
+ }
516
+ return build_spec(
517
+ title="coord dashboard",
518
+ version=__version__,
519
+ description="Phone-accessible coordination dashboard (React webapp + legacy single-file UI).",
520
+ paths=paths,
521
+ components=components,
522
+ )
523
+
524
+
525
+ def _dashboard_path_param(name: str, description: str = "") -> dict:
526
+ return {
527
+ "name": name,
528
+ "in": "path",
529
+ "required": True,
530
+ "schema": {"type": "string"},
531
+ "description": description,
532
+ }
533
+
534
+
535
+ def build_app(
536
+ config: Config,
537
+ *,
538
+ token: str | None = None,
539
+ session_attacher: SessionAttacher | None = None,
540
+ fixture: FixtureServer | None = None,
541
+ dist_path: Path | None = None,
542
+ ) -> Starlette:
543
+ """Build the dashboard Starlette app bound to a Config.
544
+
545
+ ``token``: the ``/ws/terminal/{session_id}`` bridge's bearer token
546
+ (see :func:`coord.dashboard.terminal.resolve_web_token`). ``None`` means
547
+ the endpoint runs open (dev default) -- fine on a tailnet-only box, but
548
+ the production dashboard should set one, same convention as `coord
549
+ serve`'s ``resolve_serve_token``.
550
+
551
+ ``session_attacher``: injectable seam for the ssh/tmux PTY spawn behind
552
+ the terminal bridge (#1065 acceptance) -- defaults to the real
553
+ :class:`~coord.dashboard.terminal.TmuxSessionAttacher`; tests pass a fake.
554
+
555
+ ``fixture`` (#1538): a :class:`~coord.dashboard.fixture.FixtureServer`
556
+ puts the app in **seeded-board mode** — every read is answered from the
557
+ fixture instead of ``~/.coord/coord.db`` / the fleet, and every write is
558
+ recorded rather than executed. The routes, handlers and serialization are
559
+ unchanged; only the data source is swapped, so an acceptance suite built
560
+ on this is still testing the real contract. ``None`` (the default) is the
561
+ ordinary live dashboard, byte-for-byte as before.
562
+
563
+ ``dist_path`` (#1543): override where the built React webapp is read
564
+ from — ``coord web --dist PATH`` / ``$COORD_WEB_DIST``. Lets the bundle
565
+ be served from outside the installed package (e.g. a checkout a build
566
+ hook keeps in sync with merged ``main``) so a webapp change goes live
567
+ without upgrading ``~/.coord-venv`` — see docs/PHONE_WEBAPP.md. ``None``
568
+ (the default) keeps the historical behaviour of serving
569
+ ``coord/dashboard/webapp/dist`` from inside the installed package.
570
+ """
571
+ attacher: SessionAttacher = session_attacher or TmuxSessionAttacher()
572
+ _fixture = fixture
573
+ # Resolved once per app build. WEBAPP_DIST is read as a module global
574
+ # (not captured as a default arg) so tests that
575
+ # `patch("coord.dashboard.server.WEBAPP_DIST", ...)` keep working when
576
+ # dist_path is left unset (the CLI default).
577
+ webapp_dist = Path(dist_path) if dist_path is not None else WEBAPP_DIST
578
+
579
+ def _read_board():
580
+ """The board for this request — seeded fixture or the live DB/daemon.
581
+
582
+ Fixture mode rebuilds the Board from the raw payload on every call, so
583
+ a handler that mutates what it is handed (``unstick`` →
584
+ ``mark_failed_by_id``) can't leak that into the next request.
585
+ """
586
+ if _fixture is not None:
587
+ return _fixture.board()
588
+ return read_board()
589
+
590
+ def _write_board(board) -> None: # noqa: ANN001
591
+ """Persist *board* — a no-op in fixture mode (writes never execute)."""
592
+ if _fixture is not None:
593
+ return
594
+ write_board(board)
595
+
596
+ # ── Real-time event bus ────────────────────────────────────────────────
597
+ event_source = EventSource()
598
+
599
+ # Assignments whose terminal transition has already been published via SSE
600
+ # so that repeated polls don't re-fire the same toast.
601
+ _seen_terminal: set[str] = set()
602
+ # assignment_id → timestamp when we first noticed it orphaned.
603
+ _orphaned_since: dict[str, float] = {}
604
+ # #846: assignment_ids that already fired an ASSIGNMENT_NEEDS_ATTENTION
605
+ # toast, so the live poller doesn't re-publish every _POLL_INTERVAL.
606
+ _needs_attention_seen: set[str] = set()
607
+
608
+ # #1217 fix iteration 1: api_sessions' fleet tmux sweep gets its OWN bounded
609
+ # executor rather than sharing the asyncio loop's default executor (which
610
+ # `loop.run_in_executor(None, ...)` submits to). The Home.tsx phone client
611
+ # polls /api/sessions every 4s starting the instant the page loads; each
612
+ # poll fans out one blocking subprocess call per configured machine
613
+ # (bounded at 5s inside `list_coord_tmux_sessions`, batch-mode SSH
614
+ # ConnectTimeout=4). A machine that's down takes the full ~4-5s on EVERY
615
+ # poll, and 4s < 5s means a new sweep task for that machine can be
616
+ # submitted before the previous one finishes — so tasks for a chronically
617
+ # unreachable machine back up faster than they drain. On the shared
618
+ # default executor that backlog eventually starves every other consumer
619
+ # of `run_in_executor(None, ...)` in the process (the background agent
620
+ # poller, the terminal WS PTY-read loop, ...), which is exactly the
621
+ # dashboard-wide hang the operator hit. A dedicated executor contains the
622
+ # backlog to this endpoint; the offline-cooldown cache below stops the
623
+ # backlog from growing in the first place.
624
+ _sessions_executor = ThreadPoolExecutor(
625
+ max_workers=max(4, len(config.machines) * 4),
626
+ thread_name_prefix="coord-sessions-sweep",
627
+ )
628
+ # machine name -> monotonic timestamp of the last sweep that looked like a
629
+ # timeout/unreachable host (took close to the 5s subprocess cap). While a
630
+ # machine is within cooldown, skip spawning a new sweep thread for it
631
+ # entirely and report "no sessions" immediately, instead of re-paying the
632
+ # full SSH ConnectTimeout on every ~4s dashboard poll.
633
+ _sessions_offline_since: dict[str, float] = {}
634
+
635
+ async def _background_poller() -> None:
636
+ """Runs forever; polls agents every _POLL_INTERVAL seconds."""
637
+ await asyncio.sleep(10) # Short initial delay so the server is ready
638
+ while True:
639
+ try:
640
+ possibly_stuck = await _poll_once(
641
+ config, event_source, _seen_terminal, _orphaned_since,
642
+ needs_attention_seen=_needs_attention_seen,
643
+ )
644
+ event_source.publish(BOARD_UPDATED, {
645
+ "possibly_stuck": possibly_stuck,
646
+ "timestamp": time.time(),
647
+ })
648
+ except Exception:
649
+ pass
650
+ await asyncio.sleep(_POLL_INTERVAL)
651
+
652
+ async def _play_event_script() -> None:
653
+ """Publish the fixture's scripted SSE sequence once (#1538).
654
+
655
+ Each entry's ``after`` is a delay relative to the previous one, so the
656
+ fixture reads as a timeline. This is what makes live-update behaviour
657
+ testable: the acceptance suite subscribes to ``/events`` and then hits
658
+ ``POST /api/fixture/events/replay`` to run the script deterministically
659
+ instead of racing the server's startup — which is why the script does
660
+ NOT play at startup unless the fixture opts in with
661
+ ``autoplay_events``.
662
+ """
663
+ for scripted in _fixture.events:
664
+ if scripted.after:
665
+ await asyncio.sleep(scripted.after)
666
+ event_source.publish(scripted.type, scripted.data)
667
+
668
+ from contextlib import asynccontextmanager
669
+
670
+ @asynccontextmanager
671
+ async def _lifespan(app): # noqa: ANN001
672
+ # Fixture mode never polls the fleet (no network, no money); the
673
+ # scripted event sequence takes the background poller's place, and
674
+ # only auto-plays when the fixture explicitly asks for it.
675
+ if _fixture is None:
676
+ asyncio.create_task(_background_poller())
677
+ elif _fixture.events and _fixture.autoplay_events:
678
+ asyncio.create_task(_play_event_script())
679
+ yield
680
+ _sessions_executor.shutdown(wait=False, cancel_futures=True)
681
+
682
+ async def index(request: Request) -> HTMLResponse:
683
+ # Serve the built React webapp when available; fall back to the legacy
684
+ # single-file dashboard so existing behaviour is entirely unchanged.
685
+ spa_index = webapp_dist / "index.html"
686
+ html = spa_index.read_text() if spa_index.exists() else (DASHBOARD_DIR / "index.html").read_text()
687
+ return HTMLResponse(html)
688
+
689
+ async def api_board(request: Request) -> JSONResponse:
690
+ board = _read_board()
691
+ from dataclasses import asdict
692
+ return JSONResponse({
693
+ "round_number": board.round_number,
694
+ "active": [asdict(a) for a in board.active],
695
+ "completed": [asdict(a) for a in board.completed[-20:]],
696
+ })
697
+
698
+ async def api_machines(request: Request) -> JSONResponse:
699
+ if _fixture is not None:
700
+ # Seeded reachability — never probe the fleet in fixture mode.
701
+ return JSONResponse(_fixture.machines())
702
+ statuses = check_all(config.machines, timeout=3.0)
703
+ result = []
704
+ for s in statuses:
705
+ machine_data = {
706
+ "name": s.machine.name,
707
+ "host": s.machine.host,
708
+ "repos": s.machine.repos,
709
+ "state": s.state,
710
+ "reason": s.reason,
711
+ "latency_ms": s.latency_ms,
712
+ }
713
+ if s.is_online:
714
+ status_result = fetch_status(s.machine, timeout=3.0)
715
+ if status_result.ok:
716
+ machine_data["assignments"] = status_result.data
717
+ else:
718
+ machine_data["assignments"] = None
719
+ machine_data["status_error"] = status_result.error
720
+ result.append(machine_data)
721
+ return JSONResponse(result)
722
+
723
+ async def api_sessions(request: Request) -> JSONResponse:
724
+ """GET /api/sessions — live coord-* interactive sessions the phone can
725
+ attach to via GET /ws/terminal/{session_id} (#1066).
726
+
727
+ Sources the roster from the same fleet session substrate `coord
728
+ sessions` itself reads — :func:`coord.interactive.list_coord_tmux_sessions`
729
+ (milestone #32 / substrate #28) — rather than inventing a parallel tmux
730
+ discovery path, then enriches each session against the board, the same
731
+ source :func:`~coord.dashboard.terminal.resolve_session_target` (#1065)
732
+ uses to route the actual WS attach, so the two paths can't drift.
733
+
734
+ Fleet-wide (#1217): sweeps *every* configured machine, not just the
735
+ local host `coord web` happens to run on — reusing the exact pattern
736
+ `coord sessions --remote` already proves
737
+ (``list_coord_tmux_sessions(host=TmuxHost(ssh_target=machine.host,
738
+ batch=True))`` per machine; ``batch=True`` so a down/unreachable host
739
+ fails fast instead of hanging on an ssh passphrase prompt). The
740
+ dashboard host itself is probed with ``TmuxHost(None)`` (a plain local
741
+ tmux call, no ssh round-trip to itself). Per-host sweeps run
742
+ concurrently via ``asyncio.gather`` over ``run_in_executor`` calls, so
743
+ wall-clock is bounded by the slowest single host's timeout, not the
744
+ sum across the fleet — important since each host sweep already has
745
+ its own 5s cap inside ``list_coord_tmux_sessions`` and this endpoint
746
+ is polled every ~4s from the phone. On a session-name collision across
747
+ hosts (shouldn't happen in practice — session names embed the
748
+ assignment id) the local host wins, mirroring `coord sessions
749
+ --remote`'s "local always wins" rule.
750
+
751
+ Each session is tagged with the machine it was actually discovered
752
+ on: the board assignment's `machine_name` when the session matches
753
+ one (the common case), falling back to the sweep's source machine for
754
+ orphaned/unmatched sessions (`coord terminal new` panes, stale
755
+ sessions with no board row) — previously these reported `machine:
756
+ null` even though the sweep knew exactly which host they came from.
757
+
758
+ tmux discovery shells out (bounded by a 5s timeout inside
759
+ ``list_coord_tmux_sessions``) so each host's sweep runs off the event
760
+ loop thread via ``run_in_executor`` — but on a **dedicated** executor
761
+ (``_sessions_executor``, sized to the fleet) rather than the shared
762
+ default one, and a machine that recently looked unreachable is
763
+ skipped for a cooldown window instead of being re-probed every ~4s
764
+ (see the comment above ``_sessions_executor``'s definition for why:
765
+ #1217 iteration 1 fixed a dashboard-wide hang caused by exactly this
766
+ fan-out saturating the process's shared default executor).
767
+ """
768
+ if _fixture is not None:
769
+ # Seeded roster — no tmux, no ssh fan-out in fixture mode.
770
+ return JSONResponse(_fixture.sessions())
771
+
772
+ from coord.interactive import (
773
+ TMUX_SESSION_PREFIX,
774
+ TmuxHost,
775
+ _get_local_short_hostname,
776
+ list_coord_tmux_sessions,
777
+ )
778
+
779
+ loop = asyncio.get_running_loop()
780
+ local_hn = _get_local_short_hostname()
781
+
782
+ def _is_local_machine(machine) -> bool:
783
+ return (
784
+ machine.name.lower() == local_hn
785
+ or machine.host.split(".")[0].lower() == local_hn
786
+ )
787
+
788
+ def _sweep_one(machine):
789
+ is_local = _is_local_machine(machine)
790
+ host = (
791
+ TmuxHost(None)
792
+ if is_local
793
+ else TmuxHost(ssh_target=machine.host, batch=True)
794
+ )
795
+ start = time.monotonic()
796
+ try:
797
+ found = list_coord_tmux_sessions(host=host)
798
+ except Exception: # noqa: BLE001 — a down/unreachable machine just contributes nothing
799
+ found = []
800
+ elapsed = time.monotonic() - start
801
+ # Only track cooldown for remote machines — the local sweep never
802
+ # goes over SSH and a slow local tmux call shouldn't suppress it.
803
+ if not is_local:
804
+ if elapsed >= _SESSIONS_SLOW_THRESHOLD:
805
+ _sessions_offline_since[machine.name] = time.monotonic()
806
+ else:
807
+ _sessions_offline_since.pop(machine.name, None)
808
+ return machine, found, is_local
809
+
810
+ async def _cached_empty(machine, is_local):
811
+ return machine, [], is_local
812
+
813
+ tasks = []
814
+ for m in config.machines:
815
+ is_local = _is_local_machine(m)
816
+ since = _sessions_offline_since.get(m.name)
817
+ if (
818
+ not is_local
819
+ and since is not None
820
+ and (time.monotonic() - since) < _SESSIONS_COOLDOWN
821
+ ):
822
+ tasks.append(_cached_empty(m, is_local))
823
+ else:
824
+ tasks.append(loop.run_in_executor(_sessions_executor, _sweep_one, m))
825
+
826
+ sweeps = await asyncio.gather(*tasks)
827
+ # Local host(s) first so they win any session-name collision, matching
828
+ # `coord sessions --remote`'s "local always wins" rule. Stable sort
829
+ # preserves config.machines order within each group.
830
+ sweeps = sorted(sweeps, key=lambda t: not t[2])
831
+
832
+ board = _read_board()
833
+ assignments_by_id = {
834
+ a.assignment_id: a
835
+ for a in (*board.active, *board.completed)
836
+ if a.assignment_id
837
+ }
838
+ machines_by_name = {m.name: m for m in config.machines}
839
+
840
+ sessions = []
841
+ seen_names: set[str] = set()
842
+ for source_machine, raw_sessions, _is_local in sweeps:
843
+ for s in raw_sessions:
844
+ session_name = s.get("session_name", "")
845
+ if session_name in seen_names:
846
+ continue
847
+ seen_names.add(session_name)
848
+ session_id = session_name[len(TMUX_SESSION_PREFIX):]
849
+ assignment = assignments_by_id.get(session_id)
850
+ machine_name = (
851
+ assignment.machine_name if assignment else source_machine.name
852
+ )
853
+ machine_cfg = machines_by_name.get(machine_name)
854
+ sessions.append({
855
+ "session_id": session_id,
856
+ "session_name": session_name,
857
+ "machine": machine_name,
858
+ "host": machine_cfg.host if machine_cfg else source_machine.host,
859
+ "repo": assignment.repo_name if assignment else None,
860
+ "issue": assignment.issue_number if assignment else None,
861
+ "issue_title": assignment.issue_title if assignment else None,
862
+ "stage": assignment.type if assignment else None,
863
+ "status": assignment.status if assignment else None,
864
+ "attached": bool(s.get("attached", False)),
865
+ "pane_dead": s.get("pane_dead") == "1",
866
+ })
867
+ return JSONResponse(sessions)
868
+
869
+ async def api_proposals(request: Request) -> JSONResponse:
870
+ proposals = _fixture.proposals() if _fixture is not None else load_proposals()
871
+ from dataclasses import asdict
872
+ return JSONResponse([asdict(p) for p in proposals])
873
+
874
+ async def api_approve(request: Request) -> JSONResponse:
875
+ from coord.dispatch import dispatch, post_briefing, compute_do_not_touch
876
+ from coord.state import (
877
+ clear_proposals, load_dispatched, load_proposals as load_p,
878
+ record_dispatched,
879
+ )
880
+
881
+ try:
882
+ body = await request.json()
883
+ except ValueError:
884
+ return JSONResponse({"error": "invalid JSON"}, status_code=400)
885
+
886
+ ids = body.get("ids", [])
887
+ if not ids or not isinstance(ids, list):
888
+ return JSONResponse({"error": "ids must be a non-empty list"}, status_code=400)
889
+
890
+ briefing_overrides = body.get("briefings", {})
891
+
892
+ if _fixture is not None:
893
+ # Recorded, not executed — the shape below matches the live path's
894
+ # per-proposal results array so the client can't tell the
895
+ # difference, but nothing is dispatched and no money is spent.
896
+ selected = [p for p in _fixture.proposals() if p.id in ids]
897
+ if not selected:
898
+ return JSONResponse({"error": "no matching proposals"}, status_code=404)
899
+ _fixture.record("/api/approve", body, action="approve")
900
+ return JSONResponse({
901
+ "results": [
902
+ {"id": p.id, "assignment_id": f"fixture-{p.id}", "ok": True}
903
+ for p in selected
904
+ ]
905
+ })
906
+
907
+ proposals = load_p()
908
+ selected = [p for p in proposals if p.id in ids]
909
+ if not selected:
910
+ return JSONResponse({"error": "no matching proposals"}, status_code=404)
911
+
912
+ for p in selected:
913
+ override = briefing_overrides.get(str(p.id))
914
+ if override is not None:
915
+ p.briefing = override
916
+
917
+ from coord.claim import claim_message, find_work_claim
918
+
919
+ in_flight = load_dispatched()
920
+ board_for_claim = _read_board()
921
+ results = []
922
+ for p in selected:
923
+ repo = config.repo(p.repo_name)
924
+ if repo is not None:
925
+ claim = find_work_claim(
926
+ p.issue_number, p.repo_name, repo.github, board_for_claim
927
+ )
928
+ if claim is not None:
929
+ results.append({
930
+ "id": p.id, "ok": False,
931
+ "error": claim_message(claim),
932
+ "claimed": True,
933
+ })
934
+ continue
935
+ try:
936
+ response = dispatch(p, config)
937
+ assignment_id = response.get("id", "pending")
938
+ if repo:
939
+ record_dispatched(
940
+ assignment_id=assignment_id,
941
+ proposal=p,
942
+ repo_github=repo.github,
943
+ provider_name=response.get("_provider_name"),
944
+ )
945
+ do_not_touch = compute_do_not_touch(p, peers=selected, in_flight=in_flight)
946
+ try:
947
+ post_briefing(p, config, assignment_id=assignment_id, do_not_touch=do_not_touch)
948
+ except Exception:
949
+ pass
950
+ results.append({"id": p.id, "assignment_id": assignment_id, "ok": True})
951
+ except Exception as e:
952
+ results.append({"id": p.id, "ok": False, "error": str(e)})
953
+
954
+ clear_proposals()
955
+ board = _read_board()
956
+ board.round_number += 1
957
+ _write_board(board)
958
+ return JSONResponse({"results": results})
959
+
960
+ async def api_chat(request: Request) -> StreamingResponse:
961
+ try:
962
+ body = await request.json()
963
+ except ValueError:
964
+ return JSONResponse({"error": "invalid JSON"}, status_code=400)
965
+
966
+ message = body.get("message", "").strip()
967
+ if not message:
968
+ return JSONResponse({"error": "message required"}, status_code=400)
969
+
970
+ if _fixture is not None:
971
+ # Recorded, not executed — never spawn a provider subprocess in
972
+ # fixture mode. Same SSE envelope the live path streams.
973
+ _fixture.record("/api/chat", body, action="chat")
974
+ reply = _fixture.chat_reply
975
+
976
+ async def canned():
977
+ yield f"data: {json.dumps({'text': reply})}\n\n"
978
+ yield "data: [DONE]\n\n"
979
+
980
+ return StreamingResponse(canned(), media_type="text/event-stream")
981
+
982
+ board = _read_board()
983
+ from dataclasses import asdict
984
+ board_context = json.dumps({
985
+ "round_number": board.round_number,
986
+ "active": [asdict(a) for a in board.active],
987
+ "completed": [asdict(a) for a in board.completed[-10:]],
988
+ }, indent=2)
989
+
990
+ system = (
991
+ "You are the coordinator assistant for a multi-machine Claude Code system. "
992
+ "Answer questions about the current board state, assignments, and machines. "
993
+ "Be concise.\n\n"
994
+ f"Current board state:\n{board_context}"
995
+ )
996
+
997
+ # Resolve the coordinator's default provider so the dashboard chat
998
+ # honours the configured backend rather than hard-coding "claude".
999
+ # Uses resolve_default_provider (shared with brain.py) which also
1000
+ # enforces the human_attended_only guard — raises ValueError if the
1001
+ # configured default is a human-attended-only backend such as
1002
+ # ClaudePtyProvider, preventing unattended use of those providers.
1003
+ from coord.providers import resolve_default_provider # noqa: PLC0415
1004
+
1005
+ try:
1006
+ _provider = resolve_default_provider(config.providers, config.models)
1007
+ except ValueError as exc:
1008
+ return JSONResponse({"error": str(exc)}, status_code=400)
1009
+ # output_format=None → no --output-format flag; dashboard streams
1010
+ # plain-text lines, not a JSON envelope.
1011
+ _chat_cmd = _provider.oneshot_command(system_prompt=system, output_format=None)
1012
+
1013
+ async def stream():
1014
+ proc = await asyncio.create_subprocess_exec(
1015
+ *_chat_cmd,
1016
+ stdin=asyncio.subprocess.PIPE,
1017
+ stdout=asyncio.subprocess.PIPE,
1018
+ stderr=asyncio.subprocess.PIPE,
1019
+ )
1020
+ proc.stdin.write(message.encode())
1021
+ proc.stdin.close()
1022
+
1023
+ async for line in proc.stdout:
1024
+ text = line.decode("utf-8", errors="replace")
1025
+ yield f"data: {json.dumps({'text': text})}\n\n"
1026
+
1027
+ await proc.wait()
1028
+ yield "data: [DONE]\n\n"
1029
+
1030
+ return StreamingResponse(stream(), media_type="text/event-stream")
1031
+
1032
+ async def api_reject(request: Request) -> JSONResponse:
1033
+ from coord.state import load_proposals as load_p, save_proposals as save_p
1034
+
1035
+ try:
1036
+ body = await request.json()
1037
+ except ValueError:
1038
+ return JSONResponse({"error": "invalid JSON"}, status_code=400)
1039
+
1040
+ ids = body.get("ids", [])
1041
+ if not ids or not isinstance(ids, list):
1042
+ return JSONResponse({"error": "ids must be a non-empty list"}, status_code=400)
1043
+
1044
+ if _fixture is not None:
1045
+ seeded = _fixture.proposals()
1046
+ remaining = [p for p in seeded if p.id not in ids]
1047
+ _fixture.record("/api/reject", body, action="reject")
1048
+ return JSONResponse(
1049
+ {"removed": len(seeded) - len(remaining), "remaining": len(remaining)}
1050
+ )
1051
+
1052
+ proposals = load_p()
1053
+ remaining = [p for p in proposals if p.id not in ids]
1054
+ removed = len(proposals) - len(remaining)
1055
+ if remaining:
1056
+ save_p(remaining)
1057
+ else:
1058
+ from coord.state import clear_proposals
1059
+ clear_proposals()
1060
+ return JSONResponse({"removed": removed, "remaining": len(remaining)})
1061
+
1062
+ async def api_diff(request: Request) -> JSONResponse:
1063
+ assignment_id = request.path_params["id"]
1064
+ board = _read_board()
1065
+ assignment = board.find_by_id(assignment_id)
1066
+ if assignment is None:
1067
+ return JSONResponse({"error": "assignment not found"}, status_code=404)
1068
+ if not assignment.branch:
1069
+ return JSONResponse({"error": "no branch recorded"}, status_code=404)
1070
+
1071
+ if _fixture is not None:
1072
+ # Seeded diff text — never shell out to `gh` in fixture mode.
1073
+ return JSONResponse(
1074
+ {"diff": _fixture.diff(assignment_id), "source": "fixture"}
1075
+ )
1076
+
1077
+ repo = config.repo(assignment.repo_name)
1078
+ if repo is None:
1079
+ return JSONResponse({"error": "unknown repo"}, status_code=404)
1080
+
1081
+ try:
1082
+ from coord.github_ops import _gh
1083
+ raw = _gh(
1084
+ "pr", "diff", "--repo", repo.github,
1085
+ assignment.branch,
1086
+ )
1087
+ return JSONResponse({"diff": raw, "source": "pr"})
1088
+ except RuntimeError:
1089
+ pass
1090
+
1091
+ try:
1092
+ from coord.github_ops import _gh
1093
+ raw = _gh(
1094
+ "api", f"repos/{repo.github}/compare/{repo.default_branch}...{assignment.branch}",
1095
+ "--jq", ".files[].patch // empty",
1096
+ )
1097
+ return JSONResponse({"diff": raw, "source": "compare"})
1098
+ except RuntimeError as e:
1099
+ return JSONResponse({"error": str(e)}, status_code=500)
1100
+
1101
+ async def api_pipeline(request: Request) -> JSONResponse:
1102
+ """GET /api/pipeline — return PipelineView for every type='work' assignment.
1103
+
1104
+ Fixture mode (#1538) swaps the three data sources below — board, merge
1105
+ queue, cached review findings — and pins ``now``; the computation and
1106
+ serialization underneath are the same ``compute_pipeline`` + ``asdict``
1107
+ the live dashboard runs, which is what keeps a fixture-backed
1108
+ acceptance suite honest.
1109
+
1110
+ #2066: bounded by default to active work + recently-finished terminal
1111
+ work, and sorted newest-first — the unbounded response used to grow
1112
+ ~14 rows/day forever (711 rows, 2 of them live, when filed). The
1113
+ cutoff itself (``coord.dao._board_retention_cutoff`` /
1114
+ ``COORD_BOARD_RETENTION_DAYS``, default 14 days) is reused from
1115
+ ``/board``'s DAO layer per #773's precedent, but *what* gets aged is
1116
+ deliberately not a straight port of ``coord.dao.compute_board_keep_ids``:
1117
+ that function judges a row's own ``status`` against
1118
+ ``TERMINAL_STATUSES``, then closes the kept set over
1119
+ ``review_of_assignment_id`` links so an in-flight review/smoke never
1120
+ strands its parent work row. Here we instead filter on
1121
+ ``PipelineView.current_stage`` *after* ``compute_pipeline`` has
1122
+ already inspected the linked review/smoke assignment — a work
1123
+ assignment's own ``status`` flips to ``"done"`` the moment coding
1124
+ finishes, independent of whether its review or smoke is still
1125
+ running (``coord/notify.py`` documents this as the normal, possibly
1126
+ long-lived intermediate state #846 ``needs_attention`` exists to
1127
+ catch), so gating on ``a.status`` alone silently aged out exactly the
1128
+ stalled-review rows that matter most. Filtering on ``current_stage``
1129
+ gets the same protection as the DAO's closure — and additionally
1130
+ covers "finished this sub-stage, awaiting a human gate click"
1131
+ states (``review_done``, ``smoke_passed``, ``merge_ready``,
1132
+ ``merging``) that the closure alone wouldn't, since those have no
1133
+ non-terminal linked assignment to close over. We deliberately do
1134
+ **not** port the DAO's third exemption — "latest assignment of a
1135
+ still-open issue" — the issue that drove this fix
1136
+ (claude-coordinator#772) is itself an *open* issue with a long-dead
1137
+ assignment, so keeping every open issue's latest row alive forever
1138
+ would defeat the bound.
1139
+
1140
+ ``?include=all`` opts back into the full, unbounded history.
1141
+
1142
+ The recency signal is ``PipelineView.finished_at`` — the max across
1143
+ the work/review/smoke assignments (#1218) — when present, falling
1144
+ back to the work assignment's own ``dispatched_at``; a row with
1145
+ neither is kept rather than guessed away (same conservative rule as
1146
+ ``compute_board_keep_ids``). Using ``pv.finished_at`` rather than the
1147
+ raw ``a.finished_at`` matters independently of whatever turns out to
1148
+ be causing some rows' ``finished_at`` to read back ``None``
1149
+ (#2066's still-open second half): ``a.finished_at`` freezes at
1150
+ coding-done and doesn't advance as review/smoke progress, while
1151
+ ``dispatched_at`` is unconditionally stamped at dispatch time by
1152
+ every code path, so the bound holds even for a row whose
1153
+ ``finished_at`` never got recorded.
1154
+ """
1155
+ from dataclasses import asdict
1156
+
1157
+ from coord.dao import _board_retention_cutoff
1158
+ from coord.pipeline import compute_pipeline
1159
+ from coord.merge_queue import load_queue
1160
+ from coord.state import load_assignment_review_findings
1161
+
1162
+ board = _read_board()
1163
+ mq_items = _fixture.merge_queue() if _fixture is not None else load_queue()
1164
+ pipeline_now = _fixture.now if _fixture is not None else None
1165
+ now = pipeline_now if pipeline_now is not None else time.time()
1166
+
1167
+ include_all = request.query_params.get("include") == "all"
1168
+ cutoff = None if include_all else _board_retention_cutoff(now)
1169
+
1170
+ # Build a lookup of review assignment id per work assignment_id so we can
1171
+ # fetch the review findings body with one pass instead of N nested loops.
1172
+ all_assignments = list(board.active) + list(board.completed)
1173
+ review_by_work: dict[str, str] = {} # work_aid → review aid
1174
+ for a in all_assignments:
1175
+ if a.type == "review" and a.review_of_assignment_id and a.assignment_id:
1176
+ review_by_work[a.review_of_assignment_id] = a.assignment_id
1177
+ merge_queue_ids = {m.assignment_id for m in mq_items if m.assignment_id}
1178
+
1179
+ pipelines: list[PipelineView] = []
1180
+ for a in all_assignments:
1181
+ if a.type not in ("work", None, ""):
1182
+ continue
1183
+ # Exclude assignments with no id (shouldn't normally happen).
1184
+ if not a.assignment_id:
1185
+ continue
1186
+ # No review findings yet — current_stage/finished_at (used for the
1187
+ # cutoff decision below) don't depend on it, so defer the DB call
1188
+ # until we know the row survives the filter.
1189
+ pv = compute_pipeline(
1190
+ a, board, mq_items, config,
1191
+ review_findings_body=None,
1192
+ now=pipeline_now,
1193
+ )
1194
+ if (
1195
+ cutoff is not None
1196
+ and pv.current_stage in _PIPELINE_QUIESCENT_STAGES
1197
+ and a.assignment_id not in merge_queue_ids
1198
+ ):
1199
+ ts = pv.finished_at if pv.finished_at is not None else a.dispatched_at
1200
+ if ts is not None and ts < cutoff:
1201
+ continue
1202
+ rev_aid = review_by_work.get(a.assignment_id)
1203
+ if rev_aid:
1204
+ found = (
1205
+ _fixture.review_findings(rev_aid)
1206
+ if _fixture is not None
1207
+ else load_assignment_review_findings(rev_aid)
1208
+ )
1209
+ if found:
1210
+ _, pv.review_findings_body = found
1211
+ pipelines.append(pv)
1212
+
1213
+ # Newest-first: today's running work above a July failure, not the
1214
+ # reverse (#2066 step 3). Same finished_at-then-dispatched_at signal
1215
+ # as the cutoff above; a genuinely undatable row sorts last.
1216
+ dispatched_by_id = {a.assignment_id: a.dispatched_at for a in all_assignments}
1217
+ pipelines.sort(
1218
+ key=lambda pv: (
1219
+ pv.finished_at
1220
+ if pv.finished_at is not None
1221
+ else dispatched_by_id.get(pv.assignment_id)
1222
+ )
1223
+ or 0.0,
1224
+ reverse=True,
1225
+ )
1226
+
1227
+ return JSONResponse([asdict(pv) for pv in pipelines])
1228
+
1229
+ # Actions whose live handler returns a fixed-shape success envelope. The
1230
+ # fixture branch below reproduces that envelope exactly (`ok: true` plus
1231
+ # whatever fields the client reads) so a seeded acceptance run exercises
1232
+ # the same client code as production — while the side effect that would
1233
+ # have cost money is only written to the recorded-action log.
1234
+ def _fixture_action(action, body, assignment, board) -> JSONResponse: # noqa: ANN001
1235
+ """POST /api/pipeline/action in fixture mode — record, never execute.
1236
+
1237
+ Validation that can be answered from the fixture alone (unknown
1238
+ action, verdict enums, missing review row, merge-queue membership) is
1239
+ replicated so the error contract holds. Checks that depend on live
1240
+ config or the fleet (reviewer-machine availability, merge gates) are
1241
+ deliberately not — a fixture asserts the *client* contract, and a
1242
+ seeded board has no fleet to be unavailable.
1243
+ """
1244
+ aid = assignment.assignment_id or ""
1245
+ all_assignments = list(board.active) + list(board.completed)
1246
+ review_a = next(
1247
+ (
1248
+ a for a in all_assignments
1249
+ if a.review_of_assignment_id == aid and a.type == "review"
1250
+ ),
1251
+ None,
1252
+ )
1253
+
1254
+ # Reject before recording: an invalid request never happened.
1255
+ if action == "retry":
1256
+ return JSONResponse(
1257
+ {"ok": False, "error": "'retry' is not yet implemented in the dashboard"},
1258
+ status_code=501,
1259
+ )
1260
+ if action == "test-verdict":
1261
+ verdict = body.get("verdict")
1262
+ if verdict not in ("pass", "fail", "skip"):
1263
+ return JSONResponse(
1264
+ {"error": "verdict must be one of ['fail', 'pass', 'skip']"},
1265
+ status_code=400,
1266
+ )
1267
+ elif action == "record-review-verdict":
1268
+ if body.get("verdict") not in ("approve", "request-changes"):
1269
+ return JSONResponse(
1270
+ {"error": "verdict must be one of ['approve', 'request-changes']"},
1271
+ status_code=400,
1272
+ )
1273
+ if not body.get("body"):
1274
+ return JSONResponse(
1275
+ {"error": "body is required for record-review-verdict"},
1276
+ status_code=400,
1277
+ )
1278
+ if review_a is None:
1279
+ return JSONResponse(
1280
+ {"error": "no review assignment found for this work assignment"},
1281
+ status_code=404,
1282
+ )
1283
+ elif action == "post_findings":
1284
+ if review_a is None:
1285
+ return JSONResponse({"error": "no review assignment found"}, status_code=404)
1286
+ elif action == "merge":
1287
+ if not any(m.assignment_id == aid for m in _fixture.merge_queue()):
1288
+ return JSONResponse({"error": "not in merge queue"}, status_code=404)
1289
+ elif action == "dispatch_fix":
1290
+ if body.get("parent_type", "work") not in ("work", "review"):
1291
+ return JSONResponse(
1292
+ {
1293
+ "error": "parent_type must be 'work' or 'review', got "
1294
+ f"{body.get('parent_type')!r}"
1295
+ },
1296
+ status_code=400,
1297
+ )
1298
+ if not assignment.branch:
1299
+ return JSONResponse(
1300
+ {"ok": False, "error": "work assignment has no branch to fix"},
1301
+ status_code=400,
1302
+ )
1303
+ elif action not in (
1304
+ "dispatch_review", "dispatch_smoke", "enqueue", "unstick",
1305
+ ):
1306
+ return JSONResponse({"error": f"unknown action: {action!r}"}, status_code=400)
1307
+
1308
+ _fixture.record("/api/pipeline/action", body, action=action)
1309
+
1310
+ if action in ("dispatch_review", "dispatch_smoke"):
1311
+ kind = "review" if action == "dispatch_review" else "smoke"
1312
+ return JSONResponse({
1313
+ "ok": True,
1314
+ "machine_name": assignment.machine_name,
1315
+ "assignment_id": f"fixture-{kind}-{aid}",
1316
+ })
1317
+ if action == "dispatch_fix":
1318
+ return JSONResponse({
1319
+ "ok": True,
1320
+ "machine_name": assignment.machine_name,
1321
+ "assignment_id": f"fixture-fix-{aid}",
1322
+ "branch": assignment.branch,
1323
+ })
1324
+ if action == "merge":
1325
+ return JSONResponse({
1326
+ "ok": True,
1327
+ "events": [
1328
+ {"kind": "merged", "message": f"fixture: would merge {aid}"}
1329
+ ],
1330
+ })
1331
+ if action == "post_findings":
1332
+ return JSONResponse({"ok": True, "detail": "posted"})
1333
+ if action == "unstick":
1334
+ return JSONResponse({"ok": True, "cancelled_on_agent": False})
1335
+ if action == "test-verdict":
1336
+ state = {"pass": "passed", "fail": "failed", "skip": "skipped"}[
1337
+ body["verdict"]
1338
+ ]
1339
+ return JSONResponse({"ok": True, "test_state": state})
1340
+ # enqueue, record-review-verdict
1341
+ return JSONResponse({"ok": True})
1342
+
1343
+ async def api_pipeline_action(request: Request) -> JSONResponse:
1344
+ """POST /api/pipeline/action — advance an assignment through a gate.
1345
+
1346
+ Body: {"assignment_id": "...", "action": "..."}
1347
+
1348
+ Supported actions: dispatch_review, dispatch_smoke, enqueue, merge,
1349
+ retry (501), dispatch_fix (501).
1350
+
1351
+ In fixture mode (#1538) every one of these is **recorded, not
1352
+ executed** — see :func:`_fixture_action`.
1353
+ """
1354
+ try:
1355
+ body = await request.json()
1356
+ except ValueError:
1357
+ return JSONResponse({"error": "invalid JSON"}, status_code=400)
1358
+
1359
+ assignment_id = body.get("assignment_id")
1360
+ action = body.get("action")
1361
+ if not assignment_id or not action:
1362
+ return JSONResponse(
1363
+ {"error": "assignment_id and action are required"}, status_code=400
1364
+ )
1365
+
1366
+ board = _read_board()
1367
+ assignment = board.find_by_id(assignment_id)
1368
+ if assignment is None:
1369
+ return JSONResponse({"error": "assignment not found"}, status_code=404)
1370
+
1371
+ if _fixture is not None:
1372
+ return _fixture_action(action, body, assignment, board)
1373
+
1374
+ if action == "dispatch_review":
1375
+ from coord.review import dispatch_review
1376
+
1377
+ try:
1378
+ result = dispatch_review(assignment, board, config)
1379
+ except Exception as exc:
1380
+ return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
1381
+ if result:
1382
+ _write_board(board)
1383
+ return JSONResponse({
1384
+ "ok": True,
1385
+ "machine_name": result.machine_name,
1386
+ "assignment_id": result.assignment_id,
1387
+ })
1388
+ # #1627: report the specific guard dispatch_review hit instead of
1389
+ # a generic guess — it's recorded on the assignment itself.
1390
+ return JSONResponse({
1391
+ "ok": False,
1392
+ "error": assignment.review_dispatch_reason
1393
+ or "could not find a suitable reviewer machine (check reviews config and machine availability)",
1394
+ })
1395
+
1396
+ elif action == "dispatch_smoke":
1397
+ from coord.smoke import dispatch_smoke
1398
+
1399
+ try:
1400
+ result = dispatch_smoke(assignment, board, config)
1401
+ except Exception as exc:
1402
+ return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
1403
+ if result:
1404
+ _write_board(board)
1405
+ return JSONResponse({
1406
+ "ok": True,
1407
+ "machine_name": result.machine_name,
1408
+ "assignment_id": result.assignment_id,
1409
+ })
1410
+ return JSONResponse({
1411
+ "ok": False,
1412
+ "error": "no smoke test needed or no capable machine matched the diff",
1413
+ })
1414
+
1415
+ elif action == "enqueue":
1416
+ repo = config.repo(assignment.repo_name)
1417
+ if repo is None:
1418
+ return JSONResponse({"error": "unknown repo"}, status_code=404)
1419
+ from coord import github_ops as _gh_ops
1420
+ from coord import merge_queue as mq
1421
+
1422
+ # #946: this was the third (dashboard-only) enqueue path left
1423
+ # ungated after the daemon (`enqueue_approved_work`) and `coord
1424
+ # merge`'s auto-enqueue loop were fixed to use the shared
1425
+ # `passes_merge_gates` predicate. Gate here too — untested /
1426
+ # unreviewed work must never enter the merge queue through any
1427
+ # path. `force: true` in the request body is the explicit
1428
+ # escape hatch, mirroring `--force-merge` at merge time.
1429
+ #
1430
+ # #2085: `assignment` is a raw work Assignment — it has no
1431
+ # `branch_head_sha`/`branch_patch_id` attribute at all, so
1432
+ # handing it straight to `passes_merge_gates` made
1433
+ # `has_approved_review`'s #821 freshness check permanently
1434
+ # UNCONFIRMABLE here: every review carrying a real
1435
+ # `review_head_sha` (virtually every modern approval) failed
1436
+ # closed, so pressing "Enqueue" in the Phone Control Center on
1437
+ # perfectly fresh, approved work reported "has not passed the
1438
+ # required review/smoke gates" and demanded `force: true`. This
1439
+ # was the one raw-Assignment gate call site missed when the
1440
+ # other four (`coord.gates.build_gate_report`,
1441
+ # `enqueue_approved_work`, `coord.notify`'s stalled-dispatch
1442
+ # recovery, `coord.diagnose`'s stage-work recovery) were routed
1443
+ # through `mq.live_gate_entry`. Same target_branch that
1444
+ # `enqueue` below merges into, so the gate is evaluated against
1445
+ # exactly the branch pair the merge would use.
1446
+ force = bool(body.get("force"))
1447
+ target_branch = repo.default_branch
1448
+ if not force:
1449
+ gate_entry = mq.live_gate_entry(
1450
+ assignment, repo.github, target_branch, _gh_ops
1451
+ )
1452
+ if not mq.passes_merge_gates(
1453
+ gate_entry, config, board, gh_ops=_gh_ops
1454
+ ):
1455
+ return JSONResponse({
1456
+ "ok": False,
1457
+ "error": (
1458
+ "assignment has not passed the required review/smoke "
1459
+ "gates — pass force: true to enqueue anyway"
1460
+ ),
1461
+ })
1462
+
1463
+ try:
1464
+ if force:
1465
+ # Bypass the gate entirely — don't pass config/board, or
1466
+ # enqueue()'s own (unconditional) gate check would still
1467
+ # reject it despite the explicit override above.
1468
+ entry = mq.enqueue(assignment, repo.github, target_branch)
1469
+ else:
1470
+ entry = mq.enqueue(
1471
+ assignment, repo.github, target_branch,
1472
+ config=config, board=board, gh_ops=_gh_ops,
1473
+ )
1474
+ except Exception as exc:
1475
+ return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
1476
+ if entry is None:
1477
+ return JSONResponse({"ok": False, "error": "could not enqueue (already in queue?)"})
1478
+ return JSONResponse({"ok": True})
1479
+
1480
+ elif action == "merge":
1481
+ from coord import github_ops as _gh_ops
1482
+ from coord.merge_queue import PENDING, load_queue, process, save_queue
1483
+
1484
+ items = load_queue()
1485
+ target = next(
1486
+ (x for x in items if x.assignment_id == assignment_id), None
1487
+ )
1488
+ if target is None:
1489
+ return JSONResponse({"error": "not in merge queue"}, status_code=404)
1490
+ if target.state != PENDING:
1491
+ return JSONResponse(
1492
+ {"error": f"queue entry state is {target.state!r}, expected 'pending'"},
1493
+ status_code=400,
1494
+ )
1495
+ # Process only the single entry (target is in `items` by reference;
1496
+ # process() mutates it in place, then we save the full queue).
1497
+ events = process([target], _gh_ops)
1498
+ save_queue(items)
1499
+ return JSONResponse(
1500
+ {
1501
+ "ok": True,
1502
+ "events": [
1503
+ {"kind": e.kind, "message": e.message} for e in events
1504
+ ],
1505
+ }
1506
+ )
1507
+
1508
+ elif action == "post_findings":
1509
+ # Find the review assignment linked to this work assignment and
1510
+ # attempt to post its findings.
1511
+ all_assignments = list(board.active) + list(board.completed)
1512
+ review_assignment = next(
1513
+ (
1514
+ a for a in all_assignments
1515
+ if a.review_of_assignment_id == assignment_id and a.type == "review"
1516
+ ),
1517
+ None,
1518
+ )
1519
+ if review_assignment is None:
1520
+ return JSONResponse({"error": "no review assignment found"}, status_code=404)
1521
+ if review_assignment.review_posted_at is not None:
1522
+ return JSONResponse({"ok": True, "detail": "already posted"})
1523
+ from coord.notify import post_orphaned_review_findings # noqa: PLC0415
1524
+
1525
+ posted = post_orphaned_review_findings(config)
1526
+ ok = review_assignment.assignment_id in posted
1527
+ return JSONResponse(
1528
+ {"ok": ok, "detail": "posted" if ok else "not posted (agent offline or no structured findings)"}
1529
+ )
1530
+
1531
+ elif action == "unstick":
1532
+ # Cancel on the agent server (best-effort) then mark failed on the
1533
+ # board. Used for assignments that are running in the DB but have
1534
+ # silently disappeared from the agent's active list.
1535
+ machine = next(
1536
+ (m for m in config.machines if m.name == assignment.machine_name),
1537
+ None,
1538
+ )
1539
+ cancelled_on_agent = False
1540
+ if machine is not None:
1541
+ try:
1542
+ resp = httpx.post(
1543
+ f"http://{machine.host}:{AGENT_PORT}/cancel/{assignment_id}",
1544
+ timeout=10.0,
1545
+ )
1546
+ cancelled_on_agent = resp.status_code in (200, 202)
1547
+ except Exception:
1548
+ pass
1549
+ # Mark failed in the board regardless of agent response.
1550
+ board.mark_failed_by_id(assignment_id, finished_at=time.time())
1551
+ _write_board(board)
1552
+ return JSONResponse({"ok": True, "cancelled_on_agent": cancelled_on_agent})
1553
+
1554
+ elif action == "test-verdict":
1555
+ # Record a human Test-gate verdict for a work assignment.
1556
+ # Body: {assignment_id, verdict: "pass"|"fail"|"skip", reason?}
1557
+ verdict = body.get("verdict")
1558
+ reason = body.get("reason") or None
1559
+ _VALID_VERDICTS = {"pass", "fail", "skip"}
1560
+ if verdict not in _VALID_VERDICTS:
1561
+ return JSONResponse(
1562
+ {"error": f"verdict must be one of {sorted(_VALID_VERDICTS)!r}"},
1563
+ status_code=400,
1564
+ )
1565
+ # Map short form to the canonical test_state values used by the TUI
1566
+ # and reconcile gating logic.
1567
+ test_state_map = {"pass": "passed", "fail": "failed", "skip": "skipped"}
1568
+ test_state = test_state_map[verdict]
1569
+ test_reason = reason if verdict == "fail" else None
1570
+ # Mirror to legacy smoke_test column for the smoke-stage scoring in
1571
+ # pipeline.py (predates the human Test gate — same mirror as cli.py).
1572
+ smoke_test: str | None = verdict if verdict in ("pass", "fail") else None
1573
+ smoke_test_reason: str | None = reason if verdict == "fail" else None
1574
+ from coord.state import record_test_verdict as _record_test_verdict
1575
+
1576
+ try:
1577
+ _record_test_verdict(
1578
+ assignment_id=assignment_id,
1579
+ test_state=test_state,
1580
+ test_reason=test_reason,
1581
+ smoke_test=smoke_test,
1582
+ smoke_test_reason=smoke_test_reason,
1583
+ )
1584
+ except Exception as exc:
1585
+ return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
1586
+ return JSONResponse({"ok": True, "test_state": test_state})
1587
+
1588
+ elif action == "record-review-verdict":
1589
+ # Persist a parsed review verdict + findings body to the DB cache so
1590
+ # the phone can record results from a manual review session without
1591
+ # going through the full notify/auto_loop path.
1592
+ # Body: {assignment_id, verdict: "approve"|"request-changes", body}
1593
+ # NOTE: assignment_id here is the WORK assignment id (as exposed by
1594
+ # GET /api/pipeline). We must look up the linked review assignment
1595
+ # before writing, since _persist_review_findings writes to the review
1596
+ # row and compute_pipeline reads findings back from the review row.
1597
+ verdict = body.get("verdict")
1598
+ findings_body = body.get("body")
1599
+ _VALID_REVIEW_VERDICTS = {"approve", "request-changes"}
1600
+ if verdict not in _VALID_REVIEW_VERDICTS:
1601
+ return JSONResponse(
1602
+ {"error": f"verdict must be one of {sorted(_VALID_REVIEW_VERDICTS)!r}"},
1603
+ status_code=400,
1604
+ )
1605
+ if not findings_body:
1606
+ return JSONResponse(
1607
+ {"error": "body is required for record-review-verdict"},
1608
+ status_code=400,
1609
+ )
1610
+ # Look up the review assignment linked to this work assignment.
1611
+ all_assignments = list(board.active) + list(board.completed)
1612
+ review_a = next(
1613
+ (
1614
+ a for a in all_assignments
1615
+ if a.review_of_assignment_id == assignment_id and a.type == "review"
1616
+ ),
1617
+ None,
1618
+ )
1619
+ if review_a is None:
1620
+ return JSONResponse(
1621
+ {"error": "no review assignment found for this work assignment"},
1622
+ status_code=404,
1623
+ )
1624
+ from coord.notify import _persist_review_findings # noqa: PLC0415
1625
+
1626
+ try:
1627
+ _persist_review_findings(review_a.assignment_id, verdict, findings_body)
1628
+ except Exception as exc:
1629
+ return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
1630
+ return JSONResponse({"ok": True})
1631
+
1632
+ elif action == "retry":
1633
+ return JSONResponse(
1634
+ {"ok": False, "error": "'retry' is not yet implemented in the dashboard"},
1635
+ status_code=501,
1636
+ )
1637
+
1638
+ elif action == "dispatch_fix":
1639
+ parent_type = body.get("parent_type", "work")
1640
+ if parent_type not in ("work", "review"):
1641
+ return JSONResponse(
1642
+ {"error": f"parent_type must be 'work' or 'review', got {parent_type!r}"},
1643
+ status_code=400,
1644
+ )
1645
+ if not assignment.branch:
1646
+ return JSONResponse(
1647
+ {"ok": False, "error": "work assignment has no branch to fix"},
1648
+ status_code=400,
1649
+ )
1650
+ from coord.review import dispatch_headless_fix # noqa: PLC0415
1651
+
1652
+ try:
1653
+ result = dispatch_headless_fix(
1654
+ assignment, board, config, parent_type=parent_type
1655
+ )
1656
+ except Exception as exc:
1657
+ return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
1658
+ if result:
1659
+ _write_board(board)
1660
+ return JSONResponse({
1661
+ "ok": True,
1662
+ "machine_name": result.machine_name,
1663
+ "assignment_id": result.assignment_id,
1664
+ "branch": result.branch,
1665
+ })
1666
+ return JSONResponse({
1667
+ "ok": False,
1668
+ "error": (
1669
+ "could not dispatch fix — no capable machine, branch missing, "
1670
+ "findings unresolvable, or max review iterations reached"
1671
+ ),
1672
+ })
1673
+
1674
+ else:
1675
+ return JSONResponse(
1676
+ {"error": f"unknown action: {action!r}"}, status_code=400
1677
+ )
1678
+
1679
+ async def terminal_ws(websocket: WebSocket) -> None:
1680
+ """Human-attended PTY<->WebSocket bridge for a live tmux session (#1065).
1681
+
1682
+ ToS §3.7 / #437: relays a live human only -- browser keystrokes (binary
1683
+ frames) to the PTY's stdin, PTY stdout back as binary frames, plus
1684
+ JSON text control messages:
1685
+
1686
+ * ``{"type": "resize", "cols": .., "rows": ..}`` -- propagate a
1687
+ terminal resize to the PTY (``TIOCSWINSZ``).
1688
+ * ``{"type": "copy-mode", "action": "enter"|"exit"|"page-up"|\
1689
+ "page-down"|"top"|"bottom"}`` -- drive tmux copy-mode via
1690
+ ``tmux send-keys -X`` / ``tmux copy-mode`` so the phone's Scroll
1691
+ button can reach pane history without knowing the user's prefix
1692
+ key or mode-keys setting (#1299).
1693
+
1694
+ No autonomous injection or scraping happens here.
1695
+
1696
+ Auth: requires ``?token=`` to match the dashboard's configured bearer
1697
+ token (browsers can't set custom headers on a WS upgrade, so it can't
1698
+ travel as an ``Authorization`` header like the REST API's). No token
1699
+ configured on the server => open, matching `coord serve`'s
1700
+ ``resolve_serve_token`` convention. A token is configured but missing
1701
+ / wrong on the request => the connection is accepted and then closed
1702
+ immediately with 4401 (see the accept-then-close note below); no PTY
1703
+ is ever attached, so nothing is relayed to an unauthenticated client.
1704
+
1705
+ Accept-then-close (#1071 live-smoke fix): both rejection paths below
1706
+ MUST ``accept()`` the handshake before ``close(code=...)``. Per the
1707
+ ASGI/WebSocket spec an application close code can only be delivered
1708
+ over an *accepted* connection -- closing pre-accept aborts the HTTP
1709
+ upgrade instead, which reaches the browser as a plain ``403`` with no
1710
+ code attached. The client (`webapp/src/components/Terminal.tsx`)
1711
+ tells "this session is gone for good" (4404, a terminal state) apart
1712
+ from "transient drop, reconnect with backoff" purely by the close
1713
+ code, so a pre-accept close made every unknown session look like a
1714
+ transient drop and the client retried it forever. Accepting first
1715
+ costs one extra round trip on an already-failing request and makes
1716
+ the close code actually arrive.
1717
+ """
1718
+ # Consume the ASGI "websocket.connect" event before we can accept()
1719
+ # or close() the handshake.
1720
+ await websocket.receive()
1721
+ await websocket.accept()
1722
+
1723
+ if token and websocket.query_params.get("token") != token:
1724
+ await websocket.close(code=4401)
1725
+ return
1726
+
1727
+ session_id = websocket.path_params["session_id"]
1728
+ board = _read_board()
1729
+ target = resolve_session_target(session_id, board, config)
1730
+ if target is None:
1731
+ await websocket.close(code=4404)
1732
+ return
1733
+ host, session_name = target
1734
+
1735
+ try:
1736
+ attached = await attacher.attach(host, session_name)
1737
+ except Exception:
1738
+ await websocket.close(code=1011)
1739
+ return
1740
+
1741
+ async def _pump_output() -> None:
1742
+ try:
1743
+ while True:
1744
+ chunk = await attached.read()
1745
+ if not chunk:
1746
+ break
1747
+ await websocket.send_bytes(chunk)
1748
+ except Exception:
1749
+ pass
1750
+
1751
+ reader_task = asyncio.create_task(_pump_output())
1752
+ try:
1753
+ while True:
1754
+ message = await websocket.receive()
1755
+ if message["type"] == "websocket.disconnect":
1756
+ break
1757
+ data = message.get("bytes")
1758
+ if data is not None:
1759
+ attached.write(data)
1760
+ continue
1761
+ text = message.get("text")
1762
+ if text is None:
1763
+ continue
1764
+ try:
1765
+ payload = json.loads(text)
1766
+ except (json.JSONDecodeError, TypeError):
1767
+ continue
1768
+ if payload.get("type") == "resize":
1769
+ try:
1770
+ cols = int(payload.get("cols", 0))
1771
+ rows = int(payload.get("rows", 0))
1772
+ except (TypeError, ValueError):
1773
+ continue
1774
+ if cols > 0 and rows > 0:
1775
+ attached.resize(cols, rows)
1776
+ elif payload.get("type") == "copy-mode":
1777
+ action = payload.get("action")
1778
+ if isinstance(action, str):
1779
+ await attached.copy_mode(action)
1780
+ except WebSocketDisconnect:
1781
+ pass
1782
+ finally:
1783
+ reader_task.cancel()
1784
+ # Detach only -- NEVER kill the underlying tmux session (#1065).
1785
+ attached.detach()
1786
+
1787
+ routes = [
1788
+ Route("/", index, methods=["GET"]),
1789
+ Route("/api/board", api_board, methods=["GET"]),
1790
+ Route("/api/machines", api_machines, methods=["GET"]),
1791
+ Route("/api/sessions", api_sessions, methods=["GET"]),
1792
+ Route("/api/proposals", api_proposals, methods=["GET"]),
1793
+ Route("/api/approve", api_approve, methods=["POST"]),
1794
+ Route("/api/reject", api_reject, methods=["POST"]),
1795
+ Route("/api/diff/{id}", api_diff, methods=["GET"]),
1796
+ Route("/api/chat", api_chat, methods=["POST"]),
1797
+ Route("/api/pipeline", api_pipeline, methods=["GET"]),
1798
+ Route("/api/pipeline/action", api_pipeline_action, methods=["POST"]),
1799
+ build_events_route(event_source),
1800
+ WebSocketRoute("/ws/terminal/{session_id}", terminal_ws),
1801
+ ]
1802
+ # #757: served OpenAPI 3 spec + Swagger UI docs page.
1803
+ routes.extend(openapi_and_docs_routes(openapi_spec()))
1804
+
1805
+ # ── Fixture-mode introspection (#1538) ─────────────────────────────────
1806
+ # Registered ONLY under `coord web --fixture`, so the live dashboard's
1807
+ # route table and OpenAPI inventory are byte-for-byte unchanged. These are
1808
+ # the assertion surface: what would have been dispatched, and a
1809
+ # deterministic trigger for the fixture's scripted SSE sequence.
1810
+ if _fixture is not None:
1811
+
1812
+ async def api_fixture_actions(request: Request) -> JSONResponse:
1813
+ """GET/DELETE /api/fixture/actions — the recorded-write log."""
1814
+ if request.method == "DELETE":
1815
+ return JSONResponse({"cleared": _fixture.clear_actions()})
1816
+ return JSONResponse(
1817
+ {"actions": [a.to_dict() for a in _fixture.actions]}
1818
+ )
1819
+
1820
+ async def api_fixture_replay(request: Request) -> JSONResponse:
1821
+ """POST /api/fixture/events/replay — run the scripted SSE sequence."""
1822
+ asyncio.create_task(_play_event_script())
1823
+ return JSONResponse({"ok": True, "count": len(_fixture.events)})
1824
+
1825
+ async def api_fixture_publish(request: Request) -> JSONResponse:
1826
+ """POST /api/fixture/events — publish one ad-hoc SSE event now."""
1827
+ try:
1828
+ body = await request.json()
1829
+ except ValueError:
1830
+ return JSONResponse({"error": "invalid JSON"}, status_code=400)
1831
+ etype = body.get("type")
1832
+ if not etype or not isinstance(etype, str):
1833
+ return JSONResponse({"error": "type is required"}, status_code=400)
1834
+ event = event_source.publish(etype, body.get("data"))
1835
+ return JSONResponse({"ok": True, "id": event.id})
1836
+
1837
+ routes.extend([
1838
+ Route(
1839
+ "/api/fixture/actions",
1840
+ api_fixture_actions,
1841
+ methods=["GET", "DELETE"],
1842
+ include_in_schema=False,
1843
+ ),
1844
+ Route(
1845
+ "/api/fixture/events/replay",
1846
+ api_fixture_replay,
1847
+ methods=["POST"],
1848
+ include_in_schema=False,
1849
+ ),
1850
+ Route(
1851
+ "/api/fixture/events",
1852
+ api_fixture_publish,
1853
+ methods=["POST"],
1854
+ include_in_schema=False,
1855
+ ),
1856
+ ])
1857
+
1858
+ # ── Static file serving for the built React webapp ─────────────────────
1859
+ # Only activated when the resolved dist dir exists (i.e. after
1860
+ # `npm run build`, into either coord/dashboard/webapp/dist/ or the
1861
+ # --dist/COORD_WEB_DIST override — #1543). When absent the routes list
1862
+ # is unchanged and the legacy dashboard serves normally — no
1863
+ # test-suite impact.
1864
+ if webapp_dist.exists():
1865
+ # /assets/ — Vite hashed JS/CSS bundles (immutable; safe to cache).
1866
+ _assets = webapp_dist / "assets"
1867
+ if _assets.exists():
1868
+ routes.append(
1869
+ Mount("/assets", StaticFiles(directory=str(_assets)), name="assets")
1870
+ )
1871
+
1872
+ async def _spa_catch_all(request: Request) -> FileResponse | HTMLResponse:
1873
+ """Serve exact static files from dist/ or SPA index.html fallback.
1874
+
1875
+ Handles three cases:
1876
+ - Known static roots (sw.js, manifest.webmanifest, icons/, …)
1877
+ → served as the actual file so the browser gets correct MIME types.
1878
+ - SPA client-side routes (/issues/42, /pipeline, …)
1879
+ → serve index.html; the React router takes over.
1880
+ """
1881
+ path = request.path_params.get("path", "")
1882
+ candidate = webapp_dist / path
1883
+ if candidate.is_file():
1884
+ return FileResponse(str(candidate))
1885
+ # SPA fallback — let the React router handle the path.
1886
+ return HTMLResponse((webapp_dist / "index.html").read_text())
1887
+
1888
+ # Not part of the JSON API contract (client-side-router fallback) —
1889
+ # excluded from the OpenAPI route inventory via include_in_schema.
1890
+ routes.append(
1891
+ Route("/{path:path}", _spa_catch_all, methods=["GET"], include_in_schema=False)
1892
+ )
1893
+
1894
+ return Starlette(routes=routes, lifespan=_lifespan)