@deksden-com/dd-flow-cli 0.9.0-beta.7 → 0.9.0-beta.75

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.
Files changed (123) hide show
  1. package/CHANGELOG.md +404 -0
  2. package/README.md +65 -0
  3. package/dist/build-info.json +10 -10
  4. package/dist/cli/help.js +81 -9
  5. package/dist/cli/run-cli.js +442 -46
  6. package/dist/harness-runtime/bin/dd-agy.mjs +37 -0
  7. package/dist/harness-runtime/bin/dd-codex.mjs +25 -0
  8. package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
  9. package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
  10. package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
  11. package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
  12. package/dist/harness-runtime/lib/daemon-operations.mjs +215 -0
  13. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +423 -0
  14. package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
  15. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +141 -0
  16. package/dist/harness-runtime/lib/dd-codex.mjs +438 -0
  17. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
  18. package/dist/harness-runtime/lib/dd-droid.mjs +471 -0
  19. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +274 -0
  20. package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
  21. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +198 -0
  22. package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
  23. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +644 -0
  24. package/dist/harness-runtime/lib/dd-zcode.mjs +910 -0
  25. package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
  26. package/dist/harness-runtime/lib/delegation-instructions.mjs +121 -0
  27. package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
  28. package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
  29. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  30. package/dist/harness-runtime/lib/managed-daemon.mjs +260 -0
  31. package/dist/harness-runtime/lib/model-observations.mjs +77 -0
  32. package/dist/harness-runtime/lib/native-hook-command.mjs +34 -0
  33. package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
  34. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  35. package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
  36. package/dist/harness-runtime/lib/process-json.mjs +70 -0
  37. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  38. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  39. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  40. package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
  41. package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
  42. package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
  43. package/dist/runtime/context.js +3 -1
  44. package/dist/schemas/agent-profile.schema.json +1 -1
  45. package/dist/schemas/code-review-result.schema.json +2 -2
  46. package/dist/schemas/code-work-batch.schema.json +4 -3
  47. package/dist/schemas/code-work-result.schema.json +4 -4
  48. package/dist/schemas/harness-config.schema.json +23 -0
  49. package/dist/schemas/plan-review-result.schema.json +2 -2
  50. package/dist/schemas/run-control-receipt.schema.json +99 -0
  51. package/dist/schemas/run-control-request.schema.json +36 -0
  52. package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
  53. package/dist/services/cleanup.js +8 -5
  54. package/dist/services/cli-operation-classifier.js +11 -1
  55. package/dist/services/code-checks.js +543 -96
  56. package/dist/services/controller-fanout.js +116 -0
  57. package/dist/services/dashboard.js +9 -0
  58. package/dist/services/engines.js +50 -19
  59. package/dist/services/eval-snapshots.js +639 -60
  60. package/dist/services/execution-policy.js +177 -0
  61. package/dist/services/external-work-launch.js +110 -0
  62. package/dist/services/harness-adapter.js +147 -24
  63. package/dist/services/harness-config.js +68 -0
  64. package/dist/services/hooks.js +413 -168
  65. package/dist/services/lanes.js +1 -1
  66. package/dist/services/lifecycle-command.js +74 -6
  67. package/dist/services/lifecycle-invocations.js +637 -0
  68. package/dist/services/managed-daemon-binding.js +38 -0
  69. package/dist/services/managed-processes.js +138 -22
  70. package/dist/services/merge-queue.js +6 -6
  71. package/dist/services/merge-server.js +75 -29
  72. package/dist/services/migrations.js +1 -1
  73. package/dist/services/native-daemon-history.js +49 -0
  74. package/dist/services/native-session-control.js +39 -0
  75. package/dist/services/projects.js +7 -3
  76. package/dist/services/prompts.js +4 -2
  77. package/dist/services/protocols.js +11 -2
  78. package/dist/services/recovery-observation-budget.js +61 -0
  79. package/dist/services/recovery-snapshot-database.js +107 -0
  80. package/dist/services/run-control-receipt.js +56 -0
  81. package/dist/services/run-control-worker.js +363 -0
  82. package/dist/services/run-control.js +876 -0
  83. package/dist/services/run-controller-adapter.js +211 -0
  84. package/dist/services/run-controller-capture.js +134 -0
  85. package/dist/services/run-controller-process.js +189 -0
  86. package/dist/services/run-controller-recovery.js +222 -0
  87. package/dist/services/run-controller-state.js +31 -0
  88. package/dist/services/run-controller.js +733 -0
  89. package/dist/services/run-engine-bindings.js +20 -62
  90. package/dist/services/run-fork.js +124 -0
  91. package/dist/services/run-observations.js +112 -0
  92. package/dist/services/run-recovery-runtime.js +69 -0
  93. package/dist/services/run-recovery.js +327 -0
  94. package/dist/services/runs.js +280 -40
  95. package/dist/services/runtime-budget.js +362 -0
  96. package/dist/services/runtime-scope-capture.js +50 -0
  97. package/dist/services/runtime-scope-control.js +450 -0
  98. package/dist/services/runtime-scope-resume.js +543 -0
  99. package/dist/services/runtime-scope-stop.js +99 -0
  100. package/dist/services/runtime-scope-worker.js +210 -0
  101. package/dist/services/runtime-service.js +96 -0
  102. package/dist/services/schema-validation.js +12 -12
  103. package/dist/services/stage-context.js +2 -2
  104. package/dist/services/stage-lifecycle.js +32 -10
  105. package/dist/services/stage-pause.js +25 -13
  106. package/dist/services/usage.js +37 -93
  107. package/dist/services/vnext-code-review.js +149 -89
  108. package/dist/services/vnext-code.js +224 -216
  109. package/dist/services/vnext-execution-profile.js +5 -3
  110. package/dist/services/vnext-fanout.js +135 -21
  111. package/dist/services/vnext-merge.js +258 -89
  112. package/dist/services/vnext-plan-review.js +75 -64
  113. package/dist/services/vnext-plan.js +77 -29
  114. package/dist/services/vnext-protocolize.js +21 -14
  115. package/dist/services/vnext-specify.js +26 -16
  116. package/dist/services/work-registry.js +605 -157
  117. package/dist/services/workspace-bootstrap.js +76 -0
  118. package/dist/shared/errors.js +12 -0
  119. package/dist/storage/database.js +341 -31
  120. package/dist/storage/writer-contract.js +77 -0
  121. package/dist/storage/writer-migration.js +102 -0
  122. package/package.json +20 -13
  123. package/tools/repair-paused-run-status.mjs +59 -0
@@ -2,12 +2,18 @@ import crypto from "node:crypto";
2
2
  import { spawnSync } from "node:child_process";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
+ import { DatabaseSync } from "node:sqlite";
6
+ import { registerStoreWriter } from "../storage/writer-contract.js";
5
7
  import { createContext } from "../runtime/context.js";
6
8
  import { AppError } from "../shared/errors.js";
7
- import { resolveProjectRoot } from "../storage/paths.js";
9
+ import { resolveProjectRoot, runtimeProtocolDir, runtimeRunDir } from "../storage/paths.js";
8
10
  import { registerProject, requireProjectByRoot } from "./projects.js";
9
- import { getFlowRunStatus, startFlowRun } from "./runs.js";
11
+ import { freezeRunExecutionRouting, getFlowRunStatus, importFlowRunExecutionProfile, startFlowRun } from "./runs.js";
12
+ import { loadExecutionRoutingInput, resolveExecutionPolicy } from "./execution-policy.js";
10
13
  import { readVnextFlowDefinition } from "./vnext-specify.js";
14
+ import { captureRecoveryDatabase, readRecoveryResourceEvidence } from "./recovery-snapshot-database.js";
15
+ import { resourceHome } from "./managed-processes.js";
16
+ import { codexHomeSharedEntries } from "./hooks.js";
11
17
  /** Capture the only flow boundary which exists before `stage start --bootstrap` creates a RUN. */
12
18
  export function createEvalBootstrapSnapshot(context, input) {
13
19
  const projectRoot = resolveProjectRoot(input.projectRoot);
@@ -19,7 +25,7 @@ export function createEvalBootstrapSnapshot(context, input) {
19
25
  if (isInside(physicalPath(context.ddFlowHome), physicalPath(output)))
20
26
  throw new AppError("snapshot_output_inside_runtime", "Bootstrap snapshot output must be outside DD_FLOW_HOME", 1, { output });
21
27
  fs.mkdirSync(output, { recursive: true });
22
- fs.cpSync(projectRoot, path.join(output, "project"), { recursive: true, verbatimSymlinks: true, filter: (source) => path.basename(source) !== ".git" });
28
+ fs.cpSync(projectRoot, path.join(output, "project"), { recursive: true, verbatimSymlinks: true, filter: snapshotPayloadAllowed });
23
29
  const manifest = {
24
30
  schema_id: "dd-eval/bootstrap-snapshot@1",
25
31
  stage: "specify",
@@ -44,18 +50,66 @@ export function restoreEvalBootstrapSnapshot(_context, input) {
44
50
  return { ok: true, schema_id: "dd-eval/bootstrap-restore@1", snapshot, project_root: projectRoot, target_stage: "specify" };
45
51
  }
46
52
  /**
47
- * Snapshot a deliberately dedicated DD_FLOW_HOME. Eval checkpoints are not a
48
- * general runtime export format: a shared home fails closed instead of trying
49
- * to infer a safe subset of SQLite rows.
53
+ * Eval fixtures require a dedicated home. Sealed recovery exports instead
54
+ * retain only the selected RUN's relational and filesystem evidence.
50
55
  */
51
56
  export function createEvalRunSnapshot(context, input) {
57
+ const output = path.resolve(input.output);
58
+ if (fs.existsSync(output))
59
+ throw new AppError("snapshot_exists", "Snapshot output already exists", 1, { output });
60
+ const temporary = `${output}.partial-${crypto.randomUUID()}`;
61
+ try {
62
+ input.boundary?.verify();
63
+ const sourceVersion = input.boundary ? snapshotSourceVersion(context, input) : null;
64
+ const result = captureEvalRunSnapshot(context, { ...input, output: temporary });
65
+ if (input.boundary && sourceVersion) {
66
+ Object.assign(result, { consistency: "controller_boundary_writer_barrier@1", boundary_capture: { controller_id: input.boundary.controllerId, operation_id: input.boundary.operationId, boundary_key: input.boundary.boundaryKey, source_sha256: sourceVersion, observation: input.boundary.observation } });
67
+ writeJson(path.join(temporary, "snapshot.json"), Object.fromEntries(Object.entries(result).filter(([key]) => key !== "ok" && key !== "snapshot")));
68
+ }
69
+ if (input.recoveryId) {
70
+ const projectRoot = resolveProjectRoot(input.projectRoot);
71
+ const project = requireProjectByRoot(context, projectRoot);
72
+ const run = context.db.get("SELECT workspace_root FROM runs WHERE project_id = ? AND id = ?", [project.id, input.runId]);
73
+ verifyRecoverySnapshot(temporary, { runId: input.runId, recoveryId: input.recoveryId, projectRoot, workspaceRoot: run?.workspace_root ?? projectRoot });
74
+ }
75
+ if (fs.existsSync(output))
76
+ throw new AppError("snapshot_exists", "Snapshot output already exists", 1, { output });
77
+ syncSnapshotTree(temporary);
78
+ if (input.boundary) {
79
+ if (sourceVersion !== snapshotSourceVersion(context, input))
80
+ throw new AppError("snapshot_source_changed", "Snapshot source files or Git state changed during boundary capture", 1);
81
+ input.boundary.verify();
82
+ }
83
+ fs.renameSync(temporary, output);
84
+ syncPath(path.dirname(output));
85
+ return { ...result, snapshot: output };
86
+ }
87
+ catch (error) {
88
+ fs.rmSync(temporary, { recursive: true, force: true });
89
+ throw error;
90
+ }
91
+ }
92
+ function captureEvalRunSnapshot(context, input) {
52
93
  const projectRoot = resolveProjectRoot(input.projectRoot);
53
94
  // Snapshot is read-only at the control-plane boundary. An invalid workspace
54
95
  // root must fail without registering a second project in a dedicated home.
55
96
  const project = requireProjectByRoot(context, projectRoot);
56
97
  const status = getFlowRunStatus(context, { projectRoot, runId: input.runId });
57
- assertDedicatedHome(context, project.id, input.runId);
58
- if (input.candidate)
98
+ const recovery = typeof input.recoveryId === "string";
99
+ if (!recovery && !input.incomplete && context.db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'run_controllers'")) {
100
+ const controller = context.db.get("SELECT controller_id,status,owner_pid,owner_token FROM run_controllers WHERE project_id = ? AND run_id = ? AND status NOT IN ('completed','cancelled','superseded','stop_target_reached')", [project.id, input.runId]);
101
+ if (controller && (controller.status !== "capturing" || controller.controller_id !== input.boundary?.controllerId || controller.owner_pid !== process.pid || controller.owner_token !== context.env.DD_FLOW_CONTROLLER_TOKEN))
102
+ throw new AppError("snapshot_controller_capture_required", "A live managed RUN must be captured by its fenced controller, not an external observer", 1, { controller_id: controller.controller_id });
103
+ }
104
+ if (!recovery)
105
+ assertDedicatedHome(context, project.id, input.runId);
106
+ if (recovery) {
107
+ 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]);
108
+ if (!guard || guard.status !== "sealed")
109
+ 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 });
110
+ }
111
+ if (input.incomplete || recovery) { /* Recovery captures only after the runner seals all writers. */ }
112
+ else if (input.candidate)
59
113
  assertTerminalCandidate(status.index.stage_runs ?? []);
60
114
  else if (input.stageEntry)
61
115
  assertStageEntry(status.index.stage_runs ?? [], input.stageEntry);
@@ -65,7 +119,7 @@ export function createEvalRunSnapshot(context, input) {
65
119
  const activeChildren = input.candidate
66
120
  ? 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
67
121
  : 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;
68
- if (activeChildren > 0)
122
+ if (!input.incomplete && !recovery && activeChildren > 0)
69
123
  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 });
70
124
  const output = path.resolve(input.output);
71
125
  if (fs.existsSync(output))
@@ -75,36 +129,81 @@ export function createEvalRunSnapshot(context, input) {
75
129
  const workspaceRoot = String(status.run.workspace_root ?? projectRoot);
76
130
  if (isInside(physicalPath(projectRoot), physicalPath(output)) || isInside(physicalPath(workspaceRoot), physicalPath(output)))
77
131
  throw new AppError("snapshot_output_inside_project", "Snapshot output must be outside the project and RUN workspace trees", 1, { output });
78
- context.db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
79
- fs.mkdirSync(output, { recursive: true });
80
- fs.cpSync(context.ddFlowHome, path.join(output, "runtime"), {
81
- recursive: true,
82
- verbatimSymlinks: true,
83
- // Managed checkouts are restored from their Git bundle and workspace
84
- // payload. Copying their .git indirection into runtime makes the snapshot
85
- // dependent on the source machine's worktree metadata.
86
- filter: (source) => !["engines", "checkouts"].includes(path.basename(source))
87
- });
132
+ fs.mkdirSync(output, { recursive: true, mode: 0o700 });
133
+ const resourceDatabase = path.join(context.ddFlowHome, "runtime.sqlite");
134
+ const databaseFiles = [context.db.path, resourceDatabase].flatMap(file => [file, `${file}-wal`, `${file}-shm`]);
135
+ const codexExclusions = codexHomeSnapshotExclusions(context, project.id, input.runId);
136
+ if (recovery) {
137
+ if (typeof status.run.run_root !== "string" || !isInside(physicalPath(context.ddFlowHome), physicalPath(status.run.run_root)) || samePath(physicalPath(context.ddFlowHome), physicalPath(status.run.run_root)))
138
+ throw new AppError("snapshot_run_scope_invalid", "Recovery requires a RUN artifact root within its runtime", 1);
139
+ for (const source of new Set([status.run.run_root, runtimeRunDir(context.ddFlowHome, project.id, input.runId)])) {
140
+ if (fs.existsSync(source))
141
+ copyRuntimeTree(source, path.join(output, "runtime", path.relative(context.ddFlowHome, source)), true, codexExclusions);
142
+ }
143
+ }
144
+ else
145
+ copyRuntimeTree(context.ddFlowHome, path.join(output, "runtime"), false, [...databaseFiles, ...codexExclusions]);
146
+ // VACUUM INTO is a consistent SQLite read snapshot, including committed WAL
147
+ // pages. Copying the live database and its WAL separately is not atomic.
148
+ const databaseCopy = path.join(output, "runtime", path.relative(context.ddFlowHome, context.db.path));
149
+ fs.mkdirSync(path.dirname(databaseCopy), { recursive: true });
150
+ if (recovery) {
151
+ const report = typeof status.run.run_root === "string" ? path.join(output, "runtime", path.relative(context.ddFlowHome, status.run.run_root), "02-protocolize", "stage-report.json") : null;
152
+ if (report && !isInside(physicalPath(path.join(output, "runtime")), physicalPath(report)))
153
+ throw new AppError("snapshot_protocol_scope_invalid", "Recovery protocol evidence is outside the captured runtime", 1);
154
+ const protocols = report && fs.existsSync(report) ? JSON.parse(fs.readFileSync(report, "utf8")).semantic?.acceptance : [];
155
+ if (!Array.isArray(protocols) || !protocols.every(id => typeof id === "string" && id.startsWith("PRT-")))
156
+ throw new AppError("snapshot_protocol_scope_invalid", "Recovery export requires the accepted PROTOCOLIZE protocol IDs", 1);
157
+ captureRecoveryDatabase(context.db, databaseCopy, project.id, input.runId, protocols);
158
+ }
159
+ else
160
+ context.db.exec(`VACUUM INTO '${databaseCopy.replace(/'/g, "''")}'`);
161
+ // Resource heartbeats can continue after productive RUN writers are sealed.
162
+ // Keep their committed evidence consistent too; never copy a live WAL pair.
163
+ if (!recovery && fs.existsSync(resourceDatabase)) {
164
+ const resources = new DatabaseSync(resourceDatabase, { readOnly: true });
165
+ try {
166
+ const resourceCopy = path.join(output, "runtime", "runtime.sqlite");
167
+ resources.exec("PRAGMA busy_timeout = 4000");
168
+ resources.exec(`VACUUM INTO '${resourceCopy.replace(/'/g, "''")}'`);
169
+ }
170
+ finally {
171
+ resources.close();
172
+ }
173
+ }
88
174
  fs.cpSync(projectRoot, path.join(output, "project"), {
89
175
  recursive: true,
90
176
  verbatimSymlinks: true,
91
- filter: (source) => path.basename(source) !== ".git"
177
+ filter: snapshotPayloadAllowed
92
178
  });
93
179
  const projectGit = snapshotProjectGit(projectRoot, output);
94
180
  const workspace = snapshotWorkspace(workspaceRoot, projectRoot, output);
181
+ // Accepted Work results are captured in SQLite. Their file representations
182
+ // are retained evidence, not a second authority or a checkpoint gate.
183
+ if (recovery) {
184
+ const copiedContext = createContext({ ...context.env, DD_FLOW_HOME: path.join(output, "runtime") }, "read_existing");
185
+ try {
186
+ captureRecoveryAuxiliary(context, copiedContext, { projectId: project.id, runId: input.runId, output });
187
+ }
188
+ finally {
189
+ copiedContext.db.close?.();
190
+ }
191
+ }
95
192
  const manifest = {
96
193
  schema_id: "dd-flow/eval-run-snapshot@5",
97
194
  run_id: input.runId,
98
195
  project_id: project.id,
99
196
  project_root: projectRoot,
100
197
  dd_flow_home: context.ddFlowHome,
101
- purpose: input.candidate ? "candidate" : "stage_entry",
102
- stage_entry: input.candidate ? null : input.stageEntry,
198
+ purpose: input.incomplete ? "incomplete" : recovery ? "recovery" : input.candidate ? "candidate" : "stage_entry",
199
+ stage_entry: input.incomplete || recovery || input.candidate ? null : input.stageEntry,
200
+ ...(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" } : {}),
103
201
  created_at: context.now(),
104
202
  runtime_sha256: treeHash(path.join(output, "runtime")),
105
203
  project_sha256: treeHash(path.join(output, "project")),
106
204
  project_git: projectGit,
107
205
  workspace,
206
+ payload_modes: { project: treeModesHash(path.join(output, "project")), runtime: treeModesHash(path.join(output, "runtime")), ...(workspace.kind === "git_worktree" ? { workspace: treeModesHash(path.join(output, "workspace")) } : {}) },
108
207
  source_status: status
109
208
  };
110
209
  writeJson(path.join(output, "snapshot.json"), manifest);
@@ -121,20 +220,57 @@ function createdWorkSchemaForStage(stage) {
121
220
  export function restoreEvalRunSnapshot(context, input) {
122
221
  const snapshot = path.resolve(input.snapshot);
123
222
  const manifest = readManifest(snapshot);
223
+ if (manifest.purpose === "incomplete")
224
+ throw new AppError("snapshot_incomplete_not_restorable", "Incomplete snapshot is forensic evidence, not a stage-entry fixture", 1, { snapshot });
225
+ const recovery = manifest.purpose === "recovery";
226
+ if (recovery && input.executionRoutingFile !== undefined)
227
+ throw new AppError("snapshot_recovery_policy_frozen", "Recovery must retain its frozen execution policy", 2);
228
+ if (recovery && input.recoveryId !== manifest.recovery_id)
229
+ throw new AppError("snapshot_recovery_requires_explicit_restore", "Recovery snapshots require their explicit --recovery-id", 1, { snapshot });
230
+ if (!recovery && input.recoveryId)
231
+ throw new AppError("snapshot_invalid", "Recovery ID cannot restore a non-recovery snapshot", 1, { snapshot });
124
232
  if (manifest.purpose === "candidate")
125
233
  throw new AppError("snapshot_candidate_not_restorable", "A terminal candidate snapshot is evidence, not a stage-entry fixture", 1, { snapshot });
126
- if (!manifest.stage_entry)
234
+ if (!recovery && !manifest.stage_entry)
127
235
  throw new AppError("snapshot_invalid", "Stage-entry snapshot has no target stage", 1, { snapshot });
128
236
  const projectRoot = resolveProjectRoot(input.projectRoot);
129
- const existing = fs.readdirSync(context.ddFlowHome).filter((name) => !["db.sqlite", "db.sqlite-wal", "db.sqlite-shm"].includes(name));
237
+ if (recovery) {
238
+ if (fs.readdirSync(projectRoot).length || (context.db.get("SELECT COUNT(*) AS count FROM projects")?.count ?? 0) > 0)
239
+ throw new AppError("recovery_restore_target_not_empty", "Recovery restore requires fresh project and runtime targets", 1);
240
+ verifyRecoverySnapshot(snapshot, { runId: manifest.run_id, recoveryId: input.recoveryId, projectRoot, workspaceRoot: projectRoot }, false);
241
+ }
242
+ const existing = fs.readdirSync(context.ddFlowHome).filter((name) => !["db.sqlite", "db.sqlite-wal", "db.sqlite-shm", ...(input.executionRoutingFile !== undefined ? ["agent-profiles"] : [])].includes(name));
130
243
  if (existing.length > 0)
131
244
  throw new AppError("restore_home_not_empty", "Snapshot restore requires an empty DD_FLOW_HOME", 1, { dd_flow_home: context.ddFlowHome, existing });
245
+ if (context.db.get("SELECT 1 FROM projects LIMIT 1") || context.db.get("SELECT 1 FROM runs LIMIT 1"))
246
+ throw new AppError("restore_home_not_empty", "Snapshot restore cannot replace an initialized runtime", 1);
132
247
  const sourceRuntime = path.join(snapshot, "runtime");
133
248
  const sourceProject = path.join(snapshot, "project");
134
- if (treeHash(sourceRuntime) !== manifest.runtime_sha256)
135
- throw new AppError("snapshot_checksum_mismatch", "Snapshot runtime checksum does not match manifest", 1, { snapshot });
136
- if (treeHash(sourceProject) !== manifest.project_sha256)
137
- throw new AppError("snapshot_checksum_mismatch", "Snapshot project checksum does not match manifest", 1, { snapshot });
249
+ verifySnapshotPayload(snapshot, manifest);
250
+ const routingInput = input.executionRoutingFile !== undefined ? loadExecutionRoutingInput(input.executionRoutingFile) : undefined;
251
+ let selectedProfile;
252
+ if (routingInput) {
253
+ // Resolve selected profiles from the fresh caller home before replacing it.
254
+ // Source DB is read-only; no context or migrations may touch the snapshot.
255
+ const sourceDb = new DatabaseSync(path.join(sourceRuntime, "db.sqlite"), { readOnly: true });
256
+ try {
257
+ const row = sourceDb.prepare("SELECT index_json FROM runs WHERE id = ?").get(manifest.run_id);
258
+ const index = row ? JSON.parse(row.index_json) : null;
259
+ if (!index?.execution_profile || !["vnext_specify", "vnext_protocolize"].includes(index.flow_kind))
260
+ throw new AppError("execution_profile_not_frozen", "Stage-entry routing requires a vNext execution snapshot", 1);
261
+ assertStageEntry(index.stage_runs, manifest.stage_entry);
262
+ selectedProfile = freezeRunExecutionRouting(context.ddFlowHome, index.execution_profile, routingInput);
263
+ for (const row of sourceDb.prepare("SELECT payload_json FROM works WHERE run_id = ? AND status = 'created'").all(manifest.run_id)) {
264
+ const payload = row.payload_json ? JSON.parse(row.payload_json) : null;
265
+ if (payload?.execution_override !== undefined)
266
+ resolveExecutionPolicy({ ddFlowHome: context.ddFlowHome, policy: routingInput.execution, profiles: selectedProfile.agent_profiles, stage: manifest.stage_entry, work: payload.execution_override });
267
+ }
268
+ selectedProfile.routing_input.artifact = `execution-routing.${routingInput.source_checksum}.input.json`;
269
+ }
270
+ finally {
271
+ sourceDb.close();
272
+ }
273
+ }
138
274
  clearProjectTree(projectRoot, false);
139
275
  restoreProjectGit(snapshot, manifest.project_git, projectRoot);
140
276
  replaceProjectTree(projectRoot, sourceProject);
@@ -146,12 +282,38 @@ export function restoreEvalRunSnapshot(context, input) {
146
282
  // A fresh process owns all subsequent normal commands.
147
283
  const restored = createContext({ ...context.env, DD_FLOW_HOME: context.ddFlowHome });
148
284
  try {
149
- rebaseRuntime(restored, manifest.project_root, projectRoot, sourceWorkspaceRoot(manifest), workspaceRoot, manifest.dd_flow_home, context.ddFlowHome);
285
+ const runtimeLineage = recovery ? null : detachSnapshotRuntimeOwners(restored, snapshot, manifest);
286
+ rebaseRuntime(restored, manifest.project_root, projectRoot, sourceWorkspaceRoot(manifest), workspaceRoot, manifest.dd_flow_home, context.ddFlowHome, recovery);
150
287
  registerProject(restored, { root: projectRoot });
151
- const project = requireProjectByRoot(restored, projectRoot);
288
+ requireProjectByRoot(restored, projectRoot);
289
+ if (selectedProfile && routingInput && runtimeLineage) {
290
+ const run = restored.db.get("SELECT run_root FROM runs WHERE id = ?", [manifest.run_id]);
291
+ const artifact = path.join(run.run_root, selectedProfile.routing_input.artifact);
292
+ if (fs.existsSync(artifact)) {
293
+ if (!fs.readFileSync(artifact).equals(routingInput.bytes))
294
+ throw new AppError("snapshot_checksum_mismatch", "Existing routing input does not match its checksum", 1);
295
+ }
296
+ else
297
+ fs.writeFileSync(artifact, routingInput.bytes, { flag: "wx", mode: 0o600 });
298
+ importFlowRunExecutionProfile(restored, { projectRoot, runId: manifest.run_id, stage: manifest.stage_entry, lineageFile: runtimeLineage, profile: selectedProfile });
299
+ }
152
300
  const status = getFlowRunStatus(restored, { projectRoot, runId: manifest.run_id });
153
- assertStageEntry(status.index.stage_runs ?? [], manifest.stage_entry);
154
- const runHome = status.run.run_home_path ?? path.join(context.ddFlowHome, "projects", project.id, "runs", manifest.run_id);
301
+ if (!recovery)
302
+ assertStageEntry(status.index.stage_runs ?? [], manifest.stage_entry);
303
+ else
304
+ restored.db.run("UPDATE run_recovery_guards SET capture_path = ? WHERE recovery_id = ? AND status = 'sealed'", [snapshot, input.recoveryId]);
305
+ if (!status.run.run_root)
306
+ throw new AppError("runtime_missing", "Restored RUN has no artifact root", 1, { run_id: manifest.run_id });
307
+ const runHome = status.run.run_root;
308
+ const relocation = recovery ? {
309
+ schema_id: "dd-flow/recovery-relocation@1", physical_runtime_id: crypto.randomUUID(), recovery_id: input.recoveryId, run_id: manifest.run_id,
310
+ path_mapping: [{ from: manifest.dd_flow_home, to: context.ddFlowHome }, { from: sourceWorkspaceRoot(manifest), to: workspaceRoot }, { from: manifest.project_root, to: projectRoot }]
311
+ } : null;
312
+ const relocationFile = relocation ? path.join(context.ddFlowHome, "recovery-imports", `${relocation.physical_runtime_id}.json`) : null;
313
+ if (relocationFile) {
314
+ fs.mkdirSync(path.dirname(relocationFile), { recursive: true });
315
+ writeJson(relocationFile, relocation);
316
+ }
155
317
  return {
156
318
  ok: true,
157
319
  schema_id: "dd-flow/eval-run-restore@1",
@@ -162,7 +324,9 @@ export function restoreEvalRunSnapshot(context, input) {
162
324
  dd_flow_home: context.ddFlowHome,
163
325
  run_home: runHome,
164
326
  target_stage: manifest.stage_entry,
165
- next_command: `dd-flow stage start ${status.run.id} --stage ${manifest.stage_entry} --project-root ${JSON.stringify(projectRoot)} --json`
327
+ ...(runtimeLineage ? { runtime_lineage_file: runtimeLineage } : {}),
328
+ ...(recovery ? { recovery_id: input.recoveryId, native_session_portability: "requires_adapter_verification", relocation_file: relocationFile, relocation } : {}),
329
+ 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`
166
330
  };
167
331
  }
168
332
  finally {
@@ -180,9 +344,10 @@ export function prepareVnextSpecifyRun(context, input) {
180
344
  subjectType: "discussion",
181
345
  subjectId: "SPECIFY",
182
346
  slug: input.slug,
347
+ executionRoutingFile: input.executionRoutingFile,
183
348
  nextAction: "start_specify"
184
349
  });
185
- return { ok: true, run_id: started.run.id, run_home: started.run.run_home_path, target_stage: "specify" };
350
+ return { ok: true, run_id: started.run.id, run_root: started.run.run_root, target_stage: "specify" };
186
351
  }
187
352
  function assertDedicatedHome(context, projectId, runId) {
188
353
  const projects = context.db.get("SELECT COUNT(*) AS count FROM projects")?.count ?? 0;
@@ -194,18 +359,72 @@ function assertDedicatedHome(context, projectId, runId) {
194
359
  function assertStageEntry(stages, target) {
195
360
  if (stages.some((entry) => entry.stage === target))
196
361
  throw new AppError("snapshot_target_started", "Stage-entry snapshot requires its target stage to be unstarted", 1, { target });
197
- const unfinished = stages.filter((entry) => entry.status !== "done");
362
+ const unfinished = stages.filter((entry) => !["done", "skipped"].includes(entry.status ?? ""));
198
363
  if (unfinished.length > 0)
199
364
  throw new AppError("snapshot_predecessor_incomplete", "Stage-entry snapshot requires completed predecessor stages", 1, { target, unfinished });
200
365
  }
201
366
  function assertTerminalCandidate(stages) {
202
- const unfinished = stages.filter((entry) => entry.status !== "done");
367
+ const unfinished = stages.filter((entry) => !["done", "skipped"].includes(entry.status ?? ""));
203
368
  if (unfinished.length > 0)
204
369
  throw new AppError("snapshot_candidate_incomplete", "Candidate snapshot requires every started stage to be done", 1, { unfinished });
205
370
  }
206
- function rebaseRuntime(context, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome) {
371
+ /** A stage-entry import is a new experiment, not a lease takeover or recovery.
372
+ * Keep source authority as evidence, never as executable target inventory. */
373
+ function detachSnapshotRuntimeOwners(context, snapshot, manifest) {
374
+ const hasTable = (table) => Boolean(context.db.get("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?", [table]));
375
+ if (hasTable("run_recovery_guards") && context.db.get("SELECT 1 FROM run_recovery_guards WHERE status IN ('draining','sealed','resuming') LIMIT 1"))
376
+ throw new AppError("snapshot_control_requires_recovery", "A fenced RUN requires explicit recovery, not a stage-entry fork", 1);
377
+ if (context.db.get("SELECT 1 FROM merge_requests WHERE status IN ('dispatching','active','waiting_user','action_required','recovery_required') LIMIT 1"))
378
+ throw new AppError("snapshot_execution_active", "A stage-entry fork cannot discard an unresolved MERGE execution", 1);
379
+ const detachedTables = ["lifecycle_invocations", "run_control_admissions", "run_control_stage_admissions", "run_control_operations", "run_control_workers", "run_controls", "run_controller_events", "run_controller_operations", "run_controllers", "run_recovery_bindings", "work_recovery_segments", "run_recovery_guards", "work_launches", "merge_servers", "merge_sessions", "lane_locks", "lane_waiters"].filter(hasTable);
380
+ const runTables = Object.fromEntries([...detachedTables, "runs", "merge_requests", "sessions", "work_sessions"].map(table => [table, context.db.all(`SELECT * FROM ${table}`)]));
381
+ const resourceFile = path.join(context.ddFlowHome, "runtime.sqlite");
382
+ const resources = fs.existsSync(resourceFile) ? new DatabaseSync(resourceFile) : null;
383
+ try {
384
+ if (resources)
385
+ registerStoreWriter(resources);
386
+ const resourceTables = Object.fromEntries(["managed_resources", "managed_processes"].filter(table => resources?.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?").get(table)).map(table => [table, resources.prepare(`SELECT * FROM ${table}`).all()]));
387
+ const directory = path.join(context.ddFlowHome, "snapshot-imports");
388
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
389
+ const file = path.join(directory, `${crypto.randomUUID()}.json`);
390
+ fs.writeFileSync(file, `${JSON.stringify({ schema_id: "dd-flow/snapshot-runtime-lineage@1", source_snapshot: snapshot, source_manifest_sha256: fileHash(path.join(snapshot, "snapshot.json")), source_runtime: manifest.dd_flow_home, restored_runtime: context.ddFlowHome, run_id: manifest.run_id, run_tables: runTables, resource_tables: resourceTables }, null, 2)}\n`, { flag: "wx", mode: 0o600 });
391
+ syncPath(file);
392
+ syncPath(directory);
393
+ context.db.beginWriteTransaction();
394
+ try {
395
+ context.db.run("UPDATE sessions SET metadata_json = json_set(metadata_json, '$.snapshot_import', json(?))", [JSON.stringify({ lineage_file: file, lineage_sha256: fileHash(file) })]);
396
+ for (const table of detachedTables)
397
+ context.db.exec(`DELETE FROM ${table}`);
398
+ context.db.run("UPDATE merge_requests SET dispatch_owner = NULL, dispatch_lease_token = NULL, dispatch_lease_expires_at = NULL, lock_acquired_at = NULL, adapter_receipt_json = NULL");
399
+ // Only the copied dedicated registry is touched. Source/external homes
400
+ // are historical references in the archive and are never opened here.
401
+ if (resources) {
402
+ resources.exec("BEGIN IMMEDIATE");
403
+ try {
404
+ for (const table of Object.keys(resourceTables))
405
+ resources.exec(`DELETE FROM ${table}`);
406
+ resources.exec("COMMIT");
407
+ }
408
+ catch (error) {
409
+ resources.exec("ROLLBACK");
410
+ throw error;
411
+ }
412
+ }
413
+ context.db.exec("COMMIT");
414
+ }
415
+ catch (error) {
416
+ context.db.exec("ROLLBACK");
417
+ throw error;
418
+ }
419
+ return file;
420
+ }
421
+ finally {
422
+ resources?.close();
423
+ }
424
+ }
425
+ function rebaseRuntime(context, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome, recovery = false) {
207
426
  context.db.exec("PRAGMA foreign_keys = OFF");
208
- context.db.exec("BEGIN IMMEDIATE");
427
+ context.db.beginWriteTransaction();
209
428
  try {
210
429
  context.db.run("UPDATE projects SET root = ?", [newRoot]);
211
430
  context.db.run("UPDATE runs SET project_root = ?, workspace_root = ?", [newRoot, newWorkspace]);
@@ -214,7 +433,12 @@ function rebaseRuntime(context, oldRoot, newRoot, oldWorkspace, newWorkspace, ol
214
433
  context.db.run("UPDATE protocols SET project_root = ?", [newRoot]);
215
434
  for (const column of ["state_path", "plan_path"])
216
435
  rewriteDatabaseColumn(context, "protocols", column, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
217
- context.db.run("UPDATE sessions SET project_root = ?, status = CASE WHEN status = 'active' THEN 'stopped' ELSE status END, stop_reason = CASE WHEN status = 'active' THEN 'snapshot_restored' ELSE stop_reason END", [newRoot]);
436
+ for (const column of ["source_workspace", "target_workspace"])
437
+ rewriteDatabaseColumn(context, "merge_requests", column, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
438
+ if (recovery)
439
+ context.db.run("UPDATE sessions SET project_root = ?, status = CASE WHEN status = 'active' THEN 'stopped' ELSE status END, stop_reason = CASE WHEN status = 'active' THEN 'snapshot_restored' ELSE stop_reason END", [newRoot]);
440
+ else
441
+ context.db.run("UPDATE sessions SET project_root = ?, status = 'stopped', stop_reason = 'snapshot_restored', stopped_at = ?", [newRoot, context.now()]);
218
442
  for (const column of ["workspace_path", "cwd", "transcript_path"])
219
443
  rewriteDatabaseColumn(context, "sessions", column, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
220
444
  for (const column of ["prompt_path", "result_path"])
@@ -224,7 +448,10 @@ function rebaseRuntime(context, oldRoot, newRoot, oldWorkspace, newWorkspace, ol
224
448
  rewriteDatabaseColumn(context, "usage", "source_locator", oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
225
449
  for (const column of ["worktree_path", "worktrunk_metadata_json", "last_command_result_json"])
226
450
  rewriteDatabaseColumn(context, "worktree_records", column, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
227
- 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()]);
451
+ // Recovery imports remain sealed. Their old Work sessions are historical
452
+ // bindings, not completed work; explicit recovery closes them interrupted.
453
+ if (!recovery)
454
+ 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()]);
228
455
  context.db.run("DELETE FROM codex_session_bindings");
229
456
  context.db.run("COMMIT");
230
457
  }
@@ -235,24 +462,21 @@ function rebaseRuntime(context, oldRoot, newRoot, oldWorkspace, newWorkspace, ol
235
462
  finally {
236
463
  context.db.exec("PRAGMA foreign_keys = ON");
237
464
  }
238
- rewriteStructuredFiles(context.ddFlowHome, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
465
+ // Only operational database paths move. Imported packets, results and
466
+ // journals retain their historical bytes, including interrupted JSONL tails.
467
+ // The frozen workspace route is an operational receipt consumed by later
468
+ // stages, so its relocated path is the sole structured-file exception.
469
+ rewriteOperationalRouteReceipts(context.ddFlowHome, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
239
470
  }
240
- function rewriteStructuredFiles(root, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome) {
241
- const visit = (dir) => {
242
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
243
- const file = path.join(dir, entry.name);
244
- // DD_FLOW_HOME also contains the restored checkout and installed tools.
245
- // They are project/tool bytes, not flow records; parsing every *.json
246
- // there breaks on valid JSON-with-comments such as tsconfig files.
247
- if (entry.isDirectory() && ![".git", "checkouts", "engines", "node_modules", "tools"].includes(entry.name))
471
+ function rewriteOperationalRouteReceipts(root, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome) {
472
+ const visit = (directory) => {
473
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
474
+ const file = path.join(directory, entry.name);
475
+ if (entry.isDirectory())
248
476
  visit(file);
249
- else if (entry.isFile() && entry.name.endsWith(".json")) {
477
+ else if (entry.isFile() && entry.name === "workspace-route.json") {
250
478
  const value = JSON.parse(fs.readFileSync(file, "utf8"));
251
- writeJson(file, rebaseStructuredValue(value, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome));
252
- }
253
- else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
254
- const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean).map((line) => JSON.stringify(rebaseStructuredValue(JSON.parse(line), oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome)));
255
- fs.writeFileSync(file, `${lines.join("\n")}\n`);
479
+ fs.writeFileSync(file, `${JSON.stringify(rebaseStructuredValue(value, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome), null, 2)}\n`);
256
480
  }
257
481
  }
258
482
  };
@@ -283,9 +507,9 @@ function snapshotWorkspace(workspaceRoot, projectRoot, output) {
283
507
  throw new AppError("snapshot_workspace_invalid", "Feature workspace has no readable HEAD", 1, { workspace_root: workspaceRoot });
284
508
  const workspace = path.join(output, "workspace");
285
509
  const bundle = path.join(output, "workspace.bundle");
286
- fs.cpSync(workspaceRoot, workspace, { recursive: true, verbatimSymlinks: true, filter: (source) => path.basename(source) !== ".git" });
510
+ fs.cpSync(workspaceRoot, workspace, { recursive: true, verbatimSymlinks: true, filter: snapshotPayloadAllowed });
287
511
  runGit(projectRoot, ["bundle", "create", bundle, branch]);
288
- return { kind: "git_worktree", source_root: workspaceRoot, branch, base_ref: baseRef, sha256: treeHash(workspace), bundle_sha256: fileHash(bundle) };
512
+ return { kind: "git_worktree", source_root: workspaceRoot, branch, base_ref: baseRef, sha256: treeHash(workspace), bundle_sha256: fileHash(bundle), ...snapshotIndex(workspaceRoot, output, "workspace") };
289
513
  }
290
514
  function restoreWorkspace(snapshot, manifest, projectRoot, ddFlowHome) {
291
515
  if (manifest.workspace.kind === "project")
@@ -308,6 +532,7 @@ function restoreWorkspace(snapshot, manifest, projectRoot, ddFlowHome) {
308
532
  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 });
309
533
  runGit(projectRoot, ["worktree", "add", workspaceRoot, manifest.workspace.branch]);
310
534
  replaceProjectTree(workspaceRoot, source);
535
+ restoreIndex(snapshot, "workspace", manifest.workspace, workspaceRoot);
311
536
  return fs.realpathSync(workspaceRoot);
312
537
  }
313
538
  function sourceWorkspaceRoot(manifest) {
@@ -320,7 +545,7 @@ function snapshotProjectGit(projectRoot, output) {
320
545
  throw new AppError("snapshot_project_git_invalid", "Snapshot requires a project on a named Git branch with a readable HEAD", 1, { project_root: projectRoot });
321
546
  const bundle = path.join(output, "project.bundle");
322
547
  runGit(projectRoot, ["bundle", "create", bundle, "--all"]);
323
- return { branch, head, bundle_sha256: fileHash(bundle) };
548
+ return { branch, head, bundle_sha256: fileHash(bundle), ...snapshotIndex(projectRoot, output, "project") };
324
549
  }
325
550
  function restoreProjectGit(snapshot, source, projectRoot) {
326
551
  const bundle = path.join(snapshot, "project.bundle");
@@ -330,6 +555,128 @@ function restoreProjectGit(snapshot, source, projectRoot) {
330
555
  runGit(projectRoot, ["init", "-b", source.branch]);
331
556
  runGit(projectRoot, ["fetch", bundle, `+refs/heads/${source.branch}:refs/remotes/snapshot/${source.branch}`]);
332
557
  runGit(projectRoot, ["checkout", "-B", source.branch, source.head]);
558
+ restoreIndex(snapshot, "project", source, projectRoot);
559
+ }
560
+ const mergeStateFiles = ["MERGE_HEAD", "MERGE_MSG", "MERGE_MODE", "ORIG_HEAD", "AUTO_MERGE"];
561
+ function indexEntries(root) {
562
+ const entries = Buffer.from(gitBytes(root, ["ls-files", "--stage", "-z"])
563
+ .toString("utf8").split("\0").filter(entry => entry && snapshotEnvironmentAllowed(entry.slice(entry.indexOf("\t") + 1)))
564
+ .map(entry => `${entry}\0`).join(""));
565
+ const merge = {};
566
+ for (const name of mergeStateFiles) {
567
+ const file = git(root, ["rev-parse", "--git-path", name]);
568
+ if (!file)
569
+ throw new AppError("snapshot_index_invalid", "Could not locate Git merge state", 1);
570
+ const resolved = path.resolve(root, file);
571
+ if (fs.existsSync(resolved))
572
+ merge[name] = fs.readFileSync(resolved).toString("base64");
573
+ }
574
+ return Buffer.from(JSON.stringify({ entries: entries.toString("base64"), merge }));
575
+ }
576
+ function snapshotIndex(root, output, name) {
577
+ const bytes = indexEntries(root);
578
+ const value = JSON.parse(bytes.toString());
579
+ const objects = new Set();
580
+ for (const entry of Buffer.from(value.entries, "base64").toString("utf8").split("\0").filter(Boolean)) {
581
+ const match = /^\d{6} ([a-f0-9]{40,64}) [0-3]\t/.exec(entry);
582
+ if (!match)
583
+ throw new AppError("snapshot_index_invalid", "Malformed Git index entry", 1);
584
+ // Gitlinks belong to another repository and are preserved as index IDs.
585
+ if (!entry.startsWith("160000 "))
586
+ objects.add(match[1]);
587
+ }
588
+ for (const name of ["MERGE_HEAD", "ORIG_HEAD", "AUTO_MERGE"]) {
589
+ if (value.merge[name])
590
+ for (const oid of Buffer.from(value.merge[name], "base64").toString().trim().split("\n")) {
591
+ if (!/^[a-f0-9]{40,64}$/.test(oid))
592
+ throw new AppError("snapshot_index_invalid", "Malformed Git merge reference", 1);
593
+ objects.add(oid);
594
+ }
595
+ }
596
+ const payload = path.join(output, `${name}.index.patch`);
597
+ const pack = path.join(output, `${name}.index.pack`);
598
+ fs.writeFileSync(payload, bytes);
599
+ fs.writeFileSync(pack, gitBytes(root, ["pack-objects", "--stdout", "--revs"], Buffer.from([...objects].sort().join("\n") + "\n")));
600
+ return { index_format: "entries@1", index_sha256: fileHash(payload), index_objects_sha256: fileHash(pack) };
601
+ }
602
+ function gitBytes(root, args, input) {
603
+ const result = spawnSync("git", ["-C", root, ...args], { input, maxBuffer: 64 * 1024 * 1024 });
604
+ if (result.status !== 0)
605
+ throw new AppError("snapshot_index_invalid", "Could not preserve Git index objects", 1, { args, stderr: result.stderr?.toString().trim() });
606
+ return result.stdout;
607
+ }
608
+ function indexPatch(root) {
609
+ const result = spawnSync("git", ["-C", root, "diff", "--cached", "--binary", "--full-index", "--no-ext-diff", "HEAD", "--", ".", ":(exclude)**/.env", ":(exclude)**/.env.*"], { maxBuffer: 64 * 1024 * 1024 });
610
+ if (result.status !== 0)
611
+ throw new AppError("snapshot_index_invalid", "Could not capture Git index", 1);
612
+ return result.stdout;
613
+ }
614
+ function restoreIndex(snapshot, name, metadata, root) {
615
+ if (!metadata.index_sha256)
616
+ return;
617
+ const file = path.join(snapshot, `${name}.index.patch`);
618
+ if (fileHash(file) !== metadata.index_sha256)
619
+ throw new AppError("snapshot_checksum_mismatch", "Snapshot Git index checksum does not match", 1);
620
+ if (metadata.index_format === "entries@1") {
621
+ const pack = path.join(snapshot, `${name}.index.pack`);
622
+ if (!metadata.index_objects_sha256 || fileHash(pack) !== metadata.index_objects_sha256)
623
+ throw new AppError("snapshot_checksum_mismatch", "Snapshot Git index object checksum does not match", 1);
624
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
625
+ gitBytes(root, ["unpack-objects"], fs.readFileSync(pack));
626
+ runGit(root, ["read-tree", "--empty"]);
627
+ gitBytes(root, ["update-index", "-z", "--index-info"], Buffer.from(value.entries, "base64"));
628
+ for (const name of mergeStateFiles) {
629
+ const target = git(root, ["rev-parse", "--git-path", name]);
630
+ if (!target)
631
+ throw new AppError("snapshot_index_invalid", "Could not locate restored Git merge state", 1);
632
+ if (value.merge[name] !== undefined)
633
+ fs.writeFileSync(path.resolve(root, target), Buffer.from(value.merge[name], "base64"));
634
+ else
635
+ fs.rmSync(path.resolve(root, target), { force: true });
636
+ }
637
+ return;
638
+ }
639
+ if (fs.statSync(file).size)
640
+ runGit(root, ["apply", "--cached", "--binary", file]);
641
+ }
642
+ export function verifyRecoverySnapshot(snapshot, input, checkWorkingState = true) {
643
+ const manifest = readManifest(snapshot);
644
+ if (manifest.purpose !== "recovery" || manifest.recovery_id !== input.recoveryId || manifest.run_id !== input.runId)
645
+ throw new AppError("recovery_capture_invalid", "Snapshot does not identify this recovery", 1);
646
+ const entries = [
647
+ { name: "project", root: input.projectRoot, hash: manifest.project_sha256, git: manifest.project_git, head: manifest.project_git.head },
648
+ ...(manifest.workspace.kind === "git_worktree" ? [{ name: "workspace", root: input.workspaceRoot, hash: manifest.workspace.sha256, git: manifest.workspace, head: manifest.workspace.base_ref }] : [])
649
+ ];
650
+ verifySnapshotPayload(snapshot, manifest);
651
+ for (const entry of entries) {
652
+ const expectedModes = manifest.payload_modes?.[entry.name];
653
+ if (checkWorkingState && expectedModes && treeModesHash(entry.root, true) !== expectedModes)
654
+ throw new AppError("recovery_workspace_drift", "Executable file modes changed after recovery capture", 1, { payload: entry.name });
655
+ 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)))
656
+ throw new AppError("recovery_workspace_drift", "Working files or Git state changed after recovery capture", 1, { payload: entry.name });
657
+ }
658
+ }
659
+ /** Reuse an already published boundary only after checking its identity and
660
+ * every payload, including its Git index objects. Never overwrite evidence. */
661
+ export function verifyControllerBoundarySnapshot(snapshot, input) {
662
+ const manifest = readManifest(snapshot), boundary = manifest.boundary_capture;
663
+ if (!boundary || manifest.consistency !== "controller_boundary_writer_barrier@1" || manifest.run_id !== input.runId || boundary.controller_id !== input.controllerId || boundary.operation_id !== input.operationId || boundary.boundary_key !== input.boundaryKey || manifest.stage_entry !== input.stageEntry || manifest.purpose !== (input.stageEntry ? "stage_entry" : "candidate") || !/^[a-f0-9]{64}$/.test(boundary.source_sha256) || boundary.observation?.settled !== true || boundary.observation.operation_id !== input.operationId)
664
+ throw new AppError("controller_boundary_snapshot_invalid", "Published snapshot does not prove this controller boundary", 1, { snapshot });
665
+ verifySnapshotPayload(snapshot, manifest);
666
+ const file = path.join(snapshot, "snapshot.json");
667
+ if (input.manifestSha256 !== undefined && fileHash(file) !== input.manifestSha256)
668
+ throw new AppError("snapshot_checksum_mismatch", "Boundary manifest no longer matches its retained publication receipt", 1, { snapshot });
669
+ return { snapshot, manifest: file, manifest_sha256: fileHash(file), purpose: manifest.purpose, stage_entry: manifest.stage_entry };
670
+ }
671
+ function verifySnapshotPayload(snapshot, manifest) {
672
+ if (treeHash(path.join(snapshot, "runtime")) !== manifest.runtime_sha256)
673
+ throw new AppError("snapshot_checksum_mismatch", "Snapshot runtime checksum does not match", 1);
674
+ verifySnapshotModes(snapshot, manifest);
675
+ const payloads = [{ name: "project", hash: manifest.project_sha256, git: manifest.project_git }, ...(manifest.workspace.kind === "git_worktree" ? [{ name: "workspace", hash: manifest.workspace.sha256, git: manifest.workspace }] : [])];
676
+ for (const payload of payloads) {
677
+ if (treeHash(path.join(snapshot, payload.name)) !== payload.hash || fileHash(path.join(snapshot, `${payload.name}.bundle`)) !== payload.git.bundle_sha256 || payload.git.index_sha256 && fileHash(path.join(snapshot, `${payload.name}.index.patch`)) !== payload.git.index_sha256 || payload.git.index_format === "entries@1" && (!payload.git.index_objects_sha256 || fileHash(path.join(snapshot, `${payload.name}.index.pack`)) !== payload.git.index_objects_sha256))
678
+ throw new AppError("snapshot_checksum_mismatch", "Snapshot payload or Git index checksum does not match", 1, { payload: payload.name });
679
+ }
333
680
  }
334
681
  function git(root, args) {
335
682
  const result = spawnSync("git", ["-C", root, ...args], { encoding: "utf8" });
@@ -341,12 +688,199 @@ function runGit(root, args) {
341
688
  throw new AppError("snapshot_workspace_restore_failed", "Could not restore feature worktree", 1, { stderr: result.stderr.trim(), args });
342
689
  }
343
690
  function samePath(left, right) { return path.resolve(left) === path.resolve(right); }
344
- function treeHash(root) {
691
+ function snapshotEnvironmentAllowed(source) {
692
+ const name = path.basename(source);
693
+ // Public configuration template, not a local environment/credential file.
694
+ return name === ".env.example" || (name !== ".env" && !name.startsWith(".env."));
695
+ }
696
+ function snapshotPayloadAllowed(source) {
697
+ const name = path.basename(source);
698
+ return ![".git", "node_modules", ".cache", ".npm", ".pnpm-store"].includes(name)
699
+ && snapshotEnvironmentAllowed(source);
700
+ }
701
+ function runtimePayloadAllowed(source) {
702
+ return snapshotPayloadAllowed(source) && !["auth.json", "auth.bk", "credentials.json", ".credentials.json", "credentials", ".credentials", "id_rsa", "id_ed25519", "engines", "checkouts"].includes(path.basename(source));
703
+ }
704
+ /** Productive writers are fenced separately. Recheck copied bytes and Git
705
+ * state so an uncoordinated file writer cannot publish a mixed boundary. */
706
+ function snapshotSourceVersion(context, input) {
707
+ const projectRoot = resolveProjectRoot(input.projectRoot);
708
+ const project = requireProjectByRoot(context, projectRoot);
709
+ const run = context.db.get("SELECT workspace_root FROM runs WHERE project_id = ? AND id = ?", [project.id, input.runId]);
710
+ if (!run)
711
+ throw new AppError("not_found", "Snapshot RUN is not registered", 1);
712
+ const resources = path.join(context.ddFlowHome, "runtime.sqlite");
713
+ // Resource heartbeats are not productive writers. Their semantic identities
714
+ // are rechecked by the owner; each resource DB is copied consistently.
715
+ const runtimeFilter = (file) => runtimePayloadAllowed(file) && !file.endsWith("-shm") && ![resources, `${resources}-wal`, `${resources}-journal`].includes(file);
716
+ const roots = [...new Set([projectRoot, run.workspace_root])];
717
+ return crypto.createHash("sha256").update(JSON.stringify([
718
+ treeHash(context.ddFlowHome, runtimeFilter), treeModesHash(context.ddFlowHome, runtimeFilter),
719
+ ...roots.map(root => [treeHash(root, true), treeModesHash(root, true), git(root, ["symbolic-ref", "--quiet", "HEAD"]), gitBytes(root, ["show-ref", "--head"]).toString("base64"), indexEntries(root).toString("base64")])
720
+ ])).digest("hex");
721
+ }
722
+ /** Managed Codex homes share host configuration, not owned snapshot payload. */
723
+ function codexHomeSnapshotExclusions(context, projectId, runId) {
724
+ const homes = new Set(context.db.all("SELECT target_home FROM codex_home_profiles WHERE project_id = ?", [projectId]).map(row => row.target_home));
725
+ if (context.db.get("SELECT 1 FROM sqlite_schema WHERE name = 'run_controllers'")) {
726
+ for (const { home } of context.db.all("SELECT json_extract(value, '$.codex_home') AS home FROM run_controllers, json_each(state_json, '$.sessions') WHERE project_id = ? AND run_id = ?", [projectId, runId]))
727
+ if (home)
728
+ homes.add(home);
729
+ }
730
+ const excluded = [];
731
+ for (const home of homes) {
732
+ if (!path.isAbsolute(home) || !fs.existsSync(home) || fs.lstatSync(home).isSymbolicLink())
733
+ continue;
734
+ const marker = path.join(home, ".dd-flow-home.json");
735
+ if (!fs.existsSync(marker) || fs.lstatSync(marker).isSymbolicLink())
736
+ continue;
737
+ const identity = JSON.parse(fs.readFileSync(marker, "utf8"));
738
+ if (identity.managed_by !== "dd-flow-cli" || identity.project_id !== projectId || identity.target_home !== home || !identity.source_home || !path.isAbsolute(identity.source_home))
739
+ continue;
740
+ for (const entry of codexHomeSharedEntries) {
741
+ const file = path.join(home, entry);
742
+ // Skip only the exact links installed by initCodexHome, never arbitrary
743
+ // external links or owned local files with the same convenient name.
744
+ if (fs.lstatSync(file, { throwIfNoEntry: false })?.isSymbolicLink() && fs.readlinkSync(file) === path.join(identity.source_home, entry))
745
+ excluded.push(file);
746
+ }
747
+ excluded.push(path.join(home, "tmp"), path.join(home, ".tmp"));
748
+ }
749
+ return excluded;
750
+ }
751
+ /** Copy a dedicated home or scoped recovery tree, preserving SQLite WAL commits. */
752
+ function copyRuntimeTree(source, destination, scoped = true, excluded = []) {
753
+ if (fs.lstatSync(source).isSymbolicLink())
754
+ throw new AppError("snapshot_runtime_symlink_outside_scope", "Recovery artifact roots must not be symbolic links", 1, { path: source });
755
+ const root = physicalPath(source);
756
+ if (isInside(root, physicalPath(destination)))
757
+ throw new AppError("snapshot_output_inside_runtime", "Recovery output must be outside its source artifact tree", 1);
758
+ const databases = [];
759
+ const sqlite = (file) => {
760
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile())
761
+ return false;
762
+ const descriptor = fs.openSync(file, "r");
763
+ try {
764
+ const header = Buffer.alloc(16);
765
+ return fs.readSync(descriptor, header, 0, 16, 0) === 16 && header.toString() === "SQLite format 3\0";
766
+ }
767
+ finally {
768
+ fs.closeSync(descriptor);
769
+ }
770
+ };
771
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
772
+ fs.cpSync(source, destination, {
773
+ recursive: true, verbatimSymlinks: true,
774
+ filter: file => {
775
+ if (!runtimePayloadAllowed(file) || excluded.includes(file))
776
+ return false;
777
+ const stat = fs.lstatSync(file);
778
+ if (stat.isSymbolicLink()) {
779
+ if (scoped && !isInside(root, physicalPath(file)))
780
+ throw new AppError("snapshot_runtime_symlink_outside_scope", "Recovery artifact link leaves its owned tree", 1, { path: file });
781
+ return true;
782
+ }
783
+ if (!stat.isDirectory() && !stat.isFile())
784
+ return false;
785
+ if (stat.isFile()) {
786
+ if (/-(?:wal|shm|journal)$/.test(file) && sqlite(file.replace(/-(?:wal|shm|journal)$/, "")))
787
+ return false;
788
+ if (sqlite(file)) {
789
+ databases.push(file);
790
+ return false;
791
+ }
792
+ }
793
+ return true;
794
+ }
795
+ });
796
+ for (const file of databases) {
797
+ const db = new DatabaseSync(file, { readOnly: true });
798
+ try {
799
+ const target = source === file ? destination : path.join(destination, path.relative(source, file));
800
+ fs.mkdirSync(path.dirname(target), { recursive: true });
801
+ db.exec("PRAGMA busy_timeout = 4000");
802
+ db.exec(`VACUUM INTO '${target.replace(/'/g, "''")}'`);
803
+ }
804
+ finally {
805
+ db.close();
806
+ }
807
+ }
808
+ }
809
+ function captureRecoveryAuxiliary(context, captured, input) {
810
+ const runtime = path.join(input.output, "runtime");
811
+ const codexExclusions = codexHomeSnapshotExclusions(context, input.projectId, input.runId);
812
+ const run = captured.db.get("SELECT run_root, project_root, workspace_root FROM runs WHERE project_id = ? AND id = ?", [input.projectId, input.runId]);
813
+ const roots = [run.run_root, runtimeRunDir(context.ddFlowHome, input.projectId, input.runId)];
814
+ for (const protocol of captured.db.all("SELECT id, state_path, plan_path FROM protocols")) {
815
+ const directory = runtimeProtocolDir(context.ddFlowHome, input.projectId, protocol.id);
816
+ if (!isInside(physicalPath(context.ddFlowHome), physicalPath(directory)))
817
+ throw new AppError("snapshot_protocol_scope_invalid", "Protocol runtime leaves DD_FLOW_HOME", 1);
818
+ if (fs.existsSync(directory)) {
819
+ copyRuntimeTree(directory, path.join(runtime, path.relative(context.ddFlowHome, directory)), true, codexExclusions);
820
+ roots.push(directory);
821
+ }
822
+ for (const file of [protocol.state_path, protocol.plan_path]) {
823
+ if (!fs.existsSync(file) || roots.some(root => isInside(physicalPath(root), physicalPath(file))))
824
+ continue;
825
+ if (isInside(physicalPath(context.ddFlowHome), physicalPath(file)) && fs.statSync(file).isFile())
826
+ copyRuntimeTree(file, path.join(runtime, path.relative(context.ddFlowHome, file)));
827
+ else if (![run.project_root, run.workspace_root].some(root => isInside(physicalPath(root), physicalPath(file))))
828
+ throw new AppError("snapshot_protocol_scope_invalid", "Protocol evidence is outside captured payloads", 1, { path: file });
829
+ }
830
+ }
831
+ const homes = new Set([context.ddFlowHome, resourceHome(context)]);
832
+ const queries = {
833
+ run_controllers: "SELECT json_extract(manifest_json, '$.resource_home') AS home FROM run_controllers UNION SELECT json_extract(value, '$.owner.resource_home') FROM run_controllers, json_each(state_json, '$.sessions')",
834
+ run_control_workers: "SELECT resource_home AS home FROM run_control_workers",
835
+ work_launches: "SELECT json_extract(profile_json, '$.runtime_adapter.owner.resource_home') AS home FROM work_launches",
836
+ merge_requests: "SELECT json_extract(adapter_receipt_json, '$.owner.resource_home') AS home FROM merge_requests",
837
+ run_recovery_guards: "SELECT value AS home FROM run_recovery_guards, json_each(settlement_json, '$.resource_homes')"
838
+ };
839
+ const references = Object.entries(queries).flatMap(([table, query]) => captured.db.get("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?", [table]) ? captured.db.all(query) : []);
840
+ for (const { home } of references) {
841
+ if (home === null)
842
+ continue;
843
+ if (typeof home !== "string" || !path.isAbsolute(home))
844
+ throw new AppError("snapshot_resource_scope_invalid", "Retained resource home must be an absolute path", 1);
845
+ homes.add(home);
846
+ }
847
+ const nativeCaptures = new Map();
848
+ const registries = [];
849
+ for (const home of [...homes].sort()) {
850
+ if (!path.isAbsolute(home))
851
+ throw new AppError("snapshot_resource_scope_invalid", "Resource home must be an absolute path", 1);
852
+ const evidence = readRecoveryResourceEvidence(home, input.projectId, input.runId, { db: captured.db, ddFlowHome: context.ddFlowHome });
853
+ for (const process of evidence.processes) {
854
+ const directory = JSON.parse(process.metadata_json).state_dir;
855
+ if (directory === undefined)
856
+ continue;
857
+ if (typeof directory !== "string" || !path.isAbsolute(directory))
858
+ throw new AppError("snapshot_native_scope_invalid", "Native artifact root must be absolute", 1);
859
+ if (roots.some(root => isInside(physicalPath(root), physicalPath(directory))) || nativeCaptures.has(directory))
860
+ continue;
861
+ const state = JSON.parse(fs.readFileSync(path.join(directory, "daemon.json"), "utf8"));
862
+ const owner = state.config?.runtime_owner;
863
+ if (!owner || owner.project_id !== input.projectId || owner.run_id !== input.runId || owner.owner_id !== process.owner_id || owner.dd_flow_home !== context.ddFlowHome || owner.resource_home !== home || owner.state_dir !== directory)
864
+ throw new AppError("snapshot_native_scope_invalid", "External native artifact root has no matching RUN owner", 1, { state_dir: directory });
865
+ const relative = path.join("native-evidence", crypto.createHash("sha256").update(directory).digest("hex"));
866
+ copyRuntimeTree(directory, path.join(runtime, relative), true, codexExclusions);
867
+ nativeCaptures.set(directory, relative);
868
+ }
869
+ const relative = path.join("resource-evidence", `${crypto.createHash("sha256").update(home).digest("hex")}.json`);
870
+ fs.mkdirSync(path.dirname(path.join(runtime, relative)), { recursive: true });
871
+ writeJson(path.join(runtime, relative), evidence);
872
+ registries.push(relative);
873
+ }
874
+ writeJson(path.join(runtime, "recovery-evidence.json"), { schema_id: "dd-flow/recovery-runtime-evidence@1", project_id: input.projectId, run_id: input.runId, registries, native_captures: [...nativeCaptures].map(([source_root, payload]) => ({ source_root, payload })) });
875
+ }
876
+ function treeHash(root, filtered = false) {
345
877
  const hash = crypto.createHash("sha256");
346
878
  const visit = (dir, relative) => {
347
879
  for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
348
880
  const file = path.join(dir, entry.name);
349
881
  const rel = path.join(relative, entry.name);
882
+ if (typeof filtered === "function" ? !filtered(file) : filtered && !snapshotPayloadAllowed(file))
883
+ continue;
350
884
  if (entry.isDirectory())
351
885
  visit(file, rel);
352
886
  else if (entry.isFile()) {
@@ -362,13 +896,58 @@ function treeHash(root) {
362
896
  visit(root, "");
363
897
  return hash.digest("hex");
364
898
  }
899
+ function treeModesHash(root, filtered = false) {
900
+ const entries = [];
901
+ const visit = (directory) => {
902
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
903
+ const file = path.join(directory, entry.name);
904
+ if (typeof filtered === "function" ? !filtered(file) : filtered && !snapshotPayloadAllowed(file))
905
+ continue;
906
+ if (entry.isDirectory())
907
+ visit(file);
908
+ else if (entry.isFile())
909
+ entries.push([path.relative(root, file), fs.statSync(file).mode & 0o111]);
910
+ }
911
+ };
912
+ visit(root);
913
+ return crypto.createHash("sha256").update(JSON.stringify(entries)).digest("hex");
914
+ }
915
+ function verifySnapshotModes(snapshot, manifest) {
916
+ if (!manifest.payload_modes)
917
+ return; // Legacy snapshots did not attest modes.
918
+ for (const name of ["project", "runtime", ...(manifest.workspace.kind === "git_worktree" ? ["workspace"] : [])]) {
919
+ if (treeModesHash(path.join(snapshot, name)) !== manifest.payload_modes[name])
920
+ throw new AppError("snapshot_checksum_mismatch", "Snapshot executable modes do not match manifest", 1, { payload: name });
921
+ }
922
+ }
365
923
  function fileHash(file) { return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); }
924
+ // Flush children before their directory entries, then publish and flush the
925
+ // parent. Never follow snapshot symlinks into the original project tree.
926
+ function syncSnapshotTree(root) {
927
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
928
+ const file = path.join(root, entry.name);
929
+ if (entry.isDirectory())
930
+ syncSnapshotTree(file);
931
+ else if (entry.isFile())
932
+ syncPath(file);
933
+ }
934
+ syncPath(root);
935
+ }
936
+ function syncPath(file) {
937
+ const descriptor = fs.openSync(file, "r");
938
+ try {
939
+ fs.fsyncSync(descriptor);
940
+ }
941
+ finally {
942
+ fs.closeSync(descriptor);
943
+ }
944
+ }
366
945
  function readManifest(snapshot) {
367
946
  const file = path.join(snapshot, "snapshot.json");
368
947
  if (!fs.existsSync(file))
369
948
  throw new AppError("snapshot_missing", "Snapshot manifest is missing", 1, { snapshot });
370
949
  const value = JSON.parse(fs.readFileSync(file, "utf8"));
371
- 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"].includes(value.purpose) || (value.purpose === "stage_entry" && !value.stage_entry) || (value.purpose === "candidate" && value.stage_entry !== null) || !value.runtime_sha256 || !value.project_sha256 || !value.project_git || !value.workspace)
950
+ 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)
372
951
  throw new AppError("snapshot_invalid", "Snapshot manifest is invalid", 1, { snapshot });
373
952
  if (!value.project_git.branch || !value.project_git.head || !value.project_git.bundle_sha256)
374
953
  throw new AppError("snapshot_invalid", "Snapshot project Git metadata is incomplete", 1, { snapshot });