@dsdenes/devboard 0.1.559 → 0.1.562
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
package/issues/lock.ts
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
fstatSync,
|
|
5
|
+
linkSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
openSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
renameSync,
|
|
10
|
+
unlinkSync,
|
|
11
|
+
writeSync,
|
|
12
|
+
} from "node:fs";
|
|
13
|
+
import { dirname } from "node:path";
|
|
14
|
+
import { isInteger, isRecord } from "./guards";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* One repository-anchored lock serialises every worker start for a checkout,
|
|
18
|
+
* whichever dashboard and state directory asked for it. It records the owner
|
|
19
|
+
* pid and start time, so a killed worker cannot wedge the repository forever
|
|
20
|
+
* and a recycled pid is not mistaken for the owner.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export interface LockOwner {
|
|
24
|
+
pid: number;
|
|
25
|
+
/** ISO 8601 start time of the owning process, from its own clock. */
|
|
26
|
+
startedAt: string;
|
|
27
|
+
repo: string;
|
|
28
|
+
stateDir: string;
|
|
29
|
+
harnessVersion: string | null;
|
|
30
|
+
argv: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type LockResult =
|
|
34
|
+
| { acquired: true; owner: LockOwner; release: () => void }
|
|
35
|
+
| { acquired: false; owner?: LockOwner };
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Linux reports the process start in `/proc/<pid>/stat` as ticks of USER_HZ,
|
|
39
|
+
* which is 100 on every supported Linux build.
|
|
40
|
+
*/
|
|
41
|
+
const CLOCK_TICKS_PER_SECOND = 100;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Slack between a recorded start and the same process's start read back from
|
|
45
|
+
* `/proc`. Locks written before the start time was recorded carry the
|
|
46
|
+
* acquisition instant instead, which trails the process start by startup time.
|
|
47
|
+
*/
|
|
48
|
+
const START_TIME_TOLERANCE_MS = 2_000;
|
|
49
|
+
|
|
50
|
+
export function pidAlive(pid: number): boolean {
|
|
51
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
52
|
+
try {
|
|
53
|
+
process.kill(pid, 0);
|
|
54
|
+
return true;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The instant a live process started, in epoch milliseconds, derived from
|
|
62
|
+
* `/proc/<pid>/stat` and the kernel's own uptime. Undefined when the platform
|
|
63
|
+
* has no `/proc` or the process start cannot be read.
|
|
64
|
+
*/
|
|
65
|
+
function processStartedAt(pid: number): number | undefined {
|
|
66
|
+
if (process.platform !== "linux") return undefined;
|
|
67
|
+
try {
|
|
68
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
69
|
+
// The command name may contain spaces and parentheses; fields resume after
|
|
70
|
+
// the last ")". That suffix starts at field 3 (state), so starttime, field
|
|
71
|
+
// 22, is index 19.
|
|
72
|
+
const close = stat.lastIndexOf(")");
|
|
73
|
+
if (close < 0) return undefined;
|
|
74
|
+
const startTicks = Number(stat.slice(close + 2).split(" ")[19]);
|
|
75
|
+
// Measured after the stat read, so process start = now - uptime + ticks.
|
|
76
|
+
const uptimeSeconds = Number(readFileSync("/proc/uptime", "utf8").split(" ")[0]);
|
|
77
|
+
if (!Number.isFinite(startTicks) || !Number.isFinite(uptimeSeconds)) return undefined;
|
|
78
|
+
return Date.now() - uptimeSeconds * 1000 + (startTicks * 1000) / CLOCK_TICKS_PER_SECOND;
|
|
79
|
+
} catch {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The instant this process started, for the lock it writes. */
|
|
85
|
+
function ownStartedAt(): string {
|
|
86
|
+
const started = processStartedAt(process.pid) ?? Date.now() - process.uptime() * 1000;
|
|
87
|
+
return new Date(started).toISOString();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* True when the live process with `owner.pid` is the lock's own owner, not a
|
|
92
|
+
* recycled pid. A live pid whose start does not match the recorded instant is
|
|
93
|
+
* stale: the owner died and the OS reused its pid. Without an identity source
|
|
94
|
+
* (no `/proc` on this platform) a live pid is the only available proof.
|
|
95
|
+
*/
|
|
96
|
+
function ownerAlive(owner: LockOwner): boolean {
|
|
97
|
+
if (!pidAlive(owner.pid)) return false;
|
|
98
|
+
const recorded = Date.parse(owner.startedAt);
|
|
99
|
+
if (!Number.isFinite(recorded)) return false;
|
|
100
|
+
const actual = processStartedAt(owner.pid);
|
|
101
|
+
if (actual === undefined) return process.platform !== "linux";
|
|
102
|
+
return Math.abs(actual - recorded) <= START_TIME_TOLERANCE_MS;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface ParsedLock {
|
|
106
|
+
owner: LockOwner | undefined;
|
|
107
|
+
/** Per-acquisition identity; absent on locks written by older harnesses. */
|
|
108
|
+
token: string | undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseLock(content: string): ParsedLock {
|
|
112
|
+
try {
|
|
113
|
+
const parsed: unknown = JSON.parse(content);
|
|
114
|
+
if (!isRecord(parsed)) return { owner: undefined, token: undefined };
|
|
115
|
+
if (!isInteger(parsed.pid) || typeof parsed.startedAt !== "string") {
|
|
116
|
+
return { owner: undefined, token: undefined };
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
owner: {
|
|
120
|
+
pid: parsed.pid,
|
|
121
|
+
startedAt: parsed.startedAt,
|
|
122
|
+
repo: typeof parsed.repo === "string" ? parsed.repo : "",
|
|
123
|
+
stateDir: typeof parsed.stateDir === "string" ? parsed.stateDir : "",
|
|
124
|
+
harnessVersion: typeof parsed.harnessVersion === "string" ? parsed.harnessVersion : null,
|
|
125
|
+
argv: Array.isArray(parsed.argv)
|
|
126
|
+
? parsed.argv.filter((entry): entry is string => typeof entry === "string")
|
|
127
|
+
: [],
|
|
128
|
+
},
|
|
129
|
+
token: typeof parsed.token === "string" ? parsed.token : undefined,
|
|
130
|
+
};
|
|
131
|
+
} catch {
|
|
132
|
+
return { owner: undefined, token: undefined };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The lock's owner, if the file is present and readable. */
|
|
137
|
+
export function readLock(path: string): LockOwner | undefined {
|
|
138
|
+
try {
|
|
139
|
+
return parseLock(readFileSync(path, "utf8")).owner;
|
|
140
|
+
} catch {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* A lock file's owner together with the identity of the file that carried it.
|
|
147
|
+
* The token distinguishes two lock files with the same owner fields, which is
|
|
148
|
+
* exactly what a stale lock and a freshly created one by the same process look
|
|
149
|
+
* like; the inode covers locks written before tokens existed.
|
|
150
|
+
*/
|
|
151
|
+
interface LockSnapshot {
|
|
152
|
+
owner: LockOwner | undefined;
|
|
153
|
+
token: string | undefined;
|
|
154
|
+
device: number;
|
|
155
|
+
inode: number;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Reads one lock file through a single descriptor, so content and identity agree. */
|
|
159
|
+
function readSnapshot(path: string): LockSnapshot | undefined {
|
|
160
|
+
let descriptor: number;
|
|
161
|
+
try {
|
|
162
|
+
descriptor = openSync(path, "r");
|
|
163
|
+
} catch {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
const stats = fstatSync(descriptor);
|
|
168
|
+
const parsed = parseLock(readFileSync(descriptor, "utf8"));
|
|
169
|
+
return {
|
|
170
|
+
owner: parsed.owner,
|
|
171
|
+
token: parsed.token,
|
|
172
|
+
device: stats.dev,
|
|
173
|
+
inode: stats.ino,
|
|
174
|
+
};
|
|
175
|
+
} catch {
|
|
176
|
+
return undefined;
|
|
177
|
+
} finally {
|
|
178
|
+
closeSync(descriptor);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* True when two snapshots are the same lock file. The written token is the
|
|
184
|
+
* primary identity because the filesystem can reuse a freed inode for the
|
|
185
|
+
* very lock that won the race; locks from older harnesses fall back to the
|
|
186
|
+
* inode.
|
|
187
|
+
*/
|
|
188
|
+
function sameLockFile(left: LockSnapshot, right: LockSnapshot): boolean {
|
|
189
|
+
if (left.token !== undefined || right.token !== undefined) {
|
|
190
|
+
return left.token !== undefined && left.token === right.token;
|
|
191
|
+
}
|
|
192
|
+
return left.device === right.device && left.inode === right.inode;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Reclaims `inspected` without ever deleting whatever currently sits at
|
|
197
|
+
* `path` by guess. The path is moved aside atomically; only a file that is
|
|
198
|
+
* still the inspected lock is discarded. A newer lock that arrived after the
|
|
199
|
+
* inspection is moved back instead of being destroyed.
|
|
200
|
+
*/
|
|
201
|
+
function reclaimStaleLock(path: string, inspected: LockSnapshot): void {
|
|
202
|
+
const aside = `${path}.reclaim-${process.pid}-${randomUUID()}`;
|
|
203
|
+
try {
|
|
204
|
+
renameSync(path, aside);
|
|
205
|
+
} catch {
|
|
206
|
+
// A concurrent starter already removed or replaced it; retry the create.
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const moved = readSnapshot(aside);
|
|
210
|
+
if (moved === undefined || !sameLockFile(inspected, moved)) {
|
|
211
|
+
try {
|
|
212
|
+
linkSync(aside, path);
|
|
213
|
+
} catch {
|
|
214
|
+
// The path holds a still newer lock; leave the moved file untouched
|
|
215
|
+
// rather than deleting a lock some live owner believes it holds.
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
unlinkSync(aside);
|
|
220
|
+
} catch {
|
|
221
|
+
// The restore already linked it back; the orphaned name is harmless.
|
|
222
|
+
}
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
unlinkSync(aside);
|
|
227
|
+
} catch {
|
|
228
|
+
// Another reclaimer observed the same stale inode and removed it.
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The live owner of a lock, or undefined when it is missing or stale. */
|
|
233
|
+
export function liveLockOwner(path: string): LockOwner | undefined {
|
|
234
|
+
const owner = readLock(path);
|
|
235
|
+
if (owner === undefined || !ownerAlive(owner)) return undefined;
|
|
236
|
+
return owner;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export interface AcquireLockOptions {
|
|
240
|
+
/**
|
|
241
|
+
* Test seam: called with the stale holder after inspection and before
|
|
242
|
+
* reclamation, so an interleaving starter can be driven deterministically.
|
|
243
|
+
*/
|
|
244
|
+
onStaleLock?: (owner: LockOwner | undefined) => void;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Takes the lock, or reports the live owner. A lock whose owner is gone or
|
|
249
|
+
* whose pid was recycled is reclaimed once; a second failure means a
|
|
250
|
+
* concurrent start won the race.
|
|
251
|
+
*/
|
|
252
|
+
export function acquireLock(
|
|
253
|
+
path: string,
|
|
254
|
+
details: Omit<LockOwner, "pid" | "startedAt">,
|
|
255
|
+
options: AcquireLockOptions = {},
|
|
256
|
+
): LockResult {
|
|
257
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
258
|
+
const owner: LockOwner = { ...details, pid: process.pid, startedAt: ownStartedAt() };
|
|
259
|
+
const token = randomUUID();
|
|
260
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
261
|
+
try {
|
|
262
|
+
// Publish a complete lock atomically: a reader that inspects the path
|
|
263
|
+
// must never see the empty or half-written window of a live lock.
|
|
264
|
+
const staged = `${path}.new-${process.pid}-${randomUUID()}`;
|
|
265
|
+
try {
|
|
266
|
+
const descriptor = openSync(staged, "wx");
|
|
267
|
+
try {
|
|
268
|
+
writeSync(descriptor, `${JSON.stringify({ ...owner, token })}\n`);
|
|
269
|
+
} finally {
|
|
270
|
+
closeSync(descriptor);
|
|
271
|
+
}
|
|
272
|
+
linkSync(staged, path);
|
|
273
|
+
} finally {
|
|
274
|
+
try {
|
|
275
|
+
unlinkSync(staged);
|
|
276
|
+
} catch {
|
|
277
|
+
// The staged name is removed whether the link won or lost.
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
acquired: true,
|
|
282
|
+
owner,
|
|
283
|
+
release() {
|
|
284
|
+
const current = readSnapshot(path);
|
|
285
|
+
if (current !== undefined && current.token !== token) return;
|
|
286
|
+
try {
|
|
287
|
+
unlinkSync(path);
|
|
288
|
+
} catch {
|
|
289
|
+
// Another invocation, or the operator, already removed it.
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
} catch (error) {
|
|
294
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
295
|
+
const snapshot = readSnapshot(path);
|
|
296
|
+
if (snapshot === undefined) continue;
|
|
297
|
+
if (snapshot.owner !== undefined && ownerAlive(snapshot.owner)) {
|
|
298
|
+
return { acquired: false, owner: snapshot.owner };
|
|
299
|
+
}
|
|
300
|
+
options.onStaleLock?.(snapshot.owner);
|
|
301
|
+
reclaimStaleLock(path, snapshot);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return { acquired: false };
|
|
305
|
+
}
|
package/issues/paths.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { join, resolve } from "node:path";
|
|
2
|
+
import { journalKey } from "../server/journal";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Every path one repository's issues worker owns. State lives under the
|
|
6
|
+
* dashboard's `STATE_DIR`, keyed by the repository path exactly like the
|
|
7
|
+
* dashboard journal, so two checkouts sharing one state directory never read
|
|
8
|
+
* each other's records. The lock deliberately lives in the checkout instead:
|
|
9
|
+
* one repository can have only one worker, whichever state directory a
|
|
10
|
+
* dashboard was configured with.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const ISSUES_DIRECTORY = "issues";
|
|
14
|
+
export const STATE_VERSION = 2;
|
|
15
|
+
export const LEDGER_VERSION = 1;
|
|
16
|
+
export const RUN_HISTORY_LIMIT = 50;
|
|
17
|
+
export const ACTIVITY_MAX_ENTRIES = 1000;
|
|
18
|
+
export const ACTIVITY_MAX_BYTES = 1024 * 1024;
|
|
19
|
+
export const ACTIVITY_TRIMMED_MARKER = "Earlier activity was trimmed.";
|
|
20
|
+
|
|
21
|
+
/** `<STATE_DIR>/issues`: every repository's records below one dashboard. */
|
|
22
|
+
export function issuesRoot(stateDir: string): string {
|
|
23
|
+
return join(stateDir, ISSUES_DIRECTORY);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** `<STATE_DIR>/issues/<journalKey(repoDir)>`. */
|
|
27
|
+
export function repoStateDirectory(stateDir: string, repoDir: string): string {
|
|
28
|
+
return join(issuesRoot(stateDir), journalKey(resolve(repoDir)));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function statePath(stateDir: string, repoDir: string): string {
|
|
32
|
+
return join(repoStateDirectory(stateDir, repoDir), "state.json");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function ledgerPath(stateDir: string, repoDir: string): string {
|
|
36
|
+
return join(repoStateDirectory(stateDir, repoDir), "ledger.json");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function runsDirectory(stateDir: string, repoDir: string): string {
|
|
40
|
+
return join(repoStateDirectory(stateDir, repoDir), "runs");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function runDirectory(stateDir: string, repoDir: string, runId: string): string {
|
|
44
|
+
return join(runsDirectory(stateDir, repoDir), runId);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Command files the dashboard writes and the worker consumes in order. */
|
|
48
|
+
export function controlDirectory(stateDir: string, repoDir: string): string {
|
|
49
|
+
return join(repoStateDirectory(stateDir, repoDir), "control");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The repository-anchored lock: `<REPO_DIR>/.devboard/.tmp/issues/lock`.
|
|
54
|
+
* A checkout cannot host two workers even when their dashboards disagree
|
|
55
|
+
* about the state directory.
|
|
56
|
+
*/
|
|
57
|
+
export function lockPath(repoDir: string): string {
|
|
58
|
+
return join(resolve(repoDir), ".devboard", ".tmp", ISSUES_DIRECTORY, "lock");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The removed systemd orchestrator's records, read only for migration. */
|
|
62
|
+
export function legacyDirectory(stateDir: string): string {
|
|
63
|
+
return join(stateDir, "orchestrator");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function legacyStatePath(stateDir: string): string {
|
|
67
|
+
return join(legacyDirectory(stateDir), "state.json");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function legacyLedgerPath(stateDir: string): string {
|
|
71
|
+
return join(legacyDirectory(stateDir), "ledger.json");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function legacyLockPath(stateDir: string): string {
|
|
75
|
+
return join(legacyDirectory(stateDir), "lock");
|
|
76
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
findOnPath,
|
|
5
|
+
ompCommandPaths,
|
|
6
|
+
ompExtensionPaths,
|
|
7
|
+
originForge,
|
|
8
|
+
rootProblem,
|
|
9
|
+
} from "./repository";
|
|
10
|
+
import {
|
|
11
|
+
configuredGiteaToken,
|
|
12
|
+
giteaBaseUrl,
|
|
13
|
+
type ForgeRemote,
|
|
14
|
+
} from "../lib/forge";
|
|
15
|
+
import { giteaRepository, type FetchLike } from "../lib/gitea";
|
|
16
|
+
import { legacyLiveLock, legacyLockRefusal } from "./legacy";
|
|
17
|
+
import type { GhOutcome } from "./tracker";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Everything Start must prove before it spawns a worker: the checkout is the
|
|
21
|
+
* canonical root of a GitHub origin, the agent tools exist, the command is
|
|
22
|
+
* installed, the credential can push, and no legacy orchestrator still owns
|
|
23
|
+
* the tracker. A refusal names the missing fact and writes no ACP peer.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export type PreflightResult = { ok: true; repository: string } | { ok: false; message: string };
|
|
27
|
+
|
|
28
|
+
export interface PreflightOptions {
|
|
29
|
+
repoDir: string;
|
|
30
|
+
stateDir: string;
|
|
31
|
+
environment?: Record<string, string | undefined>;
|
|
32
|
+
/** Injected for tests; defaults to the operator's `gh`. */
|
|
33
|
+
run?: (argv: string[]) => GhOutcome | Promise<GhOutcome>;
|
|
34
|
+
/** Injected transport for the Gitea API. */
|
|
35
|
+
giteaFetch?: FetchLike;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function gh(argv: string[], environment?: Record<string, string | undefined>): Promise<GhOutcome> {
|
|
39
|
+
try {
|
|
40
|
+
const child = Bun.spawn({
|
|
41
|
+
cmd: ["gh", ...argv],
|
|
42
|
+
stdin: "ignore",
|
|
43
|
+
stdout: "pipe",
|
|
44
|
+
stderr: "pipe",
|
|
45
|
+
env: {
|
|
46
|
+
...(environment ?? process.env),
|
|
47
|
+
GH_PROMPT_DISABLED: "1",
|
|
48
|
+
GH_NO_UPDATE_NOTIFIER: "1",
|
|
49
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
50
|
+
NO_COLOR: "1",
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
54
|
+
new Response(child.stdout).text(),
|
|
55
|
+
new Response(child.stderr).text(),
|
|
56
|
+
child.exited,
|
|
57
|
+
]);
|
|
58
|
+
return { code, stdout: stdout.trim(), stderr: stderr.trim() };
|
|
59
|
+
} catch (error) {
|
|
60
|
+
return { code: 1, stdout: "", stderr: error instanceof Error ? error.message : String(error) };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function firstLine(text: string): string | undefined {
|
|
65
|
+
return text
|
|
66
|
+
.split("\n")
|
|
67
|
+
.map((line) => line.trim())
|
|
68
|
+
.find((line) => line.length > 0);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function preflightIssues(options: PreflightOptions): Promise<PreflightResult> {
|
|
72
|
+
const environment = options.environment ?? process.env;
|
|
73
|
+
const root = rootProblem(options.repoDir);
|
|
74
|
+
if (root !== undefined) return { ok: false, message: root };
|
|
75
|
+
|
|
76
|
+
const remote = originForge(options.repoDir, environment);
|
|
77
|
+
if (remote === undefined) {
|
|
78
|
+
return { ok: false, message: "the checkout's origin does not name a supported repository" };
|
|
79
|
+
}
|
|
80
|
+
if (remote.forge === "gitlab") {
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
message: `the issues console supports a GitHub or Gitea origin, not a GitLab one: ${remote.host}`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const repository = remote.project;
|
|
87
|
+
|
|
88
|
+
const legacy = legacyLiveLock(options.stateDir);
|
|
89
|
+
if (legacy !== undefined) {
|
|
90
|
+
return { ok: false, message: legacyLockRefusal(legacy, options.stateDir) };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (findOnPath("omp", environment) === undefined) {
|
|
94
|
+
return { ok: false, message: "missing prerequisite: omp is not on PATH" };
|
|
95
|
+
}
|
|
96
|
+
if (remote.forge === "github" && findOnPath("gh", environment) === undefined) {
|
|
97
|
+
return { ok: false, message: "missing prerequisite: gh is not on PATH" };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const commands = ompCommandPaths(options.repoDir, environment).map((directory) =>
|
|
101
|
+
join(directory, "implement-issue.md"),
|
|
102
|
+
);
|
|
103
|
+
if (!commands.some((path) => existsSync(path))) {
|
|
104
|
+
return {
|
|
105
|
+
ok: false,
|
|
106
|
+
message:
|
|
107
|
+
"missing prerequisite: the /implement-issue command is not installed for omp " +
|
|
108
|
+
`(looked in ${commands.join(", ")}); install the harness wrappers with .devboard/install.sh`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const extensions = ompExtensionPaths(options.repoDir, environment).map((directory) =>
|
|
113
|
+
join(directory, "implement-goal-issue.ts"),
|
|
114
|
+
);
|
|
115
|
+
if (!extensions.some((path) => existsSync(path))) {
|
|
116
|
+
return {
|
|
117
|
+
ok: false,
|
|
118
|
+
message:
|
|
119
|
+
"missing prerequisite: the /implement-goal-issue extension is not installed for omp " +
|
|
120
|
+
`(looked in ${extensions.join(", ")}); install the harness wrappers with .devboard/install.sh`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (remote.forge === "gitea") {
|
|
125
|
+
const baseUrl = giteaBaseUrl(remote, environment);
|
|
126
|
+
if (baseUrl === undefined) {
|
|
127
|
+
return {
|
|
128
|
+
ok: false,
|
|
129
|
+
message:
|
|
130
|
+
"missing prerequisite: the Gitea address is not configured " +
|
|
131
|
+
"(set DEVBOARD_GITEA_URL or use an HTTP(S) origin)",
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const token = configuredGiteaToken(environment);
|
|
135
|
+
if (token === undefined) {
|
|
136
|
+
return { ok: false, message: "missing prerequisite: GITEA_TOKEN is not set" };
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
const document = await giteaRepository(
|
|
140
|
+
{ baseUrl, project: repository, token },
|
|
141
|
+
options.giteaFetch,
|
|
142
|
+
);
|
|
143
|
+
const permissions =
|
|
144
|
+
typeof document.permissions === "object" && document.permissions !== null
|
|
145
|
+
? document.permissions
|
|
146
|
+
: {};
|
|
147
|
+
const writable =
|
|
148
|
+
"push" in permissions && permissions.push === true ||
|
|
149
|
+
"admin" in permissions && permissions.admin === true;
|
|
150
|
+
if (!writable) {
|
|
151
|
+
return {
|
|
152
|
+
ok: false,
|
|
153
|
+
message:
|
|
154
|
+
`missing prerequisite: the Gitea token cannot write to ${repository} on ${baseUrl}`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
} catch (error) {
|
|
158
|
+
return {
|
|
159
|
+
ok: false,
|
|
160
|
+
message:
|
|
161
|
+
`missing prerequisite: the Gitea repository ${repository} could not be read: ` +
|
|
162
|
+
(error instanceof Error ? error.message : String(error)),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
return { ok: true, repository };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const execute = options.run ?? ((argv: string[]) => gh(argv, environment));
|
|
169
|
+
const permission = await execute([
|
|
170
|
+
"api",
|
|
171
|
+
`repos/${repository}`,
|
|
172
|
+
"--jq",
|
|
173
|
+
".permissions.push",
|
|
174
|
+
]);
|
|
175
|
+
if (permission.code !== 0 || permission.stdout.trim() !== "true") {
|
|
176
|
+
const detail =
|
|
177
|
+
firstLine(permission.stderr) ??
|
|
178
|
+
firstLine(permission.stdout) ??
|
|
179
|
+
`gh exited with code ${permission.code}`;
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
message:
|
|
183
|
+
`missing prerequisite: 'gh api repos/${repository} --jq .permissions.push' ` +
|
|
184
|
+
`did not return true: ${detail}`,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return { ok: true, repository };
|
|
189
|
+
}
|