@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,189 @@
|
|
|
1
|
+
import type { Window } from "./collect";
|
|
2
|
+
import type { LedgerInstance } from "./ledger";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GitHub issue delivery for the watchdog. Every call goes through the
|
|
6
|
+
* operator's authenticated `gh`; nothing here reads or prints an environment
|
|
7
|
+
* value, and nothing closes an issue.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** GitHub's title limit for this watchdog; the line is truncated to fit. */
|
|
11
|
+
export const TITLE_MAX = 80;
|
|
12
|
+
/** Longest first stderr line kept as a failure reason. */
|
|
13
|
+
export const REASON_MAX = 300;
|
|
14
|
+
|
|
15
|
+
export interface GhOutcome {
|
|
16
|
+
code: number;
|
|
17
|
+
stdout: string;
|
|
18
|
+
stderr: string;
|
|
19
|
+
timedOut?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Runs one `gh` invocation with a body on standard input. */
|
|
23
|
+
export type GhRun = (argv: string[], stdin: string) => GhOutcome;
|
|
24
|
+
|
|
25
|
+
export function runGh(argv: string[], stdin: string): GhOutcome {
|
|
26
|
+
try {
|
|
27
|
+
const result = Bun.spawnSync({
|
|
28
|
+
cmd: ["gh", ...argv],
|
|
29
|
+
stdin: Buffer.from(stdin),
|
|
30
|
+
stdout: "pipe",
|
|
31
|
+
stderr: "pipe",
|
|
32
|
+
timeout: 120_000,
|
|
33
|
+
env: { ...process.env, GH_PROMPT_DISABLED: "1", GH_NO_UPDATE_NOTIFIER: "1" },
|
|
34
|
+
});
|
|
35
|
+
return {
|
|
36
|
+
code: result.exitCode ?? 1,
|
|
37
|
+
stdout: result.stdout.toString(),
|
|
38
|
+
stderr: result.stderr.toString(),
|
|
39
|
+
...(result.exitedDueToTimeout === true ? { timedOut: true } : {}),
|
|
40
|
+
};
|
|
41
|
+
} catch (error) {
|
|
42
|
+
// A missing or unspawnable `gh` is a filing failure, not a crashed cycle.
|
|
43
|
+
return {
|
|
44
|
+
code: 1,
|
|
45
|
+
stdout: "",
|
|
46
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function truncate(value: string, limit: number): string {
|
|
52
|
+
const single = value.replace(/\s+/g, " ").trim();
|
|
53
|
+
if (single.length <= limit) return single;
|
|
54
|
+
return `${single.slice(0, limit - 1).trimEnd()}…`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** `[watchdog] <category>: <line>`, one line, never longer than 80 characters. */
|
|
58
|
+
export function watchdogTitle(category: string, line: string): string {
|
|
59
|
+
const title = `[watchdog] ${category}: ${line.replace(/\s+/g, " ").trim()}`;
|
|
60
|
+
if (title.length <= TITLE_MAX) return title;
|
|
61
|
+
return `${title.slice(0, TITLE_MAX - 1).trimEnd()}…`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The first diagnostic line of a failed `gh` call, bounded for a report. */
|
|
65
|
+
export function ghFailureReason(outcome: GhOutcome): string {
|
|
66
|
+
const line = [...outcome.stderr.split("\n"), ...outcome.stdout.split("\n")]
|
|
67
|
+
.map((entry) => entry.trim())
|
|
68
|
+
.find((entry) => entry.length > 0);
|
|
69
|
+
if (line !== undefined) return truncate(line, REASON_MAX);
|
|
70
|
+
return outcome.timedOut === true ? "gh timed out" : `gh exited with code ${outcome.code}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface IssueInput {
|
|
74
|
+
category: string;
|
|
75
|
+
fingerprint: string;
|
|
76
|
+
line: string;
|
|
77
|
+
firstSeen: string;
|
|
78
|
+
lastSeen: string;
|
|
79
|
+
count: number;
|
|
80
|
+
window: Window;
|
|
81
|
+
instances: LedgerInstance[];
|
|
82
|
+
/** The cycle report that filed this issue. */
|
|
83
|
+
reportPath: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function fence(text: string): string {
|
|
87
|
+
const fenceText = text.includes("```") ? "````" : "```";
|
|
88
|
+
return `${fenceText}text\n${text}\n${fenceText}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Category, identity, sightings, every contributing instance, and the report. */
|
|
92
|
+
export function watchdogBody(input: IssueInput): string {
|
|
93
|
+
const instances =
|
|
94
|
+
input.instances.length === 0
|
|
95
|
+
? ["No dashboard is recorded."]
|
|
96
|
+
: input.instances.map(
|
|
97
|
+
(instance) =>
|
|
98
|
+
`- \`${instance.repoDir}\` on port ${instance.port} ` +
|
|
99
|
+
`(pid ${instance.pid}, journal \`${instance.journal}\`)`,
|
|
100
|
+
);
|
|
101
|
+
return [
|
|
102
|
+
"## Watchdog finding",
|
|
103
|
+
"",
|
|
104
|
+
`- Category: \`${input.category}\``,
|
|
105
|
+
`- Fingerprint: \`${input.fingerprint}\``,
|
|
106
|
+
`- First seen: ${input.firstSeen}`,
|
|
107
|
+
`- Last seen: ${input.lastSeen}`,
|
|
108
|
+
`- Count: ${input.count}`,
|
|
109
|
+
`- Window: ${input.window.start.toISOString()} .. ${input.window.end.toISOString()}`,
|
|
110
|
+
"",
|
|
111
|
+
"### Quote",
|
|
112
|
+
"",
|
|
113
|
+
fence(input.line),
|
|
114
|
+
"",
|
|
115
|
+
"### Contributing instances",
|
|
116
|
+
"",
|
|
117
|
+
...instances,
|
|
118
|
+
"",
|
|
119
|
+
"### Cycle report",
|
|
120
|
+
"",
|
|
121
|
+
`\`${input.reportPath}\``,
|
|
122
|
+
"",
|
|
123
|
+
"Filed by the Devboard watchdog from machine-checkable evidence. " +
|
|
124
|
+
"It never fixes, merges, pushes, or publishes.",
|
|
125
|
+
"",
|
|
126
|
+
].join("\n");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A recurrence: the new window, the latest sighting, and the running count. */
|
|
130
|
+
export function watchdogComment(input: IssueInput): string {
|
|
131
|
+
return [
|
|
132
|
+
`Another sighting of \`${input.fingerprint}\` (\`${input.category}\`).`,
|
|
133
|
+
"",
|
|
134
|
+
`- Last seen: ${input.lastSeen}`,
|
|
135
|
+
`- Count: ${input.count}`,
|
|
136
|
+
`- Window: ${input.window.start.toISOString()} .. ${input.window.end.toISOString()}`,
|
|
137
|
+
"",
|
|
138
|
+
"### Quote",
|
|
139
|
+
"",
|
|
140
|
+
fence(input.line),
|
|
141
|
+
"",
|
|
142
|
+
"Filed by the Devboard watchdog from machine-checkable evidence.",
|
|
143
|
+
"",
|
|
144
|
+
].join("\n");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function printedUrl(outcome: GhOutcome): string | undefined {
|
|
148
|
+
return outcome.stdout
|
|
149
|
+
.split("\n")
|
|
150
|
+
.map((line) => line.trim())
|
|
151
|
+
.reverse()
|
|
152
|
+
.find((line) => /^https?:\/\//.test(line));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Files one issue in `repository`; only `gh` can fail it. */
|
|
156
|
+
export function createIssue(
|
|
157
|
+
gh: GhRun,
|
|
158
|
+
repository: string,
|
|
159
|
+
input: IssueInput,
|
|
160
|
+
): { ok: true; url: string } | { ok: false; reason: string } {
|
|
161
|
+
const outcome = gh(
|
|
162
|
+
[
|
|
163
|
+
"issue",
|
|
164
|
+
"create",
|
|
165
|
+
"--repo",
|
|
166
|
+
repository,
|
|
167
|
+
"--title",
|
|
168
|
+
watchdogTitle(input.category, input.line),
|
|
169
|
+
"--body-file",
|
|
170
|
+
"-",
|
|
171
|
+
],
|
|
172
|
+
watchdogBody(input),
|
|
173
|
+
);
|
|
174
|
+
if (outcome.code !== 0) return { ok: false, reason: ghFailureReason(outcome) };
|
|
175
|
+
const url = printedUrl(outcome);
|
|
176
|
+
if (url === undefined) return { ok: false, reason: "gh printed no issue URL" };
|
|
177
|
+
return { ok: true, url };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Comments on an existing issue. The URL is the only locator. */
|
|
181
|
+
export function commentIssue(
|
|
182
|
+
gh: GhRun,
|
|
183
|
+
url: string,
|
|
184
|
+
input: IssueInput,
|
|
185
|
+
): { ok: true } | { ok: false; reason: string } {
|
|
186
|
+
const outcome = gh(["issue", "comment", url, "--body-file", "-"], watchdogComment(input));
|
|
187
|
+
if (outcome.code !== 0) return { ok: false, reason: ghFailureReason(outcome) };
|
|
188
|
+
return { ok: true };
|
|
189
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The watchdog's retained cycle history, read from the run directories the
|
|
6
|
+
* coordinator writes. It is a read-only projection: no file is created,
|
|
7
|
+
* repaired, or locked while the dashboard renders it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** The dashboard never lists more than the newest 100 run directories. */
|
|
11
|
+
export const CYCLE_HISTORY_LIMIT = 100;
|
|
12
|
+
|
|
13
|
+
export interface WatchdogCycleWindow {
|
|
14
|
+
start: string | null;
|
|
15
|
+
end: string | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface WatchdogCycle {
|
|
19
|
+
/** The cycle's start, UTC ISO 8601, taken from its report. */
|
|
20
|
+
at: string;
|
|
21
|
+
window: WatchdogCycleWindow;
|
|
22
|
+
action: string;
|
|
23
|
+
findings: number;
|
|
24
|
+
/** The GitHub issues this cycle's findings map to, in finding order. */
|
|
25
|
+
issues: string[];
|
|
26
|
+
/** The report's optional reason, absent when the report carries none. */
|
|
27
|
+
reason?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Resolves a finding fingerprint to the GitHub issue that covers it. */
|
|
31
|
+
export type WatchdogIssueResolver = (fingerprint: string) => string | undefined;
|
|
32
|
+
|
|
33
|
+
/** The cycle rows never repeat an issue link. */
|
|
34
|
+
function connectedIssues(
|
|
35
|
+
report: Record<string, unknown>,
|
|
36
|
+
resolve?: WatchdogIssueResolver,
|
|
37
|
+
): string[] {
|
|
38
|
+
const urls: string[] = [];
|
|
39
|
+
const add = (value: unknown) => {
|
|
40
|
+
if (typeof value === "string" && value.length > 0 && !urls.includes(value)) urls.push(value);
|
|
41
|
+
};
|
|
42
|
+
if (Array.isArray(report.findings)) {
|
|
43
|
+
for (const value of report.findings) {
|
|
44
|
+
const finding = record(value);
|
|
45
|
+
if (typeof finding?.fingerprint !== "string") continue;
|
|
46
|
+
add(resolve?.(finding.fingerprint));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (Array.isArray(report.issues)) {
|
|
50
|
+
for (const value of report.issues) add(record(value)?.url);
|
|
51
|
+
}
|
|
52
|
+
return urls;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
56
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
57
|
+
? (value as Record<string, unknown>)
|
|
58
|
+
: undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function windowOf(value: unknown): WatchdogCycleWindow {
|
|
62
|
+
const window = record(value);
|
|
63
|
+
return {
|
|
64
|
+
start: typeof window?.start === "string" ? window.start : null,
|
|
65
|
+
end: typeof window?.end === "string" ? window.end : null,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** One report, or undefined when it cannot be rendered as a cycle row. */
|
|
70
|
+
function cycleFromReport(
|
|
71
|
+
value: unknown,
|
|
72
|
+
resolve?: WatchdogIssueResolver,
|
|
73
|
+
): WatchdogCycle | undefined {
|
|
74
|
+
const report = record(value);
|
|
75
|
+
if (report === undefined) return undefined;
|
|
76
|
+
if (typeof report.cycle !== "string" || report.cycle.length === 0) return undefined;
|
|
77
|
+
return {
|
|
78
|
+
at: report.cycle,
|
|
79
|
+
window: windowOf(report.window),
|
|
80
|
+
action: typeof report.action === "string" ? report.action : "none",
|
|
81
|
+
findings: Array.isArray(report.findings) ? report.findings.length : 0,
|
|
82
|
+
issues: connectedIssues(report, resolve),
|
|
83
|
+
...(typeof report.reason === "string" ? { reason: report.reason } : {}),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function readJson(path: string): unknown {
|
|
88
|
+
try {
|
|
89
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
90
|
+
} catch {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The newest run directory names first, capped before any file is read. */
|
|
96
|
+
function runDirectories(watchdogDir: string): string[] {
|
|
97
|
+
try {
|
|
98
|
+
return readdirSync(join(watchdogDir, "runs"), { withFileTypes: true })
|
|
99
|
+
.filter((entry) => entry.isDirectory())
|
|
100
|
+
.map((entry) => entry.name)
|
|
101
|
+
.sort((left, right) => (left < right ? 1 : left > right ? -1 : 0))
|
|
102
|
+
.slice(0, CYCLE_HISTORY_LIMIT);
|
|
103
|
+
} catch {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The newest-first cycle history. When no run directory exists at all, the
|
|
110
|
+
* cycle already projected from `latest.json` is the single row; a run
|
|
111
|
+
* directory whose `report.json` is missing or unparsable is skipped without
|
|
112
|
+
* hiding every other row.
|
|
113
|
+
*/
|
|
114
|
+
export function readWatchdogCycles(
|
|
115
|
+
watchdogDir: string,
|
|
116
|
+
latestCycle: WatchdogCycle | undefined,
|
|
117
|
+
resolve?: WatchdogIssueResolver,
|
|
118
|
+
): WatchdogCycle[] {
|
|
119
|
+
const directories = runDirectories(watchdogDir);
|
|
120
|
+
if (directories.length === 0) return latestCycle === undefined ? [] : [latestCycle];
|
|
121
|
+
const cycles: WatchdogCycle[] = [];
|
|
122
|
+
for (const directory of directories) {
|
|
123
|
+
const cycle = cycleFromReport(
|
|
124
|
+
readJson(join(watchdogDir, "runs", directory, "report.json")),
|
|
125
|
+
resolve,
|
|
126
|
+
);
|
|
127
|
+
if (cycle !== undefined) cycles.push(cycle);
|
|
128
|
+
}
|
|
129
|
+
return cycles;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The cycle `latest.json` carries, when it carries a usable one. */
|
|
133
|
+
export function latestWatchdogCycle(
|
|
134
|
+
latest: unknown,
|
|
135
|
+
resolve?: WatchdogIssueResolver,
|
|
136
|
+
): WatchdogCycle | undefined {
|
|
137
|
+
return cycleFromReport(latest, resolve);
|
|
138
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
requireLinger,
|
|
5
|
+
renderUnit,
|
|
6
|
+
systemctl,
|
|
7
|
+
uniqueDirectories,
|
|
8
|
+
unitPaths,
|
|
9
|
+
writeUnit,
|
|
10
|
+
} from "./systemd";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Installs and starts the user timer. Idempotent: rerunning replaces the two
|
|
14
|
+
* units and re-enables the timer without duplicating anything.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const harness = resolve(import.meta.dir, "..");
|
|
18
|
+
const top = Bun.spawnSync({
|
|
19
|
+
cmd: ["git", "-C", harness, "rev-parse", "--show-toplevel"],
|
|
20
|
+
stdin: "ignore",
|
|
21
|
+
stdout: "pipe",
|
|
22
|
+
stderr: "pipe",
|
|
23
|
+
});
|
|
24
|
+
const root = top.stdout.toString().trim();
|
|
25
|
+
if (top.exitCode !== 0 || root.length === 0) {
|
|
26
|
+
fail("missing prerequisite: the harness is not inside a git checkout");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function fail(message: string): never {
|
|
30
|
+
console.error(`watchdog:install: ${message}`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const absoluteGitDir = Bun.spawnSync({
|
|
35
|
+
cmd: ["git", "-C", harness, "rev-parse", "--absolute-git-dir"],
|
|
36
|
+
stdin: "ignore",
|
|
37
|
+
stdout: "pipe",
|
|
38
|
+
stderr: "pipe",
|
|
39
|
+
}).stdout.toString().trim();
|
|
40
|
+
const commonGitDir = Bun.spawnSync({
|
|
41
|
+
cmd: ["git", "-C", harness, "rev-parse", "--path-format=absolute", "--git-common-dir"],
|
|
42
|
+
stdin: "ignore",
|
|
43
|
+
stdout: "pipe",
|
|
44
|
+
stderr: "pipe",
|
|
45
|
+
}).stdout.toString().trim();
|
|
46
|
+
if (absoluteGitDir.length > 0 && absoluteGitDir !== commonGitDir) {
|
|
47
|
+
fail("install runs from the canonical checkout, not a worktree");
|
|
48
|
+
}
|
|
49
|
+
const canonicalHarness = join(root, ".devboard");
|
|
50
|
+
try {
|
|
51
|
+
if (realpathSync(harness) !== realpathSync(canonicalHarness)) {
|
|
52
|
+
fail(`the harness must be ${canonicalHarness}`);
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
fail(`missing prerequisite: ${canonicalHarness} is not readable`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
requireLinger();
|
|
60
|
+
} catch (error) {
|
|
61
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const bun = process.execPath;
|
|
65
|
+
const omp = Bun.which("omp");
|
|
66
|
+
const git = Bun.which("git");
|
|
67
|
+
if (omp === undefined) fail("missing prerequisite: omp is not on PATH");
|
|
68
|
+
if (git === undefined) fail("missing prerequisite: git is not on PATH");
|
|
69
|
+
|
|
70
|
+
const values = {
|
|
71
|
+
ROOT: root,
|
|
72
|
+
HARNESS: canonicalHarness,
|
|
73
|
+
BUN: bun,
|
|
74
|
+
PATH: uniqueDirectories([bun, omp, git]),
|
|
75
|
+
};
|
|
76
|
+
const paths = unitPaths();
|
|
77
|
+
writeUnit(
|
|
78
|
+
paths.service,
|
|
79
|
+
renderUnit(join(import.meta.dir, "units", "devboard-watchdog.service"), values),
|
|
80
|
+
);
|
|
81
|
+
writeUnit(
|
|
82
|
+
paths.timer,
|
|
83
|
+
renderUnit(join(import.meta.dir, "units", "devboard-watchdog.timer"), values),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const reload = systemctl("daemon-reload");
|
|
87
|
+
if (reload.code !== 0) fail(`daemon-reload failed: ${reload.stderr || reload.stdout}`);
|
|
88
|
+
const enabled = systemctl("enable", "--now", "devboard-watchdog.timer");
|
|
89
|
+
if (enabled.code !== 0) fail(`could not enable the timer: ${enabled.stderr || enabled.stdout}`);
|
|
90
|
+
|
|
91
|
+
const isEnabled = systemctl("is-enabled", "devboard-watchdog.timer");
|
|
92
|
+
if (isEnabled.stdout !== "enabled") fail(`the timer is ${isEnabled.stdout || "not enabled"}`);
|
|
93
|
+
const isActive = systemctl("is-active", "devboard-watchdog.timer");
|
|
94
|
+
if (isActive.stdout !== "active") fail(`the timer is ${isActive.stdout || "not active"}`);
|
|
95
|
+
const linger = requireLinger();
|
|
96
|
+
const timers = systemctl("list-timers", "devboard-watchdog.timer", "--all");
|
|
97
|
+
if (!/devboard-watchdog\.timer/.test(timers.stdout)) {
|
|
98
|
+
fail("the timer did not appear in list-timers");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
console.log(`watchdog:install: installed ${paths.service} and ${paths.timer}`);
|
|
102
|
+
console.log(`watchdog:install: timer enabled, active, ${linger}`);
|
|
103
|
+
console.log(timers.stdout);
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { Finding } from "./findings";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The ledger remembers every distinct signal and the GitHub issue it owns. It
|
|
7
|
+
* is harness state under `.tmp`, never committed, and it survives cycles so a
|
|
8
|
+
* recurrence comments on the existing issue instead of filing another one.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const LEDGER_VERSION = 2;
|
|
12
|
+
|
|
13
|
+
export interface LedgerInstance {
|
|
14
|
+
repoDir: string;
|
|
15
|
+
port: number;
|
|
16
|
+
pid: number;
|
|
17
|
+
journal: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface LedgerEntry {
|
|
21
|
+
category: string;
|
|
22
|
+
/** Last observed normalized evidence line. */
|
|
23
|
+
line: string;
|
|
24
|
+
firstSeen: string;
|
|
25
|
+
lastSeen: string;
|
|
26
|
+
count: number;
|
|
27
|
+
/** `filed:<url>` once the issue exists, `none` before the first filing. */
|
|
28
|
+
disposition: string;
|
|
29
|
+
url?: string;
|
|
30
|
+
filedAt?: string;
|
|
31
|
+
lastCommentAt?: string;
|
|
32
|
+
/** Every dashboard that has produced this signal. */
|
|
33
|
+
instances: LedgerInstance[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface Ledger {
|
|
37
|
+
version: typeof LEDGER_VERSION;
|
|
38
|
+
findings: Record<string, LedgerEntry>;
|
|
39
|
+
/** Fingerprints whose `gh issue create` failed and are retried first. */
|
|
40
|
+
pendingFiling: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The version 2 ledger, used when no file exists or the file is unusable. */
|
|
44
|
+
export function emptyLedger(): Ledger {
|
|
45
|
+
return { version: LEDGER_VERSION, findings: {}, pendingFiling: [] };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function text(value: unknown, fallback: string): string {
|
|
49
|
+
return typeof value === "string" && value.length > 0 ? value : fallback;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function instances(value: unknown): LedgerInstance[] {
|
|
53
|
+
if (!Array.isArray(value)) return [];
|
|
54
|
+
const parsed: LedgerInstance[] = [];
|
|
55
|
+
for (const candidate of value) {
|
|
56
|
+
if (typeof candidate !== "object" || candidate === null) continue;
|
|
57
|
+
const record = candidate as Record<string, unknown>;
|
|
58
|
+
if (typeof record.repoDir !== "string" || typeof record.journal !== "string") continue;
|
|
59
|
+
const port = Number(record.port);
|
|
60
|
+
const pid = Number(record.pid);
|
|
61
|
+
if (!Number.isInteger(port) || !Number.isInteger(pid)) continue;
|
|
62
|
+
parsed.push({ repoDir: record.repoDir, port, pid, journal: record.journal });
|
|
63
|
+
}
|
|
64
|
+
return parsed;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** One entry, or undefined when the record cannot be trusted. */
|
|
68
|
+
function parseEntry(fingerprint: string, value: unknown): LedgerEntry | undefined {
|
|
69
|
+
if (typeof value !== "object" || value === null) return undefined;
|
|
70
|
+
const record = value as Record<string, unknown>;
|
|
71
|
+
const disposition = text(record.disposition, "");
|
|
72
|
+
if (disposition !== "none" && !disposition.startsWith("filed:")) return undefined;
|
|
73
|
+
const url =
|
|
74
|
+
typeof record.url === "string"
|
|
75
|
+
? record.url
|
|
76
|
+
: disposition.startsWith("filed:")
|
|
77
|
+
? disposition.slice("filed:".length)
|
|
78
|
+
: undefined;
|
|
79
|
+
if (disposition.startsWith("filed:") && (url === undefined || url.length === 0)) return undefined;
|
|
80
|
+
if (typeof record.category !== "string" || typeof record.line !== "string") return undefined;
|
|
81
|
+
if (typeof record.firstSeen !== "string" || typeof record.lastSeen !== "string") return undefined;
|
|
82
|
+
const count = Number(record.count);
|
|
83
|
+
return {
|
|
84
|
+
category: record.category,
|
|
85
|
+
line: record.line,
|
|
86
|
+
firstSeen: record.firstSeen,
|
|
87
|
+
lastSeen: record.lastSeen,
|
|
88
|
+
count: Number.isFinite(count) && count > 0 ? count : 1,
|
|
89
|
+
disposition,
|
|
90
|
+
...(url === undefined ? {} : { url }),
|
|
91
|
+
...(typeof record.filedAt === "string" ? { filedAt: record.filedAt } : {}),
|
|
92
|
+
...(typeof record.lastCommentAt === "string" ? { lastCommentAt: record.lastCommentAt } : {}),
|
|
93
|
+
instances: instances(record.instances),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Reads the ledger. A version 1 file recorded `blocked:*` and `published:<sha>`
|
|
99
|
+
* dispositions from the retired fix pipeline: those entries describe prose, not
|
|
100
|
+
* issues, so they are discarded instead of migrated.
|
|
101
|
+
*/
|
|
102
|
+
export function loadLedger(stateDir: string): Ledger {
|
|
103
|
+
try {
|
|
104
|
+
const parsed: unknown = JSON.parse(readFileSync(join(stateDir, "state.json"), "utf8"));
|
|
105
|
+
if (typeof parsed !== "object" || parsed === null) return emptyLedger();
|
|
106
|
+
const record = parsed as Record<string, unknown>;
|
|
107
|
+
if (record.version !== LEDGER_VERSION) return emptyLedger();
|
|
108
|
+
const findings: Record<string, LedgerEntry> = {};
|
|
109
|
+
const source = record.findings;
|
|
110
|
+
if (typeof source === "object" && source !== null) {
|
|
111
|
+
for (const [fingerprint, value] of Object.entries(source)) {
|
|
112
|
+
const entry = parseEntry(fingerprint, value);
|
|
113
|
+
if (entry !== undefined) findings[fingerprint] = entry;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const pending = Array.isArray(record.pendingFiling)
|
|
117
|
+
? record.pendingFiling.filter(
|
|
118
|
+
(value): value is string => typeof value === "string" && findings[value] !== undefined,
|
|
119
|
+
)
|
|
120
|
+
: [];
|
|
121
|
+
return { version: LEDGER_VERSION, findings, pendingFiling: [...new Set(pending)] };
|
|
122
|
+
} catch {
|
|
123
|
+
return emptyLedger();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function saveLedger(stateDir: string, ledger: Ledger): void {
|
|
128
|
+
mkdirSync(stateDir, { recursive: true });
|
|
129
|
+
const path = join(stateDir, "state.json");
|
|
130
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
131
|
+
writeFileSync(temporary, `${JSON.stringify(ledger, null, 2)}\n`);
|
|
132
|
+
renameSync(temporary, path);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Records every finding in the window, creating or extending its entry. */
|
|
136
|
+
export function recordFindings(ledger: Ledger, findings: Finding[], now: Date): void {
|
|
137
|
+
const stamp = now.toISOString();
|
|
138
|
+
for (const finding of findings) {
|
|
139
|
+
const entry = ledger.findings[finding.fingerprint];
|
|
140
|
+
const contributed = finding.instances.map((instance) => ({
|
|
141
|
+
repoDir: instance.repoDir,
|
|
142
|
+
port: instance.port,
|
|
143
|
+
pid: instance.pid,
|
|
144
|
+
journal: instance.journal,
|
|
145
|
+
}));
|
|
146
|
+
if (entry === undefined) {
|
|
147
|
+
ledger.findings[finding.fingerprint] = {
|
|
148
|
+
category: finding.category,
|
|
149
|
+
line: finding.quote,
|
|
150
|
+
firstSeen: stamp,
|
|
151
|
+
lastSeen: stamp,
|
|
152
|
+
count: 1,
|
|
153
|
+
disposition: "none",
|
|
154
|
+
instances: contributed,
|
|
155
|
+
};
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
entry.category = finding.category;
|
|
159
|
+
entry.line = finding.quote;
|
|
160
|
+
entry.lastSeen = stamp;
|
|
161
|
+
entry.count += 1;
|
|
162
|
+
for (const instance of contributed) {
|
|
163
|
+
if (!entry.instances.some((known) => known.journal === instance.journal)) {
|
|
164
|
+
entry.instances.push(instance);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function markFiled(ledger: Ledger, fingerprint: string, url: string, now: Date): void {
|
|
171
|
+
const entry = ledger.findings[fingerprint];
|
|
172
|
+
if (entry === undefined) return;
|
|
173
|
+
entry.disposition = `filed:${url}`;
|
|
174
|
+
entry.url = url;
|
|
175
|
+
entry.filedAt = now.toISOString();
|
|
176
|
+
ledger.pendingFiling = ledger.pendingFiling.filter((pending) => pending !== fingerprint);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function markCommented(ledger: Ledger, fingerprint: string, now: Date): void {
|
|
180
|
+
const entry = ledger.findings[fingerprint];
|
|
181
|
+
if (entry === undefined) return;
|
|
182
|
+
entry.lastCommentAt = now.toISOString();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The fingerprint is waiting for a retry until its filing succeeds. */
|
|
186
|
+
export function markPending(ledger: Ledger, fingerprint: string): void {
|
|
187
|
+
if (!ledger.pendingFiling.includes(fingerprint)) ledger.pendingFiling.push(fingerprint);
|
|
188
|
+
}
|
package/watchdog/lock.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { mkdirSync, openSync, readFileSync, unlinkSync, writeSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One machine-wide lock serialises every watchdog invocation. The lock file
|
|
6
|
+
* records the owner's pid, so a crashed run cannot wedge the timer forever.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type LockResult =
|
|
10
|
+
| { acquired: true; release: () => void }
|
|
11
|
+
| { acquired: false; owner?: number };
|
|
12
|
+
|
|
13
|
+
function pidAlive(pid: number): boolean {
|
|
14
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
15
|
+
try {
|
|
16
|
+
process.kill(pid, 0);
|
|
17
|
+
return true;
|
|
18
|
+
} catch (error) {
|
|
19
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function owner(path: string): number | undefined {
|
|
24
|
+
try {
|
|
25
|
+
const pid = Number.parseInt(readFileSync(path, "utf8").trim(), 10);
|
|
26
|
+
return Number.isInteger(pid) && pid > 0 ? pid : undefined;
|
|
27
|
+
} catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function acquireLock(path: string): LockResult {
|
|
33
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
34
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
35
|
+
try {
|
|
36
|
+
const descriptor = openSync(path, "wx");
|
|
37
|
+
writeSync(descriptor, `${process.pid}\n`);
|
|
38
|
+
return {
|
|
39
|
+
acquired: true,
|
|
40
|
+
release() {
|
|
41
|
+
try {
|
|
42
|
+
unlinkSync(path);
|
|
43
|
+
} catch {
|
|
44
|
+
// Another cycle or the operator already removed it.
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
50
|
+
const holder = owner(path);
|
|
51
|
+
if (holder !== undefined && pidAlive(holder)) {
|
|
52
|
+
return { acquired: false, owner: holder };
|
|
53
|
+
}
|
|
54
|
+
// The owner exited without releasing; reclaim the lock once.
|
|
55
|
+
try {
|
|
56
|
+
unlinkSync(path);
|
|
57
|
+
} catch {
|
|
58
|
+
// A concurrent invocation won the race.
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { acquired: false };
|
|
63
|
+
}
|