@deksden-com/dd-flow-cli 0.9.0-beta.142 → 0.9.0-beta.144
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/dist/build-info.json +7 -7
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +8 -3
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +29 -8
- package/dist/harness-runtime/lib/dd-codex.mjs +184 -51
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +23 -6
- package/dist/schemas/code-review-decision.schema.json +9 -0
- package/dist/schemas/flow-run.schema.json +1 -0
- package/dist/schemas/plan-review-decision.schema.json +9 -0
- package/dist/services/accepted-intent.js +127 -0
- package/dist/services/check-runner-evidence.js +2 -0
- package/dist/services/code-checks.js +213 -47
- package/dist/services/provider-capacity.js +5 -1
- package/dist/services/repair-intents.js +1 -1
- package/dist/services/run-control.js +36 -1
- package/dist/services/stage-pause.js +30 -0
- package/dist/services/verification-matrix.js +20 -2
- package/dist/services/vnext-code-review.js +14 -2
- package/dist/services/vnext-plan-review.js +6 -1
- package/dist/services/vnext-protocolize.js +2 -0
- package/dist/services/vnext-specify.js +4 -0
- package/dist/services/work-registry.js +4 -2
- package/dist/storage/database.js +4 -0
- package/package.json +1 -1
- package/tools/probe-codex-history.mjs +37 -0
|
@@ -275,7 +275,9 @@ function validateCheckEvidenceContext(value) {
|
|
|
275
275
|
if (value !== undefined && (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9:._-]{0,255}$/.test(value)))
|
|
276
276
|
throw new AppError("check_evidence_context_invalid", "Check evidence context must be a bounded opaque identifier", 2, { effect: "no_effect" });
|
|
277
277
|
}
|
|
278
|
-
|
|
278
|
+
// Additive NULL columns must not invalidate accepted legacy SQL digests.
|
|
279
|
+
const scopedReceiptColumns = new Set(["fingerprint_scope", "input_manifest_json", "execution_failures_json", "execution_contract_hash"]);
|
|
280
|
+
const evidenceDigest = (value) => crypto.createHash("sha256").update(JSON.stringify(value, (key, item) => scopedReceiptColumns.has(key) && item === null ? undefined : item)).digest("hex");
|
|
279
281
|
/** Prepares current physical evidence outside a writer and retains an exact
|
|
280
282
|
* SQL-only fence. Declarations must come from the owning handler/policy, never
|
|
281
283
|
* from the result whose acceptance is being decided. This does not supply
|
|
@@ -348,6 +350,8 @@ export class PreparedCheckEvidence {
|
|
|
348
350
|
artifacts.push({ root: "run", path: path.relative(input.runHome, path.join(path.dirname(receipt.receipt_path), "artifacts", artifact.path)).split(path.sep).join("/"), sha256: artifact.sha256 });
|
|
349
351
|
const bytes = fs.readFileSync(receipt.receipt_path);
|
|
350
352
|
completions.push({ root: "run", path: path.relative(input.runHome, receipt.completion_path).split(path.sep).join("/"), sha256: crypto.createHash("sha256").update(fs.readFileSync(receipt.completion_path)).digest("hex") });
|
|
353
|
+
if (receipt.input_manifest)
|
|
354
|
+
completions.push({ root: "run", path: path.relative(input.runHome, path.join(path.dirname(receipt.receipt_path), receipt.input_manifest.path)).split(path.sep).join("/"), sha256: receipt.input_manifest.sha256 });
|
|
351
355
|
return { id: receipt.id, path: receipt.receipt_path, sha256: crypto.createHash("sha256").update(bytes).digest("hex") };
|
|
352
356
|
});
|
|
353
357
|
const prepared = new PreparedCheckEvidence(input, snapshot, generation, references, artifacts, passed);
|
|
@@ -497,13 +501,29 @@ export async function runCodeChecks(context, input) {
|
|
|
497
501
|
}
|
|
498
502
|
}
|
|
499
503
|
try {
|
|
500
|
-
|
|
504
|
+
let receipts = await runCodeChecksOwned(context, input, gate.id, generation);
|
|
505
|
+
// One bounded episode, not a product-repair turn. The second execution
|
|
506
|
+
// remains guarded by its own before/after snapshot and SQL reservation.
|
|
507
|
+
const stale = receipts.filter(staleCheckRetryEligible);
|
|
508
|
+
if (!input.retryReceiptId && !input.retryReceiptIds?.length && stale.length) {
|
|
509
|
+
const otherSession = context.db.get("SELECT 1 FROM work_sessions s JOIN works w ON w.work_id = s.work_id WHERE w.project_id = ? AND w.run_id = ? AND w.work_id != ? AND s.status = 'running' AND s.completed_at IS NULL LIMIT 1", [input.projectId, input.runId, input.workId ?? ""]);
|
|
510
|
+
if (!otherSession) {
|
|
511
|
+
for (const predecessor of stale) {
|
|
512
|
+
const selected = input.checks.filter(check => predecessor.check_refs.includes(checkRef(check)));
|
|
513
|
+
if (!selected.length || selected.some(check => check.reuse !== "deterministic"))
|
|
514
|
+
continue;
|
|
515
|
+
const retried = await runCodeChecksOwned(context, { ...input, retryReceiptId: predecessor.id, retryReason: "check_inputs_changed" }, gate.id, generation, new Set(predecessor.check_refs));
|
|
516
|
+
receipts = receipts.filter(receipt => receipt.id !== predecessor.id).concat(retried);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return receipts;
|
|
501
521
|
}
|
|
502
522
|
finally {
|
|
503
523
|
finishManagedProcess(context, { id: gate.id, leaseToken: gate.lease_token, state: "stopped", reason: "workspace_gate_settled" });
|
|
504
524
|
}
|
|
505
525
|
}
|
|
506
|
-
async function runCodeChecksOwned(context, input, workspaceGateId, generation) {
|
|
526
|
+
async function runCodeChecksOwned(context, input, workspaceGateId, generation, selectedRefs) {
|
|
507
527
|
assertRunMutationAllowed(context, input.projectId, input.runId, generation);
|
|
508
528
|
for (const check of input.checks)
|
|
509
529
|
validateCheckDeclaration(input.workspaceRoot, check);
|
|
@@ -520,7 +540,9 @@ async function runCodeChecksOwned(context, input, workspaceGateId, generation) {
|
|
|
520
540
|
});
|
|
521
541
|
const resolved = resolveCodeCheckCommands(input.workspaceRoot, input.runId, checks.map((check) => check.command));
|
|
522
542
|
assertConsistentDeclarations(checks, resolved);
|
|
523
|
-
|
|
543
|
+
// Retain all declarations for the planned-alias profile hash; filter only
|
|
544
|
+
// execution, otherwise a targeted retry changes its own frozen contract.
|
|
545
|
+
const groups = deduplicate(checks, resolved).filter(group => !selectedRefs || group.declarations.some(check => selectedRefs.has(checkRef(check)))), receipts = [];
|
|
524
546
|
const retryIds = [...new Set([...(input.retryReceiptId ? [input.retryReceiptId] : []), ...(input.retryReceiptIds ?? [])])];
|
|
525
547
|
const retries = new Map();
|
|
526
548
|
for (const receiptId of retryIds) {
|
|
@@ -528,7 +550,8 @@ async function runCodeChecksOwned(context, input, workspaceGateId, generation) {
|
|
|
528
550
|
const snapshot = workspaceState(input.workspaceRoot, [input.runHome]);
|
|
529
551
|
const group = groups.find(candidate => candidate.declarations.some(check => check.run_at === predecessor.gate)
|
|
530
552
|
&& (checkInputHash(input.workspaceRoot, { ...candidate, gate: predecessor.gate }, profileHash, workspaceInputFingerprint(input.workspaceRoot, snapshot.files, candidate.inputs, [input.runHome]), input.evidenceContext, input.checkEnvironment) === predecessor.input_hash
|
|
531
|
-
|| sourceRepairContractMatches(predecessor, candidate, profileHash)
|
|
553
|
+
|| sourceRepairContractMatches(predecessor, candidate, profileHash)
|
|
554
|
+
|| input.retryReason === "check_inputs_changed" && staleRetryContractMatches(predecessor, candidate, profileHash, input)));
|
|
532
555
|
if (!group || retries.has(group))
|
|
533
556
|
throw new AppError("retry_check_invalid", "Retry must match exactly one current execution contract", 2, { check_receipt_id: receiptId });
|
|
534
557
|
// Preserve the identity of a pre-deduplication failed execution when an
|
|
@@ -540,10 +563,12 @@ async function runCodeChecksOwned(context, input, workspaceGateId, generation) {
|
|
|
540
563
|
const group = groups[index];
|
|
541
564
|
const before = workspaceState(input.workspaceRoot, [input.runHome]);
|
|
542
565
|
const startedAt = context.now();
|
|
543
|
-
const
|
|
566
|
+
const inputFiles = selectedInputFiles(input.workspaceRoot, before.files, group.inputs, [input.runHome]);
|
|
567
|
+
const inputFingerprint = inputFilesFingerprint(inputFiles);
|
|
544
568
|
const inputHash = checkInputHash(input.workspaceRoot, group, profileHash, inputFingerprint, input.evidenceContext, input.checkEnvironment);
|
|
545
|
-
const epoch = crypto.createHash("sha256").update(`${group.gate}\0${
|
|
546
|
-
const
|
|
569
|
+
const epoch = crypto.createHash("sha256").update(`${group.gate}\0${inputFingerprint}`).digest("hex");
|
|
570
|
+
const executionContractHash = checkInputHash(input.workspaceRoot, group, profileHash, "", input.evidenceContext, input.checkEnvironment);
|
|
571
|
+
const reservationInput = { ...input, retryReceiptIds: retries.has(group) ? [retries.get(group)] : [], generation, group, before, inputFiles, inputFingerprint, executionContractHash, profileHash, inputHash, epoch, startedAt };
|
|
547
572
|
delete reservationInput.retryReceiptId;
|
|
548
573
|
const decision = reserveOrReuseReceipt(context, reservationInput);
|
|
549
574
|
if (decision.receipt) {
|
|
@@ -557,6 +582,7 @@ async function runCodeChecksOwned(context, input, workspaceGateId, generation) {
|
|
|
557
582
|
let result = { exitCode: null, error: "check did not start", aborted: true };
|
|
558
583
|
try {
|
|
559
584
|
fs.mkdirSync(evidenceDir, { recursive: true });
|
|
585
|
+
retainInputManifest(context, reserved);
|
|
560
586
|
const startingReceipt = receiptFor({ ...reserved, resources, result, status: "running", after: before, mutationPaths: [], artifacts: [] });
|
|
561
587
|
if (group.runnerEvidence)
|
|
562
588
|
startingReceipt.runner_evidence_required = group.runnerEvidence;
|
|
@@ -610,7 +636,8 @@ async function runCodeChecksOwned(context, input, workspaceGateId, generation) {
|
|
|
610
636
|
result = { exitCode: null, error: error instanceof Error ? error.message : String(error), aborted: true };
|
|
611
637
|
}
|
|
612
638
|
const after = workspaceState(input.workspaceRoot, [input.runHome]);
|
|
613
|
-
const
|
|
639
|
+
const afterFiles = selectedInputFiles(input.workspaceRoot, after.files, group.inputs, [input.runHome]);
|
|
640
|
+
const mutationPaths = changedPaths(reserved.inputFiles, afterFiles);
|
|
614
641
|
let artifacts = collectCheckArtifacts(evidenceDir, group.requiredArtifacts);
|
|
615
642
|
if (fs.existsSync(stderrPath)) {
|
|
616
643
|
if (!artifacts.complete)
|
|
@@ -618,23 +645,27 @@ async function runCodeChecksOwned(context, input, workspaceGateId, generation) {
|
|
|
618
645
|
if (result.error)
|
|
619
646
|
fs.appendFileSync(stderrPath, `\n${result.error}\n`);
|
|
620
647
|
if (mutationPaths.length)
|
|
621
|
-
fs.appendFileSync(stderrPath, `\ncheck
|
|
648
|
+
fs.appendFileSync(stderrPath, `\ncheck inputs changed while running: ${mutationPaths.join(", ")}\n`);
|
|
622
649
|
}
|
|
623
650
|
const runnerEvidence = group.runnerEvidence ? readRunnerEvidence(evidenceDir, group.runnerEvidence, { invocation_id: id, check_id: id, command: group.command, input_hash: inputHash, verification_epoch: epoch }) : undefined;
|
|
624
|
-
const physicalFailures = executionFailures({ exitCode: result.exitCode, launcherError: result.error ?? undefined, cleanupUnconfirmed: result.cleanupUnconfirmed, missing: artifacts.missing, mutations: mutationPaths, invalidRunner: Boolean(group.runnerEvidence && !runnerEvidence) });
|
|
651
|
+
const physicalFailures = executionFailures({ fingerprintScope: "check_inputs", exitCode: result.exitCode, launcherError: result.error ?? undefined, cleanupUnconfirmed: result.cleanupUnconfirmed, missing: artifacts.missing, mutations: mutationPaths, invalidRunner: Boolean(group.runnerEvidence && !runnerEvidence) });
|
|
625
652
|
if (result.failure)
|
|
626
653
|
physicalFailures.push(result.failure);
|
|
627
654
|
const candidateFiles = candidateFailureFiles(runnerEvidence, input.workspaceRoot);
|
|
628
655
|
if (group.runnerEvidence)
|
|
629
|
-
retainExecutionOutcome(evidenceDir, { id, command: group.command, input_hash: inputHash, verification_epoch: epoch }, input.workspaceRoot, physicalFailures, candidateFiles);
|
|
656
|
+
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);
|
|
630
657
|
const requiredArtifacts = [...new Set([...group.requiredArtifacts, ...runnerProofArtifacts(runnerEvidence), ...(group.runnerEvidence ? ["engine-outcome.json"] : [])])];
|
|
631
658
|
artifacts = collectCheckArtifacts(evidenceDir, requiredArtifacts);
|
|
632
659
|
const status = result.aborted ? "aborted" : result.exitCode === 0 && !result.error && artifacts.complete && mutationPaths.length === 0 && (!group.runnerEvidence || runnerEvidencePass(runnerEvidence)) ? "passed" : "failed";
|
|
633
|
-
const receipt = receiptFor({ ...reserved, resources, result, status, after, mutationPaths, artifacts: artifacts.items });
|
|
660
|
+
const receipt = receiptFor({ ...reserved, resources, result, status, after: { ...after, fingerprint: inputFilesFingerprint(afterFiles) }, mutationPaths, artifacts: artifacts.items });
|
|
634
661
|
receipt.required_artifacts = requiredArtifacts;
|
|
635
662
|
receipt.candidate_root = input.workspaceRoot;
|
|
636
663
|
receipt.candidate_files = candidateFiles;
|
|
637
664
|
receipt.execution_failures = physicalFailures;
|
|
665
|
+
context.db.run("UPDATE check_receipts SET execution_failures_json = ? WHERE project_id = ? AND id = ? AND status = 'running'", [JSON.stringify(physicalFailures), input.projectId, id]);
|
|
666
|
+
const completion = readCompletion(completionPath);
|
|
667
|
+
if (completion)
|
|
668
|
+
writeCompletionSnapshot(completionPath, completion, receipt);
|
|
638
669
|
if (group.runnerEvidence) {
|
|
639
670
|
receipt.runner_evidence_required = group.runnerEvidence;
|
|
640
671
|
receipt.runner_evidence = runnerEvidence ?? null;
|
|
@@ -678,6 +709,10 @@ export function reconcileUnfinishedChecks(context, input) {
|
|
|
678
709
|
for (const pendingReceipt of pending) {
|
|
679
710
|
const process = processes.filter((item) => item.check_id === pendingReceipt.id && item.project_id === input.projectId && item.run_id === input.runId).at(-1);
|
|
680
711
|
const receipt = readReceipt(pendingReceipt.receipt_path);
|
|
712
|
+
const authority = context.db.get("SELECT * FROM check_receipts WHERE project_id = ? AND id = ?", [input.projectId, pendingReceipt.id]);
|
|
713
|
+
if ((receipt?.fingerprint_scope ?? null) !== (authority?.fingerprint_scope ?? null)
|
|
714
|
+
|| authority?.fingerprint_scope === "check_inputs" && JSON.stringify(receipt?.input_manifest ?? null) !== authority.input_manifest_json)
|
|
715
|
+
throw new AppError("check_receipt_changed", "Detached receipt scope or input manifest differs from SQL custody", 2, { check_receipt_id: pendingReceipt.id });
|
|
681
716
|
const completion = receipt ? readCompletion(receipt.completion_path || path.join(path.dirname(pendingReceipt.receipt_path), "completion.json")) : null;
|
|
682
717
|
// A stale lease after host sleep is not permission to duplicate a living
|
|
683
718
|
// check. Its durable completion marker or explicit reconciliation decides
|
|
@@ -694,11 +729,24 @@ export function reconcileUnfinishedChecks(context, input) {
|
|
|
694
729
|
}
|
|
695
730
|
const after = workspaceState(input.workspaceRoot, input.runHome ? [input.runHome] : []);
|
|
696
731
|
let artifacts = receipt ? collectCheckArtifacts(path.join(path.dirname(receipt.completion_path || pendingReceipt.receipt_path), "artifacts"), receipt.required_artifacts ?? []) : { complete: false, missing: [], items: [] };
|
|
697
|
-
const
|
|
732
|
+
const scoped = receipt?.fingerprint_scope === "check_inputs";
|
|
733
|
+
const completionSnapshot = scoped && completion?.fingerprint_scope === "check_inputs" && completion.id === receipt.id && completion.input_hash === receipt.input_hash
|
|
734
|
+
&& completion.verification_epoch === receipt.verification_epoch && completion.before_fingerprint === receipt.before_fingerprint
|
|
735
|
+
&& JSON.stringify(completion.input_manifest ?? null) === JSON.stringify(receipt.input_manifest ?? null) && typeof completion.after_fingerprint === "string";
|
|
736
|
+
const afterFingerprint = scoped ? completionSnapshot ? completion.after_fingerprint : workspaceInputFingerprint(input.workspaceRoot, after.files, receipt.inputs ?? [], input.runHome ? [input.runHome] : []) : after.fingerprint;
|
|
737
|
+
const workspaceUnchanged = receipt?.before_fingerprint === afterFingerprint;
|
|
738
|
+
const beforeFiles = scoped ? retainedInputFiles(receipt) : null;
|
|
739
|
+
// A completion digest describes that instant, not the later recovery tree.
|
|
740
|
+
// Without an after manifest, never attach today's delta to that old proof.
|
|
741
|
+
const mutationPaths = workspaceUnchanged ? [] : scoped && completionSnapshot ? ["<declared check inputs differed at completion; exact delta unavailable>"]
|
|
742
|
+
: scoped && beforeFiles ? changedPaths(beforeFiles, selectedInputFiles(input.workspaceRoot, after.files, receipt.inputs ?? [], input.runHome ? [input.runHome] : []))
|
|
743
|
+
: [scoped ? "<declared check inputs changed; exact manifest unavailable>" : "<workspace changed while detached check ran>"];
|
|
698
744
|
const runnerEvidence = receipt?.runner_evidence_required ? readRunnerEvidence(path.join(path.dirname(pendingReceipt.receipt_path), "artifacts"), receipt.runner_evidence_required, { invocation_id: receipt.id, check_id: receipt.id, command: receipt.command, input_hash: receipt.input_hash, verification_epoch: receipt.verification_epoch }) : undefined;
|
|
699
745
|
const exitCode = completion?.exit_code ?? null;
|
|
700
746
|
const finishedAt = completion?.finished_at ?? context.now();
|
|
701
|
-
const physicalFailures = executionFailures({ exitCode, missing: artifacts.missing.filter(file => file !== "engine-outcome.json"), mutations:
|
|
747
|
+
const physicalFailures = executionFailures({ fingerprintScope: scoped ? "check_inputs" : undefined, exitCode, missing: artifacts.missing.filter(file => file !== "engine-outcome.json"), mutations: mutationPaths, invalidRunner: Boolean(receipt?.runner_evidence_required && !runnerEvidence) });
|
|
748
|
+
if (scoped && !completionSnapshot)
|
|
749
|
+
physicalFailures.push({ code: "completion_snapshot_unknown", message: "Detached check has no bound scoped completion snapshot; current inputs cannot prove inputs at command completion" });
|
|
702
750
|
let candidateFiles = candidateFailureFiles(runnerEvidence, input.workspaceRoot);
|
|
703
751
|
if (receipt?.runner_evidence_required) {
|
|
704
752
|
const evidenceDir = path.join(path.dirname(pendingReceipt.receipt_path), "artifacts");
|
|
@@ -720,11 +768,12 @@ export function reconcileUnfinishedChecks(context, input) {
|
|
|
720
768
|
artifacts = collectCheckArtifacts(evidenceDir, receipt.required_artifacts);
|
|
721
769
|
}
|
|
722
770
|
const status = completion === null ? "aborted" : completion.exit_code === 0 && workspaceUnchanged && artifacts.complete && physicalFailures.length === 0 && (!receipt?.runner_evidence_required || runnerEvidencePass(runnerEvidence)) ? "passed" : "failed";
|
|
723
|
-
context.db.run("UPDATE check_receipts SET status = ?, exit_code = ?, after_fingerprint = ?, mutation_paths_json = ?, artifacts_json = ?, finished_at = ? WHERE id = ? AND project_id = ? AND status = 'running'", [status, exitCode,
|
|
771
|
+
context.db.run("UPDATE check_receipts SET status = ?, exit_code = ?, after_fingerprint = ?, mutation_paths_json = ?, artifacts_json = ?, finished_at = ? WHERE id = ? AND project_id = ? AND status = 'running'", [status, exitCode, afterFingerprint, JSON.stringify(mutationPaths), JSON.stringify(artifacts.items), finishedAt, pendingReceipt.id, input.projectId]);
|
|
772
|
+
context.db.run("UPDATE check_receipts SET execution_failures_json = ? WHERE project_id = ? AND id = ?", [JSON.stringify(physicalFailures), input.projectId, pendingReceipt.id]);
|
|
724
773
|
if (process)
|
|
725
774
|
finishManagedProcess(context, { id: process.id, leaseToken: process.lease_token, state: status === "passed" ? "stopped" : "failed", reason: status === "passed" ? "reconciled_completion" : completion === null ? "completion_marker_missing" : "reconciled_check_failed" });
|
|
726
775
|
if (receipt) {
|
|
727
|
-
const repaired = { ...receipt, ...(receipt.runner_evidence_required ? { runner_evidence: runnerEvidence ?? null } : {}), completion_path: receipt.completion_path || path.join(path.dirname(pendingReceipt.receipt_path), "completion.json"), required_artifacts: receipt.required_artifacts ?? [], ...(status === "aborted" ? { abort_reason: "completion_marker_missing" } : {}), status, exit_code: exitCode, after_fingerprint:
|
|
776
|
+
const repaired = { ...receipt, ...(receipt.runner_evidence_required ? { runner_evidence: runnerEvidence ?? null } : {}), completion_path: receipt.completion_path || path.join(path.dirname(pendingReceipt.receipt_path), "completion.json"), required_artifacts: receipt.required_artifacts ?? [], ...(status === "aborted" ? { abort_reason: "completion_marker_missing" } : {}), status, exit_code: exitCode, after_fingerprint: afterFingerprint, mutation_paths: mutationPaths, artifacts: artifacts.items, finished_at: finishedAt };
|
|
728
777
|
repaired.candidate_root = input.workspaceRoot;
|
|
729
778
|
repaired.candidate_files = candidateFiles;
|
|
730
779
|
repaired.execution_failures = physicalFailures;
|
|
@@ -739,18 +788,19 @@ export function reconcileUnfinishedChecks(context, input) {
|
|
|
739
788
|
function reserveOrReuseReceipt(context, input) {
|
|
740
789
|
// Receipt files and artifact hashes are prepared before reserving SQLite.
|
|
741
790
|
// The compact SQL snapshot below fences races with another check settlement.
|
|
742
|
-
const snapshotSql = "SELECT
|
|
791
|
+
const snapshotSql = "SELECT * FROM check_receipts WHERE project_id = ? AND run_id = ? ORDER BY reservation_sequence, rowid";
|
|
743
792
|
const snapshot = JSON.stringify(context.db.all(snapshotSql, [input.projectId, input.runId]));
|
|
744
793
|
const receipts = checkReceipts(context, { projectId: input.projectId, runId: input.runId });
|
|
745
794
|
if (JSON.stringify(context.db.all(snapshotSql, [input.projectId, input.runId])) !== snapshot)
|
|
746
795
|
throw new AppError("check_receipt_changed", "Check receipts changed during evidence preparation", 1);
|
|
747
|
-
const prior = receipts.filter((receipt) => receipt.input_hash === input.inputHash && receipt.profile_hash === input.profileHash).at(-1) ?? null;
|
|
796
|
+
const prior = receipts.filter((receipt) => receipt.fingerprint_scope === "check_inputs" && receipt.input_hash === input.inputHash && receipt.profile_hash === input.profileHash).at(-1) ?? null;
|
|
748
797
|
const reusablePrior = prior?.status === "passed" && checkReceiptEvidenceValid(prior);
|
|
749
798
|
const retryIds = new Set([...(input.retryReceiptId ? [input.retryReceiptId] : []), ...(input.retryReceiptIds ?? [])]);
|
|
750
799
|
const successor = retryIds.size === 1 ? receipts.find((receipt) => receipt.retry_of_receipt_id === [...retryIds][0] && receipt.input_hash === input.inputHash && receipt.profile_hash === input.profileHash) : null;
|
|
751
800
|
const reusableSuccessor = successor?.status === "passed" && checkReceiptEvidenceValid(successor);
|
|
752
801
|
const retryPredecessor = receipts.find(receipt => retryIds.has(receipt.id));
|
|
753
|
-
const sourceCorrection = Boolean(retryPredecessor && sourceRepairContractMatches(retryPredecessor, input.group, input.profileHash)
|
|
802
|
+
const sourceCorrection = Boolean(retryPredecessor && (sourceRepairContractMatches(retryPredecessor, input.group, input.profileHash)
|
|
803
|
+
|| input.retryReason === "check_inputs_changed" && staleRetryContractMatches(retryPredecessor, input.group, input.profileHash, input)));
|
|
754
804
|
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);
|
|
755
805
|
return context.db.writeTransaction(() => {
|
|
756
806
|
assertRunMutationAllowed(context, input.projectId, input.runId, input.generation);
|
|
@@ -762,6 +812,13 @@ function reserveOrReuseReceipt(context, input) {
|
|
|
762
812
|
throw new AppError("retry_check_invalid", "A check execution accepts exactly one retry predecessor", 2);
|
|
763
813
|
const retryId = [...retryIds][0];
|
|
764
814
|
if (retryId) {
|
|
815
|
+
if (input.retryReason === "check_inputs_changed" && retryPredecessor) {
|
|
816
|
+
const allSuccessors = receipts.filter(receipt => receipt.retry_of_receipt_id === retryId);
|
|
817
|
+
if (allSuccessors.length === 1 && reusableSuccessor)
|
|
818
|
+
return { receipt: successor };
|
|
819
|
+
if (retryPredecessor.retry_reason === "check_inputs_changed" || allSuccessors.length)
|
|
820
|
+
throw new AppError("retry_check_invalid", "Scoped input retry episode already consumed its one successor", 2, { check_receipt_id: retryId });
|
|
821
|
+
}
|
|
765
822
|
if (!input.retryReason?.trim())
|
|
766
823
|
throw new AppError("retry_check_invalid", "A check retry requires the restored-condition reason", 2, { check_receipt_id: retryId });
|
|
767
824
|
const predecessor = receipts.find(receipt => receipt.id === retryId);
|
|
@@ -779,6 +836,9 @@ function reserveOrReuseReceipt(context, input) {
|
|
|
779
836
|
}
|
|
780
837
|
// An unchanged failed contract needs a concrete restored-condition retry,
|
|
781
838
|
// not repeated coordinator finish until it happens to become green.
|
|
839
|
+
if (!retryId && latestContract?.fingerprint_scope === "check_inputs" && latestContract.status === "failed"
|
|
840
|
+
&& latestContract.execution_failures?.some(failure => failure.code === "check_inputs_changed") && latestContract.execution_contract_hash === input.executionContractHash)
|
|
841
|
+
return { receipt: latestContract };
|
|
782
842
|
if (prior && ["failed", "aborted"].includes(prior.status) && !retryId)
|
|
783
843
|
return { receipt: prior };
|
|
784
844
|
if (prior?.status === "passed" && !retryIds.has(prior.id) && input.group.reusable && reusablePrior)
|
|
@@ -791,8 +851,9 @@ function reserveOrReuseReceipt(context, input) {
|
|
|
791
851
|
id = `${input.workId ?? input.runId}/${localId}`;
|
|
792
852
|
}
|
|
793
853
|
const directory = path.join(input.runHome, input.artifactDir ?? "05-code", "checks", localId);
|
|
794
|
-
const reserved = { id, localId, directory, evidenceDir: path.join(directory, "artifacts"), stdoutPath: path.join(directory, "stdout.log"), stderrPath: path.join(directory, "stderr.log"), receiptPath: path.join(directory, "receipt.json"), completionPath: path.join(directory, "completion.json"), group: input.group, before: input.before, profileHash: input.profileHash, inputHash: input.inputHash, epoch: input.epoch, startedAt: input.startedAt, scope: input.scope, ...(retryId ? { retryOf: retryId, retryReason: input.retryReason } : {}), ...(input.workId ? { workId: input.workId } : {}), projectId: input.projectId, runId: input.runId };
|
|
854
|
+
const reserved = { id, localId, directory, evidenceDir: path.join(directory, "artifacts"), stdoutPath: path.join(directory, "stdout.log"), stderrPath: path.join(directory, "stderr.log"), receiptPath: path.join(directory, "receipt.json"), completionPath: path.join(directory, "completion.json"), group: input.group, before: input.before, inputFiles: input.inputFiles, inputFingerprint: input.inputFingerprint, executionContractHash: input.executionContractHash, profileHash: input.profileHash, inputHash: input.inputHash, epoch: input.epoch, startedAt: input.startedAt, scope: input.scope, ...(retryId ? { retryOf: retryId, retryReason: input.retryReason } : {}), ...(input.workId ? { workId: input.workId } : {}), projectId: input.projectId, runId: input.runId, workspaceRoot: input.workspaceRoot, runHome: input.runHome };
|
|
795
855
|
context.db.run("INSERT INTO check_receipts (id, project_id, run_id, work_id, scope, declaration_id, check_refs_json, gate, command, input_hash, verification_epoch, reservation_sequence, retry_of_receipt_id, retry_reason, status, exit_code, stdout_path, stderr_path, receipt_path, workspace_fingerprint, before_fingerprint, after_fingerprint, profile_hash, mutation_paths_json, artifacts_json, started_at, finished_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', NULL, ?, ?, ?, ?, ?, ?, ?, '[]', '[]', ?, ?)", [id, input.projectId, input.runId, input.workId ?? null, input.scope, input.group.declarations[0].id, JSON.stringify(input.group.declarations.map(checkRef).sort()), input.group.gate, input.group.command, input.inputHash, input.epoch, ordinal, reserved.retryOf ?? null, reserved.retryReason ?? null, reserved.stdoutPath, reserved.stderrPath, reserved.receiptPath, input.before.fingerprint, input.before.fingerprint, input.before.fingerprint, input.profileHash, input.startedAt, input.startedAt]);
|
|
856
|
+
context.db.run("UPDATE check_receipts SET fingerprint_scope = ?, before_fingerprint = ?, after_fingerprint = ?, execution_contract_hash = ? WHERE id = ? AND project_id = ?", ["check_inputs", input.inputFingerprint, input.inputFingerprint, input.executionContractHash, id, input.projectId]);
|
|
796
857
|
return { reserved };
|
|
797
858
|
}, "checks.reserveOrReuseReceipt");
|
|
798
859
|
}
|
|
@@ -800,7 +861,48 @@ function sourceRepairContractMatches(receipt, group, profileHash) {
|
|
|
800
861
|
return checkRepairDisposition(receipt) === "source_repair" && group.command === receipt.command && profileHash === receipt.profile_hash && isDeepStrictEqual(group.inputs, receipt.inputs)
|
|
801
862
|
&& isDeepStrictEqual(derivedReceiptArtifacts(group, receipt).sort(), [...receipt.required_artifacts].sort()) && isDeepStrictEqual([...group.ports].sort(), Object.keys(receipt.resources.ports).sort());
|
|
802
863
|
}
|
|
803
|
-
function
|
|
864
|
+
function staleCheckRetryEligible(receipt) {
|
|
865
|
+
if (receipt.fingerprint_scope !== "check_inputs" || receipt.status !== "failed" || receipt.exit_code !== 0 || receipt.retry_reason === "check_inputs_changed"
|
|
866
|
+
|| !receipt.execution_failures?.length || receipt.execution_failures.some(failure => failure.code !== "check_inputs_changed")
|
|
867
|
+
|| receipt.runner_evidence_required && !runnerEvidencePass(receipt.runner_evidence))
|
|
868
|
+
return false;
|
|
869
|
+
return checkReceiptEvidenceValidForMode(receipt, "diagnostic");
|
|
870
|
+
}
|
|
871
|
+
function staleRetryContractMatches(receipt, group, profileHash, input) {
|
|
872
|
+
return group.reusable && staleCheckRetryEligible(receipt) && isDeepStrictEqual(group.runnerEvidence ?? null, receipt.runner_evidence_required ?? null)
|
|
873
|
+
&& receipt.execution_contract_hash === checkInputHash(input.workspaceRoot, group, profileHash, "", input.evidenceContext, input.checkEnvironment);
|
|
874
|
+
}
|
|
875
|
+
function receiptFor(input) { return { id: input.id, local_id: input.localId, declaration_id: input.group.declarations[0].id, check_refs: input.group.declarations.map(checkRef), gate: input.group.gate, scope: input.scope, work_id: input.workId ?? null, command: input.group.command, input_hash: input.inputHash, verification_epoch: input.epoch, retry_of_receipt_id: input.retryOf ?? null, retry_reason: input.retryReason ?? null, status: input.status, ...(input.status === "aborted" ? { abort_reason: input.result.error } : {}), exit_code: input.status === "running" ? null : input.result.exitCode, stdout_path: input.stdoutPath, stderr_path: input.stderrPath, receipt_path: input.receiptPath, completion_path: input.completionPath, required_artifacts: input.group.requiredArtifacts, inputs: input.group.inputs, fingerprint_scope: "check_inputs", input_manifest: input.inputManifest ?? null, execution_contract_hash: input.executionContractHash, workspace_fingerprint: input.before.fingerprint, before_fingerprint: input.inputFingerprint, after_fingerprint: input.status === "running" ? input.inputFingerprint : input.after.fingerprint, profile_hash: input.profileHash, mutation_paths: input.mutationPaths, artifacts: input.artifacts, resources: { ports: input.resources }, started_at: input.startedAt, finished_at: input.status === "running" ? input.startedAt : new Date().toISOString() }; }
|
|
876
|
+
function retainInputManifest(context, reserved) {
|
|
877
|
+
const full = JSON.stringify({ fingerprint_scope: "check_inputs", before_fingerprint: reserved.inputFingerprint, files: [...reserved.inputFiles].sort(([left], [right]) => left.localeCompare(right)) });
|
|
878
|
+
const truncated = Buffer.byteLength(full) > 1024 * 1024;
|
|
879
|
+
const bytes = truncated ? JSON.stringify({ fingerprint_scope: "check_inputs", before_fingerprint: reserved.inputFingerprint, truncated: true }) : full;
|
|
880
|
+
const relative = "inputs-before.json", file = path.join(reserved.directory, relative);
|
|
881
|
+
fs.writeFileSync(file, bytes, { flag: "wx", mode: 0o600 });
|
|
882
|
+
reserved.inputManifest = { path: relative, sha256: crypto.createHash("sha256").update(bytes).digest("hex"), truncated };
|
|
883
|
+
context.db.run("UPDATE check_receipts SET input_manifest_json = ? WHERE project_id = ? AND id = ? AND status = 'running'", [JSON.stringify(reserved.inputManifest), reserved.projectId, reserved.id]);
|
|
884
|
+
}
|
|
885
|
+
function retainedInputFiles(receipt) {
|
|
886
|
+
if (!receipt.input_manifest || receipt.input_manifest.truncated || receipt.input_manifest.path !== "inputs-before.json")
|
|
887
|
+
return null;
|
|
888
|
+
try {
|
|
889
|
+
const bytes = readBoundedReleaseFile(path.join(path.dirname(receipt.receipt_path), receipt.input_manifest.path), 1024 * 1024);
|
|
890
|
+
if (crypto.createHash("sha256").update(bytes).digest("hex") !== receipt.input_manifest.sha256)
|
|
891
|
+
return null;
|
|
892
|
+
const value = JSON.parse(bytes.toString("utf8"));
|
|
893
|
+
if (value.before_fingerprint !== receipt.before_fingerprint || !Array.isArray(value.files) || value.files.some((entry) => !Array.isArray(entry) || entry.length !== 2 || entry.some(item => typeof item !== "string")))
|
|
894
|
+
return null;
|
|
895
|
+
return new Map(value.files);
|
|
896
|
+
}
|
|
897
|
+
catch {
|
|
898
|
+
return null;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
function writeCompletionSnapshot(file, completion, receipt) {
|
|
902
|
+
const temporary = file + ".tmp-" + crypto.randomUUID();
|
|
903
|
+
fs.writeFileSync(temporary, JSON.stringify({ ...completion, fingerprint_scope: receipt.fingerprint_scope, id: receipt.id, input_hash: receipt.input_hash, verification_epoch: receipt.verification_epoch, before_fingerprint: receipt.before_fingerprint, after_fingerprint: receipt.after_fingerprint, input_manifest: receipt.input_manifest ?? null }));
|
|
904
|
+
fs.renameSync(temporary, file);
|
|
905
|
+
}
|
|
804
906
|
function writeReceipt(file, receipt) { const temporary = `${file}.tmp-${process.pid}-${crypto.randomUUID()}`; fs.writeFileSync(temporary, `${JSON.stringify(receipt, null, 2)}\n`); fs.renameSync(temporary, file); }
|
|
805
907
|
function readReceipt(file) { try {
|
|
806
908
|
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
@@ -811,7 +913,7 @@ catch {
|
|
|
811
913
|
function readCompletion(file) { try {
|
|
812
914
|
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
813
915
|
const done = value;
|
|
814
|
-
return value && typeof value === "object" && Number.isInteger(done.exit_code) ? { exit_code: Number(done.exit_code), finished_at: typeof done.finished_at === "string" && Number.isFinite(Date.parse(done.finished_at)) ? done.finished_at : null } : null;
|
|
916
|
+
return value && typeof value === "object" && Number.isInteger(done.exit_code) ? { ...done, exit_code: Number(done.exit_code), finished_at: typeof done.finished_at === "string" && Number.isFinite(Date.parse(done.finished_at)) ? done.finished_at : null } : null;
|
|
815
917
|
}
|
|
816
918
|
catch {
|
|
817
919
|
return null;
|
|
@@ -826,15 +928,20 @@ export function validateFailedCheckRetry(context, input) {
|
|
|
826
928
|
const declared = new Set(input.declarations.flatMap((check) => [check.id, checkRef(check)]));
|
|
827
929
|
if (!receipt.check_refs.some((ref) => declared.has(ref)))
|
|
828
930
|
throw new AppError("retry_check_invalid", "--retry-check does not belong to this stage gate", 2, { check_receipt_id: input.receiptId, check_refs: receipt.check_refs });
|
|
931
|
+
if (receipt.fingerprint_scope === "check_inputs" && receipt.execution_failures?.some(failure => failure.code === "check_inputs_changed") && input.reason !== "check_inputs_changed")
|
|
932
|
+
throw new AppError("retry_check_invalid", "Scoped drift uses its bounded stale transition, not an environment/source retry", 2, { check_receipt_id: receipt.id });
|
|
933
|
+
if (input.reason === "check_inputs_changed" && (!staleCheckRetryEligible(receipt) || receipt.retry_reason === "check_inputs_changed"))
|
|
934
|
+
throw new AppError("retry_check_invalid", "Scoped input retry requires a bound, safely completed stale predecessor and one unconsumed episode", 2, { check_receipt_id: receipt.id });
|
|
829
935
|
return receipt;
|
|
830
936
|
}
|
|
831
937
|
export function checkReceipts(context, input) {
|
|
832
938
|
const where = input.workId ? "project_id = ? AND run_id = ? AND work_id = ?" : "project_id = ? AND run_id = ?";
|
|
833
939
|
const params = input.workId ? [input.projectId, input.runId, input.workId] : [input.projectId, input.runId];
|
|
834
|
-
return context.db.all(`SELECT
|
|
940
|
+
return context.db.all(`SELECT * FROM check_receipts WHERE ${where} ORDER BY reservation_sequence, rowid`, params).map(projectCheckReceiptRow);
|
|
835
941
|
}
|
|
836
942
|
function projectCheckReceiptRow(row) {
|
|
837
|
-
const
|
|
943
|
+
const inputManifest = row.input_manifest_json ? JSON.parse(row.input_manifest_json) : null;
|
|
944
|
+
const receipt = { ...row, input_manifest: inputManifest, local_id: row.id.slice(row.id.lastIndexOf("/") + 1), check_refs: parseArray(row.check_refs_json, [row.declaration_id]), mutation_paths: parseArray(row.mutation_paths_json, []), resources: readReceiptResources(row.receipt_path), ...resolvedReceiptArtifacts(row.receipt_path, JSON.parse(row.artifacts_json), { ...row, input_manifest: inputManifest }), ...(row.execution_failures_json ? { execution_failures: JSON.parse(row.execution_failures_json) } : {}) };
|
|
838
945
|
if (receipt.status === "failed")
|
|
839
946
|
receipt.diagnostic_evidence_valid = checkReceiptEvidenceValidForMode(receipt, "diagnostic");
|
|
840
947
|
return receipt;
|
|
@@ -867,7 +974,7 @@ function resolvedReceiptArtifacts(receiptPath, artifacts, binding) {
|
|
|
867
974
|
if (retainedOutcome.complete && retainedOutcome.items.every(item => artifacts.some(artifact => relocate(artifact.path) === item.path && artifact.sha256 === item.sha256))) {
|
|
868
975
|
try {
|
|
869
976
|
const value = JSON.parse(fs.readFileSync(outcomePath, "utf8"));
|
|
870
|
-
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 && 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)) && Array.isArray(value.execution_failures) && value.execution_failures.every((failure) => failure && typeof failure === "object" && typeof failure.code === "string" && typeof failure.message === "string"))
|
|
977
|
+
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)) && Array.isArray(value.execution_failures) && value.execution_failures.every((failure) => failure && typeof failure === "object" && typeof failure.code === "string" && typeof failure.message === "string"))
|
|
871
978
|
outcome = value;
|
|
872
979
|
}
|
|
873
980
|
catch { /* Missing physical proof cannot grant attribution. */ }
|
|
@@ -885,7 +992,7 @@ function executionFailures(input) {
|
|
|
885
992
|
if (input.missing.length)
|
|
886
993
|
failures.push({ code: "missing_required_artifact", message: input.missing.join(", ") });
|
|
887
994
|
if (input.mutations.length)
|
|
888
|
-
failures.push({ code: "check_input_mutated", message: input.mutations.join(", ") });
|
|
995
|
+
failures.push({ code: input.fingerprintScope === "check_inputs" ? "check_inputs_changed" : "check_input_mutated", message: input.mutations.join(", ") });
|
|
889
996
|
if (input.invalidRunner)
|
|
890
997
|
failures.push({ code: "invalid_runner_evidence", message: "Required runner evidence is unavailable or invalid" });
|
|
891
998
|
return failures;
|
|
@@ -894,7 +1001,7 @@ function retainExecutionOutcome(evidenceDir, binding, candidateRoot, failures, c
|
|
|
894
1001
|
const temporary = path.join(evidenceDir, `.engine-outcome-${crypto.randomUUID()}.tmp`);
|
|
895
1002
|
if (!fs.lstatSync(evidenceDir).isDirectory() || fs.lstatSync(evidenceDir).isSymbolicLink())
|
|
896
1003
|
throw new AppError("check_evidence_stale", "Check evidence directory was replaced", 1);
|
|
897
|
-
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, candidate_root: candidateRoot, candidate_files: candidateFiles, execution_failures: failures }), { flag: "wx", mode: 0o600 });
|
|
1004
|
+
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, execution_failures: failures }), { flag: "wx", mode: 0o600 });
|
|
898
1005
|
fs.renameSync(temporary, path.join(evidenceDir, "engine-outcome.json"));
|
|
899
1006
|
}
|
|
900
1007
|
export function workspaceFingerprint(root) { return workspaceState(root).fingerprint; }
|
|
@@ -1002,10 +1109,16 @@ function workspaceState(root, ignoredRoots = []) {
|
|
|
1002
1109
|
}
|
|
1003
1110
|
return { fingerprint: hash.digest("hex"), files };
|
|
1004
1111
|
}
|
|
1005
|
-
function
|
|
1112
|
+
function selectedInputFiles(root, files, inputs, ignoredRoots) {
|
|
1006
1113
|
const selected = new Map(inputs.length === 0 || inputs.includes(".") ? files : []);
|
|
1007
|
-
for (const [file, digest] of explicitInputFiles(root, inputs.filter(input => input !== "."), ignoredRoots))
|
|
1114
|
+
for (const [file, digest] of explicitInputFiles(root, inputs.filter(input => input !== "."), ignoredRoots, files.keys()))
|
|
1008
1115
|
selected.set(file, digest);
|
|
1116
|
+
return selected;
|
|
1117
|
+
}
|
|
1118
|
+
function workspaceInputFingerprint(root, files, inputs, ignoredRoots) {
|
|
1119
|
+
return inputFilesFingerprint(selectedInputFiles(root, files, inputs, ignoredRoots));
|
|
1120
|
+
}
|
|
1121
|
+
function inputFilesFingerprint(selected) {
|
|
1009
1122
|
const hash = crypto.createHash("sha256");
|
|
1010
1123
|
for (const [file, digest] of [...selected].sort(([left], [right]) => left.localeCompare(right)))
|
|
1011
1124
|
hash.update(file).update("\0").update(digest).update("\0");
|
|
@@ -1080,23 +1193,40 @@ export function reconcileWorkspaceOwnership(context, workspaceRoot) {
|
|
|
1080
1193
|
}
|
|
1081
1194
|
function normalizedInputs(inputs) { for (const input of inputs)
|
|
1082
1195
|
validateCheckInputSyntax(input); return [...new Set(inputs.map((input) => { const normalized = path.posix.normalize(input.replaceAll("\\", "/")).replace(/^\.\/$/, ""); return normalized === "" || normalized === "." ? "." : normalized.replace(/\/$/, ""); }))].sort(); }
|
|
1083
|
-
function validateCheckInputs(workspaceRoot, inputs) { const root = fs.realpathSync.native(workspaceRoot); for (const input of normalizedInputs(inputs)) {
|
|
1084
|
-
if (input
|
|
1085
|
-
throw new AppError("invalid_check_input", "Check inputs cannot include Git internals", 2, { input });
|
|
1196
|
+
function validateCheckInputs(workspaceRoot, inputs) { const root = fs.realpathSync.native(workspaceRoot); const allowed = sourceInputPathFilter(workspaceRoot, inputs); for (const input of normalizedInputs(inputs)) {
|
|
1197
|
+
if (input.split("/").some(part => [".git", "node_modules"].includes(part)))
|
|
1198
|
+
throw new AppError("invalid_check_input", "Check inputs cannot include Git internals or installed dependencies; declare lock/config inputs", 2, { input });
|
|
1086
1199
|
const candidate = path.resolve(workspaceRoot, input);
|
|
1087
1200
|
if (candidate !== workspaceRoot && !candidate.startsWith(`${workspaceRoot}${path.sep}`))
|
|
1088
1201
|
throw new AppError("invalid_check_input", "Check inputs must stay within the workspace", 2, { input });
|
|
1089
1202
|
if (fs.existsSync(candidate))
|
|
1090
|
-
validateInputTree(root, candidate, input, new Set());
|
|
1203
|
+
validateInputTree(root, candidate, input, new Set(), allowed);
|
|
1091
1204
|
else
|
|
1092
1205
|
validateMissingInputParent(root, candidate, input);
|
|
1093
1206
|
} }
|
|
1207
|
+
/** Directory scopes select source inventory, not generated Git-ignored output.
|
|
1208
|
+
* Direct ignored inputs (e.g. a real config) remain explicit obligations. */
|
|
1209
|
+
function sourceInputPathFilter(root, inputs, sourceFiles) {
|
|
1210
|
+
const listing = sourceFiles ? [...sourceFiles] : workspaceGitFiles(root, ["-co", "--exclude-standard"]);
|
|
1211
|
+
const retained = new Set([...(listing ?? []), ...inputs]), branches = new Set();
|
|
1212
|
+
for (const file of retained)
|
|
1213
|
+
for (let parent = path.posix.dirname(file); parent !== "." && parent !== "/"; parent = path.posix.dirname(parent))
|
|
1214
|
+
branches.add(parent);
|
|
1215
|
+
const explicitTrees = inputs.filter(input => !listing?.some(file => file === input || file.startsWith(`${input}/`)));
|
|
1216
|
+
const serviceAllowed = projectServicePathFilter(root, inputs, listing ?? []);
|
|
1217
|
+
return file => {
|
|
1218
|
+
const relative = path.relative(root, file).split(path.sep).join("/");
|
|
1219
|
+
if (relative.split("/").some(part => [".git", "node_modules"].includes(part)) || !serviceAllowed(file))
|
|
1220
|
+
return false;
|
|
1221
|
+
return listing === null || retained.has(relative) || branches.has(relative) || explicitTrees.some(input => relative.startsWith(`${input}/`));
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1094
1224
|
function validateCheckInputSyntax(input) { if (typeof input !== "string" || !input.trim() || path.isAbsolute(input) || input.split(/[\\/]/).includes(".."))
|
|
1095
1225
|
throw new AppError("invalid_check_input", "Check inputs must be non-empty relative workspace paths", 2, { input }); }
|
|
1096
1226
|
function validateMissingInputParent(root, candidate, input) { let parent = candidate; while (!fs.existsSync(parent))
|
|
1097
1227
|
parent = path.dirname(parent); const resolved = fs.realpathSync.native(parent); if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`))
|
|
1098
1228
|
throw new AppError("invalid_check_input", "Check input parent symlink escapes the workspace", 2, { input }); }
|
|
1099
|
-
function validateInputTree(root, candidate, input, seen) {
|
|
1229
|
+
function validateInputTree(root, candidate, input, seen, allowed) {
|
|
1100
1230
|
const resolved = fs.realpathSync.native(candidate);
|
|
1101
1231
|
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`))
|
|
1102
1232
|
throw new AppError("invalid_check_input", "Check input symlink escapes the workspace", 2, { input });
|
|
@@ -1104,13 +1234,19 @@ function validateInputTree(root, candidate, input, seen) {
|
|
|
1104
1234
|
throw new AppError("invalid_check_input", "Check input contains a symbolic-link cycle", 2, { input });
|
|
1105
1235
|
seen.add(resolved);
|
|
1106
1236
|
const stat = fs.statSync(candidate);
|
|
1237
|
+
if (stat.isDirectory() && fs.lstatSync(candidate).isSymbolicLink())
|
|
1238
|
+
throw new AppError("invalid_check_input", "Directory symlinks cannot prove scoped source inputs; declare their contained real source path instead", 2, { input });
|
|
1107
1239
|
if (stat.isDirectory())
|
|
1108
|
-
for (const entry of fs.readdirSync(candidate))
|
|
1109
|
-
|
|
1240
|
+
for (const entry of fs.readdirSync(candidate)) {
|
|
1241
|
+
const file = path.join(candidate, entry);
|
|
1242
|
+
if (allowed(file))
|
|
1243
|
+
validateInputTree(root, file, input, seen, allowed);
|
|
1244
|
+
}
|
|
1110
1245
|
seen.delete(resolved);
|
|
1111
1246
|
}
|
|
1112
|
-
function explicitInputFiles(root, inputs, ignoredRoots) {
|
|
1247
|
+
function explicitInputFiles(root, inputs, ignoredRoots, sourceFiles) {
|
|
1113
1248
|
const rootReal = fs.realpathSync.native(root), ignored = ignoredRoots.map(entry => path.resolve(entry)), result = new Map();
|
|
1249
|
+
const allowed = sourceInputPathFilter(root, inputs, sourceFiles);
|
|
1114
1250
|
const visit = (file, relative, seen) => {
|
|
1115
1251
|
if (ignored.some(entry => file === entry || file.startsWith(`${entry}${path.sep}`)))
|
|
1116
1252
|
return;
|
|
@@ -1123,15 +1259,21 @@ function explicitInputFiles(root, inputs, ignoredRoots) {
|
|
|
1123
1259
|
throw new AppError("invalid_check_input", "Check input symlink escapes the workspace", 2, { input: relative });
|
|
1124
1260
|
if (seen.has(resolved))
|
|
1125
1261
|
throw new AppError("invalid_check_input", "Check input contains a symbolic-link cycle", 2, { input: relative });
|
|
1262
|
+
const symbolic = fs.lstatSync(file).isSymbolicLink();
|
|
1126
1263
|
const stat = fs.statSync(file);
|
|
1264
|
+
if (symbolic && stat.isDirectory())
|
|
1265
|
+
throw new AppError("invalid_check_input", "Directory symlinks cannot prove scoped source inputs; declare their contained real source path instead", 2, { input: relative });
|
|
1127
1266
|
if (stat.isDirectory()) {
|
|
1128
1267
|
seen.add(resolved);
|
|
1129
|
-
for (const entry of fs.readdirSync(file).sort())
|
|
1130
|
-
|
|
1268
|
+
for (const entry of fs.readdirSync(file).sort()) {
|
|
1269
|
+
const child = path.join(file, entry);
|
|
1270
|
+
if (allowed(child))
|
|
1271
|
+
visit(child, path.posix.join(relative, entry), seen);
|
|
1272
|
+
}
|
|
1131
1273
|
seen.delete(resolved);
|
|
1132
1274
|
}
|
|
1133
1275
|
else if (stat.isFile())
|
|
1134
|
-
result.set(relative, crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"));
|
|
1276
|
+
result.set(relative, crypto.createHash("sha256").update(symbolic ? `symlink\0${fs.readlinkSync(file)}\0` : "").update(fs.readFileSync(file)).digest("hex"));
|
|
1135
1277
|
};
|
|
1136
1278
|
for (const input of inputs)
|
|
1137
1279
|
visit(path.resolve(root, input), input, new Set());
|
|
@@ -1143,8 +1285,24 @@ function bindReceipt(receipt, group) { return { ...receipt, gate: group.gate, de
|
|
|
1143
1285
|
export function checkReceiptEvidenceValid(receipt) { return checkReceiptEvidenceValidForMode(receipt); }
|
|
1144
1286
|
function checkReceiptEvidenceValidForMode(receipt, mode) {
|
|
1145
1287
|
try {
|
|
1146
|
-
|
|
1147
|
-
|
|
1288
|
+
const staleDiagnostic = mode === "diagnostic" && receipt.fingerprint_scope === "check_inputs" && receipt.status === "failed" && Number.isInteger(receipt.exit_code)
|
|
1289
|
+
&& receipt.before_fingerprint !== receipt.after_fingerprint && receipt.mutation_paths.length > 0 && receipt.execution_failures?.some(failure => failure.code === "check_inputs_changed");
|
|
1290
|
+
if (receipt.fingerprint_scope != null && receipt.fingerprint_scope !== "check_inputs")
|
|
1291
|
+
return false;
|
|
1292
|
+
if (receipt.fingerprint_scope === "check_inputs") {
|
|
1293
|
+
const completion = readCompletion(receipt.completion_path);
|
|
1294
|
+
if (!completion || completion.fingerprint_scope !== "check_inputs" || completion.id !== receipt.id || completion.input_hash !== receipt.input_hash
|
|
1295
|
+
|| completion.verification_epoch !== receipt.verification_epoch || completion.before_fingerprint !== receipt.before_fingerprint || completion.after_fingerprint !== receipt.after_fingerprint
|
|
1296
|
+
|| JSON.stringify(completion.input_manifest ?? null) !== JSON.stringify(receipt.input_manifest ?? null))
|
|
1297
|
+
return false;
|
|
1298
|
+
if (!receipt.input_manifest || receipt.input_manifest.path !== "inputs-before.json")
|
|
1299
|
+
return false;
|
|
1300
|
+
const manifestBytes = readBoundedReleaseFile(path.join(path.dirname(receipt.receipt_path), receipt.input_manifest.path), 1024 * 1024);
|
|
1301
|
+
if (crypto.createHash("sha256").update(manifestBytes).digest("hex") !== receipt.input_manifest.sha256)
|
|
1302
|
+
return false;
|
|
1303
|
+
}
|
|
1304
|
+
if (!(receipt.status === "passed" && receipt.exit_code === 0 || mode === "diagnostic" && receipt.status === "failed" && typeof receipt.exit_code === "number" && receipt.exit_code !== 0 || staleDiagnostic) || !staleDiagnostic && receipt.before_fingerprint !== receipt.after_fingerprint
|
|
1305
|
+
|| !staleDiagnostic && receipt.mutation_paths.length !== 0 || receipt.completion_path !== path.join(path.dirname(receipt.receipt_path), "completion.json")
|
|
1148
1306
|
|| readCompletion(receipt.completion_path)?.exit_code !== receipt.exit_code)
|
|
1149
1307
|
return false;
|
|
1150
1308
|
const stored = readReceipt(receipt.receipt_path);
|
|
@@ -1156,16 +1314,24 @@ function checkReceiptEvidenceValidForMode(receipt, mode) {
|
|
|
1156
1314
|
// Gate/ref labels may be rebound for explicit deterministic reuse. Physical
|
|
1157
1315
|
// execution identity and settled SQL evidence must never be rebound from a
|
|
1158
1316
|
// mutable receipt file, including after recovery relocates the RUN home.
|
|
1159
|
-
const fields = ["id", "scope", "work_id", "command", "input_hash", "verification_epoch", "status", "exit_code",
|
|
1317
|
+
const fields = ["fingerprint_scope", "execution_contract_hash", "id", "scope", "work_id", "command", "input_hash", "verification_epoch", "status", "exit_code",
|
|
1160
1318
|
"workspace_fingerprint", "before_fingerprint", "after_fingerprint", "profile_hash", "started_at", "finished_at"];
|
|
1161
|
-
if (
|
|
1162
|
-
|
|
1319
|
+
if (JSON.stringify(stored.input_manifest ?? null) !== JSON.stringify(receipt.input_manifest ?? null))
|
|
1320
|
+
return false;
|
|
1321
|
+
if (fields.some(field => (stored[field] ?? null) !== (receipt[field] ?? null)) || !Array.isArray(stored.artifacts)
|
|
1322
|
+
|| !Array.isArray(stored.mutation_paths) || !isDeepStrictEqual(stored.mutation_paths, receipt.mutation_paths)
|
|
1163
1323
|
|| JSON.stringify(stored.required_artifacts) !== JSON.stringify(receipt.required_artifacts)
|
|
1164
1324
|
|| JSON.stringify(stored.inputs) !== JSON.stringify(receipt.inputs))
|
|
1165
1325
|
return false;
|
|
1166
|
-
const
|
|
1326
|
+
const retainedProof = resolvedReceiptArtifacts(receipt.receipt_path, stored.artifacts, receipt);
|
|
1327
|
+
const retained = retainedProof.artifacts;
|
|
1167
1328
|
if (JSON.stringify(retained) !== JSON.stringify(receipt.artifacts))
|
|
1168
1329
|
return false;
|
|
1330
|
+
// Receipt annotations are not execution authority. Compare the SQL-owned
|
|
1331
|
+
// failures with the independently hash/binding-checked engine sidecar.
|
|
1332
|
+
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"));
|
|
1333
|
+
if (hasEngineOutcome && !isDeepStrictEqual(retainedProof.execution_failures ?? [], receipt.execution_failures ?? []))
|
|
1334
|
+
return false;
|
|
1169
1335
|
const root = path.join(path.dirname(receipt.receipt_path), "artifacts");
|
|
1170
1336
|
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 })))
|
|
1171
1337
|
return false;
|
|
@@ -275,7 +275,11 @@ export async function continueProviderCapacity(input) {
|
|
|
275
275
|
const seen = await observe(() => input.inspectSession(turnId));
|
|
276
276
|
input.signal?.throwIfAborted();
|
|
277
277
|
input.assertCurrent();
|
|
278
|
-
|
|
278
|
+
// An unloaded exact-Turn snapshot is not conflicting tool evidence.
|
|
279
|
+
// Preserve the bound failure receipt, but never hide a pending item.
|
|
280
|
+
const freshItems = seen.native_turn_items;
|
|
281
|
+
const items = (seen.terminal_turn_id === turnId && seen.terminal_status === "failed"
|
|
282
|
+
&& !(freshItems?.observed === false && freshItems.pending !== true) ? freshItems : confirmed?.native_turn_items);
|
|
279
283
|
return capacityReadiness(seen, input.sessionId, operationId, turnId, items, input.allowSettledDescendants);
|
|
280
284
|
};
|
|
281
285
|
const ready = await readiness();
|
|
@@ -8,7 +8,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
|
assertSourceRepairEligible(input.failures);
|
|
11
|
-
const causes = input.failures.map(receipt => ({ id: receipt.id, epoch: receipt.verification_epoch, fingerprint: receipt.workspace_fingerprint })).sort((a, b) => a.id.localeCompare(b.id));
|
|
11
|
+
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
12
|
return ensureRepairIntent(context, { ...input, causes: { receipts: causes, origins: [...(input.origins ?? [])].sort() } }, create);
|
|
13
13
|
}
|
|
14
14
|
export function ensureRepairIntent(context, input, create) {
|