@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.
- package/AGENTS.md +59 -0
- package/README.md +2497 -26
- package/bin/devboard.mjs +352 -118
- package/client/App.svelte +523 -0
- package/client/FeedbackOverlay.svelte +1125 -0
- package/client/FlowMode.svelte +1015 -0
- package/client/app.css +3922 -0
- package/client/env.d.ts +23 -0
- package/client/lib/commands.ts +836 -0
- package/client/lib/components/ActivityBar.svelte +295 -0
- package/client/lib/components/AdrTable.svelte +241 -0
- package/client/lib/components/CommandPalette.svelte +198 -0
- package/client/lib/components/ConceptKind.svelte +66 -0
- package/client/lib/components/ConfirmDialog.svelte +62 -0
- package/client/lib/components/DataTable.svelte +243 -0
- package/client/lib/components/DetailSplit.svelte +68 -0
- package/client/lib/components/DiscoverOverview.svelte +898 -0
- package/client/lib/components/DiscoverWorkspace.svelte +437 -0
- package/client/lib/components/DraftsPage.svelte +186 -0
- package/client/lib/components/GoalWorkspace.svelte +1042 -0
- package/client/lib/components/IssuesAgentFocus.svelte +117 -0
- package/client/lib/components/IssuesAgentLog.svelte +50 -0
- package/client/lib/components/IssuesQuestion.svelte +95 -0
- package/client/lib/components/IssuesWorkspace.svelte +577 -0
- package/client/lib/components/MermaidDiagram.svelte +89 -0
- package/client/lib/components/MonacoEditor.svelte +190 -0
- package/client/lib/components/NotificationRegion.svelte +195 -0
- package/client/lib/components/PaneHandle.svelte +25 -0
- package/client/lib/components/ScoutIssues.svelte +215 -0
- package/client/lib/components/ScoutWorkspace.svelte +180 -0
- package/client/lib/components/Sidebar.svelte +622 -0
- package/client/lib/components/StatusBar.svelte +118 -0
- package/client/lib/components/Terminal.svelte +193 -0
- package/client/lib/components/WatchdogWorkspace.svelte +537 -0
- package/client/lib/components/ui/button.svelte +56 -0
- package/client/lib/components/ui/icon-button.svelte +56 -0
- package/client/lib/components/ui/input.svelte +19 -0
- package/client/lib/components/ui/page-header.svelte +120 -0
- package/client/lib/components/ui/stage-track.svelte +141 -0
- package/client/lib/components/watchdog-indicator.svelte +189 -0
- package/client/lib/connection.svelte.ts +210 -0
- package/client/lib/dependency-diagram.test.ts +90 -0
- package/client/lib/dependency-diagram.ts +75 -0
- package/client/lib/discover-format.test.ts +265 -0
- package/client/lib/discover-format.ts +394 -0
- package/client/lib/discover.svelte.ts +412 -0
- package/client/lib/draft-format.test.ts +48 -0
- package/client/lib/draft-format.ts +13 -0
- package/client/lib/flow-save.test.ts +148 -0
- package/client/lib/flow.svelte.ts +445 -0
- package/client/lib/goal-reconnect.test.ts +282 -0
- package/client/lib/goal.svelte.ts +652 -0
- package/client/lib/issues.svelte.ts +439 -0
- package/client/lib/layout.svelte.ts +234 -0
- package/client/lib/notification-issue.test.ts +53 -0
- package/client/lib/notification-issue.ts +27 -0
- package/client/lib/notifications.svelte.ts +62 -0
- package/client/lib/poll-status.test.ts +141 -0
- package/client/lib/poll-status.ts +88 -0
- package/client/lib/record-status.test.ts +19 -0
- package/client/lib/record-status.ts +25 -0
- package/client/lib/scout.svelte.ts +164 -0
- package/client/lib/skill-document.test.ts +77 -0
- package/client/lib/skill-document.ts +57 -0
- package/client/lib/status-tone.test.ts +40 -0
- package/client/lib/status-tone.ts +33 -0
- package/client/lib/types.ts +578 -0
- package/client/lib/utils.ts +6 -0
- package/client/lib/viewport.svelte.ts +20 -0
- package/client/lib/watchdog.svelte.ts +69 -0
- package/client/main.ts +7 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/abnfDiagram-VCTEODGH-CmBSMArZ.js +1 -0
- package/dist/assets/arc-BZgCSX2H.js +1 -0
- package/dist/assets/architecture-7GRP2DOG-CF9MHlfo.js +1 -0
- package/dist/assets/architectureDiagram-5GKGNRK7-DzWB6xiQ.js +36 -0
- package/dist/assets/array-BifhSqXX.js +1 -0
- package/dist/assets/blockDiagram-I7D4REHJ-BtmGJ0hn.js +129 -0
- package/dist/assets/c4Diagram-7LVT6UL2-D5CHrrRX.js +38 -0
- package/dist/assets/channel-CgM4lcUW.js +1 -0
- package/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/dist/assets/chunk-4HAMMTFA-DeIufjFl.js +62 -0
- package/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/dist/assets/chunk-75Z2AOVW-DbtzyKUg.js +2 -0
- package/dist/assets/chunk-DU6HZSFF-C0fdbx3K.js +127 -0
- package/dist/assets/chunk-F27PBJKO-HObL2IvF.js +1 -0
- package/dist/assets/chunk-FOHPRMQF-DD4dKubD.js +161 -0
- package/dist/assets/chunk-GMAD6QVW-fg8wvtPY.js +72 -0
- package/dist/assets/chunk-GVQU2GXP-C0TaHOrD.js +1 -0
- package/dist/assets/chunk-IMKFNOWR-BVoIdx20.js +231 -0
- package/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/dist/assets/chunk-L3NEJ4N5-B_kmqfk5.js +1 -0
- package/dist/assets/chunk-OSK3NFVY-DO0vhxHH.js +10 -0
- package/dist/assets/chunk-P2QGCYS3-CjljLvFj.js +1 -0
- package/dist/assets/chunk-POPQ4Y6H-BU0C7tw6.js +1 -0
- package/dist/assets/chunk-PWAF6VOD-Dn0OjBt0.js +1 -0
- package/dist/assets/chunk-SHT3W25Y-CMCZE2xx.js +168 -0
- package/dist/assets/chunk-SVP7TREG-BIRmRy_W.js +88 -0
- package/dist/assets/chunk-TICWLB2K-C1bc2CDE.js +206 -0
- package/dist/assets/chunk-XXDRQBXY-Cc38QUmR.js +1 -0
- package/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/dist/assets/classDiagram-ZZMXUADV-BDh5w6Nc.js +1 -0
- package/dist/assets/classDiagram-v2-VYDZK3BY-BDh5w6Nc.js +1 -0
- package/dist/assets/cose-bilkent-JH36ORCC-B7pU7qhM.js +1 -0
- package/dist/assets/cynefin-OW5HDTMX-76dswSqS.js +1 -0
- package/dist/assets/cynefinDiagram-5FMLGOSQ-2kS4q01d.js +62 -0
- package/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
- package/dist/assets/dagre-D7JM2Yhv.js +1 -0
- package/dist/assets/dagre-GXQ25YYZ-B5Y21TQM.js +4 -0
- package/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/dist/assets/diagram-S7CK7UJ4-CY6td7Et.js +30 -0
- package/dist/assets/diagram-UQ7AKVKN-DmDqnGZf.js +41 -0
- package/dist/assets/diagram-VSXAHHWV-KGLWOOcE.js +3 -0
- package/dist/assets/diagram-VX7I27RA-D5syUraJ.js +24 -0
- package/dist/assets/diagram-Z3DM3KII-Dtrbs1Om.js +24 -0
- package/dist/assets/dist-Dce-2YbA.js +1 -0
- package/dist/assets/ebnfDiagram-PWID7BFC-CQpJLtU3.js +1 -0
- package/dist/assets/editor.worker-BmdWdrHZ.js +33 -0
- package/dist/assets/erDiagram-RLTQ6QDP-cMTzKLu_.js +99 -0
- package/dist/assets/eventmodeling-NTZA5JFV-CMzLdNaL.js +1 -0
- package/dist/assets/flowDiagram-HODETNUW-BMUajB-G.js +1 -0
- package/dist/assets/ganttDiagram-EL5Y4UJY-DseBS5Fs.js +292 -0
- package/dist/assets/gitGraph-4MIJSDKK-CUQZRpxJ.js +1 -0
- package/dist/assets/gitGraphDiagram-WWUBYQGX-CfMfsJ3b.js +106 -0
- package/dist/assets/graphlib-DS17s2tU.js +1 -0
- package/dist/assets/index-ByQ-XX38.css +1 -0
- package/dist/assets/index-CIf5TpfY.js +214 -0
- package/dist/assets/info-A6RAGUB7-D3nAKZ8M.js +1 -0
- package/dist/assets/infoDiagram-27XIBGKW-zu1O0WOy.js +2 -0
- package/dist/assets/init-C-OQMol4.js +1 -0
- package/dist/assets/ishikawaDiagram-5VMMS53U-DKC7BO9p.js +70 -0
- package/dist/assets/journeyDiagram-3NMN7TZE-Ld5mXse7.js +139 -0
- package/dist/assets/kanban-definition-UXKFOSKX-CH7_QlHL.js +89 -0
- package/dist/assets/katex-DJhrJSbw.js +257 -0
- package/dist/assets/line-N9XiCREw.js +1 -0
- package/dist/assets/linear-C9hnqmJv.js +1 -0
- package/dist/assets/mermaid-parser.core-DgPwV5Bh.js +7 -0
- package/dist/assets/mermaid.core-BKqynXLR.js +44 -0
- package/dist/assets/mindmap-definition-YA3MSWOX-DkiL-wiW.js +96 -0
- package/dist/assets/monaco-DjpsE8to.css +1 -0
- package/dist/assets/monaco-DqzJaNIe.js +908 -0
- package/dist/assets/ordinal-BDEzSJ7C.js +1 -0
- package/dist/assets/packet-AYTQ26CC-DuMTvpEJ.js +1 -0
- package/dist/assets/path-fybaL0A-.js +1 -0
- package/dist/assets/pegDiagram-XKGWAZYB-DvMl_xSe.js +1 -0
- package/dist/assets/pie-WAS4IAKB-Da0xPaD8.js +1 -0
- package/dist/assets/pieDiagram-E7YTZNPT-amANNRJY.js +39 -0
- package/dist/assets/quadrantDiagram-AXDQQJYC-BrgvDMnk.js +7 -0
- package/dist/assets/radar-RG4KPBEZ-Dz3Ih5-N.js +1 -0
- package/dist/assets/railroad-74A4TZTK-CKMW-IV2.js +1 -0
- package/dist/assets/railroad-abnf-HS5TGJTU-ChqmSWSF.js +1 -0
- package/dist/assets/railroad-ebnf-LZEXJU2U-V3R4fFdb.js +1 -0
- package/dist/assets/railroad-peg-WCYAUIDC-B-RuhUQW.js +1 -0
- package/dist/assets/railroadDiagram-O6MQD6OU-FjG-6OLu.js +1 -0
- package/dist/assets/requirementDiagram-BXWQKSXE-Cnj8RfE4.js +84 -0
- package/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
- package/dist/assets/sankeyDiagram-P5KCCOFB-DMu1fnEh.js +40 -0
- package/dist/assets/sequenceDiagram-WJ2MYXX4-CtCtaRpW.js +162 -0
- package/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
- package/dist/assets/src-CGp3bfio.js +1 -0
- package/dist/assets/stateDiagram-D77RDMKH-xcJbLNhz.js +1 -0
- package/dist/assets/stateDiagram-v2-MP3YSRHH-DYHrQmJJ.js +1 -0
- package/dist/assets/swimlanes-42K2YHIH-BwLln_ga.js +1 -0
- package/dist/assets/swimlanesDiagram-VR7AAH4N-BcDfUQ0A.js +8 -0
- package/dist/assets/timeline-definition-24CTP7MA-ucNGsUUz.js +120 -0
- package/dist/assets/treeView-Q6P3EWNA-3uOomRue.js +1 -0
- package/dist/assets/treemap-WGGIJYW6-iGK7vwID.js +1 -0
- package/dist/assets/vennDiagram-4TSXK5OY-B9LgGe16.js +34 -0
- package/dist/assets/wardley-WFR3VGLG-FupMOf6k.js +1 -0
- package/dist/assets/wardleyDiagram-VM6X3IG4-CGZdEXM2.js +78 -0
- package/dist/assets/xterm-BrP-ENHg.css +1 -0
- package/dist/assets/xterm-C8zLTNGk.js +16 -0
- package/dist/assets/xychartDiagram-S5SC5T6Z-DafZrSBJ.js +7 -0
- package/dist/favicon.ico +0 -0
- package/dist/favicon.svg +6 -0
- package/dist/index.html +34 -0
- package/extensions/implement-goal-issue.ts +368 -0
- package/extensions/implement-next-epic.ts +835 -0
- package/index.html +28 -0
- package/install.sh +111 -0
- package/issues/control.ts +91 -0
- package/issues/guards.ts +12 -0
- package/issues/ledger.ts +122 -0
- package/issues/legacy.ts +68 -0
- package/issues/lock.ts +305 -0
- package/issues/paths.ts +76 -0
- package/issues/preflight.ts +189 -0
- package/issues/repository.ts +203 -0
- package/issues/scout/actions.ts +255 -0
- package/issues/scout/control.ts +80 -0
- package/issues/scout/github.ts +358 -0
- package/issues/scout/paths.ts +129 -0
- package/issues/scout/preflight.ts +277 -0
- package/issues/scout/recommendation.ts +400 -0
- package/issues/scout/scan.ts +143 -0
- package/issues/scout/state.ts +448 -0
- package/issues/scout/worker.ts +1282 -0
- package/issues/state.ts +584 -0
- package/issues/tracker.ts +271 -0
- package/issues/worker.ts +1065 -0
- package/lib/dependencies.ts +61 -0
- package/lib/forge-auth.ts +953 -0
- package/lib/forge.ts +141 -0
- package/lib/gitea.ts +709 -0
- package/lib/github-release.ts +318 -0
- package/lib/inventory.test.ts +79 -0
- package/lib/inventory.ts +53 -0
- package/lib/release.test.ts +269 -0
- package/lib/release.ts +801 -0
- package/omp/commands/adr-resolve.md +8 -0
- package/omp/commands/discover.md +6 -0
- package/omp/commands/epic-next.md +8 -0
- package/omp/commands/flow-change.md +8 -0
- package/omp/commands/flow-goal.md +8 -0
- package/omp/commands/flow-implement.md +8 -0
- package/omp/commands/flow-widget.md +8 -0
- package/omp/commands/goal-draft.md +8 -0
- package/omp/commands/goal-outcome.md +8 -0
- package/omp/commands/implement-issue.md +8 -0
- package/omp/commands/prepare-skills.md +6 -0
- package/omp/commands/scout-recommendations.md +8 -0
- package/omp/commands/setup-devboard.md +24 -0
- package/omp/config.yml +12 -0
- package/omp/extensions/implement-goal-issue.ts +2 -0
- package/omp/extensions/implement-next-epic.ts +2 -0
- package/omp/mcp.json +9 -0
- package/package.json +86 -7
- package/prompts/adr-resolve.md +130 -0
- package/prompts/discover.md +541 -0
- package/prompts/epic-next.md +533 -0
- package/prompts/flow-change.md +76 -0
- package/prompts/flow-goal.md +126 -0
- package/prompts/flow-implement.md +103 -0
- package/prompts/flow-widget.md +54 -0
- package/prompts/goal-draft.md +147 -0
- package/prompts/goal-outcome.md +87 -0
- package/prompts/implement-issue.md +66 -0
- package/prompts/prepare-skills.md +142 -0
- package/prompts/scout-recommendations.md +143 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +6 -0
- package/server/acp.ts +540 -0
- package/server/activity.test.ts +96 -0
- package/server/activity.ts +48 -0
- package/server/app.test.ts +882 -0
- package/server/app.ts +6179 -0
- package/server/ask.test.ts +543 -0
- package/server/ask.ts +589 -0
- package/server/dependencies.test.ts +118 -0
- package/server/discover-remote.test.ts +1602 -0
- package/server/discover-sheet.test.ts +176 -0
- package/server/discover.test.ts +1510 -0
- package/server/discover.ts +3198 -0
- package/server/feedback.test.ts +458 -0
- package/server/feedback.ts +369 -0
- package/server/fixtures/adr-source.ts +71 -0
- package/server/fixtures/hang-download.ts +14 -0
- package/server/fixtures/stub-agent.ts +1870 -0
- package/server/fixtures/stub-forge.ts +141 -0
- package/server/fixtures/stub-gh.ts +57 -0
- package/server/flow-pr.test.ts +976 -0
- package/server/flow.test.ts +1632 -0
- package/server/flow.ts +2655 -0
- package/server/forge-pr.ts +387 -0
- package/server/goal-creation.test.ts +957 -0
- package/server/goal-draft.test.ts +348 -0
- package/server/goal-drafts.ts +449 -0
- package/server/goal-proposal.test.ts +479 -0
- package/server/goal-proposal.ts +232 -0
- package/server/goal-revision.test.ts +613 -0
- package/server/goal-slug.test.ts +56 -0
- package/server/goal-slug.ts +96 -0
- package/server/goal.ts +20 -0
- package/server/index.ts +147 -0
- package/server/issue-close.test.ts +100 -0
- package/server/issue-close.ts +178 -0
- package/server/issues.test.ts +167 -0
- package/server/issues.ts +479 -0
- package/server/journal.test.ts +350 -0
- package/server/journal.ts +329 -0
- package/server/lifecycle-pause.test.ts +1795 -0
- package/server/lifecycle.test.ts +1879 -0
- package/server/parallel-lifecycle.test.ts +1029 -0
- package/server/persistence-restart.test.ts +723 -0
- package/server/persistence.test.ts +1935 -0
- package/server/records.test.ts +180 -0
- package/server/records.ts +99 -0
- package/server/reset-implementation.test.ts +997 -0
- package/server/run.test.ts +611 -0
- package/server/scout.test.ts +877 -0
- package/server/scout.ts +459 -0
- package/server/skills-preparation.test.ts +1073 -0
- package/server/state.ts +249 -0
- package/server/tracker.ts +369 -0
- package/server/updates.test.ts +444 -0
- package/server/updates.ts +198 -0
- package/server/version.test.ts +140 -0
- package/server/version.ts +151 -0
- package/server/watchdog.ts +389 -0
- package/svelte.config.js +5 -0
- package/templates/host-AGENTS.md +235 -0
- package/tsconfig.json +21 -0
- package/vite.config.ts +21 -0
- package/watchdog/cleanup-main.ts +98 -0
- package/watchdog/cleanup.ts +301 -0
- package/watchdog/collect.ts +311 -0
- package/watchdog/coordinator.ts +397 -0
- package/watchdog/delivery.ts +107 -0
- package/watchdog/discovery.ts +209 -0
- package/watchdog/findings.ts +211 -0
- package/watchdog/github.ts +189 -0
- package/watchdog/history.ts +138 -0
- package/watchdog/install.ts +103 -0
- package/watchdog/ledger.ts +188 -0
- package/watchdog/lock.ts +63 -0
- package/watchdog/main.ts +106 -0
- package/watchdog/status.ts +71 -0
- package/watchdog/systemd.ts +96 -0
- package/watchdog/uninstall.ts +30 -0
- package/watchdog/units/devboard-watchdog.service +9 -0
- package/watchdog/units/devboard-watchdog.timer +10 -0
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
import { pendingRecord } from "./fixtures/adr-source";
|
|
2
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
5
|
+
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join, resolve } from "node:path";
|
|
8
|
+
import { stateFile } from "./state";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A real server process over a fixture repository holds its state across a
|
|
12
|
+
* SIGKILL: the replacement ends the killed peers, re-spawns the same work, and
|
|
13
|
+
* replays what the first process broadcast. The stub agent stands in for
|
|
14
|
+
* `omp acp`, and a stub `gh` supplies the tracker so a lifecycle runs without a
|
|
15
|
+
* network.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const temporaryDirectories: string[] = [];
|
|
19
|
+
const SERVER = join(import.meta.dir, "index.ts");
|
|
20
|
+
const STUB_AGENT = join(import.meta.dir, "fixtures", "stub-agent.ts");
|
|
21
|
+
const PENDING_ONE = pendingRecord("0001-first.md", "First decision", "First.", "Choose the first.");
|
|
22
|
+
/** The offline release the fixture serves; the stub `gh` never answers a release query. */
|
|
23
|
+
const FIXTURE_RELEASE_VERSION = "0.0.0";
|
|
24
|
+
const FIXTURE_RELEASE_TARBALL = `devboard-${FIXTURE_RELEASE_VERSION}.tgz`;
|
|
25
|
+
const MANAGED_SKILL = [
|
|
26
|
+
"---",
|
|
27
|
+
"name: behavior-first-testing",
|
|
28
|
+
"description: Choose tests by observable behavior.",
|
|
29
|
+
"metadata:",
|
|
30
|
+
' devboard-managed: "true"',
|
|
31
|
+
"---",
|
|
32
|
+
"",
|
|
33
|
+
"# Behavior-first testing",
|
|
34
|
+
"",
|
|
35
|
+
"- Prefer observable behavior over implementation details.",
|
|
36
|
+
"",
|
|
37
|
+
].join("\n");
|
|
38
|
+
const FAKE_GH = `#!/usr/bin/env bun
|
|
39
|
+
import { readFileSync } from "node:fs";
|
|
40
|
+
|
|
41
|
+
function out(value: unknown) {
|
|
42
|
+
process.stdout.write(\`\${JSON.stringify(value)}\\n\`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const args = process.argv.slice(2);
|
|
46
|
+
const tracker = process.env.FAKE_TRACKER ?? "";
|
|
47
|
+
|
|
48
|
+
if (args[0] === "label") {
|
|
49
|
+
out([{ name: "epic" }]);
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
if (args[0] === "issue") {
|
|
53
|
+
try {
|
|
54
|
+
out(JSON.parse(readFileSync(tracker, "utf8")));
|
|
55
|
+
} catch {
|
|
56
|
+
out([]);
|
|
57
|
+
}
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
if (args[0] === "auth") {
|
|
61
|
+
if (args[1] === "status") {
|
|
62
|
+
out({ hosts: { "github.com": [{ login: "tester", active: true, state: "success" }] } });
|
|
63
|
+
} else {
|
|
64
|
+
process.stdout.write("fake-token\\n");
|
|
65
|
+
}
|
|
66
|
+
process.exit(0);
|
|
67
|
+
}
|
|
68
|
+
if (args[0] === "api") {
|
|
69
|
+
if (args.includes("user")) {
|
|
70
|
+
process.stdout.write("tester\\n");
|
|
71
|
+
} else {
|
|
72
|
+
out({ admin: true, maintain: true, push: true, triage: true, pull: true });
|
|
73
|
+
}
|
|
74
|
+
process.exit(0);
|
|
75
|
+
}
|
|
76
|
+
out({});
|
|
77
|
+
`;
|
|
78
|
+
|
|
79
|
+
async function temporaryDirectory(): Promise<string> {
|
|
80
|
+
const directory = await mkdtemp(join(tmpdir(), "devboard-restart-"));
|
|
81
|
+
temporaryDirectories.push(directory);
|
|
82
|
+
return directory;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
afterAll(async () => {
|
|
86
|
+
await Promise.all(
|
|
87
|
+
temporaryDirectories.map((directory) => rm(directory, { recursive: true, force: true })),
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
function git(directory: string, args: string[]): string {
|
|
92
|
+
const result = Bun.spawnSync({
|
|
93
|
+
cmd: ["git", "-C", directory, ...args],
|
|
94
|
+
env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", GIT_TERMINAL_PROMPT: "0" },
|
|
95
|
+
stdout: "pipe",
|
|
96
|
+
stderr: "pipe",
|
|
97
|
+
});
|
|
98
|
+
if ((result.exitCode ?? -1) !== 0) {
|
|
99
|
+
throw new Error(`git ${args.join(" ")} failed: ${result.stderr.toString()}`);
|
|
100
|
+
}
|
|
101
|
+
return result.stdout.toString().trim();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface Fixture {
|
|
105
|
+
root: string;
|
|
106
|
+
adrDir: string;
|
|
107
|
+
staticDir: string;
|
|
108
|
+
goalFile: string;
|
|
109
|
+
stateDir: string;
|
|
110
|
+
logPath: string;
|
|
111
|
+
trackerFile: string;
|
|
112
|
+
binDir: string;
|
|
113
|
+
releaseDir: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function createFixture(): Promise<Fixture> {
|
|
117
|
+
const root = await temporaryDirectory();
|
|
118
|
+
const adrDir = join(root, "adrs");
|
|
119
|
+
const staticDir = join(root, "dist");
|
|
120
|
+
const goalFile = join(root, "GOAL.md");
|
|
121
|
+
const stateDir = join(root, "state");
|
|
122
|
+
const logPath = join(root, "agent.log");
|
|
123
|
+
const trackerFile = join(root, "tracker.json");
|
|
124
|
+
const binDir = join(root, "bin");
|
|
125
|
+
const releaseDir = join(root, "releases");
|
|
126
|
+
await mkdir(adrDir, { recursive: true });
|
|
127
|
+
await mkdir(staticDir, { recursive: true });
|
|
128
|
+
await mkdir(binDir, { recursive: true });
|
|
129
|
+
await mkdir(releaseDir, { recursive: true });
|
|
130
|
+
const tarball = Buffer.from("fixture release\n");
|
|
131
|
+
await writeFile(join(releaseDir, FIXTURE_RELEASE_TARBALL), tarball);
|
|
132
|
+
await writeFile(
|
|
133
|
+
join(releaseDir, `${FIXTURE_RELEASE_TARBALL}.sha256`),
|
|
134
|
+
`${createHash("sha256").update(tarball).digest("hex")} ${FIXTURE_RELEASE_TARBALL}\n`,
|
|
135
|
+
);
|
|
136
|
+
await writeFile(join(staticDir, "index.html"), "<!doctype html><title>ADR overview</title>");
|
|
137
|
+
await writeFile(goalFile, "# Goal\n\nShip the lifecycle.\n");
|
|
138
|
+
await writeFile(join(adrDir, "0001-first.md"), PENDING_ONE);
|
|
139
|
+
await mkdir(join(root, ".omp", "skills", "behavior-first-testing"), { recursive: true });
|
|
140
|
+
await writeFile(
|
|
141
|
+
join(root, ".omp", "skills", "behavior-first-testing", "SKILL.md"),
|
|
142
|
+
MANAGED_SKILL,
|
|
143
|
+
);
|
|
144
|
+
await writeFile(trackerFile, JSON.stringify([]));
|
|
145
|
+
const ghPath = join(binDir, "gh");
|
|
146
|
+
await writeFile(ghPath, FAKE_GH);
|
|
147
|
+
await chmod(ghPath, 0o755);
|
|
148
|
+
await writeFile(join(root, ".gitignore"), "state/\n.tmp/\n");
|
|
149
|
+
git(root, ["init", "-q", "-b", "main"]);
|
|
150
|
+
git(root, ["config", "user.name", "Devboard Test"]);
|
|
151
|
+
git(root, ["config", "user.email", "devboard@example.invalid"]);
|
|
152
|
+
git(root, ["config", "commit.gpgsign", "false"]);
|
|
153
|
+
git(root, ["remote", "add", "origin", "https://github.com/owner/repo.git"]);
|
|
154
|
+
git(root, ["add", "-A"]);
|
|
155
|
+
git(root, ["commit", "-q", "-m", "initial"]);
|
|
156
|
+
return { root, adrDir, staticDir, goalFile, stateDir, logPath, trackerFile, binDir, releaseDir };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
interface RunningServer {
|
|
160
|
+
child: Bun.Subprocess<"ignore", "pipe", "pipe">;
|
|
161
|
+
port: number;
|
|
162
|
+
url: string;
|
|
163
|
+
stderr(): string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function freePort(): Promise<number> {
|
|
167
|
+
const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("") });
|
|
168
|
+
const port = probe.port;
|
|
169
|
+
probe.stop(true);
|
|
170
|
+
return port ?? 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function startProcess(fixture: Fixture, port?: number): Promise<RunningServer> {
|
|
174
|
+
const chosen = port ?? (await freePort());
|
|
175
|
+
const child = Bun.spawn({
|
|
176
|
+
cmd: [process.execPath, SERVER],
|
|
177
|
+
cwd: join(import.meta.dir, ".."),
|
|
178
|
+
env: {
|
|
179
|
+
...process.env,
|
|
180
|
+
HOST: "127.0.0.1",
|
|
181
|
+
PORT: String(chosen),
|
|
182
|
+
ADRS_DIR: fixture.adrDir,
|
|
183
|
+
REPO_DIR: fixture.root,
|
|
184
|
+
GOAL_FILE: fixture.goalFile,
|
|
185
|
+
STATIC_DIR: fixture.staticDir,
|
|
186
|
+
STATE_DIR: fixture.stateDir,
|
|
187
|
+
AGENT_COMMAND: [
|
|
188
|
+
`bun ${STUB_AGENT}`,
|
|
189
|
+
`--log=${fixture.logPath}`,
|
|
190
|
+
`--fixture-tracker=${fixture.trackerFile}`,
|
|
191
|
+
"--delay-ms=3000",
|
|
192
|
+
"--no-close",
|
|
193
|
+
"--question-epic=81",
|
|
194
|
+
"--question-epic=82",
|
|
195
|
+
].join(" "),
|
|
196
|
+
PATH: `${fixture.binDir}:${process.env.PATH ?? ""}`,
|
|
197
|
+
GH_TOKEN: "fake-token",
|
|
198
|
+
FAKE_TRACKER: fixture.trackerFile,
|
|
199
|
+
// The update check reads the offline fixture instead of the stub `gh`.
|
|
200
|
+
DEVBOARD_RELEASE_DIR: fixture.releaseDir,
|
|
201
|
+
},
|
|
202
|
+
stdin: "ignore",
|
|
203
|
+
stdout: "pipe",
|
|
204
|
+
stderr: "pipe",
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
let stderr = "";
|
|
208
|
+
void (async () => {
|
|
209
|
+
const reader = child.stderr.getReader();
|
|
210
|
+
const decoder = new TextDecoder();
|
|
211
|
+
for (;;) {
|
|
212
|
+
const { done, value } = await reader.read();
|
|
213
|
+
if (done) break;
|
|
214
|
+
stderr += decoder.decode(value, { stream: true });
|
|
215
|
+
}
|
|
216
|
+
})();
|
|
217
|
+
|
|
218
|
+
const deadline = Date.now() + 20_000;
|
|
219
|
+
for (;;) {
|
|
220
|
+
try {
|
|
221
|
+
const response = await fetch(`http://127.0.0.1:${chosen}/`, { method: "HEAD" });
|
|
222
|
+
if (response.ok || response.status === 404) break;
|
|
223
|
+
} catch {
|
|
224
|
+
// Still binding or still starting.
|
|
225
|
+
}
|
|
226
|
+
if (Date.now() > deadline) throw new Error(`the server did not start: ${stderr}`);
|
|
227
|
+
await Bun.sleep(50);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
child,
|
|
232
|
+
port: chosen,
|
|
233
|
+
url: `127.0.0.1:${chosen}`,
|
|
234
|
+
stderr: () => stderr,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function logLines(logPath: string): string[] {
|
|
239
|
+
if (!existsSync(logPath)) return [];
|
|
240
|
+
return readFileSync(logPath, "utf8")
|
|
241
|
+
.split("\n")
|
|
242
|
+
.filter((line) => line.length > 0);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function prompts(logPath: string): string[] {
|
|
246
|
+
return logLines(logPath)
|
|
247
|
+
.filter((line) => line.startsWith("prompt "))
|
|
248
|
+
.map((line) => line.slice("prompt ".length));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function spawnPids(logPath: string): number[] {
|
|
252
|
+
return logLines(logPath)
|
|
253
|
+
.filter((line) => line.startsWith("spawn "))
|
|
254
|
+
.map((line) => Number(line.slice("spawn ".length)));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function alive(pid: number): boolean {
|
|
258
|
+
try {
|
|
259
|
+
process.kill(pid, 0);
|
|
260
|
+
return true;
|
|
261
|
+
} catch {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function waitUntil(
|
|
267
|
+
predicate: () => boolean | Promise<boolean>,
|
|
268
|
+
message: string,
|
|
269
|
+
timeout = 20000,
|
|
270
|
+
): Promise<void> {
|
|
271
|
+
const deadline = Date.now() + timeout;
|
|
272
|
+
while (Date.now() < deadline) {
|
|
273
|
+
if (await predicate()) return;
|
|
274
|
+
await Bun.sleep(25);
|
|
275
|
+
}
|
|
276
|
+
if (!(await predicate())) throw new Error(message);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
interface TestClient {
|
|
280
|
+
socket: WebSocket;
|
|
281
|
+
events: Record<string, unknown>[];
|
|
282
|
+
send(command: unknown): void;
|
|
283
|
+
waitFor(
|
|
284
|
+
predicate: (message: Record<string, unknown>) => boolean,
|
|
285
|
+
message?: string,
|
|
286
|
+
timeout?: number,
|
|
287
|
+
): Promise<Record<string, unknown>>;
|
|
288
|
+
close(): void;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function connect(url: string): Promise<TestClient> {
|
|
292
|
+
const deadline = Date.now() + 20_000;
|
|
293
|
+
for (;;) {
|
|
294
|
+
const { promise, resolve: settle, reject } = Promise.withResolvers<TestClient>();
|
|
295
|
+
const socket = new WebSocket(`ws://${url}/ws`);
|
|
296
|
+
const events: Record<string, unknown>[] = [];
|
|
297
|
+
const buffered: Record<string, unknown>[] = [];
|
|
298
|
+
const waiters: {
|
|
299
|
+
predicate: (message: Record<string, unknown>) => boolean;
|
|
300
|
+
resolve: (message: Record<string, unknown>) => void;
|
|
301
|
+
}[] = [];
|
|
302
|
+
|
|
303
|
+
socket.onopen = () => {
|
|
304
|
+
settle({
|
|
305
|
+
socket,
|
|
306
|
+
events,
|
|
307
|
+
send(command) {
|
|
308
|
+
socket.send(JSON.stringify(command));
|
|
309
|
+
},
|
|
310
|
+
waitFor(
|
|
311
|
+
predicate,
|
|
312
|
+
message = `the expected event did not arrive; saw ${events
|
|
313
|
+
.slice(-6)
|
|
314
|
+
.map((event) => String(event.type))
|
|
315
|
+
.join(",")}`,
|
|
316
|
+
timeout = 20000,
|
|
317
|
+
) {
|
|
318
|
+
const index = buffered.findIndex(predicate);
|
|
319
|
+
if (index >= 0) {
|
|
320
|
+
const found = buffered.splice(index, 1)[0];
|
|
321
|
+
if (found !== undefined) return Promise.resolve(found);
|
|
322
|
+
}
|
|
323
|
+
{
|
|
324
|
+
const { promise: waiting, resolve: ready } =
|
|
325
|
+
Promise.withResolvers<Record<string, unknown>>();
|
|
326
|
+
const entry = { predicate, resolve: ready };
|
|
327
|
+
waiters.push(entry);
|
|
328
|
+
setTimeout(() => {
|
|
329
|
+
const at = waiters.indexOf(entry);
|
|
330
|
+
if (at >= 0) {
|
|
331
|
+
waiters.splice(at, 1);
|
|
332
|
+
ready(Promise.reject(new Error(message)) as never);
|
|
333
|
+
}
|
|
334
|
+
}, timeout);
|
|
335
|
+
return waiting;
|
|
336
|
+
}
|
|
337
|
+
},
|
|
338
|
+
close() {
|
|
339
|
+
socket.close();
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
};
|
|
343
|
+
socket.onmessage = (event) => {
|
|
344
|
+
let message: Record<string, unknown>;
|
|
345
|
+
try {
|
|
346
|
+
message = JSON.parse(String(event.data)) as Record<string, unknown>;
|
|
347
|
+
} catch {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
events.push(message);
|
|
351
|
+
const index = waiters.findIndex((waiter) => waiter.predicate(message));
|
|
352
|
+
if (index >= 0) {
|
|
353
|
+
const waiter = waiters.splice(index, 1)[0];
|
|
354
|
+
if (waiter !== undefined) waiter.resolve(message);
|
|
355
|
+
} else {
|
|
356
|
+
buffered.push(message);
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
socket.onerror = () => reject(new Error("websocket failed"));
|
|
360
|
+
|
|
361
|
+
try {
|
|
362
|
+
return await promise;
|
|
363
|
+
} catch (error) {
|
|
364
|
+
if (Date.now() > deadline) throw error;
|
|
365
|
+
await Bun.sleep(50);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function storedPath(fixture: Fixture): string {
|
|
371
|
+
return stateFile(fixture.stateDir, resolve(fixture.root));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** The newest release version a fresh connection hears, or null before a check succeeds. */
|
|
375
|
+
async function reportedLatest(url: string): Promise<string | null> {
|
|
376
|
+
const client = await connect(url);
|
|
377
|
+
try {
|
|
378
|
+
client.send({ type: "getVersion" });
|
|
379
|
+
const state = await client.waitFor((message) => message.type === "versionState");
|
|
380
|
+
return typeof state.latest === "string" ? state.latest : null;
|
|
381
|
+
} finally {
|
|
382
|
+
client.close();
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function kill(child: Bun.Subprocess<"ignore", "pipe", "pipe">): Promise<void> {
|
|
387
|
+
child.kill("SIGKILL");
|
|
388
|
+
await child.exited;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
describe("restart after SIGKILL", () => {
|
|
392
|
+
test(
|
|
393
|
+
"replays and resumes lifecycle, foreground, and paused work",
|
|
394
|
+
async () => {
|
|
395
|
+
const fixture = await createFixture();
|
|
396
|
+
await writeFile(
|
|
397
|
+
fixture.trackerFile,
|
|
398
|
+
JSON.stringify([{ number: 81, title: "Killed epic", url: "https://example.test/81" }]),
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
let server = await startProcess(fixture);
|
|
402
|
+
let client = await connect(server.url);
|
|
403
|
+
let laneId = "";
|
|
404
|
+
let laneActivity: string[] = [];
|
|
405
|
+
let questionId: unknown;
|
|
406
|
+
let lifecycleState: Record<string, unknown>;
|
|
407
|
+
|
|
408
|
+
try {
|
|
409
|
+
// The update check resolves the offline fixture instead of journaling a failure.
|
|
410
|
+
await waitUntil(
|
|
411
|
+
async () => (await reportedLatest(server.url)) === FIXTURE_RELEASE_VERSION,
|
|
412
|
+
"the server never reported the fixture release",
|
|
413
|
+
);
|
|
414
|
+
client.send({ type: "startLifecycle" });
|
|
415
|
+
lifecycleState = await client.waitFor(
|
|
416
|
+
(message) =>
|
|
417
|
+
message.type === "lifecycleState" &&
|
|
418
|
+
message.stage === "implementation" &&
|
|
419
|
+
message.work !== null,
|
|
420
|
+
);
|
|
421
|
+
const question = await client.waitFor(
|
|
422
|
+
(message) => message.type === "question" && typeof message.lane === "string",
|
|
423
|
+
);
|
|
424
|
+
laneId = String(question.lane);
|
|
425
|
+
questionId = question.id;
|
|
426
|
+
const snapshot = await client.waitFor(
|
|
427
|
+
(message) =>
|
|
428
|
+
message.type === "laneState" &&
|
|
429
|
+
(message.lanes as { id: string; activity: string[] }[]).some(
|
|
430
|
+
(lane) => lane.id === laneId && lane.activity.length > 0,
|
|
431
|
+
),
|
|
432
|
+
);
|
|
433
|
+
laneActivity = (
|
|
434
|
+
snapshot.lanes as { id: string; activity: string[] }[]
|
|
435
|
+
).find((lane) => lane.id === laneId)?.activity ?? [];
|
|
436
|
+
expect(laneActivity).toEqual(["Working on epic 81"]);
|
|
437
|
+
expect(prompts(fixture.logPath)).toEqual(["/implement-next-epic 81"]);
|
|
438
|
+
} finally {
|
|
439
|
+
client.close();
|
|
440
|
+
await kill(server.child);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const oldPid = spawnPids(fixture.logPath)[0] ?? 0;
|
|
444
|
+
|
|
445
|
+
server = await startProcess(fixture, server.port);
|
|
446
|
+
client = await connect(server.url);
|
|
447
|
+
try {
|
|
448
|
+
const replayedLifecycle = await client.waitFor(
|
|
449
|
+
(message) => message.type === "lifecycleState",
|
|
450
|
+
);
|
|
451
|
+
expect(replayedLifecycle).toMatchObject({
|
|
452
|
+
stage: lifecycleState.stage,
|
|
453
|
+
pass: lifecycleState.pass,
|
|
454
|
+
work: lifecycleState.work,
|
|
455
|
+
paused: lifecycleState.paused,
|
|
456
|
+
});
|
|
457
|
+
const replayedLanes = await client.waitFor((message) => message.type === "laneState");
|
|
458
|
+
const lane = (replayedLanes.lanes as {
|
|
459
|
+
id: string;
|
|
460
|
+
kind: string;
|
|
461
|
+
status: string;
|
|
462
|
+
activity: string[];
|
|
463
|
+
question: { id: unknown } | null;
|
|
464
|
+
}[]).find((entry) => entry.id === laneId);
|
|
465
|
+
expect(lane?.kind).toBe("implementation");
|
|
466
|
+
expect(lane?.status).toBe("running");
|
|
467
|
+
// The recorded activity stays an exact prefix of the restored log.
|
|
468
|
+
expect(lane?.activity.slice(0, laneActivity.length)).toEqual(laneActivity);
|
|
469
|
+
expect(lane?.question?.id).toBe(questionId);
|
|
470
|
+
// No epic owns a second implementation lane after the restart.
|
|
471
|
+
expect(
|
|
472
|
+
(replayedLanes.lanes as { kind: string; epic: { number: number } | null }[]).filter(
|
|
473
|
+
(entry) => entry.kind === "implementation" && entry.epic?.number === 81,
|
|
474
|
+
).length,
|
|
475
|
+
).toBe(1);
|
|
476
|
+
|
|
477
|
+
await waitUntil(
|
|
478
|
+
() => prompts(fixture.logPath).length === 2,
|
|
479
|
+
"the replacement did not re-spawn the killed lane",
|
|
480
|
+
);
|
|
481
|
+
const newPid = spawnPids(fixture.logPath)[1] ?? 0;
|
|
482
|
+
expect(alive(oldPid)).toBe(false);
|
|
483
|
+
expect(alive(newPid)).toBe(true);
|
|
484
|
+
expect(prompts(fixture.logPath).filter((prompt) => prompt === "/implement-next-epic 81")).toEqual([
|
|
485
|
+
"/implement-next-epic 81",
|
|
486
|
+
"/implement-next-epic 81",
|
|
487
|
+
]);
|
|
488
|
+
|
|
489
|
+
// The restored question is refused until the replacement asks again.
|
|
490
|
+
client.send({ type: "answer", lane: laneId, id: questionId, value: "Beta" });
|
|
491
|
+
expect(await client.waitFor((message) => message.type === "error")).toMatchObject({
|
|
492
|
+
message: "The question is no longer pending.",
|
|
493
|
+
});
|
|
494
|
+
const reissued = await client.waitFor(
|
|
495
|
+
(message) => message.type === "question" && message.lane === laneId,
|
|
496
|
+
);
|
|
497
|
+
client.send({ type: "answer", lane: laneId, id: reissued.id, value: "Beta" });
|
|
498
|
+
await waitUntil(
|
|
499
|
+
() => logLines(fixture.logPath).includes("answer Beta"),
|
|
500
|
+
"the replacement question was not answerable",
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
// End the lifecycle so a foreground run can take the agent slot.
|
|
504
|
+
client.send({ type: "stopLifecycle" });
|
|
505
|
+
await client.waitFor(
|
|
506
|
+
(message) =>
|
|
507
|
+
message.type === "lifecycleState" && message.stage === "goal" && message.paused === false,
|
|
508
|
+
);
|
|
509
|
+
} finally {
|
|
510
|
+
client.close();
|
|
511
|
+
await kill(server.child);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Foreground: a manual ADR run is live with a pending question at death.
|
|
515
|
+
server = await startProcess(fixture, server.port);
|
|
516
|
+
client = await connect(server.url);
|
|
517
|
+
let runActivity = "";
|
|
518
|
+
let runQuestion: unknown;
|
|
519
|
+
try {
|
|
520
|
+
client.send({ type: "startRun" });
|
|
521
|
+
const started = await client.waitFor((message) => message.type === "runStarted");
|
|
522
|
+
expect(started).toMatchObject({ file: "0001-first.md", manual: true });
|
|
523
|
+
const activity = await client.waitFor(
|
|
524
|
+
(message) => message.type === "activity" && typeof message.lane !== "string",
|
|
525
|
+
);
|
|
526
|
+
runActivity = String(activity.text);
|
|
527
|
+
const question = await client.waitFor(
|
|
528
|
+
(message) => message.type === "question" && typeof message.lane !== "string",
|
|
529
|
+
);
|
|
530
|
+
runQuestion = question.id;
|
|
531
|
+
} finally {
|
|
532
|
+
client.close();
|
|
533
|
+
await kill(server.child);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const runPid = spawnPids(fixture.logPath).at(-1) ?? 0;
|
|
537
|
+
const spawnsBeforeRestart = spawnPids(fixture.logPath).length;
|
|
538
|
+
server = await startProcess(fixture, server.port);
|
|
539
|
+
client = await connect(server.url);
|
|
540
|
+
try {
|
|
541
|
+
const started = await client.waitFor((message) => message.type === "runStarted");
|
|
542
|
+
expect(started).toMatchObject({
|
|
543
|
+
file: "0001-first.md",
|
|
544
|
+
action: "resolving",
|
|
545
|
+
manual: true,
|
|
546
|
+
});
|
|
547
|
+
expect(await client.waitFor((message) => message.type === "activity")).toEqual({
|
|
548
|
+
type: "activity",
|
|
549
|
+
text: runActivity,
|
|
550
|
+
});
|
|
551
|
+
expect(
|
|
552
|
+
await client.waitFor((message) => message.type === "question" && typeof message.lane !== "string"),
|
|
553
|
+
).toMatchObject({ id: runQuestion });
|
|
554
|
+
|
|
555
|
+
await waitUntil(
|
|
556
|
+
() => spawnPids(fixture.logPath).length === spawnsBeforeRestart + 1,
|
|
557
|
+
"the replacement did not re-spawn the manual run",
|
|
558
|
+
);
|
|
559
|
+
expect(alive(runPid)).toBe(false);
|
|
560
|
+
expect(alive(spawnPids(fixture.logPath).at(-1) ?? 0)).toBe(true);
|
|
561
|
+
|
|
562
|
+
const reissued = await client.waitFor(
|
|
563
|
+
(message) => message.type === "question" && typeof message.lane !== "string",
|
|
564
|
+
);
|
|
565
|
+
client.send({ type: "answer", id: reissued.id, value: "Alpha" });
|
|
566
|
+
const stopped = await client.waitFor(
|
|
567
|
+
(message) => message.type === "runStopped",
|
|
568
|
+
"the restored manual run did not finish",
|
|
569
|
+
20000,
|
|
570
|
+
);
|
|
571
|
+
expect(stopped).toMatchObject({ file: "0001-first.md", resolved: true });
|
|
572
|
+
} finally {
|
|
573
|
+
client.close();
|
|
574
|
+
await kill(server.child);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Paused: a lifecycle run paused at death spawns nothing on restart.
|
|
578
|
+
await writeFile(
|
|
579
|
+
fixture.trackerFile,
|
|
580
|
+
JSON.stringify([
|
|
581
|
+
{ number: 81, title: "Killed epic", url: "https://example.test/81" },
|
|
582
|
+
{ number: 82, title: "Paused epic", url: "https://example.test/82" },
|
|
583
|
+
]),
|
|
584
|
+
);
|
|
585
|
+
server = await startProcess(fixture, server.port);
|
|
586
|
+
client = await connect(server.url);
|
|
587
|
+
try {
|
|
588
|
+
client.send({ type: "startLifecycle" });
|
|
589
|
+
await client.waitFor(
|
|
590
|
+
(message) =>
|
|
591
|
+
message.type === "laneState" &&
|
|
592
|
+
(message.lanes as { status: string }[]).filter((lane) => lane.status === "running")
|
|
593
|
+
.length >= 2,
|
|
594
|
+
);
|
|
595
|
+
await waitUntil(
|
|
596
|
+
() =>
|
|
597
|
+
prompts(fixture.logPath).filter((prompt) => prompt === "/implement-next-epic 82")
|
|
598
|
+
.length === 1,
|
|
599
|
+
"the second epic did not start",
|
|
600
|
+
);
|
|
601
|
+
client.send({ type: "pauseLifecycle" });
|
|
602
|
+
await client.waitFor(
|
|
603
|
+
(message) => message.type === "lifecycleState" && message.paused === true,
|
|
604
|
+
);
|
|
605
|
+
await client.waitFor(
|
|
606
|
+
(message) =>
|
|
607
|
+
message.type === "laneState" &&
|
|
608
|
+
(message.lanes as { status: string }[]).every((lane) => lane.status === "paused"),
|
|
609
|
+
);
|
|
610
|
+
} finally {
|
|
611
|
+
client.close();
|
|
612
|
+
await kill(server.child);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const pausedPids = spawnPids(fixture.logPath).length;
|
|
616
|
+
const pausedPrompts = (id: string) =>
|
|
617
|
+
prompts(fixture.logPath).filter((prompt) => prompt === `/implement-next-epic ${id}`)
|
|
618
|
+
.length;
|
|
619
|
+
const paused81 = pausedPrompts("81");
|
|
620
|
+
const paused82 = pausedPrompts("82");
|
|
621
|
+
server = await startProcess(fixture, server.port);
|
|
622
|
+
client = await connect(server.url);
|
|
623
|
+
try {
|
|
624
|
+
expect(await client.waitFor((message) => message.type === "lifecycleState")).toMatchObject({
|
|
625
|
+
stage: "implementation",
|
|
626
|
+
paused: true,
|
|
627
|
+
});
|
|
628
|
+
const snapshot = await client.waitFor((message) => message.type === "laneState");
|
|
629
|
+
expect(
|
|
630
|
+
(snapshot.lanes as { status: string; kind: string }[])
|
|
631
|
+
.filter((lane) => lane.kind !== "generation")
|
|
632
|
+
.every((lane) => lane.status === "paused"),
|
|
633
|
+
).toBe(true);
|
|
634
|
+
|
|
635
|
+
// Nothing spawns while the restored run stays paused.
|
|
636
|
+
await Bun.sleep(700);
|
|
637
|
+
expect(spawnPids(fixture.logPath).length).toBe(pausedPids);
|
|
638
|
+
expect(prompts(fixture.logPath).filter((prompt) => prompt === "/epic-next")).toEqual([]);
|
|
639
|
+
|
|
640
|
+
client.send({ type: "resumeLifecycle" });
|
|
641
|
+
await client.waitFor(
|
|
642
|
+
(message) => message.type === "lifecycleState" && message.paused === false,
|
|
643
|
+
);
|
|
644
|
+
await waitUntil(
|
|
645
|
+
() => spawnPids(fixture.logPath).length === pausedPids + 2,
|
|
646
|
+
"resume did not re-spawn the interrupted lanes",
|
|
647
|
+
);
|
|
648
|
+
expect(pausedPrompts("81")).toBe(paused81 + 1);
|
|
649
|
+
expect(pausedPrompts("82")).toBe(paused82 + 1);
|
|
650
|
+
expect(alive(spawnPids(fixture.logPath).at(-1) ?? 0)).toBe(true);
|
|
651
|
+
} finally {
|
|
652
|
+
client.close();
|
|
653
|
+
await kill(server.child);
|
|
654
|
+
}
|
|
655
|
+
},
|
|
656
|
+
180000,
|
|
657
|
+
);
|
|
658
|
+
|
|
659
|
+
test(
|
|
660
|
+
"keeps serving with one stderr error when the state directory is read-only",
|
|
661
|
+
async () => {
|
|
662
|
+
const fixture = await createFixture();
|
|
663
|
+
await mkdir(join(fixture.stateDir, "state"), { recursive: true });
|
|
664
|
+
await chmod(join(fixture.stateDir, "state"), 0o500);
|
|
665
|
+
|
|
666
|
+
const server = await startProcess(fixture);
|
|
667
|
+
const client = await connect(server.url);
|
|
668
|
+
try {
|
|
669
|
+
client.send({ type: "getProject" });
|
|
670
|
+
await client.waitFor((message) => message.type === "project");
|
|
671
|
+
// A broadcast is what writes the state; a manual run provides one.
|
|
672
|
+
client.send({ type: "startRun" });
|
|
673
|
+
await client.waitFor((message) => message.type === "runStarted");
|
|
674
|
+
await waitUntil(
|
|
675
|
+
() => server.stderr().includes("state: could not write"),
|
|
676
|
+
"the read-only state directory was not reported",
|
|
677
|
+
);
|
|
678
|
+
expect(server.stderr().split("state: could not write").length - 1).toBe(1);
|
|
679
|
+
expect(client.events.filter((message) => message.type === "error")).toEqual([]);
|
|
680
|
+
client.send({ type: "getProject" });
|
|
681
|
+
await client.waitFor((message) => message.type === "project");
|
|
682
|
+
} finally {
|
|
683
|
+
client.close();
|
|
684
|
+
await kill(server.child);
|
|
685
|
+
await chmod(join(fixture.stateDir, "state"), 0o700);
|
|
686
|
+
}
|
|
687
|
+
},
|
|
688
|
+
60000,
|
|
689
|
+
);
|
|
690
|
+
|
|
691
|
+
test(
|
|
692
|
+
"reports an unusable state file on stderr and keeps its bytes",
|
|
693
|
+
async () => {
|
|
694
|
+
const fixture = await createFixture();
|
|
695
|
+
await mkdir(join(fixture.stateDir, "state"), { recursive: true });
|
|
696
|
+
const corrupt = "{ not json";
|
|
697
|
+
await writeFile(storedPath(fixture), corrupt);
|
|
698
|
+
|
|
699
|
+
const server = await startProcess(fixture);
|
|
700
|
+
const client = await connect(server.url);
|
|
701
|
+
try {
|
|
702
|
+
const error = await client.waitFor((message) => message.type === "error");
|
|
703
|
+
expect(String(error.message)).toContain("could not be restored");
|
|
704
|
+
expect(client.events.filter((message) => message.type === "error").length).toBe(1);
|
|
705
|
+
await waitUntil(
|
|
706
|
+
() => server.stderr().includes("could not be restored"),
|
|
707
|
+
"the replacement did not report the unusable state on stderr",
|
|
708
|
+
);
|
|
709
|
+
const preserved = readdirSync(join(fixture.stateDir, "state")).filter((name) =>
|
|
710
|
+
name.includes(".damaged-"),
|
|
711
|
+
);
|
|
712
|
+
expect(preserved.length).toBe(1);
|
|
713
|
+
expect(
|
|
714
|
+
readFileSync(join(fixture.stateDir, "state", String(preserved[0])), "utf8"),
|
|
715
|
+
).toBe(corrupt);
|
|
716
|
+
} finally {
|
|
717
|
+
client.close();
|
|
718
|
+
await kill(server.child);
|
|
719
|
+
}
|
|
720
|
+
},
|
|
721
|
+
60000,
|
|
722
|
+
);
|
|
723
|
+
});
|