@deksden-com/dd-flow-cli 0.9.0-beta.13 → 0.9.0-beta.14
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 +6 -0
- package/dist/build-info.json +10 -10
- package/dist/cli/run-cli.js +2 -1
- package/dist/services/code-checks.js +68 -22
- package/dist/services/managed-processes.js +18 -6
- package/dist/services/vnext-code.js +6 -34
- package/dist/services/vnext-merge.js +10 -4
- package/dist/services/work-registry.js +17 -11
- package/dist/services/workspace-bootstrap.js +50 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @deksden-com/dd-flow-cli
|
|
2
2
|
|
|
3
|
+
## 0.9.0-beta.14
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Audit runtime recovery: distinguish process owners from children, retain live check resources, await failed check startup cleanup, share managed workspace bootstrap between CODE/MERGE, correct assigned repair examples, and reject publication without canonical build provenance.
|
|
8
|
+
|
|
3
9
|
## 0.9.0-beta.13
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/dist/build-info.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"cli_package": "@deksden-com/dd-flow-cli",
|
|
3
|
-
"cli_version": "0.9.0-beta.
|
|
4
|
-
"cli_commit": "
|
|
5
|
-
"built_at": "2026-09-05T08:
|
|
3
|
+
"cli_version": "0.9.0-beta.14",
|
|
4
|
+
"cli_commit": "29b30887789f9ee686d5903a4ddcea40a24dc8ce",
|
|
5
|
+
"built_at": "2026-09-05T08:57:36.734Z",
|
|
6
6
|
"built_with_canon": {
|
|
7
|
-
"version":
|
|
8
|
-
"commit":
|
|
9
|
-
"flow_contract":
|
|
10
|
-
"repo_root":
|
|
11
|
-
"memorybank_root":
|
|
12
|
-
"flow_root":
|
|
13
|
-
"layout":
|
|
7
|
+
"version": "4.0.4",
|
|
8
|
+
"commit": "c8cae271f844fd3b3c7492f164e13a60eaba4839",
|
|
9
|
+
"flow_contract": "dd-flow-canonical-2026-08",
|
|
10
|
+
"repo_root": "/Users/deksden/Documents/_Projects/dd-memorybank",
|
|
11
|
+
"memorybank_root": "/Users/deksden/Documents/_Projects/dd-memorybank/.memory-bank",
|
|
12
|
+
"flow_root": "/Users/deksden/Documents/_Projects/dd-memorybank/.memory-bank/dd-flow",
|
|
13
|
+
"layout": "dot_memory_bank"
|
|
14
14
|
}
|
|
15
15
|
}
|
package/dist/cli/run-cli.js
CHANGED
|
@@ -1591,6 +1591,7 @@ async function dispatchRuntime(context, command, parsed) {
|
|
|
1591
1591
|
const leaseMs = optionalPositiveNumber(parsed, "lease-ms", true);
|
|
1592
1592
|
const process = registerManagedProcess(context, {
|
|
1593
1593
|
kind: requiredOption(parsed, "kind"), ownerId: requiredOption(parsed, "owner"),
|
|
1594
|
+
ownerPid: optionalPositiveNumber(parsed, "owner-pid", true),
|
|
1594
1595
|
projectId: optionalOption(parsed, "project-id") ?? null, runId: optionalOption(parsed, "run") ?? null,
|
|
1595
1596
|
workId: optionalOption(parsed, "work") ?? null, checkId: optionalOption(parsed, "check") ?? null,
|
|
1596
1597
|
operationId: optionalOption(parsed, "operation") ?? null, stdoutPath: optionalOption(parsed, "stdout") ?? null,
|
|
@@ -1601,7 +1602,7 @@ async function dispatchRuntime(context, command, parsed) {
|
|
|
1601
1602
|
if (action === "confirm") {
|
|
1602
1603
|
const leaseMs = optionalPositiveNumber(parsed, "lease-ms", true);
|
|
1603
1604
|
const processGroupId = optionalPositiveNumber(parsed, "process-group-id", true);
|
|
1604
|
-
return { ok: true, process: confirmManagedProcess(context, { id: requiredOption(parsed, "id"), leaseToken: requiredOption(parsed, "lease-token"), pid: optionalPositiveNumber(parsed, "pid", true) ?? 0, ...(processGroupId ? { processGroupId } : {}), ...(leaseMs ? { leaseMs } : {}) }) };
|
|
1605
|
+
return { ok: true, process: confirmManagedProcess(context, { id: requiredOption(parsed, "id"), leaseToken: requiredOption(parsed, "lease-token"), ownerPid: optionalPositiveNumber(parsed, "owner-pid", true), pid: optionalPositiveNumber(parsed, "pid", true) ?? 0, ...(processGroupId ? { processGroupId } : {}), ...(leaseMs ? { leaseMs } : {}) }) };
|
|
1605
1606
|
}
|
|
1606
1607
|
if (action === "heartbeat") {
|
|
1607
1608
|
const leaseMs = optionalPositiveNumber(parsed, "lease-ms", true);
|
|
@@ -3,7 +3,7 @@ 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,
|
|
6
|
+
import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessStatus, managedOwnerIsAlive, processTreeIsAlive, registerManagedProcess, reservePorts } from "./managed-processes.js";
|
|
7
7
|
const profileRelativePath = path.join(".memory-bank", "spec", "engineering", "code-check-profile.json");
|
|
8
8
|
// Receipt reservation precedes child spawn so an interrupted CLI leaves an
|
|
9
9
|
// auditable attempt. A second caller must not mistake that small handoff window
|
|
@@ -99,7 +99,7 @@ export async function runCodeChecks(context, input) {
|
|
|
99
99
|
try {
|
|
100
100
|
fs.mkdirSync(evidenceDir, { recursive: true });
|
|
101
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 } });
|
|
102
|
+
const managed = registerManagedProcess(context, { kind: "check", ownerPid: process.pid, ownerId: `check:${id}`, projectId: input.projectId, runId: input.runId, workId: input.workId ?? null, checkId: id, stdoutPath, stderrPath, metadata: { command: group.command } });
|
|
103
103
|
const allocation = await reservePorts(context, { ownerId: managed.owner_id, processId: managed.id, names: group.ports });
|
|
104
104
|
resources = allocation.ports;
|
|
105
105
|
input.progress?.(`check ${index + 1}/${groups.length} started: ${group.command}`);
|
|
@@ -109,13 +109,18 @@ export async function runCodeChecks(context, input) {
|
|
|
109
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
110
|
}
|
|
111
111
|
finally {
|
|
112
|
-
allocation.release();
|
|
113
112
|
fs.closeSync(stdout);
|
|
114
113
|
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
114
|
}
|
|
115
|
+
const current = managedProcessStatus(context).find(item => item.id === managed.id);
|
|
116
|
+
if (processTreeIsAlive(current))
|
|
117
|
+
throw new AppError("check_in_progress", "The check shell exited but its process tree is still live; retain this receipt and its resources until settlement", 2, { process_id: managed.id, check_receipt_id: id });
|
|
118
|
+
allocation.release();
|
|
119
|
+
finishManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, state: result.aborted || result.exitCode !== 0 || Boolean(result.error) ? "failed" : "stopped", reason: result.error });
|
|
117
120
|
}
|
|
118
121
|
catch (error) {
|
|
122
|
+
if (error instanceof AppError && error.code === "check_in_progress")
|
|
123
|
+
throw error;
|
|
119
124
|
result = { exitCode: null, error: error instanceof Error ? error.message : String(error), aborted: true };
|
|
120
125
|
}
|
|
121
126
|
const after = workspaceState(input.workspaceRoot, [input.runHome]);
|
|
@@ -158,7 +163,7 @@ export function reconcileUnfinishedChecks(context, input) {
|
|
|
158
163
|
// A stale lease after host sleep is not permission to duplicate a living
|
|
159
164
|
// check. Its durable completion marker or explicit reconciliation decides
|
|
160
165
|
// the outcome before another invocation may start.
|
|
161
|
-
if (process && ["starting", "running", "stopping"].includes(process.state) && (process.state === "starting" ||
|
|
166
|
+
if (process && ["starting", "running", "stopping"].includes(process.state) && (processTreeIsAlive(process) || (process.state === "starting" && (managedOwnerIsAlive(process) !== false || Date.parse(process.lease_expires_at) > Date.parse(context.now()))))) {
|
|
162
167
|
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 });
|
|
163
168
|
}
|
|
164
169
|
if (!process && !completion && Date.now() - Date.parse(pendingReceipt.started_at) < receiptStartingGraceMs) {
|
|
@@ -269,24 +274,65 @@ function collectRequiredArtifacts(root, required) { const missing = []; const it
|
|
|
269
274
|
} return { complete: missing.length === 0, missing, items }; }
|
|
270
275
|
function listFiles(root, current = root) { return fs.readdirSync(current, { withFileTypes: true }).flatMap((entry) => { if ([".git", "node_modules"].includes(entry.name))
|
|
271
276
|
return []; const absolute = path.join(current, entry.name); return entry.isDirectory() ? listFiles(root, absolute) : [path.relative(root, absolute)]; }).sort(); }
|
|
272
|
-
function runCheck(command, cwd, environment, stdout, stderr, heartbeat, onSpawn, renewLease) {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
+
export function runCheck(command, cwd, environment, stdout, stderr, heartbeat, onSpawn, renewLease) {
|
|
278
|
+
return new Promise((resolve) => {
|
|
279
|
+
const started = Date.now();
|
|
280
|
+
const child = spawn("/bin/sh", ["-lc", checkShellScript()], { cwd, env: { ...environment, DD_FLOW_CHECK_COMMAND: command }, stdio: ["ignore", stdout, stderr], detached: process.platform !== "win32" });
|
|
281
|
+
let error = null;
|
|
282
|
+
let stopping = false;
|
|
283
|
+
let progress;
|
|
284
|
+
let escalation;
|
|
285
|
+
const stop = (cause) => {
|
|
286
|
+
if (stopping)
|
|
287
|
+
return;
|
|
288
|
+
stopping = true;
|
|
289
|
+
error = cause instanceof Error ? cause.message : String(cause);
|
|
290
|
+
if (progress) {
|
|
291
|
+
clearInterval(progress);
|
|
292
|
+
progress = undefined;
|
|
293
|
+
}
|
|
294
|
+
if (child.pid) {
|
|
295
|
+
terminateProcessGroup(child.pid, "SIGTERM");
|
|
296
|
+
escalation = setTimeout(() => terminateProcessGroup(child.pid, "SIGKILL"), 5_000);
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
// Register handlers before checking pid or calling persistence callbacks.
|
|
300
|
+
// Failure is not completion until the spawned process has actually closed.
|
|
301
|
+
child.once("error", (value) => { error = value.message; });
|
|
302
|
+
child.once("close", (exitCode, signal) => {
|
|
303
|
+
if (progress)
|
|
304
|
+
clearInterval(progress);
|
|
305
|
+
if (escalation)
|
|
306
|
+
clearTimeout(escalation);
|
|
307
|
+
if (stopping)
|
|
308
|
+
terminateProcessGroup(child.pid, "SIGKILL");
|
|
309
|
+
resolve({ exitCode, error: error ?? (signal ? `terminated by ${signal}` : null), aborted: stopping || Boolean(signal) || Boolean(error) });
|
|
310
|
+
});
|
|
311
|
+
if (!child.pid) {
|
|
312
|
+
error = "check process did not provide a PID";
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
try {
|
|
316
|
+
onSpawn(child.pid);
|
|
317
|
+
}
|
|
318
|
+
catch (cause) {
|
|
319
|
+
stop(cause);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
progress = setInterval(() => {
|
|
323
|
+
try {
|
|
324
|
+
if (!renewLease()) {
|
|
325
|
+
stop("managed process lease lost");
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
heartbeat(Math.floor((Date.now() - started) / 1000));
|
|
329
|
+
}
|
|
330
|
+
catch (cause) {
|
|
331
|
+
stop(cause);
|
|
332
|
+
}
|
|
333
|
+
}, 15_000);
|
|
334
|
+
});
|
|
277
335
|
}
|
|
278
|
-
catch (error) {
|
|
279
|
-
terminateProcessGroup(child.pid, "SIGTERM");
|
|
280
|
-
resolve({ exitCode: null, error: error instanceof Error ? error.message : String(error), aborted: true });
|
|
281
|
-
return;
|
|
282
|
-
} let leaseLost = false; let error = null; let escalation; const progress = setInterval(() => { const elapsed = Math.floor((Date.now() - started) / 1000); if (!renewLease()) {
|
|
283
|
-
error = "managed process lease lost";
|
|
284
|
-
leaseLost = true;
|
|
285
|
-
terminateProcessGroup(child.pid, "SIGTERM");
|
|
286
|
-
escalation = setTimeout(() => terminateProcessGroup(child.pid, "SIGKILL"), 5_000);
|
|
287
|
-
return;
|
|
288
|
-
} heartbeat(elapsed); }, 15_000); child.once("error", (value) => { error = value.message; }); child.once("close", (exitCode, signal) => { clearInterval(progress); if (escalation)
|
|
289
|
-
clearTimeout(escalation); resolve({ exitCode, error: error ?? (signal ? `terminated by ${signal}` : null), aborted: leaseLost || Boolean(signal) || Boolean(error) }); }); }); }
|
|
290
336
|
function checkShellScript() {
|
|
291
337
|
return [
|
|
292
338
|
'completion="${DD_FLOW_CHECK_COMPLETION_FILE:-}"',
|
|
@@ -13,7 +13,7 @@ export function registerManagedProcess(context, input) {
|
|
|
13
13
|
const token = crypto.randomUUID();
|
|
14
14
|
db.run(`INSERT INTO managed_processes
|
|
15
15
|
(id, kind, pid, pid_started_at, owner_id, lease_token, lease_expires_at, project_id, run_id, work_id, check_id, operation_id, stdout_path, stderr_path, state, started_at, updated_at, finished_at, termination_reason, metadata_json)
|
|
16
|
-
VALUES (?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'starting', ?, ?, NULL, NULL, ?)`, [id, input.kind, input.ownerId, token, leaseExpiry(now, input.leaseMs), input.projectId ?? null, input.runId ?? null, input.workId ?? null, input.checkId ?? null, input.operationId ?? null, input.stdoutPath ?? null, input.stderrPath ?? null, now, now, JSON.stringify(input.metadata
|
|
16
|
+
VALUES (?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'starting', ?, ?, NULL, NULL, ?)`, [id, input.kind, input.ownerId, token, leaseExpiry(now, input.leaseMs), input.projectId ?? null, input.runId ?? null, input.workId ?? null, input.checkId ?? null, input.operationId ?? null, input.stdoutPath ?? null, input.stderrPath ?? null, now, now, JSON.stringify({ ...input.metadata, ...(input.ownerPid ? { owner_pid: input.ownerPid, owner_pid_started_at: processStartedAt(input.ownerPid) } : {}) })]);
|
|
17
17
|
return requireProcess(db, id);
|
|
18
18
|
}
|
|
19
19
|
export function confirmManagedProcess(context, input) {
|
|
@@ -23,6 +23,10 @@ export function confirmManagedProcess(context, input) {
|
|
|
23
23
|
const metadata = parseMetadata(current.metadata_json);
|
|
24
24
|
if (input.processGroupId)
|
|
25
25
|
metadata.process_group_id = input.processGroupId;
|
|
26
|
+
if (input.ownerPid) {
|
|
27
|
+
metadata.owner_pid = input.ownerPid;
|
|
28
|
+
metadata.owner_pid_started_at = processStartedAt(input.ownerPid);
|
|
29
|
+
}
|
|
26
30
|
const result = db.run(`UPDATE managed_processes
|
|
27
31
|
SET pid = ?, pid_started_at = ?, state = 'running', lease_expires_at = ?, updated_at = ?, metadata_json = ?
|
|
28
32
|
WHERE id = ? AND lease_token = ? AND state = 'starting'`, [input.pid, processStartedAt(input.pid), leaseExpiry(now, input.leaseMs), now, JSON.stringify(metadata), input.id, input.leaseToken]);
|
|
@@ -48,12 +52,13 @@ export function processIsAlive(record) {
|
|
|
48
52
|
try {
|
|
49
53
|
process.kill(record.pid, 0);
|
|
50
54
|
}
|
|
51
|
-
catch {
|
|
52
|
-
return
|
|
55
|
+
catch (error) {
|
|
56
|
+
return error.code !== "ESRCH";
|
|
53
57
|
}
|
|
54
|
-
|
|
58
|
+
const started = processStartedAt(record.pid);
|
|
59
|
+
return !record.pid_started_at || !started || record.pid_started_at === started;
|
|
55
60
|
}
|
|
56
|
-
function processTreeIsAlive(record) {
|
|
61
|
+
export function processTreeIsAlive(record) {
|
|
57
62
|
if (processIsAlive(record))
|
|
58
63
|
return true;
|
|
59
64
|
const group = parseMetadata(record.metadata_json).process_group_id;
|
|
@@ -68,6 +73,12 @@ function processTreeIsAlive(record) {
|
|
|
68
73
|
}
|
|
69
74
|
return false;
|
|
70
75
|
}
|
|
76
|
+
export function managedOwnerIsAlive(record) {
|
|
77
|
+
const metadata = parseMetadata(record.metadata_json);
|
|
78
|
+
if (typeof metadata.owner_pid !== "number")
|
|
79
|
+
return null;
|
|
80
|
+
return processIsAlive({ pid: metadata.owner_pid, pid_started_at: typeof metadata.owner_pid_started_at === "string" ? metadata.owner_pid_started_at : null });
|
|
81
|
+
}
|
|
71
82
|
/**
|
|
72
83
|
* Expiry proves that observation stopped, not that the owner died. A live PID
|
|
73
84
|
* remains owned until its result is reconciled; only a dead process can be
|
|
@@ -81,7 +92,8 @@ export function claimExpiredManagedProcesses(context, ownerId) {
|
|
|
81
92
|
const candidates = db.all("SELECT * FROM managed_processes WHERE state IN ('starting','running','stopping','orphaned') AND lease_expires_at < ? ORDER BY lease_expires_at, id", [now]);
|
|
82
93
|
const claimed = [];
|
|
83
94
|
for (const candidate of candidates) {
|
|
84
|
-
|
|
95
|
+
const ownerAlive = managedOwnerIsAlive(candidate);
|
|
96
|
+
if (ownerAlive === true || (ownerAlive === null && processTreeIsAlive(candidate)))
|
|
85
97
|
continue;
|
|
86
98
|
const token = crypto.randomUUID();
|
|
87
99
|
const result = db.run("UPDATE managed_processes SET owner_id = ?, lease_token = ?, state = 'orphaned', lease_expires_at = ?, updated_at = ? WHERE id = ? AND lease_token = ? AND state IN ('starting','running','stopping','orphaned') AND lease_expires_at < ?", [ownerId, token, leaseExpiry(now), now, candidate.id, candidate.lease_token, now]);
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { runWorkspaceBootstrap } from "./workspace-bootstrap.js";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { AppError } from "../shared/errors.js";
|
|
6
6
|
import { resolveProjectRoot } from "../storage/paths.js";
|
|
7
|
-
import { aggregateCheckDeclarations, checkReceipts,
|
|
7
|
+
import { aggregateCheckDeclarations, checkReceipts, finalCodeCheckDeclarations, runCodeChecks, unchangedFinalGateFailures, workspaceChangedPaths, workspaceFingerprint } from "./code-checks.js";
|
|
8
8
|
import { requireProjectByRoot } from "./projects.js";
|
|
9
9
|
import { appendFlowRunTimelineEvent, advanceFlowRun, attachFlowRunStage, completeFlowRunStage, completeFlowRun, getFlowRunVariables, gitFacts } from "./runs.js";
|
|
10
10
|
import { validateSchema } from "./schema-validation.js";
|
|
@@ -653,50 +653,22 @@ async function ensureCodeWorkspaceReady(context, projectId, run, root, readiness
|
|
|
653
653
|
throw new AppError("execution_profile_invalid", "CODE requires a frozen bootstrap command in the RUN execution profile", 1, { run_id: run.id });
|
|
654
654
|
}
|
|
655
655
|
const receiptPath = path.join(root, "workspace-readiness.json");
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
return { ...prior, reused: true };
|
|
656
|
+
// Re-run the project bootstrap on stage entry: a prior receipt cannot prove
|
|
657
|
+
// that ignored dependencies or local services still exist.
|
|
659
658
|
const startedAt = new Date().toISOString();
|
|
660
659
|
progress?.(`workspace bootstrap started: ${bootstrap.command}`);
|
|
661
|
-
const result = await
|
|
660
|
+
const result = await runWorkspaceBootstrap(context, { projectId, runId: run.id, runHome: requireHome(run), workspaceRoot: run.workspace_root, command: bootstrap.command, artifactDir: "05-code/readiness", progress });
|
|
662
661
|
const bootstrapPassed = result.exit_code === 0 && !result.error;
|
|
663
662
|
const checks = bootstrapPassed && readinessChecks.length
|
|
664
663
|
? await runCodeChecks(context, { projectId, runId: run.id, runHome: requireHome(run), workspaceRoot: run.workspace_root, scope: "aggregate", artifactDir: "05-code/readiness", checks: readinessChecks, ...(progress ? { progress } : {}) })
|
|
665
664
|
: [];
|
|
666
|
-
const receipt = { schema_id: "dd-flow/workspace-readiness@2", workspace_root: run.workspace_root, command: bootstrap.command, policy_ref: bootstrap.policy_ref, started_at: startedAt, finished_at: new Date().toISOString(), status: bootstrapPassed && checks.every((check) => check.status === "passed") ? "passed" : "failed", exit_code: result.exit_code, stdout: result.stdout, stderr: result.stderr, check_declarations: readinessChecks, checks: checks.map((check) => ({ id: check.id, status: check.status, receipt_path: check.receipt_path })), ...(result.error ? { error: result.error } : {}), receipt_path: receiptPath };
|
|
665
|
+
const receipt = { schema_id: "dd-flow/workspace-readiness@2", workspace_root: run.workspace_root, command: bootstrap.command, policy_ref: bootstrap.policy_ref, started_at: startedAt, finished_at: new Date().toISOString(), status: bootstrapPassed && checks.every((check) => check.status === "passed") ? "passed" : "failed", process_id: result.process_id, stdout_path: result.stdout_path, stderr_path: result.stderr_path, completion_path: result.completion_path, exit_code: result.exit_code, stdout: result.stdout, stderr: result.stderr, check_declarations: readinessChecks, checks: checks.map((check) => ({ id: check.id, status: check.status, receipt_path: check.receipt_path })), ...(result.error ? { error: result.error } : {}), receipt_path: receiptPath };
|
|
667
666
|
fs.writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`);
|
|
668
667
|
if (receipt.status !== "passed")
|
|
669
668
|
throw new AppError("workspace_readiness_failed", "CODE workspace bootstrap failed", 1, receipt);
|
|
670
669
|
progress?.("workspace bootstrap passed");
|
|
671
670
|
return receipt;
|
|
672
671
|
}
|
|
673
|
-
function runBootstrap(command, cwd, environment, progress) {
|
|
674
|
-
return new Promise((resolve) => {
|
|
675
|
-
const started = Date.now();
|
|
676
|
-
const child = spawn("/bin/sh", ["-lc", command], { cwd, env: environment, stdio: ["ignore", "pipe", "pipe"] });
|
|
677
|
-
let stdout = "";
|
|
678
|
-
let stderr = "";
|
|
679
|
-
let error = null;
|
|
680
|
-
child.stdout.setEncoding("utf8").on("data", (chunk) => { stdout += chunk; });
|
|
681
|
-
child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr += chunk; });
|
|
682
|
-
const heartbeat = setInterval(() => progress?.(`workspace bootstrap still running (${Math.floor((Date.now() - started) / 1000)}s)`), 15_000);
|
|
683
|
-
const timeout = setTimeout(() => child.kill("SIGTERM"), 15 * 60 * 1000);
|
|
684
|
-
child.once("error", (value) => { error = value.message; });
|
|
685
|
-
child.once("close", (exitCode, signal) => {
|
|
686
|
-
clearInterval(heartbeat);
|
|
687
|
-
clearTimeout(timeout);
|
|
688
|
-
resolve({ exit_code: exitCode, stdout, stderr, error: error ?? (signal ? `terminated by ${signal}` : null) });
|
|
689
|
-
});
|
|
690
|
-
});
|
|
691
|
-
}
|
|
692
|
-
function readReadiness(file) {
|
|
693
|
-
try {
|
|
694
|
-
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
695
|
-
}
|
|
696
|
-
catch {
|
|
697
|
-
return null;
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
672
|
function requireHome(run) {
|
|
701
673
|
if (!run.run_root)
|
|
702
674
|
throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
+
import { runWorkspaceBootstrap } from "./workspace-bootstrap.js";
|
|
2
3
|
import fs from "node:fs";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
@@ -220,7 +221,7 @@ export async function finishVnextMerge(context, input) {
|
|
|
220
221
|
throw new AppError("merge_semantic_blocked", "A blocked semantic result cannot complete MERGE", 2, { result_path: resultPath });
|
|
221
222
|
if (request.checkpoint === "apply_recorded") {
|
|
222
223
|
input.progress?.("bootstrapping integrated target");
|
|
223
|
-
runBootstrap(run, request.target_workspace);
|
|
224
|
+
await runBootstrap(context, run, request.target_workspace, request.merge_request_id, input.progress);
|
|
224
225
|
context.db.run("UPDATE merge_requests SET checkpoint = 'bootstrap_ready', updated_at = ? WHERE merge_request_id = ?", [context.now(), request.merge_request_id]);
|
|
225
226
|
request = requireRequest(context, request.merge_request_id);
|
|
226
227
|
}
|
|
@@ -360,9 +361,14 @@ function protocolIds(home) { const root = path.join(home, "03-plan"); if (!fs.ex
|
|
|
360
361
|
function workspaceRoute(home) { const file = path.join(home, "02-protocolize", "workspace-route.json"); const value = JSON.parse(fs.readFileSync(file, "utf8")); if (!value.policy?.integration_branch)
|
|
361
362
|
throw new AppError("workspace_route_missing", "MERGE requires the frozen integration branch", 1, { file }); return { integration_branch: value.policy.integration_branch }; }
|
|
362
363
|
function executionSettings(run) { return JSON.parse(run.index_json).execution_profile?.settings ?? {}; }
|
|
363
|
-
function runBootstrap(run, cwd
|
|
364
|
-
|
|
365
|
-
|
|
364
|
+
async function runBootstrap(context, run, cwd, mergeId, progress) {
|
|
365
|
+
const command = executionSettings(run).code_bootstrap?.command;
|
|
366
|
+
if (!command)
|
|
367
|
+
throw new AppError("execution_profile_invalid", "MERGE requires a frozen workspace bootstrap command", 2);
|
|
368
|
+
const result = await runWorkspaceBootstrap(context, { projectId: run.project_id, runId: run.id, runHome: requireHome(run), workspaceRoot: cwd, command, artifactDir: `${stageDir}/${mergeId}/readiness`, progress });
|
|
369
|
+
if (result.exit_code !== 0 || result.error)
|
|
370
|
+
throw new AppError("merge_bootstrap_failed", "Integrated target bootstrap failed; inspect its retained logs", 2, { command, ...result });
|
|
371
|
+
}
|
|
366
372
|
function verifyLocalDelivery(request) { const head = gitValue(request.target_workspace, ["rev-parse", request.target_branch]); if (!request.integration_commit || head !== request.integration_commit)
|
|
367
373
|
throw new AppError("merge_delivery_unproven", "Local integration branch does not resolve to the accepted integration commit", 1, { target_branch: request.target_branch, expected: request.integration_commit, actual: head }); }
|
|
368
374
|
function cleanupReceiptPath(run) { return path.join(requireHome(run), stageDir, "cleanup-receipt.json"); }
|
|
@@ -464,10 +464,10 @@ function validateCodeWorkResult(work, value, projectRoot, runHome, runId) {
|
|
|
464
464
|
if (unchangedDocuments.length)
|
|
465
465
|
throw new AppError("document_update_not_materialized", "Assigned durable document updates must exist and differ from their PLAN baseline", 2, { work_id: work.work_id, unchanged_paths: unchangedDocuments });
|
|
466
466
|
const assigned = packet.repair?.review_findings?.map((finding) => finding.finding_ref) ?? [];
|
|
467
|
+
validateReviewResolutions(work.work_id, assigned, result.resolutions ?? []);
|
|
467
468
|
if (assigned.length) {
|
|
468
469
|
if ((result.changed_paths?.length ?? 0) === 0)
|
|
469
470
|
throw new AppError("review_repair_no_change", "Review repair must materialize a project change; a no-op cannot resolve a finding", 2, { work_id: work.work_id, findings: assigned });
|
|
470
|
-
validateReviewResolutions(work.work_id, assigned, result.resolutions ?? []);
|
|
471
471
|
for (const resolution of result.resolutions ?? [])
|
|
472
472
|
for (const ref of resolution.evidence_refs ?? [])
|
|
473
473
|
assertPortableArtifactRef(ref, { workspaceRoot: projectRoot, runHome, runId });
|
|
@@ -491,28 +491,34 @@ function renderWorkerPrompt(context, work, run, dependencies) {
|
|
|
491
491
|
const completionRepair = mergeWork
|
|
492
492
|
? ["A failed integration check is evidence for a source-repair cycle, not permission to repair the integration workspace. Follow the exact merge repair command returned by dd-flow."]
|
|
493
493
|
: ["Read the failed receipt and its stdout/stderr. Fix project-owned source, migration, test, formatting, or configuration errors in this same Work, then call Finish again. Do not invent a cause that does not appear in the retained output."];
|
|
494
|
-
const codeContext = packet ? ["<semantic_spine>", JSON.stringify(packet.semantic_spine, null, 2), "</semantic_spine>", "", ...(packet.repair ? ["<repair_context>", JSON.stringify(packet.repair, null, 2), "Read the failed receipt and its linked stdout/stderr before editing. Preserve the accepted origin context and fix only the evidenced failure.", "</repair_context>", ""] : []), "<accepted_requirements>", JSON.stringify(packet.requirements, null, 2), "</accepted_requirements>", "", "<write_boundary_invariant>", "For a mutation guarded by membership, ownership, authorization or parent lifecycle state, preserve that predicate in the write statement or make guard and write one explicit transaction with the needed lock. A prior read may diagnose an error but never proves a later write remains allowed. Apply this to create, update, delete and parent-state mutations.", "</write_boundary_invariant>", "", "<acceptance_context>", "The criteria below are end-to-end context. Complete this Work's semantic contribution and declared checks; another ordered Work may own a different acceptance surface.", JSON.stringify(packet.acceptance, null, 2), "</acceptance_context>", "", "<required_read>", "These are mandatory starting sources, not a read allowlist. Read any additional project files needed to implement the Work correctly.", ...packet.required_read.map((item) => `- ${resolveRunReferences(item, work.run_id, requireRunHome(run))}`), "</required_read>", "", "<discovery_boundary>", "These are likely discovery areas, not a hard boundary. Expand project-local investigation when required and report material additions.", ...packet.discovery_boundary.map((item) => `- ${item}`), "</discovery_boundary>", "", "<planned_write_areas>", "SOFT COORDINATION HINT ONLY. These paths help the coordinator avoid concurrent collisions. They do not grant or deny write permission and do not limit the files needed for this Work. You may create or change any project file under workspace_root that is necessary and in semantic scope; report every actual changed path.", ...(packet.planned_write_areas.length ? packet.planned_write_areas.map((item) => `- ${item}`) : ["- none predicted; derive the necessary files from the task"]), "</planned_write_areas>", "", ...(packet.provides_checks.length ? ["<provided_checks>", ...packet.provides_checks.map((item) => `- ${item.id}: materialize ${item.command}${item.definition ? ` as ${item.definition}` : ""}; it is not usable until this Work finishes.`), "Update the declared project command or alias before Work finish. The CLI verifies
|
|
494
|
+
const codeContext = packet ? ["<semantic_spine>", JSON.stringify(packet.semantic_spine, null, 2), "</semantic_spine>", "", ...(packet.repair ? ["<repair_context>", JSON.stringify(packet.repair, null, 2), "Read the failed receipt and its linked stdout/stderr before editing. Preserve the accepted origin context and fix only the evidenced failure.", "</repair_context>", ""] : []), "<accepted_requirements>", JSON.stringify(packet.requirements, null, 2), "</accepted_requirements>", "", "<write_boundary_invariant>", "For a mutation guarded by membership, ownership, authorization or parent lifecycle state, preserve that predicate in the write statement or make guard and write one explicit transaction with the needed lock. A prior read may diagnose an error but never proves a later write remains allowed. Apply this to create, update, delete and parent-state mutations.", "</write_boundary_invariant>", "", "<acceptance_context>", "The criteria below are end-to-end context. Complete this Work's semantic contribution and declared checks; another ordered Work may own a different acceptance surface.", JSON.stringify(packet.acceptance, null, 2), "</acceptance_context>", "", "<required_read>", "These are mandatory starting sources, not a read allowlist. Read any additional project files needed to implement the Work correctly.", ...packet.required_read.map((item) => `- ${resolveRunReferences(item, work.run_id, requireRunHome(run))}`), "</required_read>", "", "<discovery_boundary>", "These are likely discovery areas, not a hard boundary. Expand project-local investigation when required and report material additions.", ...packet.discovery_boundary.map((item) => `- ${item}`), "</discovery_boundary>", "", "<planned_write_areas>", "SOFT COORDINATION HINT ONLY. These paths help the coordinator avoid concurrent collisions. They do not grant or deny write permission and do not limit the files needed for this Work. You may create or change any project file under workspace_root that is necessary and in semantic scope; report every actual changed path.", ...(packet.planned_write_areas.length ? packet.planned_write_areas.map((item) => `- ${item}`) : ["- none predicted; derive the necessary files from the task"]), "</planned_write_areas>", "", ...(packet.provides_checks.length ? ["<provided_checks>", ...packet.provides_checks.map((item) => `- ${item.id}: materialize ${item.command}${item.definition ? ` as ${item.definition}` : ""}; it is not usable until this Work finishes.`), "Update the declared project command or alias before Work finish. The CLI verifies materialization at Work finish. Execution occurs at its declared run_at gate, not necessarily in this Work.", "</provided_checks>", ""] : []), "<verification>", ...packet.checks.map((item) => `- ${item.id} at ${item.run_at}: ${item.command} — ${item.purpose}`), "The CLI executes work-scoped checks and retains their receipts. Report semantic evidence only; do not rerun declared checks manually.", "</verification>", "", "<stop_conditions>", ...packet.stop_conditions.map((item) => `- ${item}`), "</stop_conditions>", ""] : [];
|
|
495
495
|
if (packet)
|
|
496
496
|
codeContext.push("<document_updates>", JSON.stringify(packet.document_updates, null, 2), "Materialize every listed update. dd-flow verifies the resulting file against its PLAN-time baseline.", "</document_updates>", "", "<completion_contract>", "Successful completion requires empty deviations and blockers and every assigned document update in changed_paths. A necessary path outside planned_write_areas is normal coordination drift, not a blocker; include it in changed_paths and continue.", "</completion_contract>", "");
|
|
497
|
-
return ["<work>", `- work_id: ${work.work_id}`, `- run_id: ${work.run_id}`, `- project_root: ${run.project_root}`, `- workspace_root: ${run.workspace_root}`, `- run_home: ${requireRunHome(run)}`, "</work>", "", "<hard_write_boundary>", ...writeBoundary, "</hard_write_boundary>", "", ...codeContext, "<dependency_results>", JSON.stringify(dependencies.filter(Boolean), null, 2), "</dependency_results>", "", "<task>", resolveRunReferences(work.task, work.run_id, requireRunHome(run)), "</task>", "", ...(work.result_schema ? ["<result_contract>", `Return JSON matching \`${work.result_schema}\`.`, ...resultSchemaGuidance(work, run.id), "Do not create result.json yourself. Send the JSON to dd-flow on stdin; it atomically validates and stores the canonical receipt.", "</result_contract>", ""] : []), "<completion>", "
|
|
497
|
+
return ["<work>", `- work_id: ${work.work_id}`, `- run_id: ${work.run_id}`, `- project_root: ${run.project_root}`, `- workspace_root: ${run.workspace_root}`, `- run_home: ${requireRunHome(run)}`, "</work>", "", "<hard_write_boundary>", ...writeBoundary, "</hard_write_boundary>", "", ...codeContext, "<dependency_results>", JSON.stringify(dependencies.filter(Boolean), null, 2), "</dependency_results>", "", "<task>", resolveRunReferences(work.task, work.run_id, requireRunHome(run)), "</task>", "", ...(work.result_schema ? ["<result_contract>", `Return JSON matching \`${work.result_schema}\`.`, ...resultSchemaGuidance(work, run.id), "Do not create result.json yourself. Send the JSON to dd-flow on stdin; it atomically validates and stores the canonical receipt.", "</result_contract>", ""] : []), "<completion>", "Work finish runs only declared run_at=work checks. Stage finish owns readiness/code/merge gates; successful Work completion does not mean those gates have passed. A failed receipt means only that the check failed; it is not proof of an engine, harness, dependency, or environment blocker.", ...completionRepair, "Use Fail only for a concrete external blocker after deterministic bootstrap or a contradiction with an accepted requirement/non-goal. Never fail merely because a necessary project path was absent from planned_write_areas.", "Finish may run for several minutes. Preserve the shell tool's process/session handle and poll that same invocation until it exits; progress arrives as JSONL on stderr. Never reissue Finish merely because final stdout has not arrived.", `Finish as one standalone command, piping your JSON object to stdin: ${command} work finish ${work.work_id} --result-stdin --project-root ${JSON.stringify(run.project_root)} --json --progress-jsonl`, `Fail only for an evidenced external or semantic-contract blocker: ${command} work fail ${work.work_id} --reason "receipt path + exact external or semantic blocker" --project-root ${JSON.stringify(run.project_root)} --json`, "</completion>", ""].join("\n");
|
|
498
498
|
}
|
|
499
|
-
function resultSchemaGuidance(work, runId) {
|
|
499
|
+
export function resultSchemaGuidance(work, runId) {
|
|
500
500
|
const schema = work.result_schema;
|
|
501
501
|
const refs = `Evidence refs for project source are relative to workspace_root; RUN evidence uses run://${runId}/path/to/artifact.`;
|
|
502
502
|
if (schema === "dd-flow/code-work-result@3") {
|
|
503
503
|
const packet = codePacket(work);
|
|
504
|
-
const
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
504
|
+
const obligations = [...new Set([
|
|
505
|
+
...(packet?.acceptance ?? []).map((item) => item.criterion_id).filter((id) => Boolean(id)),
|
|
506
|
+
...(packet?.checks ?? []).map((item) => item.id),
|
|
507
|
+
...(packet?.repair?.review_findings ?? []).flatMap((finding) => finding.obligation_refs ?? [])
|
|
508
|
+
])];
|
|
509
|
+
const findings = packet?.repair?.review_findings ?? [];
|
|
510
|
+
return [refs, `Assigned obligation refs: ${JSON.stringify(obligations)}. Cite only these in evidence.obligation_ref.`,
|
|
511
|
+
"This is a shape template, not a completed result. Populate changed_paths and evidence from the actual work and existing files; empty arrays below do not establish completion.",
|
|
512
|
+
"For a gate repair, cite the assigned check id. For a review repair, replace each resolution summary and fill evidence_refs with existing evidence proving its required outcome. Ordinary Work has resolutions: [].",
|
|
513
|
+
"```json", JSON.stringify({ schema_id: schema, summary: "Describe the actual implementation.", changed_paths: [], evidence: [], deviations: [], blockers: [], resolutions: findings.map((finding) => ({ finding_ref: finding.finding_ref, summary: "Describe how the assigned required outcome was achieved.", evidence_refs: [] })) }, null, 2), "```",
|
|
514
|
+
"Each evidence entry has exactly obligation_ref (one assigned ref) and refs (nonempty list of existing evidence paths). Each resolution has finding_ref, summary and nonempty evidence_refs. Do not invent file names or finding ids."];
|
|
509
515
|
}
|
|
510
516
|
if (schema === "dd-flow/code-review-result@1") {
|
|
511
|
-
return [refs, "Assess every assigned aspect exactly once. Number findings locally inside this reviewer Work as FIND-001, FIND-002, ...; dd-flow adds the Work id to form the canonical reference.", "
|
|
517
|
+
return [refs, "Assess every assigned aspect exactly once. Number findings locally inside this reviewer Work as FIND-001, FIND-002, ...; dd-flow adds the Work id to form the canonical reference.", "This is a shape template: replace labels with assigned ids and observed verdicts, fill evidence_refs using existing files from the task packet, and remove findings when none exist. Report only material, direct-evidence findings; taste and cosmetics are not findings:", "```json", JSON.stringify({ schema_id: schema, verdict: "pass | findings | blocked", summary: "Concise evidence-backed conclusion.", aspects: [{ aspect_id: "assigned_aspect_id", verdict: "pass | findings | blocked", summary: "Conclusion.", evidence_refs: [] }], findings: [{ finding_id: "FIND-001", aspect_id: "assigned_aspect_id", priority: "p0 | p1 | p2 | p3", problem: "Violated obligation or rule.", impact: "Concrete risk or failure.", required_outcome: "Smallest observable result that closes the defect.", evidence_refs: ["path/to/file"], obligation_refs: ["R-001 | AC-001 | policy ref"] }] }, null, 2), "```"];
|
|
512
518
|
}
|
|
513
519
|
if (schema !== "dd-flow/plan-review-result@1")
|
|
514
520
|
return [];
|
|
515
|
-
return [refs, "
|
|
521
|
+
return [refs, "This is a shape template: replace labels with assigned ids and observed verdicts, fill evidence_refs using existing files from the task packet, and remove findings when none exist. Do not add fields. Number findings locally inside this reviewer Work as FIND-001, FIND-002, ...; dd-flow adds the Work id when the coordinator classifies them:", "```json", JSON.stringify({ schema_id: schema, plan_revision: 1, overall_verdict: "pass | watch | needs_changes | blocked", summary: "Concise evidence-backed conclusion.", aspects: [{ aspect_id: "assigned_aspect_id", verdict: "pass | watch | needs_changes | blocked", summary: "Conclusion for this aspect.", evidence_refs: [], findings: [{ finding_id: "FIND-001", severity: "high | medium | low | info", summary: "Problem, if any.", evidence_refs: ["path/to/file"] }] }] }, null, 2), "```"];
|
|
516
522
|
}
|
|
517
523
|
export function validateCodeReviewResultIdentity(work, value) {
|
|
518
524
|
const group = codeReviewGroup(work);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { AppError } from "../shared/errors.js";
|
|
5
|
+
import { codeExecutionEnvironment, runCheck } from "./code-checks.js";
|
|
6
|
+
import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedOwnerIsAlive, managedProcessStatus, processTreeIsAlive, registerManagedProcess } from "./managed-processes.js";
|
|
7
|
+
/** CODE and MERGE prepare their own checkout through the same managed executor.
|
|
8
|
+
* Bootstrap is a project operation, not an alias classified by the engine as a test. */
|
|
9
|
+
export async function runWorkspaceBootstrap(context, input) {
|
|
10
|
+
const operationId = `bootstrap:${crypto.createHash("sha256").update(JSON.stringify([input.runId, input.workspaceRoot, input.artifactDir, input.command])).digest("hex")}`;
|
|
11
|
+
const pending = managedProcessStatus(context).find(item => item.operation_id === operationId && ["starting", "running", "stopping"].includes(item.state));
|
|
12
|
+
if (pending) {
|
|
13
|
+
if (processTreeIsAlive(pending) || (pending.state === "starting" && managedOwnerIsAlive(pending) !== false)) {
|
|
14
|
+
throw new AppError("bootstrap_in_progress", "Workspace preparation is still running; inspect its logs and wait for the existing process, do not launch another copy", 2, { process_id: pending.id, stdout_path: pending.stdout_path, stderr_path: pending.stderr_path });
|
|
15
|
+
}
|
|
16
|
+
const completionPath = path.join(path.dirname(pending.stdout_path), "completion.json");
|
|
17
|
+
let completion = null;
|
|
18
|
+
try {
|
|
19
|
+
completion = JSON.parse(fs.readFileSync(completionPath, "utf8"));
|
|
20
|
+
}
|
|
21
|
+
catch { /* Interrupted shell has no terminal marker. */ }
|
|
22
|
+
const exitCode = typeof completion?.exit_code === "number" ? completion.exit_code : null;
|
|
23
|
+
const error = exitCode === null ? "bootstrap ended without a completion marker; inspect retained logs before retrying" : null;
|
|
24
|
+
finishManagedProcess(context, { id: pending.id, leaseToken: pending.lease_token, state: exitCode === 0 ? "stopped" : "failed", reason: error ?? "bootstrap_completion_reconciled" });
|
|
25
|
+
return result(pending.id, pending.stdout_path, pending.stderr_path, completionPath, exitCode, error);
|
|
26
|
+
}
|
|
27
|
+
const directory = path.join(input.runHome, input.artifactDir, `bootstrap-${crypto.randomUUID()}`);
|
|
28
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
29
|
+
const stdoutPath = path.join(directory, "stdout.log"), stderrPath = path.join(directory, "stderr.log"), completionPath = path.join(directory, "completion.json");
|
|
30
|
+
const managed = registerManagedProcess(context, { kind: "workspace-bootstrap", ownerId: operationId, ownerPid: process.pid, operationId, projectId: input.projectId, runId: input.runId, stdoutPath, stderrPath, metadata: { command: input.command, workspace_root: input.workspaceRoot } });
|
|
31
|
+
const stdout = fs.openSync(stdoutPath, "a"), stderr = fs.openSync(stderrPath, "a");
|
|
32
|
+
try {
|
|
33
|
+
input.progress?.(`workspace bootstrap started: ${input.command}; logs: ${directory}`);
|
|
34
|
+
const completed = await runCheck(input.command, input.workspaceRoot, { ...codeExecutionEnvironment(input.workspaceRoot), DD_FLOW_CHECK_COMPLETION_FILE: completionPath }, stdout, stderr, elapsed => input.progress?.(`workspace bootstrap still running (${elapsed}s); logs: ${directory}; next report in 15s`), 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 }));
|
|
35
|
+
const current = managedProcessStatus(context).find(item => item.id === managed.id);
|
|
36
|
+
if (processTreeIsAlive(current))
|
|
37
|
+
throw new AppError("bootstrap_tree_not_settled", "Bootstrap shell exited but still owns live processes; resources were retained for inspection", 2, { process_id: managed.id, stdout_path: stdoutPath, stderr_path: stderrPath });
|
|
38
|
+
finishManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, state: completed.exitCode === 0 && !completed.error ? "stopped" : "failed", reason: completed.error });
|
|
39
|
+
return result(managed.id, stdoutPath, stderrPath, completionPath, completed.exitCode, completed.error);
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
fs.closeSync(stdout);
|
|
43
|
+
fs.closeSync(stderr);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function result(id, stdout, stderr, completion, exitCode, error) {
|
|
47
|
+
// Full logs remain on disk; keep the returned prompt bounded.
|
|
48
|
+
const excerpt = (file) => fs.existsSync(file) ? fs.readFileSync(file, "utf8").slice(-64_000) : "";
|
|
49
|
+
return { exit_code: exitCode, stdout: excerpt(stdout), stderr: excerpt(stderr), error, process_id: id, stdout_path: stdout, stderr_path: stderr, completion_path: completion };
|
|
50
|
+
}
|