@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,350 @@
|
|
|
1
|
+
import { pendingRecord } from "./fixtures/adr-source";
|
|
2
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { existsSync, readFileSync, readdirSync, statSync, unlinkSync, utimesSync } from "node:fs";
|
|
5
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
6
|
+
import { basename, join, resolve } from "node:path";
|
|
7
|
+
import type { Server } from "bun";
|
|
8
|
+
import { createApp } from "./app";
|
|
9
|
+
import {
|
|
10
|
+
JOURNAL_MAX_BYTES,
|
|
11
|
+
openJournal,
|
|
12
|
+
pruneJournal,
|
|
13
|
+
type Journal,
|
|
14
|
+
type JournalRecord,
|
|
15
|
+
} from "./journal";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* New tests keep their temporary directories inside the repository. The app
|
|
19
|
+
* fixture proves the journal is written by a real run; the rest prove rotation,
|
|
20
|
+
* pruning, and the size bound against a fixture clock.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const harnessRoot = resolve(import.meta.dir, "..");
|
|
24
|
+
const repositoryRoot = resolve(harnessRoot, "..");
|
|
25
|
+
const testRoot = join(repositoryRoot, ".devboard", ".tmp");
|
|
26
|
+
const STUB_AGENT = join(import.meta.dir, "fixtures", "stub-agent.ts");
|
|
27
|
+
const temporaryDirectories: string[] = [];
|
|
28
|
+
|
|
29
|
+
async function temporaryDirectory() {
|
|
30
|
+
await mkdir(testRoot, { recursive: true });
|
|
31
|
+
const directory = await mkdtemp(join(testRoot, `test-journal-${randomUUID()}-`));
|
|
32
|
+
temporaryDirectories.push(directory);
|
|
33
|
+
return directory;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
afterAll(async () => {
|
|
37
|
+
await Promise.all(
|
|
38
|
+
temporaryDirectories.map((directory) => rm(directory, { recursive: true, force: true })),
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function journalRecords(stateDir: string, repoDir: string): JournalRecord[] {
|
|
43
|
+
const path = join(stateDir, "journal", `${createKey(repoDir)}.jsonl`);
|
|
44
|
+
return readFileSync(path, "utf8")
|
|
45
|
+
.split("\n")
|
|
46
|
+
.filter((line) => line.length > 0)
|
|
47
|
+
.map((line) => JSON.parse(line) as JournalRecord);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function createKey(repoDir: string): string {
|
|
51
|
+
return createHash("sha256").update(repoDir).digest("hex").slice(0, 24);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface TestClient {
|
|
55
|
+
socket: WebSocket;
|
|
56
|
+
send(command: unknown): void;
|
|
57
|
+
waitFor(
|
|
58
|
+
predicate: (message: Record<string, unknown>) => boolean,
|
|
59
|
+
): Promise<Record<string, unknown>>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function connect(url: string): Promise<TestClient> {
|
|
63
|
+
const { promise, resolve: settle, reject } = Promise.withResolvers<TestClient>();
|
|
64
|
+
const socket = new WebSocket(`ws://${url}/ws`);
|
|
65
|
+
const buffered: Record<string, unknown>[] = [];
|
|
66
|
+
const waiters: {
|
|
67
|
+
predicate: (message: Record<string, unknown>) => boolean;
|
|
68
|
+
resolve: (message: Record<string, unknown>) => void;
|
|
69
|
+
}[] = [];
|
|
70
|
+
|
|
71
|
+
socket.onopen = () => {
|
|
72
|
+
settle({
|
|
73
|
+
socket,
|
|
74
|
+
send(command) {
|
|
75
|
+
socket.send(JSON.stringify(command));
|
|
76
|
+
},
|
|
77
|
+
waitFor(predicate) {
|
|
78
|
+
const index = buffered.findIndex(predicate);
|
|
79
|
+
if (index >= 0) {
|
|
80
|
+
const message = buffered.splice(index, 1)[0];
|
|
81
|
+
if (message !== undefined) return Promise.resolve(message);
|
|
82
|
+
}
|
|
83
|
+
const { promise: waiting, resolve: next } =
|
|
84
|
+
Promise.withResolvers<Record<string, unknown>>();
|
|
85
|
+
waiters.push({ predicate, resolve: next });
|
|
86
|
+
return waiting;
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
socket.onmessage = (event) => {
|
|
91
|
+
let message: Record<string, unknown>;
|
|
92
|
+
try {
|
|
93
|
+
message = JSON.parse(String(event.data)) as Record<string, unknown>;
|
|
94
|
+
} catch {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const index = waiters.findIndex((waiter) => waiter.predicate(message));
|
|
98
|
+
if (index >= 0) {
|
|
99
|
+
const waiter = waiters.splice(index, 1)[0];
|
|
100
|
+
if (waiter !== undefined) waiter.resolve(message);
|
|
101
|
+
} else {
|
|
102
|
+
buffered.push(message);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
socket.onerror = () => reject(new Error("websocket failed"));
|
|
106
|
+
return promise;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const PENDING = pendingRecord("0001-first.md", "First decision", "First.", "Choose the first.");
|
|
110
|
+
|
|
111
|
+
async function startServer(options: {
|
|
112
|
+
stateDir: string;
|
|
113
|
+
journal?: Journal;
|
|
114
|
+
agentCommand: string;
|
|
115
|
+
}): Promise<{ server: Server<unknown>; url: string; repoDir: string }> {
|
|
116
|
+
const root = options.stateDir;
|
|
117
|
+
const adrDir = join(root, "adrs");
|
|
118
|
+
const staticDir = join(root, "static");
|
|
119
|
+
await mkdir(adrDir, { recursive: true });
|
|
120
|
+
await mkdir(staticDir, { recursive: true });
|
|
121
|
+
await writeFile(join(adrDir, "0001-first.md"), PENDING);
|
|
122
|
+
const server = createApp({
|
|
123
|
+
adrDir,
|
|
124
|
+
staticDir,
|
|
125
|
+
hostname: "127.0.0.1",
|
|
126
|
+
port: 0,
|
|
127
|
+
repoDir: root,
|
|
128
|
+
stateDir: join(root, "state"),
|
|
129
|
+
agentCommand: options.agentCommand,
|
|
130
|
+
...(options.journal === undefined ? {} : { journal: options.journal }),
|
|
131
|
+
});
|
|
132
|
+
return { server, url: `127.0.0.1:${server.port}`, repoDir: root };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
describe("journal", () => {
|
|
136
|
+
test(
|
|
137
|
+
"records a run's activity, question, start, and stop",
|
|
138
|
+
async () => {
|
|
139
|
+
const stateDir = await temporaryDirectory();
|
|
140
|
+
const logPath = join(stateDir, "agent.log");
|
|
141
|
+
const { server, url, repoDir } = await startServer({
|
|
142
|
+
stateDir,
|
|
143
|
+
agentCommand: `bun ${STUB_AGENT} --log=${logPath} --questions=1`,
|
|
144
|
+
});
|
|
145
|
+
const client = await connect(url);
|
|
146
|
+
try {
|
|
147
|
+
client.send({ type: "startRun" });
|
|
148
|
+
await client.waitFor((message) => message.type === "runStarted");
|
|
149
|
+
const question = await client.waitFor((message) => message.type === "question");
|
|
150
|
+
client.send({ type: "answer", id: question.id, value: "Alpha" });
|
|
151
|
+
await client.waitFor((message) => message.type === "activity");
|
|
152
|
+
await client.waitFor((message) => message.type === "runStopped");
|
|
153
|
+
} finally {
|
|
154
|
+
client.socket.close();
|
|
155
|
+
server.stop(true);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const records = journalRecords(join(stateDir, "state"), repoDir);
|
|
159
|
+
const kinds = new Set(records.map((record) => record.kind));
|
|
160
|
+
expect(kinds.has("activity")).toBe(true);
|
|
161
|
+
expect(kinds.has("question")).toBe(true);
|
|
162
|
+
expect(kinds.has("run-start")).toBe(true);
|
|
163
|
+
expect(kinds.has("run-stop")).toBe(true);
|
|
164
|
+
expect(records.find((record) => record.kind === "question")?.scope).toBe(
|
|
165
|
+
"run:0001-first.md",
|
|
166
|
+
);
|
|
167
|
+
for (const record of records) {
|
|
168
|
+
expect(record.repo).toBe(repoDir);
|
|
169
|
+
expect(record.pid).toBe(process.pid);
|
|
170
|
+
expect(typeof record.port).toBe("number");
|
|
171
|
+
expect(Number.isFinite(Date.parse(record.ts))).toBe(true);
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
20000,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
test(
|
|
178
|
+
"reports a write failure and still finishes the run",
|
|
179
|
+
async () => {
|
|
180
|
+
const stateDir = await temporaryDirectory();
|
|
181
|
+
const failures: string[] = [];
|
|
182
|
+
const journal = openJournal({
|
|
183
|
+
stateDir: join(stateDir, "state"),
|
|
184
|
+
repoDir: stateDir,
|
|
185
|
+
port: 0,
|
|
186
|
+
append() {
|
|
187
|
+
throw new Error("disk full");
|
|
188
|
+
},
|
|
189
|
+
reportError(message) {
|
|
190
|
+
failures.push(message);
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
const logPath = join(stateDir, "agent.log");
|
|
194
|
+
const { server, url } = await startServer({
|
|
195
|
+
stateDir,
|
|
196
|
+
journal,
|
|
197
|
+
agentCommand: `bun ${STUB_AGENT} --log=${logPath} --questions=0`,
|
|
198
|
+
});
|
|
199
|
+
const client = await connect(url);
|
|
200
|
+
try {
|
|
201
|
+
client.send({ type: "startRun" });
|
|
202
|
+
await client.waitFor((message) => message.type === "runStarted");
|
|
203
|
+
expect(await client.waitFor((message) => message.type === "runStopped")).toMatchObject({
|
|
204
|
+
resolved: true,
|
|
205
|
+
});
|
|
206
|
+
} finally {
|
|
207
|
+
client.socket.close();
|
|
208
|
+
server.stop(true);
|
|
209
|
+
}
|
|
210
|
+
expect(failures.length).toBe(1);
|
|
211
|
+
expect(failures[0]).toContain("journal: could not write");
|
|
212
|
+
},
|
|
213
|
+
20000,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
test("rotates the live file instead of truncating it", async () => {
|
|
217
|
+
const stateDir = await temporaryDirectory();
|
|
218
|
+
const journal = openJournal({
|
|
219
|
+
stateDir,
|
|
220
|
+
repoDir: "/fixture/project",
|
|
221
|
+
port: 8080,
|
|
222
|
+
rotateBytes: 200,
|
|
223
|
+
});
|
|
224
|
+
for (let index = 0; index < 6; index += 1) {
|
|
225
|
+
journal.write("activity", `line ${index} ${"x".repeat(40)}`);
|
|
226
|
+
}
|
|
227
|
+
const files = readdirSync(join(stateDir, "journal")).sort();
|
|
228
|
+
expect(files.length).toBeGreaterThan(1);
|
|
229
|
+
expect(files.some((name) => name.endsWith(".jsonl"))).toBe(true);
|
|
230
|
+
const rotated = files.filter((name) => !name.endsWith(".jsonl"));
|
|
231
|
+
expect(rotated.length).toBeGreaterThan(0);
|
|
232
|
+
const content = rotated
|
|
233
|
+
.map((name) => readFileSync(join(stateDir, "journal", name), "utf8"))
|
|
234
|
+
.join("");
|
|
235
|
+
expect(content).toContain("line 0");
|
|
236
|
+
const live = files.find((name) => /^[0-9a-f]{24}\.jsonl$/.test(name)) as string;
|
|
237
|
+
expect(readFileSync(join(stateDir, "journal", live), "utf8")).toContain("line 5");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("prunes journals rotated by the live writer past seven days", async () => {
|
|
241
|
+
const stateDir = await temporaryDirectory();
|
|
242
|
+
const journal = openJournal({
|
|
243
|
+
stateDir,
|
|
244
|
+
repoDir: "/fixture/project",
|
|
245
|
+
port: 8080,
|
|
246
|
+
rotateBytes: 200,
|
|
247
|
+
});
|
|
248
|
+
for (let index = 0; index < 3; index += 1) {
|
|
249
|
+
journal.write("activity", `line ${index} ${"x".repeat(80)}`);
|
|
250
|
+
}
|
|
251
|
+
const directory = join(stateDir, "journal");
|
|
252
|
+
const rotated = readdirSync(directory)
|
|
253
|
+
.filter((name) => name !== basename(journal.path))
|
|
254
|
+
.sort();
|
|
255
|
+
const [oldName, freshName] = rotated;
|
|
256
|
+
if (oldName === undefined || freshName === undefined) {
|
|
257
|
+
throw new Error("rotation did not produce two journals");
|
|
258
|
+
}
|
|
259
|
+
const now = new Date("2026-10-24T12:00:00.000Z");
|
|
260
|
+
const eightDaysAgo = new Date(now.getTime() - 8 * 24 * 60 * 60 * 1000);
|
|
261
|
+
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
|
262
|
+
const old = join(directory, oldName);
|
|
263
|
+
const fresh = join(directory, freshName);
|
|
264
|
+
utimesSync(old, eightDaysAgo, eightDaysAgo);
|
|
265
|
+
utimesSync(fresh, oneDayAgo, oneDayAgo);
|
|
266
|
+
|
|
267
|
+
const removed = pruneJournal(stateDir, {
|
|
268
|
+
now,
|
|
269
|
+
windowStart: new Date(now.getTime() - 10 * 60 * 1000),
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
expect(removed).toEqual([oldName]);
|
|
273
|
+
expect(existsSync(old)).toBe(false);
|
|
274
|
+
expect(existsSync(fresh)).toBe(true);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("keeps an old live journal in retention and restores it after deletion", async () => {
|
|
278
|
+
const stateDir = await temporaryDirectory();
|
|
279
|
+
const failures: string[] = [];
|
|
280
|
+
const journal = openJournal({
|
|
281
|
+
stateDir,
|
|
282
|
+
repoDir: "/fixture/project",
|
|
283
|
+
port: 8080,
|
|
284
|
+
rotateBytes: 200,
|
|
285
|
+
reportError: (message) => failures.push(message),
|
|
286
|
+
});
|
|
287
|
+
journal.write("activity", `line 0 ${"z".repeat(120)}`);
|
|
288
|
+
const now = new Date("2026-10-24T12:00:00.000Z");
|
|
289
|
+
const eightDaysAgo = new Date(now.getTime() - 8 * 24 * 60 * 60 * 1000);
|
|
290
|
+
utimesSync(journal.path, eightDaysAgo, eightDaysAgo);
|
|
291
|
+
|
|
292
|
+
const removed = pruneJournal(stateDir, {
|
|
293
|
+
now,
|
|
294
|
+
windowStart: new Date(now.getTime() - 10 * 60 * 1000),
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
expect(removed).toEqual([]);
|
|
298
|
+
expect(existsSync(journal.path)).toBe(true);
|
|
299
|
+
|
|
300
|
+
// The writer keeps its stale size; the next write crosses rotateBytes.
|
|
301
|
+
unlinkSync(journal.path);
|
|
302
|
+
journal.write("activity", `line 1 ${"z".repeat(120)}`);
|
|
303
|
+
|
|
304
|
+
expect(failures).toEqual([]);
|
|
305
|
+
expect(readFileSync(journal.path, "utf8")).toContain("line 1");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("counts rotated journals toward the size bound outside the live window", async () => {
|
|
309
|
+
const stateDir = await temporaryDirectory();
|
|
310
|
+
const journal = openJournal({
|
|
311
|
+
stateDir,
|
|
312
|
+
repoDir: "/fixture/project",
|
|
313
|
+
port: 8080,
|
|
314
|
+
rotateBytes: 200,
|
|
315
|
+
});
|
|
316
|
+
for (let index = 0; index < 4; index += 1) {
|
|
317
|
+
journal.write("activity", `line ${index} ${"y".repeat(80)}`);
|
|
318
|
+
}
|
|
319
|
+
const directory = join(stateDir, "journal");
|
|
320
|
+
const names = readdirSync(directory);
|
|
321
|
+
const rotated = names.filter((name) => name !== basename(journal.path)).sort();
|
|
322
|
+
const [coldName, ...inside] = rotated;
|
|
323
|
+
if (coldName === undefined || inside.length === 0) {
|
|
324
|
+
throw new Error("rotation did not produce enough journals");
|
|
325
|
+
}
|
|
326
|
+
const now = new Date("2026-10-24T12:00:00.000Z");
|
|
327
|
+
const windowStart = new Date(now.getTime() - 10 * 60 * 1000);
|
|
328
|
+
const cold = join(directory, coldName);
|
|
329
|
+
const coldTime = new Date(now.getTime() - 60 * 60 * 1000);
|
|
330
|
+
utimesSync(cold, coldTime, coldTime);
|
|
331
|
+
for (const name of [...inside, basename(journal.path)]) {
|
|
332
|
+
utimesSync(join(directory, name), now, now);
|
|
333
|
+
}
|
|
334
|
+
const total = names.reduce((sum, name) => sum + statSync(join(directory, name)).size, 0);
|
|
335
|
+
const coldSize = statSync(cold).size;
|
|
336
|
+
expect(coldSize).toBeGreaterThan(1);
|
|
337
|
+
|
|
338
|
+
const removed = pruneJournal(stateDir, {
|
|
339
|
+
now,
|
|
340
|
+
windowStart,
|
|
341
|
+
maxBytes: total - coldSize + 1,
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
expect(removed).toEqual([coldName]);
|
|
345
|
+
expect(existsSync(cold)).toBe(false);
|
|
346
|
+
for (const name of inside) expect(existsSync(join(directory, name))).toBe(true);
|
|
347
|
+
expect(existsSync(journal.path)).toBe(true);
|
|
348
|
+
expect(JOURNAL_MAX_BYTES).toBe(1024 * 1024 * 1024);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
appendFileSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
statSync,
|
|
8
|
+
unlinkSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { basename, dirname, join } from "node:path";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The dashboard journal is the machine-readable record the watchdog reads.
|
|
14
|
+
* One file per project per harness, appended as JSON lines so a truncated tail
|
|
15
|
+
* costs at most one record. Writes never interrupt a run: a full disk or a
|
|
16
|
+
* read-only state directory only produces a line on the server's stderr.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const JOURNAL_KINDS = [
|
|
20
|
+
"activity",
|
|
21
|
+
"question",
|
|
22
|
+
"run-start",
|
|
23
|
+
"run-stop",
|
|
24
|
+
"lane-start",
|
|
25
|
+
"lane-stop",
|
|
26
|
+
"lifecycle",
|
|
27
|
+
"acp-stderr",
|
|
28
|
+
"server-error",
|
|
29
|
+
"update",
|
|
30
|
+
"update-error",
|
|
31
|
+
] as const;
|
|
32
|
+
|
|
33
|
+
export type JournalKind = (typeof JOURNAL_KINDS)[number];
|
|
34
|
+
|
|
35
|
+
/** A live journal rotates at this size instead of growing without bound. */
|
|
36
|
+
export const JOURNAL_ROTATE_BYTES = 8 * 1024 * 1024;
|
|
37
|
+
/** Rotated journals older than this leave at cycle start. */
|
|
38
|
+
export const JOURNAL_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
39
|
+
/** Total journal bytes the watchdog keeps, unless the live window is larger. */
|
|
40
|
+
export const JOURNAL_MAX_BYTES = 1024 * 1024 * 1024;
|
|
41
|
+
|
|
42
|
+
export interface JournalRecord {
|
|
43
|
+
/** UTC ISO 8601 timestamp of the record. */
|
|
44
|
+
ts: string;
|
|
45
|
+
/** Absolute project path the server serves. */
|
|
46
|
+
repo: string;
|
|
47
|
+
port: number;
|
|
48
|
+
pid: number;
|
|
49
|
+
kind: JournalKind;
|
|
50
|
+
text: string;
|
|
51
|
+
/** Logical run, lane, or peer the record belongs to, when one is known. */
|
|
52
|
+
scope?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface JournalOptions {
|
|
56
|
+
/** Harness state directory; the journal lives in its `journal` child. */
|
|
57
|
+
stateDir: string;
|
|
58
|
+
repoDir: string;
|
|
59
|
+
port: number;
|
|
60
|
+
pid?: number;
|
|
61
|
+
/** Injectable clock for tests. */
|
|
62
|
+
now?: () => Date;
|
|
63
|
+
/** Size at which the live file is rotated aside. */
|
|
64
|
+
rotateBytes?: number;
|
|
65
|
+
/** Injectable append, used to simulate a write failure. */
|
|
66
|
+
append?: (path: string, data: string) => void;
|
|
67
|
+
/** Injectable failure sink; defaults to the server's stderr. */
|
|
68
|
+
reportError?: (message: string) => void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface Journal {
|
|
72
|
+
path: string;
|
|
73
|
+
write(kind: JournalKind, text: string, scope?: string): void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The 24-hex project key that names this project's journal. */
|
|
77
|
+
export function journalKey(repoDir: string): string {
|
|
78
|
+
return createHash("sha256").update(repoDir).digest("hex").slice(0, 24);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function journalFile(stateDir: string, repoDir: string): string {
|
|
82
|
+
return join(stateDir, "journal", `${journalKey(repoDir)}.jsonl`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A journal filename: `<24-hex project key>.jsonl`, rotated aside as
|
|
87
|
+
* `<key>.jsonl.<rotation instant>-<counter>`. Rotation and pruning share this
|
|
88
|
+
* one definition so a name the writer produces is a name the pruner accepts.
|
|
89
|
+
*/
|
|
90
|
+
const JOURNAL_NAME = /^([0-9a-f]{24})\.jsonl(?:\.(\d+)-(\d+))?$/;
|
|
91
|
+
|
|
92
|
+
interface JournalName {
|
|
93
|
+
/** The project key that names the journal. */
|
|
94
|
+
key: string;
|
|
95
|
+
/** Rotation instant and counter; absent on the live journal. */
|
|
96
|
+
rotation?: { ms: number; counter: number };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Parses a journal filename; `null` for any other file in the directory. */
|
|
100
|
+
function parseJournalName(name: string): JournalName | null {
|
|
101
|
+
const match = JOURNAL_NAME.exec(name);
|
|
102
|
+
if (match === null) return null;
|
|
103
|
+
const key = match[1];
|
|
104
|
+
const ms = match[2];
|
|
105
|
+
const counter = match[3];
|
|
106
|
+
if (key === undefined) return null;
|
|
107
|
+
if (ms === undefined || counter === undefined) return { key };
|
|
108
|
+
return { key, rotation: { ms: Number(ms), counter: Number(counter) } };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Every journal file of one project: rotated journals oldest first, then the
|
|
113
|
+
* live file. A project that never rotated yields only its live path, and a
|
|
114
|
+
* missing journal directory yields that path so the read reports nothing.
|
|
115
|
+
*
|
|
116
|
+
* `since` is the oldest instant a caller needs. A rotated file last written
|
|
117
|
+
* before it cannot hold a later record, so it is skipped instead of parsed:
|
|
118
|
+
* a quiet dashboard keeps up to `JOURNAL_MAX_BYTES` of old rotations.
|
|
119
|
+
*/
|
|
120
|
+
export function journalFiles(livePath: string, since?: Date): string[] {
|
|
121
|
+
const directory = dirname(livePath);
|
|
122
|
+
const live = parseJournalName(basename(livePath));
|
|
123
|
+
let names: string[];
|
|
124
|
+
try {
|
|
125
|
+
names = readdirSync(directory);
|
|
126
|
+
} catch {
|
|
127
|
+
return [livePath];
|
|
128
|
+
}
|
|
129
|
+
if (live === null) return [livePath];
|
|
130
|
+
const cutoff = since?.getTime();
|
|
131
|
+
const rotated: { path: string; ms: number; counter: number; name: string }[] = [];
|
|
132
|
+
for (const name of names) {
|
|
133
|
+
const parsed = parseJournalName(name);
|
|
134
|
+
if (parsed === null || parsed.rotation === undefined) continue;
|
|
135
|
+
if (parsed.key !== live.key) continue;
|
|
136
|
+
const { ms, counter } = parsed.rotation;
|
|
137
|
+
const path = join(directory, name);
|
|
138
|
+
if (cutoff !== undefined) {
|
|
139
|
+
try {
|
|
140
|
+
if (statSync(path).mtimeMs < cutoff) continue;
|
|
141
|
+
} catch {
|
|
142
|
+
// A journal disappearing mid-scan is already gone.
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
rotated.push({ path, ms, counter, name });
|
|
147
|
+
}
|
|
148
|
+
rotated.sort(
|
|
149
|
+
(left, right) =>
|
|
150
|
+
left.ms - right.ms || left.counter - right.counter || left.name.localeCompare(right.name),
|
|
151
|
+
);
|
|
152
|
+
return [...rotated.map((file) => file.path), livePath];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** One JSONL line per event, truncated so a single huge stream cannot flood it. */
|
|
156
|
+
export function journalLine(record: JournalRecord): string {
|
|
157
|
+
const text = record.text.length > 2000 ? `${record.text.slice(0, 2000)}…` : record.text;
|
|
158
|
+
return `${JSON.stringify({ ...record, text })}\n`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function openJournal(options: JournalOptions): Journal {
|
|
162
|
+
const path = journalFile(options.stateDir, options.repoDir);
|
|
163
|
+
const append = options.append ?? ((target: string, data: string) => appendFileSync(target, data));
|
|
164
|
+
const reportError =
|
|
165
|
+
options.reportError ??
|
|
166
|
+
((message: string) => {
|
|
167
|
+
process.stderr.write(`${message}\n`);
|
|
168
|
+
});
|
|
169
|
+
const rotateBytes = options.rotateBytes ?? JOURNAL_ROTATE_BYTES;
|
|
170
|
+
const pid = options.pid ?? process.pid;
|
|
171
|
+
let opened = false;
|
|
172
|
+
let size = 0;
|
|
173
|
+
let rotations = 0;
|
|
174
|
+
let reported = false;
|
|
175
|
+
|
|
176
|
+
// A dashboard that has not emitted an event yet is still instrumented.
|
|
177
|
+
try {
|
|
178
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
179
|
+
} catch {
|
|
180
|
+
// The first write reports the failure through the configured sink.
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function open() {
|
|
184
|
+
if (opened) return;
|
|
185
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
186
|
+
try {
|
|
187
|
+
size = statSync(path).size;
|
|
188
|
+
} catch {
|
|
189
|
+
size = 0;
|
|
190
|
+
}
|
|
191
|
+
opened = true;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Moves the live file aside so no record is ever truncated away. */
|
|
195
|
+
function rotate() {
|
|
196
|
+
rotations += 1;
|
|
197
|
+
try {
|
|
198
|
+
// A counter keeps two rotations in the same millisecond from colliding.
|
|
199
|
+
renameSync(path, `${path}.${Date.now()}-${rotations}`);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
// The live file can disappear under a running writer: an external delete
|
|
202
|
+
// or a retention sweep of an older build. Rotation then starts a fresh
|
|
203
|
+
// live file instead of throwing the record away.
|
|
204
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
205
|
+
}
|
|
206
|
+
size = 0;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
path,
|
|
211
|
+
write(kind: JournalKind, text: string, scope?: string) {
|
|
212
|
+
const record: JournalRecord = {
|
|
213
|
+
ts: (options.now?.() ?? new Date()).toISOString(),
|
|
214
|
+
repo: options.repoDir,
|
|
215
|
+
port: options.port,
|
|
216
|
+
pid,
|
|
217
|
+
kind,
|
|
218
|
+
text,
|
|
219
|
+
...(scope === undefined ? {} : { scope }),
|
|
220
|
+
};
|
|
221
|
+
const line = journalLine(record);
|
|
222
|
+
const bytes = Buffer.byteLength(line);
|
|
223
|
+
try {
|
|
224
|
+
open();
|
|
225
|
+
if (size > 0 && size + bytes > rotateBytes) rotate();
|
|
226
|
+
append(path, line);
|
|
227
|
+
size += bytes;
|
|
228
|
+
} catch (error) {
|
|
229
|
+
if (reported) return;
|
|
230
|
+
reported = true;
|
|
231
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
232
|
+
reportError(`journal: could not write ${path}: ${detail}`);
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export interface PruneOptions {
|
|
239
|
+
/** Cycle start; the live evidence window ends here. */
|
|
240
|
+
now: Date;
|
|
241
|
+
/** Oldest timestamp the watchdog still needs. */
|
|
242
|
+
windowStart: Date;
|
|
243
|
+
retentionMs?: number;
|
|
244
|
+
maxBytes?: number;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
interface JournalFile {
|
|
248
|
+
path: string;
|
|
249
|
+
name: string;
|
|
250
|
+
size: number;
|
|
251
|
+
mtimeMs: number;
|
|
252
|
+
/** The live file the writer appends to; never a deletion candidate. */
|
|
253
|
+
live: boolean;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Drops rotated journals past the retention limit and enforces the total bound
|
|
258
|
+
* by deleting the oldest complete files first. A file that still holds records
|
|
259
|
+
* inside the live window is never deleted for the bound.
|
|
260
|
+
*/
|
|
261
|
+
export function pruneJournal(stateDir: string, options: PruneOptions): string[] {
|
|
262
|
+
const directory = join(stateDir, "journal");
|
|
263
|
+
const retentionMs = options.retentionMs ?? JOURNAL_RETENTION_MS;
|
|
264
|
+
const maxBytes = options.maxBytes ?? JOURNAL_MAX_BYTES;
|
|
265
|
+
let names: string[];
|
|
266
|
+
try {
|
|
267
|
+
names = readdirSync(directory);
|
|
268
|
+
} catch {
|
|
269
|
+
return [];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const files: JournalFile[] = [];
|
|
273
|
+
for (const name of names) {
|
|
274
|
+
const parsed = parseJournalName(name);
|
|
275
|
+
if (parsed === null) continue;
|
|
276
|
+
const path = join(directory, name);
|
|
277
|
+
try {
|
|
278
|
+
const info = statSync(path);
|
|
279
|
+
if (!info.isFile()) continue;
|
|
280
|
+
files.push({
|
|
281
|
+
path,
|
|
282
|
+
name,
|
|
283
|
+
size: info.size,
|
|
284
|
+
mtimeMs: info.mtimeMs,
|
|
285
|
+
live: parsed.rotation === undefined,
|
|
286
|
+
});
|
|
287
|
+
} catch {
|
|
288
|
+
// A journal disappearing mid-scan is already gone.
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const removed: string[] = [];
|
|
293
|
+
const cutoff = options.now.getTime() - retentionMs;
|
|
294
|
+
const windowStart = options.windowStart.getTime();
|
|
295
|
+
const remaining = new Set(files);
|
|
296
|
+
|
|
297
|
+
for (const file of [...files].sort((left, right) => left.mtimeMs - right.mtimeMs)) {
|
|
298
|
+
if (!remaining.has(file)) continue;
|
|
299
|
+
// Only rotated evidence expires: the live file belongs to the writer.
|
|
300
|
+
if (file.live) continue;
|
|
301
|
+
if (file.mtimeMs >= cutoff) continue;
|
|
302
|
+
try {
|
|
303
|
+
unlinkSync(file.path);
|
|
304
|
+
remaining.delete(file);
|
|
305
|
+
removed.push(file.name);
|
|
306
|
+
} catch {
|
|
307
|
+
// A concurrent reader may already have removed it.
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
let total = [...remaining].reduce((sum, file) => sum + file.size, 0);
|
|
312
|
+
for (const file of [...remaining].sort((left, right) => left.mtimeMs - right.mtimeMs)) {
|
|
313
|
+
if (total <= maxBytes) break;
|
|
314
|
+
// The live file still counts toward the bound but is never a candidate.
|
|
315
|
+
if (file.live) continue;
|
|
316
|
+
// A file still inside the window is evidence the next cycle needs.
|
|
317
|
+
if (file.mtimeMs >= windowStart) continue;
|
|
318
|
+
try {
|
|
319
|
+
unlinkSync(file.path);
|
|
320
|
+
remaining.delete(file);
|
|
321
|
+
total -= file.size;
|
|
322
|
+
removed.push(file.name);
|
|
323
|
+
} catch {
|
|
324
|
+
// Already gone.
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return removed;
|
|
329
|
+
}
|