@dsdenes/devboard 0.1.560 → 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,1602 @@
1
+ import { fixtureRecord } from "./fixtures/adr-source";
2
+ import { afterAll, describe, expect, test } from "bun:test";
3
+ import {
4
+ cpSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ readFileSync,
8
+ readdirSync,
9
+ rmSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { join, resolve } from "node:path";
13
+ import type { Server } from "bun";
14
+ import { createApp } from "./app";
15
+ import {
16
+ cleanupRemoteScratch,
17
+ hostFacts,
18
+ remoteScratchRoot,
19
+ type RemoteCloneRequest,
20
+ type RemoteCloneRunner,
21
+ } from "./discover";
22
+ import { readStateFile, stateFile } from "./state";
23
+ import { journalFile } from "./journal";
24
+
25
+ /**
26
+ * Discover's remote target: the source-checkout gate, URL validation, the one
27
+ * `git clone` child per run, retention of the published bytes after a crawl,
28
+ * restore without a clone, switching, removal, and durability. Every child is
29
+ * injected, every fixture lives under the harness `.tmp`, and no test makes a
30
+ * network request.
31
+ */
32
+
33
+ const STUB_AGENT = join(import.meta.dir, "fixtures", "stub-agent.ts");
34
+ const TEST_ROOT = join(import.meta.dir, "..", ".tmp", "discover-remote-tests");
35
+ const DISCOVER_SKILLS = [
36
+ "discover-testing-strategy",
37
+ "discover-naming-conventions",
38
+ "discover-coding-conventions",
39
+ "discover-architecture-boundaries",
40
+ "discover-glossary",
41
+ ];
42
+ const URL_MAX = 2048;
43
+
44
+ mkdirSync(TEST_ROOT, { recursive: true });
45
+
46
+ const cleanup: (() => void)[] = [];
47
+
48
+ afterAll(() => {
49
+ for (const action of cleanup.reverse()) action();
50
+ });
51
+
52
+ function testDirectory(name: string): string {
53
+ const directory = join(
54
+ TEST_ROOT,
55
+ `${name}-${process.pid}-${Math.random().toString(16).slice(2, 8)}`,
56
+ );
57
+ mkdirSync(directory, { recursive: true });
58
+ cleanup.push(() => {
59
+ try {
60
+ rmSync(directory, { recursive: true, force: true });
61
+ } catch {
62
+ // A locked fixture is left for the operator.
63
+ }
64
+ });
65
+ return directory;
66
+ }
67
+
68
+ function gitRun(cwd: string, args: string[]): { code: number; stdout: string; stderr: string } {
69
+ const result = Bun.spawnSync({
70
+ cmd: [
71
+ "git",
72
+ "-C",
73
+ cwd,
74
+ "-c",
75
+ "user.name=Discover Remote Fixture",
76
+ "-c",
77
+ "user.email=fixture@example.test",
78
+ "-c",
79
+ "commit.gpgsign=false",
80
+ "-c",
81
+ "init.defaultBranch=main",
82
+ ...args,
83
+ ],
84
+ stdout: "pipe",
85
+ stderr: "pipe",
86
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "true" },
87
+ });
88
+ return {
89
+ code: result.exitCode ?? -1,
90
+ stdout: result.stdout.toString(),
91
+ stderr: result.stderr.toString(),
92
+ };
93
+ }
94
+
95
+ function git(cwd: string, args: string[]): string {
96
+ const result = gitRun(cwd, args);
97
+ if (result.code !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`);
98
+ return result.stdout;
99
+ }
100
+
101
+ const GITIGNORE = [".devboard/.tmp/", "node_modules/", "state/", "*.log", ""].join("\n");
102
+
103
+ function writeRepository(root: string, name: string): void {
104
+ mkdirSync(join(root, "src"), { recursive: true });
105
+ mkdirSync(join(root, "test"), { recursive: true });
106
+ mkdirSync(join(root, ".github", "workflows"), { recursive: true });
107
+ writeFileSync(join(root, "README.md"), `# ${name}\n\nA fixture host for Discover.\n`);
108
+ writeFileSync(
109
+ join(root, "package.json"),
110
+ `${JSON.stringify({ name, scripts: { build: "bun run build:src", test: "bun test" } }, null, 2)}\n`,
111
+ );
112
+ writeFileSync(
113
+ join(root, "src", "order.ts"),
114
+ "export const orderTotal = (total: number): number => total;\n",
115
+ );
116
+ writeFileSync(
117
+ join(root, "test", "order.test.ts"),
118
+ 'import { test, expect } from "bun:test";\ntest("order total", () => expect(1).toBe(1));\n',
119
+ );
120
+ writeFileSync(
121
+ join(root, ".github", "workflows", "ci.yml"),
122
+ "name: CI\non: [push]\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: bun test\n",
123
+ );
124
+ writeFileSync(join(root, ".gitignore"), GITIGNORE);
125
+ }
126
+
127
+ function commitRepository(root: string, message: string): void {
128
+ git(root, ["init", "-b", "main"]);
129
+ git(root, ["add", "-A"]);
130
+ git(root, ["commit", "-m", message]);
131
+ }
132
+
133
+ type Gate = "source" | "installed" | "none";
134
+
135
+ interface Fixture {
136
+ root: string;
137
+ goalFile: string;
138
+ adrDir: string;
139
+ stateDir: string;
140
+ logPath: string;
141
+ gate: Gate;
142
+ }
143
+
144
+ function createFixture(name: string, gate: Gate = "source"): Fixture {
145
+ const root = testDirectory(name);
146
+ mkdirSync(join(root, "adrs"), { recursive: true });
147
+ writeRepository(root, `fixture-${name}`);
148
+ writeFileSync(join(root, "GOAL.md"), "# Fixture goal\n\nKeep the fixture buildable.\n");
149
+ writeFileSync(join(root, "adrs", "0001-fixture.md"), fixtureRecord("0001-fixture.md", "Fixture decision"));
150
+ if (gate !== "none") {
151
+ mkdirSync(join(root, ".devboard"), { recursive: true });
152
+ writeFileSync(
153
+ join(root, ".devboard", "package.json"),
154
+ `${JSON.stringify({ name: "@dsdenes/devboard", version: "0.0.0" }, null, 2)}\n`,
155
+ );
156
+ if (gate === "installed") {
157
+ const installed = join(root, ".devboard", "node_modules", "@dsdenes", "devboard");
158
+ mkdirSync(installed, { recursive: true });
159
+ writeFileSync(
160
+ join(installed, "package.json"),
161
+ `${JSON.stringify({ name: "@dsdenes/devboard", version: "9.9.9" }, null, 2)}\n`,
162
+ );
163
+ }
164
+ }
165
+ commitRepository(root, "chore: fixture base");
166
+ const stateDir = gate === "none" ? join(root, "state") : join(root, ".devboard", ".tmp", "state");
167
+ return {
168
+ root,
169
+ goalFile: join(root, "GOAL.md"),
170
+ adrDir: join(root, "adrs"),
171
+ stateDir,
172
+ logPath: join(root, "agent.log"),
173
+ gate,
174
+ };
175
+ }
176
+
177
+ /** A repository a test clone copies; it is never the dashboard's host. */
178
+ function createSourceRepository(name: string): string {
179
+ const root = testDirectory(name);
180
+ writeRepository(root, `remote-${name}`);
181
+ writeFileSync(join(root, "src", "catalog.ts"), "export const catalogSize = 3;\n");
182
+ commitRepository(root, "chore: remote source");
183
+ return root;
184
+ }
185
+
186
+ interface CloneBehavior {
187
+ source?: string;
188
+ fail?: { code: number; stderr: string; timedOut: boolean };
189
+ /** Return success without creating the destination. */
190
+ empty?: boolean;
191
+ /** Blocks the clone until released. */
192
+ gate?: { promise: Promise<void>; release: () => void };
193
+ }
194
+
195
+ interface CloneHarness {
196
+ calls: RemoteCloneRequest[];
197
+ runner: RemoteCloneRunner;
198
+ behavior: CloneBehavior;
199
+ }
200
+
201
+ function cloneHarness(behavior: CloneBehavior = {}): CloneHarness {
202
+ const calls: RemoteCloneRequest[] = [];
203
+ const runner: RemoteCloneRunner = async (request) => {
204
+ calls.push(request);
205
+ if (behavior.gate !== undefined) await behavior.gate.promise;
206
+ if (behavior.fail !== undefined) return behavior.fail;
207
+ if (behavior.empty === true) return { code: 0, stderr: "", timedOut: false };
208
+ const source = behavior.source;
209
+ if (source === undefined) throw new Error("clone fixture has no source");
210
+ const destination = join(request.cwd, request.argv[request.argv.length - 1] ?? "");
211
+ cpSync(source, destination, { recursive: true, force: true });
212
+ return { code: 0, stderr: "", timedOut: false };
213
+ };
214
+ return { calls, runner, behavior };
215
+ }
216
+
217
+ interface TestServer {
218
+ server: Server<unknown>;
219
+ fixture: Fixture;
220
+ url: string;
221
+ clones: CloneHarness;
222
+ }
223
+
224
+ interface StartOptions {
225
+ fixture?: Fixture;
226
+ stateDir?: string;
227
+ stub?: string[];
228
+ clone?: CloneHarness;
229
+ }
230
+
231
+ function startServer(options: StartOptions = {}): TestServer {
232
+ const fixture = options.fixture ?? createFixture("discover-remote");
233
+ const stateDir = options.stateDir ?? fixture.stateDir;
234
+ const clones = options.clone ?? cloneHarness();
235
+ const command = `bun ${STUB_AGENT} --log=${fixture.logPath} ${(options.stub ?? ["--discover"]).join(" ")}`;
236
+ const server = createApp({
237
+ adrDir: fixture.adrDir,
238
+ staticDir: join(fixture.root, "static"),
239
+ hostname: "127.0.0.1",
240
+ port: 0,
241
+ repoDir: fixture.root,
242
+ stateDir,
243
+ goalFile: fixture.goalFile,
244
+ agentCommand: command,
245
+ tracker: async () => undefined,
246
+ trackerSettle: { attempts: 1 },
247
+ discoverClone: clones.runner,
248
+ });
249
+ return { server, fixture, url: `127.0.0.1:${server.port}`, clones };
250
+ }
251
+
252
+ interface TestClient {
253
+ socket: WebSocket;
254
+ events: Record<string, unknown>[];
255
+ send(command: unknown): void;
256
+ /** Resolves with the next matching message, ignoring anything buffered. */
257
+ waitNext(
258
+ predicate: (message: Record<string, unknown>) => boolean,
259
+ ): Promise<Record<string, unknown>>;
260
+ /** Resolves with an unconsumed matching message already seen, else the next one. */
261
+ take(predicate: (message: Record<string, unknown>) => boolean): Promise<Record<string, unknown>>;
262
+ close(): void;
263
+ }
264
+
265
+ function connect(url: string): Promise<TestClient> {
266
+ const { promise, resolve: settle, reject } = Promise.withResolvers<TestClient>();
267
+ const socket = new WebSocket(`ws://${url}/ws`);
268
+ const events: Record<string, unknown>[] = [];
269
+ const consumed = new Set<Record<string, unknown>>();
270
+ const waiters: {
271
+ predicate: (message: Record<string, unknown>) => boolean;
272
+ resolve: (message: Record<string, unknown>) => void;
273
+ }[] = [];
274
+
275
+ socket.onopen = () => {
276
+ settle({
277
+ socket,
278
+ events,
279
+ send(command) {
280
+ socket.send(JSON.stringify(command));
281
+ },
282
+ waitNext(predicate) {
283
+ const { promise: waiting, resolve: next } = Promise.withResolvers<Record<string, unknown>>();
284
+ waiters.push({ predicate, resolve: next });
285
+ return waiting;
286
+ },
287
+ take(predicate) {
288
+ const seen = events.findIndex(
289
+ (message) => !consumed.has(message) && predicate(message),
290
+ );
291
+ if (seen >= 0) {
292
+ const message = events[seen] as Record<string, unknown>;
293
+ consumed.add(message);
294
+ return Promise.resolve(message);
295
+ }
296
+ const { promise: waiting, resolve: next } = Promise.withResolvers<Record<string, unknown>>();
297
+ waiters.push({ predicate, resolve: next });
298
+ return waiting;
299
+ },
300
+ close() {
301
+ socket.close();
302
+ },
303
+ });
304
+ };
305
+
306
+ socket.onmessage = (event) => {
307
+ let message: Record<string, unknown>;
308
+ try {
309
+ message = JSON.parse(String(event.data)) as Record<string, unknown>;
310
+ } catch {
311
+ return;
312
+ }
313
+ events.push(message);
314
+ const index = waiters.findIndex((waiter) => waiter.predicate(message));
315
+ if (index >= 0) {
316
+ const waiter = waiters.splice(index, 1)[0];
317
+ if (waiter !== undefined) {
318
+ consumed.add(message);
319
+ waiter.resolve(message);
320
+ }
321
+ }
322
+ };
323
+
324
+ socket.onerror = () => reject(new Error("websocket failed"));
325
+ return promise;
326
+ }
327
+
328
+ function logLines(logPath: string): string[] {
329
+ if (!existsSync(logPath)) return [];
330
+ return readFileSync(logPath, "utf8").split("\n").filter((line) => line.length > 0);
331
+ }
332
+
333
+ function spawnCount(logPath: string): number {
334
+ return logLines(logPath).filter((line) => line.startsWith("spawn ")).length;
335
+ }
336
+
337
+ function prompts(logPath: string): string[] {
338
+ return logLines(logPath)
339
+ .filter((line) => line.startsWith("prompt "))
340
+ .map((line) => line.slice("prompt ".length));
341
+ }
342
+
343
+ function isCloningState(message: Record<string, unknown>): boolean {
344
+ const state = message.state;
345
+ return (
346
+ message.type === "discoverState" &&
347
+ typeof state === "object" &&
348
+ state !== null &&
349
+ "cloning" in state &&
350
+ state.cloning !== null
351
+ );
352
+ }
353
+
354
+ function stateTargetKind(message: Record<string, unknown>, kind: string): boolean {
355
+ const state = message.state;
356
+ if (typeof state !== "object" || state === null) return false;
357
+ const target = "target" in state ? state.target : undefined;
358
+ return (
359
+ typeof target === "object" &&
360
+ target !== null &&
361
+ "kind" in target &&
362
+ target.kind === kind
363
+ );
364
+ }
365
+
366
+ interface DiscoverState {
367
+ active: boolean;
368
+ runId: string | null;
369
+ interrupted: boolean;
370
+ stale: boolean;
371
+ warnings: string[];
372
+ busy: string | null;
373
+ error: string | null;
374
+ remoteAvailable: boolean;
375
+ retained: Array<{ url: string; owner: string; repo: string; completedAt: string }>;
376
+ repository: string | null;
377
+ target: Record<string, unknown>;
378
+ cloning: Record<string, unknown> | null;
379
+ manifest: Record<string, unknown> | null;
380
+ freshness: Record<string, unknown> | null;
381
+ sheet: Record<string, unknown> | null;
382
+ glossaries: Array<Record<string, unknown>>;
383
+ decisions: Array<Record<string, unknown>>;
384
+ skills: Array<Record<string, unknown>>;
385
+ }
386
+
387
+ function stateOf(message: Record<string, unknown>): DiscoverState {
388
+ return message.state as DiscoverState;
389
+ }
390
+
391
+ async function discoverState(client: TestClient): Promise<DiscoverState> {
392
+ const reply = client.waitNext((message) => message.type === "discoverState");
393
+ client.send({ type: "getDiscover" });
394
+ return stateOf(await reply);
395
+ }
396
+
397
+ async function runRemote(client: TestClient, url: string): Promise<DiscoverState> {
398
+ const cloning = client.waitNext(isCloningState);
399
+ const started = client.waitNext((message) => message.type === "discoverStarted");
400
+ const stopped = client.waitNext((message) => message.type === "discoverStopped");
401
+ client.send({ type: "discoverRemote", url });
402
+ const cloningState = stateOf(await cloning);
403
+ expect((cloningState.cloning as Record<string, unknown>).url).toBe(url.trim());
404
+ expect(cloningState.repository).not.toBeNull();
405
+ expect(cloningState.target).toMatchObject({ kind: "remote" });
406
+ await started;
407
+ expect((await stopped).completed).toBe(true);
408
+ return discoverState(client);
409
+ }
410
+
411
+ async function runProject(client: TestClient): Promise<DiscoverState> {
412
+ const started = client.waitNext((message) => message.type === "discoverStarted");
413
+ const stopped = client.waitNext((message) => message.type === "discoverStopped");
414
+ client.send({ type: "discoverProject" });
415
+ await started;
416
+ expect((await stopped).completed).toBe(true);
417
+ return discoverState(client);
418
+ }
419
+
420
+ async function switchRemote(client: TestClient, url: string): Promise<DiscoverState> {
421
+ const reply = client.waitNext((message) => message.type === "discoverState");
422
+ client.send({ type: "switchDiscoverRemote", url });
423
+ return stateOf(await reply);
424
+ }
425
+
426
+ async function removeRemote(client: TestClient, url: string): Promise<DiscoverState> {
427
+ const reply = client.waitNext((message) => message.type === "discoverState");
428
+ client.send({ type: "removeDiscoverRemote", url });
429
+ return stateOf(await reply);
430
+ }
431
+
432
+ async function clearRemote(client: TestClient): Promise<DiscoverState> {
433
+ const reply = client.waitNext((message) => message.type === "discoverState");
434
+ client.send({ type: "clearDiscoverRemote" });
435
+ return stateOf(await reply);
436
+ }
437
+
438
+ async function refusal(client: TestClient, command: unknown): Promise<string> {
439
+ const reply = client.waitNext((message) => message.type === "discoverError");
440
+ client.send(command);
441
+ return String((await reply).message);
442
+ }
443
+
444
+ /** File bytes of one tree, keyed by relative path, for byte-for-byte checks. */
445
+ function treeBytes(root: string): Map<string, string> {
446
+ const files = new Map<string, string>();
447
+ if (!existsSync(root)) return files;
448
+ const walk = (prefix: string) => {
449
+ const directory = prefix.length === 0 ? root : join(root, prefix);
450
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
451
+ const next = prefix.length === 0 ? entry.name : `${prefix}/${entry.name}`;
452
+ if (entry.isDirectory()) walk(next);
453
+ else if (entry.isFile()) files.set(next, readFileSync(join(root, next)).toString("base64"));
454
+ }
455
+ };
456
+ walk("");
457
+ return files;
458
+ }
459
+
460
+ function ompBytes(root: string): Map<string, string> {
461
+ return new Map([
462
+ ...[...treeBytes(join(root, ".omp", "discover"))].map(
463
+ ([path, bytes]) => [`.omp/discover/${path}`, bytes] as const,
464
+ ),
465
+ ...[...treeBytes(join(root, ".omp", "skills"))].map(
466
+ ([path, bytes]) => [`.omp/skills/${path}`, bytes] as const,
467
+ ),
468
+ ]);
469
+ }
470
+
471
+ function scratchEntries(fixture: Fixture, stateDir = fixture.stateDir): string[] {
472
+ const root = remoteScratchRoot(stateDir);
473
+ if (!existsSync(root)) return [];
474
+ return readdirSync(root).sort();
475
+ }
476
+
477
+ function retainedDirectory(fixture: Fixture, owner: string, repo: string): string {
478
+ return join(remoteScratchRoot(fixture.stateDir), `${owner}-${repo}`.toLowerCase());
479
+ }
480
+
481
+ function retainedRecord(
482
+ fixture: Fixture,
483
+ owner: string,
484
+ repo: string,
485
+ ): Record<string, unknown> {
486
+ return JSON.parse(
487
+ readFileSync(join(retainedDirectory(fixture, owner, repo), "project.json"), "utf8"),
488
+ ) as Record<string, unknown>;
489
+ }
490
+
491
+ /** Seeds a retained project without a checkout, for gate and restore fixtures. */
492
+ function seedRetainedProject(
493
+ stateDir: string,
494
+ owner: string,
495
+ repo: string,
496
+ url: string,
497
+ completedAt: string,
498
+ marker: string,
499
+ ): string {
500
+ const directory = join(remoteScratchRoot(stateDir), `${owner}-${repo}`.toLowerCase());
501
+ mkdirSync(join(directory, ".omp", "discover"), { recursive: true });
502
+ mkdirSync(join(directory, ".omp", "skills"), { recursive: true });
503
+ writeFileSync(join(directory, ".omp", "discover", "marker.txt"), marker);
504
+ writeFileSync(join(directory, ".omp", "skills", "marker.txt"), marker);
505
+ writeFileSync(
506
+ join(directory, "project.json"),
507
+ `${JSON.stringify({ url, owner, repo, completedAt }, null, 2)}\n`,
508
+ );
509
+ return directory;
510
+ }
511
+
512
+ function expectNoCheckout(fixture: Fixture, stateDir = fixture.stateDir): void {
513
+ const root = remoteScratchRoot(stateDir);
514
+ for (const entry of scratchEntries(fixture, stateDir)) {
515
+ expect(entry.startsWith(".staging-")).toBe(false);
516
+ expect(existsSync(join(root, entry, ".git"))).toBe(false);
517
+ }
518
+ }
519
+
520
+ function persistedState(fixture: Fixture, stateDir = fixture.stateDir): Record<string, unknown> {
521
+ const loaded = readStateFile(stateFile(stateDir, resolve(fixture.root)));
522
+ if (loaded.kind !== "loaded") throw new Error(`state file is ${loaded.kind}`);
523
+ return loaded.value as Record<string, unknown>;
524
+ }
525
+
526
+ function hostStatus(fixture: Fixture): string {
527
+ return gitRun(fixture.root, ["status", "--porcelain", "--untracked-files=all"]).stdout;
528
+ }
529
+
530
+ function journalText(fixture: Fixture): string {
531
+ const path = journalFile(fixture.stateDir, resolve(fixture.root));
532
+ return existsSync(path) ? readFileSync(path, "utf8") : "";
533
+ }
534
+
535
+ describe("discover remote target", () => {
536
+ test(
537
+ "exposes remote targets and retained projects only for a harness source checkout",
538
+ async () => {
539
+ const cases: Array<{ gate: Gate; available: boolean }> = [
540
+ { gate: "source", available: true },
541
+ { gate: "installed", available: false },
542
+ { gate: "none", available: false },
543
+ ];
544
+ for (const entry of cases) {
545
+ const fixture = createFixture(`gate-${entry.gate}`, entry.gate);
546
+ mkdirSync(fixture.stateDir, { recursive: true });
547
+ const seeded = seedRetainedProject(
548
+ fixture.stateDir,
549
+ "octocat",
550
+ "Hello-World",
551
+ "https://github.com/octocat/Hello-World",
552
+ "2026-01-01T00:00:00.000Z",
553
+ `gate-${entry.gate}`,
554
+ );
555
+ const seededBefore = treeBytes(seeded);
556
+ const clones = cloneHarness({ source: createSourceRepository(`gate-source-${entry.gate}`) });
557
+ const { server, url } = startServer({ fixture, clone: clones });
558
+ const client = await connect(url);
559
+ try {
560
+ const initial = await discoverState(client);
561
+ expect(initial.remoteAvailable).toBe(entry.available);
562
+ expect(initial.cloning).toBeNull();
563
+
564
+ if (entry.available) {
565
+ expect(initial.retained.map((project) => project.url)).toEqual([
566
+ "https://github.com/octocat/Hello-World",
567
+ ]);
568
+ const state = await runRemote(client, "https://github.com/octocat/Hello-World");
569
+ expect(state.target).toMatchObject({
570
+ kind: "remote",
571
+ owner: "octocat",
572
+ repo: "Hello-World",
573
+ checkout: false,
574
+ });
575
+ expect(state.repository).toBe("octocat/Hello-World");
576
+ expect(clones.calls.length).toBe(1);
577
+ continue;
578
+ }
579
+
580
+ expect(initial.repository).toBeNull();
581
+ expect(initial.target).toEqual({ kind: "host" });
582
+ expect(initial.retained).toEqual([]);
583
+ for (const command of [
584
+ { type: "discoverRemote", url: "https://github.com/octocat/Hello-World" },
585
+ { type: "switchDiscoverRemote", url: "https://github.com/octocat/Hello-World" },
586
+ { type: "removeDiscoverRemote", url: "https://github.com/octocat/Hello-World" },
587
+ { type: "clearDiscoverRemote" },
588
+ ]) {
589
+ const message = await refusal(client, command);
590
+ expect(message).toContain("source checkout");
591
+ }
592
+ expect(clones.calls.length).toBe(0);
593
+ expect(treeBytes(seeded)).toEqual(seededBefore);
594
+ expect((await discoverState(client)).target).toEqual({ kind: "host" });
595
+ expect((await discoverState(client)).retained).toEqual([]);
596
+ } finally {
597
+ client.close();
598
+ server.stop(true);
599
+ }
600
+ }
601
+ },
602
+ 90_000,
603
+ );
604
+
605
+ test(
606
+ "accepts the one URL shape and refuses every other shape without spawning",
607
+ async () => {
608
+ const fixture = createFixture("urls");
609
+ const clones = cloneHarness({ source: createSourceRepository("urls-source") });
610
+ const { server, url } = startServer({ fixture, clone: clones });
611
+ const client = await connect(url);
612
+ try {
613
+ for (const accepted of [
614
+ "https://github.com/Octocat/Hello-World",
615
+ "https://github.com/Octocat/Hello-World/",
616
+ "https://github.com/Octocat/Hello-World.git",
617
+ " https://github.com/Octocat/Hello-World ",
618
+ ]) {
619
+ const state = await runRemote(client, accepted);
620
+ expect(state.target).toEqual({
621
+ kind: "remote",
622
+ url: accepted.trim(),
623
+ owner: "Octocat",
624
+ repo: "Hello-World",
625
+ checkout: false,
626
+ });
627
+ }
628
+ const acceptedCount = clones.calls.length;
629
+ expect(acceptedCount).toBe(4);
630
+ // Every accepted spelling names the same lowercased owner/repo.
631
+ expect(scratchEntries(fixture)).toEqual(["octocat-hello-world"]);
632
+ expect(retainedRecord(fixture, "octocat", "hello-world").url).toBe(
633
+ "https://github.com/Octocat/Hello-World",
634
+ );
635
+
636
+ const rejected = [
637
+ "",
638
+ " ",
639
+ "http://github.com/octocat/Hello-World",
640
+ "git@github.com:octocat/Hello-World.git",
641
+ "ssh://github.com/octocat/Hello-World",
642
+ "https://gitlab.com/octocat/Hello-World",
643
+ "https://github.com:443/octocat/Hello-World",
644
+ "https://user@github.com/octocat/Hello-World",
645
+ "https://github.com/octocat/Hello-World?ref=main",
646
+ "https://github.com/octocat/Hello-World#readme",
647
+ "https://github.com//Hello-World",
648
+ "https://github.com/octocat",
649
+ "https://github.com/octocat/Hello-World/extra",
650
+ "https://github.com/octocat/..",
651
+ "https://github.com/../Hello-World",
652
+ "/home/octocat/Hello-World",
653
+ "C:\\octocat\\Hello-World",
654
+ `https://github.com/octocat/${"r".repeat(URL_MAX)}`,
655
+ ];
656
+ for (const value of rejected) {
657
+ const refused = client.waitNext((message) => message.type === "discoverError");
658
+ client.send({ type: "discoverRemote", url: value });
659
+ expect(String((await refused).message)).toContain("Could not clone");
660
+ }
661
+ expect(clones.calls.length).toBe(acceptedCount);
662
+ expect(scratchEntries(fixture)).toEqual(["octocat-hello-world"]);
663
+ expect((await discoverState(client)).target).toMatchObject({ kind: "remote" });
664
+ } finally {
665
+ client.close();
666
+ server.stop(true);
667
+ }
668
+ },
669
+ 90_000,
670
+ );
671
+
672
+ test(
673
+ "refuses a scratch root outside the repository",
674
+ async () => {
675
+ const fixture = createFixture("containment");
676
+ const outside = testDirectory("containment-state");
677
+ const clones = cloneHarness({ source: createSourceRepository("containment-source") });
678
+ const { server, url } = startServer({ fixture, stateDir: outside, clone: clones });
679
+ const client = await connect(url);
680
+ try {
681
+ const message = await refusal(client, {
682
+ type: "discoverRemote",
683
+ url: "https://github.com/octocat/Hello-World",
684
+ });
685
+ expect(message).toContain("STATE_DIR");
686
+ expect(message).toContain("inside the repository");
687
+ expect(clones.calls.length).toBe(0);
688
+ expect(existsSync(remoteScratchRoot(outside))).toBe(false);
689
+ expect((await discoverState(client)).target).toEqual({ kind: "host" });
690
+ } finally {
691
+ client.close();
692
+ server.stop(true);
693
+ }
694
+ },
695
+ 30_000,
696
+ );
697
+
698
+ test(
699
+ "treats a malformed remote payload like an unknown command",
700
+ async () => {
701
+ const fixture = createFixture("malformed");
702
+ const clones = cloneHarness({ source: createSourceRepository("malformed-source") });
703
+ const { server, url } = startServer({ fixture, clone: clones });
704
+ const client = await connect(url);
705
+ try {
706
+ const commands = [
707
+ { type: "discoverRemote" },
708
+ { type: "discoverRemote", url: 5 },
709
+ { type: "switchDiscoverRemote" },
710
+ { type: "switchDiscoverRemote", url: null },
711
+ { type: "removeDiscoverRemote" },
712
+ { type: "removeDiscoverRemote", url: [] },
713
+ { type: "switchDiscoverRemoteUnknown" },
714
+ ];
715
+ for (const command of commands) {
716
+ const reply = client.waitNext((message) => message.type === "error");
717
+ client.send(command);
718
+ expect(String((await reply).message)).toBe("Invalid WebSocket message.");
719
+ }
720
+ expect(clones.calls.length).toBe(0);
721
+ expect((await discoverState(client)).target).toEqual({ kind: "host" });
722
+ expect((await discoverState(client)).retained).toEqual([]);
723
+ } finally {
724
+ client.close();
725
+ server.stop(true);
726
+ }
727
+ },
728
+ 30_000,
729
+ );
730
+
731
+ test(
732
+ "runs exactly one bounded git clone, crawls once, and retains the published bytes",
733
+ async () => {
734
+ const fixture = createFixture("clone");
735
+ const source = createSourceRepository("clone-source");
736
+ const clones = cloneHarness({ source });
737
+ const { server, url } = startServer({ fixture, clone: clones });
738
+ const client = await connect(url);
739
+ const hostOmpBefore = ompBytes(fixture.root);
740
+ const fingerprintBefore = hostFacts(fixture.root).fingerprint;
741
+ const statusBefore = hostStatus(fixture);
742
+ try {
743
+ const state = await runRemote(client, "https://github.com/Octocat/Hello-World.git");
744
+
745
+ expect(clones.calls.length).toBe(1);
746
+ const request = clones.calls[0] as RemoteCloneRequest;
747
+ expect(request.argv.slice(0, 7)).toEqual([
748
+ "git",
749
+ "clone",
750
+ "--depth",
751
+ "1",
752
+ "--single-branch",
753
+ "--no-tags",
754
+ "--",
755
+ ]);
756
+ expect(request.argv[7]).toBe("https://github.com/Octocat/Hello-World.git");
757
+ expect(request.argv[8]).toMatch(/^\.staging-run-/);
758
+ expect(request.cwd).toBe(remoteScratchRoot(fixture.stateDir));
759
+ expect(request.timeoutMs).toBe(300_000);
760
+ expect(request.env.GIT_TERMINAL_PROMPT).toBe("0");
761
+ expect(request.env.GIT_ASKPASS).toBe("true");
762
+ expect(request.env.NO_COLOR).toBe("1");
763
+
764
+ // The checkout is gone; only the retained project remains.
765
+ expect(scratchEntries(fixture)).toEqual(["octocat-hello-world"]);
766
+ expectNoCheckout(fixture);
767
+ const retained = retainedDirectory(fixture, "octocat", "hello-world");
768
+ expect(existsSync(join(retained, ".git"))).toBe(false);
769
+ expect(existsSync(join(retained, ".omp", "discover", "manifest.json"))).toBe(true);
770
+ expect(existsSync(join(retained, ".omp", "discover", "staging"))).toBe(false);
771
+ for (const name of DISCOVER_SKILLS) {
772
+ expect(existsSync(join(retained, ".omp", "skills", name, "SKILL.md"))).toBe(true);
773
+ }
774
+ expect(retainedRecord(fixture, "octocat", "hello-world")).toEqual({
775
+ url: "https://github.com/Octocat/Hello-World.git",
776
+ owner: "Octocat",
777
+ repo: "Hello-World",
778
+ completedAt: expect.any(String),
779
+ });
780
+
781
+ expect(state.target).toEqual({
782
+ kind: "remote",
783
+ url: "https://github.com/Octocat/Hello-World.git",
784
+ owner: "Octocat",
785
+ repo: "Hello-World",
786
+ checkout: false,
787
+ });
788
+ expect(state.cloning).toBeNull();
789
+ expect(state.retained.map((project) => project.url)).toEqual([
790
+ "https://github.com/Octocat/Hello-World.git",
791
+ ]);
792
+ expect(state.sheet).not.toBeNull();
793
+ expect(state.glossaries.length).toBe(1);
794
+ expect(state.skills.map((skill) => skill.name)).toEqual([...DISCOVER_SKILLS]);
795
+
796
+ expect(spawnCount(fixture.logPath)).toBe(1);
797
+ const prompt = prompts(fixture.logPath)[0] ?? "";
798
+ expect(prompt).toMatch(/^\/discover \/.*\/\.staging-run-[a-z0-9-]+\/\.omp\/discover\/staging\/run-[a-z0-9-]+$/);
799
+ expect(existsSync(join(retained, ".omp", "discover", "staging"))).toBe(false);
800
+
801
+ // The dashboard's own host is untouched.
802
+ expect(ompBytes(fixture.root)).toEqual(hostOmpBefore);
803
+ expect(hostFacts(fixture.root).fingerprint).toBe(fingerprintBefore);
804
+ expect(hostStatus(fixture)).toBe(statusBefore);
805
+ expect(existsSync(join(fixture.root, ".omp", "discover"))).toBe(false);
806
+ } finally {
807
+ client.close();
808
+ server.stop(true);
809
+ }
810
+ },
811
+ 60_000,
812
+ );
813
+
814
+ test(
815
+ "keeps the active target when the clone exits, times out, or leaves nothing",
816
+ async () => {
817
+ const cases: Array<{ name: string; behavior: CloneBehavior; expected: string }> = [
818
+ {
819
+ name: "exit",
820
+ behavior: { fail: { code: 128, stderr: "fatal: repository not found", timedOut: false } },
821
+ expected: "status 128",
822
+ },
823
+ {
824
+ name: "timeout",
825
+ behavior: { fail: { code: -1, stderr: "", timedOut: true } },
826
+ expected: "timed out",
827
+ },
828
+ { name: "empty", behavior: { empty: true }, expected: "readable directory" },
829
+ ];
830
+ for (const entry of cases) {
831
+ const fixture = createFixture(`clone-${entry.name}`);
832
+ const clones = cloneHarness(entry.behavior);
833
+ const { server, url } = startServer({ fixture, clone: clones });
834
+ const client = await connect(url);
835
+ try {
836
+ const cloning = client.waitNext(isCloningState);
837
+ const failed = client.waitNext((message) => message.type === "discoverError");
838
+ const cleared = client.waitNext(
839
+ (message) =>
840
+ message.type === "discoverState" &&
841
+ typeof message.state === "object" &&
842
+ message.state !== null &&
843
+ "cloning" in message.state &&
844
+ message.state.cloning === null,
845
+ );
846
+ client.send({ type: "discoverRemote", url: "https://github.com/octocat/Hello-World" });
847
+ await cloning;
848
+ expect(String((await failed).message)).toContain(entry.expected);
849
+ await cleared;
850
+ expect((await discoverState(client)).target).toEqual({ kind: "host" });
851
+ expect((await discoverState(client)).retained).toEqual([]);
852
+ expect(scratchEntries(fixture)).toEqual([]);
853
+ expect(spawnCount(fixture.logPath)).toBe(0);
854
+ } finally {
855
+ client.close();
856
+ server.stop(true);
857
+ }
858
+ }
859
+ },
860
+ 60_000,
861
+ );
862
+
863
+ test(
864
+ "refuses a second clone, a clear, a start, a switch, and a remove while cloning",
865
+ async () => {
866
+ const fixture = createFixture("cloning");
867
+ const clones = cloneHarness({ source: createSourceRepository("cloning-source") });
868
+ const { server, url } = startServer({ fixture, clone: clones });
869
+ const client = await connect(url);
870
+ const gate = Promise.withResolvers<void>();
871
+ try {
872
+ await runRemote(client, "https://github.com/octocat/Hello-World");
873
+ const retainedBefore = treeBytes(retainedDirectory(fixture, "octocat", "hello-world"));
874
+
875
+ clones.behavior.source = createSourceRepository("cloning-source-two");
876
+ clones.behavior.gate = { promise: gate.promise, release: () => gate.resolve() };
877
+ const cloning = client.waitNext(isCloningState);
878
+ const stopped = client.waitNext((message) => message.type === "discoverStopped");
879
+ client.send({ type: "discoverRemote", url: "https://github.com/octocat/Other" });
880
+ const cloningState = stateOf(await cloning);
881
+ expect(cloningState.repository).toBe("octocat/Other");
882
+ expect(cloningState.target).toEqual({
883
+ kind: "remote",
884
+ url: "https://github.com/octocat/Other",
885
+ owner: "octocat",
886
+ repo: "Other",
887
+ checkout: false,
888
+ });
889
+ expect(cloningState.sheet).toBeNull();
890
+
891
+ const refusals = [
892
+ client.waitNext((message) => message.type === "discoverError"),
893
+ client.waitNext((message) => message.type === "discoverError"),
894
+ client.waitNext((message) => message.type === "discoverError"),
895
+ client.waitNext((message) => message.type === "discoverError"),
896
+ client.waitNext((message) => message.type === "discoverError"),
897
+ ];
898
+ client.send({ type: "discoverRemote", url: "https://github.com/octocat/Third" });
899
+ client.send({ type: "clearDiscoverRemote" });
900
+ client.send({ type: "discoverProject" });
901
+ client.send({
902
+ type: "switchDiscoverRemote",
903
+ url: "https://github.com/octocat/Hello-World",
904
+ });
905
+ client.send({
906
+ type: "removeDiscoverRemote",
907
+ url: "https://github.com/octocat/Hello-World",
908
+ });
909
+ await Promise.all(refusals);
910
+ expect(clones.calls.length).toBe(2);
911
+ expect(spawnCount(fixture.logPath)).toBe(1);
912
+ expect(treeBytes(retainedDirectory(fixture, "octocat", "hello-world"))).toEqual(
913
+ retainedBefore,
914
+ );
915
+ expect((await discoverState(client)).target).toMatchObject({
916
+ kind: "remote",
917
+ repo: "Other",
918
+ });
919
+
920
+ gate.resolve();
921
+ expect((await stopped).completed).toBe(true);
922
+ expect(spawnCount(fixture.logPath)).toBe(2);
923
+ expect((await discoverState(client)).retained).toHaveLength(2);
924
+ expectNoCheckout(fixture);
925
+ } finally {
926
+ gate.resolve();
927
+ client.close();
928
+ server.stop(true);
929
+ }
930
+ },
931
+ 90_000,
932
+ );
933
+
934
+ test(
935
+ "switches between retained projects with zero clone invocations and survives a restart",
936
+ async () => {
937
+ const fixture = createFixture("switch");
938
+ const clones = cloneHarness({ source: createSourceRepository("switch-source-one") });
939
+ const first = startServer({ fixture, clone: clones });
940
+ const client = await connect(first.url);
941
+ const hostOmp = ompBytes(fixture.root);
942
+ try {
943
+ await runRemote(client, "https://github.com/octocat/Hello-World");
944
+ clones.behavior.source = createSourceRepository("switch-source-two");
945
+ const second = await runRemote(client, "https://github.com/octocat/Second");
946
+ expect(second.retained.map((project) => project.url)).toEqual([
947
+ "https://github.com/octocat/Second",
948
+ "https://github.com/octocat/Hello-World",
949
+ ]);
950
+ expect(second.target).toMatchObject({ repo: "Second" });
951
+
952
+ const callsBefore = clones.calls.length;
953
+ const spawnsBefore = spawnCount(fixture.logPath);
954
+ const secondBytes = treeBytes(retainedDirectory(fixture, "octocat", "second"));
955
+ const switched = await switchRemote(client, "https://github.com/octocat/Hello-World");
956
+ expect(switched.target).toEqual({
957
+ kind: "remote",
958
+ url: "https://github.com/octocat/Hello-World",
959
+ owner: "octocat",
960
+ repo: "Hello-World",
961
+ checkout: false,
962
+ });
963
+ expect(switched.repository).toBe("octocat/Hello-World");
964
+ expect(switched.sheet).not.toBeNull();
965
+ expect(switched.retained.map((project) => project.url)).toEqual([
966
+ "https://github.com/octocat/Second",
967
+ "https://github.com/octocat/Hello-World",
968
+ ]);
969
+ expect(clones.calls.length).toBe(callsBefore);
970
+ expect(spawnCount(fixture.logPath)).toBe(spawnsBefore);
971
+ expect(treeBytes(retainedDirectory(fixture, "octocat", "second"))).toEqual(secondBytes);
972
+ expect(ompBytes(fixture.root)).toEqual(hostOmp);
973
+ } finally {
974
+ client.close();
975
+ first.server.stop(true);
976
+ }
977
+
978
+ // The chosen project is the active target of a replacement with no clone.
979
+ const guest = cloneHarness();
980
+ const replacement = startServer({ fixture, clone: guest });
981
+ const restored = await connect(replacement.url);
982
+ try {
983
+ const replay = stateOf(await restored.take((message) => message.type === "discoverState"));
984
+ expect(replay.target).toMatchObject({
985
+ kind: "remote",
986
+ repo: "Hello-World",
987
+ checkout: false,
988
+ });
989
+ expect(replay.retained.map((project) => project.url)).toEqual([
990
+ "https://github.com/octocat/Second",
991
+ "https://github.com/octocat/Hello-World",
992
+ ]);
993
+ expect(replay.sheet).not.toBeNull();
994
+ expect(restored.events.some((message) => message.type === "discoverError")).toBe(false);
995
+ expect(guest.calls.length).toBe(0);
996
+ expect(spawnCount(fixture.logPath)).toBe(2);
997
+ expectNoCheckout(fixture);
998
+ } finally {
999
+ restored.close();
1000
+ replacement.server.stop(true);
1001
+ }
1002
+ },
1003
+ 90_000,
1004
+ );
1005
+
1006
+ test(
1007
+ "holds one entry per lowercased owner/repo, newest first, with the latest URL and time",
1008
+ async () => {
1009
+ const fixture = createFixture("dedupe");
1010
+ const clones = cloneHarness({ source: createSourceRepository("dedupe-source") });
1011
+ const { server, url } = startServer({ fixture, clone: clones });
1012
+ const client = await connect(url);
1013
+ try {
1014
+ const first = await runRemote(client, "https://github.com/Octocat/Hello-World");
1015
+ const firstRecord = retainedRecord(fixture, "octocat", "hello-world");
1016
+ const firstBytes = treeBytes(retainedDirectory(fixture, "octocat", "hello-world"));
1017
+ const second = await runRemote(client, "https://github.com/octocat/hello-world/");
1018
+ const secondRecord = retainedRecord(fixture, "octocat", "hello-world");
1019
+ expect(scratchEntries(fixture)).toEqual(["octocat-hello-world"]);
1020
+ expect(second.retained).toHaveLength(1);
1021
+ expect(secondRecord.url).toBe("https://github.com/octocat/hello-world/");
1022
+ expect(Date.parse(String(secondRecord.completedAt))).toBeGreaterThanOrEqual(
1023
+ Date.parse(String(firstRecord.completedAt)),
1024
+ );
1025
+ expect(second.retained[0]?.completedAt).toBe(String(secondRecord.completedAt));
1026
+
1027
+ const third = await runRemote(client, "https://github.com/OCTOCAT/HELLO-WORLD.git");
1028
+ expect(third.retained).toHaveLength(1);
1029
+ expect(third.retained[0]?.url).toBe("https://github.com/OCTOCAT/HELLO-WORLD.git");
1030
+ expect(treeBytes(retainedDirectory(fixture, "octocat", "hello-world")).size).toBe(
1031
+ firstBytes.size,
1032
+ );
1033
+ expect(scratchEntries(fixture)).toEqual(["octocat-hello-world"]);
1034
+
1035
+ clones.behavior.source = createSourceRepository("dedupe-source-other");
1036
+ const fourth = await runRemote(client, "https://github.com/octocat/Other");
1037
+ expect(fourth.retained.map((project) => project.url)).toEqual([
1038
+ "https://github.com/octocat/Other",
1039
+ "https://github.com/OCTOCAT/HELLO-WORLD.git",
1040
+ ]);
1041
+ expect(first.retained).toHaveLength(1);
1042
+ const fifth = await runRemote(client, "https://github.com/Octocat/Hello-World");
1043
+ expect(fifth.retained.map((project) => project.url)).toEqual([
1044
+ "https://github.com/Octocat/Hello-World",
1045
+ "https://github.com/octocat/Other",
1046
+ ]);
1047
+ expect(scratchEntries(fixture)).toHaveLength(2);
1048
+ } finally {
1049
+ client.close();
1050
+ server.stop(true);
1051
+ }
1052
+ },
1053
+ 90_000,
1054
+ );
1055
+
1056
+ test(
1057
+ "re-discovers a retained target with one clone and one crawl, replacing its snapshot",
1058
+ async () => {
1059
+ const fixture = createFixture("rediscover");
1060
+ const clones = cloneHarness({ source: createSourceRepository("rediscover-source") });
1061
+ const first = startServer({ fixture, clone: clones });
1062
+ const client = await connect(first.url);
1063
+ try {
1064
+ await runRemote(client, "https://github.com/octocat/Hello-World");
1065
+ } finally {
1066
+ client.close();
1067
+ first.server.stop(true);
1068
+ }
1069
+ const recordBefore = retainedRecord(fixture, "octocat", "hello-world");
1070
+ expect(spawnCount(fixture.logPath)).toBe(1);
1071
+
1072
+ const next = cloneHarness({ source: createSourceRepository("rediscover-source-two") });
1073
+ const second = startServer({ fixture, clone: next });
1074
+ const restored = await connect(second.url);
1075
+ try {
1076
+ const replay = stateOf(await restored.take((message) => message.type === "discoverState"));
1077
+ expect(replay.target).toMatchObject({ kind: "remote", checkout: false });
1078
+ const state = await runProject(restored);
1079
+ expect(next.calls.length).toBe(1);
1080
+ expect(spawnCount(fixture.logPath)).toBe(2);
1081
+ expect(state.target).toMatchObject({ kind: "remote", checkout: false });
1082
+ expect(state.sheet).not.toBeNull();
1083
+ const recordAfter = retainedRecord(fixture, "octocat", "hello-world");
1084
+ expect(Date.parse(String(recordAfter.completedAt))).toBeGreaterThanOrEqual(
1085
+ Date.parse(String(recordBefore.completedAt)),
1086
+ );
1087
+ expect(scratchEntries(fixture)).toEqual(["octocat-hello-world"]);
1088
+ expectNoCheckout(fixture);
1089
+ expect(ompBytes(fixture.root).size).toBe(0);
1090
+ } finally {
1091
+ restored.close();
1092
+ second.server.stop(true);
1093
+ }
1094
+ },
1095
+ 90_000,
1096
+ );
1097
+
1098
+ test(
1099
+ "a failed crawl and a failed clone leave the retained bytes and entry unchanged",
1100
+ async () => {
1101
+ const fixture = createFixture("failed-crawl");
1102
+ const clones = cloneHarness({ source: createSourceRepository("failed-crawl-source") });
1103
+ const first = startServer({ fixture, clone: clones });
1104
+ const client = await connect(first.url);
1105
+ try {
1106
+ await runRemote(client, "https://github.com/octocat/Hello-World");
1107
+ } finally {
1108
+ client.close();
1109
+ first.server.stop(true);
1110
+ }
1111
+ const retained = retainedDirectory(fixture, "octocat", "hello-world");
1112
+ const published = treeBytes(retained);
1113
+ const recordBefore = retainedRecord(fixture, "octocat", "hello-world");
1114
+
1115
+ const invalid = startServer({
1116
+ fixture,
1117
+ clone: cloneHarness({ source: createSourceRepository("failed-crawl-source-two") }),
1118
+ stub: ["--discover", "--discover-invalid-sheet"],
1119
+ });
1120
+ const failedClient = await connect(invalid.url);
1121
+ try {
1122
+ failedClient.take((message) => message.type === "discoverState");
1123
+ const started = failedClient.waitNext((message) => message.type === "discoverStarted");
1124
+ const failed = failedClient.waitNext((message) => message.type === "discoverError");
1125
+ const stopped = failedClient.waitNext((message) => message.type === "discoverStopped");
1126
+ failedClient.send({ type: "discoverProject" });
1127
+ await started;
1128
+ expect(String((await failed).message)).toContain("Discover failed");
1129
+ expect((await stopped).completed).toBe(false);
1130
+ expect(failedClient.events.filter((event) => event.type === "discoverError")).toHaveLength(1);
1131
+ expect(treeBytes(retained)).toEqual(published);
1132
+ expect(retainedRecord(fixture, "octocat", "hello-world")).toEqual(recordBefore);
1133
+ expect((await discoverState(failedClient)).target).toMatchObject({ kind: "remote" });
1134
+ expectNoCheckout(fixture);
1135
+ } finally {
1136
+ failedClient.close();
1137
+ invalid.server.stop(true);
1138
+ }
1139
+
1140
+ const failingClone = cloneHarness({
1141
+ fail: { code: 128, stderr: "fatal: repository not found", timedOut: false },
1142
+ });
1143
+ const unclonable = startServer({ fixture, clone: failingClone });
1144
+ const cloneClient = await connect(unclonable.url);
1145
+ try {
1146
+ cloneClient.take((message) => message.type === "discoverState");
1147
+ const message = await refusal(cloneClient, { type: "discoverProject" });
1148
+ expect(message).toContain("status 128");
1149
+ expect(failingClone.calls.length).toBe(1);
1150
+ expect(treeBytes(retained)).toEqual(published);
1151
+ expect(retainedRecord(fixture, "octocat", "hello-world")).toEqual(recordBefore);
1152
+ expect((await discoverState(cloneClient)).retained).toHaveLength(1);
1153
+ expectNoCheckout(fixture);
1154
+ } finally {
1155
+ cloneClient.close();
1156
+ unclonable.server.stop(true);
1157
+ }
1158
+ },
1159
+ 120_000,
1160
+ );
1161
+
1162
+ test(
1163
+ "retains bytes while clearing, and removes exactly one project",
1164
+ async () => {
1165
+ const fixture = createFixture("remove");
1166
+ const clones = cloneHarness({ source: createSourceRepository("remove-source-one") });
1167
+ const first = startServer({ fixture, clone: clones });
1168
+ const client = await connect(first.url);
1169
+ const hostOmp = ompBytes(fixture.root);
1170
+ try {
1171
+ await runRemote(client, "https://github.com/octocat/Hello-World");
1172
+ clones.behavior.source = createSourceRepository("remove-source-two");
1173
+ await runRemote(client, "https://github.com/octocat/Second");
1174
+ const helloBytes = treeBytes(retainedDirectory(fixture, "octocat", "hello-world"));
1175
+ const secondBytes = treeBytes(retainedDirectory(fixture, "octocat", "second"));
1176
+ const callsBefore = clones.calls.length;
1177
+
1178
+ const cleared = await clearRemote(client);
1179
+ expect(cleared.target).toEqual({ kind: "host" });
1180
+ expect(cleared.retained).toHaveLength(2);
1181
+ expect(existsSync(retainedDirectory(fixture, "octocat", "hello-world"))).toBe(true);
1182
+ expect(existsSync(retainedDirectory(fixture, "octocat", "second"))).toBe(true);
1183
+ expect(clones.calls.length).toBe(callsBefore);
1184
+ expect(ompBytes(fixture.root)).toEqual(hostOmp);
1185
+
1186
+ // Clear again with no remote target is refused and changes nothing.
1187
+ const emptyClear = await refusal(client, { type: "clearDiscoverRemote" });
1188
+ expect(emptyClear).toContain("No remote Discover target");
1189
+ expect((await discoverState(client)).retained).toHaveLength(2);
1190
+
1191
+ // Removing the inactive project leaves the active one untouched.
1192
+ const switched = await switchRemote(client, "https://github.com/octocat/Second");
1193
+ expect(switched.target).toMatchObject({ repo: "Second" });
1194
+ const afterRemove = await removeRemote(client, "https://github.com/octocat/Hello-World");
1195
+ expect(afterRemove.target).toMatchObject({ repo: "Second" });
1196
+ expect(afterRemove.retained.map((project) => project.url)).toEqual([
1197
+ "https://github.com/octocat/Second",
1198
+ ]);
1199
+ expect(existsSync(retainedDirectory(fixture, "octocat", "hello-world"))).toBe(false);
1200
+ expect(treeBytes(retainedDirectory(fixture, "octocat", "second"))).toEqual(secondBytes);
1201
+ expect(helloBytes.size).toBeGreaterThan(0);
1202
+
1203
+ // An unknown URL is refused and changes nothing.
1204
+ const unknown = await refusal(client, {
1205
+ type: "removeDiscoverRemote",
1206
+ url: "https://github.com/octocat/Unknown",
1207
+ });
1208
+ expect(unknown).toContain("No retained Discover project");
1209
+ expect(scratchEntries(fixture)).toEqual(["octocat-second"]);
1210
+ } finally {
1211
+ client.close();
1212
+ first.server.stop(true);
1213
+ }
1214
+
1215
+ // The removal persists through a restart.
1216
+ const second = startServer({ fixture, clone: cloneHarness() });
1217
+ const restored = await connect(second.url);
1218
+ try {
1219
+ const state = stateOf(await restored.take((message) => message.type === "discoverState"));
1220
+ expect(state.target).toMatchObject({ kind: "remote", repo: "Second" });
1221
+ expect(state.retained.map((project) => project.url)).toEqual([
1222
+ "https://github.com/octocat/Second",
1223
+ ]);
1224
+ expect(restored.events.some((message) => message.type === "discoverError")).toBe(false);
1225
+ // Removing the active project returns to the host target.
1226
+ const removed = await removeRemote(restored, "https://github.com/octocat/Second");
1227
+ expect(removed.target).toEqual({ kind: "host" });
1228
+ expect(removed.retained).toEqual([]);
1229
+ expect(scratchEntries(fixture)).toEqual([]);
1230
+ } finally {
1231
+ restored.close();
1232
+ second.server.stop(true);
1233
+ }
1234
+ },
1235
+ 120_000,
1236
+ );
1237
+
1238
+ test(
1239
+ "refuses switch, remove, and clear during a run without changing anything",
1240
+ async () => {
1241
+ const fixture = createFixture("run-refusal");
1242
+ const clones = cloneHarness({ source: createSourceRepository("run-refusal-source") });
1243
+ const first = startServer({ fixture, clone: clones });
1244
+ const client = await connect(first.url);
1245
+ try {
1246
+ await runRemote(client, "https://github.com/octocat/Hello-World");
1247
+ clones.behavior.source = createSourceRepository("run-refusal-source-two");
1248
+ await runRemote(client, "https://github.com/octocat/Second");
1249
+ } finally {
1250
+ client.close();
1251
+ first.server.stop(true);
1252
+ }
1253
+ const retainedBefore = treeBytes(retainedDirectory(fixture, "octocat", "second"));
1254
+ const helloBefore = treeBytes(retainedDirectory(fixture, "octocat", "hello-world"));
1255
+
1256
+ const running = startServer({
1257
+ fixture,
1258
+ clone: cloneHarness({ source: createSourceRepository("run-refusal-source-three") }),
1259
+ stub: ["--discover", "--discover-no-exit"],
1260
+ });
1261
+ const runningClient = await connect(running.url);
1262
+ try {
1263
+ runningClient.take((message) => message.type === "discoverState");
1264
+ const started = runningClient.waitNext((message) => message.type === "discoverStarted");
1265
+ runningClient.send({ type: "discoverProject" });
1266
+ await started;
1267
+ const during = await discoverState(runningClient);
1268
+ expect(during.active).toBe(true);
1269
+ expect(during.target).toMatchObject({ kind: "remote", checkout: true });
1270
+
1271
+ for (const command of [
1272
+ { type: "switchDiscoverRemote", url: "https://github.com/octocat/Hello-World" },
1273
+ { type: "removeDiscoverRemote", url: "https://github.com/octocat/Hello-World" },
1274
+ { type: "clearDiscoverRemote" },
1275
+ ]) {
1276
+ const message = await refusal(runningClient, command);
1277
+ expect(message).toContain("Discover run is active");
1278
+ }
1279
+ expect((await discoverState(runningClient)).target).toMatchObject({
1280
+ kind: "remote",
1281
+ repo: "Second",
1282
+ });
1283
+ expect(treeBytes(retainedDirectory(fixture, "octocat", "second"))).toEqual(retainedBefore);
1284
+ expect(treeBytes(retainedDirectory(fixture, "octocat", "hello-world"))).toEqual(helloBefore);
1285
+
1286
+ const stopped = runningClient.waitNext((message) => message.type === "discoverStopped");
1287
+ runningClient.send({ type: "stopDiscover" });
1288
+ expect((await stopped).completed).toBe(false);
1289
+ expect((await discoverState(runningClient)).target).toMatchObject({ checkout: false });
1290
+ expectNoCheckout(fixture);
1291
+ } finally {
1292
+ runningClient.close();
1293
+ running.server.stop(true);
1294
+ }
1295
+ },
1296
+ 120_000,
1297
+ );
1298
+
1299
+ test(
1300
+ "falls back to the host when the active retained directory is missing and keeps the rest",
1301
+ async () => {
1302
+ const fixture = createFixture("missing");
1303
+ const clones = cloneHarness({ source: createSourceRepository("missing-source-one") });
1304
+ const first = startServer({ fixture, clone: clones });
1305
+ const client = await connect(first.url);
1306
+ try {
1307
+ await runRemote(client, "https://github.com/octocat/Hello-World");
1308
+ clones.behavior.source = createSourceRepository("missing-source-two");
1309
+ await runRemote(client, "https://github.com/octocat/Second");
1310
+ } finally {
1311
+ client.close();
1312
+ first.server.stop(true);
1313
+ }
1314
+ const helloBefore = treeBytes(retainedDirectory(fixture, "octocat", "hello-world"));
1315
+ rmSync(retainedDirectory(fixture, "octocat", "second"), { recursive: true, force: true });
1316
+
1317
+ const guest = cloneHarness();
1318
+ const second = startServer({ fixture, clone: guest });
1319
+ const restored = await connect(second.url);
1320
+ try {
1321
+ const notice = restored.take((message) => message.type === "discoverError");
1322
+ const message = String((await notice).message);
1323
+ expect(message).toContain("missing");
1324
+ expect(message).toContain("https://github.com/octocat/Second");
1325
+ const state = await discoverState(restored);
1326
+ expect(state.target).toEqual({ kind: "host" });
1327
+ expect(state.retained.map((project) => project.url)).toEqual([
1328
+ "https://github.com/octocat/Hello-World",
1329
+ ]);
1330
+ expect(restored.events.filter((event) => event.type === "discoverError")).toHaveLength(1);
1331
+ expect(guest.calls.length).toBe(0);
1332
+ expect(existsSync(retainedDirectory(fixture, "octocat", "second"))).toBe(false);
1333
+ expect(treeBytes(retainedDirectory(fixture, "octocat", "hello-world"))).toEqual(helloBefore);
1334
+ } finally {
1335
+ restored.close();
1336
+ second.server.stop(true);
1337
+ }
1338
+
1339
+ expect(persistedState(fixture).discoverTarget).toBeNull();
1340
+ expect(journalText(fixture)).toContain("discover-remote-missing");
1341
+ const third = startServer({ fixture, clone: cloneHarness() });
1342
+ const again = await connect(third.url);
1343
+ try {
1344
+ const state = await discoverState(again);
1345
+ expect(state.target).toEqual({ kind: "host" });
1346
+ expect(again.events.filter((event) => event.type === "discoverError")).toHaveLength(0);
1347
+ expect(state.retained).toHaveLength(1);
1348
+ } finally {
1349
+ again.close();
1350
+ third.server.stop(true);
1351
+ }
1352
+ },
1353
+ 120_000,
1354
+ );
1355
+
1356
+ test(
1357
+ "removes leftover staging and old checkouts at startup, keeping retained projects",
1358
+ async () => {
1359
+ const fixture = createFixture("hygiene");
1360
+ const clones = cloneHarness({ source: createSourceRepository("hygiene-source") });
1361
+ const first = startServer({ fixture, clone: clones });
1362
+ const client = await connect(first.url);
1363
+ try {
1364
+ await runRemote(client, "https://github.com/octocat/Hello-World");
1365
+ } finally {
1366
+ client.close();
1367
+ first.server.stop(true);
1368
+ }
1369
+
1370
+ const scratch = remoteScratchRoot(fixture.stateDir);
1371
+ const retained = treeBytes(join(scratch, "octocat-hello-world"));
1372
+ mkdirSync(join(scratch, ".staging-leftover"), { recursive: true });
1373
+ mkdirSync(join(scratch, ".staging-retain-leftover"), { recursive: true });
1374
+ mkdirSync(join(scratch, "octocat-old", ".git"), { recursive: true });
1375
+
1376
+ const second = startServer({ fixture, clone: cloneHarness() });
1377
+ const restored = await connect(second.url);
1378
+ try {
1379
+ expect(scratchEntries(fixture)).toEqual(["octocat-hello-world"]);
1380
+ expect(existsSync(join(scratch, ".staging-leftover"))).toBe(false);
1381
+ expect(existsSync(join(scratch, ".staging-retain-leftover"))).toBe(false);
1382
+ expect(existsSync(join(scratch, "octocat-old"))).toBe(false);
1383
+ expect(treeBytes(join(scratch, "octocat-hello-world"))).toEqual(retained);
1384
+ const state = stateOf(await restored.take((message) => message.type === "discoverState"));
1385
+ expect(state.target).toMatchObject({ kind: "remote", repo: "Hello-World" });
1386
+ } finally {
1387
+ restored.close();
1388
+ second.server.stop(true);
1389
+ }
1390
+ },
1391
+ 60_000,
1392
+ );
1393
+
1394
+ test(
1395
+ "startup hygiene never removes a transient another live dashboard owns",
1396
+ async () => {
1397
+ const fixture = createFixture("hygiene-owner");
1398
+ const scratch = remoteScratchRoot(fixture.stateDir);
1399
+ const owned = join(scratch, ".staging-run-live");
1400
+ const stale = join(scratch, ".staging-run-dead");
1401
+ mkdirSync(join(owned, ".git"), { recursive: true });
1402
+ mkdirSync(join(stale, ".git"), { recursive: true });
1403
+ const claims = join(scratch, ".runs");
1404
+ mkdirSync(claims, { recursive: true });
1405
+
1406
+ // The claim file is the persisted contract between dashboard instances:
1407
+ // a live owner keeps its checkout, a dead one is swept.
1408
+ const live = Bun.spawn({ cmd: ["sleep", "30"], stdout: "ignore", stderr: "ignore" });
1409
+ const dead = Bun.spawn({ cmd: ["true"], stdout: "ignore", stderr: "ignore" });
1410
+ const deadPid = dead.pid;
1411
+ await dead.exited;
1412
+ writeFileSync(
1413
+ join(claims, ".staging-run-live.json"),
1414
+ `${JSON.stringify({ pid: live.pid, claimedAt: new Date().toISOString() })}\n`,
1415
+ );
1416
+ writeFileSync(
1417
+ join(claims, ".staging-run-dead.json"),
1418
+ `${JSON.stringify({ pid: deadPid, claimedAt: new Date().toISOString() })}\n`,
1419
+ );
1420
+
1421
+ try {
1422
+ cleanupRemoteScratch(scratch);
1423
+ expect(existsSync(owned)).toBe(true);
1424
+ expect(existsSync(join(claims, ".staging-run-live.json"))).toBe(true);
1425
+ expect(existsSync(stale)).toBe(false);
1426
+ expect(existsSync(join(claims, ".staging-run-dead.json"))).toBe(false);
1427
+ } finally {
1428
+ live.kill();
1429
+ await live.exited;
1430
+ }
1431
+
1432
+ cleanupRemoteScratch(scratch);
1433
+ expect(existsSync(owned)).toBe(false);
1434
+ expect(existsSync(claims)).toBe(false);
1435
+ },
1436
+ 30_000,
1437
+ );
1438
+
1439
+ test(
1440
+ "a second dashboard starting on the same state directory keeps a running crawl",
1441
+ async () => {
1442
+ const fixture = createFixture("shared-state");
1443
+ const first = startServer({
1444
+ fixture,
1445
+ clone: cloneHarness({ source: createSourceRepository("shared-state-source") }),
1446
+ stub: ["--discover", "--discover-no-exit"],
1447
+ });
1448
+ const client = await connect(first.url);
1449
+ const scratch = remoteScratchRoot(fixture.stateDir);
1450
+ try {
1451
+ const started = client.waitNext((message) => message.type === "discoverStarted");
1452
+ client.send({ type: "discoverRemote", url: "https://github.com/octocat/Hello-World" });
1453
+ await started;
1454
+ const checkoutName = scratchEntries(fixture).find((entry) =>
1455
+ entry.startsWith(".staging-"),
1456
+ );
1457
+ expect(checkoutName).toBeDefined();
1458
+ const checkout = join(scratch, checkoutName as string);
1459
+ expect(existsSync(join(checkout, ".git"))).toBe(true);
1460
+
1461
+ const second = startServer({ fixture, clone: cloneHarness() });
1462
+ const other = await connect(second.url);
1463
+ try {
1464
+ expect(existsSync(checkout)).toBe(true);
1465
+ expect(existsSync(join(checkout, ".git"))).toBe(true);
1466
+ expect(existsSync(join(scratch, ".runs", `${checkoutName as string}.json`))).toBe(true);
1467
+ } finally {
1468
+ other.close();
1469
+ second.server.stop(true);
1470
+ }
1471
+ } finally {
1472
+ client.close();
1473
+ first.server.stop(true);
1474
+ }
1475
+
1476
+ // The dashboard that owned the run removes its checkout and its claim.
1477
+ expect(
1478
+ scratchEntries(fixture).filter((entry) => entry.startsWith(".staging-")),
1479
+ ).toEqual([]);
1480
+ expect(existsSync(join(scratch, ".runs"))).toBe(false);
1481
+ },
1482
+ 60_000,
1483
+ );
1484
+
1485
+ test(
1486
+ "interrupts a remote crawl on restart, removing its checkout and keeping the snapshot",
1487
+ async () => {
1488
+ const fixture = createFixture("interrupted");
1489
+ const clones = cloneHarness({ source: createSourceRepository("interrupted-source") });
1490
+ const first = startServer({ fixture, clone: clones });
1491
+ const client = await connect(first.url);
1492
+ try {
1493
+ await runRemote(client, "https://github.com/octocat/Hello-World");
1494
+ } finally {
1495
+ client.close();
1496
+ first.server.stop(true);
1497
+ }
1498
+ const retained = retainedDirectory(fixture, "octocat", "hello-world");
1499
+ const published = treeBytes(retained);
1500
+
1501
+ const running = startServer({
1502
+ fixture,
1503
+ clone: cloneHarness({ source: createSourceRepository("interrupted-source-two") }),
1504
+ stub: ["--discover", "--discover-no-exit"],
1505
+ });
1506
+ const runningClient = await connect(running.url);
1507
+ try {
1508
+ const started = runningClient.waitNext((message) => message.type === "discoverStarted");
1509
+ runningClient.send({ type: "discoverProject" });
1510
+ await started;
1511
+ expect(persistedState(fixture).discover).not.toBeNull();
1512
+ expect(
1513
+ scratchEntries(fixture).some((entry) => entry.startsWith(".staging-")),
1514
+ ).toBe(true);
1515
+ } finally {
1516
+ runningClient.close();
1517
+ running.server.stop(true);
1518
+ }
1519
+
1520
+ const spawnsBefore = spawnCount(fixture.logPath);
1521
+ const replacement = startServer({ fixture, clone: cloneHarness() });
1522
+ const restored = await connect(replacement.url);
1523
+ try {
1524
+ const notice = restored.take((message) => message.type === "discoverError");
1525
+ expect(String((await notice).message)).toContain("interrupted");
1526
+ const state = await discoverState(restored);
1527
+ expect(state.interrupted).toBe(true);
1528
+ expect(state.active).toBe(false);
1529
+ expect(state.target).toMatchObject({ kind: "remote", checkout: false });
1530
+ expect(state.sheet).not.toBeNull();
1531
+ expect(treeBytes(retained)).toEqual(published);
1532
+ expect(scratchEntries(fixture)).toEqual(["octocat-hello-world"]);
1533
+ expectNoCheckout(fixture);
1534
+ expect(spawnCount(fixture.logPath)).toBe(spawnsBefore);
1535
+ expect(persistedState(fixture).discover).toBeNull();
1536
+ } finally {
1537
+ restored.close();
1538
+ replacement.server.stop(true);
1539
+ }
1540
+ },
1541
+ 120_000,
1542
+ );
1543
+
1544
+ test(
1545
+ "saves skills and glossary decisions into the retained copy without a clone",
1546
+ async () => {
1547
+ const fixture = createFixture("retained-edits");
1548
+ const clones = cloneHarness({ source: createSourceRepository("retained-edits-source") });
1549
+ const { server, url } = startServer({ fixture, clone: clones });
1550
+ const client = await connect(url);
1551
+ const hostOmp = ompBytes(fixture.root);
1552
+ try {
1553
+ const state = await runRemote(client, "https://github.com/octocat/Hello-World");
1554
+ const retained = retainedDirectory(fixture, "octocat", "hello-world");
1555
+ const skill = state.skills.find((entry) => entry.name === "discover-testing-strategy");
1556
+ expect(skill).toBeDefined();
1557
+ const live = String(skill?.live ?? skill?.baseline ?? "");
1558
+ const edited = await discoverStateAndMutation(client, {
1559
+ type: "saveDiscoverSkill",
1560
+ name: "discover-testing-strategy",
1561
+ text: `${live}\n- Retained edit.\n`,
1562
+ });
1563
+ expect(
1564
+ readFileSync(join(retained, ".omp", "skills", "discover-testing-strategy", "SKILL.md"), "utf8"),
1565
+ ).toBe(`${live}\n- Retained edit.\n`);
1566
+ expect(edited.skills.find((entry) => entry.name === "discover-testing-strategy")?.state).toBe(
1567
+ "edited",
1568
+ );
1569
+ expectNoCheckout(fixture);
1570
+
1571
+ const glossary = edited.glossaries[0] as Record<string, unknown>;
1572
+ const concept = (glossary.concepts as Array<Record<string, unknown>>)[0] as Record<string, unknown>;
1573
+ await discoverStateAndMutation(client, {
1574
+ type: "rejectGlossaryTerm",
1575
+ context: glossary.boundedContext,
1576
+ id: concept.id,
1577
+ reason: "The retained fixture rejected this term.",
1578
+ });
1579
+ const decisions = JSON.parse(
1580
+ readFileSync(join(retained, ".omp", "discover", "decisions.json"), "utf8"),
1581
+ ) as Array<Record<string, unknown>>;
1582
+ expect(decisions).toHaveLength(1);
1583
+ expect(decisions[0]).toMatchObject({ action: "reject" });
1584
+ expect(ompBytes(fixture.root)).toEqual(hostOmp);
1585
+ } finally {
1586
+ client.close();
1587
+ server.stop(true);
1588
+ }
1589
+ },
1590
+ 90_000,
1591
+ );
1592
+ });
1593
+
1594
+ /** Sends one mutation and resolves with the first snapshot it broadcasts. */
1595
+ async function discoverStateAndMutation(
1596
+ client: TestClient,
1597
+ command: unknown,
1598
+ ): Promise<DiscoverState> {
1599
+ const reply = client.waitNext((message) => message.type === "discoverState");
1600
+ client.send(command);
1601
+ return stateOf(await reply);
1602
+ }