@dzhechkov/harness-core 0.4.5 → 0.5.1
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/.dz-manifest.json +489 -105
- package/README.md +116 -5
- package/dist/agents-policy.d.ts +67 -0
- package/dist/agents-policy.d.ts.map +1 -0
- package/dist/agents-policy.js +258 -0
- package/dist/agents-policy.js.map +1 -0
- package/dist/codex-hooks-assets.d.ts +47 -0
- package/dist/codex-hooks-assets.d.ts.map +1 -0
- package/dist/codex-hooks-assets.js +324 -0
- package/dist/codex-hooks-assets.js.map +1 -0
- package/dist/codex-hooks-verify.d.ts +95 -0
- package/dist/codex-hooks-verify.d.ts.map +1 -0
- package/dist/codex-hooks-verify.js +169 -0
- package/dist/codex-hooks-verify.js.map +1 -0
- package/dist/codex-hooks.d.ts +341 -0
- package/dist/codex-hooks.d.ts.map +1 -0
- package/dist/codex-hooks.js +541 -0
- package/dist/codex-hooks.js.map +1 -0
- package/dist/discrimination-gate.d.ts +88 -15
- package/dist/discrimination-gate.d.ts.map +1 -1
- package/dist/discrimination-gate.js +343 -51
- package/dist/discrimination-gate.js.map +1 -1
- package/dist/feature-adr-checkpoints.d.ts +22 -0
- package/dist/feature-adr-checkpoints.d.ts.map +1 -1
- package/dist/feature-adr-checkpoints.js +42 -0
- package/dist/feature-adr-checkpoints.js.map +1 -1
- package/dist/feature-adr-routing.d.ts +218 -5
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +583 -54
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/guard.d.ts +13 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +25 -1
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +23 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +37 -4
- package/dist/index.js.map +1 -1
- package/dist/loop-blobs.generated.d.ts +1 -1
- package/dist/loop-blobs.generated.d.ts.map +1 -1
- package/dist/loop-blobs.generated.js +12 -3
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/loop-plan.d.ts +70 -0
- package/dist/loop-plan.d.ts.map +1 -1
- package/dist/loop-plan.js +103 -0
- package/dist/loop-plan.js.map +1 -1
- package/dist/loop-render.d.ts.map +1 -1
- package/dist/loop-render.js +37 -129
- package/dist/loop-render.js.map +1 -1
- package/dist/loop-run-semantics.d.ts +130 -0
- package/dist/loop-run-semantics.d.ts.map +1 -0
- package/dist/loop-run-semantics.js +257 -0
- package/dist/loop-run-semantics.js.map +1 -0
- package/dist/loop-trace.d.ts +31 -2
- package/dist/loop-trace.d.ts.map +1 -1
- package/dist/loop-trace.js +89 -12
- package/dist/loop-trace.js.map +1 -1
- package/dist/managed-hooks.d.ts +86 -0
- package/dist/managed-hooks.d.ts.map +1 -0
- package/dist/managed-hooks.js +101 -0
- package/dist/managed-hooks.js.map +1 -0
- package/dist/mutation-gate.d.ts +14 -0
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +25 -2
- package/dist/mutation-gate.js.map +1 -1
- package/dist/named-lock.d.ts +57 -0
- package/dist/named-lock.d.ts.map +1 -0
- package/dist/named-lock.js +247 -0
- package/dist/named-lock.js.map +1 -0
- package/dist/operations.d.ts +229 -0
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +880 -26
- package/dist/operations.js.map +1 -1
- package/dist/parity.d.ts +106 -1
- package/dist/parity.d.ts.map +1 -1
- package/dist/parity.js +209 -5
- package/dist/parity.js.map +1 -1
- package/dist/qe-bridge.d.ts +291 -0
- package/dist/qe-bridge.d.ts.map +1 -0
- package/dist/qe-bridge.js +538 -0
- package/dist/qe-bridge.js.map +1 -0
- package/dist/recall-usage.d.ts +53 -0
- package/dist/recall-usage.d.ts.map +1 -1
- package/dist/recall-usage.js +125 -2
- package/dist/recall-usage.js.map +1 -1
- package/dist/score.d.ts.map +1 -1
- package/dist/score.js +43 -9
- package/dist/score.js.map +1 -1
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +14 -26
- package/dist/setup.js.map +1 -1
- package/dist/shell-veto-policy.d.ts +53 -0
- package/dist/shell-veto-policy.d.ts.map +1 -0
- package/dist/shell-veto-policy.js +103 -0
- package/dist/shell-veto-policy.js.map +1 -0
- package/dist/skills.d.ts +86 -1
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +116 -1
- package/dist/skills.js.map +1 -1
- package/dist/targets.d.ts +75 -0
- package/dist/targets.d.ts.map +1 -1
- package/dist/targets.js +160 -0
- package/dist/targets.js.map +1 -1
- package/dist/workflow-run-dispatch.d.ts +230 -0
- package/dist/workflow-run-dispatch.d.ts.map +1 -0
- package/dist/workflow-run-dispatch.js +363 -0
- package/dist/workflow-run-dispatch.js.map +1 -0
- package/dist/workflow-run.d.ts +498 -0
- package/dist/workflow-run.d.ts.map +1 -0
- package/dist/workflow-run.js +1359 -0
- package/dist/workflow-run.js.map +1 -0
- package/package.json +6 -5
- package/sbom.json +1099 -139
- package/src/agents-policy.ts +338 -0
- package/src/codex-hooks-assets.ts +328 -0
- package/src/codex-hooks-verify.ts +237 -0
- package/src/codex-hooks.ts +723 -0
- package/src/discrimination-gate.ts +456 -58
- package/src/feature-adr-checkpoints.ts +38 -0
- package/src/feature-adr-routing.ts +697 -75
- package/src/guard.ts +36 -1
- package/src/index.ts +162 -2
- package/src/loop-blobs.generated.ts +12 -3
- package/src/loop-plan.ts +185 -0
- package/src/loop-render.ts +37 -127
- package/src/loop-run-semantics.ts +278 -0
- package/src/loop-trace.ts +84 -10
- package/src/managed-hooks.ts +150 -0
- package/src/mutation-gate.ts +24 -2
- package/src/named-lock.ts +277 -0
- package/src/operations.ts +1121 -29
- package/src/parity.ts +295 -6
- package/src/qe-bridge.ts +737 -0
- package/src/recall-usage.ts +184 -1
- package/src/score.ts +50 -9
- package/src/setup.ts +26 -27
- package/src/shell-veto-policy.ts +119 -0
- package/src/skills.ts +174 -1
- package/src/targets.ts +189 -0
- package/src/workflow-run-dispatch.ts +459 -0
- package/src/workflow-run.ts +1743 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE event-level managed-hook merge (`crossrt-2-codex-hooks`, AM-3 / G-E).
|
|
3
|
+
*
|
|
4
|
+
* Both hook targets — Claude Code's `.claude/settings.json` and Codex's
|
|
5
|
+
* `$CODEX_HOME/hooks.json` — need the same operation: per event, KEEP every entry that is not ours
|
|
6
|
+
* (byte-identical, same order) and append the entries that are. Before this module the Claude half
|
|
7
|
+
* lived inline in `setup.ts` and the Codex half did not exist; a second dialect would have been the
|
|
8
|
+
* obvious way to write it and the wrong one.
|
|
9
|
+
*
|
|
10
|
+
* What differs between the targets is ONLY the attribution predicate, so that is the parameter:
|
|
11
|
+
* the Claude path passes its historical substring list verbatim (bytes must not move — AM-3), the
|
|
12
|
+
* Codex path passes sha-over-manifest (ADR-001 §3: dz never deletes what it cannot prove it wrote).
|
|
13
|
+
*
|
|
14
|
+
* The plan (AM-37) is explicit that the extracted block has **three** outputs, not one: the merged
|
|
15
|
+
* body, the REPORT tail string, and the no-write path (`changed === false`). A golden test on the
|
|
16
|
+
* merged bytes alone would pass while the report text and the no-write behaviour silently changed,
|
|
17
|
+
* so all three are part of {@link HookMergePlan}.
|
|
18
|
+
*
|
|
19
|
+
* Pure: no I/O, no clock, no environment.
|
|
20
|
+
*
|
|
21
|
+
* @packageDocumentation
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** One hook registry entry, in either the matcher-group or the legacy flat shape. */
|
|
25
|
+
export type ManagedHookEntry = unknown;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Commands of a hook entry in EITHER shape: the valid matcher-group form
|
|
29
|
+
* `{matcher?, hooks:[{type,command}]}` or the legacy flat `{type,command}`.
|
|
30
|
+
*
|
|
31
|
+
* Deliberately duplicated from `setup.ts`'s `commandsOf` rather than imported: `setup.ts` imports
|
|
32
|
+
* this module, and the reverse edge would be a cycle. The two are pinned equal by a test.
|
|
33
|
+
*/
|
|
34
|
+
export function hookCommandsOf(entry: ManagedHookEntry): string[] {
|
|
35
|
+
const e = entry as { command?: unknown; hooks?: { command?: unknown }[] };
|
|
36
|
+
if (Array.isArray(e?.hooks)) return e.hooks.map((h) => String(h?.command ?? ''));
|
|
37
|
+
return [String(e?.command ?? '')];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface HookMergePlan {
|
|
41
|
+
/** The merged registry body, ready to serialize. */
|
|
42
|
+
readonly hooks: Record<string, ManagedHookEntry[]>;
|
|
43
|
+
/** False ⇒ the caller must NOT write (the `hooks already current` no-write path — AM-37). */
|
|
44
|
+
readonly changed: boolean;
|
|
45
|
+
/** The setup REPORT tail string. Part of the extracted block's contract, not decoration (AM-37). */
|
|
46
|
+
readonly report: string;
|
|
47
|
+
/** A legacy-shaped entry of ours was replaced (drives the Claude report wording). */
|
|
48
|
+
readonly replacedLegacy: boolean;
|
|
49
|
+
/** Entries kept because they are NOT ours, across the WHOLE registry. G-D's number. */
|
|
50
|
+
readonly foreignPreserved: number;
|
|
51
|
+
/**
|
|
52
|
+
* Entries that RESEMBLE ours but are not attributable to the manifest. They are KEPT and counted —
|
|
53
|
+
* never deleted (ADR-001 §3). Distinct from `replacedLegacy`, which is about our OWN old vintages.
|
|
54
|
+
*/
|
|
55
|
+
readonly unattributable: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface MergeManagedHookOptions {
|
|
59
|
+
/** True ⇒ this entry is OURS and is replaced by the managed set for that event. */
|
|
60
|
+
readonly isManaged: (entry: ManagedHookEntry, event: string) => boolean;
|
|
61
|
+
/**
|
|
62
|
+
* True ⇒ this entry resembles ours but could not be attributed. Optional; when omitted nothing is
|
|
63
|
+
* counted. Never causes deletion — only counting and a warning upstream.
|
|
64
|
+
*/
|
|
65
|
+
readonly looksLikeOurs?: (entry: ManagedHookEntry, event: string) => boolean;
|
|
66
|
+
/** True ⇒ this OURS entry was in a legacy shape/vintage (Claude path only). */
|
|
67
|
+
readonly isLegacy?: (entry: ManagedHookEntry, event: string) => boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Per-HANDLER salvage for an entry `isManaged` claimed. Given an OWNED entry, return it rebuilt
|
|
70
|
+
* from only the handlers that are NOT ours, or `null` when every handler was ours.
|
|
71
|
+
*
|
|
72
|
+
* Optional, and absent means the historical whole-entry behaviour — the Claude path passes
|
|
73
|
+
* nothing and is byte-identical to before (AM-3). The Codex path passes it because attribution at
|
|
74
|
+
* matcher-group granularity deleted a foreign handler that merely shared a group with dz's
|
|
75
|
+
* (independent review, finding 6).
|
|
76
|
+
*/
|
|
77
|
+
readonly retainForeign?: (entry: ManagedHookEntry, event: string) => ManagedHookEntry | null;
|
|
78
|
+
/** e.g. `'agentdb'` → `merged agentdb hooks (user hooks preserved)`. */
|
|
79
|
+
readonly reportLabel?: string;
|
|
80
|
+
/** Report text when nothing changed. */
|
|
81
|
+
readonly unchangedReport?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Merge `managed` into `existingHooks` at EVENT level.
|
|
86
|
+
*
|
|
87
|
+
* - Events absent from `managed` are copied through untouched (including unknown ones), and their
|
|
88
|
+
* entries still count toward `foreignPreserved` — the number answers "how many of the user's
|
|
89
|
+
* entries did this registry hold and keep", not "how many survived the touched events".
|
|
90
|
+
* - Within a touched event, non-ours entries keep their relative order and object identity, then
|
|
91
|
+
* the managed entries are appended.
|
|
92
|
+
* - `merge(merge(x)) === merge(x)`.
|
|
93
|
+
*/
|
|
94
|
+
export function mergeManagedHookEntries(
|
|
95
|
+
existingHooks: Record<string, ManagedHookEntry[]> | undefined,
|
|
96
|
+
managed: Record<string, ManagedHookEntry[]>,
|
|
97
|
+
options: MergeManagedHookOptions,
|
|
98
|
+
): HookMergePlan {
|
|
99
|
+
const source = existingHooks ?? {};
|
|
100
|
+
const hooks: Record<string, ManagedHookEntry[]> = {};
|
|
101
|
+
for (const event of Object.keys(source)) {
|
|
102
|
+
hooks[event] = Array.isArray(source[event]) ? [...(source[event] as ManagedHookEntry[])] : [];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Census FIRST, over the whole input, so the counts do not depend on which events are touched.
|
|
106
|
+
let foreignPreserved = 0;
|
|
107
|
+
let unattributable = 0;
|
|
108
|
+
for (const [event, entries] of Object.entries(source)) {
|
|
109
|
+
if (!Array.isArray(entries)) continue;
|
|
110
|
+
for (const entry of entries) {
|
|
111
|
+
if (options.isManaged(entry, event)) {
|
|
112
|
+
// A mixed group counts as a preserved foreign entry when something of the user's survives
|
|
113
|
+
// in it — the census must not report zero for a handler the merge actually keeps.
|
|
114
|
+
if (options.retainForeign?.(entry, event) != null) foreignPreserved += 1;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
foreignPreserved += 1;
|
|
118
|
+
if (options.looksLikeOurs?.(entry, event) === true) unattributable += 1;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let changed = false;
|
|
123
|
+
let replacedLegacy = false;
|
|
124
|
+
for (const event of Object.keys(managed)) {
|
|
125
|
+
const current = Array.isArray(hooks[event]) ? (hooks[event] as ManagedHookEntry[]) : [];
|
|
126
|
+
const kept: ManagedHookEntry[] = [];
|
|
127
|
+
for (const entry of current) {
|
|
128
|
+
const ours = options.isManaged(entry, event);
|
|
129
|
+
if (!ours) {
|
|
130
|
+
kept.push(entry);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (options.isLegacy?.(entry, event) === true) replacedLegacy = true;
|
|
134
|
+
const salvaged = options.retainForeign?.(entry, event) ?? null;
|
|
135
|
+
if (salvaged !== null) kept.push(salvaged);
|
|
136
|
+
}
|
|
137
|
+
const next = [...kept, ...(managed[event] ?? [])];
|
|
138
|
+
if (JSON.stringify(next) !== JSON.stringify(current)) changed = true;
|
|
139
|
+
hooks[event] = next;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const label = options.reportLabel ?? 'managed';
|
|
143
|
+
const report = !changed
|
|
144
|
+
? (options.unchangedReport ?? 'hooks already current')
|
|
145
|
+
: replacedLegacy
|
|
146
|
+
? `replaced legacy dz hooks with ${label} hooks`
|
|
147
|
+
: `merged ${label} hooks (user hooks preserved)`;
|
|
148
|
+
|
|
149
|
+
return { hooks, changed, report, replacedLegacy, foreignPreserved, unattributable };
|
|
150
|
+
}
|
package/src/mutation-gate.ts
CHANGED
|
@@ -317,6 +317,26 @@ function stripSgr(s: string): string {
|
|
|
317
317
|
|
|
318
318
|
export type RunnerKind = 'node-test' | 'vitest' | 'unknown';
|
|
319
319
|
|
|
320
|
+
/**
|
|
321
|
+
* Which runner's output shape is this? Extracted VERBATIM from `classifyRunFailure`'s two shape
|
|
322
|
+
* checks so the discrimination gate's evidence model can identify the runner of a GREEN run too —
|
|
323
|
+
* `classifyRunFailure` classifies RED runs only (its green branch returns a red-worded
|
|
324
|
+
* 'unrecognised'), so it cannot answer "which runner produced this pass?".
|
|
325
|
+
*
|
|
326
|
+
* ONE regex family per package (the ADR-001 driver): every runner-shape regex in harness-core
|
|
327
|
+
* lives here, and discrimination-gate.ts consumes this function instead of growing a second copy.
|
|
328
|
+
* Detection is from the OUTPUT SHAPE, not the command (an `npm test` alias hides the runner):
|
|
329
|
+
* node --test needs the TAP header AND node's `# duration_ms` trailer (tape emits TAP + `# fail`
|
|
330
|
+
* but not `# duration_ms`); vitest needs its `RUN v<semver>` banner or `Test Files` summary line.
|
|
331
|
+
* Anything else is 'unknown' — the honest, narrowed gap, never a silent trust mint.
|
|
332
|
+
*/
|
|
333
|
+
export function detectRunnerKind(rawOutput: string): RunnerKind {
|
|
334
|
+
const output = stripSgr(rawOutput);
|
|
335
|
+
if (/^TAP version \d+/m.test(output) && /^#\s*duration_ms\s+[\d.]+/m.test(output)) return 'node-test';
|
|
336
|
+
if (/^\s*RUN\s+v\d+\./m.test(output) || /^\s*Test Files\s/m.test(output)) return 'vitest';
|
|
337
|
+
return 'unknown';
|
|
338
|
+
}
|
|
339
|
+
|
|
320
340
|
export interface RunFailureClassification {
|
|
321
341
|
/** which runner's output shape was recognised. */
|
|
322
342
|
readonly runner: RunnerKind;
|
|
@@ -373,9 +393,11 @@ export interface RunFailureClassification {
|
|
|
373
393
|
*/
|
|
374
394
|
export function classifyRunFailure(rawOutput: string): RunFailureClassification {
|
|
375
395
|
const output = stripSgr(rawOutput);
|
|
396
|
+
// shape detection is shared with the discrimination gate's evidence model (detectRunnerKind above).
|
|
397
|
+
const runner = detectRunnerKind(rawOutput);
|
|
376
398
|
|
|
377
399
|
// node --test (flat TAP, node ≥ 20 shape measured on v22.22.0)
|
|
378
|
-
if (
|
|
400
|
+
if (runner === 'node-test') {
|
|
379
401
|
const lines = output.split('\n');
|
|
380
402
|
let sawNotOk = false;
|
|
381
403
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -407,7 +429,7 @@ export function classifyRunFailure(rawOutput: string): RunFailureClassification
|
|
|
407
429
|
}
|
|
408
430
|
|
|
409
431
|
// vitest (`vitest run`, shape measured on 3.2.4)
|
|
410
|
-
if (
|
|
432
|
+
if (runner === 'vitest') {
|
|
411
433
|
const failedSuites = /\bFailed Suites\s+(\d+)\b/.exec(output);
|
|
412
434
|
const suiteFail = /^\s*FAIL\s+(\S+)\s+\[\s*\1\s*\]/m.exec(output);
|
|
413
435
|
if (failedSuites !== null || suiteFail !== null) {
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named advisory locks — `.dz/locks/<name>.lock` (feature qe-bridge-claude, ADR-001 D4-A).
|
|
3
|
+
*
|
|
4
|
+
* WHY. The pattern store already has a cross-process lock (`store-lock.ts`), but it guards ONE
|
|
5
|
+
* resource. Other read-modify-write surfaces in this repo have the same lost-update shape and no
|
|
6
|
+
* guard — the first of them being the `$CODEX_HOME/hooks.json` merge writer, whose race was
|
|
7
|
+
* ACCEPTED as a degradation with an explicit exit condition naming this leg
|
|
8
|
+
* (`architecture/degradations.md`). A read-modify-write that two processes interleave loses one
|
|
9
|
+
* side's addition, and BOTH report success — atomicity (temp+rename) prevents corruption; only
|
|
10
|
+
* mutual exclusion prevents a lost update.
|
|
11
|
+
*
|
|
12
|
+
* WHAT THIS IS. The same `proper-lockfile` machinery as `withStoreLockSync`: atomic `mkdir`
|
|
13
|
+
* acquisition, heartbeat-refreshed staleness (age alone is never liveness), a real acquisition
|
|
14
|
+
* DEADLINE, and a LOUD throw on timeout rather than proceeding unlocked — proceeding would restore
|
|
15
|
+
* exactly the silent lost update this exists to stop. It is emphatically NOT the hand-rolled `wx`
|
|
16
|
+
* file lock that an independent review graded F (`store-lock.ts:14-31`).
|
|
17
|
+
*
|
|
18
|
+
* WHY A MIRROR AND NOT A CALL. `store-lock.ts` keeps its internals private and this feature's
|
|
19
|
+
* out-of-scope fence forbids changing the store lock's semantics, so the ~40 lines of acquisition
|
|
20
|
+
* machinery are mirrored here rather than extracted. The duplication is deliberate and is a named
|
|
21
|
+
* unification candidate; the options type, the stale/timeout defaults and the environment override
|
|
22
|
+
* are IMPORTED from `store-lock.ts`, so the two cannot drift apart on their contract.
|
|
23
|
+
*
|
|
24
|
+
* HONEST LIMIT. This is an ADVISORY lock: it serializes writers that TAKE it. A foreign process
|
|
25
|
+
* that never heard of it writes whenever it likes. For `hooks.json` the pre-existing mitigations
|
|
26
|
+
* (foreign entries preserved byte-for-byte, timestamped backup, atomic temp+rename) remain the
|
|
27
|
+
* backstop for that case, and the rule doc says so in as many words.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
31
|
+
import { randomBytes } from 'node:crypto';
|
|
32
|
+
import { createRequire } from 'node:module';
|
|
33
|
+
import { join, resolve } from 'node:path';
|
|
34
|
+
import { lockSync } from 'proper-lockfile';
|
|
35
|
+
|
|
36
|
+
import { LOCK_TIMEOUT_MS, STALE_LOCK_MS } from './store-lock.js';
|
|
37
|
+
import type { StoreLockOptions } from './store-lock.js';
|
|
38
|
+
|
|
39
|
+
/** `proper-lockfile` silently clamps `stale` up to this minimum. */
|
|
40
|
+
const MIN_STALE_MS = 2_000;
|
|
41
|
+
|
|
42
|
+
/** Lock names are filenames: one bounded, lowercase, path-free component. */
|
|
43
|
+
const SAFE_LOCK_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
44
|
+
|
|
45
|
+
export function isSafeLockName(name: string): boolean {
|
|
46
|
+
return typeof name === 'string' && SAFE_LOCK_NAME.test(name);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Path of a named lock (a DIRECTORY while held). Throws on an unsafe name — a lock whose path a
|
|
50
|
+
* caller can steer is not a lock. */
|
|
51
|
+
export function namedLockPath(projectRoot: string, name: string): string {
|
|
52
|
+
if (!isSafeLockName(name)) {
|
|
53
|
+
throw new NamedLockNameError(name);
|
|
54
|
+
}
|
|
55
|
+
return join(projectRoot, '.dz', 'locks', `${name}.lock`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class NamedLockNameError extends Error {
|
|
59
|
+
readonly code = 'ELOCKNAME';
|
|
60
|
+
constructor(name: string) {
|
|
61
|
+
super(
|
|
62
|
+
`invalid lock name ${JSON.stringify(name)} — a lock name must match ${String(SAFE_LOCK_NAME)} ` +
|
|
63
|
+
'(one lowercase path-free component); a name carrying a separator would place the lock outside .dz/locks/',
|
|
64
|
+
);
|
|
65
|
+
this.name = 'NamedLockNameError';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Acquisition gave up at the deadline. Nothing ran; the operation can be retried. */
|
|
70
|
+
export class NamedLockTimeoutError extends Error {
|
|
71
|
+
readonly code = 'ELOCKTIMEOUT';
|
|
72
|
+
constructor(lockPath: string, waitedMs: number) {
|
|
73
|
+
super(
|
|
74
|
+
`the lock at ${lockPath} stayed held for ${waitedMs}ms — another process is still inside the ` +
|
|
75
|
+
'guarded section. Nothing was written; retry.',
|
|
76
|
+
);
|
|
77
|
+
this.name = 'NamedLockTimeoutError';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The lock was broken out from under the holder while `fn` ran: it DID run, but may have raced. */
|
|
82
|
+
export class NamedLockCompromisedError extends Error {
|
|
83
|
+
readonly code = 'ECOMPROMISED';
|
|
84
|
+
constructor(lockPath: string, cause: Error) {
|
|
85
|
+
super(`the lock at ${lockPath} was compromised while held (${cause.message}) — retry the operation.`);
|
|
86
|
+
this.name = 'NamedLockCompromisedError';
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const defaultTimeoutMs = (): number => {
|
|
91
|
+
const env = Number(process.env['DZ_STORE_LOCK_TIMEOUT_MS']);
|
|
92
|
+
return Number.isFinite(env) && env > 0 ? env : LOCK_TIMEOUT_MS;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Synchronous sleep without spinning (Atomics.wait on a throwaway buffer). */
|
|
96
|
+
const sleepSync = (ms: number): void => {
|
|
97
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
function resolveOpts(opts: StoreLockOptions): { staleMs: number; timeoutMs: number; pollMs: number } {
|
|
101
|
+
return {
|
|
102
|
+
staleMs: Math.max(opts.staleMs ?? STALE_LOCK_MS, MIN_STALE_MS),
|
|
103
|
+
timeoutMs: opts.timeoutMs ?? defaultTimeoutMs(),
|
|
104
|
+
pollMs: opts.pollMs ?? 25,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface Acquired {
|
|
109
|
+
/** Releases the lock. Returns `'released'` when it was ours, `'stolen'` when it was not (nothing
|
|
110
|
+
* removed, exit-time removal disarmed) and `'stolen-undisarmed'` when it was not ours AND the
|
|
111
|
+
* exit-time removal could not be disarmed — a gap the caller must hear about. */
|
|
112
|
+
readonly release: () => 'released' | 'stolen' | 'stolen-undisarmed';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* DISARMING THE LIBRARY'S EXIT HANDLER (round-2 CRITICAL C2, second mechanism).
|
|
117
|
+
*
|
|
118
|
+
* `proper-lockfile` registers a process-exit handler that `rmdirSync`s every lock still in its
|
|
119
|
+
* internal map — unconditionally, with no ownership check (lockfile.js: "Remove acquired locks on
|
|
120
|
+
* exit"). So even after this module refuses to remove a stolen lock, the holder's PROCESS EXIT
|
|
121
|
+
* removed the successor's live lock a moment later. MEASURED: process C then acquired the lock while
|
|
122
|
+
* B was still inside its critical section.
|
|
123
|
+
*
|
|
124
|
+
* The map is reachable through the library's own `getLocks()` accessor. It is not re-exported by the
|
|
125
|
+
* package index, so this is a deep import — guarded by a runtime shape check, and NEVER silent: if
|
|
126
|
+
* the accessor is missing (a future version moved it), the compromise error says the exit-time
|
|
127
|
+
* removal could not be disarmed, so the gap is reported rather than assumed away.
|
|
128
|
+
*/
|
|
129
|
+
const lockfileInternals: { getLocks?: () => Record<string, unknown> } = (() => {
|
|
130
|
+
try {
|
|
131
|
+
const req = createRequire(import.meta.url);
|
|
132
|
+
const mod = req('proper-lockfile/lib/lockfile.js') as { getLocks?: unknown };
|
|
133
|
+
return typeof mod.getLocks === 'function' ? { getLocks: mod.getLocks as () => Record<string, unknown> } : {};
|
|
134
|
+
} catch {
|
|
135
|
+
return {};
|
|
136
|
+
}
|
|
137
|
+
})();
|
|
138
|
+
|
|
139
|
+
/** Drop a lock we no longer own from the library's exit-time removal list. Returns false when the
|
|
140
|
+
* accessor is unavailable — the caller says so out loud. */
|
|
141
|
+
function disarmExitRemoval(resourceKey: string): boolean {
|
|
142
|
+
const get = lockfileInternals.getLocks;
|
|
143
|
+
if (get === undefined) return false;
|
|
144
|
+
try {
|
|
145
|
+
const locks = get();
|
|
146
|
+
if (Object.prototype.hasOwnProperty.call(locks, resourceKey)) delete locks[resourceKey];
|
|
147
|
+
return true;
|
|
148
|
+
} catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* OWNERSHIP TOKEN (round-2 CRITICAL C2). `proper-lockfile`'s release is unconditional: it clears its
|
|
155
|
+
* timer and removes the lock directory, without checking that the directory is still the one it
|
|
156
|
+
* created. That is safe while the heartbeat runs — but a SYNCHRONOUS critical section blocks the
|
|
157
|
+
* event loop, so the heartbeat cannot fire, a waiter is entitled to break the stale lock, and the
|
|
158
|
+
* original holder then deletes its SUCCESSOR's live lock on the way out.
|
|
159
|
+
*
|
|
160
|
+
* MEASURED (2026-08-19, three real processes, reproducer in named-lock.test.ts / red-green.md):
|
|
161
|
+
* with a 6s synchronous body under a 2s stale threshold, process C acquired the lock while B was
|
|
162
|
+
* still inside its critical section — mutual exclusion gone, silently.
|
|
163
|
+
*
|
|
164
|
+
* The fix: stamp a random token BESIDE the lock directory at acquisition and verify it at release. A
|
|
165
|
+
* lock that no longer carries our token is somebody else's, and we do not touch it — we report the
|
|
166
|
+
* compromise loudly instead. (The marker lives beside, not inside: `proper-lockfile` removes the
|
|
167
|
+
* lock with a plain `rmdir`, so a file within it would make every release silently fail and leak the
|
|
168
|
+
* lock — MEASURED, the first draft of this fix did exactly that and the suite went red.)
|
|
169
|
+
*/
|
|
170
|
+
function ownerMarkerPath(lockPath: string): string {
|
|
171
|
+
return lockPath + '.owner';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function tryAcquire(
|
|
175
|
+
projectRoot: string,
|
|
176
|
+
name: string,
|
|
177
|
+
staleMs: number,
|
|
178
|
+
onCompromised: (e: Error) => void,
|
|
179
|
+
): Acquired | 'held' {
|
|
180
|
+
const lockPath = namedLockPath(projectRoot, name);
|
|
181
|
+
const resourceKey = resolve(projectRoot, '.dz', 'locks', name);
|
|
182
|
+
try {
|
|
183
|
+
const release = lockSync(resourceKey, {
|
|
184
|
+
lockfilePath: lockPath,
|
|
185
|
+
stale: staleMs,
|
|
186
|
+
realpath: false, // the guarded resource is a concept, not an existing file
|
|
187
|
+
onCompromised,
|
|
188
|
+
});
|
|
189
|
+
const token = randomBytes(16).toString('hex');
|
|
190
|
+
let ino: number | null = null;
|
|
191
|
+
try {
|
|
192
|
+
writeFileSync(ownerMarkerPath(lockPath), token, { mode: 0o600 });
|
|
193
|
+
ino = statSync(lockPath).ino;
|
|
194
|
+
} catch { /* an unwritable lock dir: fall back to inode-only evidence below */ }
|
|
195
|
+
return {
|
|
196
|
+
release: (): 'released' | 'stolen' | 'stolen-undisarmed' => {
|
|
197
|
+
let ours = false;
|
|
198
|
+
try {
|
|
199
|
+
const seen = readFileSync(ownerMarkerPath(lockPath), 'utf8');
|
|
200
|
+
const sameIno = ino === null ? true : statSync(lockPath).ino === ino;
|
|
201
|
+
ours = seen === token && sameIno;
|
|
202
|
+
} catch {
|
|
203
|
+
ours = false; // gone, or unreadable — either way not provably ours
|
|
204
|
+
}
|
|
205
|
+
if (!ours) {
|
|
206
|
+
// NEVER remove a lock directory we cannot prove is ours — including at process exit.
|
|
207
|
+
return disarmExitRemoval(resourceKey) ? 'stolen' : 'stolen-undisarmed';
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
release();
|
|
211
|
+
} catch { /* ERELEASED after a compromise — the lock is no longer ours to remove */ }
|
|
212
|
+
try {
|
|
213
|
+
rmSync(ownerMarkerPath(lockPath), { force: true });
|
|
214
|
+
} catch { /* best effort: a stale marker is overwritten by the next holder anyway */ }
|
|
215
|
+
return 'released';
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
} catch (err) {
|
|
219
|
+
if ((err as NodeJS.ErrnoException).code === 'ELOCKED') return 'held';
|
|
220
|
+
throw err;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Run `fn` while holding the named lock, releasing it however `fn` ends.
|
|
226
|
+
*
|
|
227
|
+
* Throws {@link NamedLockTimeoutError} when the lock cannot be acquired by the deadline (nothing
|
|
228
|
+
* has run) and {@link NamedLockCompromisedError} when the lock was broken while `fn` ran. The
|
|
229
|
+
* heartbeat cannot fire while a synchronous `fn` blocks the event loop, so keep bodies well under
|
|
230
|
+
* `staleMs` — the same caveat `withStoreLockSync` carries.
|
|
231
|
+
*/
|
|
232
|
+
export function withNamedLockSync<T>(
|
|
233
|
+
projectRoot: string,
|
|
234
|
+
name: string,
|
|
235
|
+
fn: () => T,
|
|
236
|
+
opts: StoreLockOptions = {},
|
|
237
|
+
): T {
|
|
238
|
+
const lockPath = namedLockPath(projectRoot, name); // validates the name BEFORE any mkdir
|
|
239
|
+
const { staleMs, timeoutMs, pollMs } = resolveOpts(opts);
|
|
240
|
+
mkdirSync(join(projectRoot, '.dz', 'locks'), { recursive: true });
|
|
241
|
+
const started = Date.now();
|
|
242
|
+
const deadline = started + timeoutMs;
|
|
243
|
+
let compromised: Error | undefined;
|
|
244
|
+
const onCompromised = (e: Error): void => { compromised = e; };
|
|
245
|
+
for (;;) {
|
|
246
|
+
const got = tryAcquire(projectRoot, name, staleMs, onCompromised);
|
|
247
|
+
if (got !== 'held') {
|
|
248
|
+
let result: T;
|
|
249
|
+
let outcome: 'released' | 'stolen' | 'stolen-undisarmed' = 'released';
|
|
250
|
+
try {
|
|
251
|
+
result = fn();
|
|
252
|
+
} finally {
|
|
253
|
+
outcome = got.release();
|
|
254
|
+
}
|
|
255
|
+
if (outcome !== 'released') {
|
|
256
|
+
throw new NamedLockCompromisedError(
|
|
257
|
+
lockPath,
|
|
258
|
+
compromised ??
|
|
259
|
+
new Error(
|
|
260
|
+
'the lock directory no longer carries this holder\u2019s ownership token — a waiter broke it as stale ' +
|
|
261
|
+
'while the critical section was still running (a synchronous body cannot heartbeat). The successor\u2019s ' +
|
|
262
|
+
'lock was left untouched; THIS run must be treated as having raced and retried.' +
|
|
263
|
+
(outcome === 'stolen-undisarmed'
|
|
264
|
+
? ' WARNING: proper-lockfile\u2019s exit-time lock removal could NOT be disarmed (its getLocks() accessor ' +
|
|
265
|
+
'is unavailable in this version), so this process may still remove the successor\u2019s lock when it exits.'
|
|
266
|
+
: ''),
|
|
267
|
+
),
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
if (compromised !== undefined) throw new NamedLockCompromisedError(lockPath, compromised);
|
|
271
|
+
return result;
|
|
272
|
+
}
|
|
273
|
+
const now = Date.now();
|
|
274
|
+
if (now >= deadline) throw new NamedLockTimeoutError(lockPath, now - started);
|
|
275
|
+
sleepSync(Math.min(pollMs, deadline - now));
|
|
276
|
+
}
|
|
277
|
+
}
|