@deksden-com/dd-flow-cli 0.9.0-beta.18 → 0.9.0-beta.21
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 +18 -0
- package/dist/build-info.json +5 -5
- package/dist/cli/help.js +5 -3
- package/dist/cli/run-cli.js +69 -12
- package/dist/services/dashboard.js +9 -0
- package/dist/services/engines.js +4 -6
- package/dist/services/eval-snapshots.js +230 -16
- package/dist/services/hooks.js +107 -20
- package/dist/services/lifecycle-command.js +11 -1
- package/dist/services/merge-server.js +27 -7
- package/dist/services/run-recovery.js +254 -0
- package/dist/services/runs.js +15 -0
- package/dist/services/stage-lifecycle.js +3 -0
- package/dist/services/vnext-fanout.js +40 -0
- package/dist/services/vnext-merge.js +34 -9
- package/dist/services/work-registry.js +105 -12
- package/dist/storage/database.js +36 -1
- package/package.json +13 -12
|
@@ -49,13 +49,44 @@ export function restoreEvalBootstrapSnapshot(_context, input) {
|
|
|
49
49
|
* to infer a safe subset of SQLite rows.
|
|
50
50
|
*/
|
|
51
51
|
export function createEvalRunSnapshot(context, input) {
|
|
52
|
+
const output = path.resolve(input.output);
|
|
53
|
+
if (fs.existsSync(output))
|
|
54
|
+
throw new AppError("snapshot_exists", "Snapshot output already exists", 1, { output });
|
|
55
|
+
const temporary = `${output}.partial-${crypto.randomUUID()}`;
|
|
56
|
+
try {
|
|
57
|
+
const result = captureEvalRunSnapshot(context, { ...input, output: temporary });
|
|
58
|
+
if (input.recoveryId) {
|
|
59
|
+
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
60
|
+
const project = requireProjectByRoot(context, projectRoot);
|
|
61
|
+
const run = context.db.get("SELECT workspace_root FROM runs WHERE project_id = ? AND id = ?", [project.id, input.runId]);
|
|
62
|
+
verifyRecoverySnapshot(temporary, { runId: input.runId, recoveryId: input.recoveryId, projectRoot, workspaceRoot: run?.workspace_root ?? projectRoot });
|
|
63
|
+
}
|
|
64
|
+
if (fs.existsSync(output))
|
|
65
|
+
throw new AppError("snapshot_exists", "Snapshot output already exists", 1, { output });
|
|
66
|
+
syncSnapshotTree(temporary);
|
|
67
|
+
fs.renameSync(temporary, output);
|
|
68
|
+
syncPath(path.dirname(output));
|
|
69
|
+
return { ...result, snapshot: output };
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
fs.rmSync(temporary, { recursive: true, force: true });
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function captureEvalRunSnapshot(context, input) {
|
|
52
77
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
53
78
|
// Snapshot is read-only at the control-plane boundary. An invalid workspace
|
|
54
79
|
// root must fail without registering a second project in a dedicated home.
|
|
55
80
|
const project = requireProjectByRoot(context, projectRoot);
|
|
56
81
|
const status = getFlowRunStatus(context, { projectRoot, runId: input.runId });
|
|
57
82
|
assertDedicatedHome(context, project.id, input.runId);
|
|
58
|
-
|
|
83
|
+
const recovery = typeof input.recoveryId === "string";
|
|
84
|
+
if (recovery) {
|
|
85
|
+
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]);
|
|
86
|
+
if (!guard || guard.status !== "sealed")
|
|
87
|
+
throw new AppError("recovery_invalid_state", "Recovery snapshots require the matching sealed recovery guard", 1, { run_id: input.runId, recovery_id: input.recoveryId ?? null, status: guard?.status ?? null });
|
|
88
|
+
}
|
|
89
|
+
if (input.incomplete || recovery) { /* Recovery captures only after the runner seals all writers. */ }
|
|
59
90
|
else if (input.candidate)
|
|
60
91
|
assertTerminalCandidate(status.index.stage_runs ?? []);
|
|
61
92
|
else if (input.stageEntry)
|
|
@@ -66,7 +97,7 @@ export function createEvalRunSnapshot(context, input) {
|
|
|
66
97
|
const activeChildren = input.candidate
|
|
67
98
|
? context.db.get("SELECT COUNT(*) AS count FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id IS NOT NULL AND status IN ('created', 'running', 'paused')", [project.id, input.runId])?.count ?? 0
|
|
68
99
|
: context.db.get("SELECT COUNT(*) AS count FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id IS NOT NULL AND (status IN ('running', 'paused') OR (status = 'created' AND (? IS NULL OR COALESCE(result_schema, '') NOT LIKE ?)))", [project.id, input.runId, allowedCreatedResultSchema, allowedCreatedResultSchema ? `${allowedCreatedResultSchema}@%` : ""])?.count ?? 0;
|
|
69
|
-
if (!input.incomplete && activeChildren > 0)
|
|
100
|
+
if (!input.incomplete && !recovery && activeChildren > 0)
|
|
70
101
|
throw new AppError("snapshot_not_quiescent", `${input.candidate ? "Candidate" : "Stage-entry"} snapshot requires no active child Work`, 1, { run_id: input.runId, active_children: activeChildren });
|
|
71
102
|
const output = path.resolve(input.output);
|
|
72
103
|
if (fs.existsSync(output))
|
|
@@ -103,14 +134,15 @@ export function createEvalRunSnapshot(context, input) {
|
|
|
103
134
|
project_id: project.id,
|
|
104
135
|
project_root: projectRoot,
|
|
105
136
|
dd_flow_home: context.ddFlowHome,
|
|
106
|
-
purpose: input.incomplete ? "incomplete" : input.candidate ? "candidate" : "stage_entry",
|
|
107
|
-
stage_entry: input.incomplete || input.candidate ? null : input.stageEntry,
|
|
108
|
-
...(input.incomplete ? { consistency: "sqlite_read_snapshot_files_copied_nonatomically", active_child_work_count: activeChildren, restorable: false } : {}),
|
|
137
|
+
purpose: input.incomplete ? "incomplete" : recovery ? "recovery" : input.candidate ? "candidate" : "stage_entry",
|
|
138
|
+
stage_entry: input.incomplete || recovery || input.candidate ? null : input.stageEntry,
|
|
139
|
+
...(input.incomplete ? { consistency: "sqlite_read_snapshot_files_copied_nonatomically", active_child_work_count: activeChildren, restorable: false } : recovery ? { recovery_id: input.recoveryId, consistency: "sealed_writer_barrier_required", active_child_work_count: activeChildren, restorable: "same-runtime-or-explicit-restore" } : {}),
|
|
109
140
|
created_at: context.now(),
|
|
110
141
|
runtime_sha256: treeHash(path.join(output, "runtime")),
|
|
111
142
|
project_sha256: treeHash(path.join(output, "project")),
|
|
112
143
|
project_git: projectGit,
|
|
113
144
|
workspace,
|
|
145
|
+
payload_modes: { project: treeModesHash(path.join(output, "project")), runtime: treeModesHash(path.join(output, "runtime")), ...(workspace.kind === "git_worktree" ? { workspace: treeModesHash(path.join(output, "workspace")) } : {}) },
|
|
114
146
|
source_status: status
|
|
115
147
|
};
|
|
116
148
|
writeJson(path.join(output, "snapshot.json"), manifest);
|
|
@@ -129,11 +161,21 @@ export function restoreEvalRunSnapshot(context, input) {
|
|
|
129
161
|
const manifest = readManifest(snapshot);
|
|
130
162
|
if (manifest.purpose === "incomplete")
|
|
131
163
|
throw new AppError("snapshot_incomplete_not_restorable", "Incomplete snapshot is forensic evidence, not a stage-entry fixture", 1, { snapshot });
|
|
164
|
+
const recovery = manifest.purpose === "recovery";
|
|
165
|
+
if (recovery && input.recoveryId !== manifest.recovery_id)
|
|
166
|
+
throw new AppError("snapshot_recovery_requires_explicit_restore", "Recovery snapshots require their explicit --recovery-id", 1, { snapshot });
|
|
167
|
+
if (!recovery && input.recoveryId)
|
|
168
|
+
throw new AppError("snapshot_invalid", "Recovery ID cannot restore a non-recovery snapshot", 1, { snapshot });
|
|
132
169
|
if (manifest.purpose === "candidate")
|
|
133
170
|
throw new AppError("snapshot_candidate_not_restorable", "A terminal candidate snapshot is evidence, not a stage-entry fixture", 1, { snapshot });
|
|
134
|
-
if (!manifest.stage_entry)
|
|
171
|
+
if (!recovery && !manifest.stage_entry)
|
|
135
172
|
throw new AppError("snapshot_invalid", "Stage-entry snapshot has no target stage", 1, { snapshot });
|
|
136
173
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
174
|
+
if (recovery) {
|
|
175
|
+
if (fs.readdirSync(projectRoot).length || (context.db.get("SELECT COUNT(*) AS count FROM projects")?.count ?? 0) > 0)
|
|
176
|
+
throw new AppError("recovery_restore_target_not_empty", "Recovery restore requires fresh project and runtime targets", 1);
|
|
177
|
+
verifyRecoverySnapshot(snapshot, { runId: manifest.run_id, recoveryId: input.recoveryId, projectRoot, workspaceRoot: projectRoot }, false);
|
|
178
|
+
}
|
|
137
179
|
const existing = fs.readdirSync(context.ddFlowHome).filter((name) => !["db.sqlite", "db.sqlite-wal", "db.sqlite-shm"].includes(name));
|
|
138
180
|
if (existing.length > 0)
|
|
139
181
|
throw new AppError("restore_home_not_empty", "Snapshot restore requires an empty DD_FLOW_HOME", 1, { dd_flow_home: context.ddFlowHome, existing });
|
|
@@ -143,6 +185,7 @@ export function restoreEvalRunSnapshot(context, input) {
|
|
|
143
185
|
throw new AppError("snapshot_checksum_mismatch", "Snapshot runtime checksum does not match manifest", 1, { snapshot });
|
|
144
186
|
if (treeHash(sourceProject) !== manifest.project_sha256)
|
|
145
187
|
throw new AppError("snapshot_checksum_mismatch", "Snapshot project checksum does not match manifest", 1, { snapshot });
|
|
188
|
+
verifySnapshotModes(snapshot, manifest);
|
|
146
189
|
clearProjectTree(projectRoot, false);
|
|
147
190
|
restoreProjectGit(snapshot, manifest.project_git, projectRoot);
|
|
148
191
|
replaceProjectTree(projectRoot, sourceProject);
|
|
@@ -154,14 +197,26 @@ export function restoreEvalRunSnapshot(context, input) {
|
|
|
154
197
|
// A fresh process owns all subsequent normal commands.
|
|
155
198
|
const restored = createContext({ ...context.env, DD_FLOW_HOME: context.ddFlowHome });
|
|
156
199
|
try {
|
|
157
|
-
rebaseRuntime(restored, manifest.project_root, projectRoot, sourceWorkspaceRoot(manifest), workspaceRoot, manifest.dd_flow_home, context.ddFlowHome);
|
|
200
|
+
rebaseRuntime(restored, manifest.project_root, projectRoot, sourceWorkspaceRoot(manifest), workspaceRoot, manifest.dd_flow_home, context.ddFlowHome, recovery);
|
|
158
201
|
registerProject(restored, { root: projectRoot });
|
|
159
202
|
requireProjectByRoot(restored, projectRoot);
|
|
160
203
|
const status = getFlowRunStatus(restored, { projectRoot, runId: manifest.run_id });
|
|
161
|
-
|
|
204
|
+
if (!recovery)
|
|
205
|
+
assertStageEntry(status.index.stage_runs ?? [], manifest.stage_entry);
|
|
206
|
+
else
|
|
207
|
+
restored.db.run("UPDATE run_recovery_guards SET capture_path = ? WHERE recovery_id = ? AND status = 'sealed'", [snapshot, input.recoveryId]);
|
|
162
208
|
if (!status.run.run_root)
|
|
163
209
|
throw new AppError("runtime_missing", "Restored RUN has no artifact root", 1, { run_id: manifest.run_id });
|
|
164
210
|
const runHome = status.run.run_root;
|
|
211
|
+
const relocation = recovery ? {
|
|
212
|
+
schema_id: "dd-flow/recovery-relocation@1", physical_runtime_id: crypto.randomUUID(), recovery_id: input.recoveryId, run_id: manifest.run_id,
|
|
213
|
+
path_mapping: [{ from: manifest.dd_flow_home, to: context.ddFlowHome }, { from: sourceWorkspaceRoot(manifest), to: workspaceRoot }, { from: manifest.project_root, to: projectRoot }]
|
|
214
|
+
} : null;
|
|
215
|
+
const relocationFile = relocation ? path.join(context.ddFlowHome, "recovery-imports", `${relocation.physical_runtime_id}.json`) : null;
|
|
216
|
+
if (relocationFile) {
|
|
217
|
+
fs.mkdirSync(path.dirname(relocationFile), { recursive: true });
|
|
218
|
+
writeJson(relocationFile, relocation);
|
|
219
|
+
}
|
|
165
220
|
return {
|
|
166
221
|
ok: true,
|
|
167
222
|
schema_id: "dd-flow/eval-run-restore@1",
|
|
@@ -172,7 +227,8 @@ export function restoreEvalRunSnapshot(context, input) {
|
|
|
172
227
|
dd_flow_home: context.ddFlowHome,
|
|
173
228
|
run_home: runHome,
|
|
174
229
|
target_stage: manifest.stage_entry,
|
|
175
|
-
|
|
230
|
+
...(recovery ? { recovery_id: input.recoveryId, native_session_portability: "requires_adapter_verification", relocation_file: relocationFile, relocation } : {}),
|
|
231
|
+
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`
|
|
176
232
|
};
|
|
177
233
|
}
|
|
178
234
|
finally {
|
|
@@ -213,7 +269,7 @@ function assertTerminalCandidate(stages) {
|
|
|
213
269
|
if (unfinished.length > 0)
|
|
214
270
|
throw new AppError("snapshot_candidate_incomplete", "Candidate snapshot requires every started stage to be done", 1, { unfinished });
|
|
215
271
|
}
|
|
216
|
-
function rebaseRuntime(context, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome) {
|
|
272
|
+
function rebaseRuntime(context, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome, recovery = false) {
|
|
217
273
|
context.db.exec("PRAGMA foreign_keys = OFF");
|
|
218
274
|
context.db.exec("BEGIN IMMEDIATE");
|
|
219
275
|
try {
|
|
@@ -234,7 +290,10 @@ function rebaseRuntime(context, oldRoot, newRoot, oldWorkspace, newWorkspace, ol
|
|
|
234
290
|
rewriteDatabaseColumn(context, "usage", "source_locator", oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
|
|
235
291
|
for (const column of ["worktree_path", "worktrunk_metadata_json", "last_command_result_json"])
|
|
236
292
|
rewriteDatabaseColumn(context, "worktree_records", column, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
|
|
237
|
-
|
|
293
|
+
// Recovery imports remain sealed. Their old Work sessions are historical
|
|
294
|
+
// bindings, not completed work; explicit recovery closes them interrupted.
|
|
295
|
+
if (!recovery)
|
|
296
|
+
context.db.run("UPDATE work_sessions SET status = CASE WHEN status = 'running' THEN 'completed' ELSE status END, completed_at = COALESCE(completed_at, ?), updated_at = ?", [context.now(), context.now()]);
|
|
238
297
|
context.db.run("DELETE FROM codex_session_bindings");
|
|
239
298
|
context.db.run("COMMIT");
|
|
240
299
|
}
|
|
@@ -245,7 +304,10 @@ function rebaseRuntime(context, oldRoot, newRoot, oldWorkspace, newWorkspace, ol
|
|
|
245
304
|
finally {
|
|
246
305
|
context.db.exec("PRAGMA foreign_keys = ON");
|
|
247
306
|
}
|
|
248
|
-
|
|
307
|
+
// SQLite owns operational paths. Imported packets, results and journals are
|
|
308
|
+
// historical bytes; the relocation receipt records their path mapping.
|
|
309
|
+
if (!recovery)
|
|
310
|
+
rewriteStructuredFiles(context.ddFlowHome, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
|
|
249
311
|
}
|
|
250
312
|
function rewriteStructuredFiles(root, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome) {
|
|
251
313
|
const visit = (dir) => {
|
|
@@ -295,7 +357,7 @@ function snapshotWorkspace(workspaceRoot, projectRoot, output) {
|
|
|
295
357
|
const bundle = path.join(output, "workspace.bundle");
|
|
296
358
|
fs.cpSync(workspaceRoot, workspace, { recursive: true, verbatimSymlinks: true, filter: snapshotPayloadAllowed });
|
|
297
359
|
runGit(projectRoot, ["bundle", "create", bundle, branch]);
|
|
298
|
-
return { kind: "git_worktree", source_root: workspaceRoot, branch, base_ref: baseRef, sha256: treeHash(workspace), bundle_sha256: fileHash(bundle) };
|
|
360
|
+
return { kind: "git_worktree", source_root: workspaceRoot, branch, base_ref: baseRef, sha256: treeHash(workspace), bundle_sha256: fileHash(bundle), ...snapshotIndex(workspaceRoot, output, "workspace") };
|
|
299
361
|
}
|
|
300
362
|
function restoreWorkspace(snapshot, manifest, projectRoot, ddFlowHome) {
|
|
301
363
|
if (manifest.workspace.kind === "project")
|
|
@@ -318,6 +380,7 @@ function restoreWorkspace(snapshot, manifest, projectRoot, ddFlowHome) {
|
|
|
318
380
|
throw new AppError("snapshot_workspace_restore_failed", "Snapshot Git bundle did not restore the frozen feature base", 1, { branch: manifest.workspace.branch, expected_base: manifest.workspace.base_ref });
|
|
319
381
|
runGit(projectRoot, ["worktree", "add", workspaceRoot, manifest.workspace.branch]);
|
|
320
382
|
replaceProjectTree(workspaceRoot, source);
|
|
383
|
+
restoreIndex(snapshot, "workspace", manifest.workspace, workspaceRoot);
|
|
321
384
|
return fs.realpathSync(workspaceRoot);
|
|
322
385
|
}
|
|
323
386
|
function sourceWorkspaceRoot(manifest) {
|
|
@@ -330,7 +393,7 @@ function snapshotProjectGit(projectRoot, output) {
|
|
|
330
393
|
throw new AppError("snapshot_project_git_invalid", "Snapshot requires a project on a named Git branch with a readable HEAD", 1, { project_root: projectRoot });
|
|
331
394
|
const bundle = path.join(output, "project.bundle");
|
|
332
395
|
runGit(projectRoot, ["bundle", "create", bundle, "--all"]);
|
|
333
|
-
return { branch, head, bundle_sha256: fileHash(bundle) };
|
|
396
|
+
return { branch, head, bundle_sha256: fileHash(bundle), ...snapshotIndex(projectRoot, output, "project") };
|
|
334
397
|
}
|
|
335
398
|
function restoreProjectGit(snapshot, source, projectRoot) {
|
|
336
399
|
const bundle = path.join(snapshot, "project.bundle");
|
|
@@ -340,6 +403,110 @@ function restoreProjectGit(snapshot, source, projectRoot) {
|
|
|
340
403
|
runGit(projectRoot, ["init", "-b", source.branch]);
|
|
341
404
|
runGit(projectRoot, ["fetch", bundle, `+refs/heads/${source.branch}:refs/remotes/snapshot/${source.branch}`]);
|
|
342
405
|
runGit(projectRoot, ["checkout", "-B", source.branch, source.head]);
|
|
406
|
+
restoreIndex(snapshot, "project", source, projectRoot);
|
|
407
|
+
}
|
|
408
|
+
const mergeStateFiles = ["MERGE_HEAD", "MERGE_MSG", "MERGE_MODE", "ORIG_HEAD", "AUTO_MERGE"];
|
|
409
|
+
function indexEntries(root) {
|
|
410
|
+
const entries = gitBytes(root, ["ls-files", "--stage", "-z", "--", ".", ":(exclude)**/.env", ":(exclude)**/.env.*"]);
|
|
411
|
+
const merge = {};
|
|
412
|
+
for (const name of mergeStateFiles) {
|
|
413
|
+
const file = git(root, ["rev-parse", "--git-path", name]);
|
|
414
|
+
if (!file)
|
|
415
|
+
throw new AppError("snapshot_index_invalid", "Could not locate Git merge state", 1);
|
|
416
|
+
const resolved = path.resolve(root, file);
|
|
417
|
+
if (fs.existsSync(resolved))
|
|
418
|
+
merge[name] = fs.readFileSync(resolved).toString("base64");
|
|
419
|
+
}
|
|
420
|
+
return Buffer.from(JSON.stringify({ entries: entries.toString("base64"), merge }));
|
|
421
|
+
}
|
|
422
|
+
function snapshotIndex(root, output, name) {
|
|
423
|
+
const bytes = indexEntries(root);
|
|
424
|
+
const value = JSON.parse(bytes.toString());
|
|
425
|
+
const objects = new Set();
|
|
426
|
+
for (const entry of Buffer.from(value.entries, "base64").toString("utf8").split("\0").filter(Boolean)) {
|
|
427
|
+
const match = /^\d{6} ([a-f0-9]{40,64}) [0-3]\t/.exec(entry);
|
|
428
|
+
if (!match)
|
|
429
|
+
throw new AppError("snapshot_index_invalid", "Malformed Git index entry", 1);
|
|
430
|
+
// Gitlinks belong to another repository and are preserved as index IDs.
|
|
431
|
+
if (!entry.startsWith("160000 "))
|
|
432
|
+
objects.add(match[1]);
|
|
433
|
+
}
|
|
434
|
+
for (const name of ["MERGE_HEAD", "ORIG_HEAD", "AUTO_MERGE"]) {
|
|
435
|
+
if (value.merge[name])
|
|
436
|
+
for (const oid of Buffer.from(value.merge[name], "base64").toString().trim().split("\n")) {
|
|
437
|
+
if (!/^[a-f0-9]{40,64}$/.test(oid))
|
|
438
|
+
throw new AppError("snapshot_index_invalid", "Malformed Git merge reference", 1);
|
|
439
|
+
objects.add(oid);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
const payload = path.join(output, `${name}.index.patch`);
|
|
443
|
+
const pack = path.join(output, `${name}.index.pack`);
|
|
444
|
+
fs.writeFileSync(payload, bytes);
|
|
445
|
+
fs.writeFileSync(pack, gitBytes(root, ["pack-objects", "--stdout", "--revs"], Buffer.from([...objects].sort().join("\n") + "\n")));
|
|
446
|
+
return { index_format: "entries@1", index_sha256: fileHash(payload), index_objects_sha256: fileHash(pack) };
|
|
447
|
+
}
|
|
448
|
+
function gitBytes(root, args, input) {
|
|
449
|
+
const result = spawnSync("git", ["-C", root, ...args], { input, maxBuffer: 64 * 1024 * 1024 });
|
|
450
|
+
if (result.status !== 0)
|
|
451
|
+
throw new AppError("snapshot_index_invalid", "Could not preserve Git index objects", 1, { args, stderr: result.stderr?.toString().trim() });
|
|
452
|
+
return result.stdout;
|
|
453
|
+
}
|
|
454
|
+
function indexPatch(root) {
|
|
455
|
+
const result = spawnSync("git", ["-C", root, "diff", "--cached", "--binary", "--full-index", "--no-ext-diff", "HEAD", "--", ".", ":(exclude)**/.env", ":(exclude)**/.env.*"], { maxBuffer: 64 * 1024 * 1024 });
|
|
456
|
+
if (result.status !== 0)
|
|
457
|
+
throw new AppError("snapshot_index_invalid", "Could not capture Git index", 1);
|
|
458
|
+
return result.stdout;
|
|
459
|
+
}
|
|
460
|
+
function restoreIndex(snapshot, name, metadata, root) {
|
|
461
|
+
if (!metadata.index_sha256)
|
|
462
|
+
return;
|
|
463
|
+
const file = path.join(snapshot, `${name}.index.patch`);
|
|
464
|
+
if (fileHash(file) !== metadata.index_sha256)
|
|
465
|
+
throw new AppError("snapshot_checksum_mismatch", "Snapshot Git index checksum does not match", 1);
|
|
466
|
+
if (metadata.index_format === "entries@1") {
|
|
467
|
+
const pack = path.join(snapshot, `${name}.index.pack`);
|
|
468
|
+
if (!metadata.index_objects_sha256 || fileHash(pack) !== metadata.index_objects_sha256)
|
|
469
|
+
throw new AppError("snapshot_checksum_mismatch", "Snapshot Git index object checksum does not match", 1);
|
|
470
|
+
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
471
|
+
gitBytes(root, ["unpack-objects"], fs.readFileSync(pack));
|
|
472
|
+
runGit(root, ["read-tree", "--empty"]);
|
|
473
|
+
gitBytes(root, ["update-index", "-z", "--index-info"], Buffer.from(value.entries, "base64"));
|
|
474
|
+
for (const name of mergeStateFiles) {
|
|
475
|
+
const target = git(root, ["rev-parse", "--git-path", name]);
|
|
476
|
+
if (!target)
|
|
477
|
+
throw new AppError("snapshot_index_invalid", "Could not locate restored Git merge state", 1);
|
|
478
|
+
if (value.merge[name] !== undefined)
|
|
479
|
+
fs.writeFileSync(path.resolve(root, target), Buffer.from(value.merge[name], "base64"));
|
|
480
|
+
else
|
|
481
|
+
fs.rmSync(path.resolve(root, target), { force: true });
|
|
482
|
+
}
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
if (fs.statSync(file).size)
|
|
486
|
+
runGit(root, ["apply", "--cached", "--binary", file]);
|
|
487
|
+
}
|
|
488
|
+
export function verifyRecoverySnapshot(snapshot, input, checkWorkingState = true) {
|
|
489
|
+
const manifest = readManifest(snapshot);
|
|
490
|
+
if (manifest.purpose !== "recovery" || manifest.recovery_id !== input.recoveryId || manifest.run_id !== input.runId)
|
|
491
|
+
throw new AppError("recovery_capture_invalid", "Snapshot does not identify this recovery", 1);
|
|
492
|
+
const entries = [
|
|
493
|
+
{ name: "project", root: input.projectRoot, hash: manifest.project_sha256, git: manifest.project_git, head: manifest.project_git.head },
|
|
494
|
+
...(manifest.workspace.kind === "git_worktree" ? [{ name: "workspace", root: input.workspaceRoot, hash: manifest.workspace.sha256, git: manifest.workspace, head: manifest.workspace.base_ref }] : [])
|
|
495
|
+
];
|
|
496
|
+
if (treeHash(path.join(snapshot, "runtime")) !== manifest.runtime_sha256)
|
|
497
|
+
throw new AppError("snapshot_checksum_mismatch", "Recovery runtime checksum does not match", 1);
|
|
498
|
+
verifySnapshotModes(snapshot, manifest);
|
|
499
|
+
for (const entry of entries) {
|
|
500
|
+
const expectedModes = manifest.payload_modes?.[entry.name];
|
|
501
|
+
if (checkWorkingState && expectedModes && treeModesHash(entry.root, true) !== expectedModes)
|
|
502
|
+
throw new AppError("recovery_workspace_drift", "Executable file modes changed after recovery capture", 1, { payload: entry.name });
|
|
503
|
+
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))
|
|
504
|
+
throw new AppError("snapshot_checksum_mismatch", "Recovery payload checksum does not match", 1, { payload: entry.name });
|
|
505
|
+
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))
|
|
506
|
+
throw new AppError("snapshot_checksum_mismatch", "Recovery Git object checksum does not match", 1, { payload: entry.name });
|
|
507
|
+
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)))
|
|
508
|
+
throw new AppError("recovery_workspace_drift", "Working files or Git state changed after recovery capture", 1, { payload: entry.name });
|
|
509
|
+
}
|
|
343
510
|
}
|
|
344
511
|
function git(root, args) {
|
|
345
512
|
const result = spawnSync("git", ["-C", root, ...args], { encoding: "utf8" });
|
|
@@ -356,12 +523,14 @@ function snapshotPayloadAllowed(source) {
|
|
|
356
523
|
return ![".git", "node_modules", ".cache", ".npm", ".pnpm-store"].includes(name)
|
|
357
524
|
&& name !== ".env" && !name.startsWith(".env.");
|
|
358
525
|
}
|
|
359
|
-
function treeHash(root) {
|
|
526
|
+
function treeHash(root, filtered = false) {
|
|
360
527
|
const hash = crypto.createHash("sha256");
|
|
361
528
|
const visit = (dir, relative) => {
|
|
362
529
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
363
530
|
const file = path.join(dir, entry.name);
|
|
364
531
|
const rel = path.join(relative, entry.name);
|
|
532
|
+
if (filtered && !snapshotPayloadAllowed(file))
|
|
533
|
+
continue;
|
|
365
534
|
if (entry.isDirectory())
|
|
366
535
|
visit(file, rel);
|
|
367
536
|
else if (entry.isFile()) {
|
|
@@ -377,13 +546,58 @@ function treeHash(root) {
|
|
|
377
546
|
visit(root, "");
|
|
378
547
|
return hash.digest("hex");
|
|
379
548
|
}
|
|
549
|
+
function treeModesHash(root, filtered = false) {
|
|
550
|
+
const entries = [];
|
|
551
|
+
const visit = (directory) => {
|
|
552
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
553
|
+
const file = path.join(directory, entry.name);
|
|
554
|
+
if (filtered && !snapshotPayloadAllowed(file))
|
|
555
|
+
continue;
|
|
556
|
+
if (entry.isDirectory())
|
|
557
|
+
visit(file);
|
|
558
|
+
else if (entry.isFile())
|
|
559
|
+
entries.push([path.relative(root, file), fs.statSync(file).mode & 0o111]);
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
visit(root);
|
|
563
|
+
return crypto.createHash("sha256").update(JSON.stringify(entries)).digest("hex");
|
|
564
|
+
}
|
|
565
|
+
function verifySnapshotModes(snapshot, manifest) {
|
|
566
|
+
if (!manifest.payload_modes)
|
|
567
|
+
return; // Legacy snapshots did not attest modes.
|
|
568
|
+
for (const name of ["project", "runtime", ...(manifest.workspace.kind === "git_worktree" ? ["workspace"] : [])]) {
|
|
569
|
+
if (treeModesHash(path.join(snapshot, name)) !== manifest.payload_modes[name])
|
|
570
|
+
throw new AppError("snapshot_checksum_mismatch", "Snapshot executable modes do not match manifest", 1, { payload: name });
|
|
571
|
+
}
|
|
572
|
+
}
|
|
380
573
|
function fileHash(file) { return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); }
|
|
574
|
+
// Flush children before their directory entries, then publish and flush the
|
|
575
|
+
// parent. Never follow snapshot symlinks into the original project tree.
|
|
576
|
+
function syncSnapshotTree(root) {
|
|
577
|
+
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
578
|
+
const file = path.join(root, entry.name);
|
|
579
|
+
if (entry.isDirectory())
|
|
580
|
+
syncSnapshotTree(file);
|
|
581
|
+
else if (entry.isFile())
|
|
582
|
+
syncPath(file);
|
|
583
|
+
}
|
|
584
|
+
syncPath(root);
|
|
585
|
+
}
|
|
586
|
+
function syncPath(file) {
|
|
587
|
+
const descriptor = fs.openSync(file, "r");
|
|
588
|
+
try {
|
|
589
|
+
fs.fsyncSync(descriptor);
|
|
590
|
+
}
|
|
591
|
+
finally {
|
|
592
|
+
fs.closeSync(descriptor);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
381
595
|
function readManifest(snapshot) {
|
|
382
596
|
const file = path.join(snapshot, "snapshot.json");
|
|
383
597
|
if (!fs.existsSync(file))
|
|
384
598
|
throw new AppError("snapshot_missing", "Snapshot manifest is missing", 1, { snapshot });
|
|
385
599
|
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
386
|
-
if (value.schema_id !== "dd-flow/eval-run-snapshot@5" || !value.run_id || !value.project_root || !value.dd_flow_home || typeof value.purpose !== "string" || !["stage_entry", "candidate", "incomplete"].includes(value.purpose) || (value.purpose === "stage_entry" && !value.stage_entry) || (value.purpose !== "stage_entry" && value.stage_entry !== null) || !value.runtime_sha256 || !value.project_sha256 || !value.project_git || !value.workspace)
|
|
600
|
+
if (value.schema_id !== "dd-flow/eval-run-snapshot@5" || !value.run_id || !value.project_root || !value.dd_flow_home || typeof value.purpose !== "string" || !["stage_entry", "candidate", "incomplete", "recovery"].includes(value.purpose) || (value.purpose === "stage_entry" && !value.stage_entry) || (value.purpose !== "stage_entry" && value.stage_entry !== null) || !value.runtime_sha256 || !value.project_sha256 || !value.project_git || !value.workspace)
|
|
387
601
|
throw new AppError("snapshot_invalid", "Snapshot manifest is invalid", 1, { snapshot });
|
|
388
602
|
if (!value.project_git.branch || !value.project_git.head || !value.project_git.bundle_sha256)
|
|
389
603
|
throw new AppError("snapshot_invalid", "Snapshot project Git metadata is incomplete", 1, { snapshot });
|