@deksden-com/dd-flow-cli 0.9.0-beta.1 → 0.9.0-beta.100

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 (153) hide show
  1. package/CHANGELOG.md +561 -0
  2. package/README.md +68 -0
  3. package/dist/build-info.json +8 -8
  4. package/dist/cli/command-inputs.js +305 -0
  5. package/dist/cli/help.js +91 -15
  6. package/dist/cli/hook-ingress.js +82 -0
  7. package/dist/cli/input-preparation.js +116 -0
  8. package/dist/cli/run-cli.js +1506 -334
  9. package/dist/cli.js +6 -2
  10. package/dist/harness-runtime/DD-ZCODE.md +99 -0
  11. package/dist/harness-runtime/bin/dd-agy.mjs +39 -0
  12. package/dist/harness-runtime/bin/dd-codex.mjs +25 -0
  13. package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
  14. package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
  15. package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
  16. package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
  17. package/dist/harness-runtime/lib/daemon-operations.mjs +215 -0
  18. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +449 -0
  19. package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
  20. package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
  21. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +146 -0
  22. package/dist/harness-runtime/lib/dd-codex.mjs +562 -0
  23. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
  24. package/dist/harness-runtime/lib/dd-droid.mjs +468 -0
  25. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +300 -0
  26. package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
  27. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +217 -0
  28. package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
  29. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +644 -0
  30. package/dist/harness-runtime/lib/dd-zcode.mjs +941 -0
  31. package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
  32. package/dist/harness-runtime/lib/delegation-instructions.mjs +130 -0
  33. package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
  34. package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
  35. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  36. package/dist/harness-runtime/lib/managed-daemon.mjs +267 -0
  37. package/dist/harness-runtime/lib/model-observations.mjs +77 -0
  38. package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
  39. package/dist/harness-runtime/lib/native-hook-command.mjs +34 -0
  40. package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
  41. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  42. package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
  43. package/dist/harness-runtime/lib/process-json.mjs +70 -0
  44. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  45. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  46. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  47. package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
  48. package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
  49. package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
  50. package/dist/harness-runtime/lib/zcode-usage.mjs +54 -0
  51. package/dist/runtime/context.js +3 -1
  52. package/dist/schemas/agent-profile.schema.json +1 -1
  53. package/dist/schemas/code-review-result.schema.json +2 -2
  54. package/dist/schemas/code-work-batch.schema.json +4 -3
  55. package/dist/schemas/code-work-result.schema.json +4 -4
  56. package/dist/schemas/harness-config.schema.json +23 -0
  57. package/dist/schemas/plan-review-decision.schema.json +1 -1
  58. package/dist/schemas/plan-review-result.schema.json +2 -2
  59. package/dist/schemas/run-control-receipt.schema.json +99 -0
  60. package/dist/schemas/run-control-request.schema.json +36 -0
  61. package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
  62. package/dist/services/canon.js +11 -8
  63. package/dist/services/cleanup.js +75 -21
  64. package/dist/services/cli-operation-classifier.js +22 -24
  65. package/dist/services/code-checks.js +676 -79
  66. package/dist/services/codex-hook-delivery.js +28 -0
  67. package/dist/services/command-context.js +80 -0
  68. package/dist/services/config.js +11 -8
  69. package/dist/services/continuation-outcome.js +20 -0
  70. package/dist/services/controller-fanout.js +134 -0
  71. package/dist/services/dashboard.js +103 -31
  72. package/dist/services/engines.js +125 -80
  73. package/dist/services/eval-snapshots.js +838 -74
  74. package/dist/services/execution-policy.js +170 -0
  75. package/dist/services/external-work-launch.js +110 -0
  76. package/dist/services/harness-adapter.js +147 -24
  77. package/dist/services/harness-config.js +68 -0
  78. package/dist/services/hooks.js +609 -310
  79. package/dist/services/lanes.js +62 -53
  80. package/dist/services/lifecycle-command.js +103 -17
  81. package/dist/services/lifecycle-contract.js +30 -0
  82. package/dist/services/lifecycle-invocations.js +1128 -0
  83. package/dist/services/managed-daemon-binding.js +38 -0
  84. package/dist/services/managed-processes.js +425 -0
  85. package/dist/services/merge-queue.js +179 -104
  86. package/dist/services/merge-server.js +78 -27
  87. package/dist/services/migrations.js +14 -9
  88. package/dist/services/native-daemon-history.js +49 -0
  89. package/dist/services/native-session-control.js +39 -0
  90. package/dist/services/plan-runtime.js +19 -8
  91. package/dist/services/plans.js +28 -25
  92. package/dist/services/portable-refs.js +57 -0
  93. package/dist/services/projects.js +18 -5
  94. package/dist/services/prompts.js +27 -18
  95. package/dist/services/protocols.js +64 -20
  96. package/dist/services/recovery-observation-budget.js +61 -0
  97. package/dist/services/recovery-snapshot-database.js +107 -0
  98. package/dist/services/repair-intents.js +40 -0
  99. package/dist/services/run-control-receipt.js +56 -0
  100. package/dist/services/run-control-worker.js +363 -0
  101. package/dist/services/run-control.js +876 -0
  102. package/dist/services/run-controller-adapter.js +222 -0
  103. package/dist/services/run-controller-capture.js +136 -0
  104. package/dist/services/run-controller-process.js +189 -0
  105. package/dist/services/run-controller-recovery.js +222 -0
  106. package/dist/services/run-controller-state.js +36 -0
  107. package/dist/services/run-controller.js +803 -0
  108. package/dist/services/run-engine-bindings.js +20 -62
  109. package/dist/services/run-fork.js +138 -0
  110. package/dist/services/run-observations.js +112 -0
  111. package/dist/services/run-projection.js +10 -8
  112. package/dist/services/run-recovery-runtime.js +69 -0
  113. package/dist/services/run-recovery.js +343 -0
  114. package/dist/services/runs.js +437 -81
  115. package/dist/services/runtime-budget.js +439 -0
  116. package/dist/services/runtime-command.js +43 -0
  117. package/dist/services/runtime-scope-capture.js +50 -0
  118. package/dist/services/runtime-scope-control.js +450 -0
  119. package/dist/services/runtime-scope-resume.js +565 -0
  120. package/dist/services/runtime-scope-stop.js +99 -0
  121. package/dist/services/runtime-scope-worker.js +243 -0
  122. package/dist/services/runtime-service.js +97 -0
  123. package/dist/services/schema-validation.js +21 -19
  124. package/dist/services/session-identity.js +19 -0
  125. package/dist/services/sessions.js +61 -80
  126. package/dist/services/stage-blocker.js +17 -7
  127. package/dist/services/stage-context.js +44 -18
  128. package/dist/services/stage-lifecycle.js +112 -108
  129. package/dist/services/stage-pause.js +117 -73
  130. package/dist/services/stage-work-graph.js +35 -0
  131. package/dist/services/usage.js +108 -132
  132. package/dist/services/vnext-code-review.js +245 -98
  133. package/dist/services/vnext-code.js +355 -257
  134. package/dist/services/vnext-execution-profile.js +5 -3
  135. package/dist/services/vnext-fanout.js +166 -26
  136. package/dist/services/vnext-merge.js +413 -147
  137. package/dist/services/vnext-plan-review.js +182 -109
  138. package/dist/services/vnext-plan.js +199 -82
  139. package/dist/services/vnext-protocolize.js +133 -60
  140. package/dist/services/vnext-specify.js +94 -71
  141. package/dist/services/work-registry.js +813 -160
  142. package/dist/services/workspace-bootstrap.js +76 -0
  143. package/dist/services/worktrees.js +85 -35
  144. package/dist/shared/entity-references.js +8 -0
  145. package/dist/shared/errors.js +12 -0
  146. package/dist/storage/database.js +569 -16
  147. package/dist/storage/paths.js +17 -4
  148. package/dist/storage/work-references.js +23 -0
  149. package/dist/storage/writer-contract.js +77 -0
  150. package/dist/storage/writer-migration.js +102 -0
  151. package/package.json +24 -13
  152. package/tools/audit-runtime-fix-boundaries.mjs +96 -0
  153. package/tools/repair-paused-run-status.mjs +59 -0
@@ -2,14 +2,21 @@ 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";
17
+ import { prepareOutputFile } from "../cli/input-preparation.js";
11
18
  /** Capture the only flow boundary which exists before `stage start --bootstrap` creates a RUN. */
12
- export function createEvalBootstrapSnapshot(context, input) {
19
+ export function prepareEvalBootstrapSnapshot(context, input) {
13
20
  const projectRoot = resolveProjectRoot(input.projectRoot);
14
21
  const output = path.resolve(input.output);
15
22
  if (fs.existsSync(output))
@@ -18,8 +25,21 @@ export function createEvalBootstrapSnapshot(context, input) {
18
25
  throw new AppError("snapshot_output_inside_project", "Bootstrap snapshot output must be outside the project tree", 1, { output });
19
26
  if (isInside(physicalPath(context.ddFlowHome), physicalPath(output)))
20
27
  throw new AppError("snapshot_output_inside_runtime", "Bootstrap snapshot output must be outside DD_FLOW_HOME", 1, { output });
28
+ prepareOutputFile(output);
29
+ try {
30
+ prepareSnapshotProjectGit(projectRoot);
31
+ }
32
+ catch (error) {
33
+ if (!(error instanceof AppError) || error.code !== "snapshot_project_git_invalid")
34
+ throw error;
35
+ throw new AppError(error.code, error.message, 2, { ...error.details, phase: "prepare", effect: "no_effect", recoverable: true });
36
+ }
37
+ return { projectRoot, output };
38
+ }
39
+ export function createEvalBootstrapSnapshot(context, input) {
40
+ const { projectRoot, output } = prepareEvalBootstrapSnapshot(context, input);
21
41
  fs.mkdirSync(output, { recursive: true });
22
- fs.cpSync(projectRoot, path.join(output, "project"), { recursive: true, verbatimSymlinks: true, filter: (source) => path.basename(source) !== ".git" });
42
+ fs.cpSync(projectRoot, path.join(output, "project"), { recursive: true, verbatimSymlinks: true, filter: snapshotPayloadAllowed });
23
43
  const manifest = {
24
44
  schema_id: "dd-eval/bootstrap-snapshot@1",
25
45
  stage: "specify",
@@ -31,31 +51,101 @@ export function createEvalBootstrapSnapshot(context, input) {
31
51
  return { ok: true, snapshot: output, ...manifest };
32
52
  }
33
53
  /** Restore a pre-RUN project state; `stage start --bootstrap` creates runtime state afterwards. */
34
- export function restoreEvalBootstrapSnapshot(_context, input) {
54
+ export function prepareEvalBootstrapRestore(input) {
35
55
  const snapshot = path.resolve(input.snapshot);
36
56
  const manifest = readBootstrapManifest(snapshot);
37
57
  const projectRoot = resolveProjectRoot(input.projectRoot);
58
+ assertRestoreTargetSeparation(snapshot, [projectRoot]);
38
59
  const sourceProject = path.join(snapshot, "project");
39
- if (treeHash(sourceProject) !== manifest.project_sha256)
40
- throw new AppError("snapshot_checksum_mismatch", "Bootstrap project checksum does not match manifest", 1, { snapshot });
60
+ verifyGitSnapshotPayload(snapshot, { name: "project", hash: manifest.project_sha256, git: manifest.project_git });
61
+ return { snapshot, manifest, projectRoot, sourceProject };
62
+ }
63
+ export function restoreEvalBootstrapSnapshot(_context, input, prepared = prepareEvalBootstrapRestore(input)) {
64
+ const { snapshot, manifest, projectRoot, sourceProject } = prepared;
65
+ assertRestoreTargetSeparation(snapshot, [projectRoot]);
41
66
  clearProjectTree(projectRoot, false);
42
67
  restoreProjectGit(snapshot, manifest.project_git, projectRoot);
43
68
  replaceProjectTree(projectRoot, sourceProject);
44
69
  return { ok: true, schema_id: "dd-eval/bootstrap-restore@1", snapshot, project_root: projectRoot, target_stage: "specify" };
45
70
  }
46
71
  /**
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.
72
+ * Eval fixtures require a dedicated home. Sealed recovery exports instead
73
+ * retain only the selected RUN's relational and filesystem evidence.
50
74
  */
51
75
  export function createEvalRunSnapshot(context, input) {
76
+ const { output } = prepareEvalRunSnapshotCreation(context, input);
77
+ const temporary = `${output}.partial-${crypto.randomUUID()}`;
78
+ let sourceVersion = null;
79
+ try {
80
+ input.boundary?.verify();
81
+ sourceVersion = input.boundary ? snapshotSourceVersion(context, input) : null;
82
+ const result = captureEvalRunSnapshot(context, { ...input, output: temporary });
83
+ if (input.boundary && sourceVersion) {
84
+ 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.sha256, observation: input.boundary.observation } });
85
+ writeJson(path.join(temporary, "snapshot.json"), Object.fromEntries(Object.entries(result).filter(([key]) => key !== "ok" && key !== "snapshot")));
86
+ }
87
+ if (input.recoveryId) {
88
+ const projectRoot = resolveProjectRoot(input.projectRoot);
89
+ const project = requireProjectByRoot(context, projectRoot);
90
+ const run = context.db.get("SELECT workspace_root FROM runs WHERE project_id = ? AND id = ?", [project.id, input.runId]);
91
+ verifyRecoverySnapshot(temporary, { runId: input.runId, recoveryId: input.recoveryId, projectRoot, workspaceRoot: run?.workspace_root ?? projectRoot });
92
+ }
93
+ if (fs.existsSync(output))
94
+ throw new AppError("snapshot_exists", "Snapshot output already exists", 1, { output });
95
+ syncSnapshotTree(temporary);
96
+ if (input.boundary) {
97
+ if (sourceVersion) {
98
+ let after;
99
+ try {
100
+ after = snapshotSourceVersion(context, input, sourceVersion.databases);
101
+ }
102
+ catch (error) {
103
+ if (error.code !== "ENOENT")
104
+ throw error;
105
+ const missing = error.path;
106
+ throw new AppError("snapshot_source_changed", "Snapshot source disappeared during boundary capture", 1, { components: [missing && path.isAbsolute(missing) && isInside(context.ddFlowHome, missing) ? `runtime/${path.relative(context.ddFlowHome, missing)}` : "source_path_disappeared"] });
107
+ }
108
+ if (sourceVersion.sha256 !== after.sha256)
109
+ throw new AppError("snapshot_source_changed", "Snapshot source files or Git state changed during boundary capture", 1, { components: snapshotSourceChanges(sourceVersion.entries, after.entries), changes: snapshotSourceChangeProofs(sourceVersion.entries, after.entries) });
110
+ }
111
+ input.boundary.verify();
112
+ }
113
+ fs.renameSync(temporary, output);
114
+ syncPath(path.dirname(output));
115
+ return { ...result, snapshot: output };
116
+ }
117
+ catch (error) {
118
+ fs.rmSync(temporary, { recursive: true, force: true });
119
+ throw error;
120
+ }
121
+ finally {
122
+ for (const db of sourceVersion?.databases.values() ?? [])
123
+ db.close();
124
+ }
125
+ }
126
+ export function prepareEvalRunSnapshotCreation(context, input) {
127
+ if (Number(Boolean(input.candidate)) + Number(Boolean(input.incomplete)) + Number(input.stageEntry !== undefined) + Number(input.recoveryId !== undefined) !== 1)
128
+ throw new AppError("usage", "Use exactly one of candidate, incomplete, recoveryId or stageEntry", 2, { phase: "prepare", effect: "no_effect", recoverable: true });
52
129
  const projectRoot = resolveProjectRoot(input.projectRoot);
53
130
  // Snapshot is read-only at the control-plane boundary. An invalid workspace
54
131
  // root must fail without registering a second project in a dedicated home.
55
132
  const project = requireProjectByRoot(context, projectRoot);
56
133
  const status = getFlowRunStatus(context, { projectRoot, runId: input.runId });
57
- assertDedicatedHome(context, project.id, input.runId);
58
- if (input.candidate)
134
+ const recovery = typeof input.recoveryId === "string";
135
+ if (!recovery && !input.incomplete && context.db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'run_controllers'")) {
136
+ 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]);
137
+ 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))
138
+ 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 });
139
+ }
140
+ if (!recovery)
141
+ assertDedicatedHome(context, project.id, input.runId);
142
+ if (recovery) {
143
+ 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]);
144
+ if (!guard || guard.status !== "sealed")
145
+ 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 });
146
+ }
147
+ if (input.incomplete || recovery) { /* Recovery captures only after the runner seals all writers. */ }
148
+ else if (input.candidate)
59
149
  assertTerminalCandidate(status.index.stage_runs ?? []);
60
150
  else if (input.stageEntry)
61
151
  assertStageEntry(status.index.stage_runs ?? [], input.stageEntry);
@@ -65,7 +155,7 @@ export function createEvalRunSnapshot(context, input) {
65
155
  const activeChildren = input.candidate
66
156
  ? 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
157
  : 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)
158
+ if (!input.incomplete && !recovery && activeChildren > 0)
69
159
  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
160
  const output = path.resolve(input.output);
71
161
  if (fs.existsSync(output))
@@ -75,33 +165,88 @@ export function createEvalRunSnapshot(context, input) {
75
165
  const workspaceRoot = String(status.run.workspace_root ?? projectRoot);
76
166
  if (isInside(physicalPath(projectRoot), physicalPath(output)) || isInside(physicalPath(workspaceRoot), physicalPath(output)))
77
167
  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
- filter: (source) => path.basename(source) !== "engines"
84
- });
168
+ prepareOutputFile(output);
169
+ prepareSnapshotProjectGit(projectRoot);
170
+ prepareSnapshotWorkspace(workspaceRoot, projectRoot);
171
+ return { projectRoot, project, status, recovery, output, workspaceRoot, activeChildren };
172
+ }
173
+ function captureEvalRunSnapshot(context, input) {
174
+ const { projectRoot, project, status, recovery, output, workspaceRoot, activeChildren } = prepareEvalRunSnapshotCreation(context, input);
175
+ fs.mkdirSync(output, { recursive: true, mode: 0o700 });
176
+ const resourceDatabase = path.join(context.ddFlowHome, "runtime.sqlite");
177
+ const databaseFiles = [context.db.path, resourceDatabase].flatMap(file => [file, `${file}-wal`, `${file}-shm`]);
178
+ const codexExclusions = codexHomeSnapshotExclusions(context, project.id, input.runId);
179
+ if (recovery) {
180
+ 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)))
181
+ throw new AppError("snapshot_run_scope_invalid", "Recovery requires a RUN artifact root within its runtime", 1);
182
+ for (const source of new Set([status.run.run_root, runtimeRunDir(context.ddFlowHome, project.id, input.runId)])) {
183
+ if (fs.existsSync(source))
184
+ copyRuntimeTree(source, path.join(output, "runtime", path.relative(context.ddFlowHome, source)), true, codexExclusions);
185
+ }
186
+ }
187
+ else
188
+ copyRuntimeTree(context.ddFlowHome, path.join(output, "runtime"), false, [...databaseFiles, ...codexExclusions]);
189
+ // VACUUM INTO is a consistent SQLite read snapshot, including committed WAL
190
+ // pages. Copying the live database and its WAL separately is not atomic.
191
+ const databaseCopy = path.join(output, "runtime", path.relative(context.ddFlowHome, context.db.path));
192
+ fs.mkdirSync(path.dirname(databaseCopy), { recursive: true });
193
+ if (recovery) {
194
+ 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;
195
+ if (report && !isInside(physicalPath(path.join(output, "runtime")), physicalPath(report)))
196
+ throw new AppError("snapshot_protocol_scope_invalid", "Recovery protocol evidence is outside the captured runtime", 1);
197
+ const protocols = report && fs.existsSync(report) ? JSON.parse(fs.readFileSync(report, "utf8")).semantic?.acceptance : [];
198
+ if (!Array.isArray(protocols) || !protocols.every(id => typeof id === "string" && id.startsWith("PRT-")))
199
+ throw new AppError("snapshot_protocol_scope_invalid", "Recovery export requires the accepted PROTOCOLIZE protocol IDs", 1);
200
+ captureRecoveryDatabase(context.db, databaseCopy, project.id, input.runId, protocols);
201
+ }
202
+ else
203
+ context.db.exec(`VACUUM INTO '${databaseCopy.replace(/'/g, "''")}'`);
204
+ // Resource heartbeats can continue after productive RUN writers are sealed.
205
+ // Keep their committed evidence consistent too; never copy a live WAL pair.
206
+ if (!recovery && fs.existsSync(resourceDatabase)) {
207
+ const resources = new DatabaseSync(resourceDatabase, { readOnly: true });
208
+ try {
209
+ const resourceCopy = path.join(output, "runtime", "runtime.sqlite");
210
+ resources.exec("PRAGMA busy_timeout = 4000");
211
+ resources.exec(`VACUUM INTO '${resourceCopy.replace(/'/g, "''")}'`);
212
+ }
213
+ finally {
214
+ resources.close();
215
+ }
216
+ }
85
217
  fs.cpSync(projectRoot, path.join(output, "project"), {
86
218
  recursive: true,
87
219
  verbatimSymlinks: true,
88
- filter: (source) => path.basename(source) !== ".git"
220
+ filter: snapshotPayloadAllowed
89
221
  });
90
222
  const projectGit = snapshotProjectGit(projectRoot, output);
91
223
  const workspace = snapshotWorkspace(workspaceRoot, projectRoot, output);
224
+ // Accepted Work results are captured in SQLite. Their file representations
225
+ // are retained evidence, not a second authority or a checkpoint gate.
226
+ if (recovery) {
227
+ const copiedContext = createContext({ ...context.env, DD_FLOW_HOME: path.join(output, "runtime") }, "read_existing");
228
+ try {
229
+ captureRecoveryAuxiliary(context, copiedContext, { projectId: project.id, runId: input.runId, output });
230
+ }
231
+ finally {
232
+ copiedContext.db.close?.();
233
+ }
234
+ }
92
235
  const manifest = {
93
236
  schema_id: "dd-flow/eval-run-snapshot@5",
94
237
  run_id: input.runId,
95
238
  project_id: project.id,
96
239
  project_root: projectRoot,
97
240
  dd_flow_home: context.ddFlowHome,
98
- purpose: input.candidate ? "candidate" : "stage_entry",
99
- stage_entry: input.candidate ? null : input.stageEntry,
241
+ purpose: input.incomplete ? "incomplete" : recovery ? "recovery" : input.candidate ? "candidate" : "stage_entry",
242
+ stage_entry: input.incomplete || recovery || input.candidate ? null : input.stageEntry,
243
+ ...(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" } : {}),
100
244
  created_at: context.now(),
101
245
  runtime_sha256: treeHash(path.join(output, "runtime")),
102
246
  project_sha256: treeHash(path.join(output, "project")),
103
247
  project_git: projectGit,
104
248
  workspace,
249
+ payload_modes: { project: treeModesHash(path.join(output, "project")), runtime: treeModesHash(path.join(output, "runtime")), ...(workspace.kind === "git_worktree" ? { workspace: treeModesHash(path.join(output, "workspace")) } : {}) },
105
250
  source_status: status
106
251
  };
107
252
  writeJson(path.join(output, "snapshot.json"), manifest);
@@ -115,40 +260,151 @@ function createdWorkSchemaForStage(stage) {
115
260
  return "dd-flow/merge-result";
116
261
  return null;
117
262
  }
118
- export function restoreEvalRunSnapshot(context, input) {
119
- const snapshot = path.resolve(input.snapshot);
263
+ /** Validate sealed source data before any target cleanup or copy. */
264
+ export function prepareEvalRunSnapshot(snapshot) {
120
265
  const manifest = readManifest(snapshot);
266
+ if (manifest.purpose === "incomplete")
267
+ throw new AppError("snapshot_incomplete_not_restorable", "Incomplete snapshot is forensic evidence, not a stage-entry fixture", 1, { snapshot });
268
+ if (manifest.purpose === "candidate")
269
+ throw new AppError("snapshot_candidate_not_restorable", "A terminal candidate snapshot is evidence, not a stage-entry fixture", 1, { snapshot });
270
+ verifySnapshotPayload(snapshot, manifest);
271
+ if (manifest.purpose === "stage_entry") {
272
+ const db = new DatabaseSync(path.join(snapshot, "runtime", "db.sqlite"), { readOnly: true });
273
+ try {
274
+ assertSnapshotRuntimeDetachAllowed(sql => db.prepare(sql).get());
275
+ const row = db.prepare("SELECT index_json FROM runs WHERE id = ?").get(manifest.run_id);
276
+ if (!row)
277
+ throw new AppError("snapshot_invalid", "Snapshot RUN is not registered", 1);
278
+ const index = JSON.parse(row.index_json);
279
+ if (!Array.isArray(index?.stage_runs))
280
+ throw new AppError("snapshot_invalid", "Snapshot RUN index is invalid", 1);
281
+ assertStageEntry(index.stage_runs, manifest.stage_entry);
282
+ }
283
+ finally {
284
+ db.close();
285
+ }
286
+ }
287
+ return manifest;
288
+ }
289
+ export function prepareEvalRunRestore(context, input, preparedManifest) {
290
+ const snapshot = path.resolve(input.snapshot);
291
+ const manifest = preparedManifest ?? prepareEvalRunSnapshot(snapshot);
292
+ if (manifest.purpose === "incomplete")
293
+ throw new AppError("snapshot_incomplete_not_restorable", "Incomplete snapshot is forensic evidence, not a stage-entry fixture", 1, { snapshot });
294
+ const recovery = manifest.purpose === "recovery";
295
+ if (recovery && input.executionRoutingFile !== undefined)
296
+ throw new AppError("snapshot_recovery_policy_frozen", "Recovery must retain its frozen execution policy", 2);
297
+ if (recovery && input.recoveryId !== manifest.recovery_id)
298
+ throw new AppError("snapshot_recovery_requires_explicit_restore", "Recovery snapshots require their explicit --recovery-id", 1, { snapshot });
299
+ if (!recovery && input.recoveryId)
300
+ throw new AppError("snapshot_invalid", "Recovery ID cannot restore a non-recovery snapshot", 1, { snapshot });
121
301
  if (manifest.purpose === "candidate")
122
302
  throw new AppError("snapshot_candidate_not_restorable", "A terminal candidate snapshot is evidence, not a stage-entry fixture", 1, { snapshot });
123
- if (!manifest.stage_entry)
303
+ if (!recovery && !manifest.stage_entry)
124
304
  throw new AppError("snapshot_invalid", "Stage-entry snapshot has no target stage", 1, { snapshot });
125
305
  const projectRoot = resolveProjectRoot(input.projectRoot);
126
- const existing = fs.readdirSync(context.ddFlowHome).filter((name) => !["db.sqlite", "db.sqlite-wal", "db.sqlite-shm"].includes(name));
127
- if (existing.length > 0)
128
- throw new AppError("restore_home_not_empty", "Snapshot restore requires an empty DD_FLOW_HOME", 1, { dd_flow_home: context.ddFlowHome, existing });
306
+ assertRunRestoreTargets(context, input, snapshot, projectRoot, manifest);
307
+ if (recovery)
308
+ verifyRecoverySnapshot(snapshot, { runId: manifest.run_id, recoveryId: input.recoveryId, projectRoot, workspaceRoot: projectRoot }, false);
129
309
  const sourceRuntime = path.join(snapshot, "runtime");
130
310
  const sourceProject = path.join(snapshot, "project");
131
- if (treeHash(sourceRuntime) !== manifest.runtime_sha256)
132
- throw new AppError("snapshot_checksum_mismatch", "Snapshot runtime checksum does not match manifest", 1, { snapshot });
133
- if (treeHash(sourceProject) !== manifest.project_sha256)
134
- throw new AppError("snapshot_checksum_mismatch", "Snapshot project checksum does not match manifest", 1, { snapshot });
311
+ const routingInput = input.executionRoutingFile !== undefined ? loadExecutionRoutingInput(input.executionRoutingFile) : undefined;
312
+ let selectedProfile;
313
+ if (routingInput) {
314
+ // Resolve selected profiles before replacing the caller's fresh runtime.
315
+ const sourceDb = new DatabaseSync(path.join(sourceRuntime, "db.sqlite"), { readOnly: true });
316
+ try {
317
+ const row = sourceDb.prepare("SELECT index_json FROM runs WHERE id = ?").get(manifest.run_id);
318
+ const index = row ? JSON.parse(row.index_json) : null;
319
+ if (!index?.execution_profile || !["vnext_specify", "vnext_protocolize"].includes(index.flow_kind))
320
+ throw new AppError("execution_profile_not_frozen", "Stage-entry routing requires a vNext execution snapshot", 1);
321
+ assertStageEntry(index.stage_runs, manifest.stage_entry);
322
+ selectedProfile = freezeRunExecutionRouting(context.ddFlowHome, index.execution_profile, routingInput);
323
+ for (const row of sourceDb.prepare("SELECT payload_json FROM works WHERE run_id = ? AND status = 'created'").all(manifest.run_id)) {
324
+ const payload = row.payload_json ? JSON.parse(row.payload_json) : null;
325
+ if (payload?.execution_override !== undefined)
326
+ resolveExecutionPolicy({ ddFlowHome: context.ddFlowHome, policy: routingInput.execution, profiles: selectedProfile.agent_profiles, stage: manifest.stage_entry, work: payload.execution_override });
327
+ }
328
+ selectedProfile.routing_input.artifact = `execution-routing.${routingInput.source_checksum}.input.json`;
329
+ const sourceRun = sourceDb.prepare("SELECT run_root FROM runs WHERE id = ?").get(manifest.run_id);
330
+ if (typeof sourceRun?.run_root !== "string" || !path.isAbsolute(sourceRun.run_root) || !isInside(manifest.dd_flow_home, sourceRun.run_root))
331
+ throw new AppError("snapshot_invalid", "Snapshot RUN root must be inside its runtime home", 1);
332
+ const existingRouting = path.join(sourceRuntime, path.relative(manifest.dd_flow_home, sourceRun.run_root), selectedProfile.routing_input.artifact);
333
+ if (fs.existsSync(existingRouting) && !fs.readFileSync(existingRouting).equals(routingInput.bytes))
334
+ throw new AppError("snapshot_checksum_mismatch", "Existing routing input does not match its checksum", 1);
335
+ }
336
+ finally {
337
+ sourceDb.close();
338
+ }
339
+ }
340
+ return { snapshot, manifest, recovery, projectRoot, sourceRuntime, sourceProject, routingInput, selectedProfile };
341
+ }
342
+ function assertRunRestoreTargets(context, input, snapshot, projectRoot, manifest) {
343
+ assertRestoreTargetSeparation(snapshot, [projectRoot, context.ddFlowHome]);
344
+ if (manifest.workspace.kind === "git_worktree") {
345
+ const workspaceRoot = restoredWorkspaceRoot(manifest, projectRoot, context.ddFlowHome);
346
+ assertRestoreTargetSeparation(snapshot, [workspaceRoot]);
347
+ if (isInside(physicalPath(projectRoot), physicalPath(workspaceRoot)) || isInside(physicalPath(workspaceRoot), physicalPath(projectRoot)) || physicalPath(workspaceRoot) === physicalPath(context.ddFlowHome))
348
+ throw new AppError("snapshot_path_conflict", "Feature workspace overlaps a restore target", 2);
349
+ if (fs.existsSync(workspaceRoot))
350
+ throw new AppError("snapshot_workspace_target_exists", "Snapshot feature workspace target already exists", 1, { workspace_root: workspaceRoot });
351
+ }
352
+ if (manifest.purpose === "recovery") {
353
+ if (fs.readdirSync(projectRoot).length || (context.db.get("SELECT COUNT(*) AS count FROM projects")?.count ?? 0) > 0)
354
+ throw new AppError("recovery_restore_target_not_empty", "Recovery restore requires fresh project and runtime targets", 1);
355
+ }
356
+ const existing = (fs.existsSync(context.ddFlowHome) ? fs.readdirSync(context.ddFlowHome) : []).filter((name) => !["db.sqlite", "db.sqlite-wal", "db.sqlite-shm", ...(input.executionRoutingFile !== undefined ? ["agent-profiles"] : [])].includes(name));
357
+ if (existing.length > 0)
358
+ throw new AppError("restore_home_not_empty", "Snapshot restore requires an empty DD_FLOW_HOME", 1, { dd_flow_home: context.ddFlowHome, existing });
359
+ if (context.db.get("SELECT 1 FROM projects LIMIT 1") || context.db.get("SELECT 1 FROM runs LIMIT 1"))
360
+ throw new AppError("restore_home_not_empty", "Snapshot restore cannot replace an initialized runtime", 1);
361
+ }
362
+ export function restoreEvalRunSnapshot(context, input, prepared = prepareEvalRunRestore(context, input)) {
363
+ const { snapshot, manifest, recovery, projectRoot, sourceRuntime, sourceProject, routingInput, selectedProfile } = prepared;
364
+ assertRunRestoreTargets(context, input, snapshot, projectRoot, manifest);
135
365
  clearProjectTree(projectRoot, false);
136
366
  restoreProjectGit(snapshot, manifest.project_git, projectRoot);
137
367
  replaceProjectTree(projectRoot, sourceProject);
138
- const workspaceRoot = restoreWorkspace(snapshot, manifest, projectRoot, context.ddFlowHome);
139
368
  context.db.close?.();
140
369
  fs.rmSync(context.ddFlowHome, { recursive: true, force: true });
141
370
  fs.cpSync(sourceRuntime, context.ddFlowHome, { recursive: true, verbatimSymlinks: true });
371
+ const workspaceRoot = restoreWorkspace(snapshot, manifest, projectRoot, context.ddFlowHome);
142
372
  // The dispatch context was intentionally closed before replacing db.sqlite.
143
373
  // A fresh process owns all subsequent normal commands.
144
374
  const restored = createContext({ ...context.env, DD_FLOW_HOME: context.ddFlowHome });
145
375
  try {
146
- rebaseRuntime(restored, manifest.project_root, projectRoot, sourceWorkspaceRoot(manifest), workspaceRoot, manifest.dd_flow_home, context.ddFlowHome);
376
+ const runtimeLineage = recovery ? null : detachSnapshotRuntimeOwners(restored, snapshot, manifest);
377
+ rebaseRuntime(restored, manifest.project_root, projectRoot, sourceWorkspaceRoot(manifest), workspaceRoot, manifest.dd_flow_home, context.ddFlowHome, recovery);
147
378
  registerProject(restored, { root: projectRoot });
148
- const project = requireProjectByRoot(restored, projectRoot);
379
+ requireProjectByRoot(restored, projectRoot);
380
+ if (selectedProfile && routingInput && runtimeLineage) {
381
+ const run = restored.db.get("SELECT run_root FROM runs WHERE id = ?", [manifest.run_id]);
382
+ const artifact = path.join(run.run_root, selectedProfile.routing_input.artifact);
383
+ if (fs.existsSync(artifact)) {
384
+ if (!fs.readFileSync(artifact).equals(routingInput.bytes))
385
+ throw new AppError("snapshot_checksum_mismatch", "Existing routing input does not match its checksum", 1);
386
+ }
387
+ else
388
+ fs.writeFileSync(artifact, routingInput.bytes, { flag: "wx", mode: 0o600 });
389
+ importFlowRunExecutionProfile(restored, { projectRoot, runId: manifest.run_id, stage: manifest.stage_entry, lineageFile: runtimeLineage, profile: selectedProfile });
390
+ }
149
391
  const status = getFlowRunStatus(restored, { projectRoot, runId: manifest.run_id });
150
- assertStageEntry(status.index.stage_runs ?? [], manifest.stage_entry);
151
- const runHome = status.run.run_home_path ?? path.join(context.ddFlowHome, "projects", project.id, "runs", manifest.run_id);
392
+ if (!recovery)
393
+ assertStageEntry(status.index.stage_runs ?? [], manifest.stage_entry);
394
+ else
395
+ restored.db.run("UPDATE run_recovery_guards SET capture_path = ? WHERE recovery_id = ? AND status = 'sealed'", [snapshot, input.recoveryId]);
396
+ if (!status.run.run_root)
397
+ throw new AppError("runtime_missing", "Restored RUN has no artifact root", 1, { run_id: manifest.run_id });
398
+ const runHome = status.run.run_root;
399
+ const relocation = recovery ? {
400
+ schema_id: "dd-flow/recovery-relocation@1", physical_runtime_id: crypto.randomUUID(), recovery_id: input.recoveryId, run_id: manifest.run_id,
401
+ path_mapping: [{ from: manifest.dd_flow_home, to: context.ddFlowHome }, { from: sourceWorkspaceRoot(manifest), to: workspaceRoot }, { from: manifest.project_root, to: projectRoot }]
402
+ } : null;
403
+ const relocationFile = relocation ? path.join(context.ddFlowHome, "recovery-imports", `${relocation.physical_runtime_id}.json`) : null;
404
+ if (relocationFile) {
405
+ fs.mkdirSync(path.dirname(relocationFile), { recursive: true });
406
+ writeJson(relocationFile, relocation);
407
+ }
152
408
  return {
153
409
  ok: true,
154
410
  schema_id: "dd-flow/eval-run-restore@1",
@@ -159,7 +415,9 @@ export function restoreEvalRunSnapshot(context, input) {
159
415
  dd_flow_home: context.ddFlowHome,
160
416
  run_home: runHome,
161
417
  target_stage: manifest.stage_entry,
162
- next_command: `dd-flow stage start ${status.run.id} --stage ${manifest.stage_entry} --project-root ${JSON.stringify(projectRoot)} --json`
418
+ ...(runtimeLineage ? { runtime_lineage_file: runtimeLineage } : {}),
419
+ ...(recovery ? { recovery_id: input.recoveryId, native_session_portability: "requires_adapter_verification", relocation_file: relocationFile, relocation } : {}),
420
+ 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`
163
421
  };
164
422
  }
165
423
  finally {
@@ -177,9 +435,10 @@ export function prepareVnextSpecifyRun(context, input) {
177
435
  subjectType: "discussion",
178
436
  subjectId: "SPECIFY",
179
437
  slug: input.slug,
438
+ executionRoutingFile: input.executionRoutingFile,
180
439
  nextAction: "start_specify"
181
440
  });
182
- return { ok: true, run_id: started.run.id, run_home: started.run.run_home_path, target_stage: "specify" };
441
+ return { ok: true, run_id: started.run.id, run_root: started.run.run_root, target_stage: "specify" };
183
442
  }
184
443
  function assertDedicatedHome(context, projectId, runId) {
185
444
  const projects = context.db.get("SELECT COUNT(*) AS count FROM projects")?.count ?? 0;
@@ -191,18 +450,75 @@ function assertDedicatedHome(context, projectId, runId) {
191
450
  function assertStageEntry(stages, target) {
192
451
  if (stages.some((entry) => entry.stage === target))
193
452
  throw new AppError("snapshot_target_started", "Stage-entry snapshot requires its target stage to be unstarted", 1, { target });
194
- const unfinished = stages.filter((entry) => entry.status !== "done");
453
+ const unfinished = stages.filter((entry) => !["done", "skipped"].includes(entry.status ?? ""));
195
454
  if (unfinished.length > 0)
196
455
  throw new AppError("snapshot_predecessor_incomplete", "Stage-entry snapshot requires completed predecessor stages", 1, { target, unfinished });
197
456
  }
198
457
  function assertTerminalCandidate(stages) {
199
- const unfinished = stages.filter((entry) => entry.status !== "done");
458
+ const unfinished = stages.filter((entry) => !["done", "skipped"].includes(entry.status ?? ""));
200
459
  if (unfinished.length > 0)
201
460
  throw new AppError("snapshot_candidate_incomplete", "Candidate snapshot requires every started stage to be done", 1, { unfinished });
202
461
  }
203
- function rebaseRuntime(context, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome) {
462
+ /** A stage-entry import is a new experiment, not a lease takeover or recovery.
463
+ * Keep source authority as evidence, never as executable target inventory. */
464
+ function assertSnapshotRuntimeDetachAllowed(get) {
465
+ if (get("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'run_recovery_guards'") && get("SELECT 1 FROM run_recovery_guards WHERE status IN ('draining','sealed','resuming') LIMIT 1"))
466
+ throw new AppError("snapshot_control_requires_recovery", "A fenced RUN requires explicit recovery, not a stage-entry fork", 1);
467
+ if (get("SELECT 1 FROM merge_requests WHERE status IN ('dispatching','active','waiting_user','action_required','recovery_required') LIMIT 1"))
468
+ throw new AppError("snapshot_execution_active", "A stage-entry fork cannot discard an unresolved MERGE execution", 1);
469
+ }
470
+ function detachSnapshotRuntimeOwners(context, snapshot, manifest) {
471
+ const hasTable = (table) => Boolean(context.db.get("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?", [table]));
472
+ assertSnapshotRuntimeDetachAllowed(sql => context.db.get(sql));
473
+ 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);
474
+ const runTables = Object.fromEntries([...detachedTables, "runs", "merge_requests", "sessions", "work_sessions"].map(table => [table, context.db.all(`SELECT * FROM ${table}`)]));
475
+ const resourceFile = path.join(context.ddFlowHome, "runtime.sqlite");
476
+ const resources = fs.existsSync(resourceFile) ? new DatabaseSync(resourceFile) : null;
477
+ try {
478
+ if (resources)
479
+ registerStoreWriter(resources);
480
+ 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()]));
481
+ const directory = path.join(context.ddFlowHome, "snapshot-imports");
482
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
483
+ const file = path.join(directory, `${crypto.randomUUID()}.json`);
484
+ 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 });
485
+ syncPath(file);
486
+ syncPath(directory);
487
+ context.db.beginWriteTransaction();
488
+ try {
489
+ context.db.run("UPDATE sessions SET metadata_json = json_set(metadata_json, '$.snapshot_import', json(?))", [JSON.stringify({ lineage_file: file, lineage_sha256: fileHash(file) })]);
490
+ for (const table of detachedTables)
491
+ context.db.exec(`DELETE FROM ${table}`);
492
+ 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");
493
+ // Only the copied dedicated registry is touched. Source/external homes
494
+ // are historical references in the archive and are never opened here.
495
+ if (resources) {
496
+ resources.exec("BEGIN IMMEDIATE");
497
+ try {
498
+ for (const table of Object.keys(resourceTables))
499
+ resources.exec(`DELETE FROM ${table}`);
500
+ resources.exec("COMMIT");
501
+ }
502
+ catch (error) {
503
+ resources.exec("ROLLBACK");
504
+ throw error;
505
+ }
506
+ }
507
+ context.db.exec("COMMIT");
508
+ }
509
+ catch (error) {
510
+ context.db.exec("ROLLBACK");
511
+ throw error;
512
+ }
513
+ return file;
514
+ }
515
+ finally {
516
+ resources?.close();
517
+ }
518
+ }
519
+ function rebaseRuntime(context, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome, recovery = false) {
204
520
  context.db.exec("PRAGMA foreign_keys = OFF");
205
- context.db.exec("BEGIN IMMEDIATE");
521
+ context.db.beginWriteTransaction();
206
522
  try {
207
523
  context.db.run("UPDATE projects SET root = ?", [newRoot]);
208
524
  context.db.run("UPDATE runs SET project_root = ?, workspace_root = ?", [newRoot, newWorkspace]);
@@ -211,7 +527,12 @@ function rebaseRuntime(context, oldRoot, newRoot, oldWorkspace, newWorkspace, ol
211
527
  context.db.run("UPDATE protocols SET project_root = ?", [newRoot]);
212
528
  for (const column of ["state_path", "plan_path"])
213
529
  rewriteDatabaseColumn(context, "protocols", column, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
214
- 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]);
530
+ for (const column of ["source_workspace", "target_workspace"])
531
+ rewriteDatabaseColumn(context, "merge_requests", column, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
532
+ if (recovery)
533
+ 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]);
534
+ else
535
+ context.db.run("UPDATE sessions SET project_root = ?, status = 'stopped', stop_reason = 'snapshot_restored', stopped_at = ?", [newRoot, context.now()]);
215
536
  for (const column of ["workspace_path", "cwd", "transcript_path"])
216
537
  rewriteDatabaseColumn(context, "sessions", column, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
217
538
  for (const column of ["prompt_path", "result_path"])
@@ -221,7 +542,10 @@ function rebaseRuntime(context, oldRoot, newRoot, oldWorkspace, newWorkspace, ol
221
542
  rewriteDatabaseColumn(context, "usage", "source_locator", oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
222
543
  for (const column of ["worktree_path", "worktrunk_metadata_json", "last_command_result_json"])
223
544
  rewriteDatabaseColumn(context, "worktree_records", column, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
224
- 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()]);
545
+ // Recovery imports remain sealed. Their old Work sessions are historical
546
+ // bindings, not completed work; explicit recovery closes them interrupted.
547
+ if (!recovery)
548
+ 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()]);
225
549
  context.db.run("DELETE FROM codex_session_bindings");
226
550
  context.db.run("COMMIT");
227
551
  }
@@ -232,24 +556,21 @@ function rebaseRuntime(context, oldRoot, newRoot, oldWorkspace, newWorkspace, ol
232
556
  finally {
233
557
  context.db.exec("PRAGMA foreign_keys = ON");
234
558
  }
235
- rewriteStructuredFiles(context.ddFlowHome, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
559
+ // Only operational database paths move. Imported packets, results and
560
+ // journals retain their historical bytes, including interrupted JSONL tails.
561
+ // The frozen workspace route is an operational receipt consumed by later
562
+ // stages, so its relocated path is the sole structured-file exception.
563
+ rewriteOperationalRouteReceipts(context.ddFlowHome, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
236
564
  }
237
- function rewriteStructuredFiles(root, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome) {
238
- const visit = (dir) => {
239
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
240
- const file = path.join(dir, entry.name);
241
- // DD_FLOW_HOME also contains the restored checkout and installed tools.
242
- // They are project/tool bytes, not flow records; parsing every *.json
243
- // there breaks on valid JSON-with-comments such as tsconfig files.
244
- if (entry.isDirectory() && ![".git", "checkouts", "engines", "node_modules", "tools"].includes(entry.name))
565
+ function rewriteOperationalRouteReceipts(root, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome) {
566
+ const visit = (directory) => {
567
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
568
+ const file = path.join(directory, entry.name);
569
+ if (entry.isDirectory())
245
570
  visit(file);
246
- else if (entry.isFile() && entry.name.endsWith(".json")) {
571
+ else if (entry.isFile() && entry.name === "workspace-route.json") {
247
572
  const value = JSON.parse(fs.readFileSync(file, "utf8"));
248
- writeJson(file, rebaseStructuredValue(value, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome));
249
- }
250
- else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
251
- const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean).map((line) => JSON.stringify(rebaseStructuredValue(JSON.parse(line), oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome)));
252
- fs.writeFileSync(file, `${lines.join("\n")}\n`);
573
+ fs.writeFileSync(file, `${JSON.stringify(rebaseStructuredValue(value, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome), null, 2)}\n`);
253
574
  }
254
575
  }
255
576
  };
@@ -269,20 +590,27 @@ function rebaseStructuredValue(value, oldRoot, newRoot, oldWorkspace, newWorkspa
269
590
  function rebasePathString(value, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome) { for (const [oldPrefix, newPrefix] of [[oldHome, newHome], [oldWorkspace, newWorkspace], [oldRoot, newRoot]])
270
591
  if (value === oldPrefix || value.startsWith(`${oldPrefix}${path.sep}`))
271
592
  return `${newPrefix}${value.slice(oldPrefix.length)}`; return value; }
272
- function snapshotWorkspace(workspaceRoot, projectRoot, output) {
593
+ function prepareSnapshotWorkspace(workspaceRoot, projectRoot) {
273
594
  if (samePath(workspaceRoot, projectRoot))
274
- return { kind: "project" };
595
+ return null;
275
596
  const branch = git(workspaceRoot, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
276
597
  if (!branch)
277
598
  throw new AppError("snapshot_workspace_invalid", "Feature workspace must be on a named Git branch", 1, { workspace_root: workspaceRoot });
278
599
  const baseRef = git(workspaceRoot, ["rev-parse", "HEAD"]);
279
600
  if (!baseRef)
280
601
  throw new AppError("snapshot_workspace_invalid", "Feature workspace has no readable HEAD", 1, { workspace_root: workspaceRoot });
602
+ return { branch, baseRef };
603
+ }
604
+ function snapshotWorkspace(workspaceRoot, projectRoot, output) {
605
+ const prepared = prepareSnapshotWorkspace(workspaceRoot, projectRoot);
606
+ if (!prepared)
607
+ return { kind: "project" };
608
+ const { branch, baseRef } = prepared;
281
609
  const workspace = path.join(output, "workspace");
282
610
  const bundle = path.join(output, "workspace.bundle");
283
- fs.cpSync(workspaceRoot, workspace, { recursive: true, verbatimSymlinks: true, filter: (source) => path.basename(source) !== ".git" });
611
+ fs.cpSync(workspaceRoot, workspace, { recursive: true, verbatimSymlinks: true, filter: snapshotPayloadAllowed });
284
612
  runGit(projectRoot, ["bundle", "create", bundle, branch]);
285
- return { kind: "git_worktree", source_root: workspaceRoot, branch, base_ref: baseRef, sha256: treeHash(workspace), bundle_sha256: fileHash(bundle) };
613
+ return { kind: "git_worktree", source_root: workspaceRoot, branch, base_ref: baseRef, sha256: treeHash(workspace), bundle_sha256: fileHash(bundle), ...snapshotIndex(workspaceRoot, output, "workspace") };
286
614
  }
287
615
  function restoreWorkspace(snapshot, manifest, projectRoot, ddFlowHome) {
288
616
  if (manifest.workspace.kind === "project")
@@ -293,11 +621,7 @@ function restoreWorkspace(snapshot, manifest, projectRoot, ddFlowHome) {
293
621
  throw new AppError("snapshot_checksum_mismatch", "Snapshot workspace checksum does not match manifest", 1, { snapshot });
294
622
  if (!fs.existsSync(bundle) || fileHash(bundle) !== manifest.workspace.bundle_sha256)
295
623
  throw new AppError("snapshot_checksum_mismatch", "Snapshot workspace Git bundle does not match manifest", 1, { snapshot });
296
- const managedRoot = physicalPath(manifest.dd_flow_home);
297
- const sourceRoot = physicalPath(manifest.workspace.source_root);
298
- const workspaceRoot = isInside(managedRoot, sourceRoot)
299
- ? path.join(ddFlowHome, path.relative(managedRoot, sourceRoot))
300
- : path.join(path.dirname(projectRoot), "workspace");
624
+ const workspaceRoot = restoredWorkspaceRoot(manifest, projectRoot, ddFlowHome);
301
625
  if (fs.existsSync(workspaceRoot))
302
626
  throw new AppError("snapshot_workspace_target_exists", "Snapshot feature workspace target already exists", 1, { workspace_root: workspaceRoot });
303
627
  runGit(projectRoot, ["fetch", bundle, `+refs/heads/${manifest.workspace.branch}:refs/heads/${manifest.workspace.branch}`]);
@@ -305,19 +629,38 @@ function restoreWorkspace(snapshot, manifest, projectRoot, ddFlowHome) {
305
629
  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 });
306
630
  runGit(projectRoot, ["worktree", "add", workspaceRoot, manifest.workspace.branch]);
307
631
  replaceProjectTree(workspaceRoot, source);
632
+ restoreIndex(snapshot, "workspace", manifest.workspace, workspaceRoot);
308
633
  return fs.realpathSync(workspaceRoot);
309
634
  }
635
+ function restoredWorkspaceRoot(manifest, projectRoot, ddFlowHome) {
636
+ if (manifest.workspace.kind === "project")
637
+ return projectRoot;
638
+ const managedRoot = physicalPath(manifest.dd_flow_home), sourceRoot = physicalPath(manifest.workspace.source_root);
639
+ return isInside(managedRoot, sourceRoot) ? path.join(ddFlowHome, path.relative(managedRoot, sourceRoot)) : path.join(path.dirname(projectRoot), "workspace");
640
+ }
641
+ function assertRestoreTargetSeparation(snapshot, targets) {
642
+ const roots = [snapshot, ...targets].map(physicalPath);
643
+ for (let i = 0; i < roots.length; i++)
644
+ for (let j = i + 1; j < roots.length; j++) {
645
+ if (isInside(roots[i], roots[j]) || isInside(roots[j], roots[i]))
646
+ throw new AppError("snapshot_path_conflict", "Snapshot source and restore targets must not overlap", 2, { source: roots[i], target: roots[j] });
647
+ }
648
+ }
310
649
  function sourceWorkspaceRoot(manifest) {
311
650
  return manifest.workspace.kind === "project" ? manifest.project_root : manifest.workspace.source_root;
312
651
  }
313
- function snapshotProjectGit(projectRoot, output) {
652
+ function prepareSnapshotProjectGit(projectRoot) {
314
653
  const branch = git(projectRoot, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
315
654
  const head = git(projectRoot, ["rev-parse", "HEAD"]);
316
655
  if (!branch || !head)
317
656
  throw new AppError("snapshot_project_git_invalid", "Snapshot requires a project on a named Git branch with a readable HEAD", 1, { project_root: projectRoot });
657
+ return { branch, head };
658
+ }
659
+ function snapshotProjectGit(projectRoot, output) {
660
+ const { branch, head } = prepareSnapshotProjectGit(projectRoot);
318
661
  const bundle = path.join(output, "project.bundle");
319
662
  runGit(projectRoot, ["bundle", "create", bundle, "--all"]);
320
- return { branch, head, bundle_sha256: fileHash(bundle) };
663
+ return { branch, head, bundle_sha256: fileHash(bundle), ...snapshotIndex(projectRoot, output, "project") };
321
664
  }
322
665
  function restoreProjectGit(snapshot, source, projectRoot) {
323
666
  const bundle = path.join(snapshot, "project.bundle");
@@ -327,6 +670,130 @@ function restoreProjectGit(snapshot, source, projectRoot) {
327
670
  runGit(projectRoot, ["init", "-b", source.branch]);
328
671
  runGit(projectRoot, ["fetch", bundle, `+refs/heads/${source.branch}:refs/remotes/snapshot/${source.branch}`]);
329
672
  runGit(projectRoot, ["checkout", "-B", source.branch, source.head]);
673
+ restoreIndex(snapshot, "project", source, projectRoot);
674
+ }
675
+ const mergeStateFiles = ["MERGE_HEAD", "MERGE_MSG", "MERGE_MODE", "ORIG_HEAD", "AUTO_MERGE"];
676
+ function indexEntries(root) {
677
+ const entries = Buffer.from(gitBytes(root, ["ls-files", "--stage", "-z"])
678
+ .toString("utf8").split("\0").filter(entry => entry && snapshotEnvironmentAllowed(entry.slice(entry.indexOf("\t") + 1)))
679
+ .map(entry => `${entry}\0`).join(""));
680
+ const merge = {};
681
+ for (const name of mergeStateFiles) {
682
+ const file = git(root, ["rev-parse", "--git-path", name]);
683
+ if (!file)
684
+ throw new AppError("snapshot_index_invalid", "Could not locate Git merge state", 1);
685
+ const resolved = path.resolve(root, file);
686
+ if (fs.existsSync(resolved))
687
+ merge[name] = fs.readFileSync(resolved).toString("base64");
688
+ }
689
+ return Buffer.from(JSON.stringify({ entries: entries.toString("base64"), merge }));
690
+ }
691
+ function snapshotIndex(root, output, name) {
692
+ const bytes = indexEntries(root);
693
+ const value = JSON.parse(bytes.toString());
694
+ const objects = new Set();
695
+ for (const entry of Buffer.from(value.entries, "base64").toString("utf8").split("\0").filter(Boolean)) {
696
+ const match = /^\d{6} ([a-f0-9]{40,64}) [0-3]\t/.exec(entry);
697
+ if (!match)
698
+ throw new AppError("snapshot_index_invalid", "Malformed Git index entry", 1);
699
+ // Gitlinks belong to another repository and are preserved as index IDs.
700
+ if (!entry.startsWith("160000 "))
701
+ objects.add(match[1]);
702
+ }
703
+ for (const name of ["MERGE_HEAD", "ORIG_HEAD", "AUTO_MERGE"]) {
704
+ if (value.merge[name])
705
+ for (const oid of Buffer.from(value.merge[name], "base64").toString().trim().split("\n")) {
706
+ if (!/^[a-f0-9]{40,64}$/.test(oid))
707
+ throw new AppError("snapshot_index_invalid", "Malformed Git merge reference", 1);
708
+ objects.add(oid);
709
+ }
710
+ }
711
+ const payload = path.join(output, `${name}.index.patch`);
712
+ const pack = path.join(output, `${name}.index.pack`);
713
+ fs.writeFileSync(payload, bytes);
714
+ fs.writeFileSync(pack, gitBytes(root, ["pack-objects", "--stdout", "--revs"], Buffer.from([...objects].sort().join("\n") + "\n")));
715
+ return { index_format: "entries@1", index_sha256: fileHash(payload), index_objects_sha256: fileHash(pack) };
716
+ }
717
+ function gitBytes(root, args, input) {
718
+ const result = spawnSync("git", ["-C", root, ...args], { input, maxBuffer: 64 * 1024 * 1024 });
719
+ if (result.status !== 0)
720
+ throw new AppError("snapshot_index_invalid", "Could not preserve Git index objects", 1, { args, stderr: result.stderr?.toString().trim() });
721
+ return result.stdout;
722
+ }
723
+ function indexPatch(root) {
724
+ const result = spawnSync("git", ["-C", root, "diff", "--cached", "--binary", "--full-index", "--no-ext-diff", "HEAD", "--", ".", ":(exclude)**/.env", ":(exclude)**/.env.*"], { maxBuffer: 64 * 1024 * 1024 });
725
+ if (result.status !== 0)
726
+ throw new AppError("snapshot_index_invalid", "Could not capture Git index", 1);
727
+ return result.stdout;
728
+ }
729
+ function restoreIndex(snapshot, name, metadata, root) {
730
+ if (!metadata.index_sha256)
731
+ return;
732
+ const file = path.join(snapshot, `${name}.index.patch`);
733
+ if (fileHash(file) !== metadata.index_sha256)
734
+ throw new AppError("snapshot_checksum_mismatch", "Snapshot Git index checksum does not match", 1);
735
+ if (metadata.index_format === "entries@1") {
736
+ const pack = path.join(snapshot, `${name}.index.pack`);
737
+ if (!metadata.index_objects_sha256 || fileHash(pack) !== metadata.index_objects_sha256)
738
+ throw new AppError("snapshot_checksum_mismatch", "Snapshot Git index object checksum does not match", 1);
739
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
740
+ gitBytes(root, ["unpack-objects"], fs.readFileSync(pack));
741
+ runGit(root, ["read-tree", "--empty"]);
742
+ gitBytes(root, ["update-index", "-z", "--index-info"], Buffer.from(value.entries, "base64"));
743
+ for (const name of mergeStateFiles) {
744
+ const target = git(root, ["rev-parse", "--git-path", name]);
745
+ if (!target)
746
+ throw new AppError("snapshot_index_invalid", "Could not locate restored Git merge state", 1);
747
+ if (value.merge[name] !== undefined)
748
+ fs.writeFileSync(path.resolve(root, target), Buffer.from(value.merge[name], "base64"));
749
+ else
750
+ fs.rmSync(path.resolve(root, target), { force: true });
751
+ }
752
+ return;
753
+ }
754
+ if (fs.statSync(file).size)
755
+ runGit(root, ["apply", "--cached", "--binary", file]);
756
+ }
757
+ export function verifyRecoverySnapshot(snapshot, input, checkWorkingState = true) {
758
+ const manifest = readManifest(snapshot);
759
+ if (manifest.purpose !== "recovery" || manifest.recovery_id !== input.recoveryId || manifest.run_id !== input.runId)
760
+ throw new AppError("recovery_capture_invalid", "Snapshot does not identify this recovery", 1);
761
+ const entries = [
762
+ { name: "project", root: input.projectRoot, hash: manifest.project_sha256, git: manifest.project_git, head: manifest.project_git.head },
763
+ ...(manifest.workspace.kind === "git_worktree" ? [{ name: "workspace", root: input.workspaceRoot, hash: manifest.workspace.sha256, git: manifest.workspace, head: manifest.workspace.base_ref }] : [])
764
+ ];
765
+ verifySnapshotPayload(snapshot, manifest);
766
+ for (const entry of entries) {
767
+ const expectedModes = manifest.payload_modes?.[entry.name];
768
+ if (checkWorkingState && expectedModes && treeModesHash(entry.root, true) !== expectedModes)
769
+ throw new AppError("recovery_workspace_drift", "Executable file modes changed after recovery capture", 1, { payload: entry.name });
770
+ 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)))
771
+ throw new AppError("recovery_workspace_drift", "Working files or Git state changed after recovery capture", 1, { payload: entry.name });
772
+ }
773
+ }
774
+ /** Reuse an already published boundary only after checking its identity and
775
+ * every payload, including its Git index objects. Never overwrite evidence. */
776
+ export function verifyControllerBoundarySnapshot(snapshot, input) {
777
+ const manifest = readManifest(snapshot), boundary = manifest.boundary_capture;
778
+ 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)
779
+ throw new AppError("controller_boundary_snapshot_invalid", "Published snapshot does not prove this controller boundary", 1, { snapshot });
780
+ verifySnapshotPayload(snapshot, manifest);
781
+ const file = path.join(snapshot, "snapshot.json");
782
+ if (input.manifestSha256 !== undefined && fileHash(file) !== input.manifestSha256)
783
+ throw new AppError("snapshot_checksum_mismatch", "Boundary manifest no longer matches its retained publication receipt", 1, { snapshot });
784
+ return { snapshot, manifest: file, manifest_sha256: fileHash(file), purpose: manifest.purpose, stage_entry: manifest.stage_entry };
785
+ }
786
+ function verifySnapshotPayload(snapshot, manifest) {
787
+ if (treeHash(path.join(snapshot, "runtime")) !== manifest.runtime_sha256)
788
+ throw new AppError("snapshot_checksum_mismatch", "Snapshot runtime checksum does not match", 1);
789
+ verifySnapshotModes(snapshot, manifest);
790
+ 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 }] : [])];
791
+ for (const payload of payloads)
792
+ verifyGitSnapshotPayload(snapshot, payload);
793
+ }
794
+ function verifyGitSnapshotPayload(snapshot, payload) {
795
+ 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))
796
+ throw new AppError("snapshot_checksum_mismatch", "Snapshot payload or Git index checksum does not match", 1, { payload: payload.name });
330
797
  }
331
798
  function git(root, args) {
332
799
  const result = spawnSync("git", ["-C", root, ...args], { encoding: "utf8" });
@@ -338,12 +805,264 @@ function runGit(root, args) {
338
805
  throw new AppError("snapshot_workspace_restore_failed", "Could not restore feature worktree", 1, { stderr: result.stderr.trim(), args });
339
806
  }
340
807
  function samePath(left, right) { return path.resolve(left) === path.resolve(right); }
341
- function treeHash(root) {
808
+ function snapshotEnvironmentAllowed(source) {
809
+ const name = path.basename(source);
810
+ // Public configuration template, not a local environment/credential file.
811
+ return name === ".env.example" || (name !== ".env" && !name.startsWith(".env."));
812
+ }
813
+ function snapshotPayloadAllowed(source) {
814
+ const name = path.basename(source);
815
+ return ![".git", "node_modules", ".cache", ".npm", ".pnpm-store"].includes(name)
816
+ && snapshotEnvironmentAllowed(source);
817
+ }
818
+ function runtimePayloadAllowed(source) {
819
+ return snapshotPayloadAllowed(source) && !["auth.json", "auth.bk", "credentials.json", ".credentials.json", "credentials", ".credentials", "id_rsa", "id_ed25519", "engines", "checkouts"].includes(path.basename(source));
820
+ }
821
+ function sqliteFile(file) {
822
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile())
823
+ return false;
824
+ const descriptor = fs.openSync(file, "r");
825
+ try {
826
+ const header = Buffer.alloc(16);
827
+ return fs.readSync(descriptor, header, 0, 16, 0) === 16 && header.toString() === "SQLite format 3\0";
828
+ }
829
+ finally {
830
+ fs.closeSync(descriptor);
831
+ }
832
+ }
833
+ /** A WAL is copied through its SQLite read snapshot, not as raw bytes. */
834
+ function runtimeSnapshotKind(file, excluded) {
835
+ if (!runtimePayloadAllowed(file) || excluded.includes(file))
836
+ return "excluded";
837
+ if (fs.lstatSync(file).isFile()) {
838
+ if (/-(?:wal|shm|journal)$/.test(file) && sqliteFile(file.replace(/-(?:wal|shm|journal)$/, "")))
839
+ return "sidecar";
840
+ if (sqliteFile(file))
841
+ return "sqlite";
842
+ }
843
+ return "ordinary";
844
+ }
845
+ /** Productive writers are fenced separately. Compare exactly the copied
846
+ * source set; SQLite serialization includes committed WAL pages without
847
+ * mistaking a physical checkpoint for new logical content. */
848
+ function snapshotSourceVersion(context, input, retainedDatabases) {
849
+ const databases = retainedDatabases ?? new Map();
850
+ const projectRoot = resolveProjectRoot(input.projectRoot);
851
+ const project = requireProjectByRoot(context, projectRoot);
852
+ const run = context.db.get("SELECT workspace_root FROM runs WHERE project_id = ? AND id = ?", [project.id, input.runId]);
853
+ if (!run)
854
+ throw new AppError("not_found", "Snapshot RUN is not registered", 1);
855
+ const entries = new Map();
856
+ const excluded = codexHomeSnapshotExclusions(context, project.id, input.runId);
857
+ const visit = (directory) => {
858
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
859
+ const file = path.join(directory, entry.name);
860
+ const kind = runtimeSnapshotKind(file, excluded);
861
+ if (kind === "excluded" || kind === "sidecar")
862
+ continue;
863
+ const key = `runtime/${path.relative(context.ddFlowHome, file)}`;
864
+ const stat = fs.lstatSync(file);
865
+ if (stat.isDirectory()) {
866
+ entries.set(key, "directory");
867
+ visit(file);
868
+ }
869
+ else if (stat.isSymbolicLink())
870
+ entries.set(key, `link:${fs.readlinkSync(file)}`);
871
+ else if (kind === "sqlite") {
872
+ // Resource heartbeats are copied consistently but are not productive
873
+ // RUN writers; owner identity is fenced by boundary.verify().
874
+ if (file === path.join(context.ddFlowHome, "runtime.sqlite")) {
875
+ entries.set(key, `resource-sqlite:${stat.dev}:${stat.ino}:${stat.mode & 0o111}`);
876
+ continue;
877
+ }
878
+ let db = databases.get(file);
879
+ if (!db) {
880
+ db = new DatabaseSync(file, { readOnly: true });
881
+ databases.set(file, db);
882
+ }
883
+ entries.set(key, `sqlite:${crypto.createHash("sha256").update(db.serialize()).digest("hex")}:${stat.dev}:${stat.ino}:${stat.mode & 0o111}`);
884
+ }
885
+ else if (stat.isFile())
886
+ entries.set(key, `file:${crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex")}:${stat.mode & 0o111}`);
887
+ }
888
+ };
889
+ try {
890
+ visit(context.ddFlowHome);
891
+ for (const root of new Set([projectRoot, run.workspace_root]))
892
+ entries.set(`source/${root}`, JSON.stringify([
893
+ treeHash(root, true), treeModesHash(root, true), git(root, ["symbolic-ref", "--quiet", "HEAD"]), gitBytes(root, ["show-ref", "--head"]).toString("base64"), indexEntries(root).toString("base64")
894
+ ]));
895
+ return { entries, databases, sha256: crypto.createHash("sha256").update(JSON.stringify([...entries].sort(([a], [b]) => a.localeCompare(b)))).digest("hex") };
896
+ }
897
+ catch (error) {
898
+ if (!retainedDatabases)
899
+ for (const db of databases.values())
900
+ try {
901
+ db.close();
902
+ }
903
+ catch { /* Preserve the original source error. */ }
904
+ throw error;
905
+ }
906
+ }
907
+ function snapshotSourceChanges(before, after) {
908
+ return [...new Set([...before.keys(), ...after.keys()])].filter(key => before.get(key) !== after.get(key)).sort().slice(0, 8);
909
+ }
910
+ function snapshotSourceChangeProofs(before, after) {
911
+ return snapshotSourceChanges(before, after).map(component => ({
912
+ component,
913
+ before: before.has(component) ? crypto.createHash("sha256").update(before.get(component)).digest("hex") : null,
914
+ after: after.has(component) ? crypto.createHash("sha256").update(after.get(component)).digest("hex") : null
915
+ }));
916
+ }
917
+ /** Managed Codex homes share host configuration, not owned snapshot payload. */
918
+ function codexHomeSnapshotExclusions(context, projectId, runId) {
919
+ const homes = new Set(context.db.all("SELECT target_home FROM codex_home_profiles WHERE project_id = ?", [projectId]).map(row => row.target_home));
920
+ if (context.db.get("SELECT 1 FROM sqlite_schema WHERE name = 'run_controllers'")) {
921
+ 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]))
922
+ if (home)
923
+ homes.add(home);
924
+ }
925
+ const excluded = [];
926
+ for (const home of homes) {
927
+ if (!path.isAbsolute(home) || !fs.existsSync(home) || fs.lstatSync(home).isSymbolicLink())
928
+ continue;
929
+ const marker = path.join(home, ".dd-flow-home.json");
930
+ if (!fs.existsSync(marker) || fs.lstatSync(marker).isSymbolicLink())
931
+ continue;
932
+ const identity = JSON.parse(fs.readFileSync(marker, "utf8"));
933
+ if (identity.managed_by !== "dd-flow-cli" || identity.project_id !== projectId || identity.target_home !== home || !identity.source_home || !path.isAbsolute(identity.source_home))
934
+ continue;
935
+ for (const entry of codexHomeSharedEntries) {
936
+ const file = path.join(home, entry);
937
+ // Skip only the exact links installed by initCodexHome, never arbitrary
938
+ // external links or owned local files with the same convenient name.
939
+ if (fs.lstatSync(file, { throwIfNoEntry: false })?.isSymbolicLink() && fs.readlinkSync(file) === path.join(identity.source_home, entry))
940
+ excluded.push(file);
941
+ }
942
+ excluded.push(path.join(home, "tmp"), path.join(home, ".tmp"));
943
+ }
944
+ return excluded;
945
+ }
946
+ /** Copy a dedicated home or scoped recovery tree, preserving SQLite WAL commits. */
947
+ function copyRuntimeTree(source, destination, scoped = true, excluded = []) {
948
+ if (fs.lstatSync(source).isSymbolicLink())
949
+ throw new AppError("snapshot_runtime_symlink_outside_scope", "Recovery artifact roots must not be symbolic links", 1, { path: source });
950
+ const root = physicalPath(source);
951
+ if (isInside(root, physicalPath(destination)))
952
+ throw new AppError("snapshot_output_inside_runtime", "Recovery output must be outside its source artifact tree", 1);
953
+ const databases = [];
954
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
955
+ fs.cpSync(source, destination, {
956
+ recursive: true, verbatimSymlinks: true,
957
+ filter: file => {
958
+ const kind = runtimeSnapshotKind(file, excluded);
959
+ if (kind === "excluded" || kind === "sidecar")
960
+ return false;
961
+ const stat = fs.lstatSync(file);
962
+ if (stat.isSymbolicLink()) {
963
+ if (scoped && !isInside(root, physicalPath(file)))
964
+ throw new AppError("snapshot_runtime_symlink_outside_scope", "Recovery artifact link leaves its owned tree", 1, { path: file });
965
+ return true;
966
+ }
967
+ if (!stat.isDirectory() && !stat.isFile())
968
+ return false;
969
+ if (stat.isFile()) {
970
+ if (kind === "sqlite") {
971
+ databases.push(file);
972
+ return false;
973
+ }
974
+ }
975
+ return true;
976
+ }
977
+ });
978
+ for (const file of databases) {
979
+ const db = new DatabaseSync(file, { readOnly: true });
980
+ try {
981
+ const target = source === file ? destination : path.join(destination, path.relative(source, file));
982
+ fs.mkdirSync(path.dirname(target), { recursive: true });
983
+ db.exec("PRAGMA busy_timeout = 4000");
984
+ db.exec(`VACUUM INTO '${target.replace(/'/g, "''")}'`);
985
+ }
986
+ finally {
987
+ db.close();
988
+ }
989
+ }
990
+ }
991
+ function captureRecoveryAuxiliary(context, captured, input) {
992
+ const runtime = path.join(input.output, "runtime");
993
+ const codexExclusions = codexHomeSnapshotExclusions(context, input.projectId, input.runId);
994
+ const run = captured.db.get("SELECT run_root, project_root, workspace_root FROM runs WHERE project_id = ? AND id = ?", [input.projectId, input.runId]);
995
+ const roots = [run.run_root, runtimeRunDir(context.ddFlowHome, input.projectId, input.runId)];
996
+ for (const protocol of captured.db.all("SELECT id, state_path, plan_path FROM protocols")) {
997
+ const directory = runtimeProtocolDir(context.ddFlowHome, input.projectId, protocol.id);
998
+ if (!isInside(physicalPath(context.ddFlowHome), physicalPath(directory)))
999
+ throw new AppError("snapshot_protocol_scope_invalid", "Protocol runtime leaves DD_FLOW_HOME", 1);
1000
+ if (fs.existsSync(directory)) {
1001
+ copyRuntimeTree(directory, path.join(runtime, path.relative(context.ddFlowHome, directory)), true, codexExclusions);
1002
+ roots.push(directory);
1003
+ }
1004
+ for (const file of [protocol.state_path, protocol.plan_path]) {
1005
+ if (!fs.existsSync(file) || roots.some(root => isInside(physicalPath(root), physicalPath(file))))
1006
+ continue;
1007
+ if (isInside(physicalPath(context.ddFlowHome), physicalPath(file)) && fs.statSync(file).isFile())
1008
+ copyRuntimeTree(file, path.join(runtime, path.relative(context.ddFlowHome, file)));
1009
+ else if (![run.project_root, run.workspace_root].some(root => isInside(physicalPath(root), physicalPath(file))))
1010
+ throw new AppError("snapshot_protocol_scope_invalid", "Protocol evidence is outside captured payloads", 1, { path: file });
1011
+ }
1012
+ }
1013
+ const homes = new Set([context.ddFlowHome, resourceHome(context)]);
1014
+ const queries = {
1015
+ 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')",
1016
+ run_control_workers: "SELECT resource_home AS home FROM run_control_workers",
1017
+ work_launches: "SELECT json_extract(profile_json, '$.runtime_adapter.owner.resource_home') AS home FROM work_launches",
1018
+ merge_requests: "SELECT json_extract(adapter_receipt_json, '$.owner.resource_home') AS home FROM merge_requests",
1019
+ run_recovery_guards: "SELECT value AS home FROM run_recovery_guards, json_each(settlement_json, '$.resource_homes')"
1020
+ };
1021
+ 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) : []);
1022
+ for (const { home } of references) {
1023
+ if (home === null)
1024
+ continue;
1025
+ if (typeof home !== "string" || !path.isAbsolute(home))
1026
+ throw new AppError("snapshot_resource_scope_invalid", "Retained resource home must be an absolute path", 1);
1027
+ homes.add(home);
1028
+ }
1029
+ const nativeCaptures = new Map();
1030
+ const registries = [];
1031
+ for (const home of [...homes].sort()) {
1032
+ if (!path.isAbsolute(home))
1033
+ throw new AppError("snapshot_resource_scope_invalid", "Resource home must be an absolute path", 1);
1034
+ const evidence = readRecoveryResourceEvidence(home, input.projectId, input.runId, { db: captured.db, ddFlowHome: context.ddFlowHome });
1035
+ for (const process of evidence.processes) {
1036
+ const directory = JSON.parse(process.metadata_json).state_dir;
1037
+ if (directory === undefined)
1038
+ continue;
1039
+ if (typeof directory !== "string" || !path.isAbsolute(directory))
1040
+ throw new AppError("snapshot_native_scope_invalid", "Native artifact root must be absolute", 1);
1041
+ if (roots.some(root => isInside(physicalPath(root), physicalPath(directory))) || nativeCaptures.has(directory))
1042
+ continue;
1043
+ const state = JSON.parse(fs.readFileSync(path.join(directory, "daemon.json"), "utf8"));
1044
+ const owner = state.config?.runtime_owner;
1045
+ 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)
1046
+ throw new AppError("snapshot_native_scope_invalid", "External native artifact root has no matching RUN owner", 1, { state_dir: directory });
1047
+ const relative = path.join("native-evidence", crypto.createHash("sha256").update(directory).digest("hex"));
1048
+ copyRuntimeTree(directory, path.join(runtime, relative), true, codexExclusions);
1049
+ nativeCaptures.set(directory, relative);
1050
+ }
1051
+ const relative = path.join("resource-evidence", `${crypto.createHash("sha256").update(home).digest("hex")}.json`);
1052
+ fs.mkdirSync(path.dirname(path.join(runtime, relative)), { recursive: true });
1053
+ writeJson(path.join(runtime, relative), evidence);
1054
+ registries.push(relative);
1055
+ }
1056
+ 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 })) });
1057
+ }
1058
+ function treeHash(root, filtered = false) {
342
1059
  const hash = crypto.createHash("sha256");
343
1060
  const visit = (dir, relative) => {
344
1061
  for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
345
1062
  const file = path.join(dir, entry.name);
346
1063
  const rel = path.join(relative, entry.name);
1064
+ if (typeof filtered === "function" ? !filtered(file) : filtered && !snapshotPayloadAllowed(file))
1065
+ continue;
347
1066
  if (entry.isDirectory())
348
1067
  visit(file, rel);
349
1068
  else if (entry.isFile()) {
@@ -359,13 +1078,58 @@ function treeHash(root) {
359
1078
  visit(root, "");
360
1079
  return hash.digest("hex");
361
1080
  }
1081
+ function treeModesHash(root, filtered = false) {
1082
+ const entries = [];
1083
+ const visit = (directory) => {
1084
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
1085
+ const file = path.join(directory, entry.name);
1086
+ if (typeof filtered === "function" ? !filtered(file) : filtered && !snapshotPayloadAllowed(file))
1087
+ continue;
1088
+ if (entry.isDirectory())
1089
+ visit(file);
1090
+ else if (entry.isFile())
1091
+ entries.push([path.relative(root, file), fs.statSync(file).mode & 0o111]);
1092
+ }
1093
+ };
1094
+ visit(root);
1095
+ return crypto.createHash("sha256").update(JSON.stringify(entries)).digest("hex");
1096
+ }
1097
+ function verifySnapshotModes(snapshot, manifest) {
1098
+ if (!manifest.payload_modes)
1099
+ return; // Legacy snapshots did not attest modes.
1100
+ for (const name of ["project", "runtime", ...(manifest.workspace.kind === "git_worktree" ? ["workspace"] : [])]) {
1101
+ if (treeModesHash(path.join(snapshot, name)) !== manifest.payload_modes[name])
1102
+ throw new AppError("snapshot_checksum_mismatch", "Snapshot executable modes do not match manifest", 1, { payload: name });
1103
+ }
1104
+ }
362
1105
  function fileHash(file) { return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); }
1106
+ // Flush children before their directory entries, then publish and flush the
1107
+ // parent. Never follow snapshot symlinks into the original project tree.
1108
+ function syncSnapshotTree(root) {
1109
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
1110
+ const file = path.join(root, entry.name);
1111
+ if (entry.isDirectory())
1112
+ syncSnapshotTree(file);
1113
+ else if (entry.isFile())
1114
+ syncPath(file);
1115
+ }
1116
+ syncPath(root);
1117
+ }
1118
+ function syncPath(file) {
1119
+ const descriptor = fs.openSync(file, "r");
1120
+ try {
1121
+ fs.fsyncSync(descriptor);
1122
+ }
1123
+ finally {
1124
+ fs.closeSync(descriptor);
1125
+ }
1126
+ }
363
1127
  function readManifest(snapshot) {
364
1128
  const file = path.join(snapshot, "snapshot.json");
365
1129
  if (!fs.existsSync(file))
366
1130
  throw new AppError("snapshot_missing", "Snapshot manifest is missing", 1, { snapshot });
367
1131
  const value = JSON.parse(fs.readFileSync(file, "utf8"));
368
- 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)
1132
+ 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)
369
1133
  throw new AppError("snapshot_invalid", "Snapshot manifest is invalid", 1, { snapshot });
370
1134
  if (!value.project_git.branch || !value.project_git.head || !value.project_git.bundle_sha256)
371
1135
  throw new AppError("snapshot_invalid", "Snapshot project Git metadata is incomplete", 1, { snapshot });