@deksden-com/dd-flow-cli 0.9.0-beta.19 → 0.9.0-beta.22

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 CHANGED
@@ -1,5 +1,25 @@
1
1
  # @deksden-com/dd-flow-cli
2
2
 
3
+ ## 0.9.0-beta.22
4
+
5
+ ### Patch Changes
6
+
7
+ - Reject conflicting or ambiguous lifecycle receipts before any harness can
8
+ mutate a Session binding. Restore a Work-start receipt when its binding
9
+ fails, so the same verified invocation remains safely retryable.
10
+
11
+ ## 0.9.0-beta.21
12
+
13
+ ### Patch Changes
14
+
15
+ - Validate every selected engine artifact and execute its pinned snapshot even when its version matches the router. Retain a new recovery interruption before root acknowledgement.
16
+
17
+ ## 0.9.0-beta.20
18
+
19
+ ### Patch Changes
20
+
21
+ - Verify recovery payloads and workspace drift before resuming, preserve staged Git changes, publish snapshots through a temporary directory, and allow explicit recovery restoration into fresh targets. Fence RUN mutations and late asynchronous Work completion while recovery is active.
22
+
3
23
  ## 0.9.0-beta.19
4
24
 
5
25
  ### Patch Changes
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.19",
4
- "cli_commit": "02648aa2db37e9332089ca941b06f5735b563496",
5
- "built_at": "2026-09-06T13:33:22.164Z",
3
+ "cli_version": "0.9.0-beta.22",
4
+ "cli_commit": "7a08ac0550694b06cec5438d5ed938d19f772418",
5
+ "built_at": "2026-09-07T02:03:13.276Z",
6
6
  "built_with_canon": {
7
7
  "version": "4.0.6",
8
8
  "commit": "2aafb30d23e3f646b736b7ead25438cab97b16bf",
package/dist/cli/help.js CHANGED
@@ -157,7 +157,7 @@ Usage:
157
157
  dd-flow run prepare-vnext-specify --project-root <root> --slug <slug> --json
158
158
  dd-flow run recovery inspect|begin|seal|capture|resume <RUN-ID|RUN-short-id> --project-root <root> --json
159
159
  dd-flow run snapshot create <RUN-ID|RUN-short-id> (--stage-entry <stage>|--candidate|--incomplete|--recovery-id <RCV-ID>) --project-root <root> --output <directory> --json
160
- dd-flow run snapshot restore --snapshot <directory> --project-root <prepared-root> --json
160
+ dd-flow run snapshot restore --snapshot <directory> --project-root <prepared-root> [--recovery-id <RCV-ID>] --json
161
161
  dd-flow run snapshot bootstrap create --project-root <root> --output <directory> --json
162
162
  dd-flow run snapshot bootstrap restore --snapshot <directory> --project-root <prepared-root> --json
163
163
  dd-flow run timeline <RUN-ID|RUN-short-id> --project-root <root> --json
@@ -49,7 +49,7 @@ import { renderWorkerPrompt } from "../services/prompts.js";
49
49
  import { finishStage, startStage } from "../services/stage-lifecycle.js";
50
50
  import { addWorkBatch, assertStageLifecycleOwner, assertWorkLifecycleOwner, cancelWork, deleteWork, failWork, finishWork, listWorks, mutateWorkDeps, retryWork, showWork, startWork } from "../services/work-registry.js";
51
51
  import { createEvalBootstrapSnapshot, createEvalRunSnapshot, prepareVnextSpecifyRun, restoreEvalBootstrapSnapshot, restoreEvalRunSnapshot } from "../services/eval-snapshots.js";
52
- import { beginRunRecovery, inspectRunRecovery, recordRecoveryCapture, resumeRunRecovery, sealRunRecovery } from "../services/run-recovery.js";
52
+ import { acceptRunRecovery, beginRunRecovery, inspectRunRecovery, recordRecoveryCapture, resumeRunRecovery, sealRunRecovery } from "../services/run-recovery.js";
53
53
  import { loadExternalStageContext } from "../services/stage-context.js";
54
54
  import { stopManagedProcess, confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessStatus, reconcileExpiredManagedProcesses, registerManagedProcess } from "../services/managed-processes.js";
55
55
  import { startRuntimeService } from "../services/runtime-service.js";
@@ -763,8 +763,9 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
763
763
  return deleteWork(context, requiredPosition(parsed, 0, "work-id"));
764
764
  if (family === "work" && command === "start") {
765
765
  const hookEventId = optionalOption(parsed, "hook-event-id");
766
+ const recoveryId = optionalOption(parsed, "recovery-id");
766
767
  const projectRoot = requiredScopeProjectRoot(scopeProjectRoot, "work start");
767
- return startWork(context, requiredPosition(parsed, 0, "work-id"), { ...(hookEventId ? { hookEventId } : {}), projectRoot });
768
+ return startWork(context, requiredPosition(parsed, 0, "work-id"), { ...(hookEventId ? { hookEventId } : {}), ...(recoveryId ? { recoveryId } : {}), projectRoot });
768
769
  }
769
770
  if (family === "work" && command === "finish") {
770
771
  const resultFile = optionalOption(parsed, "result-file");
@@ -1476,8 +1477,10 @@ function dispatchRun(context, command, parsed) {
1476
1477
  if (action === "capture")
1477
1478
  return recordRecoveryCapture(context, { projectRoot, runId, recoveryId: requiredOption(parsed, "recovery-id"), capturePath: requiredOption(parsed, "capture-path") });
1478
1479
  if (action === "resume")
1479
- return resumeRunRecovery(context, { projectRoot, runId, recoveryId: requiredOption(parsed, "recovery-id") });
1480
- throw new AppError("usage", "Use: dd-flow run recovery inspect|begin|seal|capture|resume", 2);
1480
+ return resumeRunRecovery(context, { projectRoot, runId, recoveryId: requiredOption(parsed, "recovery-id"), daemonId: requiredOption(parsed, "daemon-id"), harness: requiredOption(parsed, "harness"), nativeSessionId: requiredOption(parsed, "native-session-id") });
1481
+ if (action === "accept")
1482
+ return acceptRunRecovery(context, { projectRoot, runId, recoveryId: requiredOption(parsed, "recovery-id"), ...(optionalOption(parsed, "hook-event-id") ? { hookEventId: optionalOption(parsed, "hook-event-id") } : {}) });
1483
+ throw new AppError("usage", "Use: dd-flow run recovery inspect|begin|seal|capture|resume|accept", 2);
1481
1484
  }
1482
1485
  if (command === "snapshot") {
1483
1486
  const action = requiredPosition(parsed, 0, "snapshot action");
@@ -1518,6 +1521,7 @@ function dispatchRun(context, command, parsed) {
1518
1521
  if (action === "restore")
1519
1522
  return restoreEvalRunSnapshot(context, {
1520
1523
  snapshot: requiredOption(parsed, "snapshot"),
1524
+ ...(optionalOption(parsed, "recovery-id") ? { recoveryId: requiredOption(parsed, "recovery-id") } : {}),
1521
1525
  projectRoot: requiredOption(parsed, "project-root")
1522
1526
  });
1523
1527
  throw new AppError("usage", "Use: dd-flow run snapshot bootstrap create|restore | dd-flow run snapshot create <RUN> (--stage-entry <stage>|--candidate) --project-root <path> --output <directory> | dd-flow run snapshot restore --snapshot <directory> --project-root <path>", 2);
@@ -19,6 +19,8 @@ export function getCmuxStatus(context, input) {
19
19
  }
20
20
  export function renderDashboard(context, input) {
21
21
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
22
+ if (projectRecoveryGuarded(context, project.id))
23
+ throw new AppError("run_recovery_guarded", "Project dashboard is frozen while its RUN is retained for recovery", 1);
22
24
  const format = parseDashboardFormat(input.format);
23
25
  if (format === "html") {
24
26
  if (input.protocol) {
@@ -170,6 +172,10 @@ export function refreshAllDashboards(context, input = {}) {
170
172
  }
171
173
  export function autoRefreshDashboards(context, input) {
172
174
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
175
+ // Hooks and usage ingestion continue to record receipts after capture.
176
+ // Their derived timestamp must not mutate the captured workspace.
177
+ if (projectRecoveryGuarded(context, project.id))
178
+ return { ok: true, skipped: true, reason: "run_recovery_guarded" };
173
179
  const config = readProjectConfig(context, project.id);
174
180
  if (!config.dashboard.auto_refresh) {
175
181
  return { ok: true, skipped: true, reason: "dashboard_auto_refresh_disabled" };
@@ -196,6 +202,9 @@ export function autoRefreshDashboards(context, input) {
196
202
  }
197
203
  return result;
198
204
  }
205
+ function projectRecoveryGuarded(context, projectId) {
206
+ return Boolean(context.db.get("SELECT 1 FROM run_recovery_guards WHERE project_id = ? AND status IN ('draining', 'sealed', 'resuming') LIMIT 1", [projectId]));
207
+ }
199
208
  export function dashboardSummary(context, projectId, projectRoot) {
200
209
  const config = readProjectConfig(context, projectId);
201
210
  return {
@@ -174,7 +174,9 @@ export function routeArgsThroughEngine(context, args, io, stdin, env, resolvedPr
174
174
  if (!selection.selected)
175
175
  return Promise.resolve(null);
176
176
  const current = getCliBuildInfo();
177
- if (selection.selected.package_name === current.package_name && selection.selected.package_version === current.version) {
177
+ const currentRoot = findPackageRoot(path.dirname(sourceFile));
178
+ if (selection.selected.package_name === current.package_name && selection.selected.package_version === current.version
179
+ && currentRoot !== null && fs.realpathSync(selection.selected.snapshot_root) === fs.realpathSync(currentRoot)) {
178
180
  return Promise.resolve({ routed: true, exitCode: 0, inProcess: true });
179
181
  }
180
182
  const entrypoint = resolveManifestEntrypoint(selection.selected);
@@ -383,11 +385,7 @@ export function selectEngine(context, input, options = {}, classification = inpu
383
385
  const manifests = readInstalledManifests(engineStoreRoot(context.ddFlowHome))
384
386
  .filter((manifest) => manifest.package_name === packageName)
385
387
  .filter((manifest) => requiredRange !== null && satisfiesRange(manifest.package_version, requiredRange))
386
- .filter((manifest) => options.allowCurrentInProcess
387
- && manifest.package_name === build.package_name
388
- && manifest.package_version === build.version
389
- ? true
390
- : manifestHealthy(manifest))
388
+ .filter((manifest) => manifestHealthy(manifest))
391
389
  .sort((a, b) => {
392
390
  const versionOrder = compareSemverStrings(b.package_version, a.package_version);
393
391
  if (versionOrder !== 0)
@@ -49,6 +49,31 @@ 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.
@@ -117,6 +142,7 @@ export function createEvalRunSnapshot(context, input) {
117
142
  project_sha256: treeHash(path.join(output, "project")),
118
143
  project_git: projectGit,
119
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")) } : {}) },
120
146
  source_status: status
121
147
  };
122
148
  writeJson(path.join(output, "snapshot.json"), manifest);
@@ -135,13 +161,21 @@ export function restoreEvalRunSnapshot(context, input) {
135
161
  const manifest = readManifest(snapshot);
136
162
  if (manifest.purpose === "incomplete")
137
163
  throw new AppError("snapshot_incomplete_not_restorable", "Incomplete snapshot is forensic evidence, not a stage-entry fixture", 1, { snapshot });
138
- if (manifest.purpose === "recovery")
139
- throw new AppError("snapshot_recovery_requires_explicit_restore", "Recovery snapshots require the recovery runner; they are not a generic 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 });
140
169
  if (manifest.purpose === "candidate")
141
170
  throw new AppError("snapshot_candidate_not_restorable", "A terminal candidate snapshot is evidence, not a stage-entry fixture", 1, { snapshot });
142
- if (!manifest.stage_entry)
171
+ if (!recovery && !manifest.stage_entry)
143
172
  throw new AppError("snapshot_invalid", "Stage-entry snapshot has no target stage", 1, { snapshot });
144
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
+ }
145
179
  const existing = fs.readdirSync(context.ddFlowHome).filter((name) => !["db.sqlite", "db.sqlite-wal", "db.sqlite-shm"].includes(name));
146
180
  if (existing.length > 0)
147
181
  throw new AppError("restore_home_not_empty", "Snapshot restore requires an empty DD_FLOW_HOME", 1, { dd_flow_home: context.ddFlowHome, existing });
@@ -151,6 +185,7 @@ export function restoreEvalRunSnapshot(context, input) {
151
185
  throw new AppError("snapshot_checksum_mismatch", "Snapshot runtime checksum does not match manifest", 1, { snapshot });
152
186
  if (treeHash(sourceProject) !== manifest.project_sha256)
153
187
  throw new AppError("snapshot_checksum_mismatch", "Snapshot project checksum does not match manifest", 1, { snapshot });
188
+ verifySnapshotModes(snapshot, manifest);
154
189
  clearProjectTree(projectRoot, false);
155
190
  restoreProjectGit(snapshot, manifest.project_git, projectRoot);
156
191
  replaceProjectTree(projectRoot, sourceProject);
@@ -162,14 +197,26 @@ export function restoreEvalRunSnapshot(context, input) {
162
197
  // A fresh process owns all subsequent normal commands.
163
198
  const restored = createContext({ ...context.env, DD_FLOW_HOME: context.ddFlowHome });
164
199
  try {
165
- 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);
166
201
  registerProject(restored, { root: projectRoot });
167
202
  requireProjectByRoot(restored, projectRoot);
168
203
  const status = getFlowRunStatus(restored, { projectRoot, runId: manifest.run_id });
169
- 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]);
170
208
  if (!status.run.run_root)
171
209
  throw new AppError("runtime_missing", "Restored RUN has no artifact root", 1, { run_id: manifest.run_id });
172
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
+ }
173
220
  return {
174
221
  ok: true,
175
222
  schema_id: "dd-flow/eval-run-restore@1",
@@ -180,7 +227,8 @@ export function restoreEvalRunSnapshot(context, input) {
180
227
  dd_flow_home: context.ddFlowHome,
181
228
  run_home: runHome,
182
229
  target_stage: manifest.stage_entry,
183
- 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`
184
232
  };
185
233
  }
186
234
  finally {
@@ -221,7 +269,7 @@ function assertTerminalCandidate(stages) {
221
269
  if (unfinished.length > 0)
222
270
  throw new AppError("snapshot_candidate_incomplete", "Candidate snapshot requires every started stage to be done", 1, { unfinished });
223
271
  }
224
- function rebaseRuntime(context, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome) {
272
+ function rebaseRuntime(context, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome, recovery = false) {
225
273
  context.db.exec("PRAGMA foreign_keys = OFF");
226
274
  context.db.exec("BEGIN IMMEDIATE");
227
275
  try {
@@ -242,7 +290,10 @@ function rebaseRuntime(context, oldRoot, newRoot, oldWorkspace, newWorkspace, ol
242
290
  rewriteDatabaseColumn(context, "usage", "source_locator", oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
243
291
  for (const column of ["worktree_path", "worktrunk_metadata_json", "last_command_result_json"])
244
292
  rewriteDatabaseColumn(context, "worktree_records", column, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
245
- 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()]);
246
297
  context.db.run("DELETE FROM codex_session_bindings");
247
298
  context.db.run("COMMIT");
248
299
  }
@@ -253,7 +304,10 @@ function rebaseRuntime(context, oldRoot, newRoot, oldWorkspace, newWorkspace, ol
253
304
  finally {
254
305
  context.db.exec("PRAGMA foreign_keys = ON");
255
306
  }
256
- 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);
257
311
  }
258
312
  function rewriteStructuredFiles(root, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome) {
259
313
  const visit = (dir) => {
@@ -303,7 +357,7 @@ function snapshotWorkspace(workspaceRoot, projectRoot, output) {
303
357
  const bundle = path.join(output, "workspace.bundle");
304
358
  fs.cpSync(workspaceRoot, workspace, { recursive: true, verbatimSymlinks: true, filter: snapshotPayloadAllowed });
305
359
  runGit(projectRoot, ["bundle", "create", bundle, branch]);
306
- 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") };
307
361
  }
308
362
  function restoreWorkspace(snapshot, manifest, projectRoot, ddFlowHome) {
309
363
  if (manifest.workspace.kind === "project")
@@ -326,6 +380,7 @@ function restoreWorkspace(snapshot, manifest, projectRoot, ddFlowHome) {
326
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 });
327
381
  runGit(projectRoot, ["worktree", "add", workspaceRoot, manifest.workspace.branch]);
328
382
  replaceProjectTree(workspaceRoot, source);
383
+ restoreIndex(snapshot, "workspace", manifest.workspace, workspaceRoot);
329
384
  return fs.realpathSync(workspaceRoot);
330
385
  }
331
386
  function sourceWorkspaceRoot(manifest) {
@@ -338,7 +393,7 @@ function snapshotProjectGit(projectRoot, output) {
338
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 });
339
394
  const bundle = path.join(output, "project.bundle");
340
395
  runGit(projectRoot, ["bundle", "create", bundle, "--all"]);
341
- return { branch, head, bundle_sha256: fileHash(bundle) };
396
+ return { branch, head, bundle_sha256: fileHash(bundle), ...snapshotIndex(projectRoot, output, "project") };
342
397
  }
343
398
  function restoreProjectGit(snapshot, source, projectRoot) {
344
399
  const bundle = path.join(snapshot, "project.bundle");
@@ -348,6 +403,110 @@ function restoreProjectGit(snapshot, source, projectRoot) {
348
403
  runGit(projectRoot, ["init", "-b", source.branch]);
349
404
  runGit(projectRoot, ["fetch", bundle, `+refs/heads/${source.branch}:refs/remotes/snapshot/${source.branch}`]);
350
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
+ }
351
510
  }
352
511
  function git(root, args) {
353
512
  const result = spawnSync("git", ["-C", root, ...args], { encoding: "utf8" });
@@ -364,12 +523,14 @@ function snapshotPayloadAllowed(source) {
364
523
  return ![".git", "node_modules", ".cache", ".npm", ".pnpm-store"].includes(name)
365
524
  && name !== ".env" && !name.startsWith(".env.");
366
525
  }
367
- function treeHash(root) {
526
+ function treeHash(root, filtered = false) {
368
527
  const hash = crypto.createHash("sha256");
369
528
  const visit = (dir, relative) => {
370
529
  for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
371
530
  const file = path.join(dir, entry.name);
372
531
  const rel = path.join(relative, entry.name);
532
+ if (filtered && !snapshotPayloadAllowed(file))
533
+ continue;
373
534
  if (entry.isDirectory())
374
535
  visit(file, rel);
375
536
  else if (entry.isFile()) {
@@ -385,7 +546,52 @@ function treeHash(root) {
385
546
  visit(root, "");
386
547
  return hash.digest("hex");
387
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
+ }
388
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
+ }
389
595
  function readManifest(snapshot) {
390
596
  const file = path.join(snapshot, "snapshot.json");
391
597
  if (!fs.existsSync(file))