@deksden-com/dd-flow-cli 0.9.0-beta.48 → 0.9.0-beta.51
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 +22 -0
- package/dist/build-info.json +3 -3
- package/dist/cli/run-cli.js +4 -1
- package/dist/schemas/code-work-batch.schema.json +1 -1
- package/dist/schemas/vnext-protocol-plan.schema.json +1 -1
- package/dist/services/code-checks.js +237 -32
- package/dist/services/runs.js +16 -0
- package/dist/services/usage.js +4 -3
- package/dist/services/vnext-code-review.js +45 -21
- package/dist/services/vnext-code.js +63 -123
- package/dist/services/vnext-merge.js +73 -24
- package/dist/services/vnext-plan-review.js +24 -19
- package/dist/services/vnext-plan.js +4 -0
- package/dist/services/work-registry.js +32 -8
- package/dist/services/workspace-bootstrap.js +29 -12
- package/dist/storage/database.js +6 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# @deksden-com/dd-flow-cli
|
|
2
2
|
|
|
3
|
+
## 0.9.0-beta.51
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Settle a check receipt when its confirmed process identity has exited, even if an older registration has no parent-owner PID.
|
|
8
|
+
|
|
9
|
+
## 0.9.0-beta.50
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 422413d: Keep PLAN, PLAN-REVIEW, CODE and CODE-REVIEW settlement atomic across Work, Session and RUN state, including successor Work registration. Recover accepted CODE stage completion without repeating checks. Enforce coordinator ownership on repeated CODE start and exact reviewer coverage while preserving protocol-local aspect scopes.
|
|
14
|
+
- 422413d: Reuse completed check evidence consistently across CODE, CODE-REVIEW and MERGE,
|
|
15
|
+
with validated inputs, artifacts, retry handling and concurrent execution guards.
|
|
16
|
+
|
|
17
|
+
## 0.9.0-beta.49
|
|
18
|
+
|
|
19
|
+
### Patch Changes
|
|
20
|
+
|
|
21
|
+
- Treat a completed CODE gate command that returns `code_gate_failed` as terminal
|
|
22
|
+
recovery evidence, so the coordinator creates the returned repair Work instead
|
|
23
|
+
of waiting indefinitely or duplicating the aggregate gate.
|
|
24
|
+
|
|
3
25
|
## 0.9.0-beta.48
|
|
4
26
|
|
|
5
27
|
### Patch Changes
|
package/dist/build-info.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"cli_package": "@deksden-com/dd-flow-cli",
|
|
3
|
-
"cli_version": "0.9.0-beta.
|
|
4
|
-
"cli_commit": "
|
|
5
|
-
"built_at": "2026-09-
|
|
3
|
+
"cli_version": "0.9.0-beta.51",
|
|
4
|
+
"cli_commit": "66660a1dc362cce527bfc33bd2e8124aff6a33c1",
|
|
5
|
+
"built_at": "2026-09-11T23:08:17.146Z",
|
|
6
6
|
"built_with_canon": {
|
|
7
7
|
"version": "4.1.0",
|
|
8
8
|
"commit": "ef349bf47cba1c987468e51d73a0dbadbd48dc1f",
|
package/dist/cli/run-cli.js
CHANGED
|
@@ -799,8 +799,11 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
|
|
|
799
799
|
const workId = requiredPosition(parsed, 0, "work-id");
|
|
800
800
|
const projectRoot = requiredScopeProjectRoot(scopeProjectRoot, "work finish");
|
|
801
801
|
assertWorkLifecycleOwner(context, { workId, projectRoot, operation: "work_finish", ...(optionalOption(parsed, "hook-event-id") ? { hookEventId: optionalOption(parsed, "hook-event-id") } : {}) });
|
|
802
|
+
const retryReceiptId = optionalOption(parsed, "retry-check"), retryReason = optionalOption(parsed, "reason");
|
|
803
|
+
if (Boolean(retryReceiptId) !== Boolean(retryReason))
|
|
804
|
+
throw new AppError("usage", "--retry-check and --reason must be supplied together", 2);
|
|
802
805
|
const result = resultFile ? fs.readFileSync(path.resolve(resultFile), "utf8") : await readStdin(io.stdin);
|
|
803
|
-
return await finishWork(context, workId, result, commandProgress(io, output));
|
|
806
|
+
return await finishWork(context, workId, result, commandProgress(io, output), retryReceiptId ? { receiptId: retryReceiptId, reason: retryReason } : undefined);
|
|
804
807
|
}
|
|
805
808
|
if (family === "work" && command === "fail") {
|
|
806
809
|
const workId = requiredPosition(parsed, 0, "work-id");
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"spine": {"type": "object", "additionalProperties": false, "required": ["user_outcome", "component_responsibility", "must_preserve", "non_goals", "acceptance_contribution"], "properties": {"user_outcome": {"type": "string", "minLength": 1}, "component_responsibility": {"type": "string", "minLength": 1}, "must_preserve": {"$ref": "#/$defs/strings"}, "non_goals": {"$ref": "#/$defs/stringList"}, "acceptance_contribution": {"type": "string", "minLength": 1}}},
|
|
17
17
|
"obligation": {"type": "object", "additionalProperties": false, "required": ["id", "statement"], "properties": {"id": {"type": "string", "minLength": 1}, "statement": {"type": "string", "minLength": 1}}},
|
|
18
18
|
"acceptance": {"type": "object", "additionalProperties": false, "required": ["criterion_id", "path", "environment", "fixtures", "cleanup", "check_refs", "expected_evidence", "proof_limits", "gate"], "properties": {"criterion_id": {"type": "string", "pattern": "^AC-[0-9]+$"}, "path": {"type": "string", "minLength": 1}, "environment": {"type": "string", "minLength": 1}, "fixtures": {"$ref": "#/$defs/stringList"}, "cleanup": {"type": "string", "minLength": 1}, "check_refs": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, "expected_evidence": {"$ref": "#/$defs/strings"}, "proof_limits": {"$ref": "#/$defs/strings"}, "gate": {"type": "string", "minLength": 1}}},
|
|
19
|
-
"check": {"type": "object", "additionalProperties": false, "required": ["id", "command", "purpose", "run_at", "availability"], "properties": {"id": {"type": "string", "pattern": "^CHK-[A-Za-z0-9-]+$"}, "command": {"type": "string", "minLength": 1}, "purpose": {"type": "string", "minLength": 1}, "run_at": {"enum": ["work", "code", "readiness", "merge", "release", "external"]}, "availability": {"enum": ["available", "planned"]}, "provided_by": {"type": "string", "minLength": 1}, "definition": {"type": "string", "minLength": 1}, "required_artifacts": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, "ports": {"type": "array", "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]*$"}}}, "allOf": [{"if": {"properties": {"availability": {"const": "planned"}}, "required": ["availability"]}, "then": {"required": ["provided_by", "definition"], "properties": {"command": {"pattern": "^@check/"}}}}]},
|
|
19
|
+
"check": {"type": "object", "additionalProperties": false, "required": ["id", "command", "purpose", "run_at", "availability"], "properties": {"id": {"type": "string", "pattern": "^CHK-[A-Za-z0-9-]+$"}, "command": {"type": "string", "minLength": 1}, "purpose": {"type": "string", "minLength": 1}, "run_at": {"enum": ["work", "code", "readiness", "merge", "release", "external"]}, "availability": {"enum": ["available", "planned"]}, "provided_by": {"type": "string", "minLength": 1}, "definition": {"type": "string", "minLength": 1}, "required_artifacts": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, "ports": {"type": "array", "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]*$"}}, "inputs": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, "reuse": {"const": "deterministic"}}, "allOf": [{"if": {"properties": {"availability": {"const": "planned"}}, "required": ["availability"]}, "then": {"required": ["provided_by", "definition"], "properties": {"command": {"pattern": "^@check/"}}}}]},
|
|
20
20
|
"documentUpdate": {"type": "object", "additionalProperties": false, "required": ["path", "action", "owner", "reason", "baseline_sha256"], "properties": {"path": {"type": "string", "minLength": 1}, "action": {"enum": ["create", "update"]}, "owner": {"type": "string", "minLength": 1}, "reason": {"type": "string", "minLength": 1}, "baseline_sha256": {"type": ["string", "null"], "pattern": "^[a-f0-9]{64}$"}}},
|
|
21
21
|
"reviewFinding": {"type": "object", "additionalProperties": false, "required": ["finding_ref", "priority", "problem", "impact", "required_outcome", "evidence_refs", "obligation_refs", "decision_reason", "check_refs"], "properties": {"finding_ref": {"type": "string", "minLength": 1}, "priority": {"enum": ["p0", "p1", "p2", "p3"]}, "problem": {"type": "string", "minLength": 1}, "impact": {"type": "string", "minLength": 1}, "required_outcome": {"type": "string", "minLength": 1}, "evidence_refs": {"$ref": "#/$defs/strings"}, "obligation_refs": {"$ref": "#/$defs/strings"}, "decision_reason": {"type": "string", "minLength": 1}, "check_refs": {"$ref": "#/$defs/strings"}}},
|
|
22
22
|
"repair": {"type": "object", "additionalProperties": false, "required": ["origin_work_ids", "verification_check_refs"], "anyOf": [{"required": ["check_receipt_id", "failure_receipt_path"]}, {"required": ["review_findings"]}, {"required": ["semantic_unresolved", "verification_path"]}], "properties": {"origin_work_ids": {"$ref": "#/$defs/strings"}, "check_receipt_id": {"type": "string", "minLength": 1}, "failure_receipt_path": {"type": "string", "minLength": 1}, "review_findings": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/reviewFinding"}}, "semantic_unresolved": {"$ref": "#/$defs/strings"}, "verification_path": {"type": "string", "minLength": 1}, "verification_check_refs": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/check"}}}},
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"documentUpdate": {"type": "object", "additionalProperties": false, "required": ["path", "action", "owner", "reason"], "properties": {"path": {"type": "string", "minLength": 1}, "action": {"enum": ["create", "update"]}, "owner": {"type": "string", "minLength": 1}, "reason": {"type": "string", "minLength": 1}}},
|
|
30
30
|
"spine": {"type": "object", "additionalProperties": false, "required": ["user_outcome", "component_responsibility", "must_preserve", "non_goals", "acceptance_contribution"], "properties": {"user_outcome": {"type": "string", "minLength": 1}, "component_responsibility": {"type": "string", "minLength": 1}, "must_preserve": {"$ref": "#/$defs/strings"}, "non_goals": {"type": "array", "items": {"type": "string", "minLength": 1}}, "acceptance_contribution": {"type": "string", "minLength": 1}}},
|
|
31
31
|
"execution": {"type": "object", "additionalProperties": false, "required": ["required_read", "discovery_boundary", "planned_write_areas", "stop_conditions"], "properties": {"required_read": {"$ref": "#/$defs/strings", "description": "Mandatory starting sources. This is not a read allowlist."}, "discovery_boundary": {"$ref": "#/$defs/strings", "description": "Likely discovery areas. A worker may inspect other project-local sources when necessary."}, "planned_write_areas": {"type": "array", "items": {"type": "string", "minLength": 1}, "description": "Optional file or directory hints used only to coordinate concurrent Works. They never grant or deny write permission."}, "stop_conditions": {"$ref": "#/$defs/strings", "description": "Semantic contradictions or external blockers that require the worker to stop."}}},
|
|
32
|
-
"check": {"type": "object", "additionalProperties": false, "required": ["id", "command", "purpose", "run_at", "availability"], "properties": {"id": {"type": "string", "pattern": "^CHK-[A-Za-z0-9-]+$"}, "command": {"type": "string", "minLength": 1}, "purpose": {"type": "string", "minLength": 1}, "run_at": {"enum": ["work", "code", "readiness", "merge", "release", "external"]}, "availability": {"enum": ["available", "planned"]}, "provided_by": {"type": "string", "pattern": "^P[0-9]+$"}, "definition": {"type": "string", "minLength": 1}, "required_artifacts": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, "ports": {"type": "array", "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]*$"}}}, "allOf": [{"if": {"properties": {"availability": {"const": "planned"}}, "required": ["availability"]}, "then": {"required": ["provided_by", "definition"], "properties": {"command": {"pattern": "^@check/"}}}}, {"if": {"properties": {"command": {"pattern": "^@check/"}}}, "then": {"required": ["definition"]}}]},
|
|
32
|
+
"check": {"type": "object", "additionalProperties": false, "required": ["id", "command", "purpose", "run_at", "availability"], "properties": {"id": {"type": "string", "pattern": "^CHK-[A-Za-z0-9-]+$"}, "command": {"type": "string", "minLength": 1}, "purpose": {"type": "string", "minLength": 1}, "run_at": {"enum": ["work", "code", "readiness", "merge", "release", "external"]}, "availability": {"enum": ["available", "planned"]}, "provided_by": {"type": "string", "pattern": "^P[0-9]+$"}, "definition": {"type": "string", "minLength": 1}, "required_artifacts": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, "ports": {"type": "array", "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]*$"}}, "inputs": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, "reuse": {"const": "deterministic"}}, "allOf": [{"if": {"properties": {"availability": {"const": "planned"}}, "required": ["availability"]}, "then": {"required": ["provided_by", "definition"], "properties": {"command": {"pattern": "^@check/"}}}}, {"if": {"properties": {"command": {"pattern": "^@check/"}}}, "then": {"required": ["definition"]}}]},
|
|
33
33
|
"verification": {"type": "object", "additionalProperties": false, "required": ["check_refs"], "properties": {"check_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^CHK-[A-Za-z0-9-]+$"}}}},
|
|
34
34
|
"item": {"type": "object", "additionalProperties": false, "required": ["id", "title", "summary", "details", "depends_on", "requirement_refs", "semantic_spine", "execution_context", "verification"], "properties": {"id": {"type": "string", "pattern": "^P[0-9]+$"}, "title": {"type": "string", "minLength": 1}, "summary": {"type": "string", "minLength": 1}, "details": {"type": "string", "minLength": 1}, "depends_on": {"type": "array", "uniqueItems": true, "items": {"type": "string", "pattern": "^P[0-9]+$"}}, "requirement_refs": {"$ref": "#/$defs/strings"}, "semantic_spine": {"$ref": "#/$defs/spine"}, "execution_context": {"$ref": "#/$defs/execution"}, "verification": {"$ref": "#/$defs/verification"}}},
|
|
35
35
|
"acceptance": {"type": "object", "additionalProperties": false, "required": ["criterion_id", "plan_item_ids", "changed_surfaces", "path", "environment", "fixtures", "cleanup", "check_refs", "expected_evidence", "proof_limits", "gate"], "properties": {"criterion_id": {"type": "string", "pattern": "^AC-[0-9]+$"}, "plan_item_ids": {"type": "array", "minItems": 1, "items": {"type": "string", "pattern": "^P[0-9]+$"}}, "changed_surfaces": {"$ref": "#/$defs/strings"}, "path": {"type": "string", "minLength": 1}, "environment": {"type": "string", "minLength": 1}, "fixtures": {"type": "array", "items": {"type": "string", "minLength": 1}}, "cleanup": {"type": "string", "minLength": 1}, "check_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^CHK-[A-Za-z0-9-]+$"}}, "expected_evidence": {"$ref": "#/$defs/strings"}, "proof_limits": {"$ref": "#/$defs/strings"}, "gate": {"enum": ["work", "code", "readiness", "merge", "release", "external"]}}}
|
|
@@ -4,7 +4,7 @@ import fs from "node:fs";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { assertRunMutationAllowed } from "./run-recovery.js";
|
|
6
6
|
import { AppError } from "../shared/errors.js";
|
|
7
|
-
import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessStatus, managedOwnerIsAlive, processTreeIsAlive, registerManagedProcess, reservePorts } from "./managed-processes.js";
|
|
7
|
+
import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessStatus, managedProcessRuntimeHome, managedOwnerIsAlive, processTreeIsAlive, registerManagedProcess, reservePorts } from "./managed-processes.js";
|
|
8
8
|
import { admitRuntimeCheck, runtimeBudgetForRun } from "./runtime-budget.js";
|
|
9
9
|
const profileRelativePath = path.join(".memory-bank", "spec", "engineering", "code-check-profile.json");
|
|
10
10
|
// Receipt reservation precedes child spawn so an interrupted CLI leaves an
|
|
@@ -12,6 +12,7 @@ const profileRelativePath = path.join(".memory-bank", "spec", "engineering", "co
|
|
|
12
12
|
// for a dead check and overwrite its outcome.
|
|
13
13
|
const receiptStartingGraceMs = 30_000;
|
|
14
14
|
export function codeExecutionEnvironment(workspaceRoot) { const suffix = crypto.createHash("sha256").update(path.resolve(workspaceRoot)).digest("hex").slice(0, 12); return { ...process.env, DD_FLOW_LOCAL_DATABASE_SUFFIX: suffix }; }
|
|
15
|
+
export function workspaceOperationId(workspaceRoot) { return `workspace:${crypto.createHash("sha256").update(path.resolve(workspaceRoot)).digest("hex")}`; }
|
|
15
16
|
export function readCodeCheckProfile(workspaceRoot) {
|
|
16
17
|
const file = path.join(workspaceRoot, profileRelativePath);
|
|
17
18
|
if (!fs.existsSync(file))
|
|
@@ -20,8 +21,8 @@ export function readCodeCheckProfile(workspaceRoot) {
|
|
|
20
21
|
const value = JSON.parse(bytes.toString("utf8"));
|
|
21
22
|
if (value.schema_id !== "dd-flow/code-check-profile@6")
|
|
22
23
|
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" });
|
|
23
|
-
if (!isStringRecord(value.aliases) || !isStringRecord(value.require_alias_for) || !isGateRecord(value.mandatory_by_gate) || !isPortAliasRecord(value.ports_by_alias))
|
|
24
|
-
throw new AppError("invalid_code_check_profile", "CODE check profile aliases, require_alias_for, mandatory_by_gate or
|
|
24
|
+
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))
|
|
25
|
+
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 });
|
|
25
26
|
for (const [gate, aliases] of Object.entries(value.mandatory_by_gate))
|
|
26
27
|
for (const alias of aliases ?? [])
|
|
27
28
|
if (!value.aliases[alias])
|
|
@@ -29,6 +30,9 @@ export function readCodeCheckProfile(workspaceRoot) {
|
|
|
29
30
|
for (const alias of Object.keys(value.ports_by_alias ?? {}))
|
|
30
31
|
if (!value.aliases[alias])
|
|
31
32
|
throw new AppError("invalid_code_check_profile", "A port declaration references an unknown alias", 2, { file, alias });
|
|
33
|
+
for (const alias of Object.keys(value.inputs_by_alias ?? {}))
|
|
34
|
+
if (!value.aliases[alias])
|
|
35
|
+
throw new AppError("invalid_code_check_profile", "An input declaration references an unknown alias", 2, { file, alias });
|
|
32
36
|
return { profile: value, hash: crypto.createHash("sha256").update(bytes).digest("hex"), file };
|
|
33
37
|
}
|
|
34
38
|
export function validateCodeCheckCommands(workspaceRoot, commands) {
|
|
@@ -52,7 +56,7 @@ export function resolveCodeCheckCommands(workspaceRoot, runId, commands) {
|
|
|
52
56
|
}
|
|
53
57
|
export function projectPolicyCheckDeclarations(workspaceRoot, gate) {
|
|
54
58
|
const { profile } = readCodeCheckProfile(workspaceRoot);
|
|
55
|
-
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] } : {}) }; });
|
|
59
|
+
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] } : {}) }; });
|
|
56
60
|
}
|
|
57
61
|
export function effectiveCheckDeclarations(workspaceRoot, declared, gates) { return gates.flatMap((gate) => [...declared.filter((check) => check.run_at === gate), ...projectPolicyCheckDeclarations(workspaceRoot, gate)]); }
|
|
58
62
|
export function aggregateCheckDeclarations(workspaceRoot) { return projectPolicyCheckDeclarations(workspaceRoot, "code"); }
|
|
@@ -68,6 +72,7 @@ export function validateCheckDeclaration(workspaceRoot, check) {
|
|
|
68
72
|
if (check.availability === "planned" && check.command.startsWith("@check/") && !profile?.aliases[check.command])
|
|
69
73
|
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 });
|
|
70
74
|
validateCodeCheckCommands(workspaceRoot, [check.command]);
|
|
75
|
+
validateCheckInputs(workspaceRoot, check.inputs ?? []);
|
|
71
76
|
if (check.command.startsWith("@check/") && check.definition && profile?.aliases[check.command] !== check.definition)
|
|
72
77
|
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 });
|
|
73
78
|
if (check.availability !== "planned")
|
|
@@ -81,21 +86,52 @@ export function validateCheckDeclaration(workspaceRoot, check) {
|
|
|
81
86
|
}
|
|
82
87
|
export async function runCodeChecks(context, input) {
|
|
83
88
|
reconcileUnfinishedChecks(context, input);
|
|
89
|
+
reconcileWorkspaceOwnership(context, input.workspaceRoot);
|
|
90
|
+
const gate = acquireWorkspaceGate(context, input);
|
|
91
|
+
try {
|
|
92
|
+
return await runCodeChecksOwned(context, input, gate.id);
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
finishManagedProcess(context, { id: gate.id, leaseToken: gate.lease_token, state: "stopped", reason: "workspace_gate_settled" });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async function runCodeChecksOwned(context, input, workspaceGateId) {
|
|
84
99
|
const generation = assertRunMutationAllowed(context, input.projectId, input.runId);
|
|
85
100
|
for (const check of input.checks)
|
|
86
101
|
validateCheckDeclaration(input.workspaceRoot, check);
|
|
87
102
|
const { hash: profileHash, profile } = readCodeCheckProfile(input.workspaceRoot);
|
|
88
|
-
|
|
103
|
+
if (input.expectedProfileHash !== undefined && input.expectedProfileHash !== profileHash)
|
|
104
|
+
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 });
|
|
105
|
+
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 }) }; });
|
|
89
106
|
const resolved = resolveCodeCheckCommands(input.workspaceRoot, input.runId, checks.map((check) => check.command));
|
|
90
|
-
|
|
91
|
-
const receipts = [];
|
|
107
|
+
assertConsistentDeclarations(checks, resolved);
|
|
108
|
+
const groups = deduplicate(checks, resolved), receipts = [];
|
|
109
|
+
const retryIds = [...new Set([...(input.retryReceiptId ? [input.retryReceiptId] : []), ...(input.retryReceiptIds ?? [])])];
|
|
110
|
+
const retries = new Map();
|
|
111
|
+
for (const receiptId of retryIds) {
|
|
112
|
+
const predecessor = validateFailedCheckRetry(context, { projectId: input.projectId, runId: input.runId, receiptId, declarations: checks, reason: input.retryReason });
|
|
113
|
+
const snapshot = workspaceState(input.workspaceRoot, [input.runHome]);
|
|
114
|
+
const group = groups.find(candidate => checkInputHash(input.workspaceRoot, candidate, profileHash, workspaceInputFingerprint(input.workspaceRoot, snapshot.files, candidate.inputs, [input.runHome])) === predecessor.input_hash);
|
|
115
|
+
if (!group || retries.has(group))
|
|
116
|
+
throw new AppError("retry_check_invalid", "Retry must match exactly one current execution contract", 2, { check_receipt_id: receiptId });
|
|
117
|
+
retries.set(group, receiptId);
|
|
118
|
+
}
|
|
92
119
|
for (let index = 0; index < groups.length; index += 1) {
|
|
93
120
|
const group = groups[index];
|
|
94
121
|
const before = workspaceState(input.workspaceRoot, [input.runHome]);
|
|
95
122
|
const startedAt = context.now();
|
|
96
|
-
const
|
|
123
|
+
const inputFingerprint = workspaceInputFingerprint(input.workspaceRoot, before.files, group.inputs, [input.runHome]);
|
|
124
|
+
const inputHash = checkInputHash(input.workspaceRoot, group, profileHash, inputFingerprint);
|
|
97
125
|
const epoch = crypto.createHash("sha256").update(`${group.gate}\0${before.fingerprint}`).digest("hex");
|
|
98
|
-
const
|
|
126
|
+
const reservationInput = { ...input, retryReceiptIds: retries.has(group) ? [retries.get(group)] : [], generation, group, before, profileHash, inputHash, epoch, startedAt };
|
|
127
|
+
delete reservationInput.retryReceiptId;
|
|
128
|
+
const decision = reserveOrReuseReceipt(context, reservationInput);
|
|
129
|
+
if (decision.receipt) {
|
|
130
|
+
receipts.push(bindReceipt(decision.receipt, group));
|
|
131
|
+
input.progress?.(`check ${index + 1}/${groups.length} ${decision.receipt.status === "passed" ? "reused" : "requires repair"}: ${group.command}`);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const reserved = decision.reserved;
|
|
99
135
|
const { id, evidenceDir, stdoutPath, stderrPath, receiptPath, completionPath } = reserved;
|
|
100
136
|
let resources = {};
|
|
101
137
|
let result = { exitCode: null, error: "check did not start", aborted: true };
|
|
@@ -103,7 +139,7 @@ export async function runCodeChecks(context, input) {
|
|
|
103
139
|
fs.mkdirSync(evidenceDir, { recursive: true });
|
|
104
140
|
writeReceipt(receiptPath, receiptFor({ ...reserved, resources, result, status: "running", after: before, mutationPaths: [], artifacts: [] }));
|
|
105
141
|
const budget = runtimeBudgetForRun(context, input.projectId, input.runId);
|
|
106
|
-
const managed = registerManagedProcess(context, { kind: "check", ownerPid: process.pid, ownerId: `check:${id}`, projectId: input.projectId, runId: input.runId, workId: input.workId ?? null, checkId: id, stdoutPath, stderrPath, metadata: { command: group.command, role: "check", budget } });
|
|
142
|
+
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 } });
|
|
107
143
|
const allocation = await reservePorts(context, { ownerId: managed.owner_id, processId: managed.id, names: group.ports });
|
|
108
144
|
resources = allocation.ports;
|
|
109
145
|
try {
|
|
@@ -158,6 +194,16 @@ export async function runCodeChecks(context, input) {
|
|
|
158
194
|
receipts.push(receipt);
|
|
159
195
|
input.progress?.(`check ${index + 1}/${groups.length} ${status}: ${group.command}`);
|
|
160
196
|
}
|
|
197
|
+
if (receipts.every(receipt => receipt.status === "passed")) {
|
|
198
|
+
const current = workspaceState(input.workspaceRoot, [input.runHome]), { hash: currentProfileHash } = readCodeCheckProfile(input.workspaceRoot);
|
|
199
|
+
for (const receipt of receipts) {
|
|
200
|
+
const group = groups.find(candidate => candidate.declarations.some(check => receipt.check_refs.includes(checkRef(check))));
|
|
201
|
+
const currentHash = group ? checkInputHash(input.workspaceRoot, group, currentProfileHash, workspaceInputFingerprint(input.workspaceRoot, current.files, group.inputs, [input.runHome])) : null;
|
|
202
|
+
if (!group || !checkReceiptEvidenceValid(receipt) || receipt.input_hash !== currentHash)
|
|
203
|
+
throw new AppError("check_evidence_stale", "Check evidence changed before the gate could be accepted", 1, { check_receipt_id: receipt.id });
|
|
204
|
+
}
|
|
205
|
+
assertRunMutationAllowed(context, input.projectId, input.runId, generation);
|
|
206
|
+
}
|
|
161
207
|
return receipts;
|
|
162
208
|
}
|
|
163
209
|
/**
|
|
@@ -184,7 +230,11 @@ export function reconcileUnfinishedChecks(context, input) {
|
|
|
184
230
|
// A stale lease after host sleep is not permission to duplicate a living
|
|
185
231
|
// check. Its durable completion marker or explicit reconciliation decides
|
|
186
232
|
// the outcome before another invocation may start.
|
|
187
|
-
|
|
233
|
+
// A confirmed PID plus its birth time is sufficient to prove that this
|
|
234
|
+
// exact check writer is gone. Do not retain a dead receipt merely because
|
|
235
|
+
// older registrations lack the optional parent-owner identity.
|
|
236
|
+
const processProvenDead = Boolean(process?.pid && process.pid_started_at && !processTreeIsAlive(process));
|
|
237
|
+
if (process && !processProvenDead && (processTreeIsAlive(process) || (["starting", "running", "stopping", "orphaned"].includes(process.state) && managedOwnerIsAlive(process) !== false))) {
|
|
188
238
|
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 });
|
|
189
239
|
}
|
|
190
240
|
if (!process && !completion && Date.now() - Date.parse(pendingReceipt.started_at) < receiptStartingGraceMs) {
|
|
@@ -207,26 +257,60 @@ export function reconcileUnfinishedChecks(context, input) {
|
|
|
207
257
|
}
|
|
208
258
|
return reconciled;
|
|
209
259
|
}
|
|
210
|
-
function
|
|
260
|
+
function reserveOrReuseReceipt(context, input) {
|
|
211
261
|
context.db.exec("BEGIN IMMEDIATE");
|
|
212
262
|
try {
|
|
213
263
|
assertRunMutationAllowed(context, input.projectId, input.runId, input.generation);
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const
|
|
264
|
+
const prior = latestApplicableReceipt(context, input);
|
|
265
|
+
if (prior?.status === "running")
|
|
266
|
+
throw new AppError("check_in_progress", "A matching check invocation is still running", 1, { check_receipt_id: prior.id });
|
|
267
|
+
const retryIds = new Set([...(input.retryReceiptId ? [input.retryReceiptId] : []), ...(input.retryReceiptIds ?? [])]);
|
|
268
|
+
if (retryIds.size > 1)
|
|
269
|
+
throw new AppError("retry_check_invalid", "A check execution accepts exactly one retry predecessor", 2);
|
|
270
|
+
const retryId = [...retryIds][0];
|
|
271
|
+
if (retryId) {
|
|
272
|
+
if (!input.retryReason?.trim())
|
|
273
|
+
throw new AppError("retry_check_invalid", "A check retry requires the restored-condition reason", 2, { check_receipt_id: retryId });
|
|
274
|
+
const receipts = checkReceipts(context, { projectId: input.projectId, runId: input.runId });
|
|
275
|
+
const predecessor = receipts.find(receipt => receipt.id === retryId);
|
|
276
|
+
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)
|
|
277
|
+
throw new AppError("retry_check_invalid", "Retry predecessor does not match this exact check contract", 2, { check_receipt_id: retryId });
|
|
278
|
+
const successor = receipts.find(receipt => receipt.retry_of_receipt_id === retryId && receipt.input_hash === input.inputHash && receipt.profile_hash === input.profileHash);
|
|
279
|
+
if (prior?.id !== (successor?.id ?? predecessor.id))
|
|
280
|
+
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 });
|
|
281
|
+
if (successor?.status === "running")
|
|
282
|
+
throw new AppError("check_in_progress", "The requested retry is already running", 1, { check_receipt_id: successor.id, retry_of_receipt_id: retryId });
|
|
283
|
+
if (successor) {
|
|
284
|
+
if (successor.status === "passed" && checkReceiptEvidenceValid(successor)) {
|
|
285
|
+
context.db.exec("COMMIT");
|
|
286
|
+
return { receipt: successor };
|
|
287
|
+
}
|
|
288
|
+
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 });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (prior && !retryIds.has(prior.id) && (prior.status !== "passed" || (input.group.reusable && checkReceiptEvidenceValid(prior)))) {
|
|
292
|
+
context.db.exec("COMMIT");
|
|
293
|
+
return { receipt: prior };
|
|
294
|
+
}
|
|
295
|
+
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);
|
|
296
|
+
let localId = `RCP-${String(ordinal).padStart(3, "0")}`, id = `${input.workId ?? input.runId}/${localId}`;
|
|
297
|
+
while (context.db.get("SELECT 1 FROM check_receipts WHERE id = ?", [id])) {
|
|
298
|
+
ordinal += 1;
|
|
299
|
+
localId = `RCP-${String(ordinal).padStart(3, "0")}`;
|
|
300
|
+
id = `${input.workId ?? input.runId}/${localId}`;
|
|
301
|
+
}
|
|
218
302
|
const directory = path.join(input.runHome, input.artifactDir ?? "05-code", "checks", localId);
|
|
219
|
-
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, ...(input.workId ? { workId: input.workId } : {}), projectId: input.projectId, runId: input.runId };
|
|
220
|
-
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, 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)), input.group.gate, input.group.command, input.inputHash, input.epoch, reserved.stdoutPath, reserved.stderrPath, reserved.receiptPath, input.before.fingerprint, input.before.fingerprint, input.before.fingerprint, input.profileHash, input.startedAt, input.startedAt]);
|
|
303
|
+
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 };
|
|
304
|
+
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]);
|
|
221
305
|
context.db.exec("COMMIT");
|
|
222
|
-
return reserved;
|
|
306
|
+
return { reserved };
|
|
223
307
|
}
|
|
224
308
|
catch (error) {
|
|
225
309
|
context.db.exec("ROLLBACK");
|
|
226
310
|
throw error;
|
|
227
311
|
}
|
|
228
312
|
}
|
|
229
|
-
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, 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, 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() }; }
|
|
313
|
+
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() }; }
|
|
230
314
|
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); }
|
|
231
315
|
function readReceipt(file) { try {
|
|
232
316
|
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
@@ -242,19 +326,13 @@ function readCompletion(file) { try {
|
|
|
242
326
|
catch {
|
|
243
327
|
return null;
|
|
244
328
|
} }
|
|
245
|
-
export function unchangedFinalGateFailures(context, input) { const wanted = new Set(input.declarations.flatMap((check) => [check.id, checkRef(check)])); const current = workspaceFingerprint(input.workspaceRoot); const latest = new Map(); for (const receipt of checkReceipts(context, { projectId: input.projectId, runId: input.runId }).filter((item) => item.scope === "aggregate" && item.check_refs.some((ref) => wanted.has(ref))))
|
|
246
|
-
for (const ref of receipt.check_refs)
|
|
247
|
-
latest.set(ref, receipt); return [...new Set(latest.values())].filter((receipt) => receipt.status === "failed" && receipt.before_fingerprint === current && receipt.id !== input.retryReceiptId); }
|
|
248
329
|
/** Validate an explicit retry; the caller still executes a fresh real gate and retains a new receipt. */
|
|
249
330
|
export function validateFailedCheckRetry(context, input) {
|
|
250
331
|
if (!input.reason?.trim())
|
|
251
332
|
throw new AppError("retry_check_invalid", "--retry-check requires --reason describing the restored environment", 2);
|
|
252
333
|
const receipt = checkReceipts(context, { projectId: input.projectId, runId: input.runId }).find((item) => item.id === input.receiptId);
|
|
253
|
-
if (!receipt ||
|
|
254
|
-
throw new AppError("retry_check_invalid", "--retry-check must name a failed or aborted
|
|
255
|
-
const relative = path.relative(input.artifactRoot, receipt.receipt_path);
|
|
256
|
-
if (path.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${path.sep}`) || /^try-\d+([/\\]|$)/.test(relative))
|
|
257
|
-
throw new AppError("retry_check_invalid", "--retry-check must belong to this current stage attempt, not an archived or different stage", 2, { check_receipt_id: input.receiptId });
|
|
334
|
+
if (!receipt || !["failed", "aborted"].includes(receipt.status))
|
|
335
|
+
throw new AppError("retry_check_invalid", "--retry-check must name a failed or aborted receipt in this RUN", 2, { check_receipt_id: input.receiptId });
|
|
258
336
|
const declared = new Set(input.declarations.flatMap((check) => [check.id, checkRef(check)]));
|
|
259
337
|
if (!receipt.check_refs.some((ref) => declared.has(ref)))
|
|
260
338
|
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 });
|
|
@@ -263,7 +341,7 @@ export function validateFailedCheckRetry(context, input) {
|
|
|
263
341
|
export function checkReceipts(context, input) {
|
|
264
342
|
const where = input.workId ? "project_id = ? AND run_id = ? AND work_id = ?" : "project_id = ? AND run_id = ?";
|
|
265
343
|
const params = input.workId ? [input.projectId, input.runId, input.workId] : [input.projectId, input.runId];
|
|
266
|
-
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
|
|
344
|
+
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)) }));
|
|
267
345
|
}
|
|
268
346
|
/** File bytes remain immutable; only the reader projects their current location. */
|
|
269
347
|
function resolvedReceiptArtifacts(receiptPath, artifacts) {
|
|
@@ -278,15 +356,15 @@ function resolvedReceiptArtifacts(receiptPath, artifacts) {
|
|
|
278
356
|
const relative = path.relative(oldRoot, file);
|
|
279
357
|
return !path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`) ? path.join(currentRoot, relative) : file;
|
|
280
358
|
};
|
|
281
|
-
return { completion_path: path.join(currentRoot, "completion.json"), required_artifacts: original.required_artifacts ?? [], artifacts: artifacts.map((artifact) => ({ ...artifact, path: relocate(artifact.path) })) };
|
|
359
|
+
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) })) };
|
|
282
360
|
}
|
|
283
361
|
export function workspaceFingerprint(root) { return workspaceState(root).fingerprint; }
|
|
284
362
|
export function workspaceChangedPaths(root) { const status = spawnSync("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); if (status.status !== 0)
|
|
285
363
|
return null; return status.stdout.split("\0").filter(Boolean).map((entry) => entry.slice(3)).filter(Boolean).sort(); }
|
|
286
|
-
function deduplicate(checks, commands) { const groups = new Map(); checks.forEach((check, index) => { const artifacts = [...(check.required_artifacts ?? [])].sort(); const ports = [...(check.ports ?? [])].sort(); const key = JSON.stringify([commands[index], artifacts, ports]); const current = groups.get(key); if (current)
|
|
364
|
+
function deduplicate(checks, commands) { const groups = new Map(); checks.forEach((check, index) => { const artifacts = [...(check.required_artifacts ?? [])].sort(); const ports = [...(check.ports ?? [])].sort(); const inputs = normalizedInputs(check.inputs ?? []); const reusable = check.reuse === "deterministic"; const key = JSON.stringify([check.run_at, commands[index], artifacts, ports, inputs, reusable]); const current = groups.get(key); if (current)
|
|
287
365
|
current.declarations.push(check);
|
|
288
366
|
else
|
|
289
|
-
groups.set(key, { gate: check.run_at, command: commands[index], declarations: [check], requiredArtifacts: artifacts, ports }); }); return [...groups.values()]; }
|
|
367
|
+
groups.set(key, { gate: check.run_at, command: commands[index], declarations: [check], requiredArtifacts: artifacts, ports, inputs, reusable }); }); return [...groups.values()].map(group => ({ ...group, declarations: [...group.declarations].sort((left, right) => checkRef(left).localeCompare(checkRef(right))) })); }
|
|
290
368
|
function workspaceState(root, ignoredRoots = []) { const git = spawnSync("git", ["ls-files", "-co", "--exclude-standard", "-z"], { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); const ignored = ignoredRoots.map((entry) => path.resolve(entry)); const listing = (git.status === 0 ? git.stdout.split("\0").filter(Boolean).sort() : listFiles(root)).filter((relative) => { const file = path.resolve(root, relative); return !ignored.some((ignoredRoot) => file === ignoredRoot || file.startsWith(`${ignoredRoot}${path.sep}`)); }); const files = new Map(); const hash = crypto.createHash("sha256"); for (const relative of listing) {
|
|
291
369
|
const file = path.join(root, relative);
|
|
292
370
|
const content = fs.existsSync(file) && fs.statSync(file).isFile() ? fs.readFileSync(file) : Buffer.from("<missing>");
|
|
@@ -294,8 +372,129 @@ function workspaceState(root, ignoredRoots = []) { const git = spawnSync("git",
|
|
|
294
372
|
files.set(relative, digest);
|
|
295
373
|
hash.update(relative).update("\0").update(digest).update("\0");
|
|
296
374
|
} return { fingerprint: hash.digest("hex"), files }; }
|
|
375
|
+
function workspaceInputFingerprint(root, files, inputs, ignoredRoots) {
|
|
376
|
+
const selected = new Map(inputs.length === 0 || inputs.includes(".") ? files : []);
|
|
377
|
+
for (const [file, digest] of explicitInputFiles(root, inputs.filter(input => input !== "."), ignoredRoots))
|
|
378
|
+
selected.set(file, digest);
|
|
379
|
+
const hash = crypto.createHash("sha256");
|
|
380
|
+
for (const [file, digest] of [...selected].sort(([left], [right]) => left.localeCompare(right)))
|
|
381
|
+
hash.update(file).update("\0").update(digest).update("\0");
|
|
382
|
+
return hash.digest("hex");
|
|
383
|
+
}
|
|
297
384
|
function changedPaths(before, after) { return [...new Set([...before.keys(), ...after.keys()])].filter((key) => before.get(key) !== after.get(key)).sort(); }
|
|
298
385
|
function checkRef(check) { return check.canonical_ref ?? check.id; }
|
|
386
|
+
function assertConsistentDeclarations(checks, commands) {
|
|
387
|
+
const seen = new Map();
|
|
388
|
+
for (const [index, check] of checks.entries()) {
|
|
389
|
+
const contract = JSON.stringify([check.run_at, commands[index], [...(check.required_artifacts ?? [])].sort(), [...(check.ports ?? [])].sort(), normalizedInputs(check.inputs ?? []), check.reuse ?? null]);
|
|
390
|
+
const ref = checkRef(check), prior = seen.get(ref);
|
|
391
|
+
if (prior && prior !== contract)
|
|
392
|
+
throw new AppError("check_declaration_conflict", "The same check reference has conflicting execution definitions", 2, { check_ref: ref });
|
|
393
|
+
seen.set(ref, contract);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
function environmentFingerprint(workspaceRoot) {
|
|
397
|
+
const environment = codeExecutionEnvironment(workspaceRoot);
|
|
398
|
+
return crypto.createHash("sha256").update(JSON.stringify(Object.entries(environment).filter(([, value]) => value !== undefined).sort(([left], [right]) => left.localeCompare(right)))).digest("hex");
|
|
399
|
+
}
|
|
400
|
+
function acquireWorkspaceGate(context, input) {
|
|
401
|
+
const operationId = workspaceOperationId(input.workspaceRoot);
|
|
402
|
+
const active = managedProcessStatus(context).find(item => item.operation_id === operationId && ["starting", "running", "stopping"].includes(item.state));
|
|
403
|
+
if (active)
|
|
404
|
+
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 });
|
|
405
|
+
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" } });
|
|
406
|
+
}
|
|
407
|
+
export function reconcileWorkspaceOwnership(context, workspaceRoot) {
|
|
408
|
+
const operationId = workspaceOperationId(workspaceRoot);
|
|
409
|
+
const active = managedProcessStatus(context).filter(item => item.operation_id === operationId && ["starting", "running", "stopping", "orphaned"].includes(item.state));
|
|
410
|
+
if (active.some(item => processTreeIsAlive(item) || managedOwnerIsAlive(item) !== false))
|
|
411
|
+
return;
|
|
412
|
+
// Registry IDs are host-wide, whereas receipts belong to one runtime DB.
|
|
413
|
+
// An unknown/foreign home must not be settled against this client's rows.
|
|
414
|
+
if (active.some(item => managedProcessRuntimeHome(item, context) !== context.ddFlowHome))
|
|
415
|
+
return;
|
|
416
|
+
for (const child of active.filter(item => item.kind !== "workspace-gate")) {
|
|
417
|
+
if (child.kind !== "check" || !child.project_id || !child.run_id || !child.check_id)
|
|
418
|
+
return;
|
|
419
|
+
const run = context.db.get("SELECT run_root FROM runs WHERE project_id = ? AND id = ?", [child.project_id, child.run_id]);
|
|
420
|
+
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]))
|
|
421
|
+
return;
|
|
422
|
+
reconcileUnfinishedChecks(context, { projectId: child.project_id, runId: child.run_id, workspaceRoot, runHome: run.run_root, receiptId: child.check_id });
|
|
423
|
+
// A crash may occur after receipt settlement but before registry settlement.
|
|
424
|
+
const current = managedProcessStatus(context).find(item => item.id === child.id);
|
|
425
|
+
if (!current || current.lease_token !== child.lease_token)
|
|
426
|
+
return;
|
|
427
|
+
if (!["stopped", "failed"].includes(current.state) && !finishManagedProcess(context, { id: child.id, leaseToken: child.lease_token, state: "stopped", reason: "workspace_check_owner_reconciled" }))
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
for (const gate of active.filter(item => item.kind === "workspace-gate"))
|
|
431
|
+
finishManagedProcess(context, { id: gate.id, leaseToken: gate.lease_token, state: "stopped", reason: "workspace_gate_owner_reconciled" });
|
|
432
|
+
}
|
|
433
|
+
function normalizedInputs(inputs) { for (const input of inputs)
|
|
434
|
+
validateCheckInputSyntax(input); return [...new Set(inputs.map((input) => { const normalized = path.posix.normalize(input.replaceAll("\\", "/")).replace(/^\.\/$/, ""); return normalized === "" || normalized === "." ? "." : normalized.replace(/\/$/, ""); }))].sort(); }
|
|
435
|
+
function validateCheckInputs(workspaceRoot, inputs) { const root = fs.realpathSync.native(workspaceRoot); for (const input of normalizedInputs(inputs)) {
|
|
436
|
+
if (input === ".git" || input.startsWith(".git/"))
|
|
437
|
+
throw new AppError("invalid_check_input", "Check inputs cannot include Git internals", 2, { input });
|
|
438
|
+
const candidate = path.resolve(workspaceRoot, input);
|
|
439
|
+
if (candidate !== workspaceRoot && !candidate.startsWith(`${workspaceRoot}${path.sep}`))
|
|
440
|
+
throw new AppError("invalid_check_input", "Check inputs must stay within the workspace", 2, { input });
|
|
441
|
+
if (fs.existsSync(candidate))
|
|
442
|
+
validateInputTree(root, candidate, input, new Set());
|
|
443
|
+
else
|
|
444
|
+
validateMissingInputParent(root, candidate, input);
|
|
445
|
+
} }
|
|
446
|
+
function validateCheckInputSyntax(input) { if (typeof input !== "string" || !input.trim() || path.isAbsolute(input) || input.split(/[\\/]/).includes(".."))
|
|
447
|
+
throw new AppError("invalid_check_input", "Check inputs must be non-empty relative workspace paths", 2, { input }); }
|
|
448
|
+
function validateMissingInputParent(root, candidate, input) { let parent = candidate; while (!fs.existsSync(parent))
|
|
449
|
+
parent = path.dirname(parent); const resolved = fs.realpathSync.native(parent); if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`))
|
|
450
|
+
throw new AppError("invalid_check_input", "Check input parent symlink escapes the workspace", 2, { input }); }
|
|
451
|
+
function validateInputTree(root, candidate, input, seen) {
|
|
452
|
+
const resolved = fs.realpathSync.native(candidate);
|
|
453
|
+
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`))
|
|
454
|
+
throw new AppError("invalid_check_input", "Check input symlink escapes the workspace", 2, { input });
|
|
455
|
+
if (seen.has(resolved))
|
|
456
|
+
throw new AppError("invalid_check_input", "Check input contains a symbolic-link cycle", 2, { input });
|
|
457
|
+
seen.add(resolved);
|
|
458
|
+
const stat = fs.statSync(candidate);
|
|
459
|
+
if (stat.isDirectory())
|
|
460
|
+
for (const entry of fs.readdirSync(candidate))
|
|
461
|
+
validateInputTree(root, path.join(candidate, entry), input, seen);
|
|
462
|
+
seen.delete(resolved);
|
|
463
|
+
}
|
|
464
|
+
function explicitInputFiles(root, inputs, ignoredRoots) {
|
|
465
|
+
const rootReal = fs.realpathSync.native(root), ignored = ignoredRoots.map(entry => path.resolve(entry)), result = new Map();
|
|
466
|
+
const visit = (file, relative, seen) => {
|
|
467
|
+
if (ignored.some(entry => file === entry || file.startsWith(`${entry}${path.sep}`)))
|
|
468
|
+
return;
|
|
469
|
+
if (!fs.existsSync(file)) {
|
|
470
|
+
result.set(relative, "<missing>");
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
const resolved = fs.realpathSync.native(file);
|
|
474
|
+
if (resolved !== rootReal && !resolved.startsWith(`${rootReal}${path.sep}`))
|
|
475
|
+
throw new AppError("invalid_check_input", "Check input symlink escapes the workspace", 2, { input: relative });
|
|
476
|
+
if (seen.has(resolved))
|
|
477
|
+
throw new AppError("invalid_check_input", "Check input contains a symbolic-link cycle", 2, { input: relative });
|
|
478
|
+
const stat = fs.statSync(file);
|
|
479
|
+
if (stat.isDirectory()) {
|
|
480
|
+
seen.add(resolved);
|
|
481
|
+
for (const entry of fs.readdirSync(file).sort())
|
|
482
|
+
visit(path.join(file, entry), path.posix.join(relative, entry), seen);
|
|
483
|
+
seen.delete(resolved);
|
|
484
|
+
}
|
|
485
|
+
else if (stat.isFile())
|
|
486
|
+
result.set(relative, crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"));
|
|
487
|
+
};
|
|
488
|
+
for (const input of inputs)
|
|
489
|
+
visit(path.resolve(root, input), input, new Set());
|
|
490
|
+
return [...result.entries()];
|
|
491
|
+
}
|
|
492
|
+
function checkInputHash(workspaceRoot, group, profileHash, inputFingerprint) { return crypto.createHash("sha256").update(JSON.stringify({ workspace_root: path.resolve(workspaceRoot), gate: 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"); }
|
|
493
|
+
function bindReceipt(receipt, group) { return { ...receipt, declaration_id: group.declarations[0].id, check_refs: group.declarations.map(checkRef), required_artifacts: group.requiredArtifacts, inputs: group.inputs }; }
|
|
494
|
+
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; }
|
|
495
|
+
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)
|
|
496
|
+
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)
|
|
497
|
+
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 ?? []); }
|
|
299
498
|
function parseArray(value, fallback) { try {
|
|
300
499
|
const parsed = JSON.parse(value);
|
|
301
500
|
return Array.isArray(parsed) && parsed.every((item) => typeof item === "string") ? parsed : fallback;
|
|
@@ -306,6 +505,12 @@ catch {
|
|
|
306
505
|
function isStringRecord(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.values(value).every((item) => typeof item === "string" && item.trim().length > 0); }
|
|
307
506
|
function isGateRecord(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.entries(value).every(([gate, aliases]) => ["work", "code", "readiness", "merge", "release", "external"].includes(gate) && Array.isArray(aliases) && aliases.every((item) => typeof item === "string" && item.startsWith("@check/"))); }
|
|
308
507
|
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)))); }
|
|
508
|
+
function isInputAliasRecord(workspaceRoot, value) { try {
|
|
509
|
+
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)));
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
return false;
|
|
513
|
+
} }
|
|
309
514
|
function portEnvironment(ports) { return Object.fromEntries(Object.entries(ports).map(([name, value]) => [`DD_FLOW_PORT_${name.toUpperCase()}`, String(value)])); }
|
|
310
515
|
function readReceiptResources(file) { try {
|
|
311
516
|
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
package/dist/services/runs.js
CHANGED
|
@@ -583,6 +583,22 @@ export function attachFlowRunStage(context, input) {
|
|
|
583
583
|
const updatedRun = requireRunById(context, project.id, run.id);
|
|
584
584
|
return { ok: true, run: flowRunSummary(updatedRun), stage_run: stageRun, index, flow_guidance: guidanceForRun(context, updatedRun, index) };
|
|
585
585
|
}
|
|
586
|
+
/** Keep synchronous stage settlement atomic; files are projections, SQLite is authoritative. */
|
|
587
|
+
export function withStageSettlement(context, operation) {
|
|
588
|
+
context.db.exec("SAVEPOINT stage_settlement");
|
|
589
|
+
try {
|
|
590
|
+
const result = operation();
|
|
591
|
+
if (result instanceof Promise)
|
|
592
|
+
throw new Error("Stage settlement must be synchronous");
|
|
593
|
+
context.db.exec("RELEASE stage_settlement");
|
|
594
|
+
return result;
|
|
595
|
+
}
|
|
596
|
+
catch (error) {
|
|
597
|
+
context.db.exec("ROLLBACK TO stage_settlement");
|
|
598
|
+
context.db.exec("RELEASE stage_settlement");
|
|
599
|
+
throw error;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
586
602
|
export function completeFlowRunStage(context, input) {
|
|
587
603
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
588
604
|
const run = resolveRun(context, project.id, input.runId);
|
package/dist/services/usage.js
CHANGED
|
@@ -165,7 +165,7 @@ export function recalculateRunUsage(context, input) {
|
|
|
165
165
|
const usageFinal = final && unsettledSessions.length === 0;
|
|
166
166
|
const persistRunProjection = !input.sessionId && !input.stage;
|
|
167
167
|
if (persistRunProjection) {
|
|
168
|
-
context.db.exec("
|
|
168
|
+
context.db.exec("SAVEPOINT run_usage");
|
|
169
169
|
try {
|
|
170
170
|
context.db.run("DELETE FROM usage WHERE project_id = ? AND run_id = ?", [input.projectId, input.runId]);
|
|
171
171
|
for (const row of rows) {
|
|
@@ -185,10 +185,11 @@ export function recalculateRunUsage(context, input) {
|
|
|
185
185
|
AND status = 'idle'
|
|
186
186
|
AND NOT EXISTS (SELECT 1 FROM work_sessions WHERE session_id = ? AND status = 'running')`, [observedAt, session.completed_at, input.projectId, input.runId, session.session_id, session.session_id]);
|
|
187
187
|
}
|
|
188
|
-
context.db.exec("
|
|
188
|
+
context.db.exec("RELEASE run_usage");
|
|
189
189
|
}
|
|
190
190
|
catch (error) {
|
|
191
|
-
context.db.exec("ROLLBACK");
|
|
191
|
+
context.db.exec("ROLLBACK TO run_usage");
|
|
192
|
+
context.db.exec("RELEASE run_usage");
|
|
192
193
|
throw error;
|
|
193
194
|
}
|
|
194
195
|
}
|