@deksden-com/dd-flow-cli 0.8.0 → 0.9.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +30 -3
- package/dist/build-info.json +4 -4
- package/dist/cli/help.js +13 -61
- package/dist/cli/run-cli.js +55 -24
- package/dist/domain/stage-catalog.js +2 -2
- package/dist/schemas/agent-profile.schema.json +17 -0
- package/dist/schemas/code-review-decision.schema.json +5 -3
- package/dist/schemas/merge-result.schema.json +15 -0
- package/dist/schemas/vnext-protocol-plan.schema.json +1 -1
- package/dist/services/cli-operation-classifier.js +1 -1
- package/dist/services/code-checks.js +125 -208
- package/dist/services/eval-snapshots.js +10 -1
- package/dist/services/harness-adapter.js +59 -0
- package/dist/services/hooks.js +87 -237
- package/dist/services/ids.js +18 -1
- package/dist/services/lifecycle-command.js +288 -0
- package/dist/services/merge-server.js +124 -0
- package/dist/services/runs.js +7 -2
- package/dist/services/sessions.js +18 -27
- package/dist/services/stage-pause.js +13 -0
- package/dist/services/vnext-code-review.js +70 -16
- package/dist/services/vnext-code.js +45 -18
- package/dist/services/vnext-execution-profile.js +6 -3
- package/dist/services/vnext-merge.js +330 -0
- package/dist/services/vnext-plan.js +13 -5
- package/dist/services/vnext-specify.js +5 -2
- package/dist/services/vnext-workspace-policy.js +8 -2
- package/dist/services/work-registry.js +35 -7
- package/dist/storage/database.js +66 -0
- package/package.json +2 -1
|
@@ -3,242 +3,159 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { AppError } from "../shared/errors.js";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
|
|
6
|
+
const profileRelativePath = path.join(".memory-bank", "spec", "engineering", "code-check-profile.json");
|
|
7
|
+
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 }; }
|
|
8
|
+
export function readCodeCheckProfile(workspaceRoot) {
|
|
9
|
+
const file = path.join(workspaceRoot, profileRelativePath);
|
|
10
|
+
if (!fs.existsSync(file))
|
|
11
|
+
return { profile: null, hash: null, file };
|
|
12
|
+
const bytes = fs.readFileSync(file);
|
|
13
|
+
const value = JSON.parse(bytes.toString("utf8"));
|
|
14
|
+
if (value.schema_id !== "dd-flow/code-check-profile@5")
|
|
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@5" });
|
|
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 and mandatory_by_gate are invalid", 2, { file });
|
|
18
|
+
for (const [gate, aliases] of Object.entries(value.mandatory_by_gate))
|
|
19
|
+
for (const alias of aliases ?? [])
|
|
20
|
+
if (!value.aliases[alias])
|
|
21
|
+
throw new AppError("invalid_code_check_profile", "A mandatory gate references an unknown alias", 2, { file, gate, alias });
|
|
22
|
+
return { profile: value, hash: crypto.createHash("sha256").update(bytes).digest("hex"), file };
|
|
14
23
|
}
|
|
15
24
|
export function validateCodeCheckCommands(workspaceRoot, commands) {
|
|
16
|
-
const file =
|
|
17
|
-
|
|
18
|
-
// deliberately share a command while carrying different acceptance roles.
|
|
19
|
-
if (!fs.existsSync(file))
|
|
25
|
+
const { profile, file } = readCodeCheckProfile(workspaceRoot);
|
|
26
|
+
if (!profile)
|
|
20
27
|
return [...commands];
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const aliases = profile.aliases && typeof profile.aliases === "object" && !Array.isArray(profile.aliases)
|
|
25
|
-
? profile.aliases
|
|
26
|
-
: {};
|
|
27
|
-
const required = profile.require_alias_for && typeof profile.require_alias_for === "object" && !Array.isArray(profile.require_alias_for)
|
|
28
|
-
? profile.require_alias_for
|
|
29
|
-
: {};
|
|
30
|
-
return commands.map((command) => {
|
|
31
|
-
if (command.startsWith("@check/")) {
|
|
32
|
-
const template = aliases[command];
|
|
33
|
-
if (typeof template !== "string" || !template.trim())
|
|
34
|
-
throw new AppError("unknown_code_check_alias", "CODE check alias is not defined by the project profile", 2, { alias: command, file });
|
|
35
|
-
return command;
|
|
36
|
-
}
|
|
37
|
-
for (const [prefix, alias] of Object.entries(required)) {
|
|
38
|
-
if (command === prefix || command.startsWith(`${prefix} `)) {
|
|
39
|
-
throw new AppError("raw_code_check_forbidden", "CODE check must use the project alias instead of a raw guarded command", 2, { command, required_alias: String(alias), file });
|
|
40
|
-
}
|
|
41
|
-
}
|
|
28
|
+
return commands.map((command) => { if (command.startsWith("@check/")) {
|
|
29
|
+
if (!profile.aliases[command]?.trim())
|
|
30
|
+
throw new AppError("unknown_code_check_alias", "CODE check alias is not defined by the project profile", 2, { alias: command, file });
|
|
42
31
|
return command;
|
|
43
|
-
})
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
32
|
+
} for (const [prefix, alias] of Object.entries(profile.require_alias_for))
|
|
33
|
+
if (command === prefix || command.startsWith(`${prefix} `))
|
|
34
|
+
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 }); return command; });
|
|
35
|
+
}
|
|
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
|
+
export function projectPolicyCheckDeclarations(workspaceRoot, gate) {
|
|
38
|
+
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" }; });
|
|
40
|
+
}
|
|
41
|
+
export function effectiveCheckDeclarations(workspaceRoot, declared, gates) { return gates.flatMap((gate) => [...declared.filter((check) => check.run_at === gate), ...projectPolicyCheckDeclarations(workspaceRoot, gate)]); }
|
|
42
|
+
export function aggregateCheckDeclarations(workspaceRoot) { return projectPolicyCheckDeclarations(workspaceRoot, "code"); }
|
|
43
|
+
export function finalCodeCheckDeclarations(workspaceRoot, declared) { return effectiveCheckDeclarations(workspaceRoot, declared, ["code", "readiness"]); }
|
|
44
|
+
export function checksForRunAt(checks, runAt) { return checks.filter((check) => check.run_at === runAt); }
|
|
45
|
+
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
|
+
if (check.run_at === "work" && aggregateCommands.has(check.command))
|
|
47
|
+
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
|
+
export function validateCheckDeclaration(workspaceRoot, check) {
|
|
49
|
+
if (check.run_at === "external")
|
|
50
|
+
return;
|
|
51
|
+
const { profile } = readCodeCheckProfile(workspaceRoot);
|
|
52
|
+
if (check.availability === "planned" && check.command.startsWith("@check/") && !profile?.aliases[check.command])
|
|
53
|
+
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
|
+
validateCodeCheckCommands(workspaceRoot, [check.command]);
|
|
55
|
+
if (check.command.startsWith("@check/") && check.definition && profile?.aliases[check.command] !== check.definition)
|
|
56
|
+
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
|
+
if (check.availability !== "planned")
|
|
58
|
+
return;
|
|
59
|
+
if (!check.command.startsWith("@check/"))
|
|
60
|
+
throw new AppError("planned_check_requires_alias", "A planned CODE check must declare a new @check/... alias", 2, { check_id: check.id, command: check.command });
|
|
61
|
+
if (!check.definition?.trim())
|
|
62
|
+
throw new AppError("planned_check_definition_missing", "A planned CODE check must declare its exact alias definition", 2, { check_id: check.id, command: check.command });
|
|
63
|
+
if (!profile?.aliases[check.command])
|
|
64
|
+
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 });
|
|
57
65
|
}
|
|
58
66
|
export async function runCodeChecks(context, input) {
|
|
59
|
-
const receipts = [];
|
|
60
67
|
for (const check of input.checks)
|
|
61
68
|
validateCheckDeclaration(input.workspaceRoot, check);
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
69
|
+
const { hash: profileHash } = readCodeCheckProfile(input.workspaceRoot);
|
|
70
|
+
const resolved = resolveCodeCheckCommands(input.workspaceRoot, input.runId, input.checks.map((check) => check.command));
|
|
71
|
+
const groups = deduplicate(input.checks, resolved);
|
|
72
|
+
const receipts = [];
|
|
73
|
+
for (let index = 0; index < groups.length; index += 1) {
|
|
74
|
+
const group = groups[index];
|
|
66
75
|
const ordinal = (context.db.get("SELECT COUNT(*) AS count FROM check_receipts WHERE work_id IS ? AND run_id = ?", [input.workId ?? null, input.runId])?.count ?? 0) + 1;
|
|
67
76
|
const localId = `RCP-${String(ordinal).padStart(3, "0")}`;
|
|
68
77
|
const id = `${input.workId ?? input.runId}/${localId}`;
|
|
69
78
|
const directory = path.join(input.runHome, input.artifactDir ?? "05-code", "checks", localId);
|
|
70
|
-
|
|
79
|
+
const evidenceDir = path.join(directory, "artifacts");
|
|
80
|
+
fs.mkdirSync(evidenceDir, { recursive: true });
|
|
71
81
|
const stdoutPath = path.join(directory, "stdout.log");
|
|
72
82
|
const stderrPath = path.join(directory, "stderr.log");
|
|
73
83
|
const receiptPath = path.join(directory, "receipt.json");
|
|
74
|
-
const
|
|
75
|
-
|
|
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");
|
|
76
87
|
const startedAt = context.now();
|
|
77
|
-
input.progress?.(`check ${index + 1}/${
|
|
88
|
+
input.progress?.(`check ${index + 1}/${groups.length} started: ${group.command}`);
|
|
78
89
|
const stdout = fs.createWriteStream(stdoutPath);
|
|
79
90
|
const stderr = fs.createWriteStream(stderrPath);
|
|
80
|
-
const result = await runCheck(command, input.workspaceRoot, { ...codeExecutionEnvironment(input.workspaceRoot), DD_FLOW_EVIDENCE_DIR: evidenceDir }, stdout, stderr, (elapsed) => {
|
|
81
|
-
input.progress?.(`check ${index + 1}/${commands.length} still running (${elapsed}s): ${command}`);
|
|
82
|
-
});
|
|
91
|
+
const result = await runCheck(group.command, input.workspaceRoot, { ...codeExecutionEnvironment(input.workspaceRoot), DD_FLOW_EVIDENCE_DIR: evidenceDir }, stdout, stderr, (elapsed) => input.progress?.(`check ${index + 1}/${groups.length} still running (${elapsed}s): ${group.command}`));
|
|
83
92
|
await Promise.all([closeStream(stdout), closeStream(stderr)]);
|
|
84
|
-
const
|
|
85
|
-
const
|
|
86
|
-
const artifacts = collectRequiredArtifacts(evidenceDir,
|
|
87
|
-
const status = exitCode === 0 && !result.error && artifacts.complete ? "passed" : "failed";
|
|
93
|
+
const after = workspaceState(input.workspaceRoot);
|
|
94
|
+
const mutationPaths = changedPaths(before.files, after.files);
|
|
95
|
+
const artifacts = collectRequiredArtifacts(evidenceDir, group.requiredArtifacts);
|
|
88
96
|
if (!artifacts.complete)
|
|
89
97
|
fs.appendFileSync(stderrPath, `\nmissing required evidence artifacts: ${artifacts.missing.join(", ")}\n`);
|
|
90
98
|
if (result.error)
|
|
91
99
|
fs.appendFileSync(stderrPath, `\n${result.error}\n`);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
scope: input.scope,
|
|
97
|
-
work_id: input.workId ?? null,
|
|
98
|
-
command,
|
|
99
|
-
status,
|
|
100
|
-
exit_code: exitCode,
|
|
101
|
-
stdout_path: stdoutPath,
|
|
102
|
-
stderr_path: stderrPath,
|
|
103
|
-
receipt_path: receiptPath,
|
|
104
|
-
workspace_fingerprint: workspaceFingerprint(input.workspaceRoot),
|
|
105
|
-
artifacts: artifacts.items,
|
|
106
|
-
started_at: startedAt,
|
|
107
|
-
finished_at: finishedAt
|
|
108
|
-
};
|
|
100
|
+
if (mutationPaths.length)
|
|
101
|
+
fs.appendFileSync(stderrPath, `\ncheck mutated project workspace: ${mutationPaths.join(", ")}\n`);
|
|
102
|
+
const status = result.aborted ? "aborted" : result.exitCode === 0 && !result.error && artifacts.complete && mutationPaths.length === 0 ? "passed" : "failed";
|
|
103
|
+
const receipt = { id, local_id: localId, declaration_id: group.declarations[0].id, check_refs: group.declarations.map(checkRef), gate: group.gate, scope: input.scope, work_id: input.workId ?? null, command: group.command, input_hash: inputHash, verification_epoch: epoch, status, exit_code: result.exitCode, stdout_path: stdoutPath, stderr_path: stderrPath, receipt_path: receiptPath, workspace_fingerprint: before.fingerprint, before_fingerprint: before.fingerprint, after_fingerprint: after.fingerprint, profile_hash: profileHash, mutation_paths: mutationPaths, artifacts: artifacts.items, started_at: startedAt, finished_at: context.now() };
|
|
109
104
|
fs.writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`);
|
|
110
|
-
context.db.run("INSERT INTO check_receipts (id, project_id, run_id, work_id, scope, declaration_id, command, status, exit_code, stdout_path, stderr_path, receipt_path, workspace_fingerprint, artifacts_json, started_at, finished_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [id, input.projectId, input.runId, input.workId ?? null, input.scope,
|
|
105
|
+
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [id, input.projectId, input.runId, input.workId ?? null, input.scope, receipt.declaration_id, JSON.stringify(receipt.check_refs), receipt.gate, receipt.command, receipt.input_hash, receipt.verification_epoch, receipt.status, receipt.exit_code, stdoutPath, stderrPath, receiptPath, receipt.workspace_fingerprint, receipt.before_fingerprint, receipt.after_fingerprint, receipt.profile_hash, JSON.stringify(receipt.mutation_paths), JSON.stringify(receipt.artifacts), receipt.started_at, receipt.finished_at]);
|
|
111
106
|
receipts.push(receipt);
|
|
112
|
-
input.progress?.(`check ${index + 1}/${
|
|
107
|
+
input.progress?.(`check ${index + 1}/${groups.length} ${status}: ${group.command}`);
|
|
113
108
|
}
|
|
114
109
|
return receipts;
|
|
115
110
|
}
|
|
116
|
-
export function
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
return [];
|
|
120
|
-
const profile = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
121
|
-
if (profile.schema_id !== "dd-flow/code-check-profile@4")
|
|
122
|
-
throw new AppError("invalid_code_check_profile", "CODE check profile has an unsupported schema", 2, { file, schema_id: profile.schema_id ?? null });
|
|
123
|
-
if (!Array.isArray(profile.aggregate_commands) || !profile.aggregate_commands.every((command) => typeof command === "string" && command.trim()))
|
|
124
|
-
throw new AppError("invalid_code_check_profile", "aggregate_commands must be an array of commands", 2, { file });
|
|
125
|
-
return profile.aggregate_commands.map((command, index) => ({ id: `CHK-POLICY-CODE-${String(index + 1).padStart(3, "0")}`, command, purpose: "Mandatory project-wide policy gate.", run_at: "code", availability: "available" }));
|
|
126
|
-
}
|
|
127
|
-
/**
|
|
128
|
-
* A project-wide gate can observe files owned by more than one CODE Work.
|
|
129
|
-
* Keep it at the CODE/readiness fan-in, where the engine can create a repair
|
|
130
|
-
* Work with the failed receipt and the right project-local repair context.
|
|
131
|
-
*/
|
|
132
|
-
export function validateCheckPlacement(workspaceRoot, checks) {
|
|
133
|
-
const aggregate = new Set(aggregateCheckDeclarations(workspaceRoot).map((check) => check.command));
|
|
134
|
-
for (const check of checks) {
|
|
135
|
-
if (check.run_at === "work" && check.availability === "available" && aggregate.has(check.command)) {
|
|
136
|
-
throw new AppError("aggregate_check_requires_code_gate", "A project aggregate check must run at CODE or readiness, not inside one scoped Work", 2, {
|
|
137
|
-
check_id: check.id,
|
|
138
|
-
command: check.command,
|
|
139
|
-
run_at: check.run_at
|
|
140
|
-
});
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
export function checksForRunAt(checks, runAt) {
|
|
145
|
-
return checks.filter((check) => check.run_at === runAt);
|
|
146
|
-
}
|
|
147
|
-
/** One SSOT for the final CODE gate, reused after CODE-REVIEW repairs. */
|
|
148
|
-
export function finalCodeCheckDeclarations(workspaceRoot, declared) {
|
|
149
|
-
const selected = [...aggregateCheckDeclarations(workspaceRoot), ...declared.filter((check) => check.run_at === "code" || check.run_at === "readiness")];
|
|
150
|
-
const unique = new Map();
|
|
151
|
-
for (const check of selected)
|
|
152
|
-
if (!unique.has(check.id))
|
|
153
|
-
unique.set(check.id, check);
|
|
154
|
-
return [...unique.values()];
|
|
155
|
-
}
|
|
156
|
-
/** Prevent an unchanged failed stage gate from rerunning expensive commands. */
|
|
157
|
-
export function unchangedFinalGateFailures(context, input) {
|
|
158
|
-
const wanted = new Set(input.declarations.map((check) => check.id));
|
|
159
|
-
const current = workspaceFingerprint(input.workspaceRoot);
|
|
160
|
-
const latest = new Map();
|
|
161
|
-
for (const receipt of checkReceipts(context, { projectId: input.projectId, runId: input.runId }).filter((item) => item.scope === "aggregate" && wanted.has(item.declaration_id)))
|
|
162
|
-
latest.set(receipt.declaration_id, receipt);
|
|
163
|
-
return [...latest.values()].filter((receipt) => receipt.status === "failed" && receipt.workspace_fingerprint === current);
|
|
164
|
-
}
|
|
165
|
-
export function validateCheckDeclaration(workspaceRoot, check) {
|
|
166
|
-
if (check.availability !== "planned") {
|
|
167
|
-
validateCodeCheckCommands(workspaceRoot, [check.command]);
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
if (!check.command.startsWith("@check/")) {
|
|
171
|
-
throw new AppError("planned_check_requires_alias", "A planned CODE check must declare a new @check/... alias", 2, { check_id: check.id, command: check.command });
|
|
172
|
-
}
|
|
173
|
-
if (!check.definition?.trim()) {
|
|
174
|
-
throw new AppError("planned_check_definition_missing", "A planned CODE check must declare its exact alias definition", 2, { check_id: check.id, command: check.command });
|
|
175
|
-
}
|
|
176
|
-
const file = path.join(workspaceRoot, ".memory-bank", "spec", "engineering", "code-check-profile.json");
|
|
177
|
-
const profile = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : {};
|
|
178
|
-
const aliases = profile.aliases && typeof profile.aliases === "object" && !Array.isArray(profile.aliases) ? profile.aliases : {};
|
|
179
|
-
const actual = aliases[check.command];
|
|
180
|
-
if (typeof actual !== "string")
|
|
181
|
-
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 });
|
|
182
|
-
if (check.definition && actual !== check.definition)
|
|
183
|
-
throw new AppError("planned_check_definition_mismatch", "A planned check alias does not match its declared definition", 2, { check_id: check.id, command: check.command, expected: check.definition, actual });
|
|
184
|
-
}
|
|
185
|
-
function collectRequiredArtifacts(root, required) {
|
|
186
|
-
const missing = [];
|
|
187
|
-
const items = required.map((relative) => {
|
|
188
|
-
if (path.isAbsolute(relative) || relative.split(path.sep).includes(".."))
|
|
189
|
-
throw new AppError("invalid_evidence_path", "Required evidence paths must be relative to DD_FLOW_EVIDENCE_DIR", 2, { path: relative });
|
|
190
|
-
const file = path.join(root, relative);
|
|
191
|
-
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
|
192
|
-
missing.push(relative);
|
|
193
|
-
return null;
|
|
194
|
-
}
|
|
195
|
-
return { path: relative, sha256: crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex") };
|
|
196
|
-
}).filter((item) => item !== null);
|
|
197
|
-
return { complete: missing.length === 0, missing, items };
|
|
198
|
-
}
|
|
199
|
-
export function workspaceFingerprint(root) {
|
|
200
|
-
const git = spawnSync("git", ["ls-files", "-co", "--exclude-standard", "-z"], { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
201
|
-
const listing = git.status === 0 ? git.stdout.split("\0").filter(Boolean).sort() : listFiles(root);
|
|
202
|
-
const hash = crypto.createHash("sha256");
|
|
203
|
-
for (const relative of listing) {
|
|
204
|
-
const file = path.join(root, relative);
|
|
205
|
-
hash.update(relative).update("\0");
|
|
206
|
-
hash.update(fs.existsSync(file) && fs.statSync(file).isFile() ? fs.readFileSync(file) : "<missing>");
|
|
207
|
-
hash.update("\0");
|
|
208
|
-
}
|
|
209
|
-
return hash.digest("hex");
|
|
210
|
-
}
|
|
211
|
-
function listFiles(root, current = root) {
|
|
212
|
-
return fs.readdirSync(current, { withFileTypes: true }).flatMap((entry) => {
|
|
213
|
-
if ([".git", "node_modules"].includes(entry.name))
|
|
214
|
-
return [];
|
|
215
|
-
const absolute = path.join(current, entry.name);
|
|
216
|
-
return entry.isDirectory() ? listFiles(root, absolute) : [path.relative(root, absolute)];
|
|
217
|
-
}).sort();
|
|
218
|
-
}
|
|
219
|
-
function runCheck(command, cwd, environment, stdout, stderr, heartbeat) {
|
|
220
|
-
return new Promise((resolve) => {
|
|
221
|
-
const started = Date.now();
|
|
222
|
-
const child = spawn("/bin/sh", ["-lc", command], { cwd, env: environment, stdio: ["ignore", "pipe", "pipe"] });
|
|
223
|
-
child.stdout.pipe(stdout, { end: false });
|
|
224
|
-
child.stderr.pipe(stderr, { end: false });
|
|
225
|
-
const progress = setInterval(() => heartbeat(Math.floor((Date.now() - started) / 1000)), 15_000);
|
|
226
|
-
let timedOut = false;
|
|
227
|
-
const timeout = setTimeout(() => { timedOut = true; child.kill("SIGTERM"); }, 15 * 60 * 1000);
|
|
228
|
-
let error = null;
|
|
229
|
-
child.once("error", (value) => { error = value.message; });
|
|
230
|
-
child.once("close", (exitCode, signal) => {
|
|
231
|
-
clearInterval(progress);
|
|
232
|
-
clearTimeout(timeout);
|
|
233
|
-
resolve({ exitCode, error: error ?? (timedOut ? "timed out after 900 seconds" : signal ? `terminated by ${signal}` : null) });
|
|
234
|
-
});
|
|
235
|
-
});
|
|
236
|
-
}
|
|
237
|
-
function closeStream(stream) {
|
|
238
|
-
return new Promise((resolve, reject) => stream.end((error) => error ? reject(error) : resolve()));
|
|
239
|
-
}
|
|
111
|
+
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))))
|
|
112
|
+
for (const ref of receipt.check_refs)
|
|
113
|
+
latest.set(ref, receipt); return [...new Set(latest.values())].filter((receipt) => receipt.status === "failed" && receipt.before_fingerprint === current); }
|
|
240
114
|
export function checkReceipts(context, input) {
|
|
241
115
|
const where = input.workId ? "project_id = ? AND run_id = ? AND work_id = ?" : "project_id = ? AND run_id = ?";
|
|
242
116
|
const params = input.workId ? [input.projectId, input.runId, input.workId] : [input.projectId, input.runId];
|
|
243
|
-
return context.db.all(`SELECT id, declaration_id, scope, work_id, command, status, exit_code, stdout_path, stderr_path, receipt_path, workspace_fingerprint, artifacts_json, started_at, finished_at FROM check_receipts WHERE ${where} ORDER BY started_at, id`, params).map((
|
|
244
|
-
}
|
|
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 started_at, id`, 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, []), artifacts: JSON.parse(row.artifacts_json) }));
|
|
118
|
+
}
|
|
119
|
+
export function workspaceFingerprint(root) { return workspaceState(root).fingerprint; }
|
|
120
|
+
function deduplicate(checks, commands) { const groups = new Map(); checks.forEach((check, index) => { const artifacts = [...(check.required_artifacts ?? [])].sort(); const key = JSON.stringify([check.run_at, commands[index], artifacts]); const current = groups.get(key); if (current)
|
|
121
|
+
current.declarations.push(check);
|
|
122
|
+
else
|
|
123
|
+
groups.set(key, { gate: check.run_at, command: commands[index], declarations: [check], requiredArtifacts: artifacts }); }); return [...groups.values()]; }
|
|
124
|
+
function workspaceState(root) { const git = spawnSync("git", ["ls-files", "-co", "--exclude-standard", "-z"], { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); const listing = git.status === 0 ? git.stdout.split("\0").filter(Boolean).sort() : listFiles(root); const files = new Map(); const hash = crypto.createHash("sha256"); for (const relative of listing) {
|
|
125
|
+
const file = path.join(root, relative);
|
|
126
|
+
const content = fs.existsSync(file) && fs.statSync(file).isFile() ? fs.readFileSync(file) : Buffer.from("<missing>");
|
|
127
|
+
const digest = crypto.createHash("sha256").update(content).digest("hex");
|
|
128
|
+
files.set(relative, digest);
|
|
129
|
+
hash.update(relative).update("\0").update(digest).update("\0");
|
|
130
|
+
} return { fingerprint: hash.digest("hex"), files }; }
|
|
131
|
+
function changedPaths(before, after) { return [...new Set([...before.keys(), ...after.keys()])].filter((key) => before.get(key) !== after.get(key)).sort(); }
|
|
132
|
+
function checkRef(check) { return check.canonical_ref ?? check.id; }
|
|
133
|
+
function parseArray(value, fallback) { try {
|
|
134
|
+
const parsed = JSON.parse(value);
|
|
135
|
+
return Array.isArray(parsed) && parsed.every((item) => typeof item === "string") ? parsed : fallback;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return fallback;
|
|
139
|
+
} }
|
|
140
|
+
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", "external"].includes(gate) && Array.isArray(aliases) && aliases.every((item) => typeof item === "string" && item.startsWith("@check/"))); }
|
|
142
|
+
function collectRequiredArtifacts(root, required) { const missing = []; const items = []; for (const relative of required) {
|
|
143
|
+
if (path.isAbsolute(relative) || relative.split(/[\\/]/).includes(".."))
|
|
144
|
+
throw new AppError("invalid_evidence_path", "Required evidence paths must be relative to DD_FLOW_EVIDENCE_DIR", 2, { path: relative });
|
|
145
|
+
const file = path.join(root, relative);
|
|
146
|
+
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
|
147
|
+
missing.push(relative);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
items.push({ path: relative, sha256: crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex") });
|
|
151
|
+
} return { complete: missing.length === 0, missing, items }; }
|
|
152
|
+
function listFiles(root, current = root) { return fs.readdirSync(current, { withFileTypes: true }).flatMap((entry) => { if ([".git", "node_modules"].includes(entry.name))
|
|
153
|
+
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) { return new Promise((resolve) => { const started = Date.now(); const child = spawn("/bin/sh", ["-lc", command], { cwd, env: environment, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32" }); child.stdout.pipe(stdout, { end: false }); child.stderr.pipe(stderr, { end: false }); const progress = setInterval(() => heartbeat(Math.floor((Date.now() - started) / 1000)), 15_000); let timedOut = false; let error = null; let escalation; const timeout = setTimeout(() => { timedOut = true; terminateProcessGroup(child.pid, "SIGTERM"); escalation = setTimeout(() => terminateProcessGroup(child.pid, "SIGKILL"), 5_000); }, 15 * 60 * 1000); child.once("error", (value) => { error = value.message; }); child.once("close", (exitCode, signal) => { clearInterval(progress); clearTimeout(timeout); if (escalation)
|
|
155
|
+
clearTimeout(escalation); resolve({ exitCode, error: error ?? (timedOut ? "timed out after 900 seconds" : signal ? `terminated by ${signal}` : null), aborted: timedOut || Boolean(signal) || Boolean(error) }); }); }); }
|
|
156
|
+
function terminateProcessGroup(pid, signal) { if (!pid)
|
|
157
|
+
return; try {
|
|
158
|
+
process.kill(process.platform === "win32" ? pid : -pid, signal);
|
|
159
|
+
}
|
|
160
|
+
catch { /* already exited */ } }
|
|
161
|
+
function closeStream(stream) { return new Promise((resolve, reject) => stream.end((error) => error ? reject(error) : resolve())); }
|
|
@@ -61,9 +61,10 @@ export function createEvalRunSnapshot(context, input) {
|
|
|
61
61
|
assertStageEntry(status.index.stage_runs ?? [], input.stageEntry);
|
|
62
62
|
else
|
|
63
63
|
throw new AppError("usage", "Snapshot requires exactly one of stageEntry or candidate", 2);
|
|
64
|
+
const allowedCreatedResultSchema = input.stageEntry ? createdWorkSchemaForStage(input.stageEntry) : null;
|
|
64
65
|
const activeChildren = input.candidate
|
|
65
66
|
? context.db.get("SELECT COUNT(*) AS count FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id IS NOT NULL AND status IN ('created', 'running', 'paused')", [project.id, input.runId])?.count ?? 0
|
|
66
|
-
: context.db.get("SELECT COUNT(*) AS count FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id IS NOT NULL AND (status IN ('running', 'paused') OR (status = 'created' AND (?
|
|
67
|
+
: context.db.get("SELECT COUNT(*) AS count FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id IS NOT NULL AND (status IN ('running', 'paused') OR (status = 'created' AND (? IS NULL OR COALESCE(result_schema, '') NOT LIKE ?)))", [project.id, input.runId, allowedCreatedResultSchema, allowedCreatedResultSchema ? `${allowedCreatedResultSchema}@%` : ""])?.count ?? 0;
|
|
67
68
|
if (activeChildren > 0)
|
|
68
69
|
throw new AppError("snapshot_not_quiescent", `${input.candidate ? "Candidate" : "Stage-entry"} snapshot requires no active child Work`, 1, { run_id: input.runId, active_children: activeChildren });
|
|
69
70
|
const output = path.resolve(input.output);
|
|
@@ -106,6 +107,14 @@ export function createEvalRunSnapshot(context, input) {
|
|
|
106
107
|
writeJson(path.join(output, "snapshot.json"), manifest);
|
|
107
108
|
return { ok: true, snapshot: output, ...manifest };
|
|
108
109
|
}
|
|
110
|
+
/** A not-yet-started Work packet may be part of the target Stage's entry state. */
|
|
111
|
+
function createdWorkSchemaForStage(stage) {
|
|
112
|
+
if (stage === "code")
|
|
113
|
+
return "dd-flow/code-work-result";
|
|
114
|
+
if (stage === "merge")
|
|
115
|
+
return "dd-flow/merge-result";
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
109
118
|
export function restoreEvalRunSnapshot(context, input) {
|
|
110
119
|
const snapshot = path.resolve(input.snapshot);
|
|
111
120
|
const manifest = readManifest(snapshot);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { AppError } from "../shared/errors.js";
|
|
3
|
+
/** Runs one harness command with the lifecycle guarantees shared by agent services. */
|
|
4
|
+
export function runHarnessAdapter(input) {
|
|
5
|
+
const timeoutMs = input.timeoutMs ?? 120_000;
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
const detached = process.platform !== "win32";
|
|
8
|
+
const child = spawn(input.executable, input.args, { env: input.env, detached, stdio: ["ignore", "pipe", "pipe"] });
|
|
9
|
+
let stdout = "";
|
|
10
|
+
let stderr = "";
|
|
11
|
+
let timedOut = false;
|
|
12
|
+
let killTimer;
|
|
13
|
+
const terminate = (signal) => { try {
|
|
14
|
+
if (detached && child.pid)
|
|
15
|
+
process.kill(-child.pid, signal);
|
|
16
|
+
else
|
|
17
|
+
child.kill(signal);
|
|
18
|
+
}
|
|
19
|
+
catch { /* The process already settled. */ } };
|
|
20
|
+
const heartbeat = input.progress && input.progressMessage ? setInterval(() => input.progress?.(`${input.progressMessage}; waiting for adapter completion`), 15_000) : undefined;
|
|
21
|
+
const timer = setTimeout(() => { timedOut = true; terminate("SIGTERM"); killTimer = setTimeout(() => terminate("SIGKILL"), 5_000); }, timeoutMs);
|
|
22
|
+
const evidence = (value) => input.onEvidence?.({ executable: input.executable, args: input.args, ...value, at: new Date().toISOString() });
|
|
23
|
+
child.stdout.setEncoding("utf8").on("data", (chunk) => { stdout += chunk; });
|
|
24
|
+
child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr += chunk; });
|
|
25
|
+
child.once("error", (error) => { clearTimeout(timer); if (heartbeat)
|
|
26
|
+
clearInterval(heartbeat); if (killTimer)
|
|
27
|
+
clearTimeout(killTimer); evidence({ status: null, error: error.message }); reject(new AppError("merge_adapter_failed", "Harness adapter call failed", 1, { executable: input.executable, args: input.args.slice(0, 3), error: error.message })); });
|
|
28
|
+
child.once("close", (status, signal) => {
|
|
29
|
+
clearTimeout(timer);
|
|
30
|
+
if (heartbeat)
|
|
31
|
+
clearInterval(heartbeat);
|
|
32
|
+
if (killTimer)
|
|
33
|
+
clearTimeout(killTimer);
|
|
34
|
+
evidence({ status, signal, timed_out: timedOut, stdout, stderr });
|
|
35
|
+
if (timedOut)
|
|
36
|
+
return reject(new AppError("merge_adapter_timeout", "Harness adapter call timed out", 1, { executable: input.executable, args: input.args.slice(0, 3), timeout_ms: timeoutMs }));
|
|
37
|
+
if (status !== 0)
|
|
38
|
+
return reject(new AppError("merge_adapter_failed", "Harness adapter call failed", 1, { executable: input.executable, args: input.args.slice(0, 3), status, signal, stderr }));
|
|
39
|
+
try {
|
|
40
|
+
resolve(JSON.parse(stdout));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
reject(new AppError("merge_adapter_invalid", "Harness adapter returned invalid JSON", 1, { executable: input.executable, stdout }));
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
export function adapterSessionId(value) {
|
|
49
|
+
if (!value || typeof value !== "object")
|
|
50
|
+
return null;
|
|
51
|
+
for (const [key, item] of Object.entries(value)) {
|
|
52
|
+
if (["session_id", "sessionId", "provider_session_id", "providerSessionId", "adapter_session_id", "adapterSessionId", "thread_id", "threadId", "conversation_id"].includes(key) && typeof item === "string")
|
|
53
|
+
return item;
|
|
54
|
+
const nested = adapterSessionId(item);
|
|
55
|
+
if (nested)
|
|
56
|
+
return nested;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|