@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.
@@ -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
- if (input.incomplete) { /* Forensic evidence is not an accepted or restorable boundary. */ }
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
- assertStageEntry(status.index.stage_runs ?? [], manifest.stage_entry);
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
- next_command: `dd-flow stage start ${status.run.id} --stage ${manifest.stage_entry} --project-root ${JSON.stringify(projectRoot)} --json`
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
- 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()]);
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
- rewriteStructuredFiles(context.ddFlowHome, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
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 });