@deksden-com/dd-flow-cli 0.9.0-beta.151 → 0.9.0-beta.152
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/build-info.json +3 -3
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +56 -9
- package/dist/harness-runtime/lib/dd-opencode.mjs +30 -12
- package/dist/services/check-runner-evidence.js +39 -9
- package/dist/services/check-runner-proof.js +69 -1
- package/dist/services/code-checks.js +27 -11
- package/dist/services/repair-intents.js +4 -1
- package/dist/services/vnext-code-review.js +1 -1
- package/dist/services/vnext-code.js +3 -2
- package/dist/services/vnext-merge.js +1 -1
- package/dist/services/work-registry.js +1 -1
- package/package.json +2 -2
- package/tools/verification-jobs.mjs +317 -0
package/dist/build-info.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"cli_package": "@deksden-com/dd-flow-cli",
|
|
3
|
-
"cli_version": "0.9.0-beta.
|
|
4
|
-
"cli_commit": "
|
|
5
|
-
"built_at": "2026-10-
|
|
3
|
+
"cli_version": "0.9.0-beta.152",
|
|
4
|
+
"cli_commit": "70ed6da341845d1b7f262b74f6731ac4787aff73",
|
|
5
|
+
"built_at": "2026-10-26T03:31:35.940Z",
|
|
6
6
|
"built_with_canon": {
|
|
7
7
|
"version": "4.1.2",
|
|
8
8
|
"commit": "925ef10ecc0b683f7feba6f74913e15560842500",
|
|
@@ -14,7 +14,7 @@ import { durableDaemonDispatch, completeDaemonShutdown, commitDaemonShutdown, tr
|
|
|
14
14
|
import { waitForSettlement } from "./session-settlement.mjs";
|
|
15
15
|
import { nativeExecutionReceipt } from "./native-children.mjs";
|
|
16
16
|
import { errorRecord, isProvenShellCorrection, reportedError } from "./operation-errors.mjs";
|
|
17
|
-
import { checkObservedProfile, observeModel } from "./model-observations.mjs";
|
|
17
|
+
import { checkObservedProfile, observeModel, observedFields, readModelObservations } from "./model-observations.mjs";
|
|
18
18
|
import os from "node:os";
|
|
19
19
|
import path from "node:path";
|
|
20
20
|
import { parse as parseJsonc } from "jsonc-parser";
|
|
@@ -202,6 +202,15 @@ export class Runtime {
|
|
|
202
202
|
const record = { provider_session_id: id, adapter_session_id: id, parent_provider_session_id: parent, root_provider_session_id: root, cwd: info.directory };
|
|
203
203
|
this.sessions.set(id, record); return record;
|
|
204
204
|
}
|
|
205
|
+
registerObservedChild(info, root) {
|
|
206
|
+
const owner = this.sessions.get(root);
|
|
207
|
+
if (!owner || owner.parent_provider_session_id || owner.root_provider_session_id !== root
|
|
208
|
+
|| info.id === root || info.parentID !== root || info.fork)
|
|
209
|
+
throw new DaemonError("session_identity_mismatch", "OpenCode child is not a direct member of the owned root");
|
|
210
|
+
if (typeof info.directory !== "string" || !path.isAbsolute(info.directory))
|
|
211
|
+
throw new OpenCodeError("opencode_unavailable", "OpenCode child directory binding is not yet observable");
|
|
212
|
+
return this.retainSession(info, root);
|
|
213
|
+
}
|
|
205
214
|
async journal(operation, result) { const line = `${JSON.stringify({ schema_id: "dd-opencode/daemon-event@1", event_id: randomUUID(), observed_at: new Date().toISOString(), operation, active_tree: this.state.active_tree, session_id: result?.provider_session_id ?? null })}\n`; for (const journal of new Set([this.paths.journal, this.state.config.journal].filter(Boolean))) { await mkdir(path.dirname(journal), { recursive: true, mode: 0o700 }); await appendFile(journal, line, { mode: 0o600 }); } }
|
|
206
215
|
async childTree(id, seen = new Set()) {
|
|
207
216
|
if (seen.has(id)) throw new DaemonError("session_identity_mismatch", "OpenCode native topology contains a cycle");
|
|
@@ -299,17 +308,52 @@ export class Runtime {
|
|
|
299
308
|
return nativeExecutionReceipt(receipt, { daemonId: this.state.daemon_id, revision: before.get(id) ?? 0 });
|
|
300
309
|
}
|
|
301
310
|
async observeModels(id, messages, parent = null) {
|
|
302
|
-
this.
|
|
311
|
+
const journal = this.state.config.journal ?? this.paths.journal;
|
|
312
|
+
if (!this.modelMessages) {
|
|
313
|
+
this.modelMessages = new Map();
|
|
314
|
+
for (const event of journal ? await readModelObservations(journal) : []) {
|
|
315
|
+
if (event.harness !== 'opencode-server' || event.source?.method !== 'session/message' || !event.source.message_id) continue;
|
|
316
|
+
const key = `${event.session_id}:${event.source.message_id}`;
|
|
317
|
+
if (event.evidence_conflict) { const prior = this.modelMessages.get(key); if (prior) prior.conflicted = true; continue; }
|
|
318
|
+
if (!this.modelMessages.has(key)) this.modelMessages.set(key, { source: event.source,
|
|
319
|
+
known: { parent: event.parent_session_id, root: event.native_agent_role?.launch?.root_session_id,
|
|
320
|
+
role: event.native_agent_role ?? null, requested: observedFields(event.requested), observed: observedFields(event.observed) } });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
303
323
|
for (const message of messages ?? []) {
|
|
304
324
|
const info = message.info;
|
|
305
|
-
if (info?.role !== "assistant" || !info.id
|
|
325
|
+
if (info?.role !== "assistant" || !info.id) continue;
|
|
306
326
|
if (info.sessionID && info.sessionID !== id) throw new DaemonError("session_identity_mismatch", "native message belongs to another Session");
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
327
|
+
const record = this.sessions.get(id);
|
|
328
|
+
if (record && (record.parent_provider_session_id ?? null) !== parent)
|
|
329
|
+
throw new DaemonError("session_identity_mismatch", "OpenCode observed message parent changed");
|
|
330
|
+
if (!record?.root_provider_session_id || !info.mode) {
|
|
331
|
+
await observeModel({ journal, harness: "opencode-server", sessionId: id, parentSessionId: parent,
|
|
332
|
+
source: { channel: "native.message_observer", scope: "response", non_asserting: true }, evidence: "unavailable", reason: "native_role_binding_unavailable" });
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
const requested = { ...this.state.config, reasoning: this.state.config.variant, mode: parent ? "dd-flow-worker" : this.state.config.agent };
|
|
336
|
+
const nativeAgentRole = this.state.config.workerLaunchConfig ? { name: info.mode, kind: parent ? 'subagent' : 'primary', parent_session_id: parent,
|
|
337
|
+
launch: { ...this.state.config.workerLaunchConfig, root_session_id: record.root_provider_session_id } } : null;
|
|
338
|
+
const observed = observedFields({ provider: info.providerID, model: info.modelID, reasoning: info.variant, mode: info.mode });
|
|
339
|
+
const tuple = { parent, root: record.root_provider_session_id, role: nativeAgentRole, requested: observedFields(requested), observed };
|
|
340
|
+
const key = `${id}:${info.id}`, previous = this.modelMessages.get(key);
|
|
341
|
+
if (previous?.conflicted) throw new DaemonError("model_observation_identity_conflict", "OpenCode native message has a retained identity/profile conflict", false, { session_id: id, message_id: info.id });
|
|
342
|
+
const conflicts = (before, after) => before != null && after != null && (typeof before === "object" && typeof after === "object"
|
|
343
|
+
? [...new Set([...Object.keys(before), ...Object.keys(after)])].some(field => conflicts(before[field], after[field])) : before !== after);
|
|
344
|
+
if (previous && !conflicts(previous.known, tuple)) {
|
|
345
|
+
// Optional disclosure can arrive late. Do not rewrite the old native fact;
|
|
346
|
+
// remember known values only to detect a later positive contradiction.
|
|
347
|
+
for (const [field, value] of Object.entries(observed)) if (value != null) previous.known.observed[field] = value;
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
const source = previous?.source ?? { method: "session/message", message_id: info.id, event_sha256: createHash("sha256").update(`${id}:${info.id}`).digest("hex") };
|
|
351
|
+
const published = await observeModel({ journal, harness: "opencode-server", sessionId: id, parentSessionId: parent, requested, nativeAgentRole, observed, source, evidence: "response" });
|
|
352
|
+
if (previous || published?.evidence_conflict) {
|
|
353
|
+
if (previous) previous.conflicted = true;
|
|
354
|
+
throw new DaemonError("model_observation_identity_conflict", "OpenCode native message changed its published identity/profile", false, { session_id: id, message_id: info.id });
|
|
355
|
+
}
|
|
356
|
+
this.modelMessages.set(key, { known: structuredClone(tuple), source });
|
|
313
357
|
}
|
|
314
358
|
}
|
|
315
359
|
async requireSettled(id) {
|
|
@@ -462,6 +506,9 @@ export class Runtime {
|
|
|
462
506
|
assertDispatch, checkHookFailures, livenessTimeoutMs: params.livenessTimeoutMs ?? this.client.timeoutMs,
|
|
463
507
|
onDispatched: () => this.journal("native_dispatch", { operation_id: operationId, provider_session_id: params.sessionId }),
|
|
464
508
|
onModelMessages: (id, messages, parent) => this.observeModels(id, messages, parent),
|
|
509
|
+
onNativeSession: (info, root) => this.registerObservedChild(info, root),
|
|
510
|
+
rootSessionId: this.sessions.get(params.sessionId)?.root_provider_session_id ?? params.sessionId,
|
|
511
|
+
parentSessionId: this.sessions.get(params.sessionId)?.parent_provider_session_id ?? null,
|
|
465
512
|
onObservationError: error => observeModel({ journal: this.state.config.journal ?? this.paths.journal, harness: "opencode-server", sessionId: params.sessionId, requested: this.state.config, source: "native.message_observer", evidence: "unavailable", reason: error.code ?? "observation_unavailable" })
|
|
466
513
|
}); await checkHookFailures(); }
|
|
467
514
|
finally { current.requests.clear(); if (this.nativeHookOperation === current) this.nativeHookOperation = null; }
|
|
@@ -169,13 +169,32 @@ export class OpenCodeClient {
|
|
|
169
169
|
import(data) { return this.call("session.import", data, { productive: true }); }
|
|
170
170
|
|
|
171
171
|
async prompt(id, { prompt, messageId = `msg_${randomUUID().replaceAll("-", "")}`, retained,
|
|
172
|
-
save, onModelMessages,
|
|
172
|
+
save, onModelMessages, onNativeSession, rootSessionId = id, parentSessionId = null,
|
|
173
|
+
onObservationError, assertDispatch, onDispatched, checkHookFailures, livenessTimeoutMs = this.timeoutMs }) {
|
|
173
174
|
validateObservationDuration(livenessTimeoutMs);
|
|
174
175
|
const request = { sessionID: id, id: messageId, text: prompt, delivery: "queue", resume: true }, digest = requestDigest(request);
|
|
175
176
|
if (retained && (retained.request_sha256 !== digest || retained.message_id !== messageId)) throw new OpenCodeError("operation_request_mismatch", "OpenCode operation already binds another prompt", false, { effect: "no_effect" });
|
|
176
177
|
let state = retained ?? { message_id: messageId, request_sha256: digest, phase: "prepared", cursor: 0 };
|
|
177
|
-
const previous = await this.messages(id), baseline = new Map([[id, new Set(previous.map(item => item.info.id))]]), parents = new Map();
|
|
178
|
-
|
|
178
|
+
const previous = await this.messages(id), baseline = new Map([[id, new Set(previous.map(item => item.info.id))]]), parents = new Map([[id, parentSessionId]]);
|
|
179
|
+
// The same native lineage guard applies before dispatch and while polling.
|
|
180
|
+
// Registration precedes message publication; a list ID alone is not ownership.
|
|
181
|
+
const childrenOf = async (session, visited) => {
|
|
182
|
+
const children = await this.children(session);
|
|
183
|
+
for (const child of children) {
|
|
184
|
+
if (!child?.id || child.parentID !== session || child.fork || session !== rootSessionId || visited.has(child.id)
|
|
185
|
+
|| parents.has(child.id) && parents.get(child.id) !== session || visited.size >= 1024)
|
|
186
|
+
throw new OpenCodeError("session_identity_mismatch", "OpenCode native child lineage is invalid", false, { session_id: child?.id, parent_session_id: session });
|
|
187
|
+
visited.add(child.id); parents.set(child.id, session);
|
|
188
|
+
await onNativeSession?.(child, rootSessionId);
|
|
189
|
+
}
|
|
190
|
+
return children;
|
|
191
|
+
};
|
|
192
|
+
const capture = async (session, visited = new Set([id])) => {
|
|
193
|
+
for (const child of await childrenOf(session, visited)) {
|
|
194
|
+
baseline.set(child.id, new Set((await this.messages(child.id)).map(item => item.info.id)));
|
|
195
|
+
await capture(child.id, visited);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
179
198
|
await capture(id);
|
|
180
199
|
if (retained) {
|
|
181
200
|
const index = previous.findIndex(item => item.info.id === messageId);
|
|
@@ -258,8 +277,8 @@ export class OpenCodeClient {
|
|
|
258
277
|
}
|
|
259
278
|
if (state.cursor !== watermark) { state = { ...state, cursor: watermark }; await save?.(state); }
|
|
260
279
|
let runningTools = false;
|
|
261
|
-
const observe = async (session, visited = new Set()) => {
|
|
262
|
-
|
|
280
|
+
const observe = async (session, visited = new Set([id])) => {
|
|
281
|
+
known.add(session);
|
|
263
282
|
const messages = await this.messages(session);
|
|
264
283
|
await onModelMessages?.(session, messages, parents.get(session) ?? null);
|
|
265
284
|
const floor = baseline.get(session) ?? new Set(), current = messages.filter(item => item.info.role === "assistant" && !floor.has(item.info.id));
|
|
@@ -291,10 +310,7 @@ export class OpenCodeClient {
|
|
|
291
310
|
}
|
|
292
311
|
}
|
|
293
312
|
}
|
|
294
|
-
for (const child of await
|
|
295
|
-
if (child.parentID !== session) throw lost("OpenCode child has a foreign parent");
|
|
296
|
-
parents.set(child.id, session); await observe(child.id, visited);
|
|
297
|
-
}
|
|
313
|
+
for (const child of await childrenOf(session, visited)) await observe(child.id, visited);
|
|
298
314
|
return current;
|
|
299
315
|
};
|
|
300
316
|
const current = await observe(id), rootMessages = await this.messages(id);
|
|
@@ -309,8 +325,9 @@ export class OpenCodeClient {
|
|
|
309
325
|
// Native messages and topology are separate reads: a child can
|
|
310
326
|
// finish between them. Reconcile its ancestry before rejecting
|
|
311
327
|
// the notification, never trust the notification alone.
|
|
312
|
-
const
|
|
313
|
-
|
|
328
|
+
const visited = new Set([id]);
|
|
329
|
+
const child = (await childrenOf(id, visited)).find(child => child.id === item.info.metadata.childID);
|
|
330
|
+
if (child) await observe(child.id, visited);
|
|
314
331
|
}
|
|
315
332
|
if (item.info.role === "user" || item.info.role === "synthetic" && !(
|
|
316
333
|
item.info.metadata?.source === "subagent" && parents.get(item.info.metadata.childID) === id
|
|
@@ -353,7 +370,8 @@ export class OpenCodeClient {
|
|
|
353
370
|
}
|
|
354
371
|
} catch (error) {
|
|
355
372
|
await checkHookFailures?.();
|
|
356
|
-
if (error instanceof OpenCodeError && !["opencode_unavailable", "operation_observation_lost"].includes(error.code)
|
|
373
|
+
if (error instanceof OpenCodeError && !["opencode_unavailable", "operation_observation_lost"].includes(error.code)
|
|
374
|
+
|| ["session_identity_mismatch", "model_observation_identity_conflict"].includes(error.code)) throw error;
|
|
357
375
|
await onObservationError?.(error);
|
|
358
376
|
}
|
|
359
377
|
if (clock.sample(marker)) throw lost("OpenCode native progress stopped or observation could not recover", { session_id: id, message_id: messageId, observation: clock.state(), ...(observationErrors.count ? { observation_errors: observationErrors } : {}) });
|
|
@@ -5,8 +5,8 @@ import { parse } from "shell-quote";
|
|
|
5
5
|
import { AppError } from "../shared/errors.js";
|
|
6
6
|
import { validateObservationDuration } from "../harness-runtime/lib/observation-clock.mjs";
|
|
7
7
|
import { isDeepStrictEqual } from "node:util";
|
|
8
|
-
import { runnerFailureValid, validateRunnerProof, validatedRunnerChild, hasValidatedNativeProof, hasValidatedBrowserProof, hasUnsupportedObservation } from "./check-runner-proof.js";
|
|
9
|
-
export { candidateAbsencesValid, runnerFailureValid, runnerEvidencePass, runnerProofArtifacts } from "./check-runner-proof.js";
|
|
8
|
+
import { runnerFailureValid, validateRunnerProof, validatedRunnerChild, hasValidatedNativeProof, hasValidatedBrowserProof, hasUnsupportedObservation, runnerLibraryInputs } from "./check-runner-proof.js";
|
|
9
|
+
export { candidateAbsencesValid, candidateLibraryInputsValid, runnerFailureValid, runnerEvidencePass, runnerProofArtifacts } from "./check-runner-proof.js";
|
|
10
10
|
const absoluteFile = (value) => typeof value === "string" && value.length > 0 && value.length <= 4096 && !value.includes("\0") && path.isAbsolute(value);
|
|
11
11
|
function candidateFile(root, file) {
|
|
12
12
|
if (!root || !path.isAbsolute(file))
|
|
@@ -45,6 +45,28 @@ export function candidateFailureFiles(evidence, root) {
|
|
|
45
45
|
}
|
|
46
46
|
}))];
|
|
47
47
|
}
|
|
48
|
+
export function candidateLibraryInputs(evidence, root) {
|
|
49
|
+
const inputs = new Map();
|
|
50
|
+
for (const failure of evidence?.failures ?? []) {
|
|
51
|
+
const child = validatedRunnerChild(failure);
|
|
52
|
+
const candidates = child ? candidateLibraryInputs({ ...child.evidence, failures: [child.failure] }, root)
|
|
53
|
+
: failure.repair_evidence?.rule === "candidate_library_query_error" ? runnerLibraryInputs(failure.repair_evidence) ?? [] : [];
|
|
54
|
+
try {
|
|
55
|
+
if (candidates.some(input => !candidateFile(root, input.file) || crypto.createHash("sha256").update(readBoundedProofFile(input.file, 2 * 1024 * 1024)).digest("hex") !== input.sha256))
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
for (const input of candidates)
|
|
62
|
+
inputs.set(input.file, input.sha256);
|
|
63
|
+
}
|
|
64
|
+
return [...inputs].map(([file, sha256]) => ({ file, sha256 }));
|
|
65
|
+
}
|
|
66
|
+
/** Current execution/repair boundary only; historical readers retain captured proof. */
|
|
67
|
+
export function currentCandidateLibraryInputsValid(receipt, workspaceRoot) {
|
|
68
|
+
return isDeepStrictEqual(receipt.candidate_library_inputs ?? [], candidateLibraryInputs(receipt.runner_evidence, workspaceRoot));
|
|
69
|
+
}
|
|
48
70
|
function absentCandidate(root, file) {
|
|
49
71
|
if (!root || !absoluteFile(file))
|
|
50
72
|
return false;
|
|
@@ -104,10 +126,10 @@ export function candidateFailureAbsences(evidence, root) {
|
|
|
104
126
|
}
|
|
105
127
|
});
|
|
106
128
|
}
|
|
107
|
-
function candidateFailure(failure, evidence, root, capturedFiles, capturedAbsences) {
|
|
129
|
+
function candidateFailure(failure, evidence, root, capturedFiles, capturedAbsences, capturedLibraryInputs) {
|
|
108
130
|
const child = validatedRunnerChild(failure);
|
|
109
131
|
if (child)
|
|
110
|
-
return candidateFailure(child.failure, child.evidence, root, capturedFiles, capturedAbsences);
|
|
132
|
+
return candidateFailure(child.failure, child.evidence, root, capturedFiles, capturedAbsences, capturedLibraryInputs);
|
|
111
133
|
const owned = (file) => {
|
|
112
134
|
if (capturedFiles !== undefined)
|
|
113
135
|
return capturedFiles.includes(file);
|
|
@@ -126,6 +148,11 @@ function candidateFailure(failure, evidence, root, capturedFiles, capturedAbsenc
|
|
|
126
148
|
if (failure.category === "assertion")
|
|
127
149
|
return failure.origin === undefined || failure.origin === "candidate" && failure.file !== undefined && owned(failure.file);
|
|
128
150
|
const proof = failure.repair_evidence;
|
|
151
|
+
if (proof?.rule === "candidate_library_query_error") {
|
|
152
|
+
const required = runnerLibraryInputs(proof), captured = capturedFiles !== undefined ? capturedLibraryInputs : root ? candidateLibraryInputs({ ...evidence, failures: [failure] }, root) : undefined;
|
|
153
|
+
return hasValidatedNativeProof(proof) && failure.category === "unclassified" && failure.origin === "candidate" && failure.phase === "test" && failure.native_code === proof.exception_type
|
|
154
|
+
&& failure.test_id === proof.test_id && failure.file === proof.file && owned(proof.file) && Boolean(required?.length && captured && required.every(input => captured.some(item => item.file === input.file && item.sha256 === input.sha256)));
|
|
155
|
+
}
|
|
129
156
|
if (proof?.rule === "candidate_test_runtime_error")
|
|
130
157
|
return hasValidatedNativeProof(proof) && failure.category === "unclassified" && failure.origin === "candidate"
|
|
131
158
|
&& failure.phase === proof.phase && failure.native_code === proof.exception_type && failure.file === proof.file && owned(proof.file);
|
|
@@ -266,12 +293,15 @@ export function checkRepairDisposition(receipt) {
|
|
|
266
293
|
return "tooling_blocker";
|
|
267
294
|
if (!evidence.completed)
|
|
268
295
|
return "unclassified";
|
|
269
|
-
if (evidence.failures.some(f => !runnerFailureValid(f) || !candidateFailure(f, evidence, receipt.candidate_root, receipt.candidate_files, receipt.candidate_absences)))
|
|
296
|
+
if (evidence.failures.some(f => !runnerFailureValid(f) || !candidateFailure(f, evidence, receipt.candidate_root, receipt.candidate_files, receipt.candidate_absences, receipt.candidate_library_inputs)))
|
|
270
297
|
return "unclassified";
|
|
271
298
|
return "source_repair";
|
|
272
299
|
}
|
|
273
|
-
export function assertSourceRepairEligible(receipts) {
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
300
|
+
export function assertSourceRepairEligible(receipts, workspaceRoot) {
|
|
301
|
+
const dispositions = receipts.map(receipt => {
|
|
302
|
+
const drift = workspaceRoot !== undefined && !currentCandidateLibraryInputsValid(receipt, workspaceRoot);
|
|
303
|
+
return { check_receipt_id: receipt.id, disposition: drift ? "tooling_blocker" : checkRepairDisposition(receipt), ...(drift ? { reason: "candidate_library_inputs_changed" } : {}) };
|
|
304
|
+
});
|
|
305
|
+
if (!receipts.length || dispositions.some(item => item.disposition !== "source_repair"))
|
|
306
|
+
throw new AppError("check_gate_blocked", "Checks require tooling diagnosis or settlement before product repair; retained nonzero is not a candidate-source verdict", 2, { failures: receipts, dispositions, outcome: "blocked", recoverable: false, instruction: "Inspect retained runner evidence. Do not change product code to repair tooling, unknown observation or unclassified failure. Retry only after establishing and correcting the cause." });
|
|
277
307
|
}
|
|
@@ -8,11 +8,13 @@ function boundedString(value, bytes) { return typeof value === "string" && Buffe
|
|
|
8
8
|
const validatedBrowserProofs = new WeakSet();
|
|
9
9
|
const unsupportedObservationContracts = new WeakSet();
|
|
10
10
|
const validatedNativeProofs = new WeakSet();
|
|
11
|
+
const validatedLibraryInputs = new WeakMap();
|
|
11
12
|
const validatedChildren = new WeakMap();
|
|
12
13
|
const validatedReports = new WeakMap();
|
|
13
14
|
const hash = (value) => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
|
14
15
|
const absoluteFile = (value) => typeof value === "string" && value.length > 0 && value.length <= 4096 && !value.includes("\0") && path.isAbsolute(value);
|
|
15
16
|
export function candidateAbsencesValid(value) { return Array.isArray(value) && value.length <= 64 && value.every(item => object(item) && absoluteFile(item.file) && item.path_state === "missing" && object(item.declaration) && absoluteFile(item.declaration.file) && hash(item.declaration.sha256)); }
|
|
17
|
+
export function candidateLibraryInputsValid(value) { return Array.isArray(value) && value.length <= 64 && new Set(value.map(item => object(item) ? item.file : null)).size === value.length && value.every(item => object(item) && absoluteFile(item.file) && hash(item.sha256)); }
|
|
16
18
|
function relativeProofPath(value) { return typeof value === "string" && value.length > 0 && Buffer.byteLength(value) <= 4096 && !path.isAbsolute(value) && !value.includes("\\") && value.split("/").every(part => part !== "" && part !== "." && part !== ".."); }
|
|
17
19
|
function proofArtifact(value) { return object(value) && relativeProofPath(value.relative_path) && typeof value.sha256 === "string" && /^[a-f0-9]{64}$/.test(value.sha256); }
|
|
18
20
|
function browserProofShape(proof) {
|
|
@@ -35,6 +37,13 @@ export function runnerFailureValid(value) {
|
|
|
35
37
|
return false;
|
|
36
38
|
if (value.repair_evidence !== undefined) {
|
|
37
39
|
const proof = value.repair_evidence;
|
|
40
|
+
if (object(proof) && proof.rule === "candidate_library_query_error")
|
|
41
|
+
return proof.exception_type === "TestingLibraryElementError" && proof.phase === "test"
|
|
42
|
+
&& boundedString(proof.test_id, 4096) && Boolean(proof.test_id) && absoluteFile(proof.file) && hash(proof.file_sha256)
|
|
43
|
+
&& [proof.line, proof.column].every(n => Number.isSafeInteger(n) && Number(n) > 0) && object(proof.library)
|
|
44
|
+
&& proof.library.name === "@testing-library/dom" && proof.library.version === "10.4.1" && hash(proof.library.package_json_sha256) && hash(proof.library.lockfile_sha256)
|
|
45
|
+
&& Array.isArray(proof.helper_frames) && proof.helper_frames.length >= 2 && proof.helper_frames.length <= 8
|
|
46
|
+
&& proof.helper_frames.every(frame => object(frame) && absoluteFile(frame.file) && hash(frame.file_sha256) && [frame.line, frame.column].every(n => Number.isSafeInteger(n) && Number(n) > 0)) && proofArtifact(proof.native_artifact);
|
|
38
47
|
if (object(proof) && proof.rule === "candidate_test_runtime_error")
|
|
39
48
|
return ["TypeError", "ReferenceError", "Error"].includes(String(proof.exception_type)) && (proof.exception_type !== "Error" || proof.phase === "test")
|
|
40
49
|
&& absoluteFile(proof.file) && hash(proof.file_sha256) && ["test", "setup", "collection"].includes(String(proof.phase))
|
|
@@ -82,6 +91,62 @@ function readProofArtifact(root, ref, context, limit = 2 * 1024 * 1024) {
|
|
|
82
91
|
throw new Error("Invalid proof object");
|
|
83
92
|
return value;
|
|
84
93
|
}
|
|
94
|
+
// Exact known helper bytes keep this a library-specific proof, not permission
|
|
95
|
+
// to skip arbitrary dependency frames. New versions need explicit evidence.
|
|
96
|
+
const queryInputHashes = {
|
|
97
|
+
"dist/config.js": "3627f6265cccd26604777602d7249788c2347a273bdd1cae41719c790f2c5a7a",
|
|
98
|
+
"dist/query-helpers.js": "901f513f2ac901992d7c5002e644aef69286edbcd7d050d3ee7dd6aaaebfc9cc",
|
|
99
|
+
"package.json": "4e62e2aa8b974b6a5d2d532396f7610d1b4e806d9d2aaef3c84221f0d2fab06d",
|
|
100
|
+
};
|
|
101
|
+
function validateLibraryQuery(root, proof, failure, evidence, context) {
|
|
102
|
+
const native = readProofArtifact(root, proof.native_artifact, context, 256 * 1024);
|
|
103
|
+
const facts = Object.fromEntries(Object.entries(proof).filter(([key]) => key !== "native_artifact"));
|
|
104
|
+
if (evidence.schema_id !== "dd-flow/check-runner-evidence@2" || !isDeepStrictEqual(native.binding, evidence.binding)
|
|
105
|
+
|| Object.entries(facts).some(([key, value]) => !isDeepStrictEqual(native[key], value)) || native.executing_file !== proof.file || native.exception_name !== proof.exception_type
|
|
106
|
+
|| failure.category !== "unclassified" || failure.origin !== "candidate" || failure.native_code !== proof.exception_type || failure.phase !== "test" || failure.test_id !== proof.test_id || failure.file !== proof.file
|
|
107
|
+
|| native.stack_complete !== true || native.frames_complete !== true || !Array.isArray(native.frame_lines) || native.frame_lines.length !== proof.helper_frames.length + 1
|
|
108
|
+
|| native.frame_lines.some(line => !boundedString(line, 4096)) || Buffer.byteLength(JSON.stringify(native.frame_lines)) > 128 * 1024)
|
|
109
|
+
return false;
|
|
110
|
+
const expectedFrames = [...proof.helper_frames, { file: proof.file, line: proof.line, column: proof.column }];
|
|
111
|
+
for (const [index, line] of native.frame_lines.entries()) {
|
|
112
|
+
const match = String(line).match(/^\s*at (?:.*?\()?((?:file:\/\/)?\/[^\n]+):(\d+):(\d+)\)?$/), expected = expectedFrames[index];
|
|
113
|
+
if (!match || (match[1].startsWith("file:") ? fileURLToPath(match[1]) : match[1]) !== expected.file || Number(match[2]) !== expected.line || Number(match[3]) !== expected.column)
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
const packageRoot = path.dirname(path.dirname(proof.helper_frames[0].file));
|
|
117
|
+
if (proof.helper_frames[0].file !== path.join(packageRoot, "dist/config.js") || !String(native.frame_lines[0]).includes("getElementError")
|
|
118
|
+
|| proof.helper_frames.slice(1).some(frame => frame.file !== path.join(packageRoot, "dist/query-helpers.js")))
|
|
119
|
+
return false;
|
|
120
|
+
if (!Array.isArray(native.library_inputs) || native.library_inputs.length !== 4)
|
|
121
|
+
return false;
|
|
122
|
+
const inputs = [];
|
|
123
|
+
let packageJson;
|
|
124
|
+
for (const [index, value] of native.library_inputs.entries()) {
|
|
125
|
+
if (!object(value) || !absoluteFile(value.file) || !hash(value.sha256) || !proofArtifact(value.artifact))
|
|
126
|
+
return false;
|
|
127
|
+
const expectedName = Object.keys(queryInputHashes)[index];
|
|
128
|
+
if (expectedName ? value.file !== path.join(packageRoot, expectedName) || value.sha256 !== queryInputHashes[expectedName]
|
|
129
|
+
: path.basename(value.file) !== "pnpm-lock.yaml" || value.sha256 !== proof.library.lockfile_sha256)
|
|
130
|
+
return false;
|
|
131
|
+
const bytes = readBytes(context, path.posix.join(root, value.artifact.relative_path), 2 * 1024 * 1024);
|
|
132
|
+
if (crypto.createHash("sha256").update(bytes).digest("hex") !== value.sha256 || value.artifact.sha256 !== value.sha256)
|
|
133
|
+
return false;
|
|
134
|
+
if (index === 2)
|
|
135
|
+
packageJson = JSON.parse(bytes.toString("utf8"));
|
|
136
|
+
if (index === 3 && !bytes.toString("utf8").includes("@testing-library/dom@10.4.1"))
|
|
137
|
+
return false;
|
|
138
|
+
inputs.push({ file: value.file, sha256: value.sha256, artifact: value.artifact });
|
|
139
|
+
}
|
|
140
|
+
if (packageJson?.name !== proof.library.name || packageJson.version !== proof.library.version || inputs[2].sha256 !== proof.library.package_json_sha256
|
|
141
|
+
|| proof.helper_frames.some(frame => inputs.find(input => input.file === frame.file)?.sha256 !== frame.file_sha256))
|
|
142
|
+
return false;
|
|
143
|
+
const candidateRoot = path.dirname(inputs[3].file);
|
|
144
|
+
if ([proof.file, ...inputs.map(input => input.file)].some(file => { const relative = path.relative(candidateRoot, file); return !relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative); }))
|
|
145
|
+
return false;
|
|
146
|
+
validatedLibraryInputs.set(proof, inputs);
|
|
147
|
+
validatedNativeProofs.add(proof);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
85
150
|
function matches(actual, expected) { return object(actual) && Object.entries(expected).every(([key, value]) => actual[key] === value); }
|
|
86
151
|
function validateBrowserArtifacts(root, proof, binding, context) {
|
|
87
152
|
const snapshot = readProofArtifact(root, proof.worker_snapshot, context), native = readProofArtifact(root, proof.native_result, context);
|
|
@@ -104,7 +169,7 @@ export function runnerProofArtifacts(evidence) {
|
|
|
104
169
|
if (child && failure.child_evidence)
|
|
105
170
|
return [failure.child_evidence.report.relative_path, ...runnerProofArtifacts(child.evidence).map(file => path.posix.join(path.posix.dirname(failure.child_evidence.report.relative_path), file))];
|
|
106
171
|
const proof = failure.repair_evidence;
|
|
107
|
-
return [...(failure.diagnostic_artifact ? [failure.diagnostic_artifact.relative_path] : []), ...(proof?.rule === "candidate_browser_action_inactivity" ? [proof.worker_snapshot.relative_path, proof.native_result.relative_path] : proof && "native_artifact" in proof ? [proof.native_artifact.relative_path] : [])];
|
|
172
|
+
return [...(failure.diagnostic_artifact ? [failure.diagnostic_artifact.relative_path] : []), ...(proof?.rule === "candidate_browser_action_inactivity" ? [proof.worker_snapshot.relative_path, proof.native_result.relative_path] : proof && "native_artifact" in proof ? [proof.native_artifact.relative_path, ...(proof.rule === "candidate_library_query_error" ? (validatedLibraryInputs.get(proof) ?? []).map(input => input.artifact.relative_path) : [])] : [])];
|
|
108
173
|
}), ...(evidence ? validatedReports.get(evidence) ?? [] : []).flatMap(child => [child.report.relative_path, ...runnerProofArtifacts(child.evidence).map(file => path.posix.join(path.posix.dirname(child.report.relative_path), file))])])];
|
|
109
174
|
}
|
|
110
175
|
function diagnostic(context, value) {
|
|
@@ -224,6 +289,8 @@ function readRunnerEvidenceInternal(root, contract, binding, depth, budget, cont
|
|
|
224
289
|
if (failure.diagnostic_artifact && !matches(readProofArtifact(root, failure.diagnostic_artifact, context, 2 * 1024 * 1024).binding, binding))
|
|
225
290
|
return null;
|
|
226
291
|
const proof = failure.repair_evidence;
|
|
292
|
+
if (proof?.rule === "candidate_library_query_error" && !validateLibraryQuery(root, proof, failure, evidence, context))
|
|
293
|
+
return null;
|
|
227
294
|
if (proof?.rule === "candidate_migration_sql" || proof?.rule === "candidate_tool_diagnostic" || proof?.rule === "candidate_test_runtime_error") {
|
|
228
295
|
const native = readProofArtifact(root, proof.native_artifact, context, 2 * 1024 * 1024), facts = Object.fromEntries(Object.entries(proof).filter(([key]) => key !== "native_artifact"));
|
|
229
296
|
if (!isDeepStrictEqual(native.binding, binding) || Object.entries(facts).some(([key, value]) => !isDeepStrictEqual(native[key], value)))
|
|
@@ -301,5 +368,6 @@ function readRunnerEvidenceInternal(root, contract, binding, depth, budget, cont
|
|
|
301
368
|
export function runnerEvidencePass(evidence) { return Boolean(evidence?.completed && !evidence.failure_projection && !evidence.observation_unknown && evidence.counts.failed === 0 && evidence.failures.length === 0); }
|
|
302
369
|
export const validatedRunnerChild = (failure) => validatedChildren.get(failure);
|
|
303
370
|
export const hasValidatedNativeProof = (proof) => validatedNativeProofs.has(proof);
|
|
371
|
+
export const runnerLibraryInputs = (proof) => validatedLibraryInputs.get(proof)?.map(({ file, sha256 }) => ({ file, sha256 }));
|
|
304
372
|
export const hasValidatedBrowserProof = (proof) => validatedBrowserProofs.has(proof);
|
|
305
373
|
export const hasUnsupportedObservation = (evidence) => unsupportedObservationContracts.has(evidence);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
-
import { candidateAbsencesValid, candidateFailureAbsences, candidateFailureFiles, checkRepairDisposition, readRunnerEvidence, readRunnerEvidenceDetailed, readRunnerProgress, runnerContractsValid, runnerEvidencePass, runnerObservationDurations, runnerProofArtifacts } from "./check-runner-evidence.js";
|
|
2
|
+
import { candidateAbsencesValid, candidateFailureAbsences, candidateFailureFiles, candidateLibraryInputs, candidateLibraryInputsValid, currentCandidateLibraryInputsValid, checkRepairDisposition, readRunnerEvidence, readRunnerEvidenceDetailed, readRunnerProgress, runnerContractsValid, runnerEvidencePass, runnerObservationDurations, runnerProofArtifacts } from "./check-runner-evidence.js";
|
|
3
3
|
export { assertSourceRepairEligible, checkRepairDisposition } from "./check-runner-evidence.js";
|
|
4
4
|
import { isGitlessWorkspace, ownedWorkspaceHookPath, projectServicePathFilter, workspaceGitFiles } from "./workspace-files.js";
|
|
5
5
|
import { spawn, spawnSync } from "node:child_process";
|
|
@@ -352,7 +352,7 @@ export class PreparedCheckEvidence {
|
|
|
352
352
|
candidates: receipts.filter(item => item.scope === input.scope && item.work_id === (input.workId ?? null))
|
|
353
353
|
.slice(-16).map(item => ({ id: item.id, input_hash: item.input_hash, profile_hash: item.profile_hash }))
|
|
354
354
|
});
|
|
355
|
-
if (!checkReceiptEvidenceValidForMode(bindReceipt(receipt, group), input.mode))
|
|
355
|
+
if (!checkReceiptEvidenceValidForMode(bindReceipt(receipt, group), input.mode) || !currentCandidateLibraryInputsValid(receipt, input.workspaceRoot))
|
|
356
356
|
fail();
|
|
357
357
|
passed = passed && receipt.status === "passed";
|
|
358
358
|
for (const artifact of receipt.artifacts)
|
|
@@ -666,8 +666,9 @@ async function runCodeChecksOwned(context, input, workspaceGateId, generation, s
|
|
|
666
666
|
physicalFailures.push(result.failure);
|
|
667
667
|
const candidateFiles = candidateFailureFiles(runnerEvidence, input.workspaceRoot);
|
|
668
668
|
const candidateAbsences = candidateFailureAbsences(runnerEvidence, input.workspaceRoot);
|
|
669
|
+
const libraryInputs = candidateLibraryInputs(runnerEvidence, input.workspaceRoot);
|
|
669
670
|
if (group.runnerEvidence)
|
|
670
|
-
retainExecutionOutcome(evidenceDir, { id, command: group.command, input_hash: inputHash, verification_epoch: epoch, fingerprint_scope: "check_inputs", input_manifest: reserved.inputManifest ?? null, execution_contract_hash: executionContractHash }, input.workspaceRoot, physicalFailures, candidateFiles, candidateAbsences);
|
|
671
|
+
retainExecutionOutcome(evidenceDir, { id, command: group.command, input_hash: inputHash, verification_epoch: epoch, fingerprint_scope: "check_inputs", input_manifest: reserved.inputManifest ?? null, execution_contract_hash: executionContractHash }, input.workspaceRoot, physicalFailures, candidateFiles, candidateAbsences, libraryInputs);
|
|
671
672
|
const requiredArtifacts = [...new Set([...group.requiredArtifacts, ...runnerProofArtifacts(runnerEvidence), ...(group.runnerEvidence ? ["engine-outcome.json"] : [])])];
|
|
672
673
|
artifacts = collectCheckArtifacts(evidenceDir, requiredArtifacts);
|
|
673
674
|
const status = result.aborted ? "aborted" : result.exitCode === 0 && !result.error && artifacts.complete && mutationPaths.length === 0 && (!group.runnerEvidence || runnerEvidencePass(runnerEvidence)) ? "passed" : "failed";
|
|
@@ -676,6 +677,7 @@ async function runCodeChecksOwned(context, input, workspaceGateId, generation, s
|
|
|
676
677
|
receipt.candidate_root = input.workspaceRoot;
|
|
677
678
|
receipt.candidate_files = candidateFiles;
|
|
678
679
|
receipt.candidate_absences = candidateAbsences;
|
|
680
|
+
receipt.candidate_library_inputs = libraryInputs;
|
|
679
681
|
receipt.execution_failures = physicalFailures;
|
|
680
682
|
context.db.run("UPDATE check_receipts SET execution_failures_json = ? WHERE project_id = ? AND id = ? AND status = 'running'", [JSON.stringify(physicalFailures), input.projectId, id]);
|
|
681
683
|
const completion = readCompletion(completionPath);
|
|
@@ -768,6 +770,7 @@ export function reconcileUnfinishedChecks(context, input) {
|
|
|
768
770
|
physicalFailures.push({ code: "completion_snapshot_unknown", message: "Detached check has no bound scoped completion snapshot; current inputs cannot prove inputs at command completion" });
|
|
769
771
|
let candidateFiles = candidateFailureFiles(runnerEvidence, input.workspaceRoot);
|
|
770
772
|
let candidateAbsences = candidateFailureAbsences(runnerEvidence, input.workspaceRoot);
|
|
773
|
+
let libraryInputs = candidateLibraryInputs(runnerEvidence, input.workspaceRoot);
|
|
771
774
|
if (receipt?.runner_evidence_required) {
|
|
772
775
|
const evidenceDir = path.join(path.dirname(pendingReceipt.receipt_path), "artifacts");
|
|
773
776
|
if (fs.existsSync(path.join(evidenceDir, "engine-outcome.json"))) {
|
|
@@ -778,13 +781,14 @@ export function reconcileUnfinishedChecks(context, input) {
|
|
|
778
781
|
physicalFailures.push(...(proven.execution_failures ?? []));
|
|
779
782
|
candidateFiles = proven.candidate_files ?? candidateFiles;
|
|
780
783
|
candidateAbsences = proven.candidate_absences ?? [];
|
|
784
|
+
libraryInputs = proven.candidate_library_inputs ?? [];
|
|
781
785
|
}
|
|
782
786
|
else {
|
|
783
787
|
physicalFailures.push({ code: "engine_outcome_unbound", message: "Existing execution outcome is not bound to retained inventory; prior launcher/cleanup outcome is unknown" });
|
|
784
788
|
fs.renameSync(path.join(evidenceDir, "engine-outcome.json"), path.join(evidenceDir, `engine-outcome-unconfirmed-${crypto.randomUUID()}.json`));
|
|
785
789
|
}
|
|
786
790
|
}
|
|
787
|
-
retainExecutionOutcome(evidenceDir, receipt, input.workspaceRoot, physicalFailures, candidateFiles, candidateAbsences);
|
|
791
|
+
retainExecutionOutcome(evidenceDir, receipt, input.workspaceRoot, physicalFailures, candidateFiles, candidateAbsences, libraryInputs);
|
|
788
792
|
receipt.required_artifacts = [...new Set([...(receipt.required_artifacts ?? []), ...runnerProofArtifacts(runnerEvidence), "engine-outcome.json"])];
|
|
789
793
|
artifacts = collectCheckArtifacts(evidenceDir, receipt.required_artifacts);
|
|
790
794
|
}
|
|
@@ -798,6 +802,7 @@ export function reconcileUnfinishedChecks(context, input) {
|
|
|
798
802
|
repaired.candidate_root = input.workspaceRoot;
|
|
799
803
|
repaired.candidate_files = candidateFiles;
|
|
800
804
|
repaired.candidate_absences = candidateAbsences;
|
|
805
|
+
repaired.candidate_library_inputs = libraryInputs;
|
|
801
806
|
repaired.execution_failures = physicalFailures;
|
|
802
807
|
writeReceipt(pendingReceipt.receipt_path, repaired);
|
|
803
808
|
if (status === "failed")
|
|
@@ -824,6 +829,11 @@ function reserveOrReuseReceipt(context, input) {
|
|
|
824
829
|
const sourceCorrection = Boolean(retryPredecessor && (sourceRepairContractMatches(retryPredecessor, input.group, input.profileHash)
|
|
825
830
|
|| input.retryReason === "check_inputs_changed" && staleRetryContractMatches(retryPredecessor, input.group, input.profileHash, input)));
|
|
826
831
|
const latestContract = receipts.filter(receipt => receipt.command === input.group.command && receipt.profile_hash === input.profileHash && receipt.gate === input.group.gate && receipt.scope === input.scope && receipt.work_id === (input.workId ?? null)).at(-1);
|
|
832
|
+
// Hash files before SQLite's writer reservation. Captured identities remain
|
|
833
|
+
// valid historical evidence, but cannot authorize reuse in a changed install.
|
|
834
|
+
const priorLibrariesCurrent = !prior || currentCandidateLibraryInputsValid(prior, input.workspaceRoot);
|
|
835
|
+
const latestLibrariesCurrent = !latestContract || currentCandidateLibraryInputsValid(latestContract, input.workspaceRoot);
|
|
836
|
+
const libraryDrift = (receipt) => { throw new AppError("check_evidence_stale", "Captured library inputs differ from the current candidate installation; request an explicit restored-condition retry", 2, { reason: "candidate_library_inputs_changed", check_receipt_id: receipt.id, effect: "no_effect" }); };
|
|
827
837
|
return context.db.writeTransaction(() => {
|
|
828
838
|
assertRunMutationAllowed(context, input.projectId, input.runId, input.generation);
|
|
829
839
|
if (JSON.stringify(context.db.all(snapshotSql, [input.projectId, input.runId])) !== snapshot)
|
|
@@ -859,10 +869,16 @@ function reserveOrReuseReceipt(context, input) {
|
|
|
859
869
|
// An unchanged failed contract needs a concrete restored-condition retry,
|
|
860
870
|
// not repeated coordinator finish until it happens to become green.
|
|
861
871
|
if (!retryId && latestContract?.fingerprint_scope === "check_inputs" && latestContract.status === "failed"
|
|
862
|
-
&& latestContract.execution_failures?.some(failure => failure.code === "check_inputs_changed") && latestContract.execution_contract_hash === input.executionContractHash)
|
|
872
|
+
&& latestContract.execution_failures?.some(failure => failure.code === "check_inputs_changed") && latestContract.execution_contract_hash === input.executionContractHash) {
|
|
873
|
+
if (!latestLibrariesCurrent)
|
|
874
|
+
libraryDrift(latestContract);
|
|
863
875
|
return { receipt: latestContract };
|
|
864
|
-
|
|
876
|
+
}
|
|
877
|
+
if (prior && ["failed", "aborted"].includes(prior.status) && !retryId) {
|
|
878
|
+
if (!priorLibrariesCurrent)
|
|
879
|
+
libraryDrift(prior);
|
|
865
880
|
return { receipt: prior };
|
|
881
|
+
}
|
|
866
882
|
if (prior?.status === "passed" && !retryIds.has(prior.id) && input.group.reusable && reusablePrior)
|
|
867
883
|
return { receipt: prior };
|
|
868
884
|
let ordinal = (context.db.get("SELECT COALESCE(MAX(reservation_sequence), 0) + 1 AS sequence FROM check_receipts WHERE project_id = ? AND run_id = ?", [input.projectId, input.runId])?.sequence ?? 1);
|
|
@@ -1004,12 +1020,12 @@ function resolvedReceiptArtifacts(receiptPath, artifacts, binding) {
|
|
|
1004
1020
|
if (retainedOutcome.complete && retainedOutcome.items.every(item => artifacts.some(artifact => relocate(artifact.path) === item.path && artifact.sha256 === item.sha256))) {
|
|
1005
1021
|
try {
|
|
1006
1022
|
const value = JSON.parse(fs.readFileSync(outcomePath, "utf8"));
|
|
1007
|
-
if (value.schema_id === "dd-flow/check-execution-outcome@1" && value.id === binding.id && value.command === binding.command && value.input_hash === binding.input_hash && value.verification_epoch === binding.verification_epoch && (value.fingerprint_scope ?? null) === (binding.fingerprint_scope ?? null) && (value.execution_contract_hash ?? null) === (binding.execution_contract_hash ?? null) && JSON.stringify(value.input_manifest ?? null) === JSON.stringify(binding.input_manifest ?? null) && typeof value.candidate_root === "string" && path.isAbsolute(value.candidate_root) && (value.candidate_files === undefined || Array.isArray(value.candidate_files) && value.candidate_files.length <= 64 && value.candidate_files.every((file) => typeof file === "string" && path.isAbsolute(file) && Buffer.byteLength(file) <= 4096)) && (value.candidate_absences === undefined || candidateAbsencesValid(value.candidate_absences)) && Array.isArray(value.execution_failures) && value.execution_failures.every((failure) => failure && typeof failure === "object" && typeof failure.code === "string" && typeof failure.message === "string"))
|
|
1023
|
+
if (value.schema_id === "dd-flow/check-execution-outcome@1" && value.id === binding.id && value.command === binding.command && value.input_hash === binding.input_hash && value.verification_epoch === binding.verification_epoch && (value.fingerprint_scope ?? null) === (binding.fingerprint_scope ?? null) && (value.execution_contract_hash ?? null) === (binding.execution_contract_hash ?? null) && JSON.stringify(value.input_manifest ?? null) === JSON.stringify(binding.input_manifest ?? null) && typeof value.candidate_root === "string" && path.isAbsolute(value.candidate_root) && (value.candidate_files === undefined || Array.isArray(value.candidate_files) && value.candidate_files.length <= 64 && value.candidate_files.every((file) => typeof file === "string" && path.isAbsolute(file) && Buffer.byteLength(file) <= 4096)) && (value.candidate_absences === undefined || candidateAbsencesValid(value.candidate_absences)) && (value.candidate_library_inputs === undefined || candidateLibraryInputsValid(value.candidate_library_inputs)) && Array.isArray(value.execution_failures) && value.execution_failures.every((failure) => failure && typeof failure === "object" && typeof failure.code === "string" && typeof failure.message === "string"))
|
|
1008
1024
|
outcome = value;
|
|
1009
1025
|
}
|
|
1010
1026
|
catch { /* Missing physical proof cannot grant attribution. */ }
|
|
1011
1027
|
}
|
|
1012
|
-
return { completion_path: path.join(currentRoot, "completion.json"), required_artifacts: original.required_artifacts ?? [], inputs: original.inputs ?? [], artifacts: artifacts.map((artifact) => ({ ...artifact, path: relocate(artifact.path) })), ...(original.runner_evidence_required ? { runner_evidence_required: original.runner_evidence_required, runner_evidence: evidence, runner_evidence_diagnostics: diagnostics, ...(diagnosticsTruncated ? { runner_evidence_diagnostics_truncated: true } : {}) } : {}), execution_failures: outcome.execution_failures ?? [], ...(outcome.candidate_root ? { candidate_root: outcome.candidate_root } : {}), ...(outcome.candidate_files ? { candidate_files: outcome.candidate_files } : {}), ...(outcome.candidate_absences ? { candidate_absences: outcome.candidate_absences } : {}) };
|
|
1028
|
+
return { completion_path: path.join(currentRoot, "completion.json"), required_artifacts: original.required_artifacts ?? [], inputs: original.inputs ?? [], artifacts: artifacts.map((artifact) => ({ ...artifact, path: relocate(artifact.path) })), ...(original.runner_evidence_required ? { runner_evidence_required: original.runner_evidence_required, runner_evidence: evidence, runner_evidence_diagnostics: diagnostics, ...(diagnosticsTruncated ? { runner_evidence_diagnostics_truncated: true } : {}) } : {}), execution_failures: outcome.execution_failures ?? [], ...(outcome.candidate_root ? { candidate_root: outcome.candidate_root } : {}), ...(outcome.candidate_files ? { candidate_files: outcome.candidate_files } : {}), ...(outcome.candidate_absences ? { candidate_absences: outcome.candidate_absences } : {}), ...(outcome.candidate_library_inputs ? { candidate_library_inputs: outcome.candidate_library_inputs } : {}) };
|
|
1013
1029
|
}
|
|
1014
1030
|
function executionFailures(input) {
|
|
1015
1031
|
const failures = [];
|
|
@@ -1027,11 +1043,11 @@ function executionFailures(input) {
|
|
|
1027
1043
|
failures.push({ code: "invalid_runner_evidence", message: "Required runner evidence is unavailable or invalid" });
|
|
1028
1044
|
return failures;
|
|
1029
1045
|
}
|
|
1030
|
-
function retainExecutionOutcome(evidenceDir, binding, candidateRoot, failures, candidateFiles, candidateAbsences = []) {
|
|
1046
|
+
function retainExecutionOutcome(evidenceDir, binding, candidateRoot, failures, candidateFiles, candidateAbsences = [], libraryInputs = []) {
|
|
1031
1047
|
const temporary = path.join(evidenceDir, `.engine-outcome-${crypto.randomUUID()}.tmp`);
|
|
1032
1048
|
if (!fs.lstatSync(evidenceDir).isDirectory() || fs.lstatSync(evidenceDir).isSymbolicLink())
|
|
1033
1049
|
throw new AppError("check_evidence_stale", "Check evidence directory was replaced", 1);
|
|
1034
|
-
fs.writeFileSync(temporary, JSON.stringify({ schema_id: "dd-flow/check-execution-outcome@1", id: binding.id, command: binding.command, input_hash: binding.input_hash, verification_epoch: binding.verification_epoch, fingerprint_scope: binding.fingerprint_scope ?? null, input_manifest: binding.input_manifest ?? null, execution_contract_hash: binding.execution_contract_hash ?? null, candidate_root: candidateRoot, candidate_files: candidateFiles, candidate_absences: candidateAbsences, execution_failures: failures }), { flag: "wx", mode: 0o600 });
|
|
1050
|
+
fs.writeFileSync(temporary, JSON.stringify({ schema_id: "dd-flow/check-execution-outcome@1", id: binding.id, command: binding.command, input_hash: binding.input_hash, verification_epoch: binding.verification_epoch, fingerprint_scope: binding.fingerprint_scope ?? null, input_manifest: binding.input_manifest ?? null, execution_contract_hash: binding.execution_contract_hash ?? null, candidate_root: candidateRoot, candidate_files: candidateFiles, candidate_absences: candidateAbsences, candidate_library_inputs: libraryInputs, execution_failures: failures }), { flag: "wx", mode: 0o600 });
|
|
1035
1051
|
fs.renameSync(temporary, path.join(evidenceDir, "engine-outcome.json"));
|
|
1036
1052
|
}
|
|
1037
1053
|
export function workspaceFingerprint(root) { return workspaceState(root).fingerprint; }
|
|
@@ -1361,7 +1377,7 @@ function checkReceiptEvidenceValidForMode(receipt, mode) {
|
|
|
1361
1377
|
// Receipt annotations are not execution authority. Compare the SQL-owned
|
|
1362
1378
|
// failures with the independently hash/binding-checked engine sidecar.
|
|
1363
1379
|
const hasEngineOutcome = receipt.required_artifacts.includes("engine-outcome.json") || receipt.artifacts.some(artifact => artifact.path === "engine-outcome.json" || artifact.path === path.join(path.dirname(receipt.receipt_path), "artifacts", "engine-outcome.json"));
|
|
1364
|
-
if (hasEngineOutcome && (!isDeepStrictEqual(retainedProof.execution_failures ?? [], receipt.execution_failures ?? []) || !isDeepStrictEqual(retainedProof.candidate_absences ?? [], receipt.candidate_absences ?? [])))
|
|
1380
|
+
if (hasEngineOutcome && (!isDeepStrictEqual(retainedProof.execution_failures ?? [], receipt.execution_failures ?? []) || !isDeepStrictEqual(retainedProof.candidate_absences ?? [], receipt.candidate_absences ?? []) || !isDeepStrictEqual(retainedProof.candidate_library_inputs ?? [], receipt.candidate_library_inputs ?? [])))
|
|
1365
1381
|
return false;
|
|
1366
1382
|
const root = path.join(path.dirname(receipt.receipt_path), "artifacts");
|
|
1367
1383
|
if (stored.runner_evidence_required && mode !== "diagnostic" && !runnerEvidencePass(readRunnerEvidence(root, stored.runner_evidence_required, { invocation_id: receipt.id, check_id: receipt.id, command: receipt.command, input_hash: receipt.input_hash, verification_epoch: receipt.verification_epoch })))
|
|
@@ -7,7 +7,10 @@ import { assertSourceRepairEligible } from "./code-checks.js";
|
|
|
7
7
|
export function ensureAggregateRepair(context, input, create) {
|
|
8
8
|
if (!input.failures.length)
|
|
9
9
|
throw new AppError("repair_intent_invalid", "Repair requires candidate-source-backed failed receipts", 1);
|
|
10
|
-
|
|
10
|
+
const workspace = context.db.get("SELECT workspace_root FROM runs WHERE project_id=? AND id=?", [input.projectId, input.runId]);
|
|
11
|
+
if (!workspace?.workspace_root && input.failures.some(receipt => receipt.candidate_library_inputs?.length))
|
|
12
|
+
throw new AppError("repair_intent_invalid", "Library-backed repair requires the current RUN workspace", 1);
|
|
13
|
+
assertSourceRepairEligible(input.failures, workspace?.workspace_root);
|
|
11
14
|
const causes = input.failures.map(receipt => ({ id: receipt.id, epoch: receipt.verification_epoch, fingerprint: receipt.fingerprint_scope === "check_inputs" ? receipt.before_fingerprint : receipt.workspace_fingerprint })).sort((a, b) => a.id.localeCompare(b.id));
|
|
12
15
|
return ensureRepairIntent(context, { ...input, causes: { receipts: causes, origins: [...(input.origins ?? [])].sort() } }, create);
|
|
13
16
|
}
|
|
@@ -253,7 +253,7 @@ export async function finishVnextCodeReview(context, input) {
|
|
|
253
253
|
const matrix = prepareRunVerificationMatrix(context, { projectId: project.id, runId: run.id, stage, role: "output", receipts, semanticFile: decisionFile });
|
|
254
254
|
const failed = receipts.filter((receipt) => receipt.status !== "passed");
|
|
255
255
|
if (failed.length) {
|
|
256
|
-
assertSourceRepairEligible(failed);
|
|
256
|
+
assertSourceRepairEligible(failed, run.workspace_root);
|
|
257
257
|
context.db.writeTransaction(() => acceptVerificationMatrix(context, matrix));
|
|
258
258
|
publishVerificationMatrix(context, matrix);
|
|
259
259
|
throw new AppError("code_review_gate_failed", "CODE-REVIEW repair changed the project but the aggregate gate failed", 2, {
|
|
@@ -225,7 +225,7 @@ export async function finishVnextCode(context, input) {
|
|
|
225
225
|
const matrix = prepareRunVerificationMatrix(context, { projectId: project.id, runId: run.id, stage, role: "output", receipts, semanticFile: input.verificationFile });
|
|
226
226
|
const matrixInstruction = matrix ? verificationMatrixInstruction(matrix.metadata, home, "semantic verification in code-verification.json") : "";
|
|
227
227
|
if (failed.length) {
|
|
228
|
-
assertSourceRepairEligible(failed);
|
|
228
|
+
assertSourceRepairEligible(failed, run.workspace_root);
|
|
229
229
|
context.db.writeTransaction(() => acceptVerificationMatrix(context, matrix));
|
|
230
230
|
publishVerificationMatrix(context, matrix);
|
|
231
231
|
const continuation = registerAggregateCodeRepair(context, { projectRoot, runId: run.id, stage, failures: failed, checks, generation });
|
|
@@ -408,8 +408,9 @@ export function prepareVnextCodeRepair(context, input) {
|
|
|
408
408
|
check_receipt_id: input.checkReceiptId
|
|
409
409
|
});
|
|
410
410
|
}
|
|
411
|
+
// Aggregate registration validated current inputs before its SQL transaction.
|
|
411
412
|
if (input.checkReceiptId)
|
|
412
|
-
assertSourceRepairEligible(checkReceipts(context, { projectId: project.id, runId: run.id }).filter(item => item.id === input.checkReceiptId));
|
|
413
|
+
assertSourceRepairEligible(checkReceipts(context, { projectId: project.id, runId: run.id }).filter(item => item.id === input.checkReceiptId), input.registrationOwned ? undefined : run.workspace_root);
|
|
413
414
|
const origins = [...new Set(input.originWorkIds)].map((id) => requireCodeWork(context, project.id, run.id, id));
|
|
414
415
|
const packets = origins.map((work) => packet(work));
|
|
415
416
|
const invariantPackets = receipt || input.semanticUnresolved?.length
|
|
@@ -479,7 +479,7 @@ export async function repairVnextMerge(context, input) {
|
|
|
479
479
|
if (request.project_id !== project.id || request.status !== "action_required" || errorCode(request) !== "merge_gate_failed") {
|
|
480
480
|
throw new AppError("invalid_merge_repair_state", "MERGE source repair requires an action-required failed integration gate", 2, { merge_request_id: input.requestId, status: request.status, code: errorCode(request) });
|
|
481
481
|
}
|
|
482
|
-
assertSourceRepairEligible(checkReceipts(context, { projectId: project.id, runId: request.run_id }).filter(receipt => failedReceiptIds(request).includes(receipt.id)));
|
|
482
|
+
assertSourceRepairEligible(checkReceipts(context, { projectId: project.id, runId: request.run_id }).filter(receipt => failedReceiptIds(request).includes(receipt.id)), request.target_workspace);
|
|
483
483
|
abortIntegration(request);
|
|
484
484
|
const originWorkIds = sourceCodeWorkIds(context, request.project_id, request.run_id);
|
|
485
485
|
if (!originWorkIds.length)
|
|
@@ -1564,7 +1564,7 @@ async function settle(context, id, status, result, progress, retry, memoryResult
|
|
|
1564
1564
|
receipts = await runCodeChecks(context, { projectId: work.project_id, runId: work.run_id, runHome: requireRunHome(run), workspaceRoot: run.workspace_root, workId: work.work_id, artifactDir, scope: "work", checks: requiredChecks, ...(retry ? { retryReceiptId: retry.receiptId, retryReason: retry.reason } : {}), ...(progress ? { progress } : {}) });
|
|
1565
1565
|
const failed = receipts.filter((receipt) => receipt.status !== "passed");
|
|
1566
1566
|
if (failed.length)
|
|
1567
|
-
assertSourceRepairEligible(failed);
|
|
1567
|
+
assertSourceRepairEligible(failed, run.workspace_root);
|
|
1568
1568
|
if (failed.length)
|
|
1569
1569
|
throw new AppError("work_checks_failed", "Work remains running because required checks failed", 2, { work_id: id, failures: failed, all_receipts: receipts });
|
|
1570
1570
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deksden-com/dd-flow-cli",
|
|
3
|
-
"version": "0.9.0-beta.
|
|
3
|
+
"version": "0.9.0-beta.152",
|
|
4
4
|
"description": "Mechanical runtime CLI for dd-flow workflows.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@10.23.0",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"test": "node scripts/verification.mjs run",
|
|
31
31
|
"gate:merge": "node scripts/verification.mjs run",
|
|
32
32
|
"verify:plan": "node scripts/verification.mjs plan",
|
|
33
|
-
"test:verification": "node --test --test-concurrency=1 scripts/verification-policy.test.mjs scripts/generate-verification-components.test.mjs",
|
|
33
|
+
"test:verification": "node --test --test-concurrency=1 scripts/verification-policy.test.mjs scripts/generate-verification-components.test.mjs scripts/verification-jobs.test.mjs",
|
|
34
34
|
"test:hybrid": "node --import ./scripts/source-test-loader.mjs --test --test-concurrency=1 scripts/hybrid-contracts.test.mjs",
|
|
35
35
|
"test:hybrid:canon": "node --import ./scripts/source-test-loader.mjs --test --test-concurrency=1 scripts/hybrid-canon.test.mjs scripts/hybrid-inventory.test.mjs scripts/hybrid-consumers.test.mjs scripts/hybrid-schema-copies.test.mjs scripts/verify-e3-canon.test.mjs",
|
|
36
36
|
"test:e2": "node --import ./scripts/source-test-loader.mjs --test --test-concurrency=1 scripts/e2-*.test.mjs",
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// SSOT for the cooperating verification entrypoints. Standalone consumers vendor
|
|
2
|
+
// this file byte-for-byte; contract catalogs and report validators stay local.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
8
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
9
|
+
import { setTimeout, clearTimeout } from 'node:timers';
|
|
10
|
+
import process from 'node:process';
|
|
11
|
+
|
|
12
|
+
const realHome = os.userInfo().homedir; // OS identity, never a fixture's HOME.
|
|
13
|
+
const providerCredentials = ['OPENAI_API_KEY','OPENROUTER_API_KEY','OPENAI_DECISIONS_API_KEY','ANTHROPIC_API_KEY','GEMINI_API_KEY','GOOGLE_API_KEY','XAI_API_KEY','ZAI_API_KEY','NODE_AUTH_TOKEN','NPM_TOKEN','NPM_ACCESS_TOKEN'];
|
|
14
|
+
const read = file => JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
15
|
+
const optional = file => { try { return read(file); } catch (e) { if (e.code === 'ENOENT') return null; throw e; } };
|
|
16
|
+
const blocked = message => Object.assign(new Error(message), { code: 'verification_ownership_unknown' });
|
|
17
|
+
export function atomicJson(file, value) {
|
|
18
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
19
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
20
|
+
try { fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx' }); fs.renameSync(temporary, file); }
|
|
21
|
+
catch (error) {
|
|
22
|
+
try { fs.unlinkSync(temporary); } catch (cleanup) { if (cleanup.code !== 'ENOENT') error.cleanup_error = cleanup; }
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Same physical identity fields as EVAL's process-snapshot helper. Observation
|
|
28
|
+
// failure is never interpreted as an empty process table.
|
|
29
|
+
function snapshot() {
|
|
30
|
+
return execFileSync('ps', ['-axo', 'pid=,ppid=,pgid=,lstart=,stat='], {
|
|
31
|
+
encoding: 'utf8', timeout: 5000, env: { PATH: process.env.PATH, LC_ALL: 'C', TZ: 'UTC' },
|
|
32
|
+
}).trim().split('\n').flatMap(line => {
|
|
33
|
+
const p = line.trim().split(/\s+/);
|
|
34
|
+
return p.length >= 9 ? [{ pid: +p[0], ppid: +p[1], pgid: +p[2], birth: p.slice(3, 8).join(' '), zombie: p[8].startsWith('Z') }] : [];
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
const same = (a, b) => a && b && a.pid === b.pid && a.birth === b.birth && !b.zombie;
|
|
38
|
+
function self() {
|
|
39
|
+
const identity = snapshot().find(p => p.pid === process.pid && !p.zombie);
|
|
40
|
+
if (!identity) throw blocked('Cannot establish verification owner identity');
|
|
41
|
+
return identity;
|
|
42
|
+
}
|
|
43
|
+
function alive(owner, processes = snapshot()) { return same(owner, processes.find(p => p.pid === owner?.pid)); }
|
|
44
|
+
function release(lock, owner) {
|
|
45
|
+
// Only one reclaimer can remove this unique fence. A late reclaimer cannot
|
|
46
|
+
// unlink the next owner's record after the directory has been reacquired.
|
|
47
|
+
fs.unlinkSync(path.join(lock, `fence-${owner.token}`));
|
|
48
|
+
fs.unlinkSync(path.join(lock, 'owner.json')); fs.rmdirSync(lock);
|
|
49
|
+
}
|
|
50
|
+
async function claim(lock, signal) {
|
|
51
|
+
fs.mkdirSync(path.dirname(lock), { recursive: true });
|
|
52
|
+
const identity = self();
|
|
53
|
+
for (;;) {
|
|
54
|
+
signal?.throwIfAborted();
|
|
55
|
+
try {
|
|
56
|
+
fs.mkdirSync(lock);
|
|
57
|
+
const owner = { ...identity, token: randomUUID(), groups: [] };
|
|
58
|
+
fs.writeFileSync(path.join(lock, `fence-${owner.token}`), '', { flag: 'wx' });
|
|
59
|
+
atomicJson(path.join(lock, 'owner.json'), owner);
|
|
60
|
+
return owner;
|
|
61
|
+
} catch (e) { if (e.code !== 'EEXIST') throw e; }
|
|
62
|
+
const owner = optional(path.join(lock, 'owner.json'));
|
|
63
|
+
if (owner) {
|
|
64
|
+
const processes = snapshot();
|
|
65
|
+
if (!alive(owner, processes)) {
|
|
66
|
+
// A dead coordinator is NOT cleanup. A surviving process group keeps
|
|
67
|
+
// the whole lease until its physical absence is positively observed.
|
|
68
|
+
if (!Array.isArray(owner.groups)) throw blocked(`Unconfirmed lease groups: ${lock}`);
|
|
69
|
+
if (!owner.groups.some(g => !g.pgid || processes.some(p => p.pgid === g.pgid && !p.zombie))) {
|
|
70
|
+
// Recheck the immutable owner token before removing its record.
|
|
71
|
+
if (optional(path.join(lock, 'owner.json'))?.token === owner.token) {
|
|
72
|
+
try { release(lock, owner); } catch (e) { if (!['ENOENT', 'ENOTEMPTY'].includes(e.code)) throw e; }
|
|
73
|
+
}
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
await delay(50, undefined, { signal });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One immutable producer attempt. The index is scheduling state, not proof;
|
|
83
|
+
* callers must validate the returned report bytes and meaningful inputs. */
|
|
84
|
+
export async function withContractAttempt(index, { rerun = false, reason, signal, validate, validationKey } = {}, action) {
|
|
85
|
+
if (rerun && !reason) throw new Error('Explicit rerun requires a reason');
|
|
86
|
+
const lock = `${index}.claim`;
|
|
87
|
+
const initial = optional(index);
|
|
88
|
+
if (initial?.status === 'running' && !rerun) {
|
|
89
|
+
const result = await joinAttempt(initial, signal);
|
|
90
|
+
await validate?.(result.result, initial);
|
|
91
|
+
signal?.throwIfAborted();
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
let joined = null;
|
|
95
|
+
const owner = await claim(lock, signal);
|
|
96
|
+
let attempt;
|
|
97
|
+
try {
|
|
98
|
+
const current = optional(index); // Mandatory reread under claim.
|
|
99
|
+
if (current?.status === 'running' && !rerun) { joined = current; }
|
|
100
|
+
else if (current && !rerun) {
|
|
101
|
+
if (current.status !== 'passed') throw blocked(`Retained ${current.status} attempt requires explicit rerun: ${index}`);
|
|
102
|
+
try {
|
|
103
|
+
const terminal = read(current.report);
|
|
104
|
+
if (terminal.attemptId !== current.attemptId || terminal.status !== 'passed') throw new Error('Invalid terminal attempt');
|
|
105
|
+
await validate?.(terminal.result, current);
|
|
106
|
+
signal?.throwIfAborted();
|
|
107
|
+
return { ...terminal, reused: true, joined: false };
|
|
108
|
+
} catch (error) {
|
|
109
|
+
signal?.throwIfAborted(); // A cancelled reader never invalidates its producer.
|
|
110
|
+
atomicJson(index, { ...current, status: 'evidence_invalid', reason: String(error.message) });
|
|
111
|
+
reason = `Invalid evidence: ${error.message}`;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (!joined) {
|
|
115
|
+
if (current?.status === 'running') throw blocked('Cannot supersede an active producer');
|
|
116
|
+
const attemptId = randomUUID();
|
|
117
|
+
const root = path.join(`${index}.attempts`, attemptId);
|
|
118
|
+
fs.mkdirSync(root, { recursive: true });
|
|
119
|
+
attempt = { attemptId, root, report: path.join(root, 'terminal.json'), owner, status: 'running', reason: reason ?? null, validationKey };
|
|
120
|
+
// Accepted rerun supersedes an old PASS before dispatch.
|
|
121
|
+
atomicJson(index, attempt);
|
|
122
|
+
}
|
|
123
|
+
} finally { release(lock, owner); }
|
|
124
|
+
if (joined) {
|
|
125
|
+
const result = await joinAttempt(joined, signal);
|
|
126
|
+
await validate?.(result.result, joined);
|
|
127
|
+
signal?.throwIfAborted();
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
signal?.throwIfAborted();
|
|
132
|
+
const result = await action({ attemptId: attempt.attemptId, root: attempt.root });
|
|
133
|
+
await validate?.(result, attempt);
|
|
134
|
+
signal?.throwIfAborted();
|
|
135
|
+
const terminal = { attemptId: attempt.attemptId, result, status: 'passed' };
|
|
136
|
+
atomicJson(attempt.report, terminal);
|
|
137
|
+
atomicJson(index, { ...attempt, status: 'passed' });
|
|
138
|
+
return { ...terminal, reused: false, joined: false };
|
|
139
|
+
} catch (error) {
|
|
140
|
+
const terminal = { attemptId: attempt.attemptId, status: signal?.aborted ? 'interrupted' : 'failed', error: String(error.stack ?? error) };
|
|
141
|
+
atomicJson(attempt.report, terminal);
|
|
142
|
+
atomicJson(index, { ...attempt, status: terminal.status });
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async function joinAttempt(attempt, signal) {
|
|
147
|
+
for (;;) {
|
|
148
|
+
signal?.throwIfAborted(); // Cancels only this waiter.
|
|
149
|
+
const terminal = optional(attempt.report);
|
|
150
|
+
if (terminal) {
|
|
151
|
+
if (terminal.attemptId !== attempt.attemptId || terminal.status !== 'passed') throw blocked(`Producer attempt did not pass: ${attempt.attemptId}`);
|
|
152
|
+
return { ...terminal, reused: false, joined: true };
|
|
153
|
+
}
|
|
154
|
+
if (!alive(attempt.owner)) throw blocked(`Producer died without terminal evidence: ${attempt.attemptId}`);
|
|
155
|
+
await delay(50, undefined, { signal });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isAncestor(identity, processes) {
|
|
160
|
+
let cursor = processes.find(p => p.pid === process.pid);
|
|
161
|
+
const seen = new Set();
|
|
162
|
+
while (cursor && !seen.has(cursor.pid)) {
|
|
163
|
+
if (same(identity, cursor)) return true;
|
|
164
|
+
seen.add(cursor.pid); cursor = processes.find(p => p.pid === cursor.ppid);
|
|
165
|
+
}
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
async function inheritedSlot(env, home, signal) {
|
|
169
|
+
if (!env.DD_FLOW_VERIFICATION_SLOT) return false;
|
|
170
|
+
let context;
|
|
171
|
+
try { context = JSON.parse(env.DD_FLOW_VERIFICATION_SLOT); } catch { throw blocked('Malformed inherited slot'); }
|
|
172
|
+
for (;;) {
|
|
173
|
+
const owner = optional(path.join(home, 'lease', 'owner.json')), processes = snapshot();
|
|
174
|
+
const group = owner?.groups?.find(g => g.token === context.token);
|
|
175
|
+
if (!group || owner.token !== context.owner || !alive(owner, processes)) break;
|
|
176
|
+
// A child may reach its first import before spawn() publishes its identity.
|
|
177
|
+
if (!group.pid) { await delay(10, undefined, { signal }); continue; }
|
|
178
|
+
if (isAncestor(group, processes)) return { owner, group };
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
throw blocked('Inherited slot is not owned by an ancestor job');
|
|
182
|
+
}
|
|
183
|
+
async function nestedScope(env, home, outer, signal) {
|
|
184
|
+
if (!env.DD_FLOW_VERIFICATION_NESTED_SLOT) return outer ? `${outer.owner.token}-${outer.group.token}` : null;
|
|
185
|
+
let context;
|
|
186
|
+
try { context = JSON.parse(env.DD_FLOW_VERIFICATION_NESTED_SLOT); } catch { throw blocked('Malformed nested slot'); }
|
|
187
|
+
if (!outer || !/^[a-f0-9-]+$/.test(context.scope ?? '')) throw blocked('Invalid nested slot scope');
|
|
188
|
+
for (;;) {
|
|
189
|
+
const owner = optional(path.join(home, 'nested-slots', context.scope, 'owner.json')), processes = snapshot();
|
|
190
|
+
const child = owner?.groups?.find(group => group.token === context.child);
|
|
191
|
+
if (!child || owner.token !== context.owner || !alive(owner, processes)) break;
|
|
192
|
+
if (!child.pid) { await delay(10, undefined, { signal }); continue; }
|
|
193
|
+
if (isAncestor(child, processes)) return `${owner.token}-${child.token}`;
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
throw blocked('Nested slot is not owned by an ancestor dispatch');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** FIFO host-wide pool; descriptors are child commands, never callbacks sharing
|
|
200
|
+
* process.env. Exclusive drains both slots; inherited runners use one slot. */
|
|
201
|
+
export async function runJobs(jobs, { jobs: budget = 2, signal, env = process.env, coordinationHome } = {}) {
|
|
202
|
+
const queuedAt = Date.now();
|
|
203
|
+
if (![1, 2].includes(budget)) throw new Error('Verification budget must be 1 or 2');
|
|
204
|
+
if (Array.isArray(jobs) && jobs.some(job => job?.credentialKeys !== undefined && (!Array.isArray(job.credentialKeys) || job.credentialKeys.some(name => typeof name !== 'string' || !providerCredentials.includes(name))))) throw new Error('Invalid credential allowlist');
|
|
205
|
+
if (!Array.isArray(jobs) || jobs.some(job=>!job || typeof job.id!=='string' || !job.id || typeof job.command!=='string' || !job.command || job.args && (!Array.isArray(job.args)||job.args.some(arg=>typeof arg!=='string')) || job.executionClass!==undefined && !['isolated','exclusive'].includes(job.executionClass)) || new Set(jobs.map(job=>job.id)).size!==jobs.length) throw new Error('Invalid/duplicate verification job descriptor');
|
|
206
|
+
const home = coordinationHome ?? env.DD_FLOW_VERIFICATION_COORDINATION_HOME ?? path.join(realHome, '.dd-flow', 'verification-coordinator');
|
|
207
|
+
const nested = await inheritedSlot(env, home, signal);
|
|
208
|
+
const scope = await nestedScope(env, home, nested, signal);
|
|
209
|
+
const lock = scope ? path.join(home, 'nested-slots', scope) : path.join(home, 'lease');
|
|
210
|
+
const owner = await claim(lock, signal);
|
|
211
|
+
// A dead nested coordinator cannot free this slot while its outer group lives.
|
|
212
|
+
if (nested) { owner.groups = [{ ...nested.group, token: 'outer-group' }]; atomicJson(path.join(lock, 'owner.json'), owner); }
|
|
213
|
+
const results = new Array(jobs.length);
|
|
214
|
+
const active = new Set();
|
|
215
|
+
let failure;
|
|
216
|
+
const save = () => { if (owner) atomicJson(path.join(lock, 'owner.json'), owner); };
|
|
217
|
+
const start = (job, index) => {
|
|
218
|
+
signal?.throwIfAborted();
|
|
219
|
+
if (failure) throw failure;
|
|
220
|
+
const promise = execute(job, { home, owner, nested, scope, signal, env, save, queuedAt }).then(result => { results[index] = result; }, error => { failure ??= error; }).finally(() => active.delete(promise));
|
|
221
|
+
active.add(promise); return promise;
|
|
222
|
+
};
|
|
223
|
+
try {
|
|
224
|
+
for (let i = 0; i < jobs.length; i++) {
|
|
225
|
+
signal?.throwIfAborted();
|
|
226
|
+
if (failure) throw failure;
|
|
227
|
+
if (jobs[i].executionClass === 'exclusive') { await Promise.all(active); await start(jobs[i], i); }
|
|
228
|
+
else { while (active.size >= (nested ? 1 : budget)) await Promise.race(active); start(jobs[i], i); }
|
|
229
|
+
}
|
|
230
|
+
await Promise.all(active);
|
|
231
|
+
if (failure) throw failure;
|
|
232
|
+
return results;
|
|
233
|
+
} finally {
|
|
234
|
+
await Promise.allSettled(active);
|
|
235
|
+
// Never release a lease after unknown cleanup.
|
|
236
|
+
if (nested && owner.groups.every(group => group.token === 'outer-group')) owner.groups = [];
|
|
237
|
+
if (owner.groups.length === 0) release(lock, owner);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function execute(job, { home, owner, nested, scope, signal, env, save, queuedAt }) {
|
|
242
|
+
const started = Date.now();
|
|
243
|
+
const attemptId = randomUUID();
|
|
244
|
+
const root = path.join(home, 'jobs', attemptId);
|
|
245
|
+
fs.mkdirSync(root, { recursive: true });
|
|
246
|
+
const childEnv = { ...env, ...job.env };
|
|
247
|
+
for (const name of providerCredentials) if (!job.credentialKeys?.includes(name)) delete childEnv[name];
|
|
248
|
+
childEnv.DD_FLOW_VERIFICATION_COORDINATION_HOME = home;
|
|
249
|
+
// node:test sets this private flag for its own children; a separately managed
|
|
250
|
+
// test command must produce a standalone report, not use its parent's IPC.
|
|
251
|
+
delete childEnv.NODE_TEST_CONTEXT;
|
|
252
|
+
for (const name of ['HOME', 'TMPDIR', 'XDG_CACHE_HOME', 'XDG_CONFIG_HOME', 'XDG_RUNTIME_DIR', 'CODEX_HOME', 'AGY_HOME', 'DD_FLOW_HOME', 'DD_FLOW_RESOURCE_HOME', 'DD_FLOW_CONFIG_HOME']) {
|
|
253
|
+
childEnv[name] = path.join(root, name.toLowerCase()); fs.mkdirSync(childEnv[name], { recursive: true });
|
|
254
|
+
}
|
|
255
|
+
// Only an explicit native/delivery descriptor may opt into provider credentials.
|
|
256
|
+
const token = randomUUID();
|
|
257
|
+
if (!nested) {
|
|
258
|
+
childEnv.DD_FLOW_VERIFICATION_SLOT = JSON.stringify({ owner: owner.token, token });
|
|
259
|
+
delete childEnv.DD_FLOW_VERIFICATION_NESTED_SLOT;
|
|
260
|
+
} else {
|
|
261
|
+
childEnv.DD_FLOW_VERIFICATION_SLOT = env.DD_FLOW_VERIFICATION_SLOT;
|
|
262
|
+
childEnv.DD_FLOW_VERIFICATION_NESTED_SLOT = JSON.stringify({ scope, owner: owner.token, child: token });
|
|
263
|
+
}
|
|
264
|
+
// Unknown spawn outcome must retain the lease after coordinator death.
|
|
265
|
+
owner.groups.push({ token, attemptId, root, pgid: null }); save();
|
|
266
|
+
const child = spawn(job.command, job.args ?? [], { cwd: job.cwd, env: childEnv, stdio: job.stdio ?? 'inherit', detached: !nested });
|
|
267
|
+
let spawnError;
|
|
268
|
+
const closed = new Promise(resolve => child.once('close', resolve));
|
|
269
|
+
const completion = new Promise(resolve => { child.once('error', error => { spawnError = error; resolve({ code: null, signal: null }); }); child.once('exit', (code, exitSignal) => resolve({ code, signal: exitSignal })); });
|
|
270
|
+
// A pipe is owned by this helper, not exposed to callers. Drain it while
|
|
271
|
+
// preserving output; an orphan holding it open must not hide direct exit.
|
|
272
|
+
for (const [name, stream] of [['stdout', child.stdout], ['stderr', child.stderr]]) stream?.on('data', bytes => fs.appendFileSync(path.join(root, `${name}.log`), bytes));
|
|
273
|
+
let identity;
|
|
274
|
+
if (child.pid) identity = snapshot().find(p => p.pid === child.pid && !p.zombie);
|
|
275
|
+
if (identity && owner) { identity = { ...identity, token, attemptId, root }; owner.groups = owner.groups.map(g => g.token === token ? identity : g); save(); }
|
|
276
|
+
let cleanupError;
|
|
277
|
+
const stop = (killSignal = 'SIGTERM') => {
|
|
278
|
+
if (!identity) return;
|
|
279
|
+
try {
|
|
280
|
+
const current = snapshot().find(p => p.pid === identity.pid);
|
|
281
|
+
if (same(identity, current)) process.kill(nested ? identity.pid : -identity.pgid, killSignal);
|
|
282
|
+
} catch (error) { if (error.code !== 'ESRCH') cleanupError = error; }
|
|
283
|
+
};
|
|
284
|
+
let forceTimer;
|
|
285
|
+
const cancel = () => { stop(); forceTimer = setTimeout(() => stop('SIGKILL'), 1000); };
|
|
286
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
287
|
+
if (signal?.aborted) cancel();
|
|
288
|
+
const result = await completion;
|
|
289
|
+
clearTimeout(forceTimer);
|
|
290
|
+
signal?.removeEventListener('abort', cancel);
|
|
291
|
+
try {
|
|
292
|
+
if (cleanupError) throw cleanupError;
|
|
293
|
+
if (identity && !nested) {
|
|
294
|
+
for (let n = 0; n < 100 && snapshot().some(p => p.pgid === identity.pgid && !p.zombie); n++) {
|
|
295
|
+
stop(); await delay(50);
|
|
296
|
+
}
|
|
297
|
+
if (snapshot().some(p => p.pgid === identity.pgid && !p.zombie)) throw blocked(`Owned group cleanup unconfirmed: ${identity.pgid}`);
|
|
298
|
+
}
|
|
299
|
+
if (child.pid && !identity && snapshot().some(p => !p.zombie && (nested ? p.pid === child.pid : p.pgid === child.pid))) throw blocked(`Spawned process identity unconfirmed: ${child.pid}`);
|
|
300
|
+
const ioClosed = await Promise.race([closed.then(() => true), delay(5000, undefined, { ref: false }).then(() => false)]);
|
|
301
|
+
if (!ioClosed) {
|
|
302
|
+
owner.groups = owner.groups.map(group => group.token === token ? { ...group, pgid: null, reason: 'stdio_cleanup_unconfirmed' } : group); save();
|
|
303
|
+
throw blocked(`Owned stdio cleanup unconfirmed: ${child.pid}`);
|
|
304
|
+
}
|
|
305
|
+
// Missing startup identity is not an eternal unknown after actual exit plus
|
|
306
|
+
// positive absence of the exact spawned group. Never kill by an inferred PID.
|
|
307
|
+
owner.groups = owner.groups.filter(g => g.token !== token); save();
|
|
308
|
+
if (spawnError) throw spawnError;
|
|
309
|
+
const finished = Date.now();
|
|
310
|
+
const terminal = { id: job.id, attemptId, root, ...result, started_at: new Date(started).toISOString(), finished_at: new Date(finished).toISOString(), queue_ms: started - queuedAt, elapsed_ms: finished - started, status: signal?.aborted ? 'interrupted' : result.code === 0 ? 'passed' : 'failed' };
|
|
311
|
+
atomicJson(path.join(root, 'terminal.json'), terminal);
|
|
312
|
+
return terminal;
|
|
313
|
+
} catch (error) {
|
|
314
|
+
child.stdout?.destroy(); child.stderr?.destroy();
|
|
315
|
+
throw error;
|
|
316
|
+
}
|
|
317
|
+
}
|