@aragon-agent/cli 0.6.43 → 0.6.45
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/CHANGELOG.md +44 -0
- package/README.md +69 -2
- package/dist/agent/controller.d.ts +69 -0
- package/dist/agent/controller.js +224 -11
- package/dist/agent/governance-view.d.ts +40 -0
- package/dist/agent/governance-view.js +40 -0
- package/dist/agent/governance-wiring.d.ts +162 -0
- package/dist/agent/governance-wiring.js +470 -0
- package/dist/agent/plan-prompt.d.ts +7 -0
- package/dist/agent/plan-prompt.js +2 -1
- package/dist/agent/reducer.d.ts +33 -1
- package/dist/agent/reducer.js +45 -9
- package/dist/agent/system-prompt.d.ts +19 -0
- package/dist/agent/system-prompt.js +9 -1
- package/dist/cli.js +45 -1
- package/dist/commands/builtins.js +10 -4
- package/dist/commands/governance-command.d.ts +35 -0
- package/dist/commands/governance-command.js +292 -0
- package/dist/commands/governance-runs-table.d.ts +40 -0
- package/dist/commands/governance-runs-table.js +130 -0
- package/dist/commands/registry.d.ts +12 -0
- package/dist/config/app-paths.d.ts +10 -0
- package/dist/config/app-paths.js +12 -0
- package/dist/config/cli-commands.d.ts +18 -0
- package/dist/config/cli-commands.js +47 -1
- package/dist/config/env.d.ts +2 -1
- package/dist/config/env.js +10 -1
- package/dist/config/governance-config.d.ts +125 -0
- package/dist/config/governance-config.js +164 -0
- package/dist/config/governance-launch.d.ts +71 -0
- package/dist/config/governance-launch.js +58 -0
- package/dist/config/load.d.ts +15 -0
- package/dist/config/load.js +26 -1
- package/dist/config/model-profile-store.d.ts +4 -2
- package/dist/config/schema.d.ts +22 -0
- package/dist/config/schema.js +6 -0
- package/dist/config/store.js +14 -1
- package/dist/config/ui-state.d.ts +11 -0
- package/dist/config/ui-state.js +11 -0
- package/dist/diagnostics/doctor.js +20 -0
- package/dist/governance/card-normalize.d.ts +24 -0
- package/dist/governance/card-normalize.js +76 -0
- package/dist/governance/copy.d.ts +109 -0
- package/dist/governance/copy.js +223 -0
- package/dist/governance/engine/aftermath.d.ts +49 -0
- package/dist/governance/engine/aftermath.js +134 -0
- package/dist/governance/engine/artifact-persist.d.ts +35 -0
- package/dist/governance/engine/artifact-persist.js +86 -0
- package/dist/governance/engine/budget.d.ts +52 -0
- package/dist/governance/engine/budget.js +112 -0
- package/dist/governance/engine/cards.d.ts +18 -0
- package/dist/governance/engine/cards.js +186 -0
- package/dist/governance/engine/digest.d.ts +32 -0
- package/dist/governance/engine/digest.js +63 -0
- package/dist/governance/engine/engine-core.d.ts +219 -0
- package/dist/governance/engine/engine-core.js +562 -0
- package/dist/governance/engine/engine.d.ts +68 -0
- package/dist/governance/engine/engine.js +314 -0
- package/dist/governance/engine/estimate.d.ts +38 -0
- package/dist/governance/engine/estimate.js +46 -0
- package/dist/governance/engine/inspector.d.ts +16 -0
- package/dist/governance/engine/inspector.js +396 -0
- package/dist/governance/engine/integration.d.ts +35 -0
- package/dist/governance/engine/integration.js +220 -0
- package/dist/governance/engine/lifecycle.d.ts +29 -0
- package/dist/governance/engine/lifecycle.js +656 -0
- package/dist/governance/engine/pause.d.ts +56 -0
- package/dist/governance/engine/pause.js +107 -0
- package/dist/governance/engine/phase-members.d.ts +15 -0
- package/dist/governance/engine/phase-members.js +34 -0
- package/dist/governance/engine/phase-planner.d.ts +53 -0
- package/dist/governance/engine/phase-planner.js +282 -0
- package/dist/governance/engine/rebuild.d.ts +27 -0
- package/dist/governance/engine/rebuild.js +57 -0
- package/dist/governance/engine/report.d.ts +66 -0
- package/dist/governance/engine/report.js +220 -0
- package/dist/governance/engine/run-digest.d.ts +20 -0
- package/dist/governance/engine/run-digest.js +42 -0
- package/dist/governance/engine/spec-builder.d.ts +117 -0
- package/dist/governance/engine/spec-builder.js +321 -0
- package/dist/governance/engine/stage-runner.d.ts +22 -0
- package/dist/governance/engine/stage-runner.js +664 -0
- package/dist/governance/engine/start-input.d.ts +19 -0
- package/dist/governance/engine/start-input.js +111 -0
- package/dist/governance/engine/state-machine.d.ts +101 -0
- package/dist/governance/engine/state-machine.js +586 -0
- package/dist/governance/engine/submission-queries.d.ts +42 -0
- package/dist/governance/engine/submission-queries.js +97 -0
- package/dist/governance/engine/submission-render.d.ts +12 -0
- package/dist/governance/engine/submission-render.js +92 -0
- package/dist/governance/engine/submissions.d.ts +27 -0
- package/dist/governance/engine/submissions.js +455 -0
- package/dist/governance/engine/visibility.d.ts +104 -0
- package/dist/governance/engine/visibility.js +223 -0
- package/dist/governance/governed-dispatch.d.ts +19 -0
- package/dist/governance/governed-dispatch.js +17 -0
- package/dist/governance/limits.d.ts +107 -0
- package/dist/governance/limits.js +131 -0
- package/dist/governance/posture-transition.d.ts +38 -0
- package/dist/governance/posture-transition.js +44 -0
- package/dist/governance/posture.d.ts +35 -0
- package/dist/governance/posture.js +59 -0
- package/dist/governance/profiles.d.ts +48 -0
- package/dist/governance/profiles.js +140 -0
- package/dist/governance/prompts/orchestrator-block.d.ts +40 -0
- package/dist/governance/prompts/orchestrator-block.js +128 -0
- package/dist/governance/prompts/plan-addendum.d.ts +22 -0
- package/dist/governance/prompts/plan-addendum.js +60 -0
- package/dist/governance/prompts/refusals.d.ts +48 -0
- package/dist/governance/prompts/refusals.js +82 -0
- package/dist/governance/prompts/role-blocks.d.ts +40 -0
- package/dist/governance/prompts/role-blocks.js +255 -0
- package/dist/governance/report-reader.d.ts +31 -0
- package/dist/governance/report-reader.js +57 -0
- package/dist/governance/roles.d.ts +83 -0
- package/dist/governance/roles.js +142 -0
- package/dist/governance/rules/claims.d.ts +83 -0
- package/dist/governance/rules/claims.js +250 -0
- package/dist/governance/rules/elimination.d.ts +66 -0
- package/dist/governance/rules/elimination.js +105 -0
- package/dist/governance/rules/events-table.d.ts +46 -0
- package/dist/governance/rules/events-table.js +94 -0
- package/dist/governance/rules/failure-analysis.d.ts +28 -0
- package/dist/governance/rules/failure-analysis.js +93 -0
- package/dist/governance/rules/ledger-replay.d.ts +53 -0
- package/dist/governance/rules/ledger-replay.js +53 -0
- package/dist/governance/rules/ledger-state.d.ts +85 -0
- package/dist/governance/rules/ledger-state.js +175 -0
- package/dist/governance/rules/ledger.d.ts +228 -0
- package/dist/governance/rules/ledger.js +560 -0
- package/dist/governance/rules/rca.d.ts +68 -0
- package/dist/governance/rules/rca.js +220 -0
- package/dist/governance/rules/settlement.d.ts +129 -0
- package/dist/governance/rules/settlement.js +363 -0
- package/dist/governance/rules/suspicion.d.ts +66 -0
- package/dist/governance/rules/suspicion.js +169 -0
- package/dist/governance/store/artifacts.d.ts +81 -0
- package/dist/governance/store/artifacts.js +173 -0
- package/dist/governance/store/atomic.d.ts +79 -0
- package/dist/governance/store/atomic.js +119 -0
- package/dist/governance/store/events-log.d.ts +35 -0
- package/dist/governance/store/events-log.js +86 -0
- package/dist/governance/store/run-lock.d.ts +51 -0
- package/dist/governance/store/run-lock.js +122 -0
- package/dist/governance/store/run-store.d.ts +229 -0
- package/dist/governance/store/run-store.js +644 -0
- package/dist/governance/store/scoreboard-export.d.ts +53 -0
- package/dist/governance/store/scoreboard-export.js +65 -0
- package/dist/governance/tools/child-tools.d.ts +31 -0
- package/dist/governance/tools/child-tools.js +65 -0
- package/dist/governance/tools/guard.d.ts +71 -0
- package/dist/governance/tools/guard.js +194 -0
- package/dist/governance/tools/inspector-tools.d.ts +63 -0
- package/dist/governance/tools/inspector-tools.js +424 -0
- package/dist/governance/tools/lead-tools.d.ts +42 -0
- package/dist/governance/tools/lead-tools.js +226 -0
- package/dist/governance/tools/normalize-util.d.ts +101 -0
- package/dist/governance/tools/normalize-util.js +230 -0
- package/dist/governance/tools/scan-incomplete.d.ts +79 -0
- package/dist/governance/tools/scan-incomplete.js +486 -0
- package/dist/governance/tools/shared-tools.d.ts +44 -0
- package/dist/governance/tools/shared-tools.js +125 -0
- package/dist/governance/tools/submit-normalizers.d.ts +28 -0
- package/dist/governance/tools/submit-normalizers.js +474 -0
- package/dist/governance/tools/submit-tools.d.ts +26 -0
- package/dist/governance/tools/submit-tools.js +171 -0
- package/dist/governance/types.d.ts +661 -0
- package/dist/governance/types.js +27 -0
- package/dist/proc/kill-tree.d.ts +19 -1
- package/dist/proc/kill-tree.js +32 -5
- package/dist/proc/limits.d.ts +19 -0
- package/dist/proc/limits.js +19 -0
- package/dist/proc/supervisor.js +8 -1
- package/dist/session/validate-session.js +32 -1
- package/dist/team/runtime.d.ts +8 -0
- package/dist/team/runtime.js +9 -0
- package/dist/team/subagent.d.ts +23 -0
- package/dist/team/subagent.js +14 -3
- package/dist/team/types.d.ts +6 -0
- package/dist/tools/bounded-spawn.d.ts +56 -0
- package/dist/tools/bounded-spawn.js +76 -0
- package/dist/tools/index.d.ts +30 -7
- package/dist/tools/index.js +14 -0
- package/dist/tools/plan-tools.d.ts +14 -2
- package/dist/tools/plan-tools.js +5 -2
- package/dist/tools/search-tools.js +52 -44
- package/dist/ui/App.js +145 -25
- package/dist/ui/BottomStatusRow.js +1 -1
- package/dist/ui/Composer.d.ts +7 -1
- package/dist/ui/Composer.js +9 -6
- package/dist/ui/GovernancePanel.d.ts +50 -0
- package/dist/ui/GovernancePanel.js +77 -0
- package/dist/ui/GovernanceRun.d.ts +18 -0
- package/dist/ui/GovernanceRun.js +11 -0
- package/dist/ui/ScrollbackShell.d.ts +35 -7
- package/dist/ui/ScrollbackShell.js +1 -1
- package/dist/ui/StatusBar.d.ts +15 -2
- package/dist/ui/StatusBar.js +10 -4
- package/dist/ui/Transcript.js +3 -0
- package/dist/ui/entries/GovernanceCard.d.ts +30 -0
- package/dist/ui/entries/GovernanceCard.js +32 -0
- package/dist/ui/governance-copy.d.ts +69 -0
- package/dist/ui/governance-copy.js +104 -0
- package/dist/ui/governance-status.d.ts +145 -0
- package/dist/ui/governance-status.js +326 -0
- package/dist/ui/interaction-copy.d.ts +4 -0
- package/dist/ui/interaction-copy.js +5 -0
- package/dist/ui/interaction-hints.d.ts +17 -0
- package/dist/ui/interaction-hints.js +14 -2
- package/dist/ui/layout/governance-board.d.ts +121 -0
- package/dist/ui/layout/governance-board.js +382 -0
- package/dist/ui/layout/governance-card.d.ts +53 -0
- package/dist/ui/layout/governance-card.js +134 -0
- package/dist/ui/layout/governance-chip.d.ts +38 -0
- package/dist/ui/layout/governance-chip.js +64 -0
- package/dist/ui/layout/governance-panel.d.ts +85 -0
- package/dist/ui/layout/governance-panel.js +358 -0
- package/dist/ui/layout/governance-roster.d.ts +65 -0
- package/dist/ui/layout/governance-roster.js +275 -0
- package/dist/ui/layout/status-detail-layout.js +3 -3
- package/dist/ui/layout/status-layout.d.ts +5 -2
- package/dist/ui/layout/status-layout.js +10 -6
- package/dist/ui/layout/virtual-window.js +11 -0
- package/dist/ui/overlays/GovernanceBoardOverlay.d.ts +41 -0
- package/dist/ui/overlays/GovernanceBoardOverlay.js +76 -0
- package/dist/ui/overlays/GovernanceSettingsPage.d.ts +48 -0
- package/dist/ui/overlays/GovernanceSettingsPage.js +93 -0
- package/dist/ui/overlays/HelpOverlay.js +4 -3
- package/dist/ui/overlays/QuestionOverlay.d.ts +9 -1
- package/dist/ui/overlays/QuestionOverlay.js +4 -2
- package/dist/ui/overlays/SettingsScreen.d.ts +7 -0
- package/dist/ui/overlays/SettingsScreen.js +21 -1
- package/dist/ui/overlays/governance-settings.d.ts +79 -0
- package/dist/ui/overlays/governance-settings.js +140 -0
- package/dist/ui/overlays/model-profile-state.d.ts +6 -1
- package/dist/ui/overlays/model-profile-state.js +10 -0
- package/dist/ui/palettes.d.ts +17 -0
- package/dist/ui/palettes.js +9 -0
- package/dist/ui/scrollback/LiveTail.d.ts +3 -1
- package/dist/ui/scrollback/budget.d.ts +12 -6
- package/dist/ui/scrollback/budget.js +11 -4
- package/dist/ui/scrollback/live-preview.js +2 -0
- package/dist/ui/scrollback/settled-boundary.js +3 -0
- package/dist/ui/scrollback/tail-anchor.d.ts +5 -2
- package/dist/ui/scrollback/tail-anchor.js +3 -1
- package/dist/ui/shortcuts.js +2 -2
- package/dist/ui/theme.d.ts +7 -0
- package/dist/ui/theme.js +1 -0
- package/dist/ui/transcript-text.js +9 -0
- package/dist/ui/use-wheel-routing.js +3 -1
- package/package.json +2 -2
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifact storage: validated submissions and reports under
|
|
3
|
+
* `<run>/artifacts/<phase>/<wpId>/<stage>-<identity>-<n>.json|.md` (governance-mode
|
|
4
|
+
* sections 8.3 and 12.1).
|
|
5
|
+
*
|
|
6
|
+
* ASCII ONLY (`governance/` is glyph-scanned).
|
|
7
|
+
*
|
|
8
|
+
* IDS ARE STABLE STRINGS: `p1/dev1/review-rev1-1` = phase 1 (1-based, `p` prefix), work
|
|
9
|
+
* package `dev1`, stage `review`, written by identity `rev1`, its 1st artifact. The id is
|
|
10
|
+
* also the relative path (minus extension), so `gov_read` can resolve an id without an
|
|
11
|
+
* index and a human can find the file by eye.
|
|
12
|
+
*
|
|
13
|
+
* EVERY ARTIFACT CARRIES A VISIBILITY TAG (`work | review | test | inspector`), derived
|
|
14
|
+
* from the stage and never from content. `engine/visibility.ts` filters on it; keeping the
|
|
15
|
+
* tag in the id's stage segment means the information boundary cannot be defeated by an
|
|
16
|
+
* artifact whose body claims to be something else.
|
|
17
|
+
*
|
|
18
|
+
* Phase-level artifacts (settle / integrate reports) have no work package; they use the
|
|
19
|
+
* reserved wp segment `_phase`.
|
|
20
|
+
*/
|
|
21
|
+
import { GOV_LIMITS } from '../limits.js';
|
|
22
|
+
export const PHASE_WP_SEGMENT = '_phase';
|
|
23
|
+
const TRUNCATION_MARK = '[truncated]';
|
|
24
|
+
/** U+FFFD, built from its code point so this file stays pure ASCII. */
|
|
25
|
+
const TRAILING_REPLACEMENT_CHARS = new RegExp(`${String.fromCharCode(0xfffd)}+$`);
|
|
26
|
+
const SEGMENT = /^[a-z0-9_-]{1,32}$/;
|
|
27
|
+
const FILE_STEM = /^([a-z]+)-([a-z0-9-]+)-(\d+)$/;
|
|
28
|
+
/** The visibility tag of an artifact is a pure function of the stage that produced it. */
|
|
29
|
+
export function artifactKindForStage(stage) {
|
|
30
|
+
switch (stage) {
|
|
31
|
+
case 'dev':
|
|
32
|
+
case 'fix':
|
|
33
|
+
return 'work';
|
|
34
|
+
case 'review':
|
|
35
|
+
return 'review';
|
|
36
|
+
case 'test':
|
|
37
|
+
return 'test';
|
|
38
|
+
case 'settle':
|
|
39
|
+
case 'integrate':
|
|
40
|
+
return 'inspector';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function buildArtifactId(ref) {
|
|
44
|
+
return `p${ref.phase}/${ref.wpId}/${ref.stage}-${ref.identity}-${ref.n}`;
|
|
45
|
+
}
|
|
46
|
+
const STAGES = new Set([
|
|
47
|
+
'dev',
|
|
48
|
+
'review',
|
|
49
|
+
'test',
|
|
50
|
+
'settle',
|
|
51
|
+
'integrate',
|
|
52
|
+
'fix',
|
|
53
|
+
]);
|
|
54
|
+
/** Inverse of `buildArtifactId`. Returns null for anything that is not a well-formed id. */
|
|
55
|
+
export function parseArtifactId(id) {
|
|
56
|
+
const parts = id.split('/');
|
|
57
|
+
if (parts.length !== 3)
|
|
58
|
+
return null;
|
|
59
|
+
const [phasePart, wpId, file] = parts;
|
|
60
|
+
const phase = /^p(\d{1,3})$/.exec(phasePart);
|
|
61
|
+
const stem = FILE_STEM.exec(file);
|
|
62
|
+
if (!phase || !stem || !SEGMENT.test(wpId))
|
|
63
|
+
return null;
|
|
64
|
+
const stage = stem[1];
|
|
65
|
+
if (!STAGES.has(stage))
|
|
66
|
+
return null;
|
|
67
|
+
const ref = {
|
|
68
|
+
phase: Number(phase[1]),
|
|
69
|
+
wpId,
|
|
70
|
+
stage: stage,
|
|
71
|
+
identity: stem[2],
|
|
72
|
+
n: Number(stem[3]),
|
|
73
|
+
};
|
|
74
|
+
return { ...ref, id: buildArtifactId(ref), kind: artifactKindForStage(ref.stage) };
|
|
75
|
+
}
|
|
76
|
+
/** Cut `text` to at most `maxBytes` UTF-8 bytes without leaving a broken character. */
|
|
77
|
+
export function truncateUtf8(text, maxBytes) {
|
|
78
|
+
if (Buffer.byteLength(text) <= maxBytes)
|
|
79
|
+
return text;
|
|
80
|
+
const room = Math.max(0, maxBytes - Buffer.byteLength(`\n${TRUNCATION_MARK}`));
|
|
81
|
+
const cut = Buffer.from(text, 'utf8').subarray(0, room).toString('utf8');
|
|
82
|
+
return `${cut.replace(TRAILING_REPLACEMENT_CHARS, '')}\n${TRUNCATION_MARK}`;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Serialize a machine-form submission within `submissionBytes`. JSON cannot be cut
|
|
86
|
+
* mid-text, so an oversized body is replaced by a small valid envelope carrying a head
|
|
87
|
+
* excerpt and the original size; the marker keeps the cut visible to every reader.
|
|
88
|
+
*/
|
|
89
|
+
export function boundedJson(value, maxBytes = GOV_LIMITS.submissionBytes) {
|
|
90
|
+
const full = JSON.stringify(value, null, 2);
|
|
91
|
+
if (Buffer.byteLength(full) <= maxBytes)
|
|
92
|
+
return full;
|
|
93
|
+
const head = truncateUtf8(full, Math.floor(maxBytes / 2));
|
|
94
|
+
return JSON.stringify({ truncated: true, originalBytes: Buffer.byteLength(full), head }, null, 2);
|
|
95
|
+
}
|
|
96
|
+
/** Thin domain layer over `RunStore`'s budgeted file primitives. */
|
|
97
|
+
export class ArtifactStore {
|
|
98
|
+
store;
|
|
99
|
+
constructor(store) {
|
|
100
|
+
this.store = store;
|
|
101
|
+
}
|
|
102
|
+
/** Write `.json` (and `.md`) as artifact number n+1 for this (phase, wp, stage, who). */
|
|
103
|
+
async write(runId, input) {
|
|
104
|
+
const existing = await this.list(runId);
|
|
105
|
+
const same = existing.filter((r) => r.phase === input.phase &&
|
|
106
|
+
r.wpId === input.wpId &&
|
|
107
|
+
r.stage === input.stage &&
|
|
108
|
+
r.identity === input.identity);
|
|
109
|
+
const n = same.reduce((max, r) => Math.max(max, r.n), 0) + 1;
|
|
110
|
+
const ref = {
|
|
111
|
+
id: buildArtifactId({ ...input, n }),
|
|
112
|
+
phase: input.phase,
|
|
113
|
+
wpId: input.wpId,
|
|
114
|
+
stage: input.stage,
|
|
115
|
+
identity: input.identity,
|
|
116
|
+
n,
|
|
117
|
+
kind: artifactKindForStage(input.stage),
|
|
118
|
+
};
|
|
119
|
+
const base = ref.id;
|
|
120
|
+
const jsonText = boundedJson(input.json);
|
|
121
|
+
const jsonResult = await this.store.writeArtifactFile(runId, `${base}.json`, jsonText);
|
|
122
|
+
if (!jsonResult.ok)
|
|
123
|
+
return { ok: false, exhausted: true, message: jsonResult.message };
|
|
124
|
+
let bytes = jsonResult.bytes;
|
|
125
|
+
if (input.markdown !== undefined) {
|
|
126
|
+
const md = truncateUtf8(input.markdown, GOV_LIMITS.submissionBytes);
|
|
127
|
+
const mdResult = await this.store.writeArtifactFile(runId, `${base}.md`, md);
|
|
128
|
+
if (!mdResult.ok)
|
|
129
|
+
return { ok: false, exhausted: true, message: mdResult.message };
|
|
130
|
+
bytes += mdResult.bytes;
|
|
131
|
+
}
|
|
132
|
+
return { ok: true, ref, bytes };
|
|
133
|
+
}
|
|
134
|
+
/** Every artifact in the run, ordered by phase, wp, stage, identity, n. */
|
|
135
|
+
async list(runId) {
|
|
136
|
+
const refs = [];
|
|
137
|
+
for (const phaseDir of await this.store.listRunDir(runId, 'artifacts')) {
|
|
138
|
+
if (!phaseDir.isDirectory)
|
|
139
|
+
continue;
|
|
140
|
+
for (const wpDir of await this.store.listRunDir(runId, `artifacts/${phaseDir.name}`)) {
|
|
141
|
+
if (!wpDir.isDirectory)
|
|
142
|
+
continue;
|
|
143
|
+
const dir = `artifacts/${phaseDir.name}/${wpDir.name}`;
|
|
144
|
+
for (const file of await this.store.listRunDir(runId, dir)) {
|
|
145
|
+
if (!file.name.endsWith('.json'))
|
|
146
|
+
continue;
|
|
147
|
+
const ref = parseArtifactId(`${phaseDir.name}/${wpDir.name}/${file.name.slice(0, -5)}`);
|
|
148
|
+
if (ref)
|
|
149
|
+
refs.push(ref);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return refs.sort((a, b) => a.id.localeCompare(b.id, 'en', { numeric: true }));
|
|
154
|
+
}
|
|
155
|
+
/** Resolve an id (never a path) to its content, or null if absent / malformed. */
|
|
156
|
+
async read(runId, id) {
|
|
157
|
+
const ref = parseArtifactId(id);
|
|
158
|
+
if (!ref)
|
|
159
|
+
return null;
|
|
160
|
+
const jsonText = await this.store.readRunFile(runId, `artifacts/${ref.id}.json`);
|
|
161
|
+
if (jsonText === null)
|
|
162
|
+
return null;
|
|
163
|
+
let json;
|
|
164
|
+
try {
|
|
165
|
+
json = JSON.parse(jsonText);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
const markdown = await this.store.readRunFile(runId, `artifacts/${ref.id}.md`);
|
|
171
|
+
return { ref, json, markdown };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic file replacement and durable line append for the governance store
|
|
3
|
+
* (governance-mode section 12.1 "write discipline").
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY (`governance/` is glyph-scanned).
|
|
6
|
+
*
|
|
7
|
+
* WHY THIS LIVES HERE AND IS NOT BORROWED FROM `session/`. The two trees must not couple:
|
|
8
|
+
* a change to session persistence (different retry table, different permissions) must not
|
|
9
|
+
* silently alter how the governance TRUTH file is written. Recorded here so nobody
|
|
10
|
+
* "helpfully" deduplicates it.
|
|
11
|
+
*
|
|
12
|
+
* ATOMIC REPLACE = same-directory temp file + rename. Same directory because `rename`
|
|
13
|
+
* is only atomic within one filesystem. On Windows an antivirus scanner or the search
|
|
14
|
+
* indexer routinely holds a freshly written file for a few milliseconds, which makes
|
|
15
|
+
* `rename` fail with EPERM / EBUSY / EACCES; those three are retried 3 times at
|
|
16
|
+
* 25 / 50 / 100 ms. Anything else is a real failure and is thrown at once.
|
|
17
|
+
*
|
|
18
|
+
* The filesystem is injected (`GovFs`) so a test can make `rename` fail on demand
|
|
19
|
+
* without touching the real disk - the only honest way to cover the retry path.
|
|
20
|
+
*/
|
|
21
|
+
/** The slice of a file handle the store needs. `fs.promises.FileHandle` satisfies it. */
|
|
22
|
+
export interface GovFileHandle {
|
|
23
|
+
write(data: string): Promise<unknown>;
|
|
24
|
+
sync(): Promise<void>;
|
|
25
|
+
close(): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
export interface GovStat {
|
|
28
|
+
size: number;
|
|
29
|
+
mtimeMs: number;
|
|
30
|
+
isDirectory(): boolean;
|
|
31
|
+
}
|
|
32
|
+
/** The filesystem seam. Defaults to `node:fs/promises`; tests substitute a wrapper. */
|
|
33
|
+
export interface GovFs {
|
|
34
|
+
mkdir(path: string, options: {
|
|
35
|
+
recursive: true;
|
|
36
|
+
mode: number;
|
|
37
|
+
}): Promise<unknown>;
|
|
38
|
+
open(path: string, flags: string, mode: number): Promise<GovFileHandle>;
|
|
39
|
+
rename(from: string, to: string): Promise<void>;
|
|
40
|
+
unlink(path: string): Promise<void>;
|
|
41
|
+
rm(path: string, options: {
|
|
42
|
+
recursive: true;
|
|
43
|
+
force: true;
|
|
44
|
+
}): Promise<void>;
|
|
45
|
+
readFile(path: string): Promise<string>;
|
|
46
|
+
readdir(path: string): Promise<string[]>;
|
|
47
|
+
stat(path: string): Promise<GovStat>;
|
|
48
|
+
}
|
|
49
|
+
export declare const nodeGovFs: GovFs;
|
|
50
|
+
/** Directories 0700, files 0600 (POSIX; ignored by Windows). */
|
|
51
|
+
export declare const DIR_MODE = 448;
|
|
52
|
+
export declare const FILE_MODE = 384;
|
|
53
|
+
/** Backoff for the Windows rename race: 3 retries. */
|
|
54
|
+
export declare const RENAME_RETRY_DELAYS_MS: readonly number[];
|
|
55
|
+
export interface AtomicOptions {
|
|
56
|
+
fs?: GovFs;
|
|
57
|
+
/** Defaults to `process.platform`; injected so the retry path is testable anywhere. */
|
|
58
|
+
platform?: NodeJS.Platform;
|
|
59
|
+
/** Defaults to a real timer; tests pass a no-op or a recorder. */
|
|
60
|
+
sleep?: (ms: number) => Promise<void>;
|
|
61
|
+
}
|
|
62
|
+
/** mkdir -p with the store's directory mode. */
|
|
63
|
+
export declare function ensureDir(path: string, fs?: GovFs): Promise<void>;
|
|
64
|
+
/**
|
|
65
|
+
* Replace `path` with `data` atomically. Creates the parent directory if missing.
|
|
66
|
+
* On failure the temp file is removed (best effort) and the ORIGINAL error is thrown,
|
|
67
|
+
* so the previous content of `path` is never lost.
|
|
68
|
+
*/
|
|
69
|
+
export declare function atomicWriteFile(path: string, data: string, options?: AtomicOptions): Promise<void>;
|
|
70
|
+
/**
|
|
71
|
+
* Append one line (a trailing newline is added) and fsync before returning. This is the
|
|
72
|
+
* durability point of `events.jsonl`: a returned promise means the line is on disk.
|
|
73
|
+
* `leadingNewline` repairs a log whose last line was torn by a crash (no final newline),
|
|
74
|
+
* so the new row does not get glued onto the fragment.
|
|
75
|
+
*/
|
|
76
|
+
export declare function appendLineFsync(path: string, line: string, options?: {
|
|
77
|
+
fs?: GovFs;
|
|
78
|
+
leadingNewline?: boolean;
|
|
79
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic file replacement and durable line append for the governance store
|
|
3
|
+
* (governance-mode section 12.1 "write discipline").
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY (`governance/` is glyph-scanned).
|
|
6
|
+
*
|
|
7
|
+
* WHY THIS LIVES HERE AND IS NOT BORROWED FROM `session/`. The two trees must not couple:
|
|
8
|
+
* a change to session persistence (different retry table, different permissions) must not
|
|
9
|
+
* silently alter how the governance TRUTH file is written. Recorded here so nobody
|
|
10
|
+
* "helpfully" deduplicates it.
|
|
11
|
+
*
|
|
12
|
+
* ATOMIC REPLACE = same-directory temp file + rename. Same directory because `rename`
|
|
13
|
+
* is only atomic within one filesystem. On Windows an antivirus scanner or the search
|
|
14
|
+
* indexer routinely holds a freshly written file for a few milliseconds, which makes
|
|
15
|
+
* `rename` fail with EPERM / EBUSY / EACCES; those three are retried 3 times at
|
|
16
|
+
* 25 / 50 / 100 ms. Anything else is a real failure and is thrown at once.
|
|
17
|
+
*
|
|
18
|
+
* The filesystem is injected (`GovFs`) so a test can make `rename` fail on demand
|
|
19
|
+
* without touching the real disk - the only honest way to cover the retry path.
|
|
20
|
+
*/
|
|
21
|
+
import { randomBytes } from 'node:crypto';
|
|
22
|
+
import * as nodeFs from 'node:fs/promises';
|
|
23
|
+
import { dirname } from 'node:path';
|
|
24
|
+
export const nodeGovFs = {
|
|
25
|
+
mkdir: (path, options) => nodeFs.mkdir(path, options),
|
|
26
|
+
open: (path, flags, mode) => nodeFs.open(path, flags, mode),
|
|
27
|
+
rename: (from, to) => nodeFs.rename(from, to),
|
|
28
|
+
unlink: (path) => nodeFs.unlink(path),
|
|
29
|
+
rm: (path, options) => nodeFs.rm(path, options),
|
|
30
|
+
readFile: (path) => nodeFs.readFile(path, 'utf8'),
|
|
31
|
+
readdir: (path) => nodeFs.readdir(path),
|
|
32
|
+
stat: (path) => nodeFs.stat(path),
|
|
33
|
+
};
|
|
34
|
+
/** Directories 0700, files 0600 (POSIX; ignored by Windows). */
|
|
35
|
+
export const DIR_MODE = 0o700;
|
|
36
|
+
export const FILE_MODE = 0o600;
|
|
37
|
+
/** Backoff for the Windows rename race: 3 retries. */
|
|
38
|
+
export const RENAME_RETRY_DELAYS_MS = [25, 50, 100];
|
|
39
|
+
const RETRYABLE_CODES = new Set(['EPERM', 'EBUSY', 'EACCES']);
|
|
40
|
+
function realSleep(ms) {
|
|
41
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
42
|
+
}
|
|
43
|
+
function errorCode(error) {
|
|
44
|
+
if (error && typeof error === 'object' && 'code' in error) {
|
|
45
|
+
const code = error.code;
|
|
46
|
+
return typeof code === 'string' ? code : undefined;
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
/** mkdir -p with the store's directory mode. */
|
|
51
|
+
export async function ensureDir(path, fs = nodeGovFs) {
|
|
52
|
+
await fs.mkdir(path, { recursive: true, mode: DIR_MODE });
|
|
53
|
+
}
|
|
54
|
+
async function writeTempFile(fs, tempPath, data) {
|
|
55
|
+
const handle = await fs.open(tempPath, 'w', FILE_MODE);
|
|
56
|
+
try {
|
|
57
|
+
await handle.write(data);
|
|
58
|
+
// fsync BEFORE rename: otherwise a power loss can leave the renamed name pointing
|
|
59
|
+
// at a zero-length file, which is the exact torn state this helper exists to prevent.
|
|
60
|
+
await handle.sync();
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
await handle.close();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function renameWithRetry(fs, from, to, options) {
|
|
67
|
+
const delays = options.retry ? RENAME_RETRY_DELAYS_MS : [];
|
|
68
|
+
for (let attempt = 0;; attempt++) {
|
|
69
|
+
try {
|
|
70
|
+
await fs.rename(from, to);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
const code = errorCode(error);
|
|
75
|
+
const canRetry = attempt < delays.length && code !== undefined && RETRYABLE_CODES.has(code);
|
|
76
|
+
if (!canRetry)
|
|
77
|
+
throw error;
|
|
78
|
+
await options.sleep(delays[attempt]);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Replace `path` with `data` atomically. Creates the parent directory if missing.
|
|
84
|
+
* On failure the temp file is removed (best effort) and the ORIGINAL error is thrown,
|
|
85
|
+
* so the previous content of `path` is never lost.
|
|
86
|
+
*/
|
|
87
|
+
export async function atomicWriteFile(path, data, options = {}) {
|
|
88
|
+
const fs = options.fs ?? nodeGovFs;
|
|
89
|
+
const platform = options.platform ?? process.platform;
|
|
90
|
+
const sleep = options.sleep ?? realSleep;
|
|
91
|
+
await ensureDir(dirname(path), fs);
|
|
92
|
+
const tempPath = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
93
|
+
try {
|
|
94
|
+
await writeTempFile(fs, tempPath, data);
|
|
95
|
+
await renameWithRetry(fs, tempPath, path, { retry: platform === 'win32', sleep });
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
await fs.unlink(tempPath).catch(() => undefined);
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Append one line (a trailing newline is added) and fsync before returning. This is the
|
|
104
|
+
* durability point of `events.jsonl`: a returned promise means the line is on disk.
|
|
105
|
+
* `leadingNewline` repairs a log whose last line was torn by a crash (no final newline),
|
|
106
|
+
* so the new row does not get glued onto the fragment.
|
|
107
|
+
*/
|
|
108
|
+
export async function appendLineFsync(path, line, options = {}) {
|
|
109
|
+
const fs = options.fs ?? nodeGovFs;
|
|
110
|
+
await ensureDir(dirname(path), fs);
|
|
111
|
+
const handle = await fs.open(path, 'a', FILE_MODE);
|
|
112
|
+
try {
|
|
113
|
+
await handle.write(`${options.leadingNewline ? '\n' : ''}${line}\n`);
|
|
114
|
+
await handle.sync();
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
await handle.close();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `events.jsonl` parsing and the post-cap retention rule (governance-mode section 12.1).
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY (`governance/` is glyph-scanned).
|
|
5
|
+
*
|
|
6
|
+
* The event log is the TRUTH; `run.json` / `ledger.json` are checkpoints rebuilt from it.
|
|
7
|
+
* Parsing is therefore tolerant in exactly one way: a line that does not parse is skipped
|
|
8
|
+
* and counted, never fatal. A crash mid-append leaves a torn LAST line, which is normal
|
|
9
|
+
* and must not stop a resume; a corrupt MIDDLE line is surfaced via `badLines` so the
|
|
10
|
+
* caller can decide whether the log is still trustworthy.
|
|
11
|
+
*
|
|
12
|
+
* RETENTION PAST THE BYTE CAP. The spec says "keep only ledger / stage_* / escalation
|
|
13
|
+
* types". Taken literally that would drop `escrow`, `eliminated`, `respawned`, `claim`,
|
|
14
|
+
* `ruling` and `resolution` rows, each of which changes the replayed ledger or run state,
|
|
15
|
+
* so a literal reading would make a compacted log replay to a DIFFERENT ledger than its
|
|
16
|
+
* checkpoint (breaks I-G2). The kept set therefore includes every type that replay folds;
|
|
17
|
+
* what is dropped are the audit-only rows: `view_*`, `ownership_violation`, `budget` and
|
|
18
|
+
* `submission` (the submission itself lives in the artifact store).
|
|
19
|
+
*/
|
|
20
|
+
import type { StoredEvent, StoredEventType } from '../types.js';
|
|
21
|
+
/** Types that survive once `events.jsonl` has passed `GOV_LIMITS.eventsFileBytes`. */
|
|
22
|
+
export declare const EVENTS_KEPT_AFTER_CAP: ReadonlySet<StoredEventType>;
|
|
23
|
+
export interface ParsedEvents {
|
|
24
|
+
events: StoredEvent[];
|
|
25
|
+
/** Lines that were non-empty but did not parse into an event row. */
|
|
26
|
+
badLines: number;
|
|
27
|
+
lastSeq: number;
|
|
28
|
+
/** True when the file does not end with a newline (a torn tail). */
|
|
29
|
+
tornTail: boolean;
|
|
30
|
+
hasCompacted: boolean;
|
|
31
|
+
}
|
|
32
|
+
/** Parse the whole log. Order is file order; `lastSeq` is the maximum seen. */
|
|
33
|
+
export declare function parseEventsText(text: string): ParsedEvents;
|
|
34
|
+
/** One event row exactly as stored (single line, no trailing newline). */
|
|
35
|
+
export declare function serializeEvent(event: StoredEvent): string;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `events.jsonl` parsing and the post-cap retention rule (governance-mode section 12.1).
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY (`governance/` is glyph-scanned).
|
|
5
|
+
*
|
|
6
|
+
* The event log is the TRUTH; `run.json` / `ledger.json` are checkpoints rebuilt from it.
|
|
7
|
+
* Parsing is therefore tolerant in exactly one way: a line that does not parse is skipped
|
|
8
|
+
* and counted, never fatal. A crash mid-append leaves a torn LAST line, which is normal
|
|
9
|
+
* and must not stop a resume; a corrupt MIDDLE line is surfaced via `badLines` so the
|
|
10
|
+
* caller can decide whether the log is still trustworthy.
|
|
11
|
+
*
|
|
12
|
+
* RETENTION PAST THE BYTE CAP. The spec says "keep only ledger / stage_* / escalation
|
|
13
|
+
* types". Taken literally that would drop `escrow`, `eliminated`, `respawned`, `claim`,
|
|
14
|
+
* `ruling` and `resolution` rows, each of which changes the replayed ledger or run state,
|
|
15
|
+
* so a literal reading would make a compacted log replay to a DIFFERENT ledger than its
|
|
16
|
+
* checkpoint (breaks I-G2). The kept set therefore includes every type that replay folds;
|
|
17
|
+
* what is dropped are the audit-only rows: `view_*`, `ownership_violation`, `budget` and
|
|
18
|
+
* `submission` (the submission itself lives in the artifact store).
|
|
19
|
+
*/
|
|
20
|
+
/** Types that survive once `events.jsonl` has passed `GOV_LIMITS.eventsFileBytes`. */
|
|
21
|
+
export const EVENTS_KEPT_AFTER_CAP = new Set([
|
|
22
|
+
'run_started',
|
|
23
|
+
'stage_started',
|
|
24
|
+
'stage_ended',
|
|
25
|
+
'ledger',
|
|
26
|
+
'claim',
|
|
27
|
+
'ruling',
|
|
28
|
+
'escrow',
|
|
29
|
+
'eliminated',
|
|
30
|
+
'respawned',
|
|
31
|
+
'escalation',
|
|
32
|
+
'resolution',
|
|
33
|
+
'run_paused',
|
|
34
|
+
'run_resumed',
|
|
35
|
+
'run_closed',
|
|
36
|
+
'compacted',
|
|
37
|
+
]);
|
|
38
|
+
function isEventRow(value) {
|
|
39
|
+
if (!value || typeof value !== 'object')
|
|
40
|
+
return false;
|
|
41
|
+
const row = value;
|
|
42
|
+
return (typeof row.seq === 'number' &&
|
|
43
|
+
typeof row.at === 'number' &&
|
|
44
|
+
typeof row.type === 'string' &&
|
|
45
|
+
typeof row.actor === 'string' &&
|
|
46
|
+
typeof row.payload === 'object' &&
|
|
47
|
+
row.payload !== null);
|
|
48
|
+
}
|
|
49
|
+
/** Parse the whole log. Order is file order; `lastSeq` is the maximum seen. */
|
|
50
|
+
export function parseEventsText(text) {
|
|
51
|
+
const events = [];
|
|
52
|
+
let badLines = 0;
|
|
53
|
+
let lastSeq = 0;
|
|
54
|
+
let hasCompacted = false;
|
|
55
|
+
for (const line of text.split('\n')) {
|
|
56
|
+
const trimmed = line.trim();
|
|
57
|
+
if (trimmed === '')
|
|
58
|
+
continue;
|
|
59
|
+
try {
|
|
60
|
+
const parsed = JSON.parse(trimmed);
|
|
61
|
+
if (!isEventRow(parsed)) {
|
|
62
|
+
badLines += 1;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
events.push(parsed);
|
|
66
|
+
if (parsed.seq > lastSeq)
|
|
67
|
+
lastSeq = parsed.seq;
|
|
68
|
+
if (parsed.type === 'compacted')
|
|
69
|
+
hasCompacted = true;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
badLines += 1;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
events,
|
|
77
|
+
badLines,
|
|
78
|
+
lastSeq,
|
|
79
|
+
tornTail: text.length > 0 && !text.endsWith('\n'),
|
|
80
|
+
hasCompacted,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** One event row exactly as stored (single line, no trailing newline). */
|
|
84
|
+
export function serializeEvent(event) {
|
|
85
|
+
return JSON.stringify(event);
|
|
86
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `run.lock` primitives (governance-mode sections 8.4 and 12.1).
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY (`governance/` is glyph-scanned).
|
|
5
|
+
*
|
|
6
|
+
* A lock file is `{pid, startedAt, heartbeatAt}`. The owner refreshes `heartbeatAt` every
|
|
7
|
+
* `GOV_LIMITS.lockHeartbeatMs` while a stage runs. A lock is LIVE only when BOTH the
|
|
8
|
+
* heartbeat is fresher than `lockStaleMs` AND the holder pid still exists: a crash
|
|
9
|
+
* followed by an immediate restart is the common case, and making the user wait a minute
|
|
10
|
+
* for a lock whose owner is provably dead would be a pointless papercut. The pid check
|
|
11
|
+
* only ever ENABLES a takeover (a recycled pid can at worst delay one until the
|
|
12
|
+
* heartbeat goes stale), so it cannot cause a double-owner.
|
|
13
|
+
*
|
|
14
|
+
* Cross-process mutual exclusion cannot rely on the in-process FIFO mutex, hence the
|
|
15
|
+
* exclusive-create (`wx`) first attempt: two processes racing on a missing lock file
|
|
16
|
+
* cannot both win it. Two processes racing to take over a STALE lock can, so a takeover
|
|
17
|
+
* re-reads the file after writing and yields if somebody else's pid is in it.
|
|
18
|
+
*/
|
|
19
|
+
import { type GovFs } from './atomic.js';
|
|
20
|
+
export interface LockInfo {
|
|
21
|
+
pid: number;
|
|
22
|
+
startedAt: number;
|
|
23
|
+
heartbeatAt: number;
|
|
24
|
+
}
|
|
25
|
+
export interface LockDeps {
|
|
26
|
+
fs?: GovFs;
|
|
27
|
+
now: () => number;
|
|
28
|
+
pid: number;
|
|
29
|
+
isPidAlive: (pid: number) => boolean;
|
|
30
|
+
staleMs: number;
|
|
31
|
+
}
|
|
32
|
+
export type AcquireResult = {
|
|
33
|
+
ok: true;
|
|
34
|
+
takenOver: boolean;
|
|
35
|
+
} | {
|
|
36
|
+
ok: false;
|
|
37
|
+
holderPid: number;
|
|
38
|
+
message: string;
|
|
39
|
+
};
|
|
40
|
+
/** `kill(pid, 0)`: ESRCH means gone, EPERM means alive but not ours. */
|
|
41
|
+
export declare function defaultIsPidAlive(pid: number): boolean;
|
|
42
|
+
/** Parse a lock file; a missing or corrupt file reads as `null` (treated as stale). */
|
|
43
|
+
export declare function readLock(path: string, fs?: GovFs): Promise<LockInfo | null>;
|
|
44
|
+
/** True when a lock must be honoured: fresh heartbeat AND a living holder. */
|
|
45
|
+
export declare function isLockLive(info: LockInfo | null, deps: LockDeps): boolean;
|
|
46
|
+
/** Take the lock, or explain who holds it. Never throws for contention. */
|
|
47
|
+
export declare function acquireLock(path: string, deps: LockDeps): Promise<AcquireResult>;
|
|
48
|
+
/** Refresh `heartbeatAt`. Returns false when the lock is no longer ours (lost or removed). */
|
|
49
|
+
export declare function refreshLock(path: string, deps: LockDeps): Promise<boolean>;
|
|
50
|
+
/** Remove the lock only if it is ours; a foreign lock is left untouched. */
|
|
51
|
+
export declare function removeLock(path: string, deps: LockDeps): Promise<void>;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `run.lock` primitives (governance-mode sections 8.4 and 12.1).
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY (`governance/` is glyph-scanned).
|
|
5
|
+
*
|
|
6
|
+
* A lock file is `{pid, startedAt, heartbeatAt}`. The owner refreshes `heartbeatAt` every
|
|
7
|
+
* `GOV_LIMITS.lockHeartbeatMs` while a stage runs. A lock is LIVE only when BOTH the
|
|
8
|
+
* heartbeat is fresher than `lockStaleMs` AND the holder pid still exists: a crash
|
|
9
|
+
* followed by an immediate restart is the common case, and making the user wait a minute
|
|
10
|
+
* for a lock whose owner is provably dead would be a pointless papercut. The pid check
|
|
11
|
+
* only ever ENABLES a takeover (a recycled pid can at worst delay one until the
|
|
12
|
+
* heartbeat goes stale), so it cannot cause a double-owner.
|
|
13
|
+
*
|
|
14
|
+
* Cross-process mutual exclusion cannot rely on the in-process FIFO mutex, hence the
|
|
15
|
+
* exclusive-create (`wx`) first attempt: two processes racing on a missing lock file
|
|
16
|
+
* cannot both win it. Two processes racing to take over a STALE lock can, so a takeover
|
|
17
|
+
* re-reads the file after writing and yields if somebody else's pid is in it.
|
|
18
|
+
*/
|
|
19
|
+
import { atomicWriteFile, nodeGovFs } from './atomic.js';
|
|
20
|
+
/** `kill(pid, 0)`: ESRCH means gone, EPERM means alive but not ours. */
|
|
21
|
+
export function defaultIsPidAlive(pid) {
|
|
22
|
+
try {
|
|
23
|
+
process.kill(pid, 0);
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
return error.code === 'EPERM';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function isLockInfo(value) {
|
|
31
|
+
if (!value || typeof value !== 'object')
|
|
32
|
+
return false;
|
|
33
|
+
const record = value;
|
|
34
|
+
return (typeof record.pid === 'number' &&
|
|
35
|
+
typeof record.startedAt === 'number' &&
|
|
36
|
+
typeof record.heartbeatAt === 'number');
|
|
37
|
+
}
|
|
38
|
+
/** Parse a lock file; a missing or corrupt file reads as `null` (treated as stale). */
|
|
39
|
+
export async function readLock(path, fs = nodeGovFs) {
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(await fs.readFile(path));
|
|
42
|
+
return isLockInfo(parsed) ? parsed : null;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** True when a lock must be honoured: fresh heartbeat AND a living holder. */
|
|
49
|
+
export function isLockLive(info, deps) {
|
|
50
|
+
if (!info)
|
|
51
|
+
return false;
|
|
52
|
+
if (info.pid === deps.pid)
|
|
53
|
+
return false;
|
|
54
|
+
const fresh = deps.now() - info.heartbeatAt < deps.staleMs;
|
|
55
|
+
return fresh && deps.isPidAlive(info.pid);
|
|
56
|
+
}
|
|
57
|
+
function refusal(info, deps) {
|
|
58
|
+
const ageSec = Math.max(0, Math.round((deps.now() - info.heartbeatAt) / 1000));
|
|
59
|
+
const waitSec = Math.max(1, Math.ceil((deps.staleMs - (deps.now() - info.heartbeatAt)) / 1000));
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
holderPid: info.pid,
|
|
63
|
+
message: `NOT ACCEPTED: this governance run is in use by another process (pid ${info.pid}, ` +
|
|
64
|
+
`last heartbeat ${ageSec}s ago). Close that session, or wait about ${waitSec}s ` +
|
|
65
|
+
'for its lock to expire.',
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function freshLockText(deps, startedAt = deps.now()) {
|
|
69
|
+
const info = { pid: deps.pid, startedAt, heartbeatAt: deps.now() };
|
|
70
|
+
return JSON.stringify(info);
|
|
71
|
+
}
|
|
72
|
+
async function tryExclusiveCreate(path, deps) {
|
|
73
|
+
const fs = deps.fs ?? nodeGovFs;
|
|
74
|
+
let handle;
|
|
75
|
+
try {
|
|
76
|
+
handle = await fs.open(path, 'wx', 0o600);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (error.code === 'EEXIST')
|
|
80
|
+
return false;
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
await handle.write(freshLockText(deps));
|
|
85
|
+
await handle.sync();
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
await handle.close();
|
|
89
|
+
}
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
/** Take the lock, or explain who holds it. Never throws for contention. */
|
|
93
|
+
export async function acquireLock(path, deps) {
|
|
94
|
+
const fs = deps.fs ?? nodeGovFs;
|
|
95
|
+
if (await tryExclusiveCreate(path, deps))
|
|
96
|
+
return { ok: true, takenOver: false };
|
|
97
|
+
const existing = await readLock(path, fs);
|
|
98
|
+
if (isLockLive(existing, deps))
|
|
99
|
+
return refusal(existing, deps);
|
|
100
|
+
await atomicWriteFile(path, freshLockText(deps), { fs });
|
|
101
|
+
const confirmed = await readLock(path, fs);
|
|
102
|
+
if (confirmed && confirmed.pid !== deps.pid)
|
|
103
|
+
return refusal(confirmed, deps);
|
|
104
|
+
return { ok: true, takenOver: existing !== null };
|
|
105
|
+
}
|
|
106
|
+
/** Refresh `heartbeatAt`. Returns false when the lock is no longer ours (lost or removed). */
|
|
107
|
+
export async function refreshLock(path, deps) {
|
|
108
|
+
const fs = deps.fs ?? nodeGovFs;
|
|
109
|
+
const existing = await readLock(path, fs);
|
|
110
|
+
if (!existing || existing.pid !== deps.pid)
|
|
111
|
+
return false;
|
|
112
|
+
await atomicWriteFile(path, freshLockText(deps, existing.startedAt), { fs });
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
/** Remove the lock only if it is ours; a foreign lock is left untouched. */
|
|
116
|
+
export async function removeLock(path, deps) {
|
|
117
|
+
const fs = deps.fs ?? nodeGovFs;
|
|
118
|
+
const existing = await readLock(path, fs);
|
|
119
|
+
if (existing && existing.pid !== deps.pid)
|
|
120
|
+
return;
|
|
121
|
+
await fs.unlink(path).catch(() => undefined);
|
|
122
|
+
}
|