@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,444 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { mkdir, rm, writeFile } from "node:fs/promises";
3
+ import { randomUUID } from "node:crypto";
4
+ import { join, resolve } from "node:path";
5
+ import { createServer } from "node:net";
6
+ import type { AddressInfo, Socket } from "node:net";
7
+ import {
8
+ checksumName,
9
+ installRelease,
10
+ ReleaseFeedError,
11
+ tarballName,
12
+ type ReleaseInfo,
13
+ } from "../lib/release";
14
+ import type { Journal, JournalKind } from "./journal";
15
+ import {
16
+ DEFAULT_UPDATE_INTERVAL_MS,
17
+ DEFAULT_MAX_BACKOFF_MS,
18
+ startUpdater,
19
+ updateRetryDelay,
20
+ } from "./updates";
21
+
22
+ const harnessRoot = resolve(import.meta.dir, "..");
23
+ const testRoot = join(harnessRoot, ".tmp");
24
+ const temporaryDirectories: string[] = [];
25
+
26
+ async function temporaryDirectory(): Promise<string> {
27
+ await mkdir(testRoot, { recursive: true });
28
+ const directory = join(testRoot, `updates-${randomUUID()}`);
29
+ await mkdir(directory, { recursive: true });
30
+ temporaryDirectories.push(directory);
31
+ return directory;
32
+ }
33
+
34
+ async function installedPrefix(version: string): Promise<string> {
35
+ const prefix = await temporaryDirectory();
36
+ const directory = join(prefix, "node_modules", "@dsdenes", "devboard");
37
+ await mkdir(directory, { recursive: true });
38
+ await writeFile(join(directory, "package.json"), `${JSON.stringify({ version })}\n`);
39
+ return prefix;
40
+ }
41
+
42
+ function journalRecorder(): { journal: Journal; records: Array<[JournalKind, string]> } {
43
+ const records: Array<[JournalKind, string]> = [];
44
+ return {
45
+ records,
46
+ journal: { path: "test", write: (kind, text) => records.push([kind, text]) },
47
+ };
48
+ }
49
+
50
+ function release(version: string): ReleaseInfo {
51
+ return { version, tag: `v${version}`, assets: [] };
52
+ }
53
+
54
+ function manualSchedule() {
55
+ const delays: number[] = [];
56
+ const callbacks: Array<() => Promise<unknown> | void> = [];
57
+ const cancelled: number[] = [];
58
+ return {
59
+ delays,
60
+ cancelled,
61
+ schedule: (run: () => Promise<unknown> | void, delayMs: number) => {
62
+ const index = delays.length;
63
+ delays.push(delayMs);
64
+ callbacks.push(run);
65
+ return {
66
+ cancel: () => {
67
+ cancelled.push(index);
68
+ },
69
+ };
70
+ },
71
+ async fire(index: number) {
72
+ await callbacks[index]?.();
73
+ },
74
+ };
75
+ }
76
+
77
+ afterAll(async () => {
78
+ await Promise.all(
79
+ temporaryDirectories.map((directory) => rm(directory, { recursive: true, force: true })),
80
+ );
81
+ });
82
+
83
+ describe("updateRetryDelay", () => {
84
+ test("uses the base interval until a failure and then backs off", () => {
85
+ expect(updateRetryDelay(0, 1000, 60_000)).toBe(1000);
86
+ expect(updateRetryDelay(1, 1000, 60_000)).toBe(2000);
87
+ expect(updateRetryDelay(2, 1000, 60_000)).toBe(4000);
88
+ expect(updateRetryDelay(20, 1000, 60_000)).toBe(60_000);
89
+ });
90
+
91
+ test("defaults match the documented polling contract", () => {
92
+ expect(DEFAULT_UPDATE_INTERVAL_MS).toBe(60_000);
93
+ expect(DEFAULT_MAX_BACKOFF_MS).toBe(60 * 60_000);
94
+ });
95
+ });
96
+
97
+ describe("startUpdater", () => {
98
+ test("checks immediately, records an install, and reports the latest release", async () => {
99
+ const prefix = await installedPrefix("0.1.0");
100
+ const { journal, records } = journalRecorder();
101
+ const updates: string[] = [];
102
+ let checks = 0;
103
+ const updater = startUpdater({
104
+ repoDir: prefix,
105
+ prefixDir: prefix,
106
+ stateDir: prefix,
107
+ currentVersion: "0.1.0",
108
+ journal,
109
+ intervalMs: 60_000,
110
+ startImmediately: false,
111
+ fetchRelease: async () => {
112
+ checks += 1;
113
+ return release("0.1.1");
114
+ },
115
+ install: async (info) => {
116
+ updates.push(info.version);
117
+ return info.version;
118
+ },
119
+ onInstalled: (version) => updates.push(`callback:${version}`),
120
+ });
121
+ try {
122
+ await updater.checkNow();
123
+ expect(checks).toBe(1);
124
+ expect(updater.latest()).toBe("0.1.1");
125
+ expect(updates).toEqual(["0.1.1", "callback:0.1.1"]);
126
+ expect(records).toEqual([["update", "installed 0.1.1 (was 0.1.0); a restart loads it"]]);
127
+ } finally {
128
+ updater.stop();
129
+ }
130
+ });
131
+
132
+ test("does not install into a prefix without the package", async () => {
133
+ const prefix = await temporaryDirectory();
134
+ const { journal } = journalRecorder();
135
+ let installs = 0;
136
+ const updater = startUpdater({
137
+ repoDir: prefix,
138
+ prefixDir: prefix,
139
+ stateDir: prefix,
140
+ currentVersion: null,
141
+ journal,
142
+ startImmediately: false,
143
+ fetchRelease: async () => release("0.1.4"),
144
+ install: async () => {
145
+ installs += 1;
146
+ return "0.1.4";
147
+ },
148
+ });
149
+ try {
150
+ expect(await updater.checkNow()).toBe("not-installed");
151
+ expect(installs).toBe(0);
152
+ expect(updater.latest()).toBe("0.1.4");
153
+ } finally {
154
+ updater.stop();
155
+ }
156
+ });
157
+
158
+ test("journals a failure and keeps the previous version", async () => {
159
+ const prefix = await installedPrefix("0.1.0");
160
+ const { journal, records } = journalRecorder();
161
+ const updater = startUpdater({
162
+ repoDir: prefix,
163
+ prefixDir: prefix,
164
+ stateDir: prefix,
165
+ currentVersion: "0.1.0",
166
+ journal,
167
+ intervalMs: 60_000,
168
+ startImmediately: false,
169
+ fetchRelease: async () => release("0.1.2"),
170
+ install: async () => {
171
+ throw new Error("checksum mismatch");
172
+ },
173
+ });
174
+ try {
175
+ expect(await updater.checkNow()).toBe("failed");
176
+ expect(records).toEqual([
177
+ ["update-error", "could not install 0.1.2: checksum mismatch"],
178
+ ]);
179
+ } finally {
180
+ updater.stop();
181
+ }
182
+ });
183
+
184
+ test("journals a stalled download as an update-error and reschedules the check", async () => {
185
+ const prefix = await installedPrefix("0.1.0");
186
+ const stateDir = await temporaryDirectory();
187
+ const sockets = new Set<Socket>();
188
+ const stall = createServer((socket) => {
189
+ sockets.add(socket);
190
+ socket.on("close", () => sockets.delete(socket));
191
+ });
192
+ await new Promise<void>((settle) => stall.listen(0, "127.0.0.1", () => settle()));
193
+ const baseUrl = `http://127.0.0.1:${(stall.address() as AddressInfo).port}`;
194
+ const { journal, records } = journalRecorder();
195
+ const manual = manualSchedule();
196
+ const updater = startUpdater({
197
+ repoDir: prefix,
198
+ prefixDir: prefix,
199
+ stateDir,
200
+ currentVersion: "0.1.0",
201
+ journal,
202
+ intervalMs: 1000,
203
+ schedule: manual.schedule,
204
+ fetchRelease: async () => ({
205
+ version: "0.1.2",
206
+ tag: "v0.1.2",
207
+ assets: [
208
+ {
209
+ name: tarballName("0.1.2"),
210
+ id: 11,
211
+ browser_download_url: `${baseUrl}/stall/${tarballName("0.1.2")}`,
212
+ },
213
+ {
214
+ name: checksumName("0.1.2"),
215
+ id: 12,
216
+ browser_download_url: `${baseUrl}/stall/${checksumName("0.1.2")}`,
217
+ },
218
+ ],
219
+ }),
220
+ install: (info) =>
221
+ installRelease({
222
+ source: { kind: "gitea", baseUrl, repo: "acme/fixture" },
223
+ release: info,
224
+ prefix,
225
+ stateDir,
226
+ downloadTimeoutMs: 500,
227
+ }),
228
+ });
229
+ try {
230
+ await manual.fire(0);
231
+ expect(records).toEqual([
232
+ [
233
+ "update-error",
234
+ "could not install 0.1.2: release asset download timed out after 500 ms: " +
235
+ "devboard-0.1.2.tgz",
236
+ ],
237
+ ]);
238
+ // The failed check schedules its own backoff retry, so a stalled
239
+ // download never turns the update loop off.
240
+ expect(manual.delays).toEqual([0, 2000]);
241
+ } finally {
242
+ updater.stop();
243
+ for (const socket of sockets) socket.destroy();
244
+ stall.close();
245
+ }
246
+ }, 20_000);
247
+
248
+ test("journals an unreachable feed as context, never an update-error", async () => {
249
+ const prefix = await installedPrefix("0.1.0");
250
+ const { journal, records } = journalRecorder();
251
+ const manual = manualSchedule();
252
+ const offline =
253
+ 'gh api releases/latest failed: Get "https://api.github.com/repos/dsdenes/devboard/releases/latest": ' +
254
+ "dial tcp 140.82.121.5:443: connect: network is unreachable";
255
+ const updater = startUpdater({
256
+ repoDir: prefix,
257
+ prefixDir: prefix,
258
+ stateDir: prefix,
259
+ currentVersion: "0.1.0",
260
+ journal,
261
+ intervalMs: 1000,
262
+ schedule: manual.schedule,
263
+ fetchRelease: async () => {
264
+ throw new Error(offline);
265
+ },
266
+ install: async (info) => info.version,
267
+ });
268
+ await manual.fire(0);
269
+ expect(records).toEqual([["activity", `release check skipped: ${offline}`]]);
270
+ // The offline check still backs off instead of polling at the base interval.
271
+ expect(manual.delays).toEqual([0, 2000]);
272
+ updater.stop();
273
+ });
274
+
275
+ test("journals a missing release client as context, never an update-error", async () => {
276
+ const prefix = await installedPrefix("0.1.0");
277
+ const { journal, records } = journalRecorder();
278
+ const manual = manualSchedule();
279
+ const missing = Object.assign(
280
+ new Error("ENOENT: no such file or directory, posix_spawn 'gh'"),
281
+ { code: "ENOENT" },
282
+ );
283
+ const updater = startUpdater({
284
+ repoDir: prefix,
285
+ prefixDir: prefix,
286
+ stateDir: prefix,
287
+ currentVersion: "0.1.0",
288
+ journal,
289
+ intervalMs: 1000,
290
+ schedule: manual.schedule,
291
+ fetchRelease: async () => {
292
+ throw missing;
293
+ },
294
+ install: async (info) => info.version,
295
+ });
296
+ await manual.fire(0);
297
+ expect(records).toEqual([["activity", `release check skipped: ${missing.message}`]]);
298
+ // The skipped check still backs off instead of polling at the base interval.
299
+ expect(manual.delays).toEqual([0, 2000]);
300
+ updater.stop();
301
+ });
302
+
303
+ test("journals an unreadable release description as context, never an update-error", async () => {
304
+ const prefix = await installedPrefix("0.1.0");
305
+ const { journal, records } = journalRecorder();
306
+ const manual = manualSchedule();
307
+ const unreadable = new ReleaseFeedError(
308
+ "gh returned an unreadable release description: unexpected gh arguments: api releases/latest",
309
+ );
310
+ const updater = startUpdater({
311
+ repoDir: prefix,
312
+ prefixDir: prefix,
313
+ stateDir: prefix,
314
+ currentVersion: "0.1.0",
315
+ journal,
316
+ intervalMs: 1000,
317
+ schedule: manual.schedule,
318
+ fetchRelease: async () => {
319
+ throw unreadable;
320
+ },
321
+ install: async (info) => info.version,
322
+ });
323
+ await manual.fire(0);
324
+ expect(records).toEqual([["activity", `release check skipped: ${unreadable.message}`]]);
325
+ // The skipped check still backs off instead of polling at the base interval.
326
+ expect(manual.delays).toEqual([0, 2000]);
327
+ updater.stop();
328
+ });
329
+
330
+ test("journals a feed error that is not connectivity as an update-error", async () => {
331
+ const prefix = await installedPrefix("0.1.0");
332
+ const { journal, records } = journalRecorder();
333
+ const updater = startUpdater({
334
+ repoDir: prefix,
335
+ prefixDir: prefix,
336
+ stateDir: prefix,
337
+ currentVersion: "0.1.0",
338
+ journal,
339
+ startImmediately: false,
340
+ fetchRelease: async () => {
341
+ throw new Error("gh api releases/latest failed: HTTP 401: Bad credentials");
342
+ },
343
+ install: async (info) => info.version,
344
+ });
345
+ try {
346
+ expect(await updater.checkNow()).toBe("failed");
347
+ expect(records).toEqual([
348
+ [
349
+ "update-error",
350
+ "release check failed: gh api releases/latest failed: HTTP 401: Bad credentials",
351
+ ],
352
+ ]);
353
+ } finally {
354
+ updater.stop();
355
+ }
356
+ });
357
+
358
+ test("single-flights concurrent checks", async () => {
359
+ const prefix = await installedPrefix("0.1.0");
360
+ const { journal } = journalRecorder();
361
+ let calls = 0;
362
+ const gate = Promise.withResolvers<ReleaseInfo>();
363
+ const updater = startUpdater({
364
+ repoDir: prefix,
365
+ prefixDir: prefix,
366
+ stateDir: prefix,
367
+ currentVersion: "0.1.0",
368
+ journal,
369
+ startImmediately: false,
370
+ fetchRelease: async () => {
371
+ calls += 1;
372
+ return gate.promise;
373
+ },
374
+ install: async (info) => info.version,
375
+ });
376
+ try {
377
+ const first = updater.checkNow();
378
+ const second = updater.checkNow();
379
+ gate.resolve(release("0.1.5"));
380
+ expect(await second).toBe("installed");
381
+ expect(await first).toBe("installed");
382
+ expect(calls).toBe(1);
383
+ } finally {
384
+ updater.stop();
385
+ }
386
+ });
387
+
388
+ test("checks at the startup turn and then on the overridden interval", async () => {
389
+ const prefix = await installedPrefix("0.1.0");
390
+ const { journal } = journalRecorder();
391
+ const manual = manualSchedule();
392
+ let calls = 0;
393
+ const updater = startUpdater({
394
+ repoDir: prefix,
395
+ prefixDir: prefix,
396
+ stateDir: prefix,
397
+ currentVersion: "0.1.0",
398
+ journal,
399
+ intervalMs: 1000,
400
+ schedule: manual.schedule,
401
+ fetchRelease: async () => {
402
+ calls += 1;
403
+ return release("0.1.0");
404
+ },
405
+ install: async (info) => info.version,
406
+ });
407
+ await manual.fire(0);
408
+ expect(calls).toBe(1);
409
+ expect(manual.delays).toEqual([0, 1000]);
410
+ await manual.fire(1);
411
+ expect(calls).toBe(2);
412
+ expect(manual.delays).toEqual([0, 1000, 1000]);
413
+ updater.stop();
414
+ expect(manual.cancelled).toEqual([2]);
415
+ });
416
+
417
+ test("backs off after a failure instead of retrying at the base interval", async () => {
418
+ const prefix = await installedPrefix("0.1.0");
419
+ const { journal } = journalRecorder();
420
+ const manual = manualSchedule();
421
+ let calls = 0;
422
+ const updater = startUpdater({
423
+ repoDir: prefix,
424
+ prefixDir: prefix,
425
+ stateDir: prefix,
426
+ currentVersion: "0.1.0",
427
+ journal,
428
+ intervalMs: 1000,
429
+ schedule: manual.schedule,
430
+ fetchRelease: async () => {
431
+ calls += 1;
432
+ throw new Error("gh is not authenticated");
433
+ },
434
+ install: async (info) => info.version,
435
+ });
436
+ await manual.fire(0);
437
+ expect(calls).toBe(1);
438
+ expect(manual.delays).toEqual([0, 2000]);
439
+ await manual.fire(1);
440
+ expect(calls).toBe(2);
441
+ expect(manual.delays).toEqual([0, 2000, 4000]);
442
+ updater.stop();
443
+ });
444
+ });
@@ -0,0 +1,198 @@
1
+ import {
2
+ installRelease,
3
+ installedHarnessVersion,
4
+ isNewerVersion,
5
+ isReleaseUnreachable,
6
+ latestRelease,
7
+ releaseSource,
8
+ ReleaseFeedError,
9
+ type ReleaseInfo,
10
+ type ReleaseSource,
11
+ } from "../lib/release";
12
+ import type { Journal } from "./journal";
13
+
14
+ /**
15
+ * Automatic harness updates. The server checks npm at startup and then on an
16
+ * interval, installs a newer artifact into the host prefix while it keeps
17
+ * serving, and leaves the restart to the operator's reload control. When npm
18
+ * cannot serve the artifact and a Gitea release is configured, the release
19
+ * tarball and its checksum are the fallback. A failure is journaled and
20
+ * retried with exponential backoff; the running package is never replaced by
21
+ * a failed or partial install.
22
+ */
23
+
24
+ export const DEFAULT_UPDATE_INTERVAL_MS = 60_000;
25
+ /** A repeated failure backs off to this ceiling, not past it. */
26
+ export const DEFAULT_MAX_BACKOFF_MS = 60 * 60_000;
27
+
28
+ export type UpdateOutcome = "current" | "installed" | "not-installed" | "failed" | "stopped";
29
+
30
+ export interface UpdateOptions {
31
+ repoDir: string;
32
+ /** The host's npm prefix: `<host>/.devboard`. */
33
+ prefixDir: string;
34
+ stateDir: string;
35
+ /** Version this process started with. */
36
+ currentVersion: string | null;
37
+ journal: Journal;
38
+ env?: NodeJS.ProcessEnv;
39
+ intervalMs?: number;
40
+ maxBackoffMs?: number;
41
+ /** Injectable feed for tests and fixtures; defaults to the environment's. */
42
+ source?: ReleaseSource;
43
+ fetchRelease?: () => Promise<ReleaseInfo>;
44
+ install?: (release: ReleaseInfo) => Promise<string>;
45
+ onLatest?: (version: string) => void;
46
+ onInstalled?: (version: string) => void;
47
+ /** Test seam; production checks as soon as the event loop turns. */
48
+ startImmediately?: boolean;
49
+ /** Injectable scheduler; production uses an unref'd timeout. */
50
+ schedule?: (run: () => Promise<unknown> | void, delayMs: number) => { cancel: () => void };
51
+ }
52
+
53
+ export interface Updater {
54
+ /** The newest release a check has seen, or null before the first check. */
55
+ latest(): string | null;
56
+ /** Runs one check now; concurrent calls share the in-flight check. */
57
+ checkNow(): Promise<UpdateOutcome>;
58
+ /** Stops the next scheduled check; an in-flight check finishes. */
59
+ stop(): void;
60
+ }
61
+
62
+ function positiveNumber(value: string | undefined, fallback: number): number {
63
+ const parsed = Number(value);
64
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
65
+ }
66
+
67
+ function detailOf(error: unknown): string {
68
+ return error instanceof Error ? error.message : String(error);
69
+ }
70
+
71
+ /** The next check delay: the base interval until a failure backs it off. */
72
+ export function updateRetryDelay(failures: number, intervalMs: number, maxBackoffMs: number): number {
73
+ if (failures <= 0) return intervalMs;
74
+ return Math.min(intervalMs * 2 ** Math.min(failures, 16), maxBackoffMs);
75
+ }
76
+
77
+ export function startUpdater(options: UpdateOptions): Updater {
78
+ const env = options.env ?? process.env;
79
+ const intervalMs =
80
+ options.intervalMs ?? positiveNumber(env.DEVBOARD_UPDATE_INTERVAL_MS, DEFAULT_UPDATE_INTERVAL_MS);
81
+ const maxBackoffMs =
82
+ options.maxBackoffMs ??
83
+ positiveNumber(env.DEVBOARD_UPDATE_BACKOFF_MAX_MS, DEFAULT_MAX_BACKOFF_MS);
84
+ const source = options.source ?? releaseSource(env, options.stateDir);
85
+ const fetchRelease = options.fetchRelease ?? (() => latestRelease(source));
86
+ const install =
87
+ options.install ??
88
+ ((release: ReleaseInfo) =>
89
+ installRelease({
90
+ source,
91
+ release,
92
+ prefix: options.prefixDir,
93
+ stateDir: options.stateDir,
94
+ env,
95
+ }));
96
+
97
+ let latestVersion: string | null = null;
98
+ let failures = 0;
99
+ let timer: { cancel: () => void } | undefined;
100
+ let stopped = false;
101
+ let inFlight: Promise<UpdateOutcome> | undefined;
102
+ const scheduleTimer =
103
+ options.schedule ??
104
+ ((run: () => Promise<unknown> | void, delayMs: number) => {
105
+ const handle = setTimeout(() => void run(), delayMs);
106
+ handle.unref?.();
107
+ return { cancel: () => clearTimeout(handle) };
108
+ });
109
+
110
+ function schedule(delayMs: number) {
111
+ if (stopped) return;
112
+ timer = scheduleTimer(() => {
113
+ timer = undefined;
114
+ return checkNow();
115
+ }, delayMs);
116
+ }
117
+
118
+ function nextDelay(): number {
119
+ return updateRetryDelay(failures, intervalMs, maxBackoffMs);
120
+ }
121
+
122
+ async function runCheck(): Promise<UpdateOutcome> {
123
+ let release: ReleaseInfo;
124
+ try {
125
+ release = await fetchRelease();
126
+ } catch (error) {
127
+ failures += 1;
128
+ const detail = detailOf(error);
129
+ if (isReleaseUnreachable(error) || error instanceof ReleaseFeedError) {
130
+ // An offline host, a missing release client, or a feed that answers
131
+ // with something other than a release description is not a harness
132
+ // defect: the check backs off like any other failure, but the record
133
+ // is context, never a watchdog finding.
134
+ options.journal.write("activity", `release check skipped: ${detail}`);
135
+ } else {
136
+ options.journal.write("update-error", `release check failed: ${detail}`);
137
+ }
138
+ return "failed";
139
+ }
140
+ latestVersion = release.version;
141
+ options.onLatest?.(release.version);
142
+
143
+ try {
144
+ const installed = await installedHarnessVersion(options.prefixDir);
145
+ // The canonical source checkout has no npm prefix; never install into it.
146
+ if (installed === null) return "not-installed";
147
+ if (!isNewerVersion(release.version, installed)) {
148
+ failures = 0;
149
+ return "current";
150
+ }
151
+ const installedVersion = await install(release);
152
+ failures = 0;
153
+ options.journal.write(
154
+ "update",
155
+ `installed ${installedVersion} (was ${installed}); a restart loads it`,
156
+ );
157
+ options.onInstalled?.(installedVersion);
158
+ return "installed";
159
+ } catch (error) {
160
+ failures += 1;
161
+ options.journal.write(
162
+ "update-error",
163
+ `could not install ${release.version}: ${detailOf(error)}`,
164
+ );
165
+ return "failed";
166
+ }
167
+ }
168
+
169
+ function checkNow(): Promise<UpdateOutcome> {
170
+ if (stopped) return Promise.resolve("stopped");
171
+ if (inFlight !== undefined) return inFlight;
172
+ inFlight = runCheck()
173
+ .catch((error: unknown) => {
174
+ failures += 1;
175
+ options.journal.write("update-error", `update check crashed: ${detailOf(error)}`);
176
+ return "failed" as const;
177
+ })
178
+ .finally(() => {
179
+ inFlight = undefined;
180
+ schedule(nextDelay());
181
+ });
182
+ return inFlight;
183
+ }
184
+
185
+ // The first check runs as soon as the event loop turns, so the server binds
186
+ // its port first and an idle process still checks immediately.
187
+ if (options.startImmediately ?? true) schedule(0);
188
+
189
+ return {
190
+ latest: () => latestVersion,
191
+ checkNow,
192
+ stop: () => {
193
+ stopped = true;
194
+ timer?.cancel();
195
+ timer = undefined;
196
+ },
197
+ };
198
+ }