@deftai/directive-core 0.95.0 → 0.97.0
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/dist/cache/archive.d.ts +134 -0
- package/dist/cache/archive.js +636 -0
- package/dist/cache/index.d.ts +1 -0
- package/dist/cache/index.js +1 -0
- package/dist/cache/main.js +298 -1
- package/dist/check/gate-lists.js +8 -0
- package/dist/consumer-check-contract/evaluate.d.ts +124 -0
- package/dist/consumer-check-contract/evaluate.js +699 -0
- package/dist/consumer-check-contract/index.d.ts +5 -0
- package/dist/consumer-check-contract/index.js +5 -0
- package/dist/content-contracts/skills/helpers.d.ts +1 -1
- package/dist/content-contracts/skills/helpers.js +1 -0
- package/dist/delivery-attempt/disk-begin.d.ts +51 -0
- package/dist/delivery-attempt/disk-begin.js +68 -0
- package/dist/delivery-attempt/evaluate.d.ts +26 -0
- package/dist/delivery-attempt/evaluate.js +443 -0
- package/dist/delivery-attempt/fingerprint.d.ts +32 -0
- package/dist/delivery-attempt/fingerprint.js +100 -0
- package/dist/delivery-attempt/handoff.d.ts +25 -0
- package/dist/delivery-attempt/handoff.js +102 -0
- package/dist/delivery-attempt/index.d.ts +17 -0
- package/dist/delivery-attempt/index.js +17 -0
- package/dist/delivery-attempt/ledger.d.ts +169 -0
- package/dist/delivery-attempt/ledger.js +758 -0
- package/dist/delivery-attempt/material-delta.d.ts +38 -0
- package/dist/delivery-attempt/material-delta.js +126 -0
- package/dist/delivery-attempt/types.d.ts +210 -0
- package/dist/delivery-attempt/types.js +77 -0
- package/dist/doctor/index.d.ts +1 -0
- package/dist/doctor/index.js +1 -0
- package/dist/doctor/main.js +12 -0
- package/dist/doctor/openclaw-soft-rebind.d.ts +26 -0
- package/dist/doctor/openclaw-soft-rebind.js +164 -0
- package/dist/hooks/dispatcher.d.ts +2 -1
- package/dist/hooks/dispatcher.js +63 -9
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/init-deposit/gitignore.js +7 -0
- package/dist/init-deposit/hygiene.d.ts +1 -1
- package/dist/init-deposit/hygiene.js +16 -4
- package/dist/init-deposit/init-deposit.js +5 -0
- package/dist/init-deposit/refresh.js +3 -0
- package/dist/init-deposit/scaffold.js +6 -5
- package/dist/parent-turn-shape/evaluate.d.ts +84 -0
- package/dist/parent-turn-shape/evaluate.js +353 -0
- package/dist/parent-turn-shape/index.d.ts +8 -0
- package/dist/parent-turn-shape/index.js +8 -0
- package/dist/pr-merge-readiness/ci-gate.d.ts +29 -1
- package/dist/pr-merge-readiness/ci-gate.js +191 -24
- package/dist/pr-merge-readiness/compute.js +10 -1
- package/dist/pr-merge-readiness/index.d.ts +2 -1
- package/dist/pr-merge-readiness/index.js +2 -1
- package/dist/pr-merge-readiness/output.js +14 -0
- package/dist/pr-merge-readiness/platform-status.d.ts +29 -0
- package/dist/pr-merge-readiness/platform-status.js +49 -0
- package/dist/pr-watch/constants.d.ts +10 -0
- package/dist/pr-watch/constants.js +12 -1
- package/dist/pr-watch/main.js +16 -1
- package/dist/pr-watch/probe.js +13 -1
- package/dist/pr-watch/types.d.ts +3 -2
- package/dist/pr-watch/watch.js +14 -7
- package/dist/review-monitor/constants.js +3 -2
- package/dist/review-monitor/tier-detection.d.ts +7 -3
- package/dist/review-monitor/tier-detection.js +18 -1
- package/dist/review-monitor/verify.js +18 -0
- package/dist/scope/index.d.ts +2 -0
- package/dist/scope/index.js +2 -0
- package/dist/scope/main.d.ts +10 -0
- package/dist/scope/main.js +109 -24
- package/dist/scope/promote-from-issue.d.ts +49 -0
- package/dist/scope/promote-from-issue.js +367 -0
- package/dist/scope/promote-path.d.ts +39 -0
- package/dist/scope/promote-path.js +105 -0
- package/dist/scope-provenance/digest.d.ts +67 -0
- package/dist/scope-provenance/digest.js +188 -0
- package/dist/scope-provenance/evaluate.d.ts +82 -0
- package/dist/scope-provenance/evaluate.js +528 -0
- package/dist/scope-provenance/index.d.ts +6 -0
- package/dist/scope-provenance/index.js +6 -0
- package/dist/session/compact-ritual.d.ts +96 -0
- package/dist/session/compact-ritual.js +237 -0
- package/dist/session/compact-ritual.spec.d.ts +2 -0
- package/dist/session/compact-ritual.spec.js +21 -0
- package/dist/session/index.d.ts +2 -0
- package/dist/session/index.js +2 -0
- package/dist/session/openclaw-soft-rebind-deposit.d.ts +46 -0
- package/dist/session/openclaw-soft-rebind-deposit.js +165 -0
- package/dist/swarm/routing.d.ts +4 -2
- package/dist/swarm/routing.js +26 -4
- package/dist/test-boundary/evaluate.d.ts +54 -0
- package/dist/test-boundary/evaluate.js +368 -0
- package/dist/test-boundary/index.d.ts +6 -0
- package/dist/test-boundary/index.js +6 -0
- package/dist/test-boundary/policy.d.ts +52 -0
- package/dist/test-boundary/policy.js +182 -0
- package/dist/triage/actions/index.js +62 -2
- package/dist/triage/actions/types.d.ts +8 -1
- package/dist/triage/author-filter.d.ts +51 -0
- package/dist/triage/author-filter.js +152 -0
- package/dist/triage/bootstrap/gitignore.d.ts +1 -1
- package/dist/triage/bootstrap/gitignore.js +15 -1
- package/dist/triage/classify/index.d.ts +2 -2
- package/dist/triage/classify/index.js +2 -2
- package/dist/triage/classify/label-mirror.d.ts +68 -5
- package/dist/triage/classify/label-mirror.js +261 -31
- package/dist/triage/help/registry-data.d.ts +49 -38
- package/dist/triage/help/registry-data.js +115 -40
- package/dist/triage/index.d.ts +1 -0
- package/dist/triage/index.js +1 -0
- package/dist/triage/queue/index.d.ts +1 -0
- package/dist/triage/queue/index.js +1 -0
- package/dist/triage/queue/render.d.ts +2 -0
- package/dist/triage/queue/render.js +6 -0
- package/dist/vbrief-activate/activate.js +22 -6
- package/dist/xbrief/styles.js +33 -17
- package/package.json +11 -3
|
@@ -0,0 +1,758 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable delivery-attempt unit ledger (#3143).
|
|
3
|
+
*
|
|
4
|
+
* Persists under `.deft/delivery-attempts/` so counters survive worker
|
|
5
|
+
* replacement, session restart, context compaction, and new revisions.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
8
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, unlinkSync, } from "node:fs";
|
|
9
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
10
|
+
import { ContainedWriteError, ContainedWriteErrorCode, containedWrite, } from "../fs/contained-write.js";
|
|
11
|
+
import { assertWriteTargetSafe } from "../fs/projection-containment.js";
|
|
12
|
+
import { DELIVERY_ATTEMPT_DIR, DELIVERY_ATTEMPT_SCHEMA_VERSION, deliveryUnitKey, utcIso, } from "./types.js";
|
|
13
|
+
export function deliveryAttemptsDir(projectRoot) {
|
|
14
|
+
return join(projectRoot, ...DELIVERY_ATTEMPT_DIR.split("/"));
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Stable collision-resistant filename for a unit key.
|
|
18
|
+
* Full SHA-256 hex (64 chars) — do not truncate base64 of the raw key (#3143 P1).
|
|
19
|
+
*/
|
|
20
|
+
export function unitLedgerFilename(scopeId, targetId, workflowId) {
|
|
21
|
+
const key = deliveryUnitKey(scopeId, targetId, workflowId);
|
|
22
|
+
const digest = createHash("sha256").update(key, "utf8").digest("hex");
|
|
23
|
+
return `${digest}.json`;
|
|
24
|
+
}
|
|
25
|
+
export function unitLedgerPath(projectRoot, scopeId, targetId, workflowId) {
|
|
26
|
+
return join(deliveryAttemptsDir(projectRoot), unitLedgerFilename(scopeId, targetId, workflowId));
|
|
27
|
+
}
|
|
28
|
+
export function newAttemptId(prefix = "att") {
|
|
29
|
+
return `${prefix}-${randomBytes(8).toString("hex")}`;
|
|
30
|
+
}
|
|
31
|
+
export function emptyUnitLedger(input) {
|
|
32
|
+
return {
|
|
33
|
+
schemaVersion: DELIVERY_ATTEMPT_SCHEMA_VERSION,
|
|
34
|
+
scopeId: input.scopeId,
|
|
35
|
+
targetId: input.targetId,
|
|
36
|
+
workflowId: input.workflowId,
|
|
37
|
+
phaseId: input.phaseId ?? "default",
|
|
38
|
+
attempts: [],
|
|
39
|
+
failedAttemptCount: 0,
|
|
40
|
+
sameFailureCounts: {},
|
|
41
|
+
totalElapsedSeconds: 0,
|
|
42
|
+
totalToolCallCount: 0,
|
|
43
|
+
totalHostTokenCount: null,
|
|
44
|
+
lastFailure: null,
|
|
45
|
+
lastMaterialDelta: [],
|
|
46
|
+
lastSourceRevision: null,
|
|
47
|
+
blockedDecision: null,
|
|
48
|
+
resumeCondition: null,
|
|
49
|
+
override: null,
|
|
50
|
+
updatedAt: utcIso(input.now),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function isRecord(value) {
|
|
54
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
55
|
+
}
|
|
56
|
+
function readString(rec, key) {
|
|
57
|
+
const v = rec[key];
|
|
58
|
+
return typeof v === "string" && v.trim().length > 0 ? v.trim() : null;
|
|
59
|
+
}
|
|
60
|
+
function readNumber(rec, key) {
|
|
61
|
+
const v = rec[key];
|
|
62
|
+
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Parse a unit ledger from JSON. Returns null when required fields are invalid.
|
|
66
|
+
*/
|
|
67
|
+
export function parseUnitLedger(raw) {
|
|
68
|
+
if (!isRecord(raw))
|
|
69
|
+
return null;
|
|
70
|
+
const scopeId = readString(raw, "scopeId");
|
|
71
|
+
const targetId = readString(raw, "targetId");
|
|
72
|
+
const workflowId = readString(raw, "workflowId");
|
|
73
|
+
if (scopeId === null || targetId === null || workflowId === null)
|
|
74
|
+
return null;
|
|
75
|
+
const attemptsRaw = Array.isArray(raw.attempts) ? raw.attempts : [];
|
|
76
|
+
const attempts = [];
|
|
77
|
+
for (const a of attemptsRaw) {
|
|
78
|
+
const parsed = parseAttempt(a);
|
|
79
|
+
if (parsed !== null)
|
|
80
|
+
attempts.push(parsed);
|
|
81
|
+
}
|
|
82
|
+
const sameFailureCounts = {};
|
|
83
|
+
if (isRecord(raw.sameFailureCounts)) {
|
|
84
|
+
for (const [k, v] of Object.entries(raw.sameFailureCounts)) {
|
|
85
|
+
if (typeof v === "number" && Number.isFinite(v) && v >= 0) {
|
|
86
|
+
sameFailureCounts[k] = Math.floor(v);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
schemaVersion: DELIVERY_ATTEMPT_SCHEMA_VERSION,
|
|
92
|
+
scopeId,
|
|
93
|
+
targetId,
|
|
94
|
+
workflowId,
|
|
95
|
+
phaseId: readString(raw, "phaseId") ?? "default",
|
|
96
|
+
attempts,
|
|
97
|
+
failedAttemptCount: Math.max(0, Math.floor(readNumber(raw, "failedAttemptCount") ?? 0)),
|
|
98
|
+
sameFailureCounts,
|
|
99
|
+
totalElapsedSeconds: Math.max(0, Math.floor(readNumber(raw, "totalElapsedSeconds") ?? 0)),
|
|
100
|
+
totalToolCallCount: Math.max(0, Math.floor(readNumber(raw, "totalToolCallCount") ?? 0)),
|
|
101
|
+
totalHostTokenCount: raw.totalHostTokenCount === null || raw.totalHostTokenCount === undefined
|
|
102
|
+
? null
|
|
103
|
+
: Math.max(0, Math.floor(readNumber(raw, "totalHostTokenCount") ?? 0)),
|
|
104
|
+
lastFailure: parseFailure(raw.lastFailure),
|
|
105
|
+
lastMaterialDelta: parseMaterialDeltas(raw.lastMaterialDelta),
|
|
106
|
+
lastSourceRevision: readString(raw, "lastSourceRevision"),
|
|
107
|
+
blockedDecision: readString(raw, "blockedDecision") ?? null,
|
|
108
|
+
resumeCondition: parseResume(raw.resumeCondition),
|
|
109
|
+
override: parseOverride(raw.override),
|
|
110
|
+
updatedAt: readString(raw, "updatedAt") ?? utcIso(),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function parseFailure(raw) {
|
|
114
|
+
if (!isRecord(raw))
|
|
115
|
+
return null;
|
|
116
|
+
const stage = readString(raw, "stage");
|
|
117
|
+
const fingerprint = readString(raw, "fingerprint");
|
|
118
|
+
const retryability = readString(raw, "retryability");
|
|
119
|
+
if (stage === null || fingerprint === null || retryability === null)
|
|
120
|
+
return null;
|
|
121
|
+
if (retryability !== "transient" &&
|
|
122
|
+
retryability !== "deterministic" &&
|
|
123
|
+
retryability !== "unknown") {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
stage,
|
|
128
|
+
code: readString(raw, "code"),
|
|
129
|
+
fingerprint,
|
|
130
|
+
retryability,
|
|
131
|
+
resourceClass: readString(raw, "resourceClass"),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function parseMaterialDeltas(raw) {
|
|
135
|
+
if (!Array.isArray(raw))
|
|
136
|
+
return [];
|
|
137
|
+
const out = [];
|
|
138
|
+
for (const item of raw) {
|
|
139
|
+
if (!isRecord(item))
|
|
140
|
+
continue;
|
|
141
|
+
const kind = readString(item, "kind");
|
|
142
|
+
const sourceRevision = readString(item, "sourceRevision");
|
|
143
|
+
if (kind === null || sourceRevision === null)
|
|
144
|
+
continue;
|
|
145
|
+
const addresses = Array.isArray(item.addresses)
|
|
146
|
+
? item.addresses.filter((x) => typeof x === "string")
|
|
147
|
+
: [];
|
|
148
|
+
out.push({
|
|
149
|
+
kind: kind,
|
|
150
|
+
addresses,
|
|
151
|
+
sourceRevision,
|
|
152
|
+
note: readString(item, "note"),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
function parseAttempt(raw) {
|
|
158
|
+
if (!isRecord(raw))
|
|
159
|
+
return null;
|
|
160
|
+
const attemptId = readString(raw, "attemptId");
|
|
161
|
+
const sourceRevision = readString(raw, "sourceRevision");
|
|
162
|
+
const trigger = readString(raw, "trigger");
|
|
163
|
+
const status = readString(raw, "status");
|
|
164
|
+
const startedAt = readString(raw, "startedAt");
|
|
165
|
+
if (attemptId === null ||
|
|
166
|
+
sourceRevision === null ||
|
|
167
|
+
trigger === null ||
|
|
168
|
+
status === null ||
|
|
169
|
+
startedAt === null) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
attemptId,
|
|
174
|
+
sourceRevision,
|
|
175
|
+
trigger,
|
|
176
|
+
status,
|
|
177
|
+
failure: parseFailure(raw.failure),
|
|
178
|
+
materialDelta: parseMaterialDeltas(raw.materialDelta),
|
|
179
|
+
startedAt,
|
|
180
|
+
endedAt: readString(raw, "endedAt"),
|
|
181
|
+
elapsedSeconds: Math.max(0, Math.floor(readNumber(raw, "elapsedSeconds") ?? 0)),
|
|
182
|
+
toolCallCount: Math.max(0, Math.floor(readNumber(raw, "toolCallCount") ?? 0)),
|
|
183
|
+
hostTokenCount: raw.hostTokenCount === null || raw.hostTokenCount === undefined
|
|
184
|
+
? null
|
|
185
|
+
: Math.max(0, Math.floor(readNumber(raw, "hostTokenCount") ?? 0)),
|
|
186
|
+
workerId: readString(raw, "workerId"),
|
|
187
|
+
externalRunId: readString(raw, "externalRunId"),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
function parseResume(raw) {
|
|
191
|
+
if (!isRecord(raw))
|
|
192
|
+
return null;
|
|
193
|
+
const kind = readString(raw, "kind");
|
|
194
|
+
const description = readString(raw, "description");
|
|
195
|
+
if (kind === null || description === null)
|
|
196
|
+
return null;
|
|
197
|
+
return {
|
|
198
|
+
kind: kind,
|
|
199
|
+
description,
|
|
200
|
+
satisfied: raw.satisfied === true,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
function parseOverride(raw) {
|
|
204
|
+
if (!isRecord(raw))
|
|
205
|
+
return null;
|
|
206
|
+
const overrideId = readString(raw, "overrideId");
|
|
207
|
+
const actor = readString(raw, "actor");
|
|
208
|
+
const rationale = readString(raw, "rationale");
|
|
209
|
+
const recordedAt = readString(raw, "recordedAt");
|
|
210
|
+
if (overrideId === null || actor === null || rationale === null || recordedAt === null) {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
overrideId,
|
|
215
|
+
actor,
|
|
216
|
+
rationale,
|
|
217
|
+
recordedAt,
|
|
218
|
+
allowedAttempts: Math.max(1, Math.floor(readNumber(raw, "allowedAttempts") ?? 1)),
|
|
219
|
+
expiresAt: readString(raw, "expiresAt"),
|
|
220
|
+
remainingAttempts: Math.max(0, Math.floor(readNumber(raw, "remainingAttempts") ?? 0)),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
function writeJsonContained(projectRoot, targetPath, payload) {
|
|
224
|
+
const root = resolve(projectRoot);
|
|
225
|
+
const abs = resolve(targetPath);
|
|
226
|
+
assertWriteTargetSafe(root, abs);
|
|
227
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
228
|
+
const dir = dirname(abs);
|
|
229
|
+
const tmpBase = `.${basename(abs)}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
230
|
+
const tmp = join(dir, tmpBase);
|
|
231
|
+
try {
|
|
232
|
+
containedWrite({
|
|
233
|
+
root,
|
|
234
|
+
target: tmp,
|
|
235
|
+
data: `${JSON.stringify(payload, null, 2)}\n`,
|
|
236
|
+
mode: "create",
|
|
237
|
+
});
|
|
238
|
+
renameSync(tmp, abs);
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
try {
|
|
242
|
+
rmSync(tmp, { force: true });
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
/* best-effort */
|
|
246
|
+
}
|
|
247
|
+
throw err;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Load unit ledger distinguishing missing vs corrupt.
|
|
252
|
+
* Corrupt persisted state MUST NOT be treated as a new empty unit (fail-closed).
|
|
253
|
+
*/
|
|
254
|
+
export function loadUnitLedgerResult(projectRoot, scopeId, targetId, workflowId) {
|
|
255
|
+
const path = unitLedgerPath(projectRoot, scopeId, targetId, workflowId);
|
|
256
|
+
if (!existsSync(path))
|
|
257
|
+
return { status: "missing" };
|
|
258
|
+
try {
|
|
259
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
260
|
+
const ledger = parseUnitLedger(raw);
|
|
261
|
+
if (ledger === null) {
|
|
262
|
+
return { status: "corrupt", detail: `invalid ledger schema at ${path}` };
|
|
263
|
+
}
|
|
264
|
+
return { status: "ok", ledger };
|
|
265
|
+
}
|
|
266
|
+
catch (err) {
|
|
267
|
+
return {
|
|
268
|
+
status: "corrupt",
|
|
269
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Load unit ledger from disk, or null if missing.
|
|
275
|
+
* Throws when the file exists but is corrupt (fail-closed circuit breaker).
|
|
276
|
+
*/
|
|
277
|
+
export function loadUnitLedger(projectRoot, scopeId, targetId, workflowId) {
|
|
278
|
+
const result = loadUnitLedgerResult(projectRoot, scopeId, targetId, workflowId);
|
|
279
|
+
if (result.status === "missing")
|
|
280
|
+
return null;
|
|
281
|
+
if (result.status === "corrupt") {
|
|
282
|
+
throw new Error(`delivery-attempt ledger corrupt for ${scopeId}/${targetId}/${workflowId}: ${result.detail}`);
|
|
283
|
+
}
|
|
284
|
+
return result.ledger;
|
|
285
|
+
}
|
|
286
|
+
/** Persist unit ledger atomically under project root. */
|
|
287
|
+
export function saveUnitLedger(projectRoot, ledger) {
|
|
288
|
+
const path = unitLedgerPath(projectRoot, ledger.scopeId, ledger.targetId, ledger.workflowId);
|
|
289
|
+
writeJsonContained(projectRoot, path, ledger);
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Exclusive unit lock for begin/complete on disk (#3143 concurrent-snapshot P1).
|
|
293
|
+
* Uses O_EXCL create of a lock file under the delivery-attempts dir.
|
|
294
|
+
*/
|
|
295
|
+
/** @deprecated Kept for API stability; live PIDs are never time-reclaimed. */
|
|
296
|
+
export const UNIT_LOCK_STALE_MS = 5 * 60 * 1000;
|
|
297
|
+
function isProcessAlive(pid) {
|
|
298
|
+
if (!Number.isFinite(pid) || pid <= 0)
|
|
299
|
+
return false;
|
|
300
|
+
try {
|
|
301
|
+
process.kill(pid, 0);
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
catch (err) {
|
|
305
|
+
const code = err.code;
|
|
306
|
+
// ESRCH → no such process (dead). EPERM → process exists but this user
|
|
307
|
+
// cannot signal it — treat as alive so we never reclaim a live foreign lock.
|
|
308
|
+
if (code === "EPERM")
|
|
309
|
+
return true;
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
function readLockRecord(lockPath) {
|
|
314
|
+
try {
|
|
315
|
+
const raw = JSON.parse(readFileSync(lockPath, "utf8"));
|
|
316
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
317
|
+
return null;
|
|
318
|
+
const rec = raw;
|
|
319
|
+
const pid = typeof rec.pid === "number" ? rec.pid : Number.NaN;
|
|
320
|
+
const token = typeof rec.token === "string" ? rec.token : "";
|
|
321
|
+
const startedAt = typeof rec.startedAt === "string" ? rec.startedAt : "";
|
|
322
|
+
if (!Number.isFinite(pid) || token.length === 0)
|
|
323
|
+
return null;
|
|
324
|
+
return { pid, token, startedAt };
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Whether an existing lock may be reclaimed.
|
|
332
|
+
*
|
|
333
|
+
* **Live owner PID is never reclaimed** — including long critical sections and
|
|
334
|
+
* event-loop stalls. Only dead owners and corrupt/unreadable records are
|
|
335
|
+
* reclaimable. PID-reuse residual (dead owner, OS recycled the number onto an
|
|
336
|
+
* unrelated live process) requires manual `.lock` deletion.
|
|
337
|
+
*/
|
|
338
|
+
export function isUnitLockReclaimable(rec) {
|
|
339
|
+
if (rec === null)
|
|
340
|
+
return true;
|
|
341
|
+
return !isProcessAlive(rec.pid);
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Exclusive lock create via containedWrite mode=create (O_EXCL under root).
|
|
345
|
+
* Maps CONTAINED_WRITE_EXISTS → EEXIST so reclaim callers keep errno checks.
|
|
346
|
+
*/
|
|
347
|
+
function writeLockExclusive(projectRoot, path, record) {
|
|
348
|
+
try {
|
|
349
|
+
containedWrite({
|
|
350
|
+
root: resolve(projectRoot),
|
|
351
|
+
target: path,
|
|
352
|
+
data: `${JSON.stringify(record)}\n`,
|
|
353
|
+
mode: "create",
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
catch (err) {
|
|
357
|
+
if (err instanceof ContainedWriteError && err.code === ContainedWriteErrorCode.EXISTS) {
|
|
358
|
+
const e = new Error(`EEXIST: file already exists, open '${path}'`);
|
|
359
|
+
e.code = "EEXIST";
|
|
360
|
+
e.path = path;
|
|
361
|
+
throw e;
|
|
362
|
+
}
|
|
363
|
+
throw err;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
function unlinkIfOurs(path, token, pid) {
|
|
367
|
+
const rec = readLockRecord(path);
|
|
368
|
+
if (rec !== null && rec.token === token && rec.pid === pid) {
|
|
369
|
+
try {
|
|
370
|
+
unlinkSync(path);
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
/* best-effort */
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Acquire exclusive unit lock.
|
|
379
|
+
*
|
|
380
|
+
* Create is atomic: `containedWrite(..., mode: "create")` (O_EXCL) writes the
|
|
381
|
+
* owner record in the exclusive create (no empty-file window).
|
|
382
|
+
*
|
|
383
|
+
* Recovery when EEXIST (dead owner or corrupt record only):
|
|
384
|
+
* 1. Take an exclusive **reclaim ticket** (`*.lock.reclaim`) with create mode.
|
|
385
|
+
* 2. Under that ticket, re-read the lock; only unlink if still reclaimable
|
|
386
|
+
* (owner still dead / corrupt). Live PIDs are never unlinked.
|
|
387
|
+
* 3. Create the replacement lock with create mode, then drop the ticket.
|
|
388
|
+
*
|
|
389
|
+
* The ticket serializes reclaimers so a delayed contender cannot unlink a
|
|
390
|
+
* live replacement lock.
|
|
391
|
+
*/
|
|
392
|
+
export function withUnitLock(projectRoot, scopeId, targetId, workflowId, fn, options) {
|
|
393
|
+
const dir = deliveryAttemptsDir(projectRoot);
|
|
394
|
+
mkdirSync(dir, { recursive: true });
|
|
395
|
+
const lockName = `${unitLedgerFilename(scopeId, targetId, workflowId)}.lock`;
|
|
396
|
+
const lockPath = join(dir, lockName);
|
|
397
|
+
const reclaimPath = `${lockPath}.reclaim`;
|
|
398
|
+
const root = resolve(projectRoot);
|
|
399
|
+
assertWriteTargetSafe(root, lockPath);
|
|
400
|
+
assertWriteTargetSafe(root, reclaimPath);
|
|
401
|
+
const nowMs = options?.nowMs ?? Date.now();
|
|
402
|
+
void options?.staleMs; // retained for call-site compatibility; unused
|
|
403
|
+
const token = randomBytes(8).toString("hex");
|
|
404
|
+
const record = {
|
|
405
|
+
pid: process.pid,
|
|
406
|
+
token,
|
|
407
|
+
startedAt: new Date(nowMs).toISOString(),
|
|
408
|
+
};
|
|
409
|
+
const unitLabel = `${scopeId}/${targetId}/${workflowId}`;
|
|
410
|
+
const heldError = (detail, cause) => new Error(`delivery-attempt unit lock held for ${unitLabel}${detail}`, cause ? { cause } : undefined);
|
|
411
|
+
const tryCreateLock = () => {
|
|
412
|
+
writeLockExclusive(projectRoot, lockPath, record);
|
|
413
|
+
};
|
|
414
|
+
const acquireReclaimTicket = () => {
|
|
415
|
+
try {
|
|
416
|
+
writeLockExclusive(projectRoot, reclaimPath, record);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
catch (err) {
|
|
420
|
+
const code = err.code;
|
|
421
|
+
if (code !== "EEXIST")
|
|
422
|
+
throw err;
|
|
423
|
+
}
|
|
424
|
+
// Existing reclaim ticket — only take over if its owner PID is dead.
|
|
425
|
+
const existingReclaim = readLockRecord(reclaimPath);
|
|
426
|
+
if (!isUnitLockReclaimable(existingReclaim)) {
|
|
427
|
+
const pid = existingReclaim?.pid;
|
|
428
|
+
throw heldError(pid !== undefined ? ` (reclaim by pid ${pid})` : " (reclaim in progress)");
|
|
429
|
+
}
|
|
430
|
+
try {
|
|
431
|
+
unlinkSync(reclaimPath);
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
/* race */
|
|
435
|
+
}
|
|
436
|
+
try {
|
|
437
|
+
writeLockExclusive(projectRoot, reclaimPath, record);
|
|
438
|
+
}
|
|
439
|
+
catch (retryErr) {
|
|
440
|
+
throw heldError(" (reclaim in progress)", retryErr);
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
try {
|
|
444
|
+
tryCreateLock();
|
|
445
|
+
}
|
|
446
|
+
catch (err) {
|
|
447
|
+
const code = err.code;
|
|
448
|
+
if (code !== "EEXIST")
|
|
449
|
+
throw err;
|
|
450
|
+
const existing = readLockRecord(lockPath);
|
|
451
|
+
if (!isUnitLockReclaimable(existing)) {
|
|
452
|
+
throw heldError(existing !== null ? ` by pid ${existing.pid}` : "");
|
|
453
|
+
}
|
|
454
|
+
// Serialize reclaim so we never unlink another worker's replacement lock.
|
|
455
|
+
acquireReclaimTicket();
|
|
456
|
+
try {
|
|
457
|
+
const again = readLockRecord(lockPath);
|
|
458
|
+
if (!isUnitLockReclaimable(again)) {
|
|
459
|
+
throw heldError(again !== null ? ` by pid ${again.pid}` : "");
|
|
460
|
+
}
|
|
461
|
+
try {
|
|
462
|
+
unlinkSync(lockPath);
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
/* already gone */
|
|
466
|
+
}
|
|
467
|
+
try {
|
|
468
|
+
tryCreateLock();
|
|
469
|
+
}
|
|
470
|
+
catch (retryErr) {
|
|
471
|
+
throw heldError("; retry after the other worker finishes", retryErr);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
finally {
|
|
475
|
+
unlinkIfOurs(reclaimPath, token, process.pid);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
try {
|
|
479
|
+
return fn();
|
|
480
|
+
}
|
|
481
|
+
finally {
|
|
482
|
+
unlinkIfOurs(lockPath, token, process.pid);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Load or create empty unit ledger.
|
|
487
|
+
* Creates only when the file is missing — never on corrupt state.
|
|
488
|
+
*/
|
|
489
|
+
export function loadOrCreateUnitLedger(projectRoot, input) {
|
|
490
|
+
const result = loadUnitLedgerResult(projectRoot, input.scopeId, input.targetId, input.workflowId);
|
|
491
|
+
if (result.status === "ok")
|
|
492
|
+
return result.ledger;
|
|
493
|
+
if (result.status === "corrupt") {
|
|
494
|
+
throw new Error(`delivery-attempt ledger corrupt for ${input.scopeId}/${input.targetId}/${input.workflowId}: ${result.detail}`);
|
|
495
|
+
}
|
|
496
|
+
return emptyUnitLedger(input);
|
|
497
|
+
}
|
|
498
|
+
export function listUnitLedgers(projectRoot) {
|
|
499
|
+
const dir = deliveryAttemptsDir(projectRoot);
|
|
500
|
+
if (!existsSync(dir))
|
|
501
|
+
return [];
|
|
502
|
+
const out = [];
|
|
503
|
+
for (const name of readdirSync(dir)) {
|
|
504
|
+
if (!name.endsWith(".json"))
|
|
505
|
+
continue;
|
|
506
|
+
try {
|
|
507
|
+
const ledger = parseUnitLedger(JSON.parse(readFileSync(join(dir, name), "utf8")));
|
|
508
|
+
if (ledger !== null)
|
|
509
|
+
out.push(ledger);
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
/* skip corrupt */
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return out;
|
|
516
|
+
}
|
|
517
|
+
export function activeAttempts(ledger) {
|
|
518
|
+
return ledger.attempts.filter((a) => a.status === "queued" || a.status === "running");
|
|
519
|
+
}
|
|
520
|
+
export function hasActiveAttempt(ledger) {
|
|
521
|
+
return activeAttempts(ledger).length > 0;
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Open a new queued/running attempt. Caller must have passed evaluatePreDispatch.
|
|
525
|
+
* Does not re-check the gate (orchestrators own the order: evaluate → begin).
|
|
526
|
+
*/
|
|
527
|
+
export function beginAttempt(ledger, input) {
|
|
528
|
+
const now = utcIso(input.now);
|
|
529
|
+
const attempt = {
|
|
530
|
+
attemptId: input.attemptId ?? newAttemptId(),
|
|
531
|
+
sourceRevision: input.sourceRevision,
|
|
532
|
+
trigger: input.trigger,
|
|
533
|
+
status: input.status ?? "running",
|
|
534
|
+
failure: null,
|
|
535
|
+
materialDelta: input.materialDelta ?? [],
|
|
536
|
+
startedAt: now,
|
|
537
|
+
endedAt: null,
|
|
538
|
+
elapsedSeconds: 0,
|
|
539
|
+
toolCallCount: 0,
|
|
540
|
+
hostTokenCount: null,
|
|
541
|
+
workerId: input.workerId ?? null,
|
|
542
|
+
externalRunId: input.externalRunId ?? null,
|
|
543
|
+
};
|
|
544
|
+
// Consume override only when the pre-dispatch decision was ALLOW_OVERRIDE
|
|
545
|
+
// (caller sets consumeOverride). Trigger "override" alone is not sufficient —
|
|
546
|
+
// ordinary allows that happen to use that trigger must not burn quota.
|
|
547
|
+
let override = ledger.override;
|
|
548
|
+
if (input.consumeOverride === true && override !== null && override.remainingAttempts > 0) {
|
|
549
|
+
override = { ...override, remainingAttempts: override.remainingAttempts - 1 };
|
|
550
|
+
}
|
|
551
|
+
const next = {
|
|
552
|
+
...ledger,
|
|
553
|
+
attempts: [...ledger.attempts, attempt],
|
|
554
|
+
lastSourceRevision: input.sourceRevision,
|
|
555
|
+
lastMaterialDelta: input.materialDelta && input.materialDelta.length > 0
|
|
556
|
+
? input.materialDelta
|
|
557
|
+
: ledger.lastMaterialDelta,
|
|
558
|
+
blockedDecision: null,
|
|
559
|
+
override,
|
|
560
|
+
updatedAt: now,
|
|
561
|
+
};
|
|
562
|
+
return { ledger: next, attempt };
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Record terminal outcome for an attempt. Increments budgets / sameFailureCounts
|
|
566
|
+
* on failure. Idempotent when the same externalRunId is already terminal.
|
|
567
|
+
*/
|
|
568
|
+
export function completeAttempt(ledger, input) {
|
|
569
|
+
const now = utcIso(input.now);
|
|
570
|
+
// Interrupted-run reconciliation: if an active attempt with this externalRunId
|
|
571
|
+
// exists, complete it. Only no-op when the run is already terminal AND no
|
|
572
|
+
// active attempt shares that externalRunId (CI re-runs may reuse ids — #3143).
|
|
573
|
+
if (input.externalRunId) {
|
|
574
|
+
const activeWithRun = ledger.attempts.find((a) => a.externalRunId === input.externalRunId &&
|
|
575
|
+
(a.status === "queued" || a.status === "running"));
|
|
576
|
+
if (activeWithRun === undefined) {
|
|
577
|
+
const terminalWithRun = ledger.attempts.find((a) => a.externalRunId === input.externalRunId &&
|
|
578
|
+
(a.status === "succeeded" ||
|
|
579
|
+
a.status === "failed" ||
|
|
580
|
+
a.status === "cancelled" ||
|
|
581
|
+
a.status === "blocked"));
|
|
582
|
+
if (terminalWithRun !== undefined) {
|
|
583
|
+
return ledger;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
let idx = -1;
|
|
588
|
+
if (input.attemptId) {
|
|
589
|
+
idx = ledger.attempts.findIndex((a) => a.attemptId === input.attemptId);
|
|
590
|
+
}
|
|
591
|
+
else if (input.externalRunId) {
|
|
592
|
+
idx = ledger.attempts.findIndex((a) => a.externalRunId === input.externalRunId &&
|
|
593
|
+
(a.status === "queued" || a.status === "running"));
|
|
594
|
+
}
|
|
595
|
+
else {
|
|
596
|
+
// Latest active
|
|
597
|
+
for (let i = ledger.attempts.length - 1; i >= 0; i--) {
|
|
598
|
+
const a = ledger.attempts[i];
|
|
599
|
+
if (a !== undefined && (a.status === "queued" || a.status === "running")) {
|
|
600
|
+
idx = i;
|
|
601
|
+
break;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
if (idx < 0) {
|
|
606
|
+
// No open attempt — still allow recording a synthetic terminal for reconciliation.
|
|
607
|
+
const synthetic = {
|
|
608
|
+
attemptId: newAttemptId("rec"),
|
|
609
|
+
sourceRevision: ledger.lastSourceRevision ?? "unknown",
|
|
610
|
+
trigger: "automatic",
|
|
611
|
+
status: input.status,
|
|
612
|
+
failure: input.failure ?? null,
|
|
613
|
+
materialDelta: input.materialDelta ?? [],
|
|
614
|
+
startedAt: now,
|
|
615
|
+
endedAt: now,
|
|
616
|
+
elapsedSeconds: input.elapsedSeconds ?? 0,
|
|
617
|
+
toolCallCount: input.toolCallCount ?? 0,
|
|
618
|
+
hostTokenCount: input.hostTokenCount ?? null,
|
|
619
|
+
workerId: null,
|
|
620
|
+
externalRunId: input.externalRunId ?? null,
|
|
621
|
+
};
|
|
622
|
+
return applyTerminal(ledger, synthetic, input, now, true);
|
|
623
|
+
}
|
|
624
|
+
const prev = ledger.attempts[idx];
|
|
625
|
+
if (prev === undefined) {
|
|
626
|
+
return ledger;
|
|
627
|
+
}
|
|
628
|
+
if (prev.status === "succeeded" ||
|
|
629
|
+
prev.status === "failed" ||
|
|
630
|
+
prev.status === "cancelled" ||
|
|
631
|
+
prev.status === "blocked") {
|
|
632
|
+
// Already terminal — do not double-count.
|
|
633
|
+
return ledger;
|
|
634
|
+
}
|
|
635
|
+
const updated = {
|
|
636
|
+
...prev,
|
|
637
|
+
status: input.status,
|
|
638
|
+
failure: input.failure ?? prev.failure,
|
|
639
|
+
materialDelta: input.materialDelta ?? prev.materialDelta,
|
|
640
|
+
endedAt: now,
|
|
641
|
+
elapsedSeconds: input.elapsedSeconds ?? prev.elapsedSeconds,
|
|
642
|
+
toolCallCount: input.toolCallCount ?? prev.toolCallCount,
|
|
643
|
+
hostTokenCount: input.hostTokenCount === undefined ? prev.hostTokenCount : input.hostTokenCount,
|
|
644
|
+
externalRunId: input.externalRunId ?? prev.externalRunId,
|
|
645
|
+
};
|
|
646
|
+
const attempts = ledger.attempts.slice();
|
|
647
|
+
attempts[idx] = updated;
|
|
648
|
+
const withAttempt = { ...ledger, attempts };
|
|
649
|
+
return applyTerminal(withAttempt, updated, input, now, false);
|
|
650
|
+
}
|
|
651
|
+
function applyTerminal(ledger, attempt, input, now, appendSynthetic) {
|
|
652
|
+
const failure = input.failure ?? attempt.failure;
|
|
653
|
+
const elapsed = input.elapsedSeconds ?? attempt.elapsedSeconds;
|
|
654
|
+
const tools = input.toolCallCount ?? attempt.toolCallCount;
|
|
655
|
+
const tokens = input.hostTokenCount === undefined ? attempt.hostTokenCount : input.hostTokenCount;
|
|
656
|
+
let failedAttemptCount = ledger.failedAttemptCount;
|
|
657
|
+
const sameFailureCounts = { ...ledger.sameFailureCounts };
|
|
658
|
+
if (input.status === "failed" || input.status === "blocked") {
|
|
659
|
+
failedAttemptCount += 1;
|
|
660
|
+
if (failure !== null) {
|
|
661
|
+
sameFailureCounts[failure.fingerprint] = (sameFailureCounts[failure.fingerprint] ?? 0) + 1;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
let totalHost = ledger.totalHostTokenCount;
|
|
665
|
+
if (tokens !== null && tokens !== undefined) {
|
|
666
|
+
totalHost = (totalHost ?? 0) + tokens;
|
|
667
|
+
}
|
|
668
|
+
const attempts = appendSynthetic ? [...ledger.attempts, attempt] : ledger.attempts;
|
|
669
|
+
// Success closes the active failure identity for this unit (transient recovery
|
|
670
|
+
// path). Phase aggregate counters remain for observability / attempt budget.
|
|
671
|
+
const lastFailure = input.status === "succeeded" ? null : (failure ?? ledger.lastFailure);
|
|
672
|
+
return {
|
|
673
|
+
...ledger,
|
|
674
|
+
attempts,
|
|
675
|
+
failedAttemptCount,
|
|
676
|
+
sameFailureCounts,
|
|
677
|
+
totalElapsedSeconds: ledger.totalElapsedSeconds + elapsed,
|
|
678
|
+
totalToolCallCount: ledger.totalToolCallCount + tools,
|
|
679
|
+
totalHostTokenCount: totalHost,
|
|
680
|
+
lastFailure,
|
|
681
|
+
lastMaterialDelta: input.materialDelta ?? attempt.materialDelta ?? ledger.lastMaterialDelta,
|
|
682
|
+
lastSourceRevision: attempt.sourceRevision,
|
|
683
|
+
blockedDecision: input.status === "succeeded" ? null : ledger.blockedDecision,
|
|
684
|
+
resumeCondition: input.status === "succeeded" ? null : ledger.resumeCondition,
|
|
685
|
+
updatedAt: now,
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
/** Mark unit blocked with resume condition (persisted before worker exit). */
|
|
689
|
+
export function markBlocked(ledger, decision, resume, now) {
|
|
690
|
+
return {
|
|
691
|
+
...ledger,
|
|
692
|
+
blockedDecision: decision,
|
|
693
|
+
resumeCondition: resume,
|
|
694
|
+
updatedAt: utcIso(now),
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
/** Clear block when resume condition is satisfied (caller sets satisfied=true). */
|
|
698
|
+
export function clearBlockIfResumed(ledger, now) {
|
|
699
|
+
if (ledger.resumeCondition === null || !ledger.resumeCondition.satisfied) {
|
|
700
|
+
return ledger;
|
|
701
|
+
}
|
|
702
|
+
return {
|
|
703
|
+
...ledger,
|
|
704
|
+
blockedDecision: null,
|
|
705
|
+
resumeCondition: { ...ledger.resumeCondition, satisfied: true },
|
|
706
|
+
updatedAt: utcIso(now),
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Record an audited operator override. Preserves full attempt history.
|
|
711
|
+
* remainingAttempts starts at allowedAttempts.
|
|
712
|
+
*/
|
|
713
|
+
export function recordOperatorOverride(ledger, input) {
|
|
714
|
+
const now = utcIso(input.now);
|
|
715
|
+
const allowed = Math.max(1, input.allowedAttempts ?? 1);
|
|
716
|
+
const override = {
|
|
717
|
+
overrideId: `ovr-${randomBytes(6).toString("hex")}`,
|
|
718
|
+
actor: input.actor,
|
|
719
|
+
rationale: input.rationale,
|
|
720
|
+
recordedAt: now,
|
|
721
|
+
allowedAttempts: allowed,
|
|
722
|
+
expiresAt: input.expiresAt ?? null,
|
|
723
|
+
remainingAttempts: allowed,
|
|
724
|
+
};
|
|
725
|
+
return {
|
|
726
|
+
...ledger,
|
|
727
|
+
override,
|
|
728
|
+
blockedDecision: null,
|
|
729
|
+
resumeCondition: {
|
|
730
|
+
kind: "operator-override",
|
|
731
|
+
description: `operator override by ${input.actor}: ${input.rationale}`,
|
|
732
|
+
satisfied: true,
|
|
733
|
+
},
|
|
734
|
+
updatedAt: now,
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
/** In-memory ledger store for tests and pure evaluation paths. */
|
|
738
|
+
export class MemoryLedgerStore {
|
|
739
|
+
map = new Map();
|
|
740
|
+
get(scopeId, targetId, workflowId) {
|
|
741
|
+
return this.map.get(deliveryUnitKey(scopeId, targetId, workflowId)) ?? null;
|
|
742
|
+
}
|
|
743
|
+
set(ledger) {
|
|
744
|
+
this.map.set(deliveryUnitKey(ledger.scopeId, ledger.targetId, ledger.workflowId), ledger);
|
|
745
|
+
}
|
|
746
|
+
getOrCreate(input) {
|
|
747
|
+
const existing = this.get(input.scopeId, input.targetId, input.workflowId);
|
|
748
|
+
if (existing)
|
|
749
|
+
return existing;
|
|
750
|
+
const empty = emptyUnitLedger(input);
|
|
751
|
+
this.set(empty);
|
|
752
|
+
return empty;
|
|
753
|
+
}
|
|
754
|
+
clear() {
|
|
755
|
+
this.map.clear();
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
//# sourceMappingURL=ledger.js.map
|