@deksden-com/dd-flow-cli 0.9.0-beta.1 → 0.9.0-beta.11
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 +77 -0
- package/dist/build-info.json +5 -5
- package/dist/cli/help.js +3 -3
- package/dist/cli/run-cli.js +127 -8
- package/dist/runtime/context.js +3 -1
- package/dist/schemas/code-review-result.schema.json +1 -1
- package/dist/schemas/code-work-batch.schema.json +4 -3
- package/dist/schemas/code-work-result.schema.json +1 -1
- package/dist/schemas/harness-config.schema.json +23 -0
- package/dist/schemas/plan-review-decision.schema.json +1 -1
- package/dist/schemas/vnext-protocol-plan.schema.json +1 -1
- package/dist/services/cleanup.js +18 -8
- package/dist/services/code-checks.js +194 -44
- package/dist/services/engines.js +4 -4
- package/dist/services/eval-snapshots.js +10 -5
- package/dist/services/harness-config.js +66 -0
- package/dist/services/hooks.js +62 -28
- package/dist/services/lanes.js +1 -0
- package/dist/services/managed-processes.js +169 -0
- package/dist/services/merge-server.js +8 -2
- package/dist/services/portable-refs.js +57 -0
- package/dist/services/prompts.js +4 -2
- package/dist/services/run-engine-bindings.js +19 -61
- package/dist/services/run-projection.js +10 -8
- package/dist/services/runs.js +71 -9
- package/dist/services/schema-validation.js +11 -11
- package/dist/services/session-identity.js +19 -0
- package/dist/services/sessions.js +26 -11
- package/dist/services/stage-lifecycle.js +15 -8
- package/dist/services/stage-pause.js +35 -20
- package/dist/services/usage.js +74 -42
- package/dist/services/vnext-code-review.js +82 -41
- package/dist/services/vnext-code.js +98 -34
- package/dist/services/vnext-fanout.js +5 -12
- package/dist/services/vnext-merge.js +144 -65
- package/dist/services/vnext-plan-review.js +50 -35
- package/dist/services/vnext-plan.js +69 -21
- package/dist/services/vnext-protocolize.js +6 -6
- package/dist/services/vnext-specify.js +6 -6
- package/dist/services/work-registry.js +150 -40
- package/dist/storage/database.js +128 -2
- package/package.json +1 -1
- package/tools/audit-runtime-fix-boundaries.mjs +96 -0
|
@@ -3,7 +3,12 @@ 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
|
+
import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessStatus, processIsAlive, registerManagedProcess, reservePorts } from "./managed-processes.js";
|
|
6
7
|
const profileRelativePath = path.join(".memory-bank", "spec", "engineering", "code-check-profile.json");
|
|
8
|
+
// Receipt reservation precedes child spawn so an interrupted CLI leaves an
|
|
9
|
+
// auditable attempt. A second caller must not mistake that small handoff window
|
|
10
|
+
// for a dead check and overwrite its outcome.
|
|
11
|
+
const receiptStartingGraceMs = 30_000;
|
|
7
12
|
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
13
|
export function readCodeCheckProfile(workspaceRoot) {
|
|
9
14
|
const file = path.join(workspaceRoot, profileRelativePath);
|
|
@@ -11,14 +16,17 @@ export function readCodeCheckProfile(workspaceRoot) {
|
|
|
11
16
|
return { profile: null, hash: null, file };
|
|
12
17
|
const bytes = fs.readFileSync(file);
|
|
13
18
|
const value = JSON.parse(bytes.toString("utf8"));
|
|
14
|
-
if (value.schema_id !== "dd-flow/code-check-profile@
|
|
15
|
-
throw new AppError("invalid_code_check_profile", "CODE check profile has an unsupported schema", 2, { file, schema_id: value.schema_id ?? null, required: "dd-flow/code-check-profile@
|
|
16
|
-
if (!isStringRecord(value.aliases) || !isStringRecord(value.require_alias_for) || !isGateRecord(value.mandatory_by_gate))
|
|
17
|
-
throw new AppError("invalid_code_check_profile", "CODE check profile aliases, require_alias_for
|
|
19
|
+
if (value.schema_id !== "dd-flow/code-check-profile@6")
|
|
20
|
+
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" });
|
|
21
|
+
if (!isStringRecord(value.aliases) || !isStringRecord(value.require_alias_for) || !isGateRecord(value.mandatory_by_gate) || !isPortAliasRecord(value.ports_by_alias))
|
|
22
|
+
throw new AppError("invalid_code_check_profile", "CODE check profile aliases, require_alias_for, mandatory_by_gate or ports_by_alias are invalid", 2, { file });
|
|
18
23
|
for (const [gate, aliases] of Object.entries(value.mandatory_by_gate))
|
|
19
24
|
for (const alias of aliases ?? [])
|
|
20
25
|
if (!value.aliases[alias])
|
|
21
26
|
throw new AppError("invalid_code_check_profile", "A mandatory gate references an unknown alias", 2, { file, gate, alias });
|
|
27
|
+
for (const alias of Object.keys(value.ports_by_alias ?? {}))
|
|
28
|
+
if (!value.aliases[alias])
|
|
29
|
+
throw new AppError("invalid_code_check_profile", "A port declaration references an unknown alias", 2, { file, alias });
|
|
22
30
|
return { profile: value, hash: crypto.createHash("sha256").update(bytes).digest("hex"), file };
|
|
23
31
|
}
|
|
24
32
|
export function validateCodeCheckCommands(workspaceRoot, commands) {
|
|
@@ -33,14 +41,20 @@ export function validateCodeCheckCommands(workspaceRoot, commands) {
|
|
|
33
41
|
if (command === prefix || command.startsWith(`${prefix} `))
|
|
34
42
|
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
43
|
}
|
|
36
|
-
export function resolveCodeCheckCommands(workspaceRoot, runId, commands) {
|
|
44
|
+
export function resolveCodeCheckCommands(workspaceRoot, runId, commands) {
|
|
45
|
+
const validated = validateCodeCheckCommands(workspaceRoot, commands);
|
|
46
|
+
const { profile } = readCodeCheckProfile(workspaceRoot);
|
|
47
|
+
// `{run_id}` is a declaration-level placeholder, not an alias feature.
|
|
48
|
+
// Plans may use it in a focused raw command as well as in project aliases.
|
|
49
|
+
return validated.map((command) => (command.startsWith("@check/") ? profile.aliases[command] : command).replaceAll("{run_id}", runId));
|
|
50
|
+
}
|
|
37
51
|
export function projectPolicyCheckDeclarations(workspaceRoot, gate) {
|
|
38
52
|
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" }; });
|
|
53
|
+
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] } : {}) }; });
|
|
40
54
|
}
|
|
41
55
|
export function effectiveCheckDeclarations(workspaceRoot, declared, gates) { return gates.flatMap((gate) => [...declared.filter((check) => check.run_at === gate), ...projectPolicyCheckDeclarations(workspaceRoot, gate)]); }
|
|
42
56
|
export function aggregateCheckDeclarations(workspaceRoot) { return projectPolicyCheckDeclarations(workspaceRoot, "code"); }
|
|
43
|
-
export function finalCodeCheckDeclarations(workspaceRoot, declared) { return effectiveCheckDeclarations(workspaceRoot, declared, ["code", "readiness"]); }
|
|
57
|
+
export function finalCodeCheckDeclarations(workspaceRoot, declared) { return effectiveCheckDeclarations(workspaceRoot, declared, ["work", "code", "readiness"]); }
|
|
44
58
|
export function checksForRunAt(checks, runAt) { return checks.filter((check) => check.run_at === runAt); }
|
|
45
59
|
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
60
|
if (check.run_at === "work" && aggregateCommands.has(check.command))
|
|
@@ -64,64 +78,157 @@ export function validateCheckDeclaration(workspaceRoot, check) {
|
|
|
64
78
|
throw new AppError("planned_check_not_materialized", "A planned check alias was not materialized by its provider Work", 2, { check_id: check.id, command: check.command, provider: check.provided_by ?? null });
|
|
65
79
|
}
|
|
66
80
|
export async function runCodeChecks(context, input) {
|
|
81
|
+
reconcileUnfinishedChecks(context, input);
|
|
67
82
|
for (const check of input.checks)
|
|
68
83
|
validateCheckDeclaration(input.workspaceRoot, check);
|
|
69
|
-
const { hash: profileHash } = readCodeCheckProfile(input.workspaceRoot);
|
|
70
|
-
const
|
|
71
|
-
const
|
|
84
|
+
const { hash: profileHash, profile } = readCodeCheckProfile(input.workspaceRoot);
|
|
85
|
+
const checks = input.checks.map((check) => { const ports = profile?.ports_by_alias?.[check.command]; return check.ports || !ports ? check : { ...check, ports }; });
|
|
86
|
+
const resolved = resolveCodeCheckCommands(input.workspaceRoot, input.runId, checks.map((check) => check.command));
|
|
87
|
+
const groups = deduplicate(checks, resolved);
|
|
72
88
|
const receipts = [];
|
|
73
89
|
for (let index = 0; index < groups.length; index += 1) {
|
|
74
90
|
const group = groups[index];
|
|
75
|
-
const
|
|
76
|
-
const localId = `RCP-${String(ordinal).padStart(3, "0")}`;
|
|
77
|
-
const id = `${input.workId ?? input.runId}/${localId}`;
|
|
78
|
-
const directory = path.join(input.runHome, input.artifactDir ?? "05-code", "checks", localId);
|
|
79
|
-
const evidenceDir = path.join(directory, "artifacts");
|
|
80
|
-
fs.mkdirSync(evidenceDir, { recursive: true });
|
|
81
|
-
const stdoutPath = path.join(directory, "stdout.log");
|
|
82
|
-
const stderrPath = path.join(directory, "stderr.log");
|
|
83
|
-
const receiptPath = path.join(directory, "receipt.json");
|
|
84
|
-
const before = workspaceState(input.workspaceRoot);
|
|
85
|
-
const inputHash = crypto.createHash("sha256").update(JSON.stringify({ gate: group.gate, command: group.command, environment: String(codeExecutionEnvironment(input.workspaceRoot).DD_FLOW_LOCAL_DATABASE_SUFFIX), required_artifacts: group.requiredArtifacts })).digest("hex");
|
|
86
|
-
const epoch = crypto.createHash("sha256").update(`${group.gate}\0${before.fingerprint}`).digest("hex");
|
|
91
|
+
const before = workspaceState(input.workspaceRoot, [input.runHome]);
|
|
87
92
|
const startedAt = context.now();
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
93
|
+
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, ports: [...group.ports].sort() })).digest("hex");
|
|
94
|
+
const epoch = crypto.createHash("sha256").update(`${group.gate}\0${before.fingerprint}`).digest("hex");
|
|
95
|
+
const reserved = reserveReceipt(context, { ...input, group, before, profileHash, inputHash, epoch, startedAt });
|
|
96
|
+
const { id, evidenceDir, stdoutPath, stderrPath, receiptPath, completionPath } = reserved;
|
|
97
|
+
let resources = {};
|
|
98
|
+
let result = { exitCode: null, error: "check did not start", aborted: true };
|
|
99
|
+
try {
|
|
100
|
+
fs.mkdirSync(evidenceDir, { recursive: true });
|
|
101
|
+
writeReceipt(receiptPath, receiptFor({ ...reserved, resources, result, status: "running", after: before, mutationPaths: [], artifacts: [] }));
|
|
102
|
+
const managed = registerManagedProcess(context, { kind: "check", ownerId: `check:${id}`, projectId: input.projectId, runId: input.runId, workId: input.workId ?? null, checkId: id, stdoutPath, stderrPath, metadata: { command: group.command } });
|
|
103
|
+
const allocation = await reservePorts(context, { ownerId: managed.owner_id, processId: managed.id, names: group.ports });
|
|
104
|
+
resources = allocation.ports;
|
|
105
|
+
input.progress?.(`check ${index + 1}/${groups.length} started: ${group.command}`);
|
|
106
|
+
const stdout = fs.openSync(stdoutPath, "a");
|
|
107
|
+
const stderr = fs.openSync(stderrPath, "a");
|
|
108
|
+
try {
|
|
109
|
+
result = await runCheck(group.command, input.workspaceRoot, { ...codeExecutionEnvironment(input.workspaceRoot), DD_FLOW_EVIDENCE_DIR: evidenceDir, DD_FLOW_CHECK_COMPLETION_FILE: completionPath, ...portEnvironment(resources) }, stdout, stderr, (elapsed) => input.progress?.(`check ${index + 1}/${groups.length} still running (${elapsed}s): ${group.command}`), (pid) => confirmManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, pid, processGroupId: process.platform === "win32" ? null : pid }), () => heartbeatManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token }));
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
allocation.release();
|
|
113
|
+
fs.closeSync(stdout);
|
|
114
|
+
fs.closeSync(stderr);
|
|
115
|
+
finishManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, state: result.aborted || result.exitCode !== 0 || Boolean(result.error) ? "failed" : "stopped", reason: result.error });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
result = { exitCode: null, error: error instanceof Error ? error.message : String(error), aborted: true };
|
|
120
|
+
}
|
|
121
|
+
const after = workspaceState(input.workspaceRoot, [input.runHome]);
|
|
94
122
|
const mutationPaths = changedPaths(before.files, after.files);
|
|
95
123
|
const artifacts = collectRequiredArtifacts(evidenceDir, group.requiredArtifacts);
|
|
96
|
-
if (
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
124
|
+
if (fs.existsSync(stderrPath)) {
|
|
125
|
+
if (!artifacts.complete)
|
|
126
|
+
fs.appendFileSync(stderrPath, `\nmissing required evidence artifacts: ${artifacts.missing.join(", ")}\n`);
|
|
127
|
+
if (result.error)
|
|
128
|
+
fs.appendFileSync(stderrPath, `\n${result.error}\n`);
|
|
129
|
+
if (mutationPaths.length)
|
|
130
|
+
fs.appendFileSync(stderrPath, `\ncheck mutated project workspace: ${mutationPaths.join(", ")}\n`);
|
|
131
|
+
}
|
|
102
132
|
const status = result.aborted ? "aborted" : result.exitCode === 0 && !result.error && artifacts.complete && mutationPaths.length === 0 ? "passed" : "failed";
|
|
103
|
-
const receipt = {
|
|
104
|
-
|
|
105
|
-
|
|
133
|
+
const receipt = receiptFor({ ...reserved, resources, result, status, after, mutationPaths, artifacts: artifacts.items });
|
|
134
|
+
context.db.run("UPDATE check_receipts SET status = ?, exit_code = ?, after_fingerprint = ?, mutation_paths_json = ?, artifacts_json = ?, finished_at = ? WHERE id = ? AND project_id = ? AND status = 'running'", [receipt.status, receipt.exit_code, receipt.after_fingerprint, JSON.stringify(receipt.mutation_paths), JSON.stringify(receipt.artifacts), receipt.finished_at, id, input.projectId]);
|
|
135
|
+
writeReceipt(receiptPath, receipt);
|
|
106
136
|
receipts.push(receipt);
|
|
107
137
|
input.progress?.(`check ${index + 1}/${groups.length} ${status}: ${group.command}`);
|
|
108
138
|
}
|
|
109
139
|
return receipts;
|
|
110
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* A check receipt is durable even when its invoking CLI disappears. We never
|
|
143
|
+
* guess that an orphan passed: a live process blocks a second execution, and
|
|
144
|
+
* a dead/terminal process becomes an explicit aborted receipt that can retry.
|
|
145
|
+
*/
|
|
146
|
+
export function reconcileUnfinishedChecks(context, input) {
|
|
147
|
+
const where = input.workId ? "project_id = ? AND run_id = ? AND work_id = ? AND status = 'running'" : "project_id = ? AND run_id = ? AND status = 'running'";
|
|
148
|
+
const params = input.workId ? [input.projectId, input.runId, input.workId] : [input.projectId, input.runId];
|
|
149
|
+
const pending = context.db.all(`SELECT id, receipt_path, started_at FROM check_receipts WHERE ${where}`, params);
|
|
150
|
+
if (pending.length === 0)
|
|
151
|
+
return [];
|
|
152
|
+
const processes = managedProcessStatus(context);
|
|
153
|
+
const reconciled = [];
|
|
154
|
+
for (const pendingReceipt of pending) {
|
|
155
|
+
const process = processes.filter((item) => item.check_id === pendingReceipt.id).at(-1);
|
|
156
|
+
const receipt = readReceipt(pendingReceipt.receipt_path);
|
|
157
|
+
const completion = receipt ? readCompletion(receipt.completion_path || path.join(path.dirname(pendingReceipt.receipt_path), "completion.json")) : null;
|
|
158
|
+
if (process && ["starting", "running", "stopping"].includes(process.state) && processLeaseIsCurrent(process) && (process.state === "starting" || processIsAlive(process))) {
|
|
159
|
+
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 });
|
|
160
|
+
}
|
|
161
|
+
if (!process && !completion && Date.now() - Date.parse(pendingReceipt.started_at) < receiptStartingGraceMs) {
|
|
162
|
+
throw new AppError("check_in_progress", "A previous check invocation is still initializing", 1, { check_receipt_id: pendingReceipt.id, initializing: true });
|
|
163
|
+
}
|
|
164
|
+
const after = workspaceState(input.workspaceRoot, input.runHome ? [input.runHome] : []);
|
|
165
|
+
const artifacts = receipt ? collectRequiredArtifacts(path.join(path.dirname(receipt.completion_path || pendingReceipt.receipt_path), "artifacts"), receipt.required_artifacts ?? []) : { complete: false, missing: [], items: [] };
|
|
166
|
+
const workspaceUnchanged = receipt?.before_fingerprint === after.fingerprint;
|
|
167
|
+
const status = completion === null ? "aborted" : completion.exit_code === 0 && workspaceUnchanged && artifacts.complete ? "passed" : "failed";
|
|
168
|
+
const exitCode = completion?.exit_code ?? null;
|
|
169
|
+
const finishedAt = completion?.finished_at ?? context.now();
|
|
170
|
+
context.db.run("UPDATE check_receipts SET status = ?, exit_code = ?, after_fingerprint = ?, mutation_paths_json = ?, artifacts_json = ?, finished_at = ? WHERE id = ? AND project_id = ? AND status = 'running'", [status, exitCode, after.fingerprint, JSON.stringify(workspaceUnchanged ? [] : ["<workspace changed while detached check ran>"]), JSON.stringify(artifacts.items), finishedAt, pendingReceipt.id, input.projectId]);
|
|
171
|
+
if (process)
|
|
172
|
+
finishManagedProcess(context, { id: process.id, leaseToken: process.lease_token, state: status === "passed" ? "stopped" : "failed", reason: status === "passed" ? "reconciled_completion" : completion === null ? "completion_marker_missing" : "reconciled_check_failed" });
|
|
173
|
+
if (receipt) {
|
|
174
|
+
const repaired = { ...receipt, completion_path: receipt.completion_path || path.join(path.dirname(pendingReceipt.receipt_path), "completion.json"), required_artifacts: receipt.required_artifacts ?? [], status, exit_code: exitCode, after_fingerprint: after.fingerprint, mutation_paths: workspaceUnchanged ? [] : ["<workspace changed while detached check ran>"], artifacts: artifacts.items, finished_at: finishedAt };
|
|
175
|
+
writeReceipt(pendingReceipt.receipt_path, repaired);
|
|
176
|
+
reconciled.push(repaired);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return reconciled;
|
|
180
|
+
}
|
|
181
|
+
function reserveReceipt(context, input) {
|
|
182
|
+
context.db.exec("BEGIN IMMEDIATE");
|
|
183
|
+
try {
|
|
184
|
+
const rows = context.db.all("SELECT id FROM check_receipts WHERE work_id IS ? AND run_id = ?", [input.workId ?? null, input.runId]);
|
|
185
|
+
const ordinal = rows.reduce((max, row) => Math.max(max, Number(/\/RCP-(\d+)$/.exec(row.id)?.[1] ?? 0)), 0) + 1;
|
|
186
|
+
const localId = `RCP-${String(ordinal).padStart(3, "0")}`;
|
|
187
|
+
const id = `${input.workId ?? input.runId}/${localId}`;
|
|
188
|
+
const directory = path.join(input.runHome, input.artifactDir ?? "05-code", "checks", localId);
|
|
189
|
+
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 };
|
|
190
|
+
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]);
|
|
191
|
+
context.db.exec("COMMIT");
|
|
192
|
+
return reserved;
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
context.db.exec("ROLLBACK");
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
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() }; }
|
|
200
|
+
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); }
|
|
201
|
+
function readReceipt(file) { try {
|
|
202
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return null;
|
|
206
|
+
} }
|
|
207
|
+
function readCompletion(file) { try {
|
|
208
|
+
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
209
|
+
const done = value;
|
|
210
|
+
return value && typeof value === "object" && Number.isInteger(done.exit_code) ? { exit_code: Number(done.exit_code), finished_at: typeof done.finished_at === "string" && Number.isFinite(Date.parse(done.finished_at)) ? done.finished_at : null } : null;
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return null;
|
|
214
|
+
} }
|
|
215
|
+
function processLeaseIsCurrent(process) { return Date.parse(process.lease_expires_at ?? "") >= Date.now(); }
|
|
111
216
|
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
217
|
for (const ref of receipt.check_refs)
|
|
113
218
|
latest.set(ref, receipt); return [...new Set(latest.values())].filter((receipt) => receipt.status === "failed" && receipt.before_fingerprint === current); }
|
|
114
219
|
export function checkReceipts(context, input) {
|
|
115
220
|
const where = input.workId ? "project_id = ? AND run_id = ? AND work_id = ?" : "project_id = ? AND run_id = ?";
|
|
116
221
|
const params = input.workId ? [input.projectId, input.runId, input.workId] : [input.projectId, input.runId];
|
|
117
|
-
return context.db.all(`SELECT id, declaration_id, check_refs_json, gate, scope, work_id, command, input_hash, verification_epoch, status, exit_code, stdout_path, stderr_path, receipt_path, workspace_fingerprint, before_fingerprint, after_fingerprint, profile_hash, mutation_paths_json, artifacts_json, started_at, finished_at FROM check_receipts WHERE ${where} ORDER BY 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) }));
|
|
222
|
+
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), resources: readReceiptResources(row.receipt_path) }));
|
|
118
223
|
}
|
|
119
224
|
export function workspaceFingerprint(root) { return workspaceState(root).fingerprint; }
|
|
120
|
-
function
|
|
225
|
+
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)
|
|
226
|
+
return null; return status.stdout.split("\0").filter(Boolean).map((entry) => entry.slice(3)).filter(Boolean).sort(); }
|
|
227
|
+
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([check.run_at, commands[index], artifacts, ports]); const current = groups.get(key); if (current)
|
|
121
228
|
current.declarations.push(check);
|
|
122
229
|
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) {
|
|
230
|
+
groups.set(key, { gate: check.run_at, command: commands[index], declarations: [check], requiredArtifacts: artifacts, ports }); }); return [...groups.values()]; }
|
|
231
|
+
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) {
|
|
125
232
|
const file = path.join(root, relative);
|
|
126
233
|
const content = fs.existsSync(file) && fs.statSync(file).isFile() ? fs.readFileSync(file) : Buffer.from("<missing>");
|
|
127
234
|
const digest = crypto.createHash("sha256").update(content).digest("hex");
|
|
@@ -139,6 +246,15 @@ catch {
|
|
|
139
246
|
} }
|
|
140
247
|
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
248
|
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/"))); }
|
|
249
|
+
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)))); }
|
|
250
|
+
function portEnvironment(ports) { return Object.fromEntries(Object.entries(ports).map(([name, value]) => [`DD_FLOW_PORT_${name.toUpperCase()}`, String(value)])); }
|
|
251
|
+
function readReceiptResources(file) { try {
|
|
252
|
+
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
253
|
+
return { ports: value.resources?.ports ?? {} };
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
return { ports: {} };
|
|
257
|
+
} }
|
|
142
258
|
function collectRequiredArtifacts(root, required) { const missing = []; const items = []; for (const relative of required) {
|
|
143
259
|
if (path.isAbsolute(relative) || relative.split(/[\\/]/).includes(".."))
|
|
144
260
|
throw new AppError("invalid_evidence_path", "Required evidence paths must be relative to DD_FLOW_EVIDENCE_DIR", 2, { path: relative });
|
|
@@ -151,11 +267,45 @@ function collectRequiredArtifacts(root, required) { const missing = []; const it
|
|
|
151
267
|
} return { complete: missing.length === 0, missing, items }; }
|
|
152
268
|
function listFiles(root, current = root) { return fs.readdirSync(current, { withFileTypes: true }).flatMap((entry) => { if ([".git", "node_modules"].includes(entry.name))
|
|
153
269
|
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",
|
|
155
|
-
|
|
270
|
+
function runCheck(command, cwd, environment, stdout, stderr, heartbeat, onSpawn, renewLease) { return new Promise((resolve) => { const started = Date.now(); const child = spawn("/bin/sh", ["-lc", checkShellScript()], { cwd, env: { ...environment, DD_FLOW_CHECK_COMMAND: command }, stdio: ["ignore", stdout, stderr], detached: process.platform !== "win32" }); if (!child.pid) {
|
|
271
|
+
resolve({ exitCode: null, error: "check process did not provide a PID", aborted: true });
|
|
272
|
+
return;
|
|
273
|
+
} try {
|
|
274
|
+
onSpawn(child.pid);
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
terminateProcessGroup(child.pid, "SIGTERM");
|
|
278
|
+
resolve({ exitCode: null, error: error instanceof Error ? error.message : String(error), aborted: true });
|
|
279
|
+
return;
|
|
280
|
+
} const inactivityMs = 15 * 60 * 1000; let timeout; const lastOutputSize = new Map(); const armInactivityTimeout = () => { if (timeout)
|
|
281
|
+
clearTimeout(timeout); timeout = setTimeout(() => { timedOut = true; terminateProcessGroup(child.pid, "SIGTERM"); escalation = setTimeout(() => terminateProcessGroup(child.pid, "SIGKILL"), 5_000); }, inactivityMs); }; const progress = setInterval(() => { const elapsed = Math.floor((Date.now() - started) / 1000); if (!renewLease()) {
|
|
282
|
+
error = "managed process lease lost";
|
|
283
|
+
timedOut = true;
|
|
284
|
+
terminateProcessGroup(child.pid, "SIGTERM");
|
|
285
|
+
return;
|
|
286
|
+
} for (const file of [stdout, stderr]) {
|
|
287
|
+
try {
|
|
288
|
+
const size = fs.fstatSync(file).size, previous = lastOutputSize.get(file) ?? 0;
|
|
289
|
+
if (size > previous) {
|
|
290
|
+
lastOutputSize.set(file, size);
|
|
291
|
+
armInactivityTimeout();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
catch { /* terminal stream */ }
|
|
295
|
+
} heartbeat(elapsed); }, 15_000); let timedOut = false; let error = null; let escalation; armInactivityTimeout(); child.once("error", (value) => { error = value.message; }); child.once("close", (exitCode, signal) => { clearInterval(progress); if (timeout)
|
|
296
|
+
clearTimeout(timeout); if (escalation)
|
|
297
|
+
clearTimeout(escalation); resolve({ exitCode, error: error ?? (timedOut ? "timed out after 900 seconds without process output" : signal ? `terminated by ${signal}` : null), aborted: timedOut || Boolean(signal) || Boolean(error) }); }); }); }
|
|
298
|
+
function checkShellScript() {
|
|
299
|
+
return [
|
|
300
|
+
'completion="${DD_FLOW_CHECK_COMPLETION_FILE:-}"',
|
|
301
|
+
'/bin/sh -lc "$DD_FLOW_CHECK_COMMAND"',
|
|
302
|
+
"code=$?",
|
|
303
|
+
'if [ -n "$completion" ]; then temporary="$completion.tmp.$$"; printf \'{"exit_code":%s,"finished_at":"%s"}\\n\' "$code" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$temporary" && mv "$temporary" "$completion"; fi',
|
|
304
|
+
'exit "$code"'
|
|
305
|
+
].join("\n");
|
|
306
|
+
}
|
|
156
307
|
function terminateProcessGroup(pid, signal) { if (!pid)
|
|
157
308
|
return; try {
|
|
158
309
|
process.kill(process.platform === "win32" ? pid : -pid, signal);
|
|
159
310
|
}
|
|
160
311
|
catch { /* already exited */ } }
|
|
161
|
-
function closeStream(stream) { return new Promise((resolve, reject) => stream.end((error) => error ? reject(error) : resolve())); }
|
package/dist/services/engines.js
CHANGED
|
@@ -12,7 +12,7 @@ import { requireProtocol } from "./protocols.js";
|
|
|
12
12
|
import { resolveCanonRootBeforeContext } from "./canon.js";
|
|
13
13
|
import { appendAudit } from "./audit.js";
|
|
14
14
|
import { findProjectByRoot } from "./projects.js";
|
|
15
|
-
import { allRunEngineBindings,
|
|
15
|
+
import { allRunEngineBindings, locateRunRoot, readRunEngineBinding, runEngineBindingSchemaId, writeRunEngineBinding } from "./run-engine-bindings.js";
|
|
16
16
|
export const engineManifestSchemaId = "dd-flow/engine-manifest@1";
|
|
17
17
|
const routerNativeFamilies = new Set(["engine", "version", "schema"]);
|
|
18
18
|
const sourceFile = fileURLToPath(import.meta.url);
|
|
@@ -182,7 +182,7 @@ export function routeArgsThroughEngine(context, args, io, stdin, env, resolvedPr
|
|
|
182
182
|
}
|
|
183
183
|
export function bindRunEngine(context, input) {
|
|
184
184
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
185
|
-
const located =
|
|
185
|
+
const located = locateRunRoot(context.db, projectRoot, input.runId);
|
|
186
186
|
if (!located)
|
|
187
187
|
throw new AppError("not_found", `RUN is not registered in router storage: ${input.runId}`, 1, { run_id: input.runId, project_root: projectRoot });
|
|
188
188
|
const existing = readRunEngineBinding(located.binding_path, { allowMissing: true });
|
|
@@ -250,13 +250,13 @@ export function bindCurrentEngineToRun(context, input) {
|
|
|
250
250
|
engine: engineIdentity(manifest),
|
|
251
251
|
probe: { status: "not_required", command: null }
|
|
252
252
|
};
|
|
253
|
-
return writeRunEngineBinding(path.join(input.
|
|
253
|
+
return writeRunEngineBinding(path.join(input.runRoot, "engine-binding.json"), binding).binding;
|
|
254
254
|
}
|
|
255
255
|
function boundRunEngineForArgs(context, args, projectRoot) {
|
|
256
256
|
const runId = runIdForArgs(args);
|
|
257
257
|
if (!runId || !projectRoot)
|
|
258
258
|
return null;
|
|
259
|
-
const located =
|
|
259
|
+
const located = locateRunRoot(context.db, projectRoot, runId);
|
|
260
260
|
if (!located)
|
|
261
261
|
return null;
|
|
262
262
|
const binding = readRunEngineBinding(located.binding_path, { allowMissing: true });
|
|
@@ -80,7 +80,10 @@ export function createEvalRunSnapshot(context, input) {
|
|
|
80
80
|
fs.cpSync(context.ddFlowHome, path.join(output, "runtime"), {
|
|
81
81
|
recursive: true,
|
|
82
82
|
verbatimSymlinks: true,
|
|
83
|
-
|
|
83
|
+
// Managed checkouts are restored from their Git bundle and workspace
|
|
84
|
+
// payload. Copying their .git indirection into runtime makes the snapshot
|
|
85
|
+
// dependent on the source machine's worktree metadata.
|
|
86
|
+
filter: (source) => !["engines", "checkouts"].includes(path.basename(source))
|
|
84
87
|
});
|
|
85
88
|
fs.cpSync(projectRoot, path.join(output, "project"), {
|
|
86
89
|
recursive: true,
|
|
@@ -135,20 +138,22 @@ export function restoreEvalRunSnapshot(context, input) {
|
|
|
135
138
|
clearProjectTree(projectRoot, false);
|
|
136
139
|
restoreProjectGit(snapshot, manifest.project_git, projectRoot);
|
|
137
140
|
replaceProjectTree(projectRoot, sourceProject);
|
|
138
|
-
const workspaceRoot = restoreWorkspace(snapshot, manifest, projectRoot, context.ddFlowHome);
|
|
139
141
|
context.db.close?.();
|
|
140
142
|
fs.rmSync(context.ddFlowHome, { recursive: true, force: true });
|
|
141
143
|
fs.cpSync(sourceRuntime, context.ddFlowHome, { recursive: true, verbatimSymlinks: true });
|
|
144
|
+
const workspaceRoot = restoreWorkspace(snapshot, manifest, projectRoot, context.ddFlowHome);
|
|
142
145
|
// The dispatch context was intentionally closed before replacing db.sqlite.
|
|
143
146
|
// A fresh process owns all subsequent normal commands.
|
|
144
147
|
const restored = createContext({ ...context.env, DD_FLOW_HOME: context.ddFlowHome });
|
|
145
148
|
try {
|
|
146
149
|
rebaseRuntime(restored, manifest.project_root, projectRoot, sourceWorkspaceRoot(manifest), workspaceRoot, manifest.dd_flow_home, context.ddFlowHome);
|
|
147
150
|
registerProject(restored, { root: projectRoot });
|
|
148
|
-
|
|
151
|
+
requireProjectByRoot(restored, projectRoot);
|
|
149
152
|
const status = getFlowRunStatus(restored, { projectRoot, runId: manifest.run_id });
|
|
150
153
|
assertStageEntry(status.index.stage_runs ?? [], manifest.stage_entry);
|
|
151
|
-
|
|
154
|
+
if (!status.run.run_root)
|
|
155
|
+
throw new AppError("runtime_missing", "Restored RUN has no artifact root", 1, { run_id: manifest.run_id });
|
|
156
|
+
const runHome = status.run.run_root;
|
|
152
157
|
return {
|
|
153
158
|
ok: true,
|
|
154
159
|
schema_id: "dd-flow/eval-run-restore@1",
|
|
@@ -179,7 +184,7 @@ export function prepareVnextSpecifyRun(context, input) {
|
|
|
179
184
|
slug: input.slug,
|
|
180
185
|
nextAction: "start_specify"
|
|
181
186
|
});
|
|
182
|
-
return { ok: true, run_id: started.run.id,
|
|
187
|
+
return { ok: true, run_id: started.run.id, run_root: started.run.run_root, target_stage: "specify" };
|
|
183
188
|
}
|
|
184
189
|
function assertDedicatedHome(context, projectId, runId) {
|
|
185
190
|
const projects = context.db.get("SELECT COUNT(*) AS count FROM projects")?.count ?? 0;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { AppError } from "../shared/errors.js";
|
|
4
|
+
export function harnessConfigPath(ddFlowHome) {
|
|
5
|
+
return path.join(ddFlowHome, "harnesses.json");
|
|
6
|
+
}
|
|
7
|
+
export function harnessKey(value) {
|
|
8
|
+
const keys = {
|
|
9
|
+
codex: "codex-desktop", "codex-desktop": "codex-desktop",
|
|
10
|
+
zcode: "zcode-acp", "zcode-acp": "zcode-acp",
|
|
11
|
+
grok: "grok-acp", "grok-acp": "grok-acp",
|
|
12
|
+
agy: "antigravity-cli", "antigravity-cli": "antigravity-cli",
|
|
13
|
+
opencode: "opencode-server", "opencode-server": "opencode-server"
|
|
14
|
+
};
|
|
15
|
+
const resolved = keys[value];
|
|
16
|
+
if (!resolved)
|
|
17
|
+
throw new AppError("harness_config_invalid", `Unsupported harness: ${value}`, 2, { harness: value });
|
|
18
|
+
return resolved;
|
|
19
|
+
}
|
|
20
|
+
export function loadHarnessConfig(ddFlowHome) {
|
|
21
|
+
const file = harnessConfigPath(ddFlowHome);
|
|
22
|
+
let value;
|
|
23
|
+
try {
|
|
24
|
+
value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
25
|
+
}
|
|
26
|
+
catch (cause) {
|
|
27
|
+
throw new AppError("harness_config_missing", "Harness configuration is missing or invalid", 1, { path: file, cause: String(cause) });
|
|
28
|
+
}
|
|
29
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
30
|
+
throw new AppError("harness_config_invalid", "Harness configuration must be an object", 2, { path: file });
|
|
31
|
+
const config = value;
|
|
32
|
+
if (config.schema_id !== "dd-flow/harness-config@1" || !config.harnesses || typeof config.harnesses !== "object" || Array.isArray(config.harnesses))
|
|
33
|
+
throw new AppError("harness_config_invalid", "Harness configuration has an unsupported schema", 2, { path: file });
|
|
34
|
+
for (const [name, entry] of Object.entries(config.harnesses)) {
|
|
35
|
+
harnessKey(name);
|
|
36
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry) || typeof entry.adapter_command !== "string" || typeof entry.runtime_command !== "string")
|
|
37
|
+
throw new AppError("harness_config_invalid", "Each harness requires adapter_command and runtime_command", 2, { path: file, harness: name });
|
|
38
|
+
}
|
|
39
|
+
return config;
|
|
40
|
+
}
|
|
41
|
+
export function resolveHarnessCommand(ddFlowHome, harness, command) {
|
|
42
|
+
const key = harnessKey(harness);
|
|
43
|
+
const entry = loadHarnessConfig(ddFlowHome).harnesses[key];
|
|
44
|
+
if (!entry)
|
|
45
|
+
throw new AppError("harness_config_missing", "Selected harness is not configured", 1, { path: harnessConfigPath(ddFlowHome), harness: key });
|
|
46
|
+
const value = command === "adapter" ? entry.adapter_command : entry.runtime_command;
|
|
47
|
+
if (!path.isAbsolute(value))
|
|
48
|
+
throw new AppError("harness_config_invalid", "Harness commands must be absolute paths", 2, { harness: key, command, value });
|
|
49
|
+
if (!fs.existsSync(value))
|
|
50
|
+
throw new AppError("harness_command_missing", "Configured harness command does not exist", 1, { harness: key, command, value });
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
/** The adapter interface is intentionally small; native binary flag names stay here,
|
|
54
|
+
* rather than leaking through MERGE orchestration or agent prompts. */
|
|
55
|
+
export function harnessRuntimeArguments(ddFlowHome, harness) {
|
|
56
|
+
const key = harnessKey(harness);
|
|
57
|
+
const runtime = resolveHarnessCommand(ddFlowHome, key, "runtime");
|
|
58
|
+
const option = {
|
|
59
|
+
"codex-desktop": "--codex-bin",
|
|
60
|
+
"zcode-acp": "--zcode-acp-bin",
|
|
61
|
+
"grok-acp": "--grok-bin",
|
|
62
|
+
"antigravity-cli": "--agy-bin",
|
|
63
|
+
"opencode-server": "--opencode-bin"
|
|
64
|
+
};
|
|
65
|
+
return [option[key], runtime];
|
|
66
|
+
}
|