@deksden-com/dd-flow-cli 0.9.0-beta.1 → 0.9.0-beta.7
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 +49 -0
- package/dist/build-info.json +10 -10
- package/dist/cli/help.js +2 -2
- package/dist/cli/run-cli.js +105 -7
- package/dist/schemas/code-work-batch.schema.json +3 -3
- 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/eval-snapshots.js +5 -2
- package/dist/services/hooks.js +25 -22
- package/dist/services/lanes.js +1 -0
- package/dist/services/managed-processes.js +169 -0
- package/dist/services/merge-server.js +5 -0
- package/dist/services/portable-refs.js +57 -0
- package/dist/services/run-projection.js +10 -8
- package/dist/services/runs.js +50 -6
- package/dist/services/session-identity.js +19 -0
- package/dist/services/sessions.js +26 -11
- package/dist/services/stage-pause.js +31 -16
- package/dist/services/usage.js +74 -42
- package/dist/services/vnext-code-review.js +46 -12
- package/dist/services/vnext-code.js +59 -22
- package/dist/services/vnext-merge.js +102 -42
- package/dist/services/vnext-plan-review.js +31 -13
- package/dist/services/vnext-plan.js +57 -9
- package/dist/services/work-registry.js +130 -27
- package/dist/storage/database.js +125 -2
- package/package.json +12 -12
- 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())); }
|
|
@@ -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,10 +138,10 @@ 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 });
|
package/dist/services/hooks.js
CHANGED
|
@@ -7,6 +7,7 @@ import { AppError } from "../shared/errors.js";
|
|
|
7
7
|
import { parseJsonObject } from "../shared/json.js";
|
|
8
8
|
import { ensureDir, resolveProjectRoot } from "../storage/paths.js";
|
|
9
9
|
import { appendAudit } from "./audit.js";
|
|
10
|
+
import { nativeSessionIdentity, storageSessionId } from "./session-identity.js";
|
|
10
11
|
import { commandHasOption, commandOption, commandPosition, parseLifecycleCommand, unwrapShellCommand } from "./lifecycle-command.js";
|
|
11
12
|
import { registerProject, requireProjectByRoot } from "./projects.js";
|
|
12
13
|
import { activeFlowSessionsForProject, bindObservedFlowSession, flowSessionPayloadFromRegisterCommand, recordFlowSessionObservation } from "./sessions.js";
|
|
@@ -278,7 +279,7 @@ export function handleCodexHook(context, input) {
|
|
|
278
279
|
return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
|
|
279
280
|
// Resume legitimately receives an answer through stdin. It is matched by
|
|
280
281
|
// immutable lifecycle arguments below; never reject or rewrite that pipe.
|
|
281
|
-
if (lifecycle.analysis.kind === "compound"
|
|
282
|
+
if (lifecycle.analysis.kind === "compound") {
|
|
282
283
|
return {
|
|
283
284
|
ok: false,
|
|
284
285
|
observed: false,
|
|
@@ -303,10 +304,11 @@ export function handleCodexHook(context, input) {
|
|
|
303
304
|
if (!project)
|
|
304
305
|
return { ok: true, observed: false, reason: "unrelated_cwd" };
|
|
305
306
|
const binding = sessionId ? upsertSessionBindingFromPayload(context, project, sessionId, payload) : undefined;
|
|
307
|
+
const storageId = sessionId ? storageSessionId(nativeSessionIdentity("codex-desktop", sessionId)) : null;
|
|
306
308
|
const observedSession = flowPayload
|
|
307
|
-
? bindObservedFlowSession(context, project, flowPayload, sessionId ?? flowPayload.
|
|
309
|
+
? bindObservedFlowSession(context, project, { ...flowPayload, harness: "codex-desktop", provider_session_id: sessionId ?? flowPayload.provider_session_id ?? null }, storageId ?? undefined)
|
|
308
310
|
: undefined;
|
|
309
|
-
const effectiveSessionId = observedSession?.session_id ??
|
|
311
|
+
const effectiveSessionId = observedSession?.session_id ?? storageId;
|
|
310
312
|
const protocolId = observedSession?.protocol_id ?? binding?.protocol_id ?? null;
|
|
311
313
|
const eventKey = hookEventKey(payload, eventName, toolName, command);
|
|
312
314
|
const inserted = recordHookEvent(context, {
|
|
@@ -343,7 +345,7 @@ export function handleCodexHook(context, input) {
|
|
|
343
345
|
observed: inserted,
|
|
344
346
|
duplicate: !inserted,
|
|
345
347
|
event_key: eventKey,
|
|
346
|
-
|
|
348
|
+
session: sessionId ? nativeSessionIdentity("codex-desktop", sessionId) : null,
|
|
347
349
|
protocol_id: protocolId,
|
|
348
350
|
...(sessionId && (flowPayload || stageStart || stageResume || workStart)
|
|
349
351
|
? {
|
|
@@ -377,7 +379,7 @@ export function handleZcodeEvent(context, input) {
|
|
|
377
379
|
const lifecycle = lifecycleFacts(command);
|
|
378
380
|
if (!lifecycle)
|
|
379
381
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
380
|
-
if (lifecycle.analysis.kind === "compound"
|
|
382
|
+
if (lifecycle.analysis.kind === "compound") {
|
|
381
383
|
throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone ZCode Bash tool call", 1, {
|
|
382
384
|
standalone_command: lifecycle.invocation.command
|
|
383
385
|
});
|
|
@@ -403,8 +405,8 @@ export function handleZcodeEvent(context, input) {
|
|
|
403
405
|
throw new AppError("zcode_identity_missing", "ZCode ACP event has no root provider Session ID", 1);
|
|
404
406
|
const childProviderSessionId = stringValue(runtime.childSessionId);
|
|
405
407
|
const providerSessionId = childProviderSessionId ?? rootProviderSessionId;
|
|
406
|
-
const sessionId =
|
|
407
|
-
const parentSessionId = childProviderSessionId ?
|
|
408
|
+
const sessionId = storageSessionId(nativeSessionIdentity("zcode-acp", providerSessionId));
|
|
409
|
+
const parentSessionId = childProviderSessionId ? storageSessionId(nativeSessionIdentity("zcode-acp", rootProviderSessionId)) : null;
|
|
408
410
|
const agentId = stringValue(runtime.agentId);
|
|
409
411
|
const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
|
|
410
412
|
...flowPayload,
|
|
@@ -463,9 +465,8 @@ export function handleZcodeEvent(context, input) {
|
|
|
463
465
|
duplicate: !inserted,
|
|
464
466
|
event_key: eventKey,
|
|
465
467
|
harness: "zcode-acp",
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
parent_session_id: parentSessionId,
|
|
468
|
+
session: nativeSessionIdentity("zcode-acp", providerSessionId),
|
|
469
|
+
...(childProviderSessionId ? { parent_session: nativeSessionIdentity("zcode-acp", rootProviderSessionId) } : {}),
|
|
469
470
|
daemon_id: daemonId ?? null
|
|
470
471
|
};
|
|
471
472
|
}
|
|
@@ -485,7 +486,7 @@ export function handleGrokEvent(context, input) {
|
|
|
485
486
|
const lifecycle = lifecycleFacts(command);
|
|
486
487
|
if (!lifecycle)
|
|
487
488
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
488
|
-
if (lifecycle.analysis.kind === "compound"
|
|
489
|
+
if (lifecycle.analysis.kind === "compound") {
|
|
489
490
|
throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone Grok Build tool call", 1, {
|
|
490
491
|
standalone_command: lifecycle.invocation.command
|
|
491
492
|
});
|
|
@@ -509,8 +510,8 @@ export function handleGrokEvent(context, input) {
|
|
|
509
510
|
if (!rootProviderSessionId || !providerSessionId)
|
|
510
511
|
throw new AppError("grok_identity_missing", "Grok Build hook has no trusted Session ID", 1);
|
|
511
512
|
const isChild = providerSessionId !== rootProviderSessionId;
|
|
512
|
-
const sessionId =
|
|
513
|
-
const parentSessionId = isChild ?
|
|
513
|
+
const sessionId = storageSessionId(nativeSessionIdentity("grok-acp", providerSessionId));
|
|
514
|
+
const parentSessionId = isChild ? storageSessionId(nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId)) : null;
|
|
514
515
|
const daemonId = stringValue(ddGrok.daemonId);
|
|
515
516
|
const agentId = stringValue(hook.agent_id) ?? stringValue(hook.agentId);
|
|
516
517
|
const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
|
|
@@ -532,8 +533,8 @@ export function handleGrokEvent(context, input) {
|
|
|
532
533
|
projectId: project.id, sessionId, runId: observedSession?.run_id ?? null, protocolId: observedSession?.protocol_id ?? null,
|
|
533
534
|
cwd: expectedRoot, toolName: toolName ?? "Bash", eventKey
|
|
534
535
|
});
|
|
535
|
-
const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey,
|
|
536
|
-
|
|
536
|
+
const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, session: nativeSessionIdentity("grok-acp", providerSessionId),
|
|
537
|
+
...(isChild ? { parent_session: nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId) } : {}), daemon_id: daemonId ?? null };
|
|
537
538
|
return (flowPayload || stageStart || stageResume || workStart)
|
|
538
539
|
? { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...(rawInput ?? {}), command: commandWithHookEvent(command, eventKey) } } }
|
|
539
540
|
: result;
|
|
@@ -565,8 +566,8 @@ export function handleOpenCodeEvent(context, input) {
|
|
|
565
566
|
if (resolveProjectRoot(directory) !== expectedRoot) {
|
|
566
567
|
throw new AppError(agy ? "agy_directory_mismatch" : "opencode_directory_mismatch", `${agy ? "Antigravity" : "OpenCode"} Session directory does not match the controlled workspace`, 1, { expected: expectedRoot, actual: directory });
|
|
567
568
|
}
|
|
568
|
-
const sessionId =
|
|
569
|
-
const parentSessionId = nativeParentId ?
|
|
569
|
+
const sessionId = storageSessionId(nativeSessionIdentity(harness, providerSessionId));
|
|
570
|
+
const parentSessionId = nativeParentId ? storageSessionId(nativeSessionIdentity(harness, nativeParentId)) : null;
|
|
570
571
|
const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd) ?? stringValue(rawInput.CommandLine);
|
|
571
572
|
const baseKey = `${eventId}:${phase}`;
|
|
572
573
|
if (phase === "after") {
|
|
@@ -577,7 +578,7 @@ export function handleOpenCodeEvent(context, input) {
|
|
|
577
578
|
eventName: "PostToolUse", toolName, status: "observed", payload: { session_id: providerSessionId, cwd: expectedRoot, tool_name: toolName, status: objectRecord(event.outcome).status },
|
|
578
579
|
eventKey: baseKey, matchKey: null, transcriptPath: null, cwd: expectedRoot
|
|
579
580
|
});
|
|
580
|
-
return { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness,
|
|
581
|
+
return { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session: nativeSessionIdentity(harness, providerSessionId), ...(nativeParentId ? { parent_session: nativeSessionIdentity(harness, nativeParentId) } : {}) };
|
|
581
582
|
}
|
|
582
583
|
if (!command || !["bash", "Bash", "run_command", "run_terminal_command", "RunTerminalCommand"].includes(toolName)) {
|
|
583
584
|
return { ok: true, observed: false, reason: command ? "non_bash_tool" : "event_not_participating" };
|
|
@@ -585,7 +586,7 @@ export function handleOpenCodeEvent(context, input) {
|
|
|
585
586
|
const lifecycle = lifecycleFacts(command);
|
|
586
587
|
if (!lifecycle)
|
|
587
588
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
588
|
-
if (lifecycle.analysis.kind === "compound"
|
|
589
|
+
if (lifecycle.analysis.kind === "compound") {
|
|
589
590
|
throw new AppError("compound_lifecycle_command", `dd-flow lifecycle commands must be a standalone ${agy ? "Antigravity" : "OpenCode"} shell tool call`, 1, { standalone_command: lifecycle.invocation.command });
|
|
590
591
|
}
|
|
591
592
|
const { flowPayload, bootstrapStageStart, commandProjectRoot } = lifecycle;
|
|
@@ -614,7 +615,7 @@ export function handleOpenCodeEvent(context, input) {
|
|
|
614
615
|
status: "observed", payload, eventKey: baseKey, matchKey, transcriptPath: null, cwd: expectedRoot
|
|
615
616
|
});
|
|
616
617
|
recordFlowSessionObservation(context, { projectId: project.id, sessionId, runId: observedSession?.run_id ?? null, protocolId: observedSession?.protocol_id ?? null, cwd: expectedRoot, toolName, eventKey: baseKey });
|
|
617
|
-
const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness,
|
|
618
|
+
const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session: nativeSessionIdentity(harness, providerSessionId), ...(nativeParentId ? { parent_session: nativeSessionIdentity(harness, nativeParentId) } : {}), daemon_id: daemonId };
|
|
618
619
|
return agy ? result : { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, baseKey) } } };
|
|
619
620
|
}
|
|
620
621
|
/** Convert an Antigravity CLI tool hook into the shared lifecycle receipt. */
|
|
@@ -736,7 +737,7 @@ export function sessionIdForHookEvent(context, projectId, eventKey) {
|
|
|
736
737
|
/** Reads immutable identity facts already captured by PreToolUse. */
|
|
737
738
|
export function hookSessionIdentity(context, projectId, eventKey) {
|
|
738
739
|
const event = context.db.get(`SELECT he.id, he.harness, he.provider_session_id, he.parent_session_id, he.daemon_id, he.session_id, he.agent_id, he.turn_id, he.transcript_path, he.provider, he.model, he.reasoning, he.mode, he.agent_type, COALESCE(he.cwd, csb.cwd) AS cwd
|
|
739
|
-
FROM hook_events he LEFT JOIN codex_session_bindings csb ON csb.project_id = he.project_id AND csb.session_id = he.session_id
|
|
740
|
+
FROM hook_events he LEFT JOIN codex_session_bindings csb ON csb.project_id = he.project_id AND csb.session_id = COALESCE(he.provider_session_id, he.session_id)
|
|
740
741
|
WHERE he.project_id = ? AND he.event_key = ?`, [projectId, eventKey]);
|
|
741
742
|
if (!event?.session_id)
|
|
742
743
|
throw new AppError("hook_event_not_found", "stage start requires a trusted PreToolUse hook event", 1, { event_key: eventKey });
|
|
@@ -747,7 +748,9 @@ export function hookSessionIdentity(context, projectId, eventKey) {
|
|
|
747
748
|
parentSessionId: event.parent_session_id,
|
|
748
749
|
daemonId: event.daemon_id,
|
|
749
750
|
agentId: event.agent_id,
|
|
750
|
-
|
|
751
|
+
// agent_id is an auxiliary provider fact, never a replacement Session ID.
|
|
752
|
+
sessionId: event.session_id,
|
|
753
|
+
nativeSessionId: event.provider_session_id ?? event.session_id,
|
|
751
754
|
turnId: event.turn_id,
|
|
752
755
|
transcriptPath: event.transcript_path,
|
|
753
756
|
provider: event.provider,
|
package/dist/services/lanes.js
CHANGED
|
@@ -288,6 +288,7 @@ export async function waitAcquireLaneLock(context, input) {
|
|
|
288
288
|
timeoutSeconds
|
|
289
289
|
});
|
|
290
290
|
}
|
|
291
|
+
input.progress?.(`lane ${lane} is waiting at queue position ${String(result.payload.position ?? "unknown")}; next update in ${pollIntervalSeconds} seconds`);
|
|
291
292
|
await delay(pollIntervalSeconds * 1000);
|
|
292
293
|
}
|
|
293
294
|
}
|