@deksden-com/dd-flow-cli 0.9.0-beta.1 → 0.9.0-beta.100
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 +561 -0
- package/README.md +68 -0
- package/dist/build-info.json +8 -8
- package/dist/cli/command-inputs.js +305 -0
- package/dist/cli/help.js +91 -15
- package/dist/cli/hook-ingress.js +82 -0
- package/dist/cli/input-preparation.js +116 -0
- package/dist/cli/run-cli.js +1506 -334
- package/dist/cli.js +6 -2
- package/dist/harness-runtime/DD-ZCODE.md +99 -0
- package/dist/harness-runtime/bin/dd-agy.mjs +39 -0
- package/dist/harness-runtime/bin/dd-codex.mjs +25 -0
- package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
- package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
- package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
- package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +215 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +449 -0
- package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +146 -0
- package/dist/harness-runtime/lib/dd-codex.mjs +562 -0
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
- package/dist/harness-runtime/lib/dd-droid.mjs +468 -0
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +300 -0
- package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +217 -0
- package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +644 -0
- package/dist/harness-runtime/lib/dd-zcode.mjs +941 -0
- package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
- package/dist/harness-runtime/lib/delegation-instructions.mjs +130 -0
- package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
- package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
- package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
- package/dist/harness-runtime/lib/managed-daemon.mjs +267 -0
- package/dist/harness-runtime/lib/model-observations.mjs +77 -0
- package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
- package/dist/harness-runtime/lib/native-hook-command.mjs +34 -0
- package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
- package/dist/harness-runtime/lib/operation-context.mjs +5 -0
- package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
- package/dist/harness-runtime/lib/process-json.mjs +70 -0
- package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
- package/dist/harness-runtime/lib/runner-events.mjs +200 -0
- package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
- package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
- package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
- package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
- package/dist/harness-runtime/lib/zcode-usage.mjs +54 -0
- package/dist/runtime/context.js +3 -1
- package/dist/schemas/agent-profile.schema.json +1 -1
- package/dist/schemas/code-review-result.schema.json +2 -2
- package/dist/schemas/code-work-batch.schema.json +4 -3
- package/dist/schemas/code-work-result.schema.json +4 -4
- package/dist/schemas/harness-config.schema.json +23 -0
- package/dist/schemas/plan-review-decision.schema.json +1 -1
- package/dist/schemas/plan-review-result.schema.json +2 -2
- package/dist/schemas/run-control-receipt.schema.json +99 -0
- package/dist/schemas/run-control-request.schema.json +36 -0
- package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
- package/dist/services/canon.js +11 -8
- package/dist/services/cleanup.js +75 -21
- package/dist/services/cli-operation-classifier.js +22 -24
- package/dist/services/code-checks.js +676 -79
- package/dist/services/codex-hook-delivery.js +28 -0
- package/dist/services/command-context.js +80 -0
- package/dist/services/config.js +11 -8
- package/dist/services/continuation-outcome.js +20 -0
- package/dist/services/controller-fanout.js +134 -0
- package/dist/services/dashboard.js +103 -31
- package/dist/services/engines.js +125 -80
- package/dist/services/eval-snapshots.js +838 -74
- package/dist/services/execution-policy.js +170 -0
- package/dist/services/external-work-launch.js +110 -0
- package/dist/services/harness-adapter.js +147 -24
- package/dist/services/harness-config.js +68 -0
- package/dist/services/hooks.js +609 -310
- package/dist/services/lanes.js +62 -53
- package/dist/services/lifecycle-command.js +103 -17
- package/dist/services/lifecycle-contract.js +30 -0
- package/dist/services/lifecycle-invocations.js +1128 -0
- package/dist/services/managed-daemon-binding.js +38 -0
- package/dist/services/managed-processes.js +425 -0
- package/dist/services/merge-queue.js +179 -104
- package/dist/services/merge-server.js +78 -27
- package/dist/services/migrations.js +14 -9
- package/dist/services/native-daemon-history.js +49 -0
- package/dist/services/native-session-control.js +39 -0
- package/dist/services/plan-runtime.js +19 -8
- package/dist/services/plans.js +28 -25
- package/dist/services/portable-refs.js +57 -0
- package/dist/services/projects.js +18 -5
- package/dist/services/prompts.js +27 -18
- package/dist/services/protocols.js +64 -20
- package/dist/services/recovery-observation-budget.js +61 -0
- package/dist/services/recovery-snapshot-database.js +107 -0
- package/dist/services/repair-intents.js +40 -0
- package/dist/services/run-control-receipt.js +56 -0
- package/dist/services/run-control-worker.js +363 -0
- package/dist/services/run-control.js +876 -0
- package/dist/services/run-controller-adapter.js +222 -0
- package/dist/services/run-controller-capture.js +136 -0
- package/dist/services/run-controller-process.js +189 -0
- package/dist/services/run-controller-recovery.js +222 -0
- package/dist/services/run-controller-state.js +36 -0
- package/dist/services/run-controller.js +803 -0
- package/dist/services/run-engine-bindings.js +20 -62
- package/dist/services/run-fork.js +138 -0
- package/dist/services/run-observations.js +112 -0
- package/dist/services/run-projection.js +10 -8
- package/dist/services/run-recovery-runtime.js +69 -0
- package/dist/services/run-recovery.js +343 -0
- package/dist/services/runs.js +437 -81
- package/dist/services/runtime-budget.js +439 -0
- package/dist/services/runtime-command.js +43 -0
- package/dist/services/runtime-scope-capture.js +50 -0
- package/dist/services/runtime-scope-control.js +450 -0
- package/dist/services/runtime-scope-resume.js +565 -0
- package/dist/services/runtime-scope-stop.js +99 -0
- package/dist/services/runtime-scope-worker.js +243 -0
- package/dist/services/runtime-service.js +97 -0
- package/dist/services/schema-validation.js +21 -19
- package/dist/services/session-identity.js +19 -0
- package/dist/services/sessions.js +61 -80
- package/dist/services/stage-blocker.js +17 -7
- package/dist/services/stage-context.js +44 -18
- package/dist/services/stage-lifecycle.js +112 -108
- package/dist/services/stage-pause.js +117 -73
- package/dist/services/stage-work-graph.js +35 -0
- package/dist/services/usage.js +108 -132
- package/dist/services/vnext-code-review.js +245 -98
- package/dist/services/vnext-code.js +355 -257
- package/dist/services/vnext-execution-profile.js +5 -3
- package/dist/services/vnext-fanout.js +166 -26
- package/dist/services/vnext-merge.js +413 -147
- package/dist/services/vnext-plan-review.js +182 -109
- package/dist/services/vnext-plan.js +199 -82
- package/dist/services/vnext-protocolize.js +133 -60
- package/dist/services/vnext-specify.js +94 -71
- package/dist/services/work-registry.js +813 -160
- package/dist/services/workspace-bootstrap.js +76 -0
- package/dist/services/worktrees.js +85 -35
- package/dist/shared/entity-references.js +8 -0
- package/dist/shared/errors.js +12 -0
- package/dist/storage/database.js +569 -16
- package/dist/storage/paths.js +17 -4
- package/dist/storage/work-references.js +23 -0
- package/dist/storage/writer-contract.js +77 -0
- package/dist/storage/writer-migration.js +102 -0
- package/package.json +24 -13
- package/tools/audit-runtime-fix-boundaries.mjs +96 -0
- package/tools/repair-paused-run-status.mjs +59 -0
|
@@ -2,56 +2,89 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import { spawn, spawnSync } from "node:child_process";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { parse } from "shell-quote";
|
|
6
|
+
import { assertRunMutationAllowed } from "./run-recovery.js";
|
|
5
7
|
import { AppError } from "../shared/errors.js";
|
|
8
|
+
import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessStatus, managedProcessRuntimeHome, managedOwnerIsAlive, processTreeIsAlive, registerManagedProcess, reservePorts } from "./managed-processes.js";
|
|
9
|
+
import { admitRuntimeCheck, runtimeBudgetForRun } from "./runtime-budget.js";
|
|
6
10
|
const profileRelativePath = path.join(".memory-bank", "spec", "engineering", "code-check-profile.json");
|
|
7
|
-
|
|
11
|
+
// Receipt reservation precedes child spawn so an interrupted CLI leaves an
|
|
12
|
+
// auditable attempt. A second caller must not mistake that small handoff window
|
|
13
|
+
// for a dead check and overwrite its outcome.
|
|
14
|
+
const receiptStartingGraceMs = 30_000;
|
|
15
|
+
export function codeExecutionEnvironment(workspaceRoot) {
|
|
16
|
+
const suffix = crypto.createHash("sha256").update(path.resolve(workspaceRoot)).digest("hex").slice(0, 12);
|
|
17
|
+
// Managed checks retain the launcher's executable resolution. A login shell
|
|
18
|
+
// or NODE_PATH can silently replace it, including with another architecture.
|
|
19
|
+
const environment = { ...process.env, DD_FLOW_LOCAL_DATABASE_SUFFIX: suffix };
|
|
20
|
+
delete environment.NODE_PATH;
|
|
21
|
+
return environment;
|
|
22
|
+
}
|
|
23
|
+
export function workspaceOperationId(workspaceRoot) { return `workspace:${crypto.createHash("sha256").update(path.resolve(workspaceRoot)).digest("hex")}`; }
|
|
8
24
|
export function readCodeCheckProfile(workspaceRoot) {
|
|
9
25
|
const file = path.join(workspaceRoot, profileRelativePath);
|
|
10
26
|
if (!fs.existsSync(file))
|
|
11
27
|
return { profile: null, hash: null, file };
|
|
12
28
|
const bytes = fs.readFileSync(file);
|
|
13
29
|
const value = JSON.parse(bytes.toString("utf8"));
|
|
14
|
-
if (value.schema_id !== "dd-flow/code-check-profile@
|
|
15
|
-
throw new AppError("invalid_code_check_profile", "CODE check profile has an unsupported schema", 2, { file, schema_id: value.schema_id ?? null, required: "dd-flow/code-check-profile@
|
|
16
|
-
if (!isStringRecord(value.aliases) || !isStringRecord(value.require_alias_for) || !isGateRecord(value.mandatory_by_gate))
|
|
17
|
-
throw new AppError("invalid_code_check_profile", "CODE check profile aliases, require_alias_for
|
|
30
|
+
if (value.schema_id !== "dd-flow/code-check-profile@6")
|
|
31
|
+
throw new AppError("invalid_code_check_profile", "CODE check profile has an unsupported schema", 2, { file, schema_id: value.schema_id ?? null, required: "dd-flow/code-check-profile@6" });
|
|
32
|
+
if (!isStringRecord(value.aliases) || !isStringRecord(value.require_alias_for) || !isGateRecord(value.mandatory_by_gate) || !isPortAliasRecord(value.ports_by_alias) || !isInputAliasRecord(workspaceRoot, value.inputs_by_alias))
|
|
33
|
+
throw new AppError("invalid_code_check_profile", "CODE check profile aliases, require_alias_for, mandatory_by_gate, ports_by_alias or inputs_by_alias are invalid", 2, { file });
|
|
18
34
|
for (const [gate, aliases] of Object.entries(value.mandatory_by_gate))
|
|
19
35
|
for (const alias of aliases ?? [])
|
|
20
36
|
if (!value.aliases[alias])
|
|
21
37
|
throw new AppError("invalid_code_check_profile", "A mandatory gate references an unknown alias", 2, { file, gate, alias });
|
|
38
|
+
for (const alias of Object.keys(value.ports_by_alias ?? {}))
|
|
39
|
+
if (!value.aliases[alias])
|
|
40
|
+
throw new AppError("invalid_code_check_profile", "A port declaration references an unknown alias", 2, { file, alias });
|
|
41
|
+
for (const alias of Object.keys(value.inputs_by_alias ?? {}))
|
|
42
|
+
if (!value.aliases[alias])
|
|
43
|
+
throw new AppError("invalid_code_check_profile", "An input declaration references an unknown alias", 2, { file, alias });
|
|
22
44
|
return { profile: value, hash: crypto.createHash("sha256").update(bytes).digest("hex"), file };
|
|
23
45
|
}
|
|
24
46
|
export function validateCodeCheckCommands(workspaceRoot, commands) {
|
|
25
47
|
const { profile, file } = readCodeCheckProfile(workspaceRoot);
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
48
|
+
return commands.map((command) => {
|
|
49
|
+
if (command.startsWith("@check/")) {
|
|
50
|
+
if (!profile?.aliases[command]?.trim())
|
|
51
|
+
throw new AppError("unknown_code_check_alias", "CODE check alias is not defined by the project profile", 2, { alias: command, file });
|
|
52
|
+
return command;
|
|
53
|
+
}
|
|
54
|
+
for (const [prefix, alias] of Object.entries(profile?.require_alias_for ?? {}))
|
|
55
|
+
if (command === prefix || command.startsWith(`${prefix} `))
|
|
56
|
+
throw new AppError("raw_code_check_forbidden", "CODE check must use the project alias instead of a raw guarded command", 2, { command, required_alias: alias, file });
|
|
31
57
|
return command;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
export function resolveCodeCheckCommands(workspaceRoot, runId, commands) {
|
|
61
|
+
const validated = validateCodeCheckCommands(workspaceRoot, commands);
|
|
62
|
+
const { profile } = readCodeCheckProfile(workspaceRoot);
|
|
63
|
+
// `{run_id}` is a declaration-level placeholder, not an alias feature.
|
|
64
|
+
// Plans may use it in a focused raw command as well as in project aliases.
|
|
65
|
+
return validated.map((command) => (command.startsWith("@check/") ? profile.aliases[command] : command).replaceAll("{run_id}", runId));
|
|
35
66
|
}
|
|
36
|
-
export function resolveCodeCheckCommands(workspaceRoot, runId, commands) { const validated = validateCodeCheckCommands(workspaceRoot, commands); const { profile } = readCodeCheckProfile(workspaceRoot); return validated.map((command) => command.startsWith("@check/") ? profile.aliases[command].replaceAll("{run_id}", runId) : command); }
|
|
37
67
|
export function projectPolicyCheckDeclarations(workspaceRoot, gate) {
|
|
38
68
|
const { profile } = readCodeCheckProfile(workspaceRoot);
|
|
39
|
-
return (profile?.mandatory_by_gate[gate] ?? []).map((alias) => { const ref = `POLICY/${gate}/${alias.slice(7).replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "").toLowerCase()}`; return { id: ref, canonical_ref: ref, source: "project_policy", command: alias, purpose: `Mandatory project ${gate} gate.`, run_at: gate, availability: "available" }; });
|
|
69
|
+
return (profile?.mandatory_by_gate[gate] ?? []).map((alias) => { const ref = `POLICY/${gate}/${alias.slice(7).replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "").toLowerCase()}`; return { id: ref, canonical_ref: ref, source: "project_policy", command: alias, purpose: `Mandatory project ${gate} gate.`, run_at: gate, availability: "available", ...(profile?.ports_by_alias?.[alias] ? { ports: profile.ports_by_alias[alias] } : {}), ...(profile?.inputs_by_alias?.[alias] ? { inputs: profile.inputs_by_alias[alias] } : {}) }; });
|
|
40
70
|
}
|
|
41
71
|
export function effectiveCheckDeclarations(workspaceRoot, declared, gates) { return gates.flatMap((gate) => [...declared.filter((check) => check.run_at === gate), ...projectPolicyCheckDeclarations(workspaceRoot, gate)]); }
|
|
42
72
|
export function aggregateCheckDeclarations(workspaceRoot) { return projectPolicyCheckDeclarations(workspaceRoot, "code"); }
|
|
43
|
-
export function finalCodeCheckDeclarations(workspaceRoot, declared) { return effectiveCheckDeclarations(workspaceRoot, declared, ["code", "readiness"]); }
|
|
73
|
+
export function finalCodeCheckDeclarations(workspaceRoot, declared) { return effectiveCheckDeclarations(workspaceRoot, declared, ["work", "code", "readiness"]); }
|
|
44
74
|
export function checksForRunAt(checks, runAt) { return checks.filter((check) => check.run_at === runAt); }
|
|
45
75
|
export function validateCheckPlacement(workspaceRoot, checks) { const { profile } = readCodeCheckProfile(workspaceRoot); const aggregateCommands = new Set(projectPolicyCheckDeclarations(workspaceRoot, "code").flatMap((check) => [check.command, profile?.aliases[check.command] ?? check.command])); for (const check of checks)
|
|
46
76
|
if (check.run_at === "work" && aggregateCommands.has(check.command))
|
|
47
77
|
throw new AppError("aggregate_check_requires_code_gate", "A project aggregate check must run at CODE or readiness, not inside one scoped Work", 2, { check_id: check.id, command: check.command, run_at: check.run_at }); }
|
|
48
78
|
export function validateCheckDeclaration(workspaceRoot, check) {
|
|
49
|
-
if (
|
|
50
|
-
|
|
79
|
+
if (!["work", "code", "readiness", "merge", "release"].includes(check.run_at))
|
|
80
|
+
throw new AppError("unsupported_check_gate", "Check requires an autonomous executable gate; replan the incompatible declaration", 2, { check_id: check.id, run_at: check.run_at });
|
|
51
81
|
const { profile } = readCodeCheckProfile(workspaceRoot);
|
|
52
82
|
if (check.availability === "planned" && check.command.startsWith("@check/") && !profile?.aliases[check.command])
|
|
53
83
|
throw new AppError("planned_check_not_materialized", "A planned check alias was not materialized by its provider Work", 2, { check_id: check.id, command: check.command, provider: check.provided_by ?? null });
|
|
54
84
|
validateCodeCheckCommands(workspaceRoot, [check.command]);
|
|
85
|
+
if (check.availability === "available" && !check.command.startsWith("@check/"))
|
|
86
|
+
assertDirectCheckExecutable(workspaceRoot, check.command, check.id);
|
|
87
|
+
validateCheckInputs(workspaceRoot, check.inputs ?? []);
|
|
55
88
|
if (check.command.startsWith("@check/") && check.definition && profile?.aliases[check.command] !== check.definition)
|
|
56
89
|
throw new AppError("check_definition_drift", "The accepted check alias definition differs from the current project profile", 2, { check_id: check.id, alias: check.command, expected: check.definition, actual: profile?.aliases[check.command] ?? null });
|
|
57
90
|
if (check.availability !== "planned")
|
|
@@ -63,73 +96,539 @@ export function validateCheckDeclaration(workspaceRoot, check) {
|
|
|
63
96
|
if (!profile?.aliases[check.command])
|
|
64
97
|
throw new AppError("planned_check_not_materialized", "A planned check alias was not materialized by its provider Work", 2, { check_id: check.id, command: check.command, provider: check.provided_by ?? null });
|
|
65
98
|
}
|
|
99
|
+
/** Reject a plan-time typo or a fictitious "manual" command before it becomes
|
|
100
|
+
* a late shell exit. Compound shell programs remain valid: only a simple,
|
|
101
|
+
* directly named executable is preflighted here. */
|
|
102
|
+
function assertDirectCheckExecutable(workspaceRoot, command, checkId) {
|
|
103
|
+
const trimmed = command.trim();
|
|
104
|
+
// A shell operator or expansion makes the launch path dynamic. Do not
|
|
105
|
+
// guess there; this preflight is deliberately limited to a direct program
|
|
106
|
+
// with ordinary arguments and optional literal environment assignments.
|
|
107
|
+
if (/[|&;<>()`$\n\r~]/.test(trimmed))
|
|
108
|
+
return;
|
|
109
|
+
const parsed = parse(trimmed);
|
|
110
|
+
if (parsed.some(word => typeof word !== "string"))
|
|
111
|
+
return;
|
|
112
|
+
const words = parsed;
|
|
113
|
+
let index = 0;
|
|
114
|
+
const env = codeExecutionEnvironment(workspaceRoot);
|
|
115
|
+
while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[index] ?? "")) {
|
|
116
|
+
const assignment = words[index++];
|
|
117
|
+
const equals = assignment.indexOf("=");
|
|
118
|
+
env[assignment.slice(0, equals)] = assignment.slice(equals + 1);
|
|
119
|
+
}
|
|
120
|
+
const program = words[index];
|
|
121
|
+
if (!program)
|
|
122
|
+
return;
|
|
123
|
+
const probe = spawnSync("/bin/sh", ["-c", "command -v -- \"$1\" >/dev/null", "dd-flow-check", program], { cwd: workspaceRoot, env, encoding: "utf8", timeout: 5000 });
|
|
124
|
+
if (probe.status !== 0)
|
|
125
|
+
throw new AppError("check_command_unavailable", "Available CODE check names an executable that is unavailable in this environment", 2, { check_id: checkId, command, executable: program });
|
|
126
|
+
}
|
|
66
127
|
export async function runCodeChecks(context, input) {
|
|
128
|
+
reconcileUnfinishedChecks(context, input);
|
|
129
|
+
reconcileWorkspaceOwnership(context, input.workspaceRoot);
|
|
130
|
+
const gate = acquireWorkspaceGate(context, input);
|
|
131
|
+
try {
|
|
132
|
+
return await runCodeChecksOwned(context, input, gate.id);
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
finishManagedProcess(context, { id: gate.id, leaseToken: gate.lease_token, state: "stopped", reason: "workspace_gate_settled" });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
async function runCodeChecksOwned(context, input, workspaceGateId) {
|
|
139
|
+
const generation = assertRunMutationAllowed(context, input.projectId, input.runId);
|
|
67
140
|
for (const check of input.checks)
|
|
68
141
|
validateCheckDeclaration(input.workspaceRoot, check);
|
|
69
|
-
const { hash: profileHash } = readCodeCheckProfile(input.workspaceRoot);
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
142
|
+
const { hash: profileHash, profile } = readCodeCheckProfile(input.workspaceRoot);
|
|
143
|
+
if (input.expectedProfileHash !== undefined && input.expectedProfileHash !== profileHash)
|
|
144
|
+
throw new AppError("check_profile_drift", "The frozen check profile differs from the workspace profile", 1, { expected_profile_hash: input.expectedProfileHash, actual_profile_hash: profileHash });
|
|
145
|
+
const checks = input.checks.map((check) => { const ports = profile?.ports_by_alias?.[check.command]; const inputs = profile?.inputs_by_alias?.[check.command]; return { ...check, ...(check.ports || !ports ? {} : { ports }), ...(check.inputs || !inputs ? {} : { inputs }) }; });
|
|
146
|
+
const resolved = resolveCodeCheckCommands(input.workspaceRoot, input.runId, checks.map((check) => check.command));
|
|
147
|
+
assertConsistentDeclarations(checks, resolved);
|
|
148
|
+
const groups = deduplicate(checks, resolved), receipts = [];
|
|
149
|
+
const retryIds = [...new Set([...(input.retryReceiptId ? [input.retryReceiptId] : []), ...(input.retryReceiptIds ?? [])])];
|
|
150
|
+
const retries = new Map();
|
|
151
|
+
for (const receiptId of retryIds) {
|
|
152
|
+
const predecessor = validateFailedCheckRetry(context, { projectId: input.projectId, runId: input.runId, receiptId, declarations: checks, reason: input.retryReason });
|
|
153
|
+
const snapshot = workspaceState(input.workspaceRoot, [input.runHome]);
|
|
154
|
+
const group = groups.find(candidate => candidate.declarations.some(check => check.run_at === predecessor.gate)
|
|
155
|
+
&& checkInputHash(input.workspaceRoot, { ...candidate, gate: predecessor.gate }, profileHash, workspaceInputFingerprint(input.workspaceRoot, snapshot.files, candidate.inputs, [input.runHome])) === predecessor.input_hash);
|
|
156
|
+
if (!group || retries.has(group))
|
|
157
|
+
throw new AppError("retry_check_invalid", "Retry must match exactly one current execution contract", 2, { check_receipt_id: receiptId });
|
|
158
|
+
// Preserve the identity of a pre-deduplication failed execution when an
|
|
159
|
+
// upgraded RUN retries it; the new receipt still proves every grouped ref.
|
|
160
|
+
group.gate = predecessor.gate;
|
|
161
|
+
retries.set(group, receiptId);
|
|
162
|
+
}
|
|
73
163
|
for (let index = 0; index < groups.length; index += 1) {
|
|
74
164
|
const group = groups[index];
|
|
75
|
-
const
|
|
76
|
-
const localId = `RCP-${String(ordinal).padStart(3, "0")}`;
|
|
77
|
-
const id = `${input.workId ?? input.runId}/${localId}`;
|
|
78
|
-
const directory = path.join(input.runHome, input.artifactDir ?? "05-code", "checks", localId);
|
|
79
|
-
const evidenceDir = path.join(directory, "artifacts");
|
|
80
|
-
fs.mkdirSync(evidenceDir, { recursive: true });
|
|
81
|
-
const stdoutPath = path.join(directory, "stdout.log");
|
|
82
|
-
const stderrPath = path.join(directory, "stderr.log");
|
|
83
|
-
const receiptPath = path.join(directory, "receipt.json");
|
|
84
|
-
const before = workspaceState(input.workspaceRoot);
|
|
85
|
-
const inputHash = crypto.createHash("sha256").update(JSON.stringify({ gate: group.gate, command: group.command, environment: String(codeExecutionEnvironment(input.workspaceRoot).DD_FLOW_LOCAL_DATABASE_SUFFIX), required_artifacts: group.requiredArtifacts })).digest("hex");
|
|
86
|
-
const epoch = crypto.createHash("sha256").update(`${group.gate}\0${before.fingerprint}`).digest("hex");
|
|
165
|
+
const before = workspaceState(input.workspaceRoot, [input.runHome]);
|
|
87
166
|
const startedAt = context.now();
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
const
|
|
167
|
+
const inputFingerprint = workspaceInputFingerprint(input.workspaceRoot, before.files, group.inputs, [input.runHome]);
|
|
168
|
+
const inputHash = checkInputHash(input.workspaceRoot, group, profileHash, inputFingerprint);
|
|
169
|
+
const epoch = crypto.createHash("sha256").update(`${group.gate}\0${before.fingerprint}`).digest("hex");
|
|
170
|
+
const reservationInput = { ...input, retryReceiptIds: retries.has(group) ? [retries.get(group)] : [], generation, group, before, profileHash, inputHash, epoch, startedAt };
|
|
171
|
+
delete reservationInput.retryReceiptId;
|
|
172
|
+
const decision = reserveOrReuseReceipt(context, reservationInput);
|
|
173
|
+
if (decision.receipt) {
|
|
174
|
+
receipts.push(bindReceipt(decision.receipt, group));
|
|
175
|
+
input.progress?.(`check ${index + 1}/${groups.length} ${decision.receipt.status === "passed" ? "reused" : "requires repair"}: ${group.command}`);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const reserved = decision.reserved;
|
|
179
|
+
const { id, evidenceDir, stdoutPath, stderrPath, receiptPath, completionPath } = reserved;
|
|
180
|
+
let resources = {};
|
|
181
|
+
let result = { exitCode: null, error: "check did not start", aborted: true };
|
|
182
|
+
try {
|
|
183
|
+
fs.mkdirSync(evidenceDir, { recursive: true });
|
|
184
|
+
writeReceipt(receiptPath, receiptFor({ ...reserved, resources, result, status: "running", after: before, mutationPaths: [], artifacts: [] }));
|
|
185
|
+
const budget = runtimeBudgetForRun(context, input.projectId, input.runId);
|
|
186
|
+
const managed = registerManagedProcess(context, { kind: "check", ownerPid: process.pid, ownerId: `check:${id}`, operationId: workspaceOperationId(input.workspaceRoot), projectId: input.projectId, runId: input.runId, workId: input.workId ?? null, checkId: id, stdoutPath, stderrPath, metadata: { command: group.command, role: "check", workspace_root: input.workspaceRoot, workspace_gate_id: workspaceGateId, budget } });
|
|
187
|
+
const allocation = await reservePorts(context, { ownerId: managed.owner_id, processId: managed.id, names: group.ports });
|
|
188
|
+
resources = allocation.ports;
|
|
189
|
+
try {
|
|
190
|
+
input.progress?.(`check ${index + 1}/${groups.length} preparing: ${group.command}`);
|
|
191
|
+
assertRunMutationAllowed(context, input.projectId, input.runId, generation);
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
allocation.release();
|
|
195
|
+
finishManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, state: "failed", reason: "dispatch_fenced_before_spawn" });
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
const stdout = fs.openSync(stdoutPath, "a");
|
|
199
|
+
const stderr = fs.openSync(stderrPath, "a");
|
|
200
|
+
try {
|
|
201
|
+
result = await runCheck(group.command, input.workspaceRoot, { ...codeExecutionEnvironment(input.workspaceRoot), DD_FLOW_EVIDENCE_DIR: evidenceDir, DD_FLOW_CHECK_COMPLETION_FILE: completionPath, ...portEnvironment(resources) }, stdout, stderr, (elapsed) => input.progress?.(`check ${index + 1}/${groups.length} still running (${elapsed}s): ${group.command}`), (pid) => {
|
|
202
|
+
confirmManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, pid, processGroupId: process.platform === "win32" ? null : pid });
|
|
203
|
+
assertRunMutationAllowed(context, input.projectId, input.runId, generation);
|
|
204
|
+
if (budget)
|
|
205
|
+
admitRuntimeCheck(context, managed.id, managed.lease_token);
|
|
206
|
+
}, () => heartbeatManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token }));
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
fs.closeSync(stdout);
|
|
210
|
+
fs.closeSync(stderr);
|
|
211
|
+
}
|
|
212
|
+
const current = managedProcessStatus(context).find(item => item.id === managed.id);
|
|
213
|
+
if (processTreeIsAlive(current))
|
|
214
|
+
throw new AppError("check_in_progress", "The check shell exited but its process tree is still live; retain this receipt and its resources until settlement", 2, { process_id: managed.id, check_receipt_id: id });
|
|
215
|
+
allocation.release();
|
|
216
|
+
finishManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, state: result.aborted || result.exitCode !== 0 || Boolean(result.error) ? "failed" : "stopped", reason: result.error });
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
if (error instanceof AppError && error.code === "check_in_progress")
|
|
220
|
+
throw error;
|
|
221
|
+
result = { exitCode: null, error: error instanceof Error ? error.message : String(error), aborted: true };
|
|
222
|
+
}
|
|
223
|
+
const after = workspaceState(input.workspaceRoot, [input.runHome]);
|
|
94
224
|
const mutationPaths = changedPaths(before.files, after.files);
|
|
95
|
-
const artifacts =
|
|
96
|
-
if (
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
225
|
+
const artifacts = collectCheckArtifacts(evidenceDir, group.requiredArtifacts);
|
|
226
|
+
if (fs.existsSync(stderrPath)) {
|
|
227
|
+
if (!artifacts.complete)
|
|
228
|
+
fs.appendFileSync(stderrPath, `\nmissing required evidence artifacts: ${artifacts.missing.join(", ")}\n`);
|
|
229
|
+
if (result.error)
|
|
230
|
+
fs.appendFileSync(stderrPath, `\n${result.error}\n`);
|
|
231
|
+
if (mutationPaths.length)
|
|
232
|
+
fs.appendFileSync(stderrPath, `\ncheck mutated project workspace: ${mutationPaths.join(", ")}\n`);
|
|
233
|
+
}
|
|
102
234
|
const status = result.aborted ? "aborted" : result.exitCode === 0 && !result.error && artifacts.complete && mutationPaths.length === 0 ? "passed" : "failed";
|
|
103
|
-
const receipt = {
|
|
104
|
-
|
|
105
|
-
|
|
235
|
+
const receipt = receiptFor({ ...reserved, resources, result, status, after, mutationPaths, artifacts: artifacts.items });
|
|
236
|
+
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'", [receipt.status, receipt.exit_code, receipt.after_fingerprint, JSON.stringify(receipt.mutation_paths), JSON.stringify(receipt.artifacts), receipt.finished_at, id, input.projectId]);
|
|
237
|
+
writeReceipt(receiptPath, receipt);
|
|
106
238
|
receipts.push(receipt);
|
|
107
239
|
input.progress?.(`check ${index + 1}/${groups.length} ${status}: ${group.command}`);
|
|
108
240
|
}
|
|
241
|
+
if (receipts.every(receipt => receipt.status === "passed")) {
|
|
242
|
+
const current = workspaceState(input.workspaceRoot, [input.runHome]), { hash: currentProfileHash } = readCodeCheckProfile(input.workspaceRoot);
|
|
243
|
+
for (const receipt of receipts) {
|
|
244
|
+
const group = groups.find(candidate => candidate.declarations.some(check => receipt.check_refs.includes(checkRef(check))));
|
|
245
|
+
const currentHash = group ? checkInputHash(input.workspaceRoot, group, currentProfileHash, workspaceInputFingerprint(input.workspaceRoot, current.files, group.inputs, [input.runHome])) : null;
|
|
246
|
+
if (!group || !checkReceiptEvidenceValid(receipt) || receipt.input_hash !== currentHash)
|
|
247
|
+
throw new AppError("check_evidence_stale", "Check evidence changed before the gate could be accepted", 1, { check_receipt_id: receipt.id });
|
|
248
|
+
}
|
|
249
|
+
assertRunMutationAllowed(context, input.projectId, input.runId, generation);
|
|
250
|
+
}
|
|
109
251
|
return receipts;
|
|
110
252
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
253
|
+
/**
|
|
254
|
+
* A check receipt is durable even when its invoking CLI disappears. We never
|
|
255
|
+
* guess that an orphan passed: a live process blocks a second execution, and
|
|
256
|
+
* a dead/terminal process becomes an explicit aborted receipt that can retry.
|
|
257
|
+
*/
|
|
258
|
+
export function reconcileUnfinishedChecks(context, input) {
|
|
259
|
+
let where = input.workId ? "project_id = ? AND run_id = ? AND work_id = ? AND status = 'running'" : "project_id = ? AND run_id = ? AND status = 'running'";
|
|
260
|
+
const params = input.workId ? [input.projectId, input.runId, input.workId] : [input.projectId, input.runId];
|
|
261
|
+
if (input.receiptId) {
|
|
262
|
+
where += " AND id = ?";
|
|
263
|
+
params.push(input.receiptId);
|
|
264
|
+
}
|
|
265
|
+
const pending = context.db.all(`SELECT id, receipt_path, started_at FROM check_receipts WHERE ${where}`, params);
|
|
266
|
+
if (pending.length === 0)
|
|
267
|
+
return [];
|
|
268
|
+
const processes = managedProcessStatus(context);
|
|
269
|
+
const reconciled = [];
|
|
270
|
+
for (const pendingReceipt of pending) {
|
|
271
|
+
const process = processes.filter((item) => item.check_id === pendingReceipt.id && item.project_id === input.projectId && item.run_id === input.runId).at(-1);
|
|
272
|
+
const receipt = readReceipt(pendingReceipt.receipt_path);
|
|
273
|
+
const completion = receipt ? readCompletion(receipt.completion_path || path.join(path.dirname(pendingReceipt.receipt_path), "completion.json")) : null;
|
|
274
|
+
// A stale lease after host sleep is not permission to duplicate a living
|
|
275
|
+
// check. Its durable completion marker or explicit reconciliation decides
|
|
276
|
+
// the outcome before another invocation may start.
|
|
277
|
+
// A confirmed PID plus its birth time is sufficient to prove that this
|
|
278
|
+
// exact check writer is gone. Do not retain a dead receipt merely because
|
|
279
|
+
// older registrations lack the optional parent-owner identity.
|
|
280
|
+
const processProvenDead = Boolean(process?.pid && process.pid_started_at && !processTreeIsAlive(process));
|
|
281
|
+
if (process && !processProvenDead && (processTreeIsAlive(process) || (["starting", "running", "stopping", "orphaned"].includes(process.state) && managedOwnerIsAlive(process) !== false))) {
|
|
282
|
+
throw new AppError("check_in_progress", "A previous check invocation is still running", 1, { check_receipt_id: pendingReceipt.id, process_id: process.id, pid: process.pid });
|
|
283
|
+
}
|
|
284
|
+
if (!process && !completion && Date.now() - Date.parse(pendingReceipt.started_at) < receiptStartingGraceMs) {
|
|
285
|
+
throw new AppError("check_in_progress", "A previous check invocation is still initializing", 1, { check_receipt_id: pendingReceipt.id, initializing: true });
|
|
286
|
+
}
|
|
287
|
+
const after = workspaceState(input.workspaceRoot, input.runHome ? [input.runHome] : []);
|
|
288
|
+
const artifacts = receipt ? collectCheckArtifacts(path.join(path.dirname(receipt.completion_path || pendingReceipt.receipt_path), "artifacts"), receipt.required_artifacts ?? []) : { complete: false, missing: [], items: [] };
|
|
289
|
+
const workspaceUnchanged = receipt?.before_fingerprint === after.fingerprint;
|
|
290
|
+
const status = completion === null ? "aborted" : completion.exit_code === 0 && workspaceUnchanged && artifacts.complete ? "passed" : "failed";
|
|
291
|
+
const exitCode = completion?.exit_code ?? null;
|
|
292
|
+
const finishedAt = completion?.finished_at ?? context.now();
|
|
293
|
+
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, after.fingerprint, JSON.stringify(workspaceUnchanged ? [] : ["<workspace changed while detached check ran>"]), JSON.stringify(artifacts.items), finishedAt, pendingReceipt.id, input.projectId]);
|
|
294
|
+
if (process)
|
|
295
|
+
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" });
|
|
296
|
+
if (receipt) {
|
|
297
|
+
const repaired = { ...receipt, completion_path: receipt.completion_path || path.join(path.dirname(pendingReceipt.receipt_path), "completion.json"), required_artifacts: receipt.required_artifacts ?? [], status, exit_code: exitCode, after_fingerprint: after.fingerprint, mutation_paths: workspaceUnchanged ? [] : ["<workspace changed while detached check ran>"], artifacts: artifacts.items, finished_at: finishedAt };
|
|
298
|
+
writeReceipt(pendingReceipt.receipt_path, repaired);
|
|
299
|
+
reconciled.push(repaired);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return reconciled;
|
|
303
|
+
}
|
|
304
|
+
function reserveOrReuseReceipt(context, input) {
|
|
305
|
+
context.db.beginWriteTransaction();
|
|
306
|
+
try {
|
|
307
|
+
assertRunMutationAllowed(context, input.projectId, input.runId, input.generation);
|
|
308
|
+
const prior = latestApplicableReceipt(context, input);
|
|
309
|
+
if (prior?.status === "running")
|
|
310
|
+
throw new AppError("check_in_progress", "A matching check invocation is still running", 1, { check_receipt_id: prior.id });
|
|
311
|
+
const retryIds = new Set([...(input.retryReceiptId ? [input.retryReceiptId] : []), ...(input.retryReceiptIds ?? [])]);
|
|
312
|
+
if (retryIds.size > 1)
|
|
313
|
+
throw new AppError("retry_check_invalid", "A check execution accepts exactly one retry predecessor", 2);
|
|
314
|
+
const retryId = [...retryIds][0];
|
|
315
|
+
if (retryId) {
|
|
316
|
+
if (!input.retryReason?.trim())
|
|
317
|
+
throw new AppError("retry_check_invalid", "A check retry requires the restored-condition reason", 2, { check_receipt_id: retryId });
|
|
318
|
+
const receipts = checkReceipts(context, { projectId: input.projectId, runId: input.runId });
|
|
319
|
+
const predecessor = receipts.find(receipt => receipt.id === retryId);
|
|
320
|
+
if (!predecessor || !["failed", "aborted"].includes(predecessor.status) || predecessor.input_hash !== input.inputHash || predecessor.profile_hash !== input.profileHash || predecessor.command !== input.group.command || predecessor.gate !== input.group.gate)
|
|
321
|
+
throw new AppError("retry_check_invalid", "Retry predecessor does not match this exact check contract", 2, { check_receipt_id: retryId });
|
|
322
|
+
const successor = receipts.find(receipt => receipt.retry_of_receipt_id === retryId && receipt.input_hash === input.inputHash && receipt.profile_hash === input.profileHash);
|
|
323
|
+
if (prior?.id !== (successor?.id ?? predecessor.id))
|
|
324
|
+
throw new AppError("retry_check_invalid", "This retry was superseded by a newer check outcome; inspect the latest receipt", 2, { check_receipt_id: retryId, latest_check_receipt_id: prior?.id ?? null, latest_status: prior?.status ?? null });
|
|
325
|
+
if (successor?.status === "running")
|
|
326
|
+
throw new AppError("check_in_progress", "The requested retry is already running", 1, { check_receipt_id: successor.id, retry_of_receipt_id: retryId });
|
|
327
|
+
if (successor) {
|
|
328
|
+
if (successor.status === "passed" && checkReceiptEvidenceValid(successor)) {
|
|
329
|
+
context.db.exec("COMMIT");
|
|
330
|
+
return { receipt: successor };
|
|
331
|
+
}
|
|
332
|
+
throw new AppError("retry_check_invalid", "The requested retry already has a terminal outcome; choose its failed receipt for another retry", 2, { check_receipt_id: successor.id, retry_of_receipt_id: retryId, status: successor.status });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
// Only success is reusable evidence. A new finish attempt must execute a
|
|
336
|
+
// failed check again: repairing dependencies/services need not change any
|
|
337
|
+
// tracked input. Lifecycle invocation replay owns command idempotency;
|
|
338
|
+
// the running-receipt guard above still prevents concurrent execution.
|
|
339
|
+
if (prior?.status === "passed" && !retryIds.has(prior.id) && input.group.reusable && checkReceiptEvidenceValid(prior)) {
|
|
340
|
+
context.db.exec("COMMIT");
|
|
341
|
+
return { receipt: prior };
|
|
342
|
+
}
|
|
343
|
+
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);
|
|
344
|
+
let localId = `RCP-${String(ordinal).padStart(3, "0")}`, id = `${input.workId ?? input.runId}/${localId}`;
|
|
345
|
+
while (context.db.get("SELECT 1 FROM check_receipts WHERE id = ?", [id])) {
|
|
346
|
+
ordinal += 1;
|
|
347
|
+
localId = `RCP-${String(ordinal).padStart(3, "0")}`;
|
|
348
|
+
id = `${input.workId ?? input.runId}/${localId}`;
|
|
349
|
+
}
|
|
350
|
+
const directory = path.join(input.runHome, input.artifactDir ?? "05-code", "checks", localId);
|
|
351
|
+
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 };
|
|
352
|
+
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]);
|
|
353
|
+
context.db.exec("COMMIT");
|
|
354
|
+
return { reserved };
|
|
355
|
+
}
|
|
356
|
+
catch (error) {
|
|
357
|
+
context.db.exec("ROLLBACK");
|
|
358
|
+
throw error;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
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, 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, workspace_fingerprint: input.before.fingerprint, before_fingerprint: input.before.fingerprint, after_fingerprint: 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() }; }
|
|
362
|
+
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); }
|
|
363
|
+
function readReceipt(file) { try {
|
|
364
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
return null;
|
|
368
|
+
} }
|
|
369
|
+
function readCompletion(file) { try {
|
|
370
|
+
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
371
|
+
const done = value;
|
|
372
|
+
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;
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
return null;
|
|
376
|
+
} }
|
|
377
|
+
/** Validate an explicit retry; the caller still executes a fresh real gate and retains a new receipt. */
|
|
378
|
+
export function validateFailedCheckRetry(context, input) {
|
|
379
|
+
if (!input.reason?.trim())
|
|
380
|
+
throw new AppError("retry_check_invalid", "--retry-check requires --reason describing the restored environment", 2);
|
|
381
|
+
const receipt = checkReceipts(context, { projectId: input.projectId, runId: input.runId }).find((item) => item.id === input.receiptId);
|
|
382
|
+
if (!receipt || !["failed", "aborted"].includes(receipt.status))
|
|
383
|
+
throw new AppError("retry_check_invalid", "--retry-check must name a failed or aborted receipt in this RUN", 2, { check_receipt_id: input.receiptId });
|
|
384
|
+
const declared = new Set(input.declarations.flatMap((check) => [check.id, checkRef(check)]));
|
|
385
|
+
if (!receipt.check_refs.some((ref) => declared.has(ref)))
|
|
386
|
+
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 });
|
|
387
|
+
return receipt;
|
|
388
|
+
}
|
|
114
389
|
export function checkReceipts(context, input) {
|
|
115
390
|
const where = input.workId ? "project_id = ? AND run_id = ? AND work_id = ?" : "project_id = ? AND run_id = ?";
|
|
116
391
|
const params = input.workId ? [input.projectId, input.runId, input.workId] : [input.projectId, input.runId];
|
|
117
|
-
return context.db.all(`SELECT id, declaration_id, check_refs_json, gate, scope, work_id, command, input_hash, verification_epoch, 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 FROM check_receipts WHERE ${where} ORDER BY
|
|
392
|
+
return context.db.all(`SELECT id, declaration_id, check_refs_json, gate, scope, work_id, command, input_hash, verification_epoch, 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 FROM check_receipts WHERE ${where} ORDER BY reservation_sequence, rowid`, params).map((row) => ({ ...row, 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)) }));
|
|
393
|
+
}
|
|
394
|
+
/** File bytes remain immutable; only the reader projects their current location. */
|
|
395
|
+
function resolvedReceiptArtifacts(receiptPath, artifacts) {
|
|
396
|
+
let original = {};
|
|
397
|
+
try {
|
|
398
|
+
original = JSON.parse(fs.readFileSync(receiptPath, "utf8"));
|
|
399
|
+
}
|
|
400
|
+
catch { /* Historical missing receipts remain visibly incomplete. */ }
|
|
401
|
+
const currentRoot = path.dirname(receiptPath);
|
|
402
|
+
const oldRoot = original.receipt_path ? path.dirname(original.receipt_path) : currentRoot;
|
|
403
|
+
const relocate = (file) => {
|
|
404
|
+
const relative = path.relative(oldRoot, file);
|
|
405
|
+
return !path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`) ? path.join(currentRoot, relative) : file;
|
|
406
|
+
};
|
|
407
|
+
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) })) };
|
|
118
408
|
}
|
|
119
409
|
export function workspaceFingerprint(root) { return workspaceState(root).fingerprint; }
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
410
|
+
/** One NUL-aware source of truth for paths differing from HEAD. Rename/copy
|
|
411
|
+
* records carry a second path; retain both because the old side is a deletion. */
|
|
412
|
+
export function workspaceChangedPaths(root) {
|
|
413
|
+
const status = spawnSync("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
414
|
+
// A workspace can be intentionally gitless. Keep the same conservative
|
|
415
|
+
// file inventory used for fingerprints instead of making result validation
|
|
416
|
+
// fail before the work contract can report its actual error.
|
|
417
|
+
if (status.status === 128 && /not a git repository|outside a repository/i.test(status.stderr ?? ""))
|
|
418
|
+
return listFiles(root);
|
|
419
|
+
if (status.status !== 0 || status.error)
|
|
420
|
+
throw new AppError("workspace_status_failed", "Could not determine changed workspace paths", 1, { workspace_root: root, stderr: status.stderr || null, cause: status.error?.message ?? null });
|
|
421
|
+
const records = status.stdout.split("\0");
|
|
422
|
+
const paths = [];
|
|
423
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
424
|
+
const record = records[index];
|
|
425
|
+
if (!record)
|
|
426
|
+
continue;
|
|
427
|
+
if (record.length < 4 || record[2] !== " ")
|
|
428
|
+
throw new AppError("workspace_status_invalid", "Git returned an invalid porcelain-v1 record", 1, { workspace_root: root, record });
|
|
429
|
+
paths.push(normalizeWorkspacePath(root, record.slice(3)));
|
|
430
|
+
if (record[0] === "R" || record[0] === "C" || record[1] === "R" || record[1] === "C") {
|
|
431
|
+
const prior = records[++index];
|
|
432
|
+
if (!prior)
|
|
433
|
+
throw new AppError("workspace_status_invalid", "Git rename/copy record is missing its second path", 1, { workspace_root: root, record });
|
|
434
|
+
paths.push(normalizeWorkspacePath(root, prior));
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return [...new Set(paths)].sort();
|
|
438
|
+
}
|
|
439
|
+
/** Ignored machine state cannot be reported as a product change. Tracked paths
|
|
440
|
+
* remain eligible regardless of their directory name. */
|
|
441
|
+
export function ignoredWorkspacePaths(root, paths) {
|
|
442
|
+
const normalized = [...new Set(paths.map((item) => normalizeWorkspacePath(root, item)))];
|
|
443
|
+
if (!normalized.length)
|
|
444
|
+
return [];
|
|
445
|
+
const status = spawnSync("git", ["check-ignore", "-z", "--stdin"], { cwd: root, encoding: "utf8", input: `${normalized.join("\0")}\0`, maxBuffer: 64 * 1024 * 1024 });
|
|
446
|
+
// Projects may intentionally run without Git (the flow supports a local
|
|
447
|
+
// workspace identity independently from version control). In that case
|
|
448
|
+
// there is no ignore database to consult; a declared path is a product
|
|
449
|
+
// path, not an infrastructure path. Other Git failures remain fail-closed.
|
|
450
|
+
if (status.status === 128 && /not a git repository|outside a repository/i.test(status.stderr ?? ""))
|
|
451
|
+
return [];
|
|
452
|
+
if (![0, 1].includes(status.status ?? -1) || status.error)
|
|
453
|
+
throw new AppError("workspace_ignore_check_failed", "Could not classify reported workspace paths", 1, { workspace_root: root, stderr: status.stderr || null, cause: status.error?.message ?? null });
|
|
454
|
+
return [...new Set(status.stdout.split("\0").filter(Boolean).map((item) => normalizeWorkspacePath(root, item)))].sort();
|
|
455
|
+
}
|
|
456
|
+
export function normalizeWorkspacePath(root, value) {
|
|
457
|
+
if (typeof value !== "string" || !value || value.includes("\0") || path.isAbsolute(value))
|
|
458
|
+
throw new AppError("workspace_path_invalid", "Workspace path must be a non-empty relative path", 2, { path: value });
|
|
459
|
+
const resolvedRoot = path.resolve(root);
|
|
460
|
+
const resolved = path.resolve(resolvedRoot, value);
|
|
461
|
+
const relative = path.relative(resolvedRoot, resolved);
|
|
462
|
+
if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
|
|
463
|
+
throw new AppError("workspace_path_invalid", "Workspace path escapes its root", 2, { path: value, workspace_root: root });
|
|
464
|
+
return relative.split(path.sep).join("/");
|
|
465
|
+
}
|
|
466
|
+
function deduplicate(checks, commands) {
|
|
467
|
+
const groups = new Map();
|
|
468
|
+
checks.forEach((check, index) => {
|
|
469
|
+
const artifacts = [...(check.required_artifacts ?? [])].sort(), ports = [...(check.ports ?? [])].sort(), inputs = normalizedInputs(check.inputs ?? []);
|
|
470
|
+
const reusable = check.reuse === "deterministic";
|
|
471
|
+
// Stage labels identify obligations, not different processes in this gate.
|
|
472
|
+
// Cross-invocation reuse still requires the explicit deterministic contract.
|
|
473
|
+
const key = JSON.stringify([commands[index], artifacts, ports, inputs, reusable]);
|
|
474
|
+
const current = groups.get(key);
|
|
475
|
+
if (current)
|
|
476
|
+
current.declarations.push(check);
|
|
477
|
+
else
|
|
478
|
+
groups.set(key, { gate: check.run_at, command: commands[index], declarations: [check], requiredArtifacts: artifacts, ports, inputs, reusable });
|
|
479
|
+
});
|
|
480
|
+
return [...groups.values()].map(group => ({ ...group, gate: [...group.declarations].map(check => check.run_at).sort()[0], declarations: [...group.declarations].sort((left, right) => checkRef(left).localeCompare(checkRef(right))) }));
|
|
481
|
+
}
|
|
482
|
+
function workspaceState(root, ignoredRoots = []) {
|
|
483
|
+
const git = spawnSync("git", ["ls-files", "-co", "--exclude-standard", "-z"], { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
484
|
+
const ignored = ignoredRoots.map(entry => path.resolve(entry));
|
|
485
|
+
const listing = [...new Set(git.status === 0 ? git.stdout.split("\0").filter(Boolean) : listFiles(root))].sort();
|
|
486
|
+
const files = new Map(), hash = crypto.createHash("sha256");
|
|
487
|
+
for (const relative of listing) {
|
|
488
|
+
const file = path.resolve(root, relative);
|
|
489
|
+
if (ignored.some(ignoredRoot => file === ignoredRoot || file.startsWith(`${ignoredRoot}${path.sep}`)))
|
|
490
|
+
continue;
|
|
491
|
+
// Deleted tracked paths are absent content, both before and after commit.
|
|
492
|
+
// Do not mask other IO errors as an unchanged workspace.
|
|
493
|
+
let stat;
|
|
494
|
+
try {
|
|
495
|
+
stat = fs.lstatSync(file);
|
|
496
|
+
}
|
|
497
|
+
catch (error) {
|
|
498
|
+
if (error.code === "ENOENT")
|
|
499
|
+
continue;
|
|
500
|
+
throw error;
|
|
501
|
+
}
|
|
502
|
+
const content = stat.isSymbolicLink() ? Buffer.from(`symlink\0${fs.readlinkSync(file)}`) : stat.isFile() ? fs.readFileSync(file) : Buffer.from("<directory>");
|
|
503
|
+
const digest = crypto.createHash("sha256").update(content).digest("hex");
|
|
504
|
+
files.set(relative, digest);
|
|
505
|
+
hash.update(relative).update("\0").update(digest).update("\0");
|
|
506
|
+
}
|
|
507
|
+
return { fingerprint: hash.digest("hex"), files };
|
|
508
|
+
}
|
|
509
|
+
function workspaceInputFingerprint(root, files, inputs, ignoredRoots) {
|
|
510
|
+
const selected = new Map(inputs.length === 0 || inputs.includes(".") ? files : []);
|
|
511
|
+
for (const [file, digest] of explicitInputFiles(root, inputs.filter(input => input !== "."), ignoredRoots))
|
|
512
|
+
selected.set(file, digest);
|
|
513
|
+
const hash = crypto.createHash("sha256");
|
|
514
|
+
for (const [file, digest] of [...selected].sort(([left], [right]) => left.localeCompare(right)))
|
|
515
|
+
hash.update(file).update("\0").update(digest).update("\0");
|
|
516
|
+
return hash.digest("hex");
|
|
517
|
+
}
|
|
131
518
|
function changedPaths(before, after) { return [...new Set([...before.keys(), ...after.keys()])].filter((key) => before.get(key) !== after.get(key)).sort(); }
|
|
132
519
|
function checkRef(check) { return check.canonical_ref ?? check.id; }
|
|
520
|
+
function assertConsistentDeclarations(checks, commands) {
|
|
521
|
+
const seen = new Map();
|
|
522
|
+
for (const [index, check] of checks.entries()) {
|
|
523
|
+
const contract = JSON.stringify([check.run_at, commands[index], [...(check.required_artifacts ?? [])].sort(), [...(check.ports ?? [])].sort(), normalizedInputs(check.inputs ?? []), check.reuse ?? null]);
|
|
524
|
+
const ref = checkRef(check), prior = seen.get(ref);
|
|
525
|
+
if (prior && prior !== contract)
|
|
526
|
+
throw new AppError("check_declaration_conflict", "The same check reference has conflicting execution definitions", 2, { check_ref: ref });
|
|
527
|
+
seen.set(ref, contract);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
function environmentFingerprint(workspaceRoot) {
|
|
531
|
+
const environment = codeExecutionEnvironment(workspaceRoot);
|
|
532
|
+
return crypto.createHash("sha256").update(JSON.stringify(Object.entries(environment).filter(([, value]) => value !== undefined).sort(([left], [right]) => left.localeCompare(right)))).digest("hex");
|
|
533
|
+
}
|
|
534
|
+
function acquireWorkspaceGate(context, input) {
|
|
535
|
+
const operationId = workspaceOperationId(input.workspaceRoot);
|
|
536
|
+
const active = managedProcessStatus(context).find(item => item.operation_id === operationId && ["starting", "running", "stopping"].includes(item.state));
|
|
537
|
+
if (active)
|
|
538
|
+
throw new AppError("check_in_progress", "The workspace is owned by another bootstrap or check; observe it before starting a new check", 1, { process_id: active.id, operation_id: operationId });
|
|
539
|
+
return registerManagedProcess(context, { kind: "workspace-gate", ownerPid: process.pid, ownerId: `workspace-gate:${crypto.randomUUID()}`, operationId, uniqueActiveOperation: true, projectId: input.projectId, runId: input.runId, metadata: { workspace_root: input.workspaceRoot, role: "check_gate" } });
|
|
540
|
+
}
|
|
541
|
+
export function reconcileWorkspaceOwnership(context, workspaceRoot) {
|
|
542
|
+
const operationId = workspaceOperationId(workspaceRoot);
|
|
543
|
+
const active = managedProcessStatus(context).filter(item => item.operation_id === operationId && ["starting", "running", "stopping", "orphaned"].includes(item.state));
|
|
544
|
+
if (active.some(item => processTreeIsAlive(item) || managedOwnerIsAlive(item) !== false))
|
|
545
|
+
return;
|
|
546
|
+
// Registry IDs are host-wide, whereas receipts belong to one runtime DB.
|
|
547
|
+
// An unknown/foreign home must not be settled against this client's rows.
|
|
548
|
+
if (active.some(item => managedProcessRuntimeHome(item, context) !== context.ddFlowHome))
|
|
549
|
+
return;
|
|
550
|
+
for (const child of active.filter(item => item.kind !== "workspace-gate")) {
|
|
551
|
+
if (child.kind !== "check" || !child.project_id || !child.run_id || !child.check_id)
|
|
552
|
+
return;
|
|
553
|
+
const run = context.db.get("SELECT run_root FROM runs WHERE project_id = ? AND id = ?", [child.project_id, child.run_id]);
|
|
554
|
+
if (!run?.run_root || !context.db.get("SELECT 1 FROM check_receipts WHERE id = ? AND project_id = ? AND run_id = ?", [child.check_id, child.project_id, child.run_id]))
|
|
555
|
+
return;
|
|
556
|
+
reconcileUnfinishedChecks(context, { projectId: child.project_id, runId: child.run_id, workspaceRoot, runHome: run.run_root, receiptId: child.check_id });
|
|
557
|
+
// A crash may occur after receipt settlement but before registry settlement.
|
|
558
|
+
const current = managedProcessStatus(context).find(item => item.id === child.id);
|
|
559
|
+
if (!current || current.lease_token !== child.lease_token)
|
|
560
|
+
return;
|
|
561
|
+
if (!["stopped", "failed"].includes(current.state) && !finishManagedProcess(context, { id: child.id, leaseToken: child.lease_token, state: "stopped", reason: "workspace_check_owner_reconciled" }))
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
for (const gate of active.filter(item => item.kind === "workspace-gate"))
|
|
565
|
+
finishManagedProcess(context, { id: gate.id, leaseToken: gate.lease_token, state: "stopped", reason: "workspace_gate_owner_reconciled" });
|
|
566
|
+
}
|
|
567
|
+
function normalizedInputs(inputs) { for (const input of inputs)
|
|
568
|
+
validateCheckInputSyntax(input); return [...new Set(inputs.map((input) => { const normalized = path.posix.normalize(input.replaceAll("\\", "/")).replace(/^\.\/$/, ""); return normalized === "" || normalized === "." ? "." : normalized.replace(/\/$/, ""); }))].sort(); }
|
|
569
|
+
function validateCheckInputs(workspaceRoot, inputs) { const root = fs.realpathSync.native(workspaceRoot); for (const input of normalizedInputs(inputs)) {
|
|
570
|
+
if (input === ".git" || input.startsWith(".git/"))
|
|
571
|
+
throw new AppError("invalid_check_input", "Check inputs cannot include Git internals", 2, { input });
|
|
572
|
+
const candidate = path.resolve(workspaceRoot, input);
|
|
573
|
+
if (candidate !== workspaceRoot && !candidate.startsWith(`${workspaceRoot}${path.sep}`))
|
|
574
|
+
throw new AppError("invalid_check_input", "Check inputs must stay within the workspace", 2, { input });
|
|
575
|
+
if (fs.existsSync(candidate))
|
|
576
|
+
validateInputTree(root, candidate, input, new Set());
|
|
577
|
+
else
|
|
578
|
+
validateMissingInputParent(root, candidate, input);
|
|
579
|
+
} }
|
|
580
|
+
function validateCheckInputSyntax(input) { if (typeof input !== "string" || !input.trim() || path.isAbsolute(input) || input.split(/[\\/]/).includes(".."))
|
|
581
|
+
throw new AppError("invalid_check_input", "Check inputs must be non-empty relative workspace paths", 2, { input }); }
|
|
582
|
+
function validateMissingInputParent(root, candidate, input) { let parent = candidate; while (!fs.existsSync(parent))
|
|
583
|
+
parent = path.dirname(parent); const resolved = fs.realpathSync.native(parent); if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`))
|
|
584
|
+
throw new AppError("invalid_check_input", "Check input parent symlink escapes the workspace", 2, { input }); }
|
|
585
|
+
function validateInputTree(root, candidate, input, seen) {
|
|
586
|
+
const resolved = fs.realpathSync.native(candidate);
|
|
587
|
+
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`))
|
|
588
|
+
throw new AppError("invalid_check_input", "Check input symlink escapes the workspace", 2, { input });
|
|
589
|
+
if (seen.has(resolved))
|
|
590
|
+
throw new AppError("invalid_check_input", "Check input contains a symbolic-link cycle", 2, { input });
|
|
591
|
+
seen.add(resolved);
|
|
592
|
+
const stat = fs.statSync(candidate);
|
|
593
|
+
if (stat.isDirectory())
|
|
594
|
+
for (const entry of fs.readdirSync(candidate))
|
|
595
|
+
validateInputTree(root, path.join(candidate, entry), input, seen);
|
|
596
|
+
seen.delete(resolved);
|
|
597
|
+
}
|
|
598
|
+
function explicitInputFiles(root, inputs, ignoredRoots) {
|
|
599
|
+
const rootReal = fs.realpathSync.native(root), ignored = ignoredRoots.map(entry => path.resolve(entry)), result = new Map();
|
|
600
|
+
const visit = (file, relative, seen) => {
|
|
601
|
+
if (ignored.some(entry => file === entry || file.startsWith(`${entry}${path.sep}`)))
|
|
602
|
+
return;
|
|
603
|
+
if (!fs.existsSync(file)) {
|
|
604
|
+
result.set(relative, "<missing>");
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const resolved = fs.realpathSync.native(file);
|
|
608
|
+
if (resolved !== rootReal && !resolved.startsWith(`${rootReal}${path.sep}`))
|
|
609
|
+
throw new AppError("invalid_check_input", "Check input symlink escapes the workspace", 2, { input: relative });
|
|
610
|
+
if (seen.has(resolved))
|
|
611
|
+
throw new AppError("invalid_check_input", "Check input contains a symbolic-link cycle", 2, { input: relative });
|
|
612
|
+
const stat = fs.statSync(file);
|
|
613
|
+
if (stat.isDirectory()) {
|
|
614
|
+
seen.add(resolved);
|
|
615
|
+
for (const entry of fs.readdirSync(file).sort())
|
|
616
|
+
visit(path.join(file, entry), path.posix.join(relative, entry), seen);
|
|
617
|
+
seen.delete(resolved);
|
|
618
|
+
}
|
|
619
|
+
else if (stat.isFile())
|
|
620
|
+
result.set(relative, crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"));
|
|
621
|
+
};
|
|
622
|
+
for (const input of inputs)
|
|
623
|
+
visit(path.resolve(root, input), input, new Set());
|
|
624
|
+
return [...result.entries()];
|
|
625
|
+
}
|
|
626
|
+
function checkInputHash(workspaceRoot, group, profileHash, inputFingerprint) { return crypto.createHash("sha256").update(JSON.stringify({ workspace_root: path.resolve(workspaceRoot), gate: group.reusable ? null : group.gate, command: group.command, required_artifacts: group.requiredArtifacts, ports: group.ports, inputs: group.inputs, profile_hash: profileHash, environment: environmentFingerprint(workspaceRoot), input_fingerprint: inputFingerprint })).digest("hex"); }
|
|
627
|
+
function bindReceipt(receipt, group) { return { ...receipt, gate: group.gate, declaration_id: group.declarations[0].id, check_refs: group.declarations.map(checkRef), required_artifacts: group.requiredArtifacts, inputs: group.inputs }; }
|
|
628
|
+
function latestApplicableReceipt(context, input) { const candidates = checkReceipts(context, { projectId: input.projectId, runId: input.runId }).filter((receipt) => receipt.input_hash === input.inputHash && receipt.profile_hash === input.profileHash); return candidates.at(-1) ?? null; }
|
|
629
|
+
export function checkReceiptEvidenceValid(receipt) { if (receipt.status !== "passed" || receipt.exit_code !== 0 || receipt.before_fingerprint !== receipt.after_fingerprint || !fs.existsSync(receipt.completion_path) || readCompletion(receipt.completion_path)?.exit_code !== 0)
|
|
630
|
+
return false; const stored = readReceipt(receipt.receipt_path); if (!stored || stored.status !== "passed" || stored.input_hash !== receipt.input_hash || stored.profile_hash !== receipt.profile_hash)
|
|
631
|
+
return false; const artifacts = collectCheckArtifacts(path.join(path.dirname(receipt.receipt_path), "artifacts"), stored.required_artifacts ?? []); return artifacts.complete && JSON.stringify(artifacts.items) === JSON.stringify(stored.artifacts ?? []); }
|
|
133
632
|
function parseArray(value, fallback) { try {
|
|
134
633
|
const parsed = JSON.parse(value);
|
|
135
634
|
return Array.isArray(parsed) && parsed.every((item) => typeof item === "string") ? parsed : fallback;
|
|
@@ -138,24 +637,122 @@ catch {
|
|
|
138
637
|
return fallback;
|
|
139
638
|
} }
|
|
140
639
|
function isStringRecord(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.values(value).every((item) => typeof item === "string" && item.trim().length > 0); }
|
|
141
|
-
function isGateRecord(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.entries(value).every(([gate, aliases]) => ["work", "code", "readiness", "merge", "release"
|
|
142
|
-
function
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
640
|
+
function isGateRecord(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.entries(value).every(([gate, aliases]) => ["work", "code", "readiness", "merge", "release"].includes(gate) && Array.isArray(aliases) && aliases.every((item) => typeof item === "string" && item.startsWith("@check/"))); }
|
|
641
|
+
function isPortAliasRecord(value) { return value === undefined || (Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.entries(value).every(([alias, ports]) => alias.startsWith("@check/") && Array.isArray(ports) && ports.length > 0 && new Set(ports).size === ports.length && ports.every((port) => typeof port === "string" && /^[A-Za-z][A-Za-z0-9_]*$/.test(port)))); }
|
|
642
|
+
function isInputAliasRecord(workspaceRoot, value) { try {
|
|
643
|
+
return value === undefined || (Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.entries(value).every(([alias, inputs]) => alias.startsWith("@check/") && Array.isArray(inputs) && (validateCheckInputs(workspaceRoot, inputs), true)));
|
|
644
|
+
}
|
|
645
|
+
catch {
|
|
646
|
+
return false;
|
|
647
|
+
} }
|
|
648
|
+
function portEnvironment(ports) { return Object.fromEntries(Object.entries(ports).map(([name, value]) => [`DD_FLOW_PORT_${name.toUpperCase()}`, String(value)])); }
|
|
649
|
+
function readReceiptResources(file) { try {
|
|
650
|
+
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
651
|
+
return { ports: value.resources?.ports ?? {} };
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
return { ports: {} };
|
|
655
|
+
} }
|
|
656
|
+
function collectCheckArtifacts(root, required) {
|
|
657
|
+
for (const relative of required) {
|
|
658
|
+
if (path.isAbsolute(relative) || relative.split(/[\\/]/).includes(".."))
|
|
659
|
+
throw new AppError("invalid_evidence_path", "Required evidence paths must be relative to DD_FLOW_EVIDENCE_DIR", 2, { path: relative });
|
|
660
|
+
}
|
|
661
|
+
// Required paths are minimum obligations; retain all explicit check evidence.
|
|
662
|
+
// Never follow symlinks, including a replaced evidence root.
|
|
663
|
+
const items = [];
|
|
664
|
+
const visit = (directory) => {
|
|
665
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
666
|
+
const file = path.join(directory, entry.name);
|
|
667
|
+
if (entry.isDirectory())
|
|
668
|
+
visit(file);
|
|
669
|
+
else if (entry.isFile())
|
|
670
|
+
items.push({ path: path.relative(root, file), sha256: crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex") });
|
|
671
|
+
}
|
|
672
|
+
};
|
|
673
|
+
if (fs.existsSync(root) && fs.lstatSync(root).isDirectory())
|
|
674
|
+
visit(root);
|
|
675
|
+
items.sort((a, b) => a.path.localeCompare(b.path));
|
|
676
|
+
const retained = new Set(items.map((item) => item.path));
|
|
677
|
+
const missing = required.filter((relative) => !retained.has(path.normalize(relative)));
|
|
678
|
+
return { complete: missing.length === 0, missing, items };
|
|
679
|
+
}
|
|
152
680
|
function listFiles(root, current = root) { return fs.readdirSync(current, { withFileTypes: true }).flatMap((entry) => { if ([".git", "node_modules"].includes(entry.name))
|
|
153
681
|
return []; const absolute = path.join(current, entry.name); return entry.isDirectory() ? listFiles(root, absolute) : [path.relative(root, absolute)]; }).sort(); }
|
|
154
|
-
function runCheck(command, cwd, environment, stdout, stderr, heartbeat
|
|
155
|
-
|
|
682
|
+
export function runCheck(command, cwd, environment, stdout, stderr, heartbeat, onSpawn, renewLease) {
|
|
683
|
+
return new Promise((resolve) => {
|
|
684
|
+
const started = Date.now();
|
|
685
|
+
const child = spawn("/bin/sh", ["-c", checkShellScript()], { cwd, env: { ...environment, DD_FLOW_CHECK_COMMAND: command }, stdio: ["pipe", stdout, stderr], detached: process.platform !== "win32" });
|
|
686
|
+
let error = null;
|
|
687
|
+
let stopping = false;
|
|
688
|
+
let progress;
|
|
689
|
+
let escalation;
|
|
690
|
+
const stop = (cause) => {
|
|
691
|
+
if (stopping)
|
|
692
|
+
return;
|
|
693
|
+
stopping = true;
|
|
694
|
+
error = cause instanceof Error ? cause.message : String(cause);
|
|
695
|
+
if (progress) {
|
|
696
|
+
clearInterval(progress);
|
|
697
|
+
progress = undefined;
|
|
698
|
+
}
|
|
699
|
+
if (child.pid) {
|
|
700
|
+
terminateProcessGroup(child.pid, "SIGTERM");
|
|
701
|
+
escalation = setTimeout(() => terminateProcessGroup(child.pid, "SIGKILL"), 5_000);
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
// Register handlers before checking pid or calling persistence callbacks.
|
|
705
|
+
// Failure is not completion until the spawned process has actually closed.
|
|
706
|
+
child.once("error", (value) => { error = value.message; });
|
|
707
|
+
child.stdin.on("error", stop);
|
|
708
|
+
child.once("close", (exitCode, signal) => {
|
|
709
|
+
if (progress)
|
|
710
|
+
clearInterval(progress);
|
|
711
|
+
if (escalation)
|
|
712
|
+
clearTimeout(escalation);
|
|
713
|
+
if (stopping)
|
|
714
|
+
terminateProcessGroup(child.pid, "SIGKILL");
|
|
715
|
+
resolve({ exitCode, error: error ?? (signal ? `terminated by ${signal}` : null), aborted: stopping || Boolean(signal) || Boolean(error) });
|
|
716
|
+
});
|
|
717
|
+
if (!child.pid) {
|
|
718
|
+
error = "check process did not provide a PID";
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
try {
|
|
722
|
+
onSpawn(child.pid);
|
|
723
|
+
}
|
|
724
|
+
catch (cause) {
|
|
725
|
+
stop(cause);
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
child.stdin.end("admitted\n");
|
|
729
|
+
progress = setInterval(() => {
|
|
730
|
+
try {
|
|
731
|
+
if (!renewLease()) {
|
|
732
|
+
stop("managed process lease lost");
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
heartbeat(Math.floor((Date.now() - started) / 1000));
|
|
736
|
+
}
|
|
737
|
+
catch (cause) {
|
|
738
|
+
stop(cause);
|
|
739
|
+
}
|
|
740
|
+
}, 15_000);
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
function checkShellScript() {
|
|
744
|
+
return [
|
|
745
|
+
// The actual command cannot run before PID ownership and admission persist.
|
|
746
|
+
'IFS= read -r admission && [ "$admission" = admitted ] || exit 125',
|
|
747
|
+
'completion="${DD_FLOW_CHECK_COMPLETION_FILE:-}"',
|
|
748
|
+
'/bin/sh -c "$DD_FLOW_CHECK_COMMAND"',
|
|
749
|
+
"code=$?",
|
|
750
|
+
'if [ -n "$completion" ]; then temporary="$completion.tmp.$$"; printf \'{"exit_code":%s,"finished_at":"%s"}\\n\' "$code" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$temporary" && mv "$temporary" "$completion"; fi',
|
|
751
|
+
'exit "$code"'
|
|
752
|
+
].join("\n");
|
|
753
|
+
}
|
|
156
754
|
function terminateProcessGroup(pid, signal) { if (!pid)
|
|
157
755
|
return; try {
|
|
158
756
|
process.kill(process.platform === "win32" ? pid : -pid, signal);
|
|
159
757
|
}
|
|
160
758
|
catch { /* already exited */ } }
|
|
161
|
-
function closeStream(stream) { return new Promise((resolve, reject) => stream.end((error) => error ? reject(error) : resolve())); }
|