@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,167 @@
1
+ import { afterAll, describe, expect, test, vi } from "bun:test";
2
+ import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { IssuesBridge } from "./issues";
5
+ import { acquireLock } from "../issues/lock";
6
+ import { controlDirectory, lockPath, statePath } from "../issues/paths";
7
+ import { emptyState, writeState } from "../issues/state";
8
+ import { createdDirectories, temporaryDirectory } from "../tests/fixtures/scout-helpers";
9
+
10
+ afterAll(async () => {
11
+ await Promise.all(
12
+ createdDirectories().map((directory) => rm(directory, { recursive: true, force: true })),
13
+ );
14
+ });
15
+
16
+ function bridge(repoDir: string, stateDir: string): IssuesBridge {
17
+ return new IssuesBridge({
18
+ repoDir,
19
+ stateDir,
20
+ agentCommand: "stub",
21
+ runningVersion: "0.1.0",
22
+ harnessDir: join(import.meta.dir, ".."),
23
+ send: () => undefined,
24
+ pollMs: 60_000,
25
+ spawnWorker: () => undefined,
26
+ });
27
+ }
28
+
29
+ describe("issues bridge commands", () => {
30
+ test("a live lock with no state file receives Stop and pause", async () => {
31
+ const root = await temporaryDirectory("issues-bridge-repo");
32
+ const stateDir = join(root, "state");
33
+ const lock = acquireLock(lockPath(root), {
34
+ repo: root,
35
+ stateDir,
36
+ harnessVersion: null,
37
+ argv: [],
38
+ });
39
+ expect(lock.acquired).toBe(true);
40
+ if (!lock.acquired) return;
41
+ const subject = bridge(root, stateDir);
42
+ try {
43
+ expect(subject.command({ type: "stop" })).toEqual({ ok: true });
44
+ expect(subject.command({ type: "pause" })).toEqual({ ok: true });
45
+ expect(
46
+ subject.command({ type: "answer", id: 1, value: "yes", runId: "run-1" }),
47
+ ).toEqual({ ok: false, message: "No issues worker is running for this checkout." });
48
+ const files = await readdir(controlDirectory(stateDir, root));
49
+ expect(files).toHaveLength(2);
50
+ const commands = await Promise.all(
51
+ files
52
+ .sort()
53
+ .map(async (name) =>
54
+ JSON.parse(await readFile(join(controlDirectory(stateDir, root), name), "utf8")),
55
+ ),
56
+ );
57
+ expect(commands).toEqual([{ type: "stop" }, { type: "pause" }]);
58
+ } finally {
59
+ subject.dispose();
60
+ lock.release();
61
+ }
62
+ }, 10_000);
63
+
64
+ test("deleting the attached worker's state still proves Stop and ends it", async () => {
65
+ const root = await temporaryDirectory("issues-bridge-worker");
66
+ const stateDir = join(root, "state");
67
+ const script = join(root, "fixture", "issues", "worker.ts");
68
+ await mkdir(dirname(script), { recursive: true });
69
+ await writeFile(script, "console.log('ready');\nsetInterval(() => undefined, 1000);\n");
70
+ const worker = Bun.spawn({
71
+ cmd: [process.execPath, script, root],
72
+ cwd: root,
73
+ stdout: "pipe",
74
+ stderr: "ignore",
75
+ stdin: "ignore",
76
+ });
77
+ const reader = worker.stdout.getReader();
78
+ const ready = await reader.read();
79
+ reader.releaseLock();
80
+ expect(new TextDecoder().decode(ready.value)).toContain("ready");
81
+ const subject = bridge(root, stateDir);
82
+ vi.useFakeTimers();
83
+ try {
84
+ writeState(statePath(stateDir, root), {
85
+ ...emptyState({ repo: "acme/fixture", root, lifecycle: "running" }),
86
+ enabled: true,
87
+ worker: {
88
+ pid: worker.pid,
89
+ startedAt: new Date().toISOString(),
90
+ stateDir,
91
+ harnessVersion: null,
92
+ argv: [],
93
+ },
94
+ });
95
+ await mkdir(dirname(lockPath(root)), { recursive: true });
96
+ await writeFile(
97
+ lockPath(root),
98
+ JSON.stringify({
99
+ pid: worker.pid,
100
+ startedAt: new Date().toISOString(),
101
+ repo: root,
102
+ stateDir,
103
+ harnessVersion: null,
104
+ argv: [script, root],
105
+ }),
106
+ );
107
+ expect(subject.snapshot().attached).toBe(true);
108
+
109
+ await rm(statePath(stateDir, root), { force: true });
110
+ expect(subject.command({ type: "stop" })).toEqual({ ok: true });
111
+
112
+ // The stop proof fires 3 s after the command; advance the clock instead
113
+ // of waiting, then await the worker's own exit signal.
114
+ vi.advanceTimersByTime(3000);
115
+ const code = await worker.exited;
116
+ expect(code).not.toBeNull();
117
+ } finally {
118
+ vi.useRealTimers();
119
+ subject.dispose();
120
+ worker.kill("SIGKILL");
121
+ await rm(lockPath(root), { force: true });
122
+ await worker.exited;
123
+ }
124
+ }, 20_000);
125
+
126
+ test("a stale lock whose pid was reused lets Start spawn instead of attaching", async () => {
127
+ const root = await temporaryDirectory("issues-bridge-stale");
128
+ const stateDir = join(root, "state");
129
+ await mkdir(dirname(lockPath(root)), { recursive: true });
130
+ // The pid is alive (this test process); the recorded start cannot be its
131
+ // own, which is what a killed worker's lock looks like after pid reuse.
132
+ await writeFile(
133
+ lockPath(root),
134
+ JSON.stringify({
135
+ pid: process.pid,
136
+ startedAt: new Date(0).toISOString(),
137
+ repo: root,
138
+ stateDir,
139
+ harnessVersion: null,
140
+ argv: [],
141
+ }),
142
+ );
143
+ const spawned: string[][] = [];
144
+ const subject = new IssuesBridge({
145
+ repoDir: root,
146
+ stateDir,
147
+ agentCommand: "stub",
148
+ runningVersion: "0.1.0",
149
+ harnessDir: join(import.meta.dir, ".."),
150
+ send: () => undefined,
151
+ pollMs: 60_000,
152
+ preflight: async () => ({ ok: true, repository: "acme/fixture" }),
153
+ spawnWorker: (argv) => {
154
+ spawned.push(argv);
155
+ return 4242;
156
+ },
157
+ });
158
+ try {
159
+ expect(subject.snapshot().attached).toBe(false);
160
+ expect(await subject.start()).toEqual({ ok: true, attached: false });
161
+ expect(spawned).toHaveLength(1);
162
+ expect(spawned[0]!.join(" ")).toContain(join("issues", "worker.ts"));
163
+ } finally {
164
+ subject.dispose();
165
+ }
166
+ });
167
+ });
@@ -0,0 +1,479 @@
1
+ import { spawn } from "node:child_process";
2
+ import { readFileSync, readlinkSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { writeCommand, type IssuesCommand } from "../issues/control";
5
+ import { liveLockOwner, pidAlive, type LockOwner } from "../issues/lock";
6
+ import {
7
+ controlDirectory,
8
+ lockPath,
9
+ runDirectory,
10
+ statePath,
11
+ } from "../issues/paths";
12
+ import { preflightIssues, type PreflightResult } from "../issues/preflight";
13
+ import { cachedRepository } from "../issues/repository";
14
+ import { closeIssue as closeTrackerIssue, type CloseIssueRequest } from "./issue-close";
15
+ import {
16
+ activityPath,
17
+ parseActivity,
18
+ parseRunFile,
19
+ readState,
20
+ runDetail,
21
+ runFilePath,
22
+ type IssuesAgent,
23
+ type IssuesRunDetail,
24
+ type IssuesState,
25
+ } from "../issues/state";
26
+ import { activityDelta } from "./activity";
27
+
28
+ /**
29
+ * The dashboard half of Issues mode. It never owns a poll loop, an agent, or
30
+ * a lock: it reads the worker's repo-scoped state, replays its activity, and
31
+ * writes commands to it. A worker started here is detached in its own session,
32
+ * so killing this server leaves it running and a replacement reattaches.
33
+ */
34
+
35
+ export interface IssuesSnapshot {
36
+ present: boolean;
37
+ repo: string | null;
38
+ root: string;
39
+ lifecycle: IssuesState["lifecycle"];
40
+ enabled: boolean;
41
+ paused: boolean;
42
+ attached: boolean;
43
+ worker: IssuesState["worker"];
44
+ poll: IssuesState["poll"] | null;
45
+ queue: IssuesState["queue"];
46
+ runs: IssuesState["runs"];
47
+ agents: IssuesAgent[];
48
+ cooldowns: IssuesState["cooldowns"];
49
+ lastError: string | null;
50
+ lastExitCode: number | null;
51
+ finishedAt: string | null;
52
+ notice: string | null;
53
+ updatedAt: string | null;
54
+ }
55
+
56
+ export interface ActivityEntry {
57
+ text: string;
58
+ append: boolean;
59
+ }
60
+
61
+ export interface IssuesBridgeOptions {
62
+ repoDir: string;
63
+ stateDir: string;
64
+ agentCommand: string;
65
+ runningVersion: string | null;
66
+ harnessDir: string;
67
+ send: (event: unknown, except?: { send(data: string): unknown }) => void;
68
+ log?: (line: string) => void;
69
+ environment?: Record<string, string | undefined>;
70
+ pollMs?: number;
71
+ /** Test seams: replace the start preflight and the detached spawn. */
72
+ preflight?: (options: {
73
+ repoDir: string;
74
+ stateDir: string;
75
+ environment: Record<string, string | undefined>;
76
+ }) => Promise<PreflightResult>;
77
+ spawnWorker?: (argv: string[], environment: Record<string, string | undefined>) => number | undefined;
78
+ /** Test seam: replace the tracker mutation the close control performs. */
79
+ closeIssue?: (request: CloseIssueRequest) => Promise<void>;
80
+ }
81
+
82
+ /** The bits of the state a dashboard may safely publish. */
83
+ export class IssuesBridge {
84
+ private readonly repoDir: string;
85
+ private readonly stateDir: string;
86
+ private readonly agentCommand: string;
87
+ private readonly runningVersion: string | null;
88
+ private readonly harnessDir: string;
89
+ private readonly send: (event: unknown, except?: { send(data: string): unknown }) => void;
90
+ private readonly environment: Record<string, string | undefined>;
91
+ private readonly repository: () => string | null;
92
+ private readonly preflight: NonNullable<IssuesBridgeOptions["preflight"]>;
93
+ private readonly spawnWorker: NonNullable<IssuesBridgeOptions["spawnWorker"]>;
94
+ private readonly closeIssueAction: (request: CloseIssueRequest) => Promise<void>;
95
+ private readonly log: (line: string) => void;
96
+ private readonly pollMsValue: number;
97
+
98
+ private timer: Timer | undefined;
99
+ private lastSignature = "";
100
+ private lastActivities = new Map<string, string[]>();
101
+ private stopping: { pid: number; timer: Timer } | undefined;
102
+ private starting = false;
103
+
104
+ constructor(options: IssuesBridgeOptions) {
105
+ this.repoDir = resolve(options.repoDir);
106
+ this.stateDir = resolve(options.stateDir);
107
+ this.agentCommand = options.agentCommand;
108
+ this.runningVersion = options.runningVersion;
109
+ this.harnessDir = options.harnessDir;
110
+ this.send = options.send;
111
+ this.environment = options.environment ?? process.env;
112
+ this.repository = cachedRepository(this.repoDir);
113
+ this.log = options.log ?? (() => undefined);
114
+ this.pollMsValue = Math.max(50, options.pollMs ?? 250);
115
+ this.preflight =
116
+ options.preflight ??
117
+ ((request) =>
118
+ preflightIssues({
119
+ repoDir: request.repoDir,
120
+ stateDir: request.stateDir,
121
+ environment: request.environment,
122
+ }));
123
+ this.spawnWorker = options.spawnWorker ?? ((argv, environment) => this.detach(argv, environment));
124
+ this.closeIssueAction = options.closeIssue ?? ((request) => closeTrackerIssue(request));
125
+ }
126
+
127
+ /** Starts observing the worker state; it never starts a worker itself. */
128
+ begin(): void {
129
+ if (this.timer !== undefined) return;
130
+ this.timer = setInterval(() => this.refresh(), this.pollMsValue);
131
+ void this.refresh();
132
+ }
133
+
134
+ dispose(): void {
135
+ if (this.timer !== undefined) {
136
+ clearInterval(this.timer);
137
+ this.timer = undefined;
138
+ }
139
+ if (this.stopping !== undefined) {
140
+ clearTimeout(this.stopping.timer);
141
+ this.stopping = undefined;
142
+ }
143
+ }
144
+
145
+ private detach(argv: string[], environment: Record<string, string | undefined>): number | undefined {
146
+ try {
147
+ const child = spawn(argv[0]!, argv.slice(1), {
148
+ cwd: this.repoDir,
149
+ env: environment,
150
+ detached: true,
151
+ stdio: "ignore",
152
+ });
153
+ child.unref();
154
+ return child.pid;
155
+ } catch (error) {
156
+ this.log(`could not start the issues worker: ${error instanceof Error ? error.message : String(error)}`);
157
+ return undefined;
158
+ }
159
+ }
160
+
161
+ /* ------------------------------------------------------------------ */
162
+ /* Reading */
163
+ /* ------------------------------------------------------------------ */
164
+
165
+ private currentState(): IssuesState | undefined {
166
+ return readState(statePath(this.stateDir, this.repoDir));
167
+ }
168
+
169
+ private lockOwner(): LockOwner | undefined {
170
+ return liveLockOwner(lockPath(this.repoDir));
171
+ }
172
+
173
+ /** True when there is a worker state or a live lock to replay. */
174
+ get present(): boolean {
175
+ return this.currentState() !== undefined || this.lockOwner() !== undefined;
176
+ }
177
+
178
+ snapshot(): IssuesSnapshot {
179
+ const state = this.currentState();
180
+ const owner = this.lockOwner();
181
+ const attached =
182
+ owner !== undefined && resolve(owner.stateDir) === this.stateDir && pidAlive(owner.pid);
183
+ const repo = state !== undefined && state.repo.length > 0 ? state.repo : this.repository();
184
+ const active = attached ? state : undefined;
185
+ const lifecycle = active?.lifecycle === "stopped" || active === undefined ? "stopped" : active.lifecycle;
186
+ const worker = state?.worker ?? null;
187
+ const mismatch =
188
+ attached && worker !== null && worker.harnessVersion !== this.runningVersion
189
+ ? `Issues worker ${worker.harnessVersion ?? "unknown"} is running; this dashboard loaded ${
190
+ this.runningVersion ?? "unknown"
191
+ }. It is left running; Stop it to launch the current harness on the next Start.`
192
+ : null;
193
+ return {
194
+ present: state !== undefined,
195
+ repo,
196
+ root: this.repoDir,
197
+ lifecycle,
198
+ enabled: active?.enabled === true && lifecycle !== "stopped",
199
+ paused: active?.paused === true && lifecycle !== "stopped",
200
+ attached,
201
+ worker: active?.worker ?? null,
202
+ poll: active?.poll ?? null,
203
+ queue: state?.queue ?? [],
204
+ runs: state?.runs ?? [],
205
+ agents: active?.agents ?? [],
206
+ cooldowns: active?.cooldowns ?? {},
207
+ lastError: active?.lastError ?? null,
208
+ lastExitCode: active?.lastExitCode ?? null,
209
+ finishedAt: active?.finishedAt ?? null,
210
+ notice: mismatch ?? active?.notice ?? null,
211
+ updatedAt: state?.updatedAt ?? null,
212
+ };
213
+ }
214
+
215
+ /**
216
+ * A connect-time replay: state to the caller, then every running agent's
217
+ * whole activity to the caller and every other client. The baselines are
218
+ * shared, so advancing one for a single client would make the next delta skip
219
+ * what the older clients missed.
220
+ */
221
+ replay(target?: { send(data: string): unknown }): void {
222
+ const deliver = target === undefined ? this.send : (event: unknown) => {
223
+ try {
224
+ target.send(JSON.stringify(event));
225
+ } catch {
226
+ // The socket closed before the replay finished.
227
+ }
228
+ };
229
+ const snapshot = this.snapshot();
230
+ deliver({ type: "issuesState", ...snapshot });
231
+ for (const runId of this.activeRunIds(snapshot)) {
232
+ const activity = this.readActivity(runId);
233
+ this.lastActivities.set(runId, activity);
234
+ const event = {
235
+ type: "issuesActivity",
236
+ runId,
237
+ replay: true,
238
+ entries: this.entries(activity),
239
+ };
240
+ deliver(event);
241
+ // A targeted replay also reaches the other clients: the baseline is
242
+ // shared, so a replay only the new client saw would make the next delta
243
+ // skip it.
244
+ if (target !== undefined) this.send(event, target);
245
+ }
246
+ }
247
+
248
+ private activeRunIds(snapshot: IssuesSnapshot): string[] {
249
+ return [...new Set(snapshot.agents.map((agent) => agent.runId))];
250
+ }
251
+
252
+ private entries(lines: string[]): ActivityEntry[] {
253
+ return lines.map((text) => ({ text, append: false }));
254
+ }
255
+
256
+ private readActivity(runId: string): string[] {
257
+ try {
258
+ return parseActivity(readFileSync(activityPath(runDirectory(this.stateDir, this.repoDir, runId)), "utf8")) ?? [];
259
+ } catch {
260
+ return [];
261
+ }
262
+ }
263
+
264
+ private publishActivity(runId: string, activity: string[]): void {
265
+ const previous = this.lastActivities.get(runId);
266
+ if (previous === undefined) {
267
+ this.lastActivities.set(runId, activity);
268
+ this.send({ type: "issuesActivity", runId, replay: true, entries: this.entries(activity) });
269
+ return;
270
+ }
271
+ // A trim drops the front of the log, so only a whole replay stays correct.
272
+ const delta = activityDelta(previous, activity);
273
+ this.lastActivities.set(runId, activity);
274
+ if (!delta.replay && delta.entries.length === 0) return;
275
+ this.send({
276
+ type: "issuesActivity",
277
+ runId,
278
+ ...(delta.replay ? { replay: true } : {}),
279
+ entries: delta.entries,
280
+ });
281
+ }
282
+
283
+ refresh(): void {
284
+ const snapshot = this.snapshot();
285
+ const signature = JSON.stringify(snapshot);
286
+ if (signature !== this.lastSignature) {
287
+ this.lastSignature = signature;
288
+ this.send({ type: "issuesState", ...snapshot });
289
+ }
290
+ const runIds = this.activeRunIds(snapshot);
291
+ const active = new Set(runIds);
292
+ for (const runId of [...this.lastActivities.keys()]) {
293
+ if (!active.has(runId)) this.lastActivities.delete(runId);
294
+ }
295
+ for (const runId of runIds) {
296
+ this.publishActivity(runId, this.readActivity(runId));
297
+ }
298
+ }
299
+
300
+ /* ------------------------------------------------------------------ */
301
+ /* Commands */
302
+ /* ------------------------------------------------------------------ */
303
+
304
+ async start(): Promise<{ ok: true; attached: boolean } | { ok: false; message: string }> {
305
+ if (this.starting) return { ok: true, attached: false };
306
+ const owner = this.lockOwner();
307
+ if (owner !== undefined) {
308
+ if (resolve(owner.stateDir) !== this.stateDir) {
309
+ return {
310
+ ok: false,
311
+ message:
312
+ `this checkout already has an issues worker (pid ${owner.pid}) using ${owner.stateDir}; ` +
313
+ `this dashboard's STATE_DIR is ${this.stateDir}`,
314
+ };
315
+ }
316
+ this.refresh();
317
+ return { ok: true, attached: true };
318
+ }
319
+
320
+ this.starting = true;
321
+ try {
322
+ const result = await this.preflight({
323
+ repoDir: this.repoDir,
324
+ stateDir: this.stateDir,
325
+ environment: this.environment,
326
+ });
327
+ if (!result.ok) return result;
328
+ return await this.spawn();
329
+ } finally {
330
+ this.starting = false;
331
+ }
332
+ }
333
+
334
+ private async spawn(): Promise<{ ok: true; attached: boolean } | { ok: false; message: string }> {
335
+ const argv = [
336
+ process.execPath,
337
+ join(this.harnessDir, "issues", "worker.ts"),
338
+ "--repo",
339
+ this.repoDir,
340
+ "--state-dir",
341
+ this.stateDir,
342
+ ];
343
+ const pid = this.spawnWorker(argv, {
344
+ ...this.environment,
345
+ REPO_DIR: this.repoDir,
346
+ STATE_DIR: this.stateDir,
347
+ AGENT_COMMAND: this.agentCommand,
348
+ });
349
+ if (pid === undefined) {
350
+ return { ok: false, message: "the issues worker could not be started" };
351
+ }
352
+ return { ok: true, attached: false };
353
+ }
354
+
355
+ command(command: IssuesCommand): { ok: true } | { ok: false; message: string } {
356
+ const state = this.currentState();
357
+ const owner = this.lockOwner();
358
+ if (owner === undefined) {
359
+ return { ok: false, message: "No issues worker is running for this checkout." };
360
+ }
361
+ if (resolve(owner.stateDir) !== this.stateDir) {
362
+ return {
363
+ ok: false,
364
+ message:
365
+ `the issues worker (pid ${owner.pid}) uses ${owner.stateDir}; this dashboard's STATE_DIR is ` +
366
+ `${this.stateDir}`,
367
+ };
368
+ }
369
+ // The live lock proves a worker exists even when its state file is absent,
370
+ // unreadable, or left by another harness version; Stop, pause, resume, and
371
+ // check must still reach it. Only an answer needs the state's agent list.
372
+ if (command.type === "answer") {
373
+ if (state === undefined || !state.enabled || state.lifecycle === "stopped") {
374
+ return { ok: false, message: "No issues worker is running for this checkout." };
375
+ }
376
+ if (typeof command.runId !== "string" || command.runId.length === 0) {
377
+ return { ok: false, message: "An answer needs its run id." };
378
+ }
379
+ if (!state.agents.some((agent) => agent.runId === command.runId)) {
380
+ return { ok: false, message: `No agent is running for run ${command.runId}.` };
381
+ }
382
+ }
383
+ writeCommand(controlDirectory(this.stateDir, this.repoDir), command);
384
+ if (command.type === "stop") this.scheduleStopProof(owner.pid);
385
+ return { ok: true };
386
+ }
387
+
388
+ /**
389
+ * Closes one open issue on the tracker. The worker is the source of truth for
390
+ * the origin, so its state names the repository even when the dashboard's own
391
+ * remote discovery came up empty.
392
+ */
393
+ async closeIssue(
394
+ number: unknown,
395
+ ): Promise<{ ok: true } | { ok: false; message: string }> {
396
+ if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) {
397
+ return { ok: false, message: "Closing an issue needs a positive issue number." };
398
+ }
399
+ const state = this.currentState();
400
+ const repository = state !== undefined && state.repo.length > 0 ? state.repo : this.repository();
401
+ if (repository === null || repository === undefined) {
402
+ return { ok: false, message: "This checkout has no GitHub origin." };
403
+ }
404
+ try {
405
+ await this.closeIssueAction({
406
+ repoDir: this.repoDir,
407
+ repository,
408
+ number,
409
+ environment: this.environment,
410
+ });
411
+ return { ok: true };
412
+ } catch (error) {
413
+ return {
414
+ ok: false,
415
+ message: error instanceof Error ? error.message : String(error),
416
+ };
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Stop must end a worker that is still alive after the command. Only a
422
+ * process the table proves is this checkout's worker is signalled.
423
+ */
424
+ private scheduleStopProof(pid: number): void {
425
+ if (this.stopping !== undefined) clearTimeout(this.stopping.timer);
426
+ const timer = setTimeout(() => {
427
+ this.stopping = undefined;
428
+ if (!pidAlive(pid)) return;
429
+ if (!this.provesWorker(pid)) return;
430
+ try {
431
+ process.kill(pid, "SIGTERM");
432
+ } catch {
433
+ return;
434
+ }
435
+ const killTimer = setTimeout(() => {
436
+ if (!pidAlive(pid)) return;
437
+ if (!this.provesWorker(pid)) return;
438
+ try {
439
+ process.kill(pid, "SIGKILL");
440
+ } catch {
441
+ // It exited between the proof and the signal.
442
+ }
443
+ }, 2000);
444
+ killTimer.unref?.();
445
+ }, 3000);
446
+ timer.unref?.();
447
+ this.stopping = { pid, timer };
448
+ }
449
+
450
+ private provesWorker(pid: number): boolean {
451
+ let argv: string[];
452
+ try {
453
+ argv = readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\u0000").filter(Boolean);
454
+ } catch {
455
+ return false;
456
+ }
457
+ if (!argv.some((entry) => entry.endsWith(join("issues", "worker.ts")))) return false;
458
+ if (!argv.includes(this.repoDir)) return false;
459
+ try {
460
+ return readlinkSync(`/proc/${pid}/cwd`) === this.repoDir;
461
+ } catch {
462
+ return false;
463
+ }
464
+ }
465
+
466
+ run(runId: unknown): IssuesRunDetail | undefined {
467
+ if (typeof runId !== "string" || !/^[A-Za-z0-9_.-]+$/.test(runId)) return undefined;
468
+ const directory = runDirectory(this.stateDir, this.repoDir, runId);
469
+ try {
470
+ const run = parseRunFile(readFileSync(runFilePath(directory), "utf8"));
471
+ if (run === undefined) return undefined;
472
+ return runDetail(run, this.readActivity(runId));
473
+ } catch {
474
+ return undefined;
475
+ }
476
+ }
477
+
478
+ }
479
+