@deksden-com/dd-flow-cli 0.9.0-beta.38 → 0.9.0-beta.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -1
- package/dist/build-info.json +5 -5
- package/dist/cli/help.js +42 -1
- package/dist/cli/run-cli.js +147 -17
- package/dist/harness-runtime/bin/dd-agy.mjs +3 -2
- package/dist/harness-runtime/bin/dd-codex.mjs +4 -3
- package/dist/harness-runtime/bin/dd-droid.mjs +3 -2
- package/dist/harness-runtime/bin/dd-grok.mjs +4 -3
- package/dist/harness-runtime/bin/dd-opencode.mjs +4 -3
- package/dist/harness-runtime/bin/dd-zcode.mjs +4 -3
- package/dist/harness-runtime/lib/daemon-operations.mjs +48 -9
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +49 -12
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +57 -16
- package/dist/harness-runtime/lib/dd-codex.mjs +10 -2
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +3 -2
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +12 -6
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +4 -3
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +8 -5
- package/dist/harness-runtime/lib/managed-daemon.mjs +125 -6
- package/dist/harness-runtime/lib/process-json.mjs +1 -1
- package/dist/harness-runtime/lib/session-settlement.mjs +4 -2
- package/dist/schemas/run-control-receipt.schema.json +99 -0
- package/dist/schemas/run-control-request.schema.json +36 -0
- package/dist/services/cleanup.js +7 -4
- package/dist/services/cli-operation-classifier.js +3 -1
- package/dist/services/code-checks.js +33 -7
- package/dist/services/controller-fanout.js +105 -0
- package/dist/services/engines.js +2 -0
- package/dist/services/eval-snapshots.js +379 -36
- package/dist/services/execution-policy.js +122 -6
- package/dist/services/external-work-launch.js +33 -12
- package/dist/services/harness-adapter.js +49 -2
- package/dist/services/hooks.js +5 -5
- package/dist/services/managed-daemon-binding.js +38 -0
- package/dist/services/managed-processes.js +54 -7
- package/dist/services/merge-server.js +53 -30
- package/dist/services/native-daemon-history.js +49 -0
- package/dist/services/native-session-control.js +31 -0
- package/dist/services/protocols.js +9 -0
- package/dist/services/recovery-snapshot-database.js +103 -0
- package/dist/services/run-control-receipt.js +56 -0
- package/dist/services/run-control-worker.js +297 -0
- package/dist/services/run-control.js +752 -0
- package/dist/services/run-controller-adapter.js +79 -0
- package/dist/services/run-controller-capture.js +117 -0
- package/dist/services/run-controller-process.js +178 -0
- package/dist/services/run-controller-recovery.js +219 -0
- package/dist/services/run-controller-state.js +31 -0
- package/dist/services/run-controller.js +539 -0
- package/dist/services/run-recovery-runtime.js +69 -0
- package/dist/services/run-recovery.js +33 -9
- package/dist/services/runs.js +65 -13
- package/dist/services/runtime-budget.js +362 -0
- package/dist/services/runtime-scope-capture.js +50 -0
- package/dist/services/runtime-scope-control.js +431 -0
- package/dist/services/runtime-scope-resume.js +543 -0
- package/dist/services/runtime-scope-stop.js +95 -0
- package/dist/services/runtime-scope-worker.js +165 -0
- package/dist/services/runtime-service.js +4 -0
- package/dist/services/vnext-code-review.js +1 -1
- package/dist/services/vnext-code.js +1 -1
- package/dist/services/vnext-execution-profile.js +2 -35
- package/dist/services/vnext-fanout.js +23 -7
- package/dist/services/vnext-plan-review.js +12 -9
- package/dist/services/vnext-plan.js +1 -1
- package/dist/services/vnext-protocolize.js +10 -4
- package/dist/services/vnext-specify.js +12 -5
- package/dist/services/work-registry.js +26 -8
- package/dist/services/workspace-bootstrap.js +10 -1
- package/dist/storage/database.js +160 -13
- package/dist/storage/writer-contract.js +76 -0
- package/dist/storage/writer-migration.js +102 -0
- package/package.json +1 -1
|
@@ -2,13 +2,19 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { DatabaseSync } from "node:sqlite";
|
|
6
|
+
import { registerStoreWriter } from "../storage/writer-contract.js";
|
|
5
7
|
import { createContext } from "../runtime/context.js";
|
|
6
8
|
import { AppError } from "../shared/errors.js";
|
|
7
|
-
import { resolveProjectRoot } from "../storage/paths.js";
|
|
9
|
+
import { resolveProjectRoot, runtimeProtocolDir, runtimeRunDir } from "../storage/paths.js";
|
|
8
10
|
import { registerProject, requireProjectByRoot } from "./projects.js";
|
|
9
|
-
import { getFlowRunStatus, startFlowRun } from "./runs.js";
|
|
11
|
+
import { freezeRunExecutionRouting, getFlowRunStatus, importFlowRunExecutionProfile, startFlowRun } from "./runs.js";
|
|
12
|
+
import { loadExecutionRoutingInput, resolveExecutionPolicy } from "./execution-policy.js";
|
|
10
13
|
import { readVnextFlowDefinition } from "./vnext-specify.js";
|
|
11
14
|
import { assertRunWorkResultArtifacts, runWorkResultArtifactIssues } from "./work-registry.js";
|
|
15
|
+
import { captureRecoveryDatabase, readRecoveryResourceEvidence } from "./recovery-snapshot-database.js";
|
|
16
|
+
import { resourceHome } from "./managed-processes.js";
|
|
17
|
+
import { codexHomeSharedEntries } from "./hooks.js";
|
|
12
18
|
/** Capture the only flow boundary which exists before `stage start --bootstrap` creates a RUN. */
|
|
13
19
|
export function createEvalBootstrapSnapshot(context, input) {
|
|
14
20
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
@@ -45,9 +51,8 @@ export function restoreEvalBootstrapSnapshot(_context, input) {
|
|
|
45
51
|
return { ok: true, schema_id: "dd-eval/bootstrap-restore@1", snapshot, project_root: projectRoot, target_stage: "specify" };
|
|
46
52
|
}
|
|
47
53
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* to infer a safe subset of SQLite rows.
|
|
54
|
+
* Eval fixtures require a dedicated home. Sealed recovery exports instead
|
|
55
|
+
* retain only the selected RUN's relational and filesystem evidence.
|
|
51
56
|
*/
|
|
52
57
|
export function createEvalRunSnapshot(context, input) {
|
|
53
58
|
const output = path.resolve(input.output);
|
|
@@ -55,7 +60,13 @@ export function createEvalRunSnapshot(context, input) {
|
|
|
55
60
|
throw new AppError("snapshot_exists", "Snapshot output already exists", 1, { output });
|
|
56
61
|
const temporary = `${output}.partial-${crypto.randomUUID()}`;
|
|
57
62
|
try {
|
|
63
|
+
input.boundary?.verify();
|
|
64
|
+
const sourceVersion = input.boundary ? snapshotSourceVersion(context, input) : null;
|
|
58
65
|
const result = captureEvalRunSnapshot(context, { ...input, output: temporary });
|
|
66
|
+
if (input.boundary && sourceVersion) {
|
|
67
|
+
Object.assign(result, { consistency: "controller_boundary_writer_barrier@1", boundary_capture: { controller_id: input.boundary.controllerId, operation_id: input.boundary.operationId, boundary_key: input.boundary.boundaryKey, source_sha256: sourceVersion, observation: input.boundary.observation } });
|
|
68
|
+
writeJson(path.join(temporary, "snapshot.json"), Object.fromEntries(Object.entries(result).filter(([key]) => key !== "ok" && key !== "snapshot")));
|
|
69
|
+
}
|
|
59
70
|
if (input.recoveryId) {
|
|
60
71
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
61
72
|
const project = requireProjectByRoot(context, projectRoot);
|
|
@@ -65,6 +76,11 @@ export function createEvalRunSnapshot(context, input) {
|
|
|
65
76
|
if (fs.existsSync(output))
|
|
66
77
|
throw new AppError("snapshot_exists", "Snapshot output already exists", 1, { output });
|
|
67
78
|
syncSnapshotTree(temporary);
|
|
79
|
+
if (input.boundary) {
|
|
80
|
+
if (sourceVersion !== snapshotSourceVersion(context, input))
|
|
81
|
+
throw new AppError("snapshot_source_changed", "Snapshot source files or Git state changed during boundary capture", 1);
|
|
82
|
+
input.boundary.verify();
|
|
83
|
+
}
|
|
68
84
|
fs.renameSync(temporary, output);
|
|
69
85
|
syncPath(path.dirname(output));
|
|
70
86
|
return { ...result, snapshot: output };
|
|
@@ -80,10 +96,16 @@ function captureEvalRunSnapshot(context, input) {
|
|
|
80
96
|
// root must fail without registering a second project in a dedicated home.
|
|
81
97
|
const project = requireProjectByRoot(context, projectRoot);
|
|
82
98
|
const status = getFlowRunStatus(context, { projectRoot, runId: input.runId });
|
|
83
|
-
|
|
99
|
+
const recovery = typeof input.recoveryId === "string";
|
|
100
|
+
if (!recovery && !input.incomplete && context.db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'run_controllers'")) {
|
|
101
|
+
const controller = context.db.get("SELECT controller_id,status,owner_pid,owner_token FROM run_controllers WHERE project_id = ? AND run_id = ? AND status NOT IN ('completed','cancelled','superseded','stop_target_reached')", [project.id, input.runId]);
|
|
102
|
+
if (controller && (controller.status !== "capturing" || controller.controller_id !== input.boundary?.controllerId || controller.owner_pid !== process.pid || controller.owner_token !== context.env.DD_FLOW_CONTROLLER_TOKEN))
|
|
103
|
+
throw new AppError("snapshot_controller_capture_required", "A live managed RUN must be captured by its fenced controller, not an external observer", 1, { controller_id: controller.controller_id });
|
|
104
|
+
}
|
|
105
|
+
if (!recovery)
|
|
106
|
+
assertDedicatedHome(context, project.id, input.runId);
|
|
84
107
|
if (!input.incomplete)
|
|
85
108
|
assertRunWorkResultArtifacts(context, project.id, input.runId);
|
|
86
|
-
const recovery = typeof input.recoveryId === "string";
|
|
87
109
|
if (recovery) {
|
|
88
110
|
const guard = context.db.get("SELECT status, recovery_id FROM run_recovery_guards WHERE project_id = ? AND run_id = ? AND recovery_id = ?", [project.id, input.runId, input.recoveryId]);
|
|
89
111
|
if (!guard || guard.status !== "sealed")
|
|
@@ -110,20 +132,48 @@ function captureEvalRunSnapshot(context, input) {
|
|
|
110
132
|
const workspaceRoot = String(status.run.workspace_root ?? projectRoot);
|
|
111
133
|
if (isInside(physicalPath(projectRoot), physicalPath(output)) || isInside(physicalPath(workspaceRoot), physicalPath(output)))
|
|
112
134
|
throw new AppError("snapshot_output_inside_project", "Snapshot output must be outside the project and RUN workspace trees", 1, { output });
|
|
113
|
-
fs.mkdirSync(output, { recursive: true });
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
135
|
+
fs.mkdirSync(output, { recursive: true, mode: 0o700 });
|
|
136
|
+
const resourceDatabase = path.join(context.ddFlowHome, "runtime.sqlite");
|
|
137
|
+
const databaseFiles = [context.db.path, resourceDatabase].flatMap(file => [file, `${file}-wal`, `${file}-shm`]);
|
|
138
|
+
const codexExclusions = codexHomeSnapshotExclusions(context, project.id, input.runId);
|
|
139
|
+
if (recovery) {
|
|
140
|
+
if (typeof status.run.run_root !== "string" || !isInside(physicalPath(context.ddFlowHome), physicalPath(status.run.run_root)) || samePath(physicalPath(context.ddFlowHome), physicalPath(status.run.run_root)))
|
|
141
|
+
throw new AppError("snapshot_run_scope_invalid", "Recovery requires a RUN artifact root within its runtime", 1);
|
|
142
|
+
for (const source of new Set([status.run.run_root, runtimeRunDir(context.ddFlowHome, project.id, input.runId)])) {
|
|
143
|
+
if (fs.existsSync(source))
|
|
144
|
+
copyRuntimeTree(source, path.join(output, "runtime", path.relative(context.ddFlowHome, source)), true, codexExclusions);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
else
|
|
148
|
+
copyRuntimeTree(context.ddFlowHome, path.join(output, "runtime"), false, [...databaseFiles, ...codexExclusions]);
|
|
122
149
|
// VACUUM INTO is a consistent SQLite read snapshot, including committed WAL
|
|
123
150
|
// pages. Copying the live database and its WAL separately is not atomic.
|
|
124
151
|
const databaseCopy = path.join(output, "runtime", path.relative(context.ddFlowHome, context.db.path));
|
|
125
152
|
fs.mkdirSync(path.dirname(databaseCopy), { recursive: true });
|
|
126
|
-
|
|
153
|
+
if (recovery) {
|
|
154
|
+
const report = typeof status.run.run_root === "string" ? path.join(output, "runtime", path.relative(context.ddFlowHome, status.run.run_root), "02-protocolize", "stage-report.json") : null;
|
|
155
|
+
if (report && !isInside(physicalPath(path.join(output, "runtime")), physicalPath(report)))
|
|
156
|
+
throw new AppError("snapshot_protocol_scope_invalid", "Recovery protocol evidence is outside the captured runtime", 1);
|
|
157
|
+
const protocols = report && fs.existsSync(report) ? JSON.parse(fs.readFileSync(report, "utf8")).semantic?.acceptance : [];
|
|
158
|
+
if (!Array.isArray(protocols) || !protocols.every(id => typeof id === "string" && id.startsWith("PRT-")))
|
|
159
|
+
throw new AppError("snapshot_protocol_scope_invalid", "Recovery export requires the accepted PROTOCOLIZE protocol IDs", 1);
|
|
160
|
+
captureRecoveryDatabase(context.db, databaseCopy, project.id, input.runId, protocols);
|
|
161
|
+
}
|
|
162
|
+
else
|
|
163
|
+
context.db.exec(`VACUUM INTO '${databaseCopy.replace(/'/g, "''")}'`);
|
|
164
|
+
// Resource heartbeats can continue after productive RUN writers are sealed.
|
|
165
|
+
// Keep their committed evidence consistent too; never copy a live WAL pair.
|
|
166
|
+
if (!recovery && fs.existsSync(resourceDatabase)) {
|
|
167
|
+
const resources = new DatabaseSync(resourceDatabase, { readOnly: true });
|
|
168
|
+
try {
|
|
169
|
+
const resourceCopy = path.join(output, "runtime", "runtime.sqlite");
|
|
170
|
+
resources.exec("PRAGMA busy_timeout = 4000");
|
|
171
|
+
resources.exec(`VACUUM INTO '${resourceCopy.replace(/'/g, "''")}'`);
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
resources.close();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
127
177
|
fs.cpSync(projectRoot, path.join(output, "project"), {
|
|
128
178
|
recursive: true,
|
|
129
179
|
verbatimSymlinks: true,
|
|
@@ -136,6 +186,8 @@ function captureEvalRunSnapshot(context, input) {
|
|
|
136
186
|
const copiedContext = createContext({ ...context.env, DD_FLOW_HOME: path.join(output, "runtime") }, "read_existing");
|
|
137
187
|
let receiptIssues;
|
|
138
188
|
try {
|
|
189
|
+
if (recovery)
|
|
190
|
+
captureRecoveryAuxiliary(context, copiedContext, { projectId: project.id, runId: input.runId, output });
|
|
139
191
|
receiptIssues = runWorkResultArtifactIssues(copiedContext, project.id, input.runId, file => {
|
|
140
192
|
const roots = [[context.ddFlowHome, "runtime"], [workspaceRoot, workspace.kind === "git_worktree" ? "workspace" : "project"], [projectRoot, "project"]];
|
|
141
193
|
for (const [source, payload] of roots) {
|
|
@@ -186,6 +238,8 @@ export function restoreEvalRunSnapshot(context, input) {
|
|
|
186
238
|
if (manifest.purpose === "incomplete")
|
|
187
239
|
throw new AppError("snapshot_incomplete_not_restorable", "Incomplete snapshot is forensic evidence, not a stage-entry fixture", 1, { snapshot });
|
|
188
240
|
const recovery = manifest.purpose === "recovery";
|
|
241
|
+
if (recovery && input.executionRoutingFile !== undefined)
|
|
242
|
+
throw new AppError("snapshot_recovery_policy_frozen", "Recovery must retain its frozen execution policy", 2);
|
|
189
243
|
if (recovery && input.recoveryId !== manifest.recovery_id)
|
|
190
244
|
throw new AppError("snapshot_recovery_requires_explicit_restore", "Recovery snapshots require their explicit --recovery-id", 1, { snapshot });
|
|
191
245
|
if (!recovery && input.recoveryId)
|
|
@@ -200,16 +254,38 @@ export function restoreEvalRunSnapshot(context, input) {
|
|
|
200
254
|
throw new AppError("recovery_restore_target_not_empty", "Recovery restore requires fresh project and runtime targets", 1);
|
|
201
255
|
verifyRecoverySnapshot(snapshot, { runId: manifest.run_id, recoveryId: input.recoveryId, projectRoot, workspaceRoot: projectRoot }, false);
|
|
202
256
|
}
|
|
203
|
-
const existing = fs.readdirSync(context.ddFlowHome).filter((name) => !["db.sqlite", "db.sqlite-wal", "db.sqlite-shm"].includes(name));
|
|
257
|
+
const existing = fs.readdirSync(context.ddFlowHome).filter((name) => !["db.sqlite", "db.sqlite-wal", "db.sqlite-shm", ...(input.executionRoutingFile !== undefined ? ["agent-profiles"] : [])].includes(name));
|
|
204
258
|
if (existing.length > 0)
|
|
205
259
|
throw new AppError("restore_home_not_empty", "Snapshot restore requires an empty DD_FLOW_HOME", 1, { dd_flow_home: context.ddFlowHome, existing });
|
|
260
|
+
if (context.db.get("SELECT 1 FROM projects LIMIT 1") || context.db.get("SELECT 1 FROM runs LIMIT 1"))
|
|
261
|
+
throw new AppError("restore_home_not_empty", "Snapshot restore cannot replace an initialized runtime", 1);
|
|
206
262
|
const sourceRuntime = path.join(snapshot, "runtime");
|
|
207
263
|
const sourceProject = path.join(snapshot, "project");
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
264
|
+
verifySnapshotPayload(snapshot, manifest);
|
|
265
|
+
const routingInput = input.executionRoutingFile !== undefined ? loadExecutionRoutingInput(input.executionRoutingFile) : undefined;
|
|
266
|
+
let selectedProfile;
|
|
267
|
+
if (routingInput) {
|
|
268
|
+
// Resolve selected profiles from the fresh caller home before replacing it.
|
|
269
|
+
// Source DB is read-only; no context or migrations may touch the snapshot.
|
|
270
|
+
const sourceDb = new DatabaseSync(path.join(sourceRuntime, "db.sqlite"), { readOnly: true });
|
|
271
|
+
try {
|
|
272
|
+
const row = sourceDb.prepare("SELECT index_json FROM runs WHERE id = ?").get(manifest.run_id);
|
|
273
|
+
const index = row ? JSON.parse(row.index_json) : null;
|
|
274
|
+
if (!index?.execution_profile || !["vnext_specify", "vnext_protocolize"].includes(index.flow_kind))
|
|
275
|
+
throw new AppError("execution_profile_not_frozen", "Stage-entry routing requires a vNext execution snapshot", 1);
|
|
276
|
+
assertStageEntry(index.stage_runs, manifest.stage_entry);
|
|
277
|
+
selectedProfile = freezeRunExecutionRouting(context.ddFlowHome, index.execution_profile, routingInput);
|
|
278
|
+
for (const row of sourceDb.prepare("SELECT payload_json FROM works WHERE run_id = ? AND status = 'created'").all(manifest.run_id)) {
|
|
279
|
+
const payload = row.payload_json ? JSON.parse(row.payload_json) : null;
|
|
280
|
+
if (payload?.execution_override !== undefined)
|
|
281
|
+
resolveExecutionPolicy({ ddFlowHome: context.ddFlowHome, policy: routingInput.execution, profiles: selectedProfile.agent_profiles, stage: manifest.stage_entry, work: payload.execution_override });
|
|
282
|
+
}
|
|
283
|
+
selectedProfile.routing_input.artifact = `execution-routing.${routingInput.source_checksum}.input.json`;
|
|
284
|
+
}
|
|
285
|
+
finally {
|
|
286
|
+
sourceDb.close();
|
|
287
|
+
}
|
|
288
|
+
}
|
|
213
289
|
clearProjectTree(projectRoot, false);
|
|
214
290
|
restoreProjectGit(snapshot, manifest.project_git, projectRoot);
|
|
215
291
|
replaceProjectTree(projectRoot, sourceProject);
|
|
@@ -221,9 +297,21 @@ export function restoreEvalRunSnapshot(context, input) {
|
|
|
221
297
|
// A fresh process owns all subsequent normal commands.
|
|
222
298
|
const restored = createContext({ ...context.env, DD_FLOW_HOME: context.ddFlowHome });
|
|
223
299
|
try {
|
|
300
|
+
const runtimeLineage = recovery ? null : detachSnapshotRuntimeOwners(restored, snapshot, manifest);
|
|
224
301
|
rebaseRuntime(restored, manifest.project_root, projectRoot, sourceWorkspaceRoot(manifest), workspaceRoot, manifest.dd_flow_home, context.ddFlowHome, recovery);
|
|
225
302
|
registerProject(restored, { root: projectRoot });
|
|
226
303
|
requireProjectByRoot(restored, projectRoot);
|
|
304
|
+
if (selectedProfile && routingInput && runtimeLineage) {
|
|
305
|
+
const run = restored.db.get("SELECT run_root FROM runs WHERE id = ?", [manifest.run_id]);
|
|
306
|
+
const artifact = path.join(run.run_root, selectedProfile.routing_input.artifact);
|
|
307
|
+
if (fs.existsSync(artifact)) {
|
|
308
|
+
if (!fs.readFileSync(artifact).equals(routingInput.bytes))
|
|
309
|
+
throw new AppError("snapshot_checksum_mismatch", "Existing routing input does not match its checksum", 1);
|
|
310
|
+
}
|
|
311
|
+
else
|
|
312
|
+
fs.writeFileSync(artifact, routingInput.bytes, { flag: "wx", mode: 0o600 });
|
|
313
|
+
importFlowRunExecutionProfile(restored, { projectRoot, runId: manifest.run_id, stage: manifest.stage_entry, lineageFile: runtimeLineage, profile: selectedProfile });
|
|
314
|
+
}
|
|
227
315
|
const status = getFlowRunStatus(restored, { projectRoot, runId: manifest.run_id });
|
|
228
316
|
if (!recovery)
|
|
229
317
|
assertStageEntry(status.index.stage_runs ?? [], manifest.stage_entry);
|
|
@@ -251,6 +339,7 @@ export function restoreEvalRunSnapshot(context, input) {
|
|
|
251
339
|
dd_flow_home: context.ddFlowHome,
|
|
252
340
|
run_home: runHome,
|
|
253
341
|
target_stage: manifest.stage_entry,
|
|
342
|
+
...(runtimeLineage ? { runtime_lineage_file: runtimeLineage } : {}),
|
|
254
343
|
...(recovery ? { recovery_id: input.recoveryId, native_session_portability: "requires_adapter_verification", relocation_file: relocationFile, relocation } : {}),
|
|
255
344
|
next_command: recovery ? `dd-flow run recovery inspect ${status.run.id} --project-root ${JSON.stringify(projectRoot)} --json` : `dd-flow stage start ${status.run.id} --stage ${manifest.stage_entry} --project-root ${JSON.stringify(projectRoot)} --json`
|
|
256
345
|
};
|
|
@@ -270,6 +359,7 @@ export function prepareVnextSpecifyRun(context, input) {
|
|
|
270
359
|
subjectType: "discussion",
|
|
271
360
|
subjectId: "SPECIFY",
|
|
272
361
|
slug: input.slug,
|
|
362
|
+
executionRoutingFile: input.executionRoutingFile,
|
|
273
363
|
nextAction: "start_specify"
|
|
274
364
|
});
|
|
275
365
|
return { ok: true, run_id: started.run.id, run_root: started.run.run_root, target_stage: "specify" };
|
|
@@ -284,15 +374,69 @@ function assertDedicatedHome(context, projectId, runId) {
|
|
|
284
374
|
function assertStageEntry(stages, target) {
|
|
285
375
|
if (stages.some((entry) => entry.stage === target))
|
|
286
376
|
throw new AppError("snapshot_target_started", "Stage-entry snapshot requires its target stage to be unstarted", 1, { target });
|
|
287
|
-
const unfinished = stages.filter((entry) => entry.status
|
|
377
|
+
const unfinished = stages.filter((entry) => !["done", "skipped"].includes(entry.status ?? ""));
|
|
288
378
|
if (unfinished.length > 0)
|
|
289
379
|
throw new AppError("snapshot_predecessor_incomplete", "Stage-entry snapshot requires completed predecessor stages", 1, { target, unfinished });
|
|
290
380
|
}
|
|
291
381
|
function assertTerminalCandidate(stages) {
|
|
292
|
-
const unfinished = stages.filter((entry) => entry.status
|
|
382
|
+
const unfinished = stages.filter((entry) => !["done", "skipped"].includes(entry.status ?? ""));
|
|
293
383
|
if (unfinished.length > 0)
|
|
294
384
|
throw new AppError("snapshot_candidate_incomplete", "Candidate snapshot requires every started stage to be done", 1, { unfinished });
|
|
295
385
|
}
|
|
386
|
+
/** A stage-entry import is a new experiment, not a lease takeover or recovery.
|
|
387
|
+
* Keep source authority as evidence, never as executable target inventory. */
|
|
388
|
+
function detachSnapshotRuntimeOwners(context, snapshot, manifest) {
|
|
389
|
+
const hasTable = (table) => Boolean(context.db.get("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?", [table]));
|
|
390
|
+
if (hasTable("run_recovery_guards") && context.db.get("SELECT 1 FROM run_recovery_guards WHERE status IN ('draining','sealed','resuming') LIMIT 1"))
|
|
391
|
+
throw new AppError("snapshot_control_requires_recovery", "A fenced RUN requires explicit recovery, not a stage-entry fork", 1);
|
|
392
|
+
if (context.db.get("SELECT 1 FROM merge_requests WHERE status IN ('dispatching','active','waiting_user','action_required','recovery_required') LIMIT 1"))
|
|
393
|
+
throw new AppError("snapshot_execution_active", "A stage-entry fork cannot discard an unresolved MERGE execution", 1);
|
|
394
|
+
const detachedTables = ["run_control_admissions", "run_control_stage_admissions", "run_control_operations", "run_control_workers", "run_controls", "run_controller_events", "run_controller_operations", "run_controllers", "run_recovery_bindings", "work_recovery_segments", "run_recovery_guards", "work_launches", "merge_servers", "merge_sessions", "lane_locks", "lane_waiters"].filter(hasTable);
|
|
395
|
+
const runTables = Object.fromEntries([...detachedTables, "runs", "merge_requests", "sessions", "work_sessions"].map(table => [table, context.db.all(`SELECT * FROM ${table}`)]));
|
|
396
|
+
const resourceFile = path.join(context.ddFlowHome, "runtime.sqlite");
|
|
397
|
+
const resources = fs.existsSync(resourceFile) ? new DatabaseSync(resourceFile) : null;
|
|
398
|
+
try {
|
|
399
|
+
if (resources)
|
|
400
|
+
registerStoreWriter(resources);
|
|
401
|
+
const resourceTables = Object.fromEntries(["managed_resources", "managed_processes"].filter(table => resources?.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?").get(table)).map(table => [table, resources.prepare(`SELECT * FROM ${table}`).all()]));
|
|
402
|
+
const directory = path.join(context.ddFlowHome, "snapshot-imports");
|
|
403
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
404
|
+
const file = path.join(directory, `${crypto.randomUUID()}.json`);
|
|
405
|
+
fs.writeFileSync(file, `${JSON.stringify({ schema_id: "dd-flow/snapshot-runtime-lineage@1", source_snapshot: snapshot, source_manifest_sha256: fileHash(path.join(snapshot, "snapshot.json")), source_runtime: manifest.dd_flow_home, restored_runtime: context.ddFlowHome, run_id: manifest.run_id, run_tables: runTables, resource_tables: resourceTables }, null, 2)}\n`, { flag: "wx", mode: 0o600 });
|
|
406
|
+
syncPath(file);
|
|
407
|
+
syncPath(directory);
|
|
408
|
+
context.db.exec("BEGIN IMMEDIATE");
|
|
409
|
+
try {
|
|
410
|
+
context.db.run("UPDATE sessions SET metadata_json = json_set(metadata_json, '$.snapshot_import', json(?))", [JSON.stringify({ lineage_file: file, lineage_sha256: fileHash(file) })]);
|
|
411
|
+
for (const table of detachedTables)
|
|
412
|
+
context.db.exec(`DELETE FROM ${table}`);
|
|
413
|
+
context.db.run("UPDATE merge_requests SET dispatch_owner = NULL, dispatch_lease_token = NULL, dispatch_lease_expires_at = NULL, lock_acquired_at = NULL, adapter_receipt_json = NULL");
|
|
414
|
+
// Only the copied dedicated registry is touched. Source/external homes
|
|
415
|
+
// are historical references in the archive and are never opened here.
|
|
416
|
+
if (resources) {
|
|
417
|
+
resources.exec("BEGIN IMMEDIATE");
|
|
418
|
+
try {
|
|
419
|
+
for (const table of Object.keys(resourceTables))
|
|
420
|
+
resources.exec(`DELETE FROM ${table}`);
|
|
421
|
+
resources.exec("COMMIT");
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
resources.exec("ROLLBACK");
|
|
425
|
+
throw error;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
context.db.exec("COMMIT");
|
|
429
|
+
}
|
|
430
|
+
catch (error) {
|
|
431
|
+
context.db.exec("ROLLBACK");
|
|
432
|
+
throw error;
|
|
433
|
+
}
|
|
434
|
+
return file;
|
|
435
|
+
}
|
|
436
|
+
finally {
|
|
437
|
+
resources?.close();
|
|
438
|
+
}
|
|
439
|
+
}
|
|
296
440
|
function rebaseRuntime(context, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome, recovery = false) {
|
|
297
441
|
context.db.exec("PRAGMA foreign_keys = OFF");
|
|
298
442
|
context.db.exec("BEGIN IMMEDIATE");
|
|
@@ -304,7 +448,12 @@ function rebaseRuntime(context, oldRoot, newRoot, oldWorkspace, newWorkspace, ol
|
|
|
304
448
|
context.db.run("UPDATE protocols SET project_root = ?", [newRoot]);
|
|
305
449
|
for (const column of ["state_path", "plan_path"])
|
|
306
450
|
rewriteDatabaseColumn(context, "protocols", column, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
|
|
307
|
-
|
|
451
|
+
for (const column of ["source_workspace", "target_workspace"])
|
|
452
|
+
rewriteDatabaseColumn(context, "merge_requests", column, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
|
|
453
|
+
if (recovery)
|
|
454
|
+
context.db.run("UPDATE sessions SET project_root = ?, status = CASE WHEN status = 'active' THEN 'stopped' ELSE status END, stop_reason = CASE WHEN status = 'active' THEN 'snapshot_restored' ELSE stop_reason END", [newRoot]);
|
|
455
|
+
else
|
|
456
|
+
context.db.run("UPDATE sessions SET project_root = ?, status = 'stopped', stop_reason = 'snapshot_restored', stopped_at = ?", [newRoot, context.now()]);
|
|
308
457
|
for (const column of ["workspace_path", "cwd", "transcript_path"])
|
|
309
458
|
rewriteDatabaseColumn(context, "sessions", column, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
|
|
310
459
|
for (const column of ["prompt_path", "result_path"])
|
|
@@ -337,10 +486,13 @@ function rewriteStructuredFiles(root, oldRoot, newRoot, oldWorkspace, newWorkspa
|
|
|
337
486
|
const visit = (dir) => {
|
|
338
487
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
339
488
|
const file = path.join(dir, entry.name);
|
|
489
|
+
// This is hashed input evidence, not a mutable runtime projection.
|
|
490
|
+
if (/^execution-routing(?:\.[a-f0-9]{64})?\.input\.json$/.test(entry.name))
|
|
491
|
+
continue;
|
|
340
492
|
// DD_FLOW_HOME also contains the restored checkout and installed tools.
|
|
341
493
|
// They are project/tool bytes, not flow records; parsing every *.json
|
|
342
494
|
// there breaks on valid JSON-with-comments such as tsconfig files.
|
|
343
|
-
if (entry.isDirectory() && ![".git", "checkouts", "engines", "node_modules", "tools"].includes(entry.name))
|
|
495
|
+
if (entry.isDirectory() && ![".git", "checkouts", "engines", "node_modules", "tools", "snapshot-imports"].includes(entry.name))
|
|
344
496
|
visit(file);
|
|
345
497
|
else if (entry.isFile() && entry.name.endsWith(".json")) {
|
|
346
498
|
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
@@ -517,21 +669,37 @@ export function verifyRecoverySnapshot(snapshot, input, checkWorkingState = true
|
|
|
517
669
|
{ name: "project", root: input.projectRoot, hash: manifest.project_sha256, git: manifest.project_git, head: manifest.project_git.head },
|
|
518
670
|
...(manifest.workspace.kind === "git_worktree" ? [{ name: "workspace", root: input.workspaceRoot, hash: manifest.workspace.sha256, git: manifest.workspace, head: manifest.workspace.base_ref }] : [])
|
|
519
671
|
];
|
|
520
|
-
|
|
521
|
-
throw new AppError("snapshot_checksum_mismatch", "Recovery runtime checksum does not match", 1);
|
|
522
|
-
verifySnapshotModes(snapshot, manifest);
|
|
672
|
+
verifySnapshotPayload(snapshot, manifest);
|
|
523
673
|
for (const entry of entries) {
|
|
524
674
|
const expectedModes = manifest.payload_modes?.[entry.name];
|
|
525
675
|
if (checkWorkingState && expectedModes && treeModesHash(entry.root, true) !== expectedModes)
|
|
526
676
|
throw new AppError("recovery_workspace_drift", "Executable file modes changed after recovery capture", 1, { payload: entry.name });
|
|
527
|
-
if (treeHash(path.join(snapshot, entry.name)) !== entry.hash || fileHash(path.join(snapshot, `${entry.name}.bundle`)) !== entry.git.bundle_sha256 || (entry.git.index_sha256 && fileHash(path.join(snapshot, `${entry.name}.index.patch`)) !== entry.git.index_sha256))
|
|
528
|
-
throw new AppError("snapshot_checksum_mismatch", "Recovery payload checksum does not match", 1, { payload: entry.name });
|
|
529
|
-
if (entry.git.index_format === "entries@1" && (!entry.git.index_objects_sha256 || fileHash(path.join(snapshot, `${entry.name}.index.pack`)) !== entry.git.index_objects_sha256))
|
|
530
|
-
throw new AppError("snapshot_checksum_mismatch", "Recovery Git object checksum does not match", 1, { payload: entry.name });
|
|
531
677
|
if (checkWorkingState && (treeHash(entry.root, true) !== entry.hash || git(entry.root, ["rev-parse", "HEAD"]) !== entry.head || git(entry.root, ["symbolic-ref", "--quiet", "--short", "HEAD"]) !== entry.git.branch || (entry.git.index_sha256 && crypto.createHash("sha256").update(entry.git.index_format === "entries@1" ? indexEntries(entry.root) : indexPatch(entry.root)).digest("hex") !== entry.git.index_sha256)))
|
|
532
678
|
throw new AppError("recovery_workspace_drift", "Working files or Git state changed after recovery capture", 1, { payload: entry.name });
|
|
533
679
|
}
|
|
534
680
|
}
|
|
681
|
+
/** Reuse an already published boundary only after checking its identity and
|
|
682
|
+
* every payload, including its Git index objects. Never overwrite evidence. */
|
|
683
|
+
export function verifyControllerBoundarySnapshot(snapshot, input) {
|
|
684
|
+
const manifest = readManifest(snapshot), boundary = manifest.boundary_capture;
|
|
685
|
+
if (!boundary || manifest.consistency !== "controller_boundary_writer_barrier@1" || manifest.run_id !== input.runId || boundary.controller_id !== input.controllerId || boundary.operation_id !== input.operationId || boundary.boundary_key !== input.boundaryKey || manifest.stage_entry !== input.stageEntry || manifest.purpose !== (input.stageEntry ? "stage_entry" : "candidate") || !/^[a-f0-9]{64}$/.test(boundary.source_sha256) || boundary.observation?.settled !== true || boundary.observation.operation_id !== input.operationId)
|
|
686
|
+
throw new AppError("controller_boundary_snapshot_invalid", "Published snapshot does not prove this controller boundary", 1, { snapshot });
|
|
687
|
+
verifySnapshotPayload(snapshot, manifest);
|
|
688
|
+
const file = path.join(snapshot, "snapshot.json");
|
|
689
|
+
if (input.manifestSha256 !== undefined && fileHash(file) !== input.manifestSha256)
|
|
690
|
+
throw new AppError("snapshot_checksum_mismatch", "Boundary manifest no longer matches its retained publication receipt", 1, { snapshot });
|
|
691
|
+
return { snapshot, manifest: file, manifest_sha256: fileHash(file), purpose: manifest.purpose, stage_entry: manifest.stage_entry };
|
|
692
|
+
}
|
|
693
|
+
function verifySnapshotPayload(snapshot, manifest) {
|
|
694
|
+
if (treeHash(path.join(snapshot, "runtime")) !== manifest.runtime_sha256)
|
|
695
|
+
throw new AppError("snapshot_checksum_mismatch", "Snapshot runtime checksum does not match", 1);
|
|
696
|
+
verifySnapshotModes(snapshot, manifest);
|
|
697
|
+
const payloads = [{ name: "project", hash: manifest.project_sha256, git: manifest.project_git }, ...(manifest.workspace.kind === "git_worktree" ? [{ name: "workspace", hash: manifest.workspace.sha256, git: manifest.workspace }] : [])];
|
|
698
|
+
for (const payload of payloads) {
|
|
699
|
+
if (treeHash(path.join(snapshot, payload.name)) !== payload.hash || fileHash(path.join(snapshot, `${payload.name}.bundle`)) !== payload.git.bundle_sha256 || payload.git.index_sha256 && fileHash(path.join(snapshot, `${payload.name}.index.patch`)) !== payload.git.index_sha256 || payload.git.index_format === "entries@1" && (!payload.git.index_objects_sha256 || fileHash(path.join(snapshot, `${payload.name}.index.pack`)) !== payload.git.index_objects_sha256))
|
|
700
|
+
throw new AppError("snapshot_checksum_mismatch", "Snapshot payload or Git index checksum does not match", 1, { payload: payload.name });
|
|
701
|
+
}
|
|
702
|
+
}
|
|
535
703
|
function git(root, args) {
|
|
536
704
|
const result = spawnSync("git", ["-C", root, ...args], { encoding: "utf8" });
|
|
537
705
|
return result.status === 0 ? result.stdout.trim() : null;
|
|
@@ -547,13 +715,188 @@ function snapshotPayloadAllowed(source) {
|
|
|
547
715
|
return ![".git", "node_modules", ".cache", ".npm", ".pnpm-store"].includes(name)
|
|
548
716
|
&& name !== ".env" && !name.startsWith(".env.");
|
|
549
717
|
}
|
|
718
|
+
function runtimePayloadAllowed(source) {
|
|
719
|
+
return snapshotPayloadAllowed(source) && !["auth.json", "auth.bk", "credentials.json", ".credentials.json", "credentials", ".credentials", "id_rsa", "id_ed25519", "engines", "checkouts"].includes(path.basename(source));
|
|
720
|
+
}
|
|
721
|
+
/** Productive writers are fenced separately. Recheck copied bytes and Git
|
|
722
|
+
* state so an uncoordinated file writer cannot publish a mixed boundary. */
|
|
723
|
+
function snapshotSourceVersion(context, input) {
|
|
724
|
+
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
725
|
+
const project = requireProjectByRoot(context, projectRoot);
|
|
726
|
+
const run = context.db.get("SELECT workspace_root FROM runs WHERE project_id = ? AND id = ?", [project.id, input.runId]);
|
|
727
|
+
if (!run)
|
|
728
|
+
throw new AppError("not_found", "Snapshot RUN is not registered", 1);
|
|
729
|
+
const resources = path.join(context.ddFlowHome, "runtime.sqlite");
|
|
730
|
+
// Resource heartbeats are not productive writers. Their semantic identities
|
|
731
|
+
// are rechecked by the owner; each resource DB is copied consistently.
|
|
732
|
+
const runtimeFilter = (file) => runtimePayloadAllowed(file) && !file.endsWith("-shm") && ![resources, `${resources}-wal`, `${resources}-journal`].includes(file);
|
|
733
|
+
const roots = [...new Set([projectRoot, run.workspace_root])];
|
|
734
|
+
return crypto.createHash("sha256").update(JSON.stringify([
|
|
735
|
+
treeHash(context.ddFlowHome, runtimeFilter), treeModesHash(context.ddFlowHome, runtimeFilter),
|
|
736
|
+
...roots.map(root => [treeHash(root, true), treeModesHash(root, true), git(root, ["symbolic-ref", "--quiet", "HEAD"]), gitBytes(root, ["show-ref", "--head"]).toString("base64"), indexEntries(root).toString("base64")])
|
|
737
|
+
])).digest("hex");
|
|
738
|
+
}
|
|
739
|
+
/** Managed Codex homes share host configuration, not owned snapshot payload. */
|
|
740
|
+
function codexHomeSnapshotExclusions(context, projectId, runId) {
|
|
741
|
+
const homes = new Set(context.db.all("SELECT target_home FROM codex_home_profiles WHERE project_id = ?", [projectId]).map(row => row.target_home));
|
|
742
|
+
if (context.db.get("SELECT 1 FROM sqlite_schema WHERE name = 'run_controllers'")) {
|
|
743
|
+
for (const { home } of context.db.all("SELECT json_extract(value, '$.codex_home') AS home FROM run_controllers, json_each(state_json, '$.sessions') WHERE project_id = ? AND run_id = ?", [projectId, runId]))
|
|
744
|
+
if (home)
|
|
745
|
+
homes.add(home);
|
|
746
|
+
}
|
|
747
|
+
const excluded = [];
|
|
748
|
+
for (const home of homes) {
|
|
749
|
+
if (!path.isAbsolute(home) || !fs.existsSync(home) || fs.lstatSync(home).isSymbolicLink())
|
|
750
|
+
continue;
|
|
751
|
+
const marker = path.join(home, ".dd-flow-home.json");
|
|
752
|
+
if (!fs.existsSync(marker) || fs.lstatSync(marker).isSymbolicLink())
|
|
753
|
+
continue;
|
|
754
|
+
const identity = JSON.parse(fs.readFileSync(marker, "utf8"));
|
|
755
|
+
if (identity.managed_by !== "dd-flow-cli" || identity.project_id !== projectId || identity.target_home !== home || !identity.source_home || !path.isAbsolute(identity.source_home))
|
|
756
|
+
continue;
|
|
757
|
+
for (const entry of codexHomeSharedEntries) {
|
|
758
|
+
const file = path.join(home, entry);
|
|
759
|
+
// Skip only the exact links installed by initCodexHome, never arbitrary
|
|
760
|
+
// external links or owned local files with the same convenient name.
|
|
761
|
+
if (fs.lstatSync(file, { throwIfNoEntry: false })?.isSymbolicLink() && fs.readlinkSync(file) === path.join(identity.source_home, entry))
|
|
762
|
+
excluded.push(file);
|
|
763
|
+
}
|
|
764
|
+
excluded.push(path.join(home, "tmp"), path.join(home, ".tmp"));
|
|
765
|
+
}
|
|
766
|
+
return excluded;
|
|
767
|
+
}
|
|
768
|
+
/** Copy a dedicated home or scoped recovery tree, preserving SQLite WAL commits. */
|
|
769
|
+
function copyRuntimeTree(source, destination, scoped = true, excluded = []) {
|
|
770
|
+
if (fs.lstatSync(source).isSymbolicLink())
|
|
771
|
+
throw new AppError("snapshot_runtime_symlink_outside_scope", "Recovery artifact roots must not be symbolic links", 1, { path: source });
|
|
772
|
+
const root = physicalPath(source);
|
|
773
|
+
if (isInside(root, physicalPath(destination)))
|
|
774
|
+
throw new AppError("snapshot_output_inside_runtime", "Recovery output must be outside its source artifact tree", 1);
|
|
775
|
+
const databases = [];
|
|
776
|
+
const sqlite = (file) => {
|
|
777
|
+
if (!fs.existsSync(file) || !fs.statSync(file).isFile())
|
|
778
|
+
return false;
|
|
779
|
+
const descriptor = fs.openSync(file, "r");
|
|
780
|
+
try {
|
|
781
|
+
const header = Buffer.alloc(16);
|
|
782
|
+
return fs.readSync(descriptor, header, 0, 16, 0) === 16 && header.toString() === "SQLite format 3\0";
|
|
783
|
+
}
|
|
784
|
+
finally {
|
|
785
|
+
fs.closeSync(descriptor);
|
|
786
|
+
}
|
|
787
|
+
};
|
|
788
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
789
|
+
fs.cpSync(source, destination, {
|
|
790
|
+
recursive: true, verbatimSymlinks: true,
|
|
791
|
+
filter: file => {
|
|
792
|
+
if (!runtimePayloadAllowed(file) || excluded.includes(file))
|
|
793
|
+
return false;
|
|
794
|
+
const stat = fs.lstatSync(file);
|
|
795
|
+
if (stat.isSymbolicLink()) {
|
|
796
|
+
if (scoped && !isInside(root, physicalPath(file)))
|
|
797
|
+
throw new AppError("snapshot_runtime_symlink_outside_scope", "Recovery artifact link leaves its owned tree", 1, { path: file });
|
|
798
|
+
return true;
|
|
799
|
+
}
|
|
800
|
+
if (!stat.isDirectory() && !stat.isFile())
|
|
801
|
+
return false;
|
|
802
|
+
if (stat.isFile()) {
|
|
803
|
+
if (/-(?:wal|shm|journal)$/.test(file) && sqlite(file.replace(/-(?:wal|shm|journal)$/, "")))
|
|
804
|
+
return false;
|
|
805
|
+
if (sqlite(file)) {
|
|
806
|
+
databases.push(file);
|
|
807
|
+
return false;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
return true;
|
|
811
|
+
}
|
|
812
|
+
});
|
|
813
|
+
for (const file of databases) {
|
|
814
|
+
const db = new DatabaseSync(file, { readOnly: true });
|
|
815
|
+
try {
|
|
816
|
+
const target = source === file ? destination : path.join(destination, path.relative(source, file));
|
|
817
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
818
|
+
db.exec("PRAGMA busy_timeout = 4000");
|
|
819
|
+
db.exec(`VACUUM INTO '${target.replace(/'/g, "''")}'`);
|
|
820
|
+
}
|
|
821
|
+
finally {
|
|
822
|
+
db.close();
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
function captureRecoveryAuxiliary(context, captured, input) {
|
|
827
|
+
const runtime = path.join(input.output, "runtime");
|
|
828
|
+
const codexExclusions = codexHomeSnapshotExclusions(context, input.projectId, input.runId);
|
|
829
|
+
const run = captured.db.get("SELECT run_root, project_root, workspace_root FROM runs WHERE project_id = ? AND id = ?", [input.projectId, input.runId]);
|
|
830
|
+
const roots = [run.run_root, runtimeRunDir(context.ddFlowHome, input.projectId, input.runId)];
|
|
831
|
+
for (const protocol of captured.db.all("SELECT id, state_path, plan_path FROM protocols")) {
|
|
832
|
+
const directory = runtimeProtocolDir(context.ddFlowHome, input.projectId, protocol.id);
|
|
833
|
+
if (!isInside(physicalPath(context.ddFlowHome), physicalPath(directory)))
|
|
834
|
+
throw new AppError("snapshot_protocol_scope_invalid", "Protocol runtime leaves DD_FLOW_HOME", 1);
|
|
835
|
+
if (fs.existsSync(directory)) {
|
|
836
|
+
copyRuntimeTree(directory, path.join(runtime, path.relative(context.ddFlowHome, directory)), true, codexExclusions);
|
|
837
|
+
roots.push(directory);
|
|
838
|
+
}
|
|
839
|
+
for (const file of [protocol.state_path, protocol.plan_path]) {
|
|
840
|
+
if (!fs.existsSync(file) || roots.some(root => isInside(physicalPath(root), physicalPath(file))))
|
|
841
|
+
continue;
|
|
842
|
+
if (isInside(physicalPath(context.ddFlowHome), physicalPath(file)) && fs.statSync(file).isFile())
|
|
843
|
+
copyRuntimeTree(file, path.join(runtime, path.relative(context.ddFlowHome, file)));
|
|
844
|
+
else if (![run.project_root, run.workspace_root].some(root => isInside(physicalPath(root), physicalPath(file))))
|
|
845
|
+
throw new AppError("snapshot_protocol_scope_invalid", "Protocol evidence is outside captured payloads", 1, { path: file });
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
const homes = new Set([context.ddFlowHome, resourceHome(context)]);
|
|
849
|
+
const queries = {
|
|
850
|
+
run_controllers: "SELECT json_extract(manifest_json, '$.resource_home') AS home FROM run_controllers UNION SELECT json_extract(value, '$.owner.resource_home') FROM run_controllers, json_each(state_json, '$.sessions')",
|
|
851
|
+
run_control_workers: "SELECT resource_home AS home FROM run_control_workers",
|
|
852
|
+
work_launches: "SELECT json_extract(profile_json, '$.runtime_adapter.owner.resource_home') AS home FROM work_launches",
|
|
853
|
+
merge_requests: "SELECT json_extract(adapter_receipt_json, '$.owner.resource_home') AS home FROM merge_requests",
|
|
854
|
+
run_recovery_guards: "SELECT value AS home FROM run_recovery_guards, json_each(settlement_json, '$.resource_homes')"
|
|
855
|
+
};
|
|
856
|
+
const references = Object.entries(queries).flatMap(([table, query]) => captured.db.get("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?", [table]) ? captured.db.all(query) : []);
|
|
857
|
+
for (const { home } of references) {
|
|
858
|
+
if (home === null)
|
|
859
|
+
continue;
|
|
860
|
+
if (typeof home !== "string" || !path.isAbsolute(home))
|
|
861
|
+
throw new AppError("snapshot_resource_scope_invalid", "Retained resource home must be an absolute path", 1);
|
|
862
|
+
homes.add(home);
|
|
863
|
+
}
|
|
864
|
+
const nativeCaptures = new Map();
|
|
865
|
+
const registries = [];
|
|
866
|
+
for (const home of [...homes].sort()) {
|
|
867
|
+
if (!path.isAbsolute(home))
|
|
868
|
+
throw new AppError("snapshot_resource_scope_invalid", "Resource home must be an absolute path", 1);
|
|
869
|
+
const evidence = readRecoveryResourceEvidence(home, input.projectId, input.runId, { db: captured.db, ddFlowHome: context.ddFlowHome });
|
|
870
|
+
for (const process of evidence.processes) {
|
|
871
|
+
const directory = JSON.parse(process.metadata_json).state_dir;
|
|
872
|
+
if (directory === undefined)
|
|
873
|
+
continue;
|
|
874
|
+
if (typeof directory !== "string" || !path.isAbsolute(directory))
|
|
875
|
+
throw new AppError("snapshot_native_scope_invalid", "Native artifact root must be absolute", 1);
|
|
876
|
+
if (roots.some(root => isInside(physicalPath(root), physicalPath(directory))) || nativeCaptures.has(directory))
|
|
877
|
+
continue;
|
|
878
|
+
const state = JSON.parse(fs.readFileSync(path.join(directory, "daemon.json"), "utf8"));
|
|
879
|
+
const owner = state.config?.runtime_owner;
|
|
880
|
+
if (!owner || owner.project_id !== input.projectId || owner.run_id !== input.runId || owner.owner_id !== process.owner_id || owner.dd_flow_home !== context.ddFlowHome || owner.resource_home !== home || owner.state_dir !== directory)
|
|
881
|
+
throw new AppError("snapshot_native_scope_invalid", "External native artifact root has no matching RUN owner", 1, { state_dir: directory });
|
|
882
|
+
const relative = path.join("native-evidence", crypto.createHash("sha256").update(directory).digest("hex"));
|
|
883
|
+
copyRuntimeTree(directory, path.join(runtime, relative), true, codexExclusions);
|
|
884
|
+
nativeCaptures.set(directory, relative);
|
|
885
|
+
}
|
|
886
|
+
const relative = path.join("resource-evidence", `${crypto.createHash("sha256").update(home).digest("hex")}.json`);
|
|
887
|
+
fs.mkdirSync(path.dirname(path.join(runtime, relative)), { recursive: true });
|
|
888
|
+
writeJson(path.join(runtime, relative), evidence);
|
|
889
|
+
registries.push(relative);
|
|
890
|
+
}
|
|
891
|
+
writeJson(path.join(runtime, "recovery-evidence.json"), { schema_id: "dd-flow/recovery-runtime-evidence@1", project_id: input.projectId, run_id: input.runId, registries, native_captures: [...nativeCaptures].map(([source_root, payload]) => ({ source_root, payload })) });
|
|
892
|
+
}
|
|
550
893
|
function treeHash(root, filtered = false) {
|
|
551
894
|
const hash = crypto.createHash("sha256");
|
|
552
895
|
const visit = (dir, relative) => {
|
|
553
896
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
554
897
|
const file = path.join(dir, entry.name);
|
|
555
898
|
const rel = path.join(relative, entry.name);
|
|
556
|
-
if (filtered && !snapshotPayloadAllowed(file))
|
|
899
|
+
if (typeof filtered === "function" ? !filtered(file) : filtered && !snapshotPayloadAllowed(file))
|
|
557
900
|
continue;
|
|
558
901
|
if (entry.isDirectory())
|
|
559
902
|
visit(file, rel);
|
|
@@ -575,7 +918,7 @@ function treeModesHash(root, filtered = false) {
|
|
|
575
918
|
const visit = (directory) => {
|
|
576
919
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
577
920
|
const file = path.join(directory, entry.name);
|
|
578
|
-
if (filtered && !snapshotPayloadAllowed(file))
|
|
921
|
+
if (typeof filtered === "function" ? !filtered(file) : filtered && !snapshotPayloadAllowed(file))
|
|
579
922
|
continue;
|
|
580
923
|
if (entry.isDirectory())
|
|
581
924
|
visit(file);
|