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,227 @@
1
+ /**
2
+ * Framework-agnostic SSE client: owns one `EventSource`-shaped connection,
3
+ * reconnects with exponential backoff + jitter, and reports an honest
4
+ * connection state so the UI never shows stale data as if it were live
5
+ * (#1549).
6
+ *
7
+ * Deliberately decoupled from React and from `@tanstack/react-query` — this
8
+ * module only knows how to keep a stream open and tell you what it's typed
9
+ * events say and what state the stream is in. `RealtimeProvider.tsx` is the
10
+ * thin layer that wires those events to query-key invalidation.
11
+ *
12
+ * ## The StrictMode double-mount trap
13
+ *
14
+ * React 18 `<StrictMode>` mounts every component twice in dev: mount → effect
15
+ * → cleanup → mount → effect. A naive `useEffect(() => { const es = new
16
+ * EventSource(url); return () => es.close() }, [])` is fine on its own, but
17
+ * once reconnect/backoff state lives outside the effect (as it must, to
18
+ * survive a single real disconnect) two failure modes appear:
19
+ *
20
+ * 1. A "closed" flag set by the first cleanup is never cleared by the second
21
+ * mount's `start()`, so the second, real connection silently never
22
+ * retries after its first drop.
23
+ * 2. The first `EventSource`'s `onerror`/`onopen` fires *after* the second
24
+ * `start()` has already begun (both are async), stomping the real
25
+ * connection's state with a stale one.
26
+ *
27
+ * `start()` fixes (1) by unconditionally resetting all intent/backoff state
28
+ * — "intent flags reset in `connect()`", not read from whatever the last
29
+ * `stop()` left behind. `stop()`/`start()` fix (2) by minting a new `token`
30
+ * on every call and closing over it in each socket's callbacks; a callback
31
+ * whose captured token no longer matches the connection's current token
32
+ * belongs to a superseded socket and is dropped ("onclose guarded"). This is
33
+ * the same shape whether the second `start()` comes from StrictMode's replay
34
+ * or from a real remount, so there is exactly one code path to test.
35
+ */
36
+
37
+ // ── Public types ─────────────────────────────────────────────────────────────
38
+
39
+ /**
40
+ * `disconnected` and `reconnecting` are deliberately distinct: the instant a
41
+ * live stream drops we know only that it's stale (`disconnected`); once a
42
+ * retry attempt is actually in flight the UI can say so (`reconnecting`).
43
+ * `connecting` is the pre-first-connect state, shown only until the very
44
+ * first `open` (or first failure, which moves to `disconnected` too).
45
+ */
46
+ export type ConnectionState = 'connecting' | 'live' | 'disconnected' | 'reconnecting'
47
+
48
+ export interface ConnectionStatus {
49
+ state: ConnectionState
50
+ /** epoch ms the stream was last confirmed open, or null before the first successful connect. */
51
+ lastLiveAt: number | null
52
+ /** consecutive failed attempts since the last live connection (0 while live or before the first attempt has failed). */
53
+ attempt: number
54
+ }
55
+
56
+ /** The slice of the browser `EventSource` API this module depends on. */
57
+ export interface SseHandle {
58
+ close(): void
59
+ addEventListener(type: string, listener: (ev: { data: string }) => void): void
60
+ onopen: ((ev: unknown) => void) | null
61
+ onerror: ((ev: unknown) => void) | null
62
+ }
63
+
64
+ export type EventSourceFactory = (url: string) => SseHandle
65
+
66
+ export interface SseConnectionOptions {
67
+ url: string
68
+ /** SSE `event:` names to subscribe to; anything else on the stream is ignored. */
69
+ eventTypes: readonly string[]
70
+ /** Called for each received event, already JSON-parsed (raw string if parsing fails). */
71
+ onEvent: (type: string, data: unknown) => void
72
+ /** Called whenever the connection state machine transitions. */
73
+ onStatusChange: (status: ConnectionStatus) => void
74
+ /** Defaults to the browser's native `EventSource`; overridable for tests. */
75
+ createEventSource?: EventSourceFactory
76
+ /** Backoff delays in ms, one per consecutive failed attempt; the last entry repeats. */
77
+ backoffScheduleMs?: readonly number[]
78
+ /** Fractional +/- jitter applied to each backoff delay. Default 0.2; tests pass 0 for exact timing. */
79
+ jitterFraction?: number
80
+ now?: () => number
81
+ setTimeoutFn?: typeof setTimeout
82
+ clearTimeoutFn?: typeof clearTimeout
83
+ }
84
+
85
+ export interface SseConnection {
86
+ /** (Re)start the connection, resetting all backoff/intent state. Safe to call while already running. */
87
+ start(): void
88
+ /** Stop for good: closes the socket, cancels any pending retry, no further callbacks fire. */
89
+ stop(): void
90
+ }
91
+
92
+ // ── Defaults ─────────────────────────────────────────────────────────────────
93
+
94
+ /** 1s, 2s, 4s, 8s, 15s, then capped at 30s — gentle enough for a single tab, fast enough to feel alive. */
95
+ export const DEFAULT_BACKOFF_MS: readonly number[] = [1_000, 2_000, 4_000, 8_000, 15_000, 30_000]
96
+
97
+ /** +/- 20% jitter so a daemon restart doesn't get every tab retrying in lockstep. */
98
+ const DEFAULT_JITTER_FRACTION = 0.2
99
+
100
+ function withJitter(baseMs: number, jitterFraction: number): number {
101
+ const jitter = baseMs * jitterFraction * (Math.random() * 2 - 1)
102
+ return Math.max(0, Math.round(baseMs + jitter))
103
+ }
104
+
105
+ function defaultEventSourceFactory(url: string): SseHandle {
106
+ return new EventSource(url) as unknown as SseHandle
107
+ }
108
+
109
+ // ── Implementation ───────────────────────────────────────────────────────────
110
+
111
+ export function createSseConnection(options: SseConnectionOptions): SseConnection {
112
+ const {
113
+ url,
114
+ eventTypes,
115
+ onEvent,
116
+ onStatusChange,
117
+ createEventSource = defaultEventSourceFactory,
118
+ backoffScheduleMs = DEFAULT_BACKOFF_MS,
119
+ jitterFraction = DEFAULT_JITTER_FRACTION,
120
+ now = () => Date.now(),
121
+ setTimeoutFn = setTimeout,
122
+ clearTimeoutFn = clearTimeout,
123
+ } = options
124
+
125
+ // Bumped by every start()/stop(); a socket's callbacks close over the token
126
+ // in effect when they were wired up and no-op once it's stale. This is what
127
+ // makes a superseded (StrictMode-cleaned-up, or genuinely replaced) socket
128
+ // inert instead of racing the current one.
129
+ let token = 0
130
+ let socket: SseHandle | null = null
131
+ let timer: ReturnType<typeof setTimeout> | null = null
132
+ let attempt = 0
133
+ let lastLiveAt: number | null = null
134
+
135
+ function emitStatus(state: ConnectionState): void {
136
+ onStatusChange({ state, lastLiveAt, attempt })
137
+ }
138
+
139
+ function clearPendingRetry(): void {
140
+ if (timer !== null) {
141
+ clearTimeoutFn(timer)
142
+ timer = null
143
+ }
144
+ }
145
+
146
+ function closeSocket(): void {
147
+ if (socket !== null) {
148
+ socket.close()
149
+ socket = null
150
+ }
151
+ }
152
+
153
+ function scheduleRetry(myToken: number): void {
154
+ // `attempt` counts consecutive failures and was just incremented by
155
+ // handleFailure(), so attempt 1 (the first retry) indexes schedule[0].
156
+ const scheduleIndex = Math.min(attempt - 1, backoffScheduleMs.length - 1)
157
+ const delay = withJitter(backoffScheduleMs[scheduleIndex], jitterFraction)
158
+ timer = setTimeoutFn(() => {
159
+ timer = null
160
+ if (myToken !== token) return
161
+ openSocket(myToken)
162
+ }, delay)
163
+ }
164
+
165
+ function handleFailure(myToken: number): void {
166
+ if (myToken !== token) return
167
+ closeSocket()
168
+ attempt += 1
169
+ emitStatus('disconnected')
170
+ scheduleRetry(myToken)
171
+ }
172
+
173
+ function openSocket(myToken: number): void {
174
+ if (myToken !== token) return
175
+ emitStatus(attempt > 0 ? 'reconnecting' : 'connecting')
176
+
177
+ let handle: SseHandle
178
+ try {
179
+ handle = createEventSource(url)
180
+ } catch {
181
+ handleFailure(myToken)
182
+ return
183
+ }
184
+ socket = handle
185
+
186
+ handle.onopen = () => {
187
+ if (myToken !== token) return
188
+ attempt = 0
189
+ lastLiveAt = now()
190
+ emitStatus('live')
191
+ }
192
+ handle.onerror = () => {
193
+ handleFailure(myToken)
194
+ }
195
+ for (const type of eventTypes) {
196
+ handle.addEventListener(type, (ev) => {
197
+ if (myToken !== token) return
198
+ onEvent(type, parsePayload(ev.data))
199
+ })
200
+ }
201
+ }
202
+
203
+ return {
204
+ start(): void {
205
+ token += 1
206
+ const myToken = token
207
+ attempt = 0
208
+ clearPendingRetry()
209
+ closeSocket()
210
+ openSocket(myToken)
211
+ },
212
+ stop(): void {
213
+ token += 1
214
+ clearPendingRetry()
215
+ closeSocket()
216
+ },
217
+ }
218
+ }
219
+
220
+ function parsePayload(raw: string): unknown {
221
+ if (!raw) return null
222
+ try {
223
+ return JSON.parse(raw)
224
+ } catch {
225
+ return raw
226
+ }
227
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * The SSE wire vocabulary + which react-query keys each event type affects.
3
+ *
4
+ * NOT just a mirror of `coord/events.py`'s `KNOWN_EVENT_TYPES` — the
5
+ * dashboard server publishes three more event types of its own
6
+ * (`coord/dashboard/server.py`: `ASSIGNMENT_CANCELLED`, `ASSIGNMENT_ADVISORY`,
7
+ * `ASSIGNMENT_NEEDS_ATTENTION`, all pre-existing, not `coord.events`
8
+ * constants). `openSocket()` in `connection.ts` only registers a listener
9
+ * for the `event:` names in `EVENT_TYPES`, so any real server event type
10
+ * missing from this list is simply never delivered to a handler — it is
11
+ * *not* caught by `board_updated`'s catch-all invalidation, because that
12
+ * catch-all only fires for events the client actually subscribed to. This
13
+ * list must therefore cover every event type the dashboard server can
14
+ * publish, not just `coord.events.KNOWN_EVENT_TYPES`. Keep it in sync by
15
+ * hand — neither source is a `scripts/codegen.py` (#750) target (both are
16
+ * sets of string constants, not dataclasses).
17
+ *
18
+ * Query-key granularity here matches what the webapp already has: there is
19
+ * no per-assignment `['pipeline', id]` key (Detail.tsx reads the same
20
+ * `['pipeline']` list Home.tsx does), so "the precise query keys an event
21
+ * affects" bottoms out at `['pipeline']` and `['sessions']` — the two lists
22
+ * every event type below can change the shape of.
23
+ *
24
+ * TODO: once a per-assignment key (`['pipeline', id]`) or `['diff', id]`
25
+ * gets folded into SSE invalidation, revisit every entry below — today they
26
+ * all just invalidate the coarse lists.
27
+ */
28
+
29
+ export const ASSIGNMENT_STARTED = 'assignment_started'
30
+ export const ASSIGNMENT_COMPLETED = 'assignment_completed'
31
+ export const ASSIGNMENT_FAILED = 'assignment_failed'
32
+ // The three below are dashboard-server-only (coord/dashboard/server.py),
33
+ // not coord.events.KNOWN_EVENT_TYPES -- see the module doc comment above.
34
+ export const ASSIGNMENT_CANCELLED = 'assignment_cancelled'
35
+ export const ASSIGNMENT_ADVISORY = 'assignment_advisory'
36
+ export const ASSIGNMENT_NEEDS_ATTENTION = 'assignment_needs_attention'
37
+ export const MACHINE_CONNECTED = 'machine_connected'
38
+ export const MACHINE_DISCONNECTED = 'machine_disconnected'
39
+ export const BOARD_UPDATED = 'board_updated'
40
+
41
+ /** Every event type the client subscribes to on `/events`. */
42
+ export const EVENT_TYPES = [
43
+ ASSIGNMENT_STARTED,
44
+ ASSIGNMENT_COMPLETED,
45
+ ASSIGNMENT_FAILED,
46
+ ASSIGNMENT_CANCELLED,
47
+ ASSIGNMENT_ADVISORY,
48
+ ASSIGNMENT_NEEDS_ATTENTION,
49
+ MACHINE_CONNECTED,
50
+ MACHINE_DISCONNECTED,
51
+ BOARD_UPDATED,
52
+ ] as const
53
+
54
+ /** A react-query `queryKey`. */
55
+ export type QueryKey = readonly unknown[]
56
+
57
+ const PIPELINE: QueryKey = ['pipeline']
58
+ const SESSIONS: QueryKey = ['sessions']
59
+
60
+ /**
61
+ * Event type -> the query keys it invalidates.
62
+ *
63
+ * - `assignment_*` (including the dashboard-server-only `_cancelled` /
64
+ * `_advisory` / `_needs_attention` variants) changes an item's
65
+ * stage/verdict/needs-attention flag (-> pipeline) and can start/stop a
66
+ * live `coord-*` tmux session (-> sessions).
67
+ * - `machine_*` only affects session reachability/attach state (-> sessions).
68
+ * - `board_updated` is the background poller's coarse "something may have
69
+ * changed" heartbeat (every 30s server-side, `coord/dashboard/server.py`
70
+ * `_POLL_INTERVAL`) — treated as a catch-all for both lists so a change
71
+ * this event vocabulary doesn't yet name specifically still surfaces
72
+ * within one heartbeat instead of never. It is a catch-all for *unnamed*
73
+ * changes only, not a substitute for subscribing to a named event type —
74
+ * a type missing from `EVENT_TYPES` above never reaches this map at all
75
+ * (see that constant's doc comment).
76
+ */
77
+ export const EVENT_QUERY_KEYS: Readonly<Record<string, readonly QueryKey[]>> = {
78
+ [ASSIGNMENT_STARTED]: [PIPELINE, SESSIONS],
79
+ [ASSIGNMENT_COMPLETED]: [PIPELINE, SESSIONS],
80
+ [ASSIGNMENT_FAILED]: [PIPELINE, SESSIONS],
81
+ [ASSIGNMENT_CANCELLED]: [PIPELINE, SESSIONS],
82
+ [ASSIGNMENT_ADVISORY]: [PIPELINE, SESSIONS],
83
+ [ASSIGNMENT_NEEDS_ATTENTION]: [PIPELINE],
84
+ [MACHINE_CONNECTED]: [SESSIONS],
85
+ [MACHINE_DISCONNECTED]: [SESSIONS],
86
+ [BOARD_UPDATED]: [PIPELINE, SESSIONS],
87
+ }
88
+
89
+ /**
90
+ * Invalidated on every `disconnected`/`reconnecting` -> `live` recovery,
91
+ * regardless of which events fired.
92
+ *
93
+ * The server's event history ring buffer (`EventSource` in `coord/events.py`,
94
+ * `DEFAULT_HISTORY_SIZE = 256`) lets a reconnecting client backfill via
95
+ * `Last-Event-ID` — but a long enough drop can overflow it, and a daemon
96
+ * restart resets it (and the id sequence) entirely. Backfill is best-effort;
97
+ * an explicit resync on recovery is what makes "live" mean "correct", not
98
+ * just "the socket is open again".
99
+ */
100
+ export const RESYNC_QUERY_KEYS: readonly QueryKey[] = [PIPELINE, SESSIONS]
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Unit tests for the route tree's path builders (#1548).
3
+ *
4
+ * `paths` and `shellViewFromPath` are the trust boundary between "what the
5
+ * URL says" and "what the shell renders" — `ShellLayout` and every list
6
+ * panel import from here rather than templating strings themselves, so a bug
7
+ * here is a bug everywhere at once. Worth its own focused coverage.
8
+ */
9
+ import { describe, it, expect } from 'vitest'
10
+ import { RAIL_VIEW_PATH, isDetailTab, paths, shellViewFromPath } from '../paths'
11
+
12
+ describe('paths.pipelineItem', () => {
13
+ it('builds the two-segment form with no tab', () => {
14
+ expect(paths.pipelineItem('myrepo', 42)).toBe('/pipeline/myrepo/42')
15
+ })
16
+
17
+ it('collapses the default "overview" tab back to the two-segment form', () => {
18
+ expect(paths.pipelineItem('myrepo', 42, 'overview')).toBe('/pipeline/myrepo/42')
19
+ })
20
+
21
+ it('appends a non-default tab as a third segment', () => {
22
+ expect(paths.pipelineItem('myrepo', 42, 'log')).toBe('/pipeline/myrepo/42/log')
23
+ })
24
+
25
+ it('encodes a repo name that needs escaping (an owner/name slug)', () => {
26
+ expect(paths.pipelineItem('owner/name', 42)).toBe('/pipeline/owner%2Fname/42')
27
+ })
28
+
29
+ it('accepts a string issue number unchanged', () => {
30
+ expect(paths.pipelineItem('myrepo', '42')).toBe('/pipeline/myrepo/42')
31
+ })
32
+ })
33
+
34
+ describe('paths.session / paths.terminal', () => {
35
+ it('encodes the session id', () => {
36
+ expect(paths.session('a b')).toBe('/sessions/a%20b')
37
+ expect(paths.terminal('a b')).toBe('/terminal/a%20b')
38
+ })
39
+ })
40
+
41
+ describe('isDetailTab', () => {
42
+ it('accepts every member of the detail tab set', () => {
43
+ for (const tab of ['overview', 'issue', 'log', 'findings', 'summary']) {
44
+ expect(isDetailTab(tab)).toBe(true)
45
+ }
46
+ })
47
+
48
+ it('rejects an unknown or missing value', () => {
49
+ expect(isDetailTab('bogus')).toBe(false)
50
+ expect(isDetailTab(undefined)).toBe(false)
51
+ })
52
+ })
53
+
54
+ describe('shellViewFromPath', () => {
55
+ it('resolves the base path for every rail-selectable view', () => {
56
+ expect(shellViewFromPath(paths.pipeline())).toBe('pipeline')
57
+ expect(shellViewFromPath(paths.sessions())).toBe('sessions')
58
+ expect(shellViewFromPath(paths.board())).toBe('board')
59
+ expect(shellViewFromPath(paths.machines())).toBe('machines')
60
+ expect(shellViewFromPath(paths.mergeQueue())).toBe('merge-queue')
61
+ expect(shellViewFromPath(paths.milestones())).toBe('milestones')
62
+ expect(shellViewFromPath(paths.audit())).toBe('audit')
63
+ expect(shellViewFromPath(paths.spend())).toBe('spend')
64
+ expect(shellViewFromPath(paths.settings())).toBe('settings')
65
+ })
66
+
67
+ it('resolves an item route under its view', () => {
68
+ expect(shellViewFromPath(paths.pipelineItem('myrepo', 42))).toBe('pipeline')
69
+ expect(shellViewFromPath(paths.pipelineItem('myrepo', 42, 'log'))).toBe('pipeline')
70
+ expect(shellViewFromPath(paths.session('sess-1'))).toBe('sessions')
71
+ })
72
+
73
+ it('does not let one view path prefix-match another', () => {
74
+ // '/merge-queue' is not a sub-path of anything else in the map, but this
75
+ // guards the general case: a prefix match must respect the segment
76
+ // boundary (a trailing '/'), not just string startsWith.
77
+ expect(shellViewFromPath('/pipelines')).toBeNull()
78
+ expect(shellViewFromPath('/sessionsx')).toBeNull()
79
+ })
80
+
81
+ it('returns null for root, terminal, and unknown paths', () => {
82
+ expect(shellViewFromPath('/')).toBeNull()
83
+ expect(shellViewFromPath('/terminal/sess-1')).toBeNull()
84
+ expect(shellViewFromPath('/nope')).toBeNull()
85
+ })
86
+
87
+ it('every RAIL_VIEW_PATH entry round-trips through shellViewFromPath', () => {
88
+ for (const [view, base] of Object.entries(RAIL_VIEW_PATH)) {
89
+ expect(shellViewFromPath(base as string)).toBe(view)
90
+ }
91
+ })
92
+ })
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The route tree, in one place (#1548).
3
+ *
4
+ * Every screen the app can show has exactly one URL shape, built here rather
5
+ * than string-templated at each call site — a repo name with a `/` in it (a
6
+ * `owner/name` slug) or an id that needs escaping is a bug every hand-rolled
7
+ * template has to remember on its own; a path builder only has to get it
8
+ * right once. `Home`, `Detail`, `SessionsList`, `ShellLayout` and the e2e
9
+ * specs all import from here instead of writing template literals.
10
+ *
11
+ * `PipelineView` (see `src/api/generated.ts`) has no stable per-assignment
12
+ * URL-friendly id worth betting a bookmark on — `assignment_id` is one
13
+ * *attempt* at an issue (work, then a fix, then another fix carry different
14
+ * assignment ids for the same issue) — so the pipeline item route keys on
15
+ * `repo_name` + `issue_number`, which is what a human means by "that issue"
16
+ * and what a link pasted into a GitHub comment or Slack would naturally
17
+ * carry.
18
+ */
19
+ import { type ShellView } from '@/shell/shellState'
20
+
21
+ /** The `PipelineDetailTab` set (`docs/mocks/web/issue-detail.html`), minus
22
+ * Terminal — the tab *content* is M-W2 scope; this route tree only needs to
23
+ * know the tab is addressable and round-trips. `overview` is the default and
24
+ * is never present in a generated URL (see `pipelineItem` below). */
25
+ export const DETAIL_TABS = ['overview', 'issue', 'log', 'findings', 'summary'] as const
26
+ export type DetailTab = (typeof DETAIL_TABS)[number]
27
+
28
+ export function isDetailTab(value: string | undefined): value is DetailTab {
29
+ return !!value && (DETAIL_TABS as readonly string[]).includes(value)
30
+ }
31
+
32
+ /** Path builders. Every one returns an absolute, already-encoded path. */
33
+ export const paths = {
34
+ pipeline: () => '/pipeline',
35
+ /** `tab` omitted (or `'overview'`) collapses to the two-segment form —
36
+ * `/pipeline/repo/42`, not `/pipeline/repo/42/overview` — so the common
37
+ * case stays the short, shareable URL. */
38
+ pipelineItem: (repo: string, issue: number | string, tab?: DetailTab): string => {
39
+ const base = `/pipeline/${encodeURIComponent(repo)}/${encodeURIComponent(String(issue))}`
40
+ return tab && tab !== 'overview' ? `${base}/${tab}` : base
41
+ },
42
+ sessions: () => '/sessions',
43
+ session: (id: string) => `/sessions/${encodeURIComponent(id)}`,
44
+ terminal: (sessionId: string) => `/terminal/${encodeURIComponent(sessionId)}`,
45
+ board: () => '/board',
46
+ machines: () => '/machines',
47
+ mergeQueue: () => '/merge-queue',
48
+ milestones: () => '/milestones',
49
+ audit: () => '/audit',
50
+ spend: () => '/spend',
51
+ settings: () => '/settings',
52
+ } as const
53
+
54
+ /**
55
+ * Base path (no `:id`/`:tab`) for every rail-selectable view — what
56
+ * `ActivityRail`'s `onSelect` navigates to. Views with no route of their own
57
+ * (`terminal` is opened *from* a session, never navigated to directly) are
58
+ * omitted; `ActivityRail` never offers them as clickable (see
59
+ * `railItems.ts`'s `'soon'`/no-nav entries) so there is nothing to look up
60
+ * for those, but `Partial` keeps that honest here too rather than papering
61
+ * over it with a route that doesn't exist.
62
+ */
63
+ export const RAIL_VIEW_PATH: Partial<Record<ShellView, string>> = {
64
+ pipeline: paths.pipeline(),
65
+ board: paths.board(),
66
+ sessions: paths.sessions(),
67
+ machines: paths.machines(),
68
+ 'merge-queue': paths.mergeQueue(),
69
+ milestones: paths.milestones(),
70
+ audit: paths.audit(),
71
+ spend: paths.spend(),
72
+ settings: paths.settings(),
73
+ }
74
+
75
+ /**
76
+ * The inverse of `RAIL_VIEW_PATH` — which rail view a URL belongs to, for
77
+ * highlighting the right rail entry and picking the right list panel on cold
78
+ * load. `null` means the path doesn't belong to any known view (root `/`,
79
+ * `/terminal/...`, or a genuinely unknown route) — callers decide what that
80
+ * means for them rather than this function guessing a fallback.
81
+ */
82
+ export function shellViewFromPath(pathname: string): ShellView | null {
83
+ const entries = Object.entries(RAIL_VIEW_PATH) as Array<[ShellView, string]>
84
+ // Longest prefix first: without this, '/merge-queue' would never win
85
+ // because nothing here is a prefix of it *except* itself, but this guards
86
+ // future entries where one path is a literal prefix of another.
87
+ const sorted = entries.sort((a, b) => b[1].length - a[1].length)
88
+ for (const [view, base] of sorted) {
89
+ if (pathname === base || pathname.startsWith(`${base}/`)) return view
90
+ }
91
+ return null
92
+ }