@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,107 @@
1
+ import {
2
+ configuredGiteaToken,
3
+ giteaBaseUrl,
4
+ } from "../lib/forge";
5
+ import {
6
+ giteaCommentIssue,
7
+ giteaCreateIssue,
8
+ issueNumberFromUrl,
9
+ } from "../lib/gitea";
10
+ import { originForge } from "../issues/repository";
11
+ import {
12
+ commentIssue as commentGithubIssue,
13
+ createIssue as createGithubIssue,
14
+ watchdogBody,
15
+ watchdogComment,
16
+ watchdogTitle,
17
+ type GhRun,
18
+ type IssueInput,
19
+ } from "./github";
20
+
21
+ /**
22
+ * Watchdog issue delivery for the checkout's own forge. Every URL it returns
23
+ * is linked in the cycle history; no issue is ever closed and no repository is
24
+ * named in source. GitHub uses the operator's `gh`; Gitea uses its public API
25
+ * and the configured token.
26
+ */
27
+
28
+ export type DeliveryResult = { ok: true; url: string } | { ok: false; reason: string };
29
+ export type CommentResult = { ok: true } | { ok: false; reason: string };
30
+
31
+ export interface WatchdogDelivery {
32
+ /** `owner/name` that receives the issue, for diagnostics. */
33
+ repository: string;
34
+ create(input: IssueInput): Promise<DeliveryResult>;
35
+ comment(url: string, input: IssueInput): Promise<CommentResult>;
36
+ }
37
+
38
+ export interface DeliveryOptions {
39
+ root: string;
40
+ environment?: Record<string, string | undefined>;
41
+ /** The GitHub command; ignored for a Gitea origin. */
42
+ gh: GhRun;
43
+ /** Injectable Gitea transport. */
44
+ fetchImpl?: typeof fetch;
45
+ }
46
+
47
+ function firstLine(error: unknown): string {
48
+ const text = error instanceof Error ? error.message : String(error);
49
+ return (
50
+ text
51
+ .split("\n")
52
+ .map((line) => line.trim())
53
+ .find((line) => line.length > 0) ?? "no diagnostic"
54
+ );
55
+ }
56
+
57
+ /** The delivery for the checkout's origin, or an explicit refusal. */
58
+ export function createWatchdogDelivery(options: DeliveryOptions): WatchdogDelivery {
59
+ const environment = options.environment ?? process.env;
60
+ const remote = originForge(options.root, environment);
61
+ if (remote === undefined) {
62
+ throw new Error("the checkout's origin does not name a supported repository");
63
+ }
64
+ if (remote.forge === "gitea") {
65
+ const baseUrl = giteaBaseUrl(remote, environment);
66
+ if (baseUrl === undefined || baseUrl.length === 0) {
67
+ throw new Error("the watchdog needs DEVBOARD_GITEA_URL for a Gitea origin");
68
+ }
69
+ const token = configuredGiteaToken(environment);
70
+ if (token === undefined) throw new Error("the watchdog needs GITEA_TOKEN for a Gitea origin");
71
+ const target = { baseUrl, project: remote.project, token };
72
+ return {
73
+ repository: remote.project,
74
+ async create(input) {
75
+ try {
76
+ const issue = await giteaCreateIssue(
77
+ target,
78
+ { title: watchdogTitle(input.category, input.line), body: watchdogBody(input) },
79
+ options.fetchImpl,
80
+ );
81
+ return { ok: true, url: issue.url };
82
+ } catch (error) {
83
+ return { ok: false, reason: firstLine(error) };
84
+ }
85
+ },
86
+ async comment(url, input) {
87
+ const number = issueNumberFromUrl(url);
88
+ if (number === undefined) return { ok: false, reason: "the issue URL has no number" };
89
+ try {
90
+ await giteaCommentIssue(target, number, watchdogComment(input), options.fetchImpl);
91
+ return { ok: true };
92
+ } catch (error) {
93
+ return { ok: false, reason: firstLine(error) };
94
+ }
95
+ },
96
+ };
97
+ }
98
+ return {
99
+ repository: remote.project,
100
+ async create(input) {
101
+ return createGithubIssue(options.gh, remote.project, input);
102
+ },
103
+ async comment(url, input) {
104
+ return commentGithubIssue(options.gh, url, input);
105
+ },
106
+ };
107
+ }
@@ -0,0 +1,209 @@
1
+ import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { journalFile } from "../server/journal";
4
+
5
+ /**
6
+ * Instance discovery is deliberately independent of `scripts/publish.ts`: it
7
+ * sees every dashboard on the machine, whichever harness checkout serves it,
8
+ * and it never depends on a dashboard's state file.
9
+ */
10
+
11
+ export interface ProcessSnapshot {
12
+ pid: number;
13
+ cmdline: string;
14
+ env: Map<string, string>;
15
+ cwd?: string;
16
+ /** Process ids from the parent upward, nearest first. */
17
+ ancestors: number[];
18
+ }
19
+
20
+ export interface Instance {
21
+ pid: number;
22
+ port: number;
23
+ repoDir: string;
24
+ host: string;
25
+ /** Harness directory that serves this dashboard. */
26
+ harness: string;
27
+ /** The dashboard's own state directory, from `STATE_DIR`. */
28
+ stateDir: string;
29
+ journal: string;
30
+ /** False when the serving harness never wrote a journal for this project. */
31
+ instrumented: boolean;
32
+ /** Legacy `.tmp/devboard-<cksum>.log` the starter script wrote, when present. */
33
+ legacyLog?: string;
34
+ }
35
+
36
+ /**
37
+ * POSIX `cksum` (CRC-32 with the length trailer) as the legacy starter script
38
+ * computed it for the old log name. The checksum is only a file name.
39
+ */
40
+ export function posixCksum(data: Buffer): number {
41
+ let crc = 0;
42
+ for (const byte of data) {
43
+ crc ^= byte << 24;
44
+ for (let bit = 0; bit < 8; bit += 1) {
45
+ crc = (crc & 0x80000000) !== 0 ? (crc << 1) ^ 0x04c11db7 : crc << 1;
46
+ crc >>>= 0;
47
+ }
48
+ }
49
+ for (let length = data.length; length > 0; length = Math.floor(length / 256)) {
50
+ crc ^= (length & 0xff) << 24;
51
+ for (let bit = 0; bit < 8; bit += 1) {
52
+ crc = (crc & 0x80000000) !== 0 ? (crc << 1) ^ 0x04c11db7 : crc << 1;
53
+ crc >>>= 0;
54
+ }
55
+ }
56
+ return ~crc >>> 0;
57
+ }
58
+
59
+ export function legacyLogPath(harness: string, repoDir: string): string {
60
+ return join(harness, ".tmp", `devboard-${posixCksum(Buffer.from(repoDir))}.log`);
61
+ }
62
+
63
+ function real(path: string | undefined): string | undefined {
64
+ if (path === undefined || path.length === 0) return undefined;
65
+ try {
66
+ return realpathSync(path);
67
+ } catch {
68
+ return undefined;
69
+ }
70
+ }
71
+
72
+ /** Resolves the harness that serves an instance from its STATIC_DIR or cwd. */
73
+ export function servingHarness(process: ProcessSnapshot): string | undefined {
74
+ const staticDir = process.env.get("STATIC_DIR");
75
+ if (staticDir !== undefined) {
76
+ const harness = real(dirname(staticDir));
77
+ if (harness !== undefined) return harness;
78
+ }
79
+ return real(process.cwd);
80
+ }
81
+
82
+ /**
83
+ * Builds instances from a process table. Excludes the watchdog and its
84
+ * descendants, de-duplicates by `(REPO_DIR, PORT)`, and resolves the journal
85
+ * and legacy log through the serving harness.
86
+ */
87
+ export function instancesFromProcesses(
88
+ processes: ProcessSnapshot[],
89
+ options: { selfPids: Set<number> },
90
+ ): Instance[] {
91
+ const ordered = [...processes].sort((left, right) => left.pid - right.pid);
92
+ const seen = new Set<string>();
93
+ const instances: Instance[] = [];
94
+ for (const process of ordered) {
95
+ if (options.selfPids.has(process.pid)) continue;
96
+ if (process.ancestors.some((pid) => options.selfPids.has(pid))) continue;
97
+ if (!process.cmdline.includes("server/index.ts")) continue;
98
+ const repoDir = process.env.get("REPO_DIR");
99
+ const port = Number(process.env.get("PORT"));
100
+ if (repoDir === undefined || !Number.isInteger(port) || port <= 0) continue;
101
+ const harness = servingHarness(process);
102
+ if (harness === undefined) continue;
103
+ const key = `${repoDir}\0${port}`;
104
+ if (seen.has(key)) continue;
105
+ seen.add(key);
106
+ // An installed dashboard keeps its journal beside its npm prefix, named by
107
+ // STATE_DIR; the serving package's own `.tmp` is read-only to it.
108
+ const stateDir = process.env.get("STATE_DIR") ?? join(harness, ".tmp");
109
+ const journal = journalFile(stateDir, repoDir);
110
+ const legacy = legacyLogPath(harness, repoDir);
111
+ instances.push({
112
+ pid: process.pid,
113
+ port,
114
+ repoDir,
115
+ host: process.env.get("HOST") ?? "127.0.0.1",
116
+ harness,
117
+ stateDir,
118
+ journal,
119
+ instrumented: existsSync(join(stateDir, "journal")),
120
+ ...(existsSync(legacy) ? { legacyLog: legacy } : {}),
121
+ });
122
+ }
123
+ return instances;
124
+ }
125
+
126
+ function readStatus(pid: number): { ppid?: number } {
127
+ try {
128
+ for (const line of readFileSync(`/proc/${pid}/status`, "utf8").split("\n")) {
129
+ if (line.startsWith("PPid:")) return { ppid: Number(line.slice(5).trim()) };
130
+ }
131
+ } catch {
132
+ // The process exited.
133
+ }
134
+ return {};
135
+ }
136
+
137
+ /** The parent chain of a pid, nearest first, stopping at pid 1 or a cycle. */
138
+ export function ancestry(pid: number, limit = 64): number[] {
139
+ const chain: number[] = [];
140
+ const seen = new Set<number>([pid]);
141
+ let current = readStatus(pid).ppid;
142
+ while (current !== undefined && current > 1 && !seen.has(current) && chain.length < limit) {
143
+ chain.push(current);
144
+ seen.add(current);
145
+ current = readStatus(current).ppid;
146
+ }
147
+ return chain;
148
+ }
149
+
150
+ export function processSnapshot(pid: number): ProcessSnapshot | undefined {
151
+ let cmdline: string;
152
+ let env = new Map<string, string>();
153
+ try {
154
+ cmdline = readFileSync(`/proc/${pid}/cmdline`, "utf8").replace(/\0/g, " ");
155
+ } catch {
156
+ return undefined;
157
+ }
158
+ try {
159
+ for (const entry of readFileSync(`/proc/${pid}/environ`, "utf8").split("\0")) {
160
+ const split = entry.indexOf("=");
161
+ if (split > 0) env.set(entry.slice(0, split), entry.slice(split + 1));
162
+ }
163
+ } catch {
164
+ env = new Map();
165
+ }
166
+ let cwd: string | undefined;
167
+ try {
168
+ cwd = realpathSync(`/proc/${pid}/cwd`);
169
+ } catch {
170
+ cwd = undefined;
171
+ }
172
+ return { pid, cmdline, env, cwd, ancestors: ancestry(pid) };
173
+ }
174
+
175
+ /** Every process on the machine, read from `/proc`. */
176
+ export function processTable(): ProcessSnapshot[] {
177
+ const processes: ProcessSnapshot[] = [];
178
+ let entries: string[];
179
+ try {
180
+ entries = readdirSync("/proc");
181
+ } catch {
182
+ return processes;
183
+ }
184
+ for (const entry of entries) {
185
+ if (!/^[0-9]+$/.test(entry)) continue;
186
+ const snapshot = processSnapshot(Number(entry));
187
+ if (snapshot !== undefined) processes.push(snapshot);
188
+ }
189
+ return processes;
190
+ }
191
+
192
+ export interface DiscoveryOptions {
193
+ /** Pids that are the watchdog itself and its descendants. */
194
+ selfPids?: Set<number>;
195
+ /** Injectable process table for tests. */
196
+ processes?: ProcessSnapshot[];
197
+ }
198
+
199
+ export function discoverInstances(options: DiscoveryOptions = {}): Instance[] {
200
+ const selfPids = options.selfPids ?? new Set([process.pid]);
201
+ const processes = options.processes ?? processTable();
202
+ return instancesFromProcesses(processes, { selfPids });
203
+ }
204
+
205
+ /** True when the pid, or any ancestor, belongs to the watchdog. */
206
+ export function isOwnProcess(pid: number, selfPids: Set<number>): boolean {
207
+ if (selfPids.has(pid)) return true;
208
+ return ancestry(pid).some((ancestor) => selfPids.has(ancestor));
209
+ }
@@ -0,0 +1,211 @@
1
+ import { createHash } from "node:crypto";
2
+ import { resolve } from "node:path";
3
+ import {
4
+ normalizeEvidenceLine,
5
+ parseTimestamp,
6
+ type EvidenceEvent,
7
+ type Window,
8
+ } from "./collect";
9
+ import type { Instance } from "./discovery";
10
+
11
+ /**
12
+ * Findings are machine-checkable signals, never prose. A category names the
13
+ * evidence kind that produced it, so one text produces one fingerprint no
14
+ * matter which dashboard or which checkout observed it.
15
+ */
16
+ export const FINDING_CATEGORIES = [
17
+ "server-error",
18
+ "update-error",
19
+ "acp-stderr",
20
+ "lane-failed",
21
+ "omp-error",
22
+ "omp-warn",
23
+ "omp-fatal",
24
+ "stall",
25
+ "question-loop",
26
+ ] as const;
27
+
28
+ export type FindingCategory = (typeof FINDING_CATEGORIES)[number];
29
+
30
+ /** Journal kinds that are a finding on their own. */
31
+ const SIGNAL_KINDS: Record<string, FindingCategory> = {
32
+ "server-error": "server-error",
33
+ "update-error": "update-error",
34
+ "acp-stderr": "acp-stderr",
35
+ };
36
+
37
+ /** A start this long before window end with no matching stop is a stall. */
38
+ export const STALL_MS = 5 * 60 * 1000;
39
+
40
+ /** This many questions for one instance in one window is a loop. */
41
+ export const QUESTION_LOOP_COUNT = 3;
42
+
43
+ export interface Finding {
44
+ category: FindingCategory;
45
+ /** The normalized evidence line, truncated for a report body. */
46
+ quote: string;
47
+ /** Earliest event timestamp the finding was derived from, UTC ISO 8601. */
48
+ ts: string;
49
+ /** Every dashboard that contributed the same signal. */
50
+ instances: Instance[];
51
+ /** Stable identity across cycles and hosts: category and normalized line. */
52
+ fingerprint: string;
53
+ }
54
+
55
+ /** Source paths are reported relative to the repository when they live inside it. */
56
+ export function normalizeSource(root: string, path: string): string {
57
+ const resolved = resolve(path);
58
+ const prefix = `${resolve(root)}/`;
59
+ return resolved.startsWith(prefix) ? resolved.slice(prefix.length) : resolved;
60
+ }
61
+
62
+ /**
63
+ * `sha256(category + "\n" + normalized line)`, first 32 hex characters. The
64
+ * source path is deliberately absent: the same error on two dashboards is one
65
+ * fingerprint, and so is the same error after a dashboard moves.
66
+ */
67
+ export function findingFingerprint(category: string, line: string): string {
68
+ return createHash("sha256")
69
+ .update(`${category}\n${normalizeEvidenceLine(line)}`)
70
+ .digest("hex")
71
+ .slice(0, 32);
72
+ }
73
+
74
+ /** The last whitespace-separated token of a `lane-stop` line is its status. */
75
+ export function laneStatus(text: string): string | undefined {
76
+ const tokens = text.trim().split(/\s+/);
77
+ return tokens.length < 2 ? undefined : tokens[tokens.length - 1];
78
+ }
79
+
80
+ function eventKey(event: EvidenceEvent): string {
81
+ return event.instance === undefined
82
+ ? event.source
83
+ : `${event.instance.repoDir}:${event.instance.port}`;
84
+ }
85
+
86
+ /**
87
+ * Questions belong to one run, lane, or peer when the journal recorded a
88
+ * scope. Separate work streams on one dashboard are separate conversations,
89
+ * never one loop.
90
+ */
91
+ function questionKey(event: EvidenceEvent): string {
92
+ return event.scope === undefined ? eventKey(event) : `${eventKey(event)}\n${event.scope}`;
93
+ }
94
+
95
+ /**
96
+ * A start is finished only by a stop of the same work stream on the same
97
+ * dashboard. Lane records begin with the lane id; run records begin with the
98
+ * run file before the `file: title` separator. A lane and a run never share a
99
+ * stream, so a `run-stop` cannot end a lane and a sibling lane's stop cannot
100
+ * end another lane's stall.
101
+ */
102
+ function streamKey(event: EvidenceEvent): string {
103
+ const text = normalizeEvidenceLine(event.text);
104
+ if (event.kind.startsWith("lane-")) {
105
+ const lane = text.split(/\s+/, 1)[0] ?? "";
106
+ return `${eventKey(event)}\nlane\n${lane}`;
107
+ }
108
+ const separator = text.indexOf(":");
109
+ const file = separator === -1 ? "" : text.slice(0, separator).trim();
110
+ return `${eventKey(event)}\nrun\n${file}`;
111
+ }
112
+
113
+ /**
114
+ * Turns a window of evidence into one finding per distinct signal. Only the
115
+ * evidence kinds in `FINDING_CATEGORIES` can produce one: activity, lifecycle,
116
+ * update, run and lane records, and questions below the loop threshold are
117
+ * context, never findings.
118
+ */
119
+ export function analyzeEvidence(
120
+ events: EvidenceEvent[],
121
+ options: { window: Window },
122
+ ): Finding[] {
123
+ const findings = new Map<string, Finding>();
124
+ const questions = new Map<string, EvidenceEvent[]>();
125
+ const starts = new Map<string, EvidenceEvent[]>();
126
+ const stops = new Map<string, number[]>();
127
+
128
+ const add = (category: FindingCategory, event: EvidenceEvent) => {
129
+ const quote = normalizeEvidenceLine(event.text);
130
+ if (quote.length === 0) return;
131
+ const fingerprint = findingFingerprint(category, quote);
132
+ const existing = findings.get(fingerprint);
133
+ if (existing !== undefined) {
134
+ if (event.ts < existing.ts) existing.ts = event.ts;
135
+ if (
136
+ event.instance !== undefined &&
137
+ !existing.instances.some(
138
+ (instance) =>
139
+ instance.repoDir === event.instance?.repoDir && instance.port === event.instance.port,
140
+ )
141
+ ) {
142
+ existing.instances.push(event.instance);
143
+ }
144
+ return;
145
+ }
146
+ findings.set(fingerprint, {
147
+ category,
148
+ quote: quote.slice(0, 300),
149
+ ts: event.ts,
150
+ instances: event.instance === undefined ? [] : [event.instance],
151
+ fingerprint,
152
+ });
153
+ };
154
+
155
+ for (const event of events) {
156
+ switch (event.kind) {
157
+ case "question":
158
+ questions.set(questionKey(event), [...(questions.get(questionKey(event)) ?? []), event]);
159
+ continue;
160
+ case "run-start":
161
+ case "lane-start":
162
+ starts.set(streamKey(event), [...(starts.get(streamKey(event)) ?? []), event]);
163
+ continue;
164
+ case "run-stop":
165
+ case "lane-stop": {
166
+ const parsed = parseTimestamp(event.ts);
167
+ stops.set(streamKey(event), [
168
+ ...(stops.get(streamKey(event)) ?? []),
169
+ ...(parsed === undefined ? [] : [parsed]),
170
+ ]);
171
+ // A stop is only a stall terminator; a failed lane is a finding.
172
+ if (event.kind === "lane-stop" && laneStatus(event.text) === "failed") {
173
+ add("lane-failed", event);
174
+ }
175
+ continue;
176
+ }
177
+ default:
178
+ break;
179
+ }
180
+ if (event.kind === "omp-log" && event.level !== undefined) {
181
+ add(`omp-${event.level}` as FindingCategory, event);
182
+ continue;
183
+ }
184
+ const category = SIGNAL_KINDS[event.kind];
185
+ if (category !== undefined) add(category, event);
186
+ }
187
+
188
+ for (const list of questions.values()) {
189
+ if (list.length < QUESTION_LOOP_COUNT) continue;
190
+ const last = list[list.length - 1];
191
+ if (last !== undefined) add("question-loop", last);
192
+ }
193
+
194
+ const stallCutoff = options.window.end.getTime() - STALL_MS;
195
+ for (const [key, list] of starts) {
196
+ const finished = stops.get(key) ?? [];
197
+ for (const event of list) {
198
+ const started = parseTimestamp(event.ts);
199
+ if (started === undefined || started > stallCutoff) continue;
200
+ if (finished.some((stopped) => stopped >= started)) continue;
201
+ add("stall", event);
202
+ }
203
+ }
204
+
205
+ return [...findings.values()].sort(
206
+ (left, right) =>
207
+ left.ts.localeCompare(right.ts) ||
208
+ left.category.localeCompare(right.category) ||
209
+ left.fingerprint.localeCompare(right.fingerprint),
210
+ );
211
+ }