@dsdenes/devboard 0.1.559 → 0.1.562

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 (323) hide show
  1. package/AGENTS.md +59 -0
  2. package/README.md +2497 -26
  3. package/bin/devboard.mjs +352 -118
  4. package/client/App.svelte +523 -0
  5. package/client/FeedbackOverlay.svelte +1125 -0
  6. package/client/FlowMode.svelte +1015 -0
  7. package/client/app.css +3922 -0
  8. package/client/env.d.ts +23 -0
  9. package/client/lib/commands.ts +836 -0
  10. package/client/lib/components/ActivityBar.svelte +295 -0
  11. package/client/lib/components/AdrTable.svelte +241 -0
  12. package/client/lib/components/CommandPalette.svelte +198 -0
  13. package/client/lib/components/ConceptKind.svelte +66 -0
  14. package/client/lib/components/ConfirmDialog.svelte +62 -0
  15. package/client/lib/components/DataTable.svelte +243 -0
  16. package/client/lib/components/DetailSplit.svelte +68 -0
  17. package/client/lib/components/DiscoverOverview.svelte +898 -0
  18. package/client/lib/components/DiscoverWorkspace.svelte +437 -0
  19. package/client/lib/components/DraftsPage.svelte +186 -0
  20. package/client/lib/components/GoalWorkspace.svelte +1042 -0
  21. package/client/lib/components/IssuesAgentFocus.svelte +117 -0
  22. package/client/lib/components/IssuesAgentLog.svelte +50 -0
  23. package/client/lib/components/IssuesQuestion.svelte +95 -0
  24. package/client/lib/components/IssuesWorkspace.svelte +577 -0
  25. package/client/lib/components/MermaidDiagram.svelte +89 -0
  26. package/client/lib/components/MonacoEditor.svelte +190 -0
  27. package/client/lib/components/NotificationRegion.svelte +195 -0
  28. package/client/lib/components/PaneHandle.svelte +25 -0
  29. package/client/lib/components/ScoutIssues.svelte +215 -0
  30. package/client/lib/components/ScoutWorkspace.svelte +180 -0
  31. package/client/lib/components/Sidebar.svelte +622 -0
  32. package/client/lib/components/StatusBar.svelte +118 -0
  33. package/client/lib/components/Terminal.svelte +193 -0
  34. package/client/lib/components/WatchdogWorkspace.svelte +537 -0
  35. package/client/lib/components/ui/button.svelte +56 -0
  36. package/client/lib/components/ui/icon-button.svelte +56 -0
  37. package/client/lib/components/ui/input.svelte +19 -0
  38. package/client/lib/components/ui/page-header.svelte +120 -0
  39. package/client/lib/components/ui/stage-track.svelte +141 -0
  40. package/client/lib/components/watchdog-indicator.svelte +189 -0
  41. package/client/lib/connection.svelte.ts +210 -0
  42. package/client/lib/dependency-diagram.test.ts +90 -0
  43. package/client/lib/dependency-diagram.ts +75 -0
  44. package/client/lib/discover-format.test.ts +265 -0
  45. package/client/lib/discover-format.ts +394 -0
  46. package/client/lib/discover.svelte.ts +412 -0
  47. package/client/lib/draft-format.test.ts +48 -0
  48. package/client/lib/draft-format.ts +13 -0
  49. package/client/lib/flow-save.test.ts +148 -0
  50. package/client/lib/flow.svelte.ts +445 -0
  51. package/client/lib/goal-reconnect.test.ts +282 -0
  52. package/client/lib/goal.svelte.ts +652 -0
  53. package/client/lib/issues.svelte.ts +439 -0
  54. package/client/lib/layout.svelte.ts +234 -0
  55. package/client/lib/notification-issue.test.ts +53 -0
  56. package/client/lib/notification-issue.ts +27 -0
  57. package/client/lib/notifications.svelte.ts +62 -0
  58. package/client/lib/poll-status.test.ts +141 -0
  59. package/client/lib/poll-status.ts +88 -0
  60. package/client/lib/record-status.test.ts +19 -0
  61. package/client/lib/record-status.ts +25 -0
  62. package/client/lib/scout.svelte.ts +164 -0
  63. package/client/lib/skill-document.test.ts +77 -0
  64. package/client/lib/skill-document.ts +57 -0
  65. package/client/lib/status-tone.test.ts +40 -0
  66. package/client/lib/status-tone.ts +33 -0
  67. package/client/lib/types.ts +578 -0
  68. package/client/lib/utils.ts +6 -0
  69. package/client/lib/viewport.svelte.ts +20 -0
  70. package/client/lib/watchdog.svelte.ts +69 -0
  71. package/client/main.ts +7 -0
  72. package/dist/apple-touch-icon.png +0 -0
  73. package/dist/assets/abnfDiagram-VCTEODGH-CmBSMArZ.js +1 -0
  74. package/dist/assets/arc-BZgCSX2H.js +1 -0
  75. package/dist/assets/architecture-7GRP2DOG-CF9MHlfo.js +1 -0
  76. package/dist/assets/architectureDiagram-5GKGNRK7-DzWB6xiQ.js +36 -0
  77. package/dist/assets/array-BifhSqXX.js +1 -0
  78. package/dist/assets/blockDiagram-I7D4REHJ-BtmGJ0hn.js +129 -0
  79. package/dist/assets/c4Diagram-7LVT6UL2-D5CHrrRX.js +38 -0
  80. package/dist/assets/channel-CgM4lcUW.js +1 -0
  81. package/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  82. package/dist/assets/chunk-4HAMMTFA-DeIufjFl.js +62 -0
  83. package/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  84. package/dist/assets/chunk-75Z2AOVW-DbtzyKUg.js +2 -0
  85. package/dist/assets/chunk-DU6HZSFF-C0fdbx3K.js +127 -0
  86. package/dist/assets/chunk-F27PBJKO-HObL2IvF.js +1 -0
  87. package/dist/assets/chunk-FOHPRMQF-DD4dKubD.js +161 -0
  88. package/dist/assets/chunk-GMAD6QVW-fg8wvtPY.js +72 -0
  89. package/dist/assets/chunk-GVQU2GXP-C0TaHOrD.js +1 -0
  90. package/dist/assets/chunk-IMKFNOWR-BVoIdx20.js +231 -0
  91. package/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  92. package/dist/assets/chunk-L3NEJ4N5-B_kmqfk5.js +1 -0
  93. package/dist/assets/chunk-OSK3NFVY-DO0vhxHH.js +10 -0
  94. package/dist/assets/chunk-P2QGCYS3-CjljLvFj.js +1 -0
  95. package/dist/assets/chunk-POPQ4Y6H-BU0C7tw6.js +1 -0
  96. package/dist/assets/chunk-PWAF6VOD-Dn0OjBt0.js +1 -0
  97. package/dist/assets/chunk-SHT3W25Y-CMCZE2xx.js +168 -0
  98. package/dist/assets/chunk-SVP7TREG-BIRmRy_W.js +88 -0
  99. package/dist/assets/chunk-TICWLB2K-C1bc2CDE.js +206 -0
  100. package/dist/assets/chunk-XXDRQBXY-Cc38QUmR.js +1 -0
  101. package/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  102. package/dist/assets/classDiagram-ZZMXUADV-BDh5w6Nc.js +1 -0
  103. package/dist/assets/classDiagram-v2-VYDZK3BY-BDh5w6Nc.js +1 -0
  104. package/dist/assets/cose-bilkent-JH36ORCC-B7pU7qhM.js +1 -0
  105. package/dist/assets/cynefin-OW5HDTMX-76dswSqS.js +1 -0
  106. package/dist/assets/cynefinDiagram-5FMLGOSQ-2kS4q01d.js +62 -0
  107. package/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
  108. package/dist/assets/dagre-D7JM2Yhv.js +1 -0
  109. package/dist/assets/dagre-GXQ25YYZ-B5Y21TQM.js +4 -0
  110. package/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
  111. package/dist/assets/diagram-S7CK7UJ4-CY6td7Et.js +30 -0
  112. package/dist/assets/diagram-UQ7AKVKN-DmDqnGZf.js +41 -0
  113. package/dist/assets/diagram-VSXAHHWV-KGLWOOcE.js +3 -0
  114. package/dist/assets/diagram-VX7I27RA-D5syUraJ.js +24 -0
  115. package/dist/assets/diagram-Z3DM3KII-Dtrbs1Om.js +24 -0
  116. package/dist/assets/dist-Dce-2YbA.js +1 -0
  117. package/dist/assets/ebnfDiagram-PWID7BFC-CQpJLtU3.js +1 -0
  118. package/dist/assets/editor.worker-BmdWdrHZ.js +33 -0
  119. package/dist/assets/erDiagram-RLTQ6QDP-cMTzKLu_.js +99 -0
  120. package/dist/assets/eventmodeling-NTZA5JFV-CMzLdNaL.js +1 -0
  121. package/dist/assets/flowDiagram-HODETNUW-BMUajB-G.js +1 -0
  122. package/dist/assets/ganttDiagram-EL5Y4UJY-DseBS5Fs.js +292 -0
  123. package/dist/assets/gitGraph-4MIJSDKK-CUQZRpxJ.js +1 -0
  124. package/dist/assets/gitGraphDiagram-WWUBYQGX-CfMfsJ3b.js +106 -0
  125. package/dist/assets/graphlib-DS17s2tU.js +1 -0
  126. package/dist/assets/index-ByQ-XX38.css +1 -0
  127. package/dist/assets/index-CIf5TpfY.js +214 -0
  128. package/dist/assets/info-A6RAGUB7-D3nAKZ8M.js +1 -0
  129. package/dist/assets/infoDiagram-27XIBGKW-zu1O0WOy.js +2 -0
  130. package/dist/assets/init-C-OQMol4.js +1 -0
  131. package/dist/assets/ishikawaDiagram-5VMMS53U-DKC7BO9p.js +70 -0
  132. package/dist/assets/journeyDiagram-3NMN7TZE-Ld5mXse7.js +139 -0
  133. package/dist/assets/kanban-definition-UXKFOSKX-CH7_QlHL.js +89 -0
  134. package/dist/assets/katex-DJhrJSbw.js +257 -0
  135. package/dist/assets/line-N9XiCREw.js +1 -0
  136. package/dist/assets/linear-C9hnqmJv.js +1 -0
  137. package/dist/assets/mermaid-parser.core-DgPwV5Bh.js +7 -0
  138. package/dist/assets/mermaid.core-BKqynXLR.js +44 -0
  139. package/dist/assets/mindmap-definition-YA3MSWOX-DkiL-wiW.js +96 -0
  140. package/dist/assets/monaco-DjpsE8to.css +1 -0
  141. package/dist/assets/monaco-DqzJaNIe.js +908 -0
  142. package/dist/assets/ordinal-BDEzSJ7C.js +1 -0
  143. package/dist/assets/packet-AYTQ26CC-DuMTvpEJ.js +1 -0
  144. package/dist/assets/path-fybaL0A-.js +1 -0
  145. package/dist/assets/pegDiagram-XKGWAZYB-DvMl_xSe.js +1 -0
  146. package/dist/assets/pie-WAS4IAKB-Da0xPaD8.js +1 -0
  147. package/dist/assets/pieDiagram-E7YTZNPT-amANNRJY.js +39 -0
  148. package/dist/assets/quadrantDiagram-AXDQQJYC-BrgvDMnk.js +7 -0
  149. package/dist/assets/radar-RG4KPBEZ-Dz3Ih5-N.js +1 -0
  150. package/dist/assets/railroad-74A4TZTK-CKMW-IV2.js +1 -0
  151. package/dist/assets/railroad-abnf-HS5TGJTU-ChqmSWSF.js +1 -0
  152. package/dist/assets/railroad-ebnf-LZEXJU2U-V3R4fFdb.js +1 -0
  153. package/dist/assets/railroad-peg-WCYAUIDC-B-RuhUQW.js +1 -0
  154. package/dist/assets/railroadDiagram-O6MQD6OU-FjG-6OLu.js +1 -0
  155. package/dist/assets/requirementDiagram-BXWQKSXE-Cnj8RfE4.js +84 -0
  156. package/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  157. package/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
  158. package/dist/assets/sankeyDiagram-P5KCCOFB-DMu1fnEh.js +40 -0
  159. package/dist/assets/sequenceDiagram-WJ2MYXX4-CtCtaRpW.js +162 -0
  160. package/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
  161. package/dist/assets/src-CGp3bfio.js +1 -0
  162. package/dist/assets/stateDiagram-D77RDMKH-xcJbLNhz.js +1 -0
  163. package/dist/assets/stateDiagram-v2-MP3YSRHH-DYHrQmJJ.js +1 -0
  164. package/dist/assets/swimlanes-42K2YHIH-BwLln_ga.js +1 -0
  165. package/dist/assets/swimlanesDiagram-VR7AAH4N-BcDfUQ0A.js +8 -0
  166. package/dist/assets/timeline-definition-24CTP7MA-ucNGsUUz.js +120 -0
  167. package/dist/assets/treeView-Q6P3EWNA-3uOomRue.js +1 -0
  168. package/dist/assets/treemap-WGGIJYW6-iGK7vwID.js +1 -0
  169. package/dist/assets/vennDiagram-4TSXK5OY-B9LgGe16.js +34 -0
  170. package/dist/assets/wardley-WFR3VGLG-FupMOf6k.js +1 -0
  171. package/dist/assets/wardleyDiagram-VM6X3IG4-CGZdEXM2.js +78 -0
  172. package/dist/assets/xterm-BrP-ENHg.css +1 -0
  173. package/dist/assets/xterm-C8zLTNGk.js +16 -0
  174. package/dist/assets/xychartDiagram-S5SC5T6Z-DafZrSBJ.js +7 -0
  175. package/dist/favicon.ico +0 -0
  176. package/dist/favicon.svg +6 -0
  177. package/dist/index.html +34 -0
  178. package/extensions/implement-goal-issue.ts +368 -0
  179. package/extensions/implement-next-epic.ts +835 -0
  180. package/index.html +28 -0
  181. package/install.sh +111 -0
  182. package/issues/control.ts +91 -0
  183. package/issues/guards.ts +12 -0
  184. package/issues/ledger.ts +122 -0
  185. package/issues/legacy.ts +68 -0
  186. package/issues/lock.ts +305 -0
  187. package/issues/paths.ts +76 -0
  188. package/issues/preflight.ts +189 -0
  189. package/issues/repository.ts +203 -0
  190. package/issues/scout/actions.ts +255 -0
  191. package/issues/scout/control.ts +80 -0
  192. package/issues/scout/github.ts +358 -0
  193. package/issues/scout/paths.ts +129 -0
  194. package/issues/scout/preflight.ts +277 -0
  195. package/issues/scout/recommendation.ts +400 -0
  196. package/issues/scout/scan.ts +143 -0
  197. package/issues/scout/state.ts +448 -0
  198. package/issues/scout/worker.ts +1282 -0
  199. package/issues/state.ts +584 -0
  200. package/issues/tracker.ts +271 -0
  201. package/issues/worker.ts +1065 -0
  202. package/lib/dependencies.ts +61 -0
  203. package/lib/forge-auth.ts +953 -0
  204. package/lib/forge.ts +141 -0
  205. package/lib/gitea.ts +709 -0
  206. package/lib/github-release.ts +318 -0
  207. package/lib/inventory.test.ts +79 -0
  208. package/lib/inventory.ts +53 -0
  209. package/lib/release.test.ts +269 -0
  210. package/lib/release.ts +801 -0
  211. package/omp/commands/adr-resolve.md +8 -0
  212. package/omp/commands/discover.md +6 -0
  213. package/omp/commands/epic-next.md +8 -0
  214. package/omp/commands/flow-change.md +8 -0
  215. package/omp/commands/flow-goal.md +8 -0
  216. package/omp/commands/flow-implement.md +8 -0
  217. package/omp/commands/flow-widget.md +8 -0
  218. package/omp/commands/goal-draft.md +8 -0
  219. package/omp/commands/goal-outcome.md +8 -0
  220. package/omp/commands/implement-issue.md +8 -0
  221. package/omp/commands/prepare-skills.md +6 -0
  222. package/omp/commands/scout-recommendations.md +8 -0
  223. package/omp/commands/setup-devboard.md +24 -0
  224. package/omp/config.yml +12 -0
  225. package/omp/extensions/implement-goal-issue.ts +2 -0
  226. package/omp/extensions/implement-next-epic.ts +2 -0
  227. package/omp/mcp.json +9 -0
  228. package/package.json +86 -7
  229. package/prompts/adr-resolve.md +130 -0
  230. package/prompts/discover.md +541 -0
  231. package/prompts/epic-next.md +533 -0
  232. package/prompts/flow-change.md +76 -0
  233. package/prompts/flow-goal.md +126 -0
  234. package/prompts/flow-implement.md +103 -0
  235. package/prompts/flow-widget.md +54 -0
  236. package/prompts/goal-draft.md +147 -0
  237. package/prompts/goal-outcome.md +87 -0
  238. package/prompts/implement-issue.md +66 -0
  239. package/prompts/prepare-skills.md +142 -0
  240. package/prompts/scout-recommendations.md +143 -0
  241. package/public/apple-touch-icon.png +0 -0
  242. package/public/favicon.ico +0 -0
  243. package/public/favicon.svg +6 -0
  244. package/server/acp.ts +540 -0
  245. package/server/activity.test.ts +96 -0
  246. package/server/activity.ts +48 -0
  247. package/server/app.test.ts +882 -0
  248. package/server/app.ts +6179 -0
  249. package/server/ask.test.ts +543 -0
  250. package/server/ask.ts +589 -0
  251. package/server/dependencies.test.ts +118 -0
  252. package/server/discover-remote.test.ts +1602 -0
  253. package/server/discover-sheet.test.ts +176 -0
  254. package/server/discover.test.ts +1510 -0
  255. package/server/discover.ts +3198 -0
  256. package/server/feedback.test.ts +458 -0
  257. package/server/feedback.ts +369 -0
  258. package/server/fixtures/adr-source.ts +71 -0
  259. package/server/fixtures/hang-download.ts +14 -0
  260. package/server/fixtures/stub-agent.ts +1870 -0
  261. package/server/fixtures/stub-forge.ts +141 -0
  262. package/server/fixtures/stub-gh.ts +57 -0
  263. package/server/flow-pr.test.ts +976 -0
  264. package/server/flow.test.ts +1632 -0
  265. package/server/flow.ts +2655 -0
  266. package/server/forge-pr.ts +387 -0
  267. package/server/goal-creation.test.ts +957 -0
  268. package/server/goal-draft.test.ts +348 -0
  269. package/server/goal-drafts.ts +449 -0
  270. package/server/goal-proposal.test.ts +479 -0
  271. package/server/goal-proposal.ts +232 -0
  272. package/server/goal-revision.test.ts +613 -0
  273. package/server/goal-slug.test.ts +56 -0
  274. package/server/goal-slug.ts +96 -0
  275. package/server/goal.ts +20 -0
  276. package/server/index.ts +147 -0
  277. package/server/issue-close.test.ts +100 -0
  278. package/server/issue-close.ts +178 -0
  279. package/server/issues.test.ts +167 -0
  280. package/server/issues.ts +479 -0
  281. package/server/journal.test.ts +350 -0
  282. package/server/journal.ts +329 -0
  283. package/server/lifecycle-pause.test.ts +1795 -0
  284. package/server/lifecycle.test.ts +1879 -0
  285. package/server/parallel-lifecycle.test.ts +1029 -0
  286. package/server/persistence-restart.test.ts +723 -0
  287. package/server/persistence.test.ts +1935 -0
  288. package/server/records.test.ts +180 -0
  289. package/server/records.ts +99 -0
  290. package/server/reset-implementation.test.ts +997 -0
  291. package/server/run.test.ts +611 -0
  292. package/server/scout.test.ts +877 -0
  293. package/server/scout.ts +459 -0
  294. package/server/skills-preparation.test.ts +1073 -0
  295. package/server/state.ts +249 -0
  296. package/server/tracker.ts +369 -0
  297. package/server/updates.test.ts +444 -0
  298. package/server/updates.ts +198 -0
  299. package/server/version.test.ts +140 -0
  300. package/server/version.ts +151 -0
  301. package/server/watchdog.ts +389 -0
  302. package/svelte.config.js +5 -0
  303. package/templates/host-AGENTS.md +235 -0
  304. package/tsconfig.json +21 -0
  305. package/vite.config.ts +21 -0
  306. package/watchdog/cleanup-main.ts +98 -0
  307. package/watchdog/cleanup.ts +301 -0
  308. package/watchdog/collect.ts +311 -0
  309. package/watchdog/coordinator.ts +397 -0
  310. package/watchdog/delivery.ts +107 -0
  311. package/watchdog/discovery.ts +209 -0
  312. package/watchdog/findings.ts +211 -0
  313. package/watchdog/github.ts +189 -0
  314. package/watchdog/history.ts +138 -0
  315. package/watchdog/install.ts +103 -0
  316. package/watchdog/ledger.ts +188 -0
  317. package/watchdog/lock.ts +63 -0
  318. package/watchdog/main.ts +106 -0
  319. package/watchdog/status.ts +71 -0
  320. package/watchdog/systemd.ts +96 -0
  321. package/watchdog/uninstall.ts +30 -0
  322. package/watchdog/units/devboard-watchdog.service +9 -0
  323. package/watchdog/units/devboard-watchdog.timer +10 -0
@@ -0,0 +1,882 @@
1
+ import { fixtureRecord } from "./fixtures/adr-source";
2
+ import { afterAll, describe, expect, test } from "bun:test";
3
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
4
+ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
5
+ import { homedir, tmpdir } from "node:os";
6
+ import { join, resolve } from "node:path";
7
+ import { createApp, projectLabel } from "./app";
8
+ import { journalFile, journalKey } from "./journal";
9
+ import { acquireLock } from "../issues/lock";
10
+
11
+ const temporaryDirectories: string[] = [];
12
+ const STUB_AGENT = join(import.meta.dir, "fixtures", "stub-agent.ts");
13
+
14
+ async function temporaryDirectory() {
15
+ const directory = await mkdtemp(join(tmpdir(), "devboard-app-"));
16
+ temporaryDirectories.push(directory);
17
+ return directory;
18
+ }
19
+
20
+ afterAll(async () => {
21
+ await Promise.all(
22
+ temporaryDirectories.map((directory) => rm(directory, { recursive: true, force: true })),
23
+ );
24
+ });
25
+
26
+ async function startServer(
27
+ options: {
28
+ goalText?: string;
29
+ goalFile?: string;
30
+ agentCommand?: string;
31
+ restart?: () => void;
32
+ versionState?: () => {
33
+ running: string | null;
34
+ installed: string | null;
35
+ latest: string | null;
36
+ stale: boolean;
37
+ };
38
+ runningVersion?: string | null;
39
+ /** Installs this checkout's `origin` before the app resolves it. */
40
+ origin?: string;
41
+ issues?: {
42
+ closeIssue?: (request: {
43
+ repoDir: string;
44
+ repository: string;
45
+ number: number;
46
+ }) => Promise<void>;
47
+ };
48
+ } = {},
49
+ ) {
50
+ const root = await temporaryDirectory();
51
+ const adrDir = join(root, "adrs");
52
+ const staticDir = join(root, "static");
53
+ await mkdir(adrDir, { recursive: true });
54
+ await mkdir(staticDir, { recursive: true });
55
+ await writeFile(join(staticDir, "index.html"), "<!doctype html><title>ADR overview</title>");
56
+ await writeFile(join(adrDir, "0001-first.md"), fixtureRecord("0001-first.md", "First"));
57
+ if (options.origin !== undefined) {
58
+ Bun.spawnSync({ cmd: ["git", "init", "-q", root] });
59
+ Bun.spawnSync({ cmd: ["git", "-C", root, "remote", "add", "origin", options.origin] });
60
+ }
61
+ if (options.goalText !== undefined) {
62
+ await writeFile(options.goalFile ?? join(root, "GOAL.md"), options.goalText);
63
+ }
64
+
65
+ const server = createApp({
66
+ adrDir,
67
+ staticDir,
68
+ hostname: "127.0.0.1",
69
+ port: 0,
70
+ repoDir: root,
71
+ stateDir: join(root, "state"),
72
+ goalFile: options.goalFile,
73
+ agentCommand: options.agentCommand,
74
+ restart: options.restart,
75
+ versionState: options.versionState,
76
+ runningVersion: options.runningVersion,
77
+ ...(options.issues === undefined ? {} : { issues: options.issues }),
78
+ });
79
+ return { server, adrDir, root, url: `127.0.0.1:${server.port}` };
80
+ }
81
+
82
+ function exchange(url: string, command: unknown): Promise<unknown> {
83
+ const { promise, resolve, reject } = Promise.withResolvers<unknown>();
84
+ const socket = new WebSocket(`ws://${url}/ws`);
85
+
86
+ socket.onopen = () => socket.send(JSON.stringify(command));
87
+ const answer = (command as { type?: unknown }).type === "getIssues";
88
+ socket.onmessage = (event) => {
89
+ let message: unknown;
90
+ try {
91
+ message = JSON.parse(String(event.data));
92
+ } catch (error) {
93
+ socket.close();
94
+ reject(error);
95
+ return;
96
+ }
97
+ const type = (message as { type?: unknown }).type;
98
+ // A connection-level worker replay precedes this client's own reply.
99
+ if (
100
+ !answer &&
101
+ (type === "issuesState" ||
102
+ type === "issuesActivity" ||
103
+ type === "scoutState" ||
104
+ type === "scoutActivity")
105
+ ) {
106
+ return;
107
+ }
108
+ socket.close();
109
+ resolve(message);
110
+ };
111
+ socket.onerror = () => reject(new Error("websocket failed"));
112
+
113
+ return promise;
114
+ }
115
+
116
+ /**
117
+ * Collects socket frames until `done` accepts them. The guard timer only keeps
118
+ * a broken contract from hanging the suite; the assertion is the predicate.
119
+ */
120
+ function collect(
121
+ url: string,
122
+ command: unknown,
123
+ done: (messages: unknown[]) => boolean,
124
+ ): Promise<unknown[]> {
125
+ const { promise, resolve, reject } = Promise.withResolvers<unknown[]>();
126
+ const socket = new WebSocket(`ws://${url}/ws`);
127
+ const messages: unknown[] = [];
128
+ const guard = setTimeout(() => {
129
+ socket.close();
130
+ reject(new Error("websocket frames did not arrive"));
131
+ }, 5000);
132
+ socket.onopen = () => socket.send(JSON.stringify(command));
133
+ socket.onmessage = (event) => {
134
+ try {
135
+ messages.push(JSON.parse(String(event.data)));
136
+ } catch (error) {
137
+ clearTimeout(guard);
138
+ socket.close();
139
+ reject(error);
140
+ return;
141
+ }
142
+ if (!done(messages)) return;
143
+ clearTimeout(guard);
144
+ socket.close();
145
+ resolve(messages);
146
+ };
147
+ socket.onerror = () => {
148
+ clearTimeout(guard);
149
+ reject(new Error("websocket failed"));
150
+ };
151
+ return promise;
152
+ }
153
+
154
+ function listRecords(url: string): Promise<unknown> {
155
+ return exchange(url, { type: "listRecords" });
156
+ }
157
+
158
+ describe("projectLabel", () => {
159
+ test("names a project inside the home directory by its relative path", () => {
160
+ const home = join(homedir(), "projects");
161
+
162
+ expect(projectLabel(join(home, "devboard"), home)).toBe("devboard");
163
+ expect(projectLabel(join(home, "nested", "dashboard"), home)).toBe(
164
+ join("nested", "dashboard"),
165
+ );
166
+ });
167
+
168
+ test("keeps the absolute path for a project outside the home directory", () => {
169
+ const home = join(homedir(), "projects");
170
+ const outside = resolve(home, "..", "elsewhere");
171
+
172
+ expect(projectLabel(outside, home)).toBe(outside);
173
+ });
174
+
175
+ test("keeps the absolute path for the home directory itself", () => {
176
+ const home = join(homedir(), "projects");
177
+
178
+ expect(projectLabel(home, home)).toBe(home);
179
+ });
180
+ });
181
+
182
+ describe("createApp", () => {
183
+ test("serves the built client at the root", async () => {
184
+ const { server, url } = await startServer();
185
+
186
+ try {
187
+ const response = await fetch(`http://${url}/`);
188
+
189
+ expect(response.status).toBe(200);
190
+ expect(response.headers.get("content-type")).toContain("text/html");
191
+ expect(await response.text()).toContain("<title>ADR overview</title>");
192
+ } finally {
193
+ server.stop(true);
194
+ }
195
+ });
196
+
197
+ test("answers listRecords over the WebSocket", async () => {
198
+ const { server, url } = await startServer();
199
+
200
+ try {
201
+ const message = await listRecords(url);
202
+
203
+ expect(message).toEqual({
204
+ type: "records",
205
+ records: [
206
+ {
207
+ file: "0001-first.md",
208
+ title: "First",
209
+ status: "Accepted",
210
+ pending: false,
211
+ summary: "",
212
+ },
213
+ ],
214
+ next: null,
215
+ });
216
+ } finally {
217
+ server.stop(true);
218
+ }
219
+ });
220
+
221
+ test("restarts once on request and refuses a concurrent restart", async () => {
222
+ let restarts = 0;
223
+ const { server, url } = await startServer({ restart: () => (restarts += 1) });
224
+
225
+ try {
226
+ expect(await exchange(url, { type: "restartServer" })).toEqual({
227
+ type: "serverRestarting",
228
+ });
229
+ expect(restarts).toBe(1);
230
+ expect(await exchange(url, { type: "restartServer" })).toEqual({
231
+ type: "error",
232
+ message: "A server restart is already in progress.",
233
+ });
234
+ expect(restarts).toBe(1);
235
+ } finally {
236
+ server.stop(true);
237
+ }
238
+ });
239
+
240
+ test("reports that restarting is unavailable without a launcher", async () => {
241
+ const { server, url } = await startServer();
242
+
243
+ try {
244
+ expect(await exchange(url, { type: "restartServer" })).toEqual({
245
+ type: "error",
246
+ message: "Restarting the server is not available.",
247
+ });
248
+ } finally {
249
+ server.stop(true);
250
+ }
251
+ });
252
+
253
+ test("answers getVersion from the supplied probe", async () => {
254
+ const { server, url } = await startServer({
255
+ versionState: () => ({ running: "old", installed: "new", latest: "new", stale: true }),
256
+ });
257
+
258
+ try {
259
+ expect(await exchange(url, { type: "getVersion" })).toEqual({
260
+ type: "versionState",
261
+ running: "old",
262
+ installed: "new",
263
+ latest: "new",
264
+ stale: true,
265
+ feedbackEnabled: false,
266
+ });
267
+ } finally {
268
+ server.stop(true);
269
+ }
270
+ });
271
+
272
+ test("answers getProject with the project directory label", async () => {
273
+ const { server, root, url } = await startServer();
274
+
275
+ try {
276
+ expect(await exchange(url, { type: "getProject" })).toEqual({
277
+ type: "project",
278
+ path: projectLabel(root),
279
+ });
280
+ } finally {
281
+ server.stop(true);
282
+ }
283
+ });
284
+
285
+ test("re-reads the record directory for every command", async () => {
286
+ const { server, adrDir, url } = await startServer();
287
+
288
+ try {
289
+ expect(await listRecords(url)).toMatchObject({ next: null });
290
+
291
+ await writeFile(join(adrDir, "0002-added.md"), fixtureRecord("0002-added.md", "Added", "proposed"));
292
+ expect(await listRecords(url)).toMatchObject({ next: "0002-added.md" });
293
+
294
+ await rm(join(adrDir, "0002-added.md"));
295
+ expect(await listRecords(url)).toMatchObject({ next: null });
296
+ } finally {
297
+ server.stop(true);
298
+ }
299
+ });
300
+
301
+ test("answers getGoal with the complete goal file contents", async () => {
302
+ const goalText = "Original goal\nsecond line\n";
303
+ const { server, url } = await startServer({ goalText });
304
+
305
+ try {
306
+ expect(await exchange(url, { type: "getGoal" })).toEqual({ type: "goal", text: goalText });
307
+ } finally {
308
+ server.stop(true);
309
+ }
310
+ });
311
+
312
+ test("writes the submitted goal text verbatim and reads it back", async () => {
313
+ const goalFile = join(await temporaryDirectory(), "GOAL.md");
314
+ const { server, url } = await startServer({ goalFile, goalText: "Original\n" });
315
+ const edited = " Edited goal\n\nthird line";
316
+
317
+ try {
318
+ expect(await exchange(url, { type: "saveGoal", text: edited })).toEqual({
319
+ type: "goal",
320
+ text: edited,
321
+ });
322
+ expect(await readFile(goalFile, "utf8")).toBe(edited);
323
+ expect(await exchange(url, { type: "getGoal" })).toEqual({ type: "goal", text: edited });
324
+ } finally {
325
+ server.stop(true);
326
+ }
327
+ });
328
+
329
+ test("reports a failed goal write and leaves the goal untouched", async () => {
330
+ const goalFile = join(await temporaryDirectory(), "goal-directory");
331
+ await mkdir(goalFile);
332
+ const { server, url } = await startServer({ goalFile });
333
+
334
+ try {
335
+ const message = await exchange(url, { type: "saveGoal", text: "Edited goal" });
336
+
337
+ expect(message).toEqual({ type: "error", message: expect.any(String) });
338
+ expect((await stat(goalFile)).isDirectory()).toBe(true);
339
+ } finally {
340
+ server.stop(true);
341
+ }
342
+ });
343
+
344
+ test("does not start an agent while loading or saving the goal", async () => {
345
+ const logPath = join(await temporaryDirectory(), "agent.log");
346
+ const { server, url } = await startServer({
347
+ goalText: "Goal\n",
348
+ agentCommand: `bun ${STUB_AGENT} --log=${logPath}`,
349
+ });
350
+
351
+ try {
352
+ await exchange(url, { type: "getGoal" });
353
+ await exchange(url, { type: "saveGoal", text: "Edited goal" });
354
+
355
+ expect(existsSync(logPath)).toBe(false);
356
+ } finally {
357
+ server.stop(true);
358
+ }
359
+ });
360
+ });
361
+
362
+ /** One issues state document as the worker writes it, scoped to `repoDir`. */
363
+ function issuesDocument(repoDir: string, overrides: Record<string, unknown> = {}) {
364
+ return {
365
+ version: 2,
366
+ repo: "acme/fixture",
367
+ root: resolve(repoDir),
368
+ lifecycle: "running",
369
+ enabled: true,
370
+ paused: false,
371
+ worker: {
372
+ pid: process.pid,
373
+ startedAt: "2026-10-27T10:00:00.000Z",
374
+ stateDir: "",
375
+ harnessVersion: "0.1.0",
376
+ argv: ["bun", "issues/worker.ts"],
377
+ },
378
+ poll: {
379
+ intervalMs: 2000,
380
+ lastPollAt: "2026-10-27T10:00:00.000Z",
381
+ nextPollAt: "2026-10-27T10:00:02.000Z",
382
+ etag: '"etag"',
383
+ notModified: false,
384
+ rateLimitedUntil: null,
385
+ error: null,
386
+ },
387
+ queue: [
388
+ {
389
+ number: 7,
390
+ title: "First issue",
391
+ url: "https://github.com/acme/fixture/issues/7",
392
+ createdAt: "2026-10-26T00:00:00Z",
393
+ handled: false,
394
+ runId: null,
395
+ agentRunId: "2026-10-27T10-00-01-000Z",
396
+ },
397
+ {
398
+ number: 8,
399
+ title: "Second issue",
400
+ url: "https://github.com/acme/fixture/issues/8",
401
+ createdAt: "2026-10-26T01:00:00Z",
402
+ handled: true,
403
+ runId: "2026-10-27T09-00-00-000Z",
404
+ agentRunId: null,
405
+ },
406
+ {
407
+ number: 9,
408
+ title: "Third issue",
409
+ url: "https://github.com/acme/fixture/issues/9",
410
+ createdAt: "2026-10-26T02:00:00Z",
411
+ handled: false,
412
+ runId: null,
413
+ agentRunId: "2026-10-27T10-00-02-000Z",
414
+ },
415
+ ],
416
+ runs: [
417
+ {
418
+ runId: "2026-10-27T09-00-00-000Z",
419
+ status: "succeeded",
420
+ startedAt: "2026-10-27T09:00:00.000Z",
421
+ finishedAt: "2026-10-27T09:01:00.000Z",
422
+ exitCode: 0,
423
+ issueNumbers: [8],
424
+ activity: 1,
425
+ firstStderrLine: null,
426
+ },
427
+ {
428
+ runId: "2026-10-27T10-00-01-000Z",
429
+ status: "running",
430
+ startedAt: "2026-10-27T10:00:00.000Z",
431
+ finishedAt: null,
432
+ exitCode: null,
433
+ issueNumbers: [7],
434
+ activity: 2,
435
+ firstStderrLine: null,
436
+ },
437
+ {
438
+ runId: "2026-10-27T10-00-02-000Z",
439
+ status: "running",
440
+ startedAt: "2026-10-27T10:00:00.000Z",
441
+ finishedAt: null,
442
+ exitCode: null,
443
+ issueNumbers: [9],
444
+ activity: 1,
445
+ firstStderrLine: null,
446
+ },
447
+ ],
448
+ agents: [
449
+ {
450
+ runId: "2026-10-27T10-00-01-000Z",
451
+ issueNumber: 7,
452
+ startedAt: "2026-10-27T10:00:00.000Z",
453
+ question: null,
454
+ acp: { pid: process.pid, argv: ["omp", "acp"], cwd: resolve(repoDir) },
455
+ },
456
+ {
457
+ runId: "2026-10-27T10-00-02-000Z",
458
+ issueNumber: 9,
459
+ startedAt: "2026-10-27T10:00:00.000Z",
460
+ question: {
461
+ id: 5,
462
+ message: "Which scope?",
463
+ options: [],
464
+ },
465
+ acp: null,
466
+ },
467
+ ],
468
+ cooldowns: { "10": "2026-10-27T10:05:00.000Z" },
469
+ lastError: null,
470
+ lastExitCode: null,
471
+ finishedAt: null,
472
+ notice: null,
473
+ updatedAt: "2026-10-27T10:00:01.000Z",
474
+ ...overrides,
475
+ };
476
+ }
477
+
478
+ /** Seeds the repo-scoped state and the repo-anchored lock, as a worker would. */
479
+ async function seedIssues(root: string, overrides: Record<string, unknown> = {}) {
480
+ const stateDir = join(root, "state");
481
+ const directory = join(stateDir, "issues", journalKey(resolve(root)));
482
+ await mkdir(directory, { recursive: true });
483
+ const document = issuesDocument(root, overrides);
484
+ const worker = document.worker as Record<string, unknown>;
485
+ worker.stateDir = stateDir;
486
+ await writeFile(join(directory, "state.json"), JSON.stringify(document));
487
+ const lock = acquireLock(join(root, ".devboard", ".tmp", "issues", "lock"), {
488
+ repo: resolve(root),
489
+ stateDir,
490
+ harnessVersion: "0.1.0",
491
+ argv: ["bun", "issues/worker.ts"],
492
+ });
493
+ expect(lock.acquired).toBe(true);
494
+ return { stateDir, directory, document, lock };
495
+ }
496
+
497
+ function serverErrors(stateDir: string, root: string): string[] {
498
+ const path = journalFile(stateDir, resolve(root));
499
+ if (!existsSync(path)) return [];
500
+ return readFileSync(path, "utf8")
501
+ .split("\n")
502
+ .filter((line) => line.trim().length > 0)
503
+ .map((line) => JSON.parse(line) as { kind?: string; text?: string })
504
+ .filter((record) => record.kind === "server-error")
505
+ .map((record) => record.text ?? "");
506
+ }
507
+
508
+ describe("issues protocol", () => {
509
+ test("opens stopped, not an error, when no worker state exists", async () => {
510
+ const { server, root, url } = await startServer();
511
+
512
+ try {
513
+ const message = (await exchange(url, { type: "getIssues" })) as Record<string, unknown>;
514
+ expect(message.type).toBe("issuesState");
515
+ expect(message.present).toBe(false);
516
+ expect(message.lifecycle).toBe("stopped");
517
+ expect(message.enabled).toBe(false);
518
+ expect(message.attached).toBe(false);
519
+ expect(message.repo).toBeNull();
520
+ expect(serverErrors(join(root, "state"), root)).toEqual([]);
521
+ } finally {
522
+ server.stop(true);
523
+ }
524
+ });
525
+
526
+ test("replays the checkout origin on connect before any worker exists", async () => {
527
+ const { server, url } = await startServer({
528
+ origin: "https://github.com/acme/fixture.git",
529
+ });
530
+
531
+ try {
532
+ const frames = await collect(url, { type: "getProject" }, (messages) =>
533
+ messages.some(
534
+ (message) =>
535
+ typeof message === "object" &&
536
+ message !== null &&
537
+ "type" in message &&
538
+ message.type === "scoutState",
539
+ ),
540
+ );
541
+ const issues = frames.find(
542
+ (frame): frame is { repo?: unknown; present?: unknown } =>
543
+ typeof frame === "object" &&
544
+ frame !== null &&
545
+ "type" in frame &&
546
+ frame.type === "issuesState",
547
+ );
548
+ const scout = frames.find(
549
+ (frame): frame is { repo?: unknown; present?: unknown } =>
550
+ typeof frame === "object" &&
551
+ frame !== null &&
552
+ "type" in frame &&
553
+ frame.type === "scoutState",
554
+ );
555
+ expect(issues?.repo).toBe("acme/fixture");
556
+ expect(issues?.present).toBe(false);
557
+ expect(scout?.repo).toBe("acme/fixture");
558
+ expect(scout?.present).toBe(false);
559
+ } finally {
560
+ server.stop(true);
561
+ }
562
+ });
563
+
564
+ test("opens stopped for damaged bytes without a journal error", async () => {
565
+ const { server, root, url } = await startServer();
566
+ const stateDir = join(root, "state");
567
+ const directory = join(stateDir, "issues", journalKey(resolve(root)));
568
+ await mkdir(directory, { recursive: true });
569
+ await writeFile(join(directory, "state.json"), '{"version": 1, "root":');
570
+
571
+ try {
572
+ const message = (await exchange(url, { type: "getIssues" })) as Record<string, unknown>;
573
+ expect(message.present).toBe(false);
574
+ expect(message.lifecycle).toBe("stopped");
575
+ expect(serverErrors(stateDir, root)).toEqual([]);
576
+ } finally {
577
+ server.stop(true);
578
+ }
579
+ });
580
+
581
+ test("replays a seeded worker snapshot with every running agent", async () => {
582
+ const { server, root, url } = await startServer();
583
+ await seedIssues(root);
584
+
585
+ try {
586
+ const message = (await exchange(url, { type: "getIssues" })) as Record<string, unknown>;
587
+ expect(message.type).toBe("issuesState");
588
+ expect(message.present).toBe(true);
589
+ expect(message.attached).toBe(true);
590
+ expect(message.lifecycle).toBe("running");
591
+ expect(message.repo).toBe("acme/fixture");
592
+ expect((message.queue as Array<{ number: number }>).map((entry) => entry.number)).toEqual([
593
+ 7, 8, 9,
594
+ ]);
595
+ const agents = message.agents as Array<{ runId: string; issueNumber: number }>;
596
+ expect(agents.map((agent) => agent.issueNumber)).toEqual([7, 9]);
597
+ expect(message.cooldowns).toEqual({ "10": "2026-10-27T10:05:00.000Z" });
598
+ expect(message.activeRun).toBeUndefined();
599
+ expect(message.question).toBeUndefined();
600
+ expect(message.nextAttemptAt).toBeUndefined();
601
+ } finally {
602
+ server.stop(true);
603
+ }
604
+ });
605
+
606
+ test("replays every running agent's activity on connect", async () => {
607
+ const { server, root, url } = await startServer();
608
+ const { directory } = await seedIssues(root);
609
+ for (const [runId, lines] of [
610
+ ["2026-10-27T10-00-01-000Z", ["agent one", "working one"]],
611
+ ["2026-10-27T10-00-02-000Z", ["agent two"]],
612
+ ] as const) {
613
+ await mkdir(join(directory, "runs", runId), { recursive: true });
614
+ await writeFile(join(directory, "runs", runId, "activity.json"), JSON.stringify(lines));
615
+ }
616
+
617
+ try {
618
+ const messages = await collect(url, { type: "getIssues" }, (received) => {
619
+ const replays = received.filter(
620
+ (message) =>
621
+ (message as { type?: string }).type === "issuesActivity" &&
622
+ (message as { replay?: boolean }).replay === true,
623
+ );
624
+ return replays.length >= 2;
625
+ });
626
+ const replays = messages.filter(
627
+ (message) =>
628
+ (message as { type?: string }).type === "issuesActivity" &&
629
+ (message as { replay?: boolean }).replay === true,
630
+ );
631
+ expect(replays).toHaveLength(2);
632
+ const byRun = new Map(
633
+ replays.map((message) => {
634
+ const entry = message as { runId: string; entries: Array<{ text: string }> };
635
+ return [entry.runId, entry.entries.map((item) => item.text)] as const;
636
+ }),
637
+ );
638
+ expect(byRun.get("2026-10-27T10-00-01-000Z")).toEqual(["agent one", "working one"]);
639
+ expect(byRun.get("2026-10-27T10-00-02-000Z")).toEqual(["agent two"]);
640
+ } finally {
641
+ server.stop(true);
642
+ }
643
+ });
644
+
645
+ test("answerIssues refuses a missing or unknown run id and routes a valid one", async () => {
646
+ const { server, root, url } = await startServer();
647
+ await seedIssues(root);
648
+ const control = join(root, "state", "issues", journalKey(resolve(root)), "control");
649
+
650
+ try {
651
+ const missing = (await exchange(url, {
652
+ type: "answerIssues",
653
+ id: 5,
654
+ value: "answer",
655
+ })) as Record<string, unknown>;
656
+ expect(missing.type).toBe("error");
657
+ expect(String(missing.message)).toContain("run id");
658
+
659
+ const unknown = (await exchange(url, {
660
+ type: "answerIssues",
661
+ id: 5,
662
+ value: "answer",
663
+ runId: "2026-10-27T11-00-00-000Z",
664
+ })) as Record<string, unknown>;
665
+ expect(unknown.type).toBe("error");
666
+ expect(String(unknown.message)).toContain("2026-10-27T11-00-00-000Z");
667
+
668
+ // A routed answer has no acknowledgement; it reaches the command dir.
669
+ const socket = new WebSocket(`ws://${url}/ws`);
670
+ await new Promise<void>((resolve) => {
671
+ socket.onopen = () => {
672
+ socket.send(
673
+ JSON.stringify({
674
+ type: "answerIssues",
675
+ id: 5,
676
+ value: "the whole goal",
677
+ runId: "2026-10-27T10-00-02-000Z",
678
+ }),
679
+ );
680
+ resolve();
681
+ };
682
+ });
683
+
684
+ // The command reaches the control directory; the worker consumes it.
685
+ let command: Record<string, unknown> | undefined;
686
+ for (let attempt = 0; attempt < 100 && command === undefined; attempt += 1) {
687
+ if (existsSync(control)) {
688
+ const name = readdirSync(control).find((entry) => entry.endsWith(".json"));
689
+ if (name !== undefined) {
690
+ command = JSON.parse(readFileSync(join(control, name), "utf8")) as Record<
691
+ string,
692
+ unknown
693
+ >;
694
+ break;
695
+ }
696
+ }
697
+ await Bun.sleep(10);
698
+ }
699
+ socket.close();
700
+ expect(command).toEqual({
701
+ type: "answer",
702
+ id: 5,
703
+ value: "the whole goal",
704
+ runId: "2026-10-27T10-00-02-000Z",
705
+ });
706
+ } finally {
707
+ server.stop(true);
708
+ }
709
+ });
710
+
711
+ test("serves a run detail from the repo-scoped history", async () => {
712
+ const { server, root, url } = await startServer();
713
+ const { directory } = await seedIssues(root);
714
+ const runId = "2026-10-27T09-00-00-000Z";
715
+ const runDirectory = join(directory, "runs", runId);
716
+ await mkdir(runDirectory, { recursive: true });
717
+ await writeFile(
718
+ join(runDirectory, "run.json"),
719
+ JSON.stringify({
720
+ version: 1,
721
+ runId,
722
+ repo: "acme/fixture",
723
+ status: "succeeded",
724
+ argv: ["omp", "acp"],
725
+ cwd: resolve(root),
726
+ startedAt: "2026-10-27T09:00:00.000Z",
727
+ finishedAt: "2026-10-27T09:01:00.000Z",
728
+ exitCode: 0,
729
+ issues: [
730
+ {
731
+ number: 8,
732
+ title: "Second issue",
733
+ url: "https://github.com/acme/fixture/issues/8",
734
+ createdAt: "2026-10-26T01:00:00Z",
735
+ },
736
+ ],
737
+ firstStderrLine: null,
738
+ workerPid: process.pid,
739
+ harnessVersion: "0.1.0",
740
+ }),
741
+ );
742
+ await writeFile(join(runDirectory, "activity.json"), JSON.stringify(["handled #8"]));
743
+
744
+ try {
745
+ const message = (await exchange(url, { type: "getIssuesRun", runId })) as Record<string, unknown>;
746
+ expect(message.type).toBe("issuesRun");
747
+ expect((message.run as { runId: string }).runId).toBe(runId);
748
+ expect((message.run as { activityLines: string[] }).activityLines).toEqual(["handled #8"]);
749
+ const missing = (await exchange(url, { type: "getIssuesRun", runId: "../../etc/passwd" })) as Record<string, unknown>;
750
+ expect(missing.type).toBe("error");
751
+ } finally {
752
+ server.stop(true);
753
+ }
754
+ });
755
+
756
+ test("opens stopped when the state is present but no live lock remains", async () => {
757
+ const { server, root, url } = await startServer();
758
+ await seedIssues(root);
759
+ await rm(join(root, ".devboard", ".tmp", "issues", "lock"), { force: true });
760
+
761
+ try {
762
+ const message = (await exchange(url, { type: "getIssues" })) as Record<string, unknown>;
763
+ expect(message.present).toBe(true);
764
+ expect(message.attached).toBe(false);
765
+ expect(message.lifecycle).toBe("stopped");
766
+ expect(message.enabled).toBe(false);
767
+ } finally {
768
+ server.stop(true);
769
+ }
770
+ });
771
+
772
+ test("refuses Start when this checkout's worker uses another state directory", async () => {
773
+ const { server, root, url } = await startServer();
774
+ const { stateDir, lock: seeded } = await seedIssues(root);
775
+ // Replace the seeded lock with an equally live one naming another state.
776
+ if (seeded.acquired) seeded.release();
777
+ const held = acquireLock(join(root, ".devboard", ".tmp", "issues", "lock"), {
778
+ repo: resolve(root),
779
+ stateDir: join(root, "other-state"),
780
+ harnessVersion: "0.1.0",
781
+ argv: ["bun", "issues/worker.ts"],
782
+ });
783
+ expect(held.acquired).toBe(true);
784
+ if (!held.acquired) return;
785
+
786
+ try {
787
+ const message = (await exchange(url, { type: "startIssues" })) as Record<string, unknown>;
788
+ expect(message.type).toBe("error");
789
+ expect(String(message.message)).toContain(String(join(root, "other-state")));
790
+ expect(String(message.message)).toContain(String(stateDir));
791
+ } finally {
792
+ server.stop(true);
793
+ }
794
+ });
795
+
796
+ test("names both harness versions when the worker loaded another one", async () => {
797
+ const { server, root, url } = await startServer({ runningVersion: "0.2.0" });
798
+ await seedIssues(root);
799
+
800
+ try {
801
+ const message = (await exchange(url, { type: "getIssues" })) as Record<string, unknown>;
802
+ expect(message.notice).toBe(
803
+ "Issues worker 0.1.0 is running; this dashboard loaded 0.2.0. " +
804
+ "It is left running; Stop it to launch the current harness on the next Start.",
805
+ );
806
+ } finally {
807
+ server.stop(true);
808
+ }
809
+ });
810
+
811
+ test("closes one issue through the bridge with the worker's repository", async () => {
812
+ const calls: Array<{ repoDir: string; repository: string; number: number }> = [];
813
+ const { server, root, url } = await startServer({
814
+ issues: {
815
+ closeIssue: async (request) => {
816
+ calls.push({
817
+ repoDir: request.repoDir,
818
+ repository: request.repository,
819
+ number: request.number,
820
+ });
821
+ },
822
+ },
823
+ });
824
+ await seedIssues(root);
825
+
826
+ try {
827
+ const message = (await exchange(url, { type: "closeIssue", number: 8 })) as Record<
828
+ string,
829
+ unknown
830
+ >;
831
+ expect(message).toEqual({ type: "issuesClosed", number: 8, ok: true });
832
+ expect(calls).toEqual([{ repoDir: resolve(root), repository: "acme/fixture", number: 8 }]);
833
+ } finally {
834
+ server.stop(true);
835
+ }
836
+ });
837
+
838
+ test("reports a refused closure without a generic error", async () => {
839
+ const { server, root, url } = await startServer({
840
+ issues: {
841
+ closeIssue: async () => {
842
+ throw new Error("Could not close issue #8: HTTP 403: Resource not accessible");
843
+ },
844
+ },
845
+ });
846
+ await seedIssues(root);
847
+
848
+ try {
849
+ const message = (await exchange(url, { type: "closeIssue", number: 8 })) as Record<
850
+ string,
851
+ unknown
852
+ >;
853
+ expect(message).toEqual({
854
+ type: "issuesClosed",
855
+ number: 8,
856
+ ok: false,
857
+ message: "Could not close issue #8: HTTP 403: Resource not accessible",
858
+ });
859
+ } finally {
860
+ server.stop(true);
861
+ }
862
+ });
863
+
864
+ test("refuses a close request without a number or without an origin", async () => {
865
+ const { server, url } = await startServer();
866
+
867
+ try {
868
+ expect(await exchange(url, { type: "closeIssue" })).toEqual({
869
+ type: "error",
870
+ message: "Closing an issue needs its number.",
871
+ });
872
+ expect(await exchange(url, { type: "closeIssue", number: 8 })).toEqual({
873
+ type: "issuesClosed",
874
+ number: 8,
875
+ ok: false,
876
+ message: "This checkout has no GitHub origin.",
877
+ });
878
+ } finally {
879
+ server.stop(true);
880
+ }
881
+ });
882
+ });