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

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 (68) hide show
  1. package/dist/build-info.json +6 -6
  2. package/dist/cli/command-inputs.js +300 -0
  3. package/dist/cli/help.js +1 -1
  4. package/dist/cli/hook-ingress.js +82 -0
  5. package/dist/cli/input-preparation.js +115 -0
  6. package/dist/cli/run-cli.js +1015 -423
  7. package/dist/cli.js +6 -2
  8. package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
  9. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +7 -2
  10. package/dist/harness-runtime/lib/dd-codex.mjs +137 -13
  11. package/dist/harness-runtime/lib/dd-droid.mjs +2 -2
  12. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +1 -1
  13. package/dist/harness-runtime/lib/delegation-instructions.mjs +13 -4
  14. package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
  15. package/dist/services/canon.js +11 -8
  16. package/dist/services/cleanup.js +49 -8
  17. package/dist/services/cli-operation-classifier.js +12 -24
  18. package/dist/services/codex-hook-delivery.js +28 -0
  19. package/dist/services/config.js +11 -8
  20. package/dist/services/controller-fanout.js +2 -2
  21. package/dist/services/dashboard.js +94 -31
  22. package/dist/services/engines.js +75 -61
  23. package/dist/services/eval-snapshots.js +138 -38
  24. package/dist/services/execution-policy.js +5 -12
  25. package/dist/services/hooks.js +226 -203
  26. package/dist/services/lanes.js +60 -52
  27. package/dist/services/lifecycle-command.js +13 -1
  28. package/dist/services/lifecycle-invocations.js +285 -94
  29. package/dist/services/managed-processes.js +158 -18
  30. package/dist/services/merge-queue.js +173 -98
  31. package/dist/services/migrations.js +13 -8
  32. package/dist/services/plan-runtime.js +19 -8
  33. package/dist/services/plans.js +28 -25
  34. package/dist/services/projects.js +11 -2
  35. package/dist/services/prompts.js +23 -16
  36. package/dist/services/protocols.js +54 -19
  37. package/dist/services/recovery-observation-budget.js +1 -1
  38. package/dist/services/run-controller-adapter.js +31 -20
  39. package/dist/services/run-controller-state.js +5 -0
  40. package/dist/services/run-controller.js +102 -56
  41. package/dist/services/run-fork.js +36 -22
  42. package/dist/services/run-recovery.js +32 -17
  43. package/dist/services/runs.js +110 -38
  44. package/dist/services/runtime-budget.js +120 -48
  45. package/dist/services/runtime-scope-capture.js +2 -2
  46. package/dist/services/runtime-scope-control.js +2 -2
  47. package/dist/services/runtime-scope-resume.js +34 -12
  48. package/dist/services/runtime-scope-worker.js +49 -16
  49. package/dist/services/runtime-service.js +8 -7
  50. package/dist/services/schema-validation.js +9 -7
  51. package/dist/services/sessions.js +35 -69
  52. package/dist/services/stage-blocker.js +17 -7
  53. package/dist/services/stage-context.js +42 -16
  54. package/dist/services/stage-lifecycle.js +82 -100
  55. package/dist/services/stage-pause.js +54 -24
  56. package/dist/services/vnext-code-review.js +82 -32
  57. package/dist/services/vnext-code.js +78 -45
  58. package/dist/services/vnext-fanout.js +13 -12
  59. package/dist/services/vnext-merge.js +68 -35
  60. package/dist/services/vnext-plan-review.js +84 -41
  61. package/dist/services/vnext-plan.js +91 -70
  62. package/dist/services/vnext-protocolize.js +116 -50
  63. package/dist/services/vnext-specify.js +70 -57
  64. package/dist/services/work-registry.js +195 -92
  65. package/dist/services/worktrees.js +85 -35
  66. package/dist/storage/database.js +104 -10
  67. package/dist/storage/paths.js +17 -4
  68. package/package.json +1 -1
@@ -12,6 +12,7 @@ import { requireProtocol } from "./protocols.js";
12
12
  import { resolveCanonRootBeforeContext } from "./canon.js";
13
13
  import { appendAudit } from "./audit.js";
14
14
  import { findProjectByRoot } from "./projects.js";
15
+ import { parseCommandArgs } from "./lifecycle-command.js";
15
16
  import { allRunEngineBindings, locateRunRoot, readRunEngineBinding, runEngineBindingSchemaId, writeRunEngineBinding } from "./run-engine-bindings.js";
16
17
  export const engineManifestSchemaId = "dd-flow/engine-manifest@1";
17
18
  const routerNativeFamilies = new Set(["engine", "version", "schema"]);
@@ -181,7 +182,8 @@ export function routeArgsThroughEngine(context, args, io, stdin, env, resolvedPr
181
182
  const entrypoint = resolveManifestEntrypoint(selection.selected);
182
183
  return spawnEngine(selection.selected, entrypoint, args, io, stdin, env, current.version);
183
184
  }
184
- export function bindRunEngine(context, input) {
185
+ export function prepareRunEngineBind(context, input) {
186
+ const reason = requiredText(input.reason, "reason");
185
187
  const projectRoot = resolveProjectRoot(input.projectRoot);
186
188
  const located = locateRunRoot(context.db, projectRoot, input.runId);
187
189
  if (!located)
@@ -191,19 +193,31 @@ export function bindRunEngine(context, input) {
191
193
  packageName: input.packageName ?? getCliBuildInfo().package_name,
192
194
  packageVersion: input.engineVersion,
193
195
  ...(input.integrityChecksum ? { integrityChecksum: input.integrityChecksum } : {})
194
- });
196
+ }, { checkEntrypoint: false });
195
197
  const requested = engineIdentity(manifest);
196
198
  if (existing) {
197
199
  if (sameEngineIdentity(existing.engine, requested))
198
- return { ok: true, action: "run_engine_bind", changed: false, binding: existing };
200
+ return { kind: "replay", binding: existing };
199
201
  throw new AppError("run_engine_binding_immutable", "RUN engine binding cannot be changed", 1, {
200
202
  run_id: located.run_id,
201
203
  current: existing.engine,
202
204
  requested
203
205
  });
204
206
  }
207
+ return { kind: "create", projectRoot, located, manifest, requested, reason, entrypoint: resolveManifestEntrypoint(manifest) };
208
+ }
209
+ export function bindRunEngine(context, input, prepared = prepareRunEngineBind(context, input)) {
210
+ if (prepared.kind === "replay")
211
+ return { ok: true, action: "run_engine_bind", changed: false, binding: prepared.binding };
212
+ const { projectRoot, located, manifest, requested, reason, entrypoint } = prepared;
213
+ const existing = readRunEngineBinding(located.binding_path, { allowMissing: true });
214
+ if (existing) {
215
+ if (sameEngineIdentity(existing.engine, requested))
216
+ return { ok: true, action: "run_engine_bind", changed: false, binding: existing };
217
+ throw new AppError("run_engine_binding_immutable", "RUN engine binding cannot be changed", 1, { run_id: located.run_id, current: existing.engine, requested });
218
+ }
205
219
  const probeArgs = ["run", "status", located.run_id, "--project-root", projectRoot, "--json"];
206
- const probe = spawnSync(process.execPath, [resolveManifestEntrypoint(manifest), ...probeArgs], {
220
+ const probe = spawnSync(process.execPath, [entrypoint, ...probeArgs], {
207
221
  cwd: manifest.snapshot_root,
208
222
  env: { ...process.env, ...context.env, DD_FLOW_HOME: context.ddFlowHome, DD_FLOW_ENGINE_MODE: "1", DD_FLOW_ENGINE_HOME: manifest.snapshot_root },
209
223
  encoding: "utf8",
@@ -224,7 +238,7 @@ export function bindRunEngine(context, input) {
224
238
  project_root: projectRoot,
225
239
  bound_at: context.now(),
226
240
  source: "legacy_recovery",
227
- reason: requiredText(input.reason, "reason"),
241
+ reason,
228
242
  engine: requested,
229
243
  probe: { status: "passed", command: `dd-flow ${probeArgs.join(" ")}` }
230
244
  };
@@ -325,7 +339,7 @@ function runIdForArgs(args) {
325
339
  return option(parsed, "run");
326
340
  return null;
327
341
  }
328
- function requireBoundManifest(ddFlowHome, input) {
342
+ function requireBoundManifest(ddFlowHome, input, options = {}) {
329
343
  const manifest = input.snapshotRoot
330
344
  ? requireManifest(path.join(input.snapshotRoot, "engine.json"))
331
345
  : findInstalledManifest(ddFlowHome, {
@@ -338,7 +352,7 @@ function requireBoundManifest(ddFlowHome, input) {
338
352
  || manifest.package_version !== input.packageVersion
339
353
  || input.integrityChecksum !== undefined && manifest.integrity.checksum !== input.integrityChecksum
340
354
  || input.snapshotRoot !== undefined && path.resolve(manifest.snapshot_root) !== path.resolve(input.snapshotRoot)
341
- || !manifestHealthy(manifest)) {
355
+ || manifestDiagnostics(manifest, options).length !== 0) {
342
356
  throw new AppError("run_engine_binding_invalid", "RUN engine binding points to a missing or unhealthy engine snapshot", 1, {
343
357
  package_name: input.packageName,
344
358
  package_version: input.packageVersion,
@@ -379,7 +393,7 @@ function executionManifest(context) {
379
393
  return requireManifest(path.join(String(installed.path), "engine.json"));
380
394
  }
381
395
  function requiredText(value, label) {
382
- const normalized = value.trim();
396
+ const normalized = typeof value === "string" ? value.trim() : "";
383
397
  if (!normalized)
384
398
  throw new AppError("usage", `Missing required value: ${label}`, 2);
385
399
  return normalized;
@@ -591,35 +605,11 @@ function canonCompatibilitySha(memorybankRoot) {
591
605
  return null;
592
606
  }
593
607
  }
594
- function parseLightArgs(args) {
595
- const positional = [];
596
- const options = new Map();
597
- for (let index = 0; index < args.length; index += 1) {
598
- const value = args[index];
599
- if (value?.startsWith("--")) {
600
- const equal = value.indexOf("=");
601
- if (equal > 2) {
602
- const key = value.slice(2, equal);
603
- options.set(key, [...(options.get(key) ?? []), value.slice(equal + 1)]);
604
- continue;
605
- }
606
- const key = value.slice(2);
607
- const next = args[index + 1];
608
- if (!next || next.startsWith("--"))
609
- options.set(key, [...(options.get(key) ?? []), ""]);
610
- else {
611
- options.set(key, [...(options.get(key) ?? []), next]);
612
- index += 1;
613
- }
614
- }
615
- else if (value)
616
- positional.push(value);
617
- }
618
- return { positional, options };
619
- }
608
+ const parseLightArgs = parseCommandArgs;
620
609
  function option(parsed, key) {
621
610
  const values = parsed.options.get(key);
622
- return values?.[values.length - 1] || null;
611
+ const value = values?.[values.length - 1];
612
+ return value && value !== "__dd_flow_flag__" ? value : null;
623
613
  }
624
614
  function positional(parsed, index) {
625
615
  return parsed.positional[index] ?? null;
@@ -651,23 +641,37 @@ function buildManifest(context, packageName, version, packageRoot, snapshotRoot,
651
641
  };
652
642
  }
653
643
  function copyPackageSnapshot(packageRoot, target) {
644
+ for (const [relative, source] of packageSnapshotFiles(packageRoot)) {
645
+ const destination = path.join(target, relative);
646
+ ensureDir(path.dirname(destination));
647
+ fs.copyFileSync(source, destination);
648
+ }
649
+ }
650
+ /** The same source inventory drives validation and copying; no staging writes. */
651
+ function packageSnapshotFiles(packageRoot) {
652
+ const files = new Map();
653
+ const collect = (source, relative, skipNodeModules = false) => {
654
+ if (!fs.statSync(source).isDirectory()) {
655
+ files.set(relative, source);
656
+ return;
657
+ }
658
+ for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
659
+ if (skipNodeModules && entry.name === "node_modules")
660
+ continue;
661
+ const child = path.join(source, entry.name), name = path.join(relative, entry.name);
662
+ if (entry.isDirectory())
663
+ collect(child, name, skipNodeModules);
664
+ else if (entry.isFile())
665
+ files.set(name, child);
666
+ }
667
+ };
654
668
  for (const item of ["dist", "package.json", "README.md", "CHANGELOG.md", "tools"]) {
655
669
  const from = path.join(packageRoot, item);
656
670
  if (!fs.existsSync(from))
657
671
  continue;
658
- const to = path.join(target, item);
659
- const stat = fs.statSync(from);
660
- if (stat.isDirectory())
661
- copyDir(from, to);
662
- else {
663
- ensureDir(path.dirname(to));
664
- fs.copyFileSync(from, to);
665
- }
672
+ collect(from, item);
666
673
  }
667
- copyProductionDependencies(packageRoot, target);
668
- }
669
- function copyProductionDependencies(packageRoot, target) {
670
- const queue = [{ sourceRoot: packageRoot, targetRoot: target, ancestors: new Set() }];
674
+ const queue = [{ sourceRoot: packageRoot, targetRoot: "", ancestors: new Set() }];
671
675
  const copied = new Set();
672
676
  while (queue.length > 0) {
673
677
  const current = queue.shift();
@@ -682,12 +686,35 @@ function copyProductionDependencies(packageRoot, target) {
682
686
  if (copied.has(copyKey))
683
687
  continue;
684
688
  copied.add(copyKey);
685
- copyDir(sourceRoot, targetRoot, true);
689
+ collect(sourceRoot, targetRoot, true);
686
690
  if (current.ancestors.has(sourceRoot))
687
691
  continue;
688
692
  queue.push({ sourceRoot, targetRoot, ancestors: new Set([...current.ancestors, sourceRoot]) });
689
693
  }
690
694
  }
695
+ return files;
696
+ }
697
+ export function prepareForkEngine(engineVersion, integrityChecksum) {
698
+ const build = getCliBuildInfo();
699
+ if (engineVersion !== build.version)
700
+ throw new AppError("fork_engine_unavailable", "Fork installs the invoking CLI engine; invoke the requested version to upgrade the fork", 2, { requested: engineVersion, available: build.version });
701
+ const packageRoot = findPackageRoot(path.dirname(sourceFile));
702
+ if (!packageRoot || !fs.existsSync(path.join(packageRoot, "dist", "cli.js")))
703
+ throw new AppError("harness_runtime_unbuilt", "Build the CLI before preparing a fork", 1);
704
+ const files = packageSnapshotFiles(packageRoot);
705
+ if (integrityChecksum !== undefined) {
706
+ const checksum = crypto.createHash("sha256");
707
+ for (const [relative, source] of [...files].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
708
+ if (path.basename(relative) === "engine.json")
709
+ continue;
710
+ checksum.update(relative);
711
+ checksum.update("\0");
712
+ checksum.update(fs.readFileSync(source));
713
+ checksum.update("\0");
714
+ }
715
+ if (checksum.digest("hex") !== integrityChecksum)
716
+ throw new AppError("fork_engine_unavailable", "Requested engine checksum differs from the invoking CLI artifact", 2, { requested: integrityChecksum });
717
+ }
691
718
  }
692
719
  function readProductionDependencies(packageRoot) {
693
720
  try {
@@ -744,19 +771,6 @@ function hasAncestorFile(start, name) {
744
771
  current = parent;
745
772
  }
746
773
  }
747
- function copyDir(from, to, skipNodeModules = false) {
748
- ensureDir(to);
749
- for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
750
- if (skipNodeModules && entry.name === "node_modules")
751
- continue;
752
- const source = path.join(from, entry.name);
753
- const target = path.join(to, entry.name);
754
- if (entry.isDirectory())
755
- copyDir(source, target, skipNodeModules);
756
- else if (entry.isFile())
757
- fs.copyFileSync(source, target);
758
- }
759
- }
760
774
  function acquireInstallLock(lockDir) {
761
775
  ensureDir(path.dirname(lockDir));
762
776
  try {
@@ -14,8 +14,9 @@ import { readVnextFlowDefinition } from "./vnext-specify.js";
14
14
  import { captureRecoveryDatabase, readRecoveryResourceEvidence } from "./recovery-snapshot-database.js";
15
15
  import { resourceHome } from "./managed-processes.js";
16
16
  import { codexHomeSharedEntries } from "./hooks.js";
17
+ import { prepareOutputFile } from "../cli/input-preparation.js";
17
18
  /** Capture the only flow boundary which exists before `stage start --bootstrap` creates a RUN. */
18
- export function createEvalBootstrapSnapshot(context, input) {
19
+ export function prepareEvalBootstrapSnapshot(context, input) {
19
20
  const projectRoot = resolveProjectRoot(input.projectRoot);
20
21
  const output = path.resolve(input.output);
21
22
  if (fs.existsSync(output))
@@ -24,6 +25,19 @@ export function createEvalBootstrapSnapshot(context, input) {
24
25
  throw new AppError("snapshot_output_inside_project", "Bootstrap snapshot output must be outside the project tree", 1, { output });
25
26
  if (isInside(physicalPath(context.ddFlowHome), physicalPath(output)))
26
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);
27
41
  fs.mkdirSync(output, { recursive: true });
28
42
  fs.cpSync(projectRoot, path.join(output, "project"), { recursive: true, verbatimSymlinks: true, filter: snapshotPayloadAllowed });
29
43
  const manifest = {
@@ -37,13 +51,18 @@ export function createEvalBootstrapSnapshot(context, input) {
37
51
  return { ok: true, snapshot: output, ...manifest };
38
52
  }
39
53
  /** Restore a pre-RUN project state; `stage start --bootstrap` creates runtime state afterwards. */
40
- export function restoreEvalBootstrapSnapshot(_context, input) {
54
+ export function prepareEvalBootstrapRestore(input) {
41
55
  const snapshot = path.resolve(input.snapshot);
42
56
  const manifest = readBootstrapManifest(snapshot);
43
57
  const projectRoot = resolveProjectRoot(input.projectRoot);
58
+ assertRestoreTargetSeparation(snapshot, [projectRoot]);
44
59
  const sourceProject = path.join(snapshot, "project");
45
- if (treeHash(sourceProject) !== manifest.project_sha256)
46
- 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]);
47
66
  clearProjectTree(projectRoot, false);
48
67
  restoreProjectGit(snapshot, manifest.project_git, projectRoot);
49
68
  replaceProjectTree(projectRoot, sourceProject);
@@ -54,9 +73,7 @@ export function restoreEvalBootstrapSnapshot(_context, input) {
54
73
  * retain only the selected RUN's relational and filesystem evidence.
55
74
  */
56
75
  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 });
76
+ const { output } = prepareEvalRunSnapshotCreation(context, input);
60
77
  const temporary = `${output}.partial-${crypto.randomUUID()}`;
61
78
  try {
62
79
  input.boundary?.verify();
@@ -89,7 +106,9 @@ export function createEvalRunSnapshot(context, input) {
89
106
  throw error;
90
107
  }
91
108
  }
92
- function captureEvalRunSnapshot(context, input) {
109
+ export function prepareEvalRunSnapshotCreation(context, input) {
110
+ if (Number(Boolean(input.candidate)) + Number(Boolean(input.incomplete)) + Number(input.stageEntry !== undefined) + Number(input.recoveryId !== undefined) !== 1)
111
+ throw new AppError("usage", "Use exactly one of candidate, incomplete, recoveryId or stageEntry", 2, { phase: "prepare", effect: "no_effect", recoverable: true });
93
112
  const projectRoot = resolveProjectRoot(input.projectRoot);
94
113
  // Snapshot is read-only at the control-plane boundary. An invalid workspace
95
114
  // root must fail without registering a second project in a dedicated home.
@@ -129,6 +148,13 @@ function captureEvalRunSnapshot(context, input) {
129
148
  const workspaceRoot = String(status.run.workspace_root ?? projectRoot);
130
149
  if (isInside(physicalPath(projectRoot), physicalPath(output)) || isInside(physicalPath(workspaceRoot), physicalPath(output)))
131
150
  throw new AppError("snapshot_output_inside_project", "Snapshot output must be outside the project and RUN workspace trees", 1, { output });
151
+ prepareOutputFile(output);
152
+ prepareSnapshotProjectGit(projectRoot);
153
+ prepareSnapshotWorkspace(workspaceRoot, projectRoot);
154
+ return { projectRoot, project, status, recovery, output, workspaceRoot, activeChildren };
155
+ }
156
+ function captureEvalRunSnapshot(context, input) {
157
+ const { projectRoot, project, status, recovery, output, workspaceRoot, activeChildren } = prepareEvalRunSnapshotCreation(context, input);
132
158
  fs.mkdirSync(output, { recursive: true, mode: 0o700 });
133
159
  const resourceDatabase = path.join(context.ddFlowHome, "runtime.sqlite");
134
160
  const databaseFiles = [context.db.path, resourceDatabase].flatMap(file => [file, `${file}-wal`, `${file}-shm`]);
@@ -217,9 +243,35 @@ function createdWorkSchemaForStage(stage) {
217
243
  return "dd-flow/merge-result";
218
244
  return null;
219
245
  }
220
- export function restoreEvalRunSnapshot(context, input) {
221
- const snapshot = path.resolve(input.snapshot);
246
+ /** Validate sealed source data before any target cleanup or copy. */
247
+ export function prepareEvalRunSnapshot(snapshot) {
222
248
  const manifest = readManifest(snapshot);
249
+ if (manifest.purpose === "incomplete")
250
+ throw new AppError("snapshot_incomplete_not_restorable", "Incomplete snapshot is forensic evidence, not a stage-entry fixture", 1, { snapshot });
251
+ if (manifest.purpose === "candidate")
252
+ throw new AppError("snapshot_candidate_not_restorable", "A terminal candidate snapshot is evidence, not a stage-entry fixture", 1, { snapshot });
253
+ verifySnapshotPayload(snapshot, manifest);
254
+ if (manifest.purpose === "stage_entry") {
255
+ const db = new DatabaseSync(path.join(snapshot, "runtime", "db.sqlite"), { readOnly: true });
256
+ try {
257
+ assertSnapshotRuntimeDetachAllowed(sql => db.prepare(sql).get());
258
+ const row = db.prepare("SELECT index_json FROM runs WHERE id = ?").get(manifest.run_id);
259
+ if (!row)
260
+ throw new AppError("snapshot_invalid", "Snapshot RUN is not registered", 1);
261
+ const index = JSON.parse(row.index_json);
262
+ if (!Array.isArray(index?.stage_runs))
263
+ throw new AppError("snapshot_invalid", "Snapshot RUN index is invalid", 1);
264
+ assertStageEntry(index.stage_runs, manifest.stage_entry);
265
+ }
266
+ finally {
267
+ db.close();
268
+ }
269
+ }
270
+ return manifest;
271
+ }
272
+ export function prepareEvalRunRestore(context, input, preparedManifest) {
273
+ const snapshot = path.resolve(input.snapshot);
274
+ const manifest = preparedManifest ?? prepareEvalRunSnapshot(snapshot);
223
275
  if (manifest.purpose === "incomplete")
224
276
  throw new AppError("snapshot_incomplete_not_restorable", "Incomplete snapshot is forensic evidence, not a stage-entry fixture", 1, { snapshot });
225
277
  const recovery = manifest.purpose === "recovery";
@@ -234,24 +286,15 @@ export function restoreEvalRunSnapshot(context, input) {
234
286
  if (!recovery && !manifest.stage_entry)
235
287
  throw new AppError("snapshot_invalid", "Stage-entry snapshot has no target stage", 1, { snapshot });
236
288
  const projectRoot = resolveProjectRoot(input.projectRoot);
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);
289
+ assertRunRestoreTargets(context, input, snapshot, projectRoot, manifest);
290
+ if (recovery)
240
291
  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));
243
- if (existing.length > 0)
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);
247
292
  const sourceRuntime = path.join(snapshot, "runtime");
248
293
  const sourceProject = path.join(snapshot, "project");
249
- verifySnapshotPayload(snapshot, manifest);
250
294
  const routingInput = input.executionRoutingFile !== undefined ? loadExecutionRoutingInput(input.executionRoutingFile) : undefined;
251
295
  let selectedProfile;
252
296
  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.
297
+ // Resolve selected profiles before replacing the caller's fresh runtime.
255
298
  const sourceDb = new DatabaseSync(path.join(sourceRuntime, "db.sqlite"), { readOnly: true });
256
299
  try {
257
300
  const row = sourceDb.prepare("SELECT index_json FROM runs WHERE id = ?").get(manifest.run_id);
@@ -266,11 +309,42 @@ export function restoreEvalRunSnapshot(context, input) {
266
309
  resolveExecutionPolicy({ ddFlowHome: context.ddFlowHome, policy: routingInput.execution, profiles: selectedProfile.agent_profiles, stage: manifest.stage_entry, work: payload.execution_override });
267
310
  }
268
311
  selectedProfile.routing_input.artifact = `execution-routing.${routingInput.source_checksum}.input.json`;
312
+ const sourceRun = sourceDb.prepare("SELECT run_root FROM runs WHERE id = ?").get(manifest.run_id);
313
+ if (typeof sourceRun?.run_root !== "string" || !path.isAbsolute(sourceRun.run_root) || !isInside(manifest.dd_flow_home, sourceRun.run_root))
314
+ throw new AppError("snapshot_invalid", "Snapshot RUN root must be inside its runtime home", 1);
315
+ const existingRouting = path.join(sourceRuntime, path.relative(manifest.dd_flow_home, sourceRun.run_root), selectedProfile.routing_input.artifact);
316
+ if (fs.existsSync(existingRouting) && !fs.readFileSync(existingRouting).equals(routingInput.bytes))
317
+ throw new AppError("snapshot_checksum_mismatch", "Existing routing input does not match its checksum", 1);
269
318
  }
270
319
  finally {
271
320
  sourceDb.close();
272
321
  }
273
322
  }
323
+ return { snapshot, manifest, recovery, projectRoot, sourceRuntime, sourceProject, routingInput, selectedProfile };
324
+ }
325
+ function assertRunRestoreTargets(context, input, snapshot, projectRoot, manifest) {
326
+ assertRestoreTargetSeparation(snapshot, [projectRoot, context.ddFlowHome]);
327
+ if (manifest.workspace.kind === "git_worktree") {
328
+ const workspaceRoot = restoredWorkspaceRoot(manifest, projectRoot, context.ddFlowHome);
329
+ assertRestoreTargetSeparation(snapshot, [workspaceRoot]);
330
+ if (isInside(physicalPath(projectRoot), physicalPath(workspaceRoot)) || isInside(physicalPath(workspaceRoot), physicalPath(projectRoot)) || physicalPath(workspaceRoot) === physicalPath(context.ddFlowHome))
331
+ throw new AppError("snapshot_path_conflict", "Feature workspace overlaps a restore target", 2);
332
+ if (fs.existsSync(workspaceRoot))
333
+ throw new AppError("snapshot_workspace_target_exists", "Snapshot feature workspace target already exists", 1, { workspace_root: workspaceRoot });
334
+ }
335
+ if (manifest.purpose === "recovery") {
336
+ if (fs.readdirSync(projectRoot).length || (context.db.get("SELECT COUNT(*) AS count FROM projects")?.count ?? 0) > 0)
337
+ throw new AppError("recovery_restore_target_not_empty", "Recovery restore requires fresh project and runtime targets", 1);
338
+ }
339
+ 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));
340
+ if (existing.length > 0)
341
+ throw new AppError("restore_home_not_empty", "Snapshot restore requires an empty DD_FLOW_HOME", 1, { dd_flow_home: context.ddFlowHome, existing });
342
+ if (context.db.get("SELECT 1 FROM projects LIMIT 1") || context.db.get("SELECT 1 FROM runs LIMIT 1"))
343
+ throw new AppError("restore_home_not_empty", "Snapshot restore cannot replace an initialized runtime", 1);
344
+ }
345
+ export function restoreEvalRunSnapshot(context, input, prepared = prepareEvalRunRestore(context, input)) {
346
+ const { snapshot, manifest, recovery, projectRoot, sourceRuntime, sourceProject, routingInput, selectedProfile } = prepared;
347
+ assertRunRestoreTargets(context, input, snapshot, projectRoot, manifest);
274
348
  clearProjectTree(projectRoot, false);
275
349
  restoreProjectGit(snapshot, manifest.project_git, projectRoot);
276
350
  replaceProjectTree(projectRoot, sourceProject);
@@ -370,12 +444,15 @@ function assertTerminalCandidate(stages) {
370
444
  }
371
445
  /** A stage-entry import is a new experiment, not a lease takeover or recovery.
372
446
  * 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"))
447
+ function assertSnapshotRuntimeDetachAllowed(get) {
448
+ 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"))
376
449
  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"))
450
+ if (get("SELECT 1 FROM merge_requests WHERE status IN ('dispatching','active','waiting_user','action_required','recovery_required') LIMIT 1"))
378
451
  throw new AppError("snapshot_execution_active", "A stage-entry fork cannot discard an unresolved MERGE execution", 1);
452
+ }
453
+ function detachSnapshotRuntimeOwners(context, snapshot, manifest) {
454
+ const hasTable = (table) => Boolean(context.db.get("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?", [table]));
455
+ assertSnapshotRuntimeDetachAllowed(sql => context.db.get(sql));
379
456
  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
457
  const runTables = Object.fromEntries([...detachedTables, "runs", "merge_requests", "sessions", "work_sessions"].map(table => [table, context.db.all(`SELECT * FROM ${table}`)]));
381
458
  const resourceFile = path.join(context.ddFlowHome, "runtime.sqlite");
@@ -496,15 +573,22 @@ function rebaseStructuredValue(value, oldRoot, newRoot, oldWorkspace, newWorkspa
496
573
  function rebasePathString(value, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome) { for (const [oldPrefix, newPrefix] of [[oldHome, newHome], [oldWorkspace, newWorkspace], [oldRoot, newRoot]])
497
574
  if (value === oldPrefix || value.startsWith(`${oldPrefix}${path.sep}`))
498
575
  return `${newPrefix}${value.slice(oldPrefix.length)}`; return value; }
499
- function snapshotWorkspace(workspaceRoot, projectRoot, output) {
576
+ function prepareSnapshotWorkspace(workspaceRoot, projectRoot) {
500
577
  if (samePath(workspaceRoot, projectRoot))
501
- return { kind: "project" };
578
+ return null;
502
579
  const branch = git(workspaceRoot, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
503
580
  if (!branch)
504
581
  throw new AppError("snapshot_workspace_invalid", "Feature workspace must be on a named Git branch", 1, { workspace_root: workspaceRoot });
505
582
  const baseRef = git(workspaceRoot, ["rev-parse", "HEAD"]);
506
583
  if (!baseRef)
507
584
  throw new AppError("snapshot_workspace_invalid", "Feature workspace has no readable HEAD", 1, { workspace_root: workspaceRoot });
585
+ return { branch, baseRef };
586
+ }
587
+ function snapshotWorkspace(workspaceRoot, projectRoot, output) {
588
+ const prepared = prepareSnapshotWorkspace(workspaceRoot, projectRoot);
589
+ if (!prepared)
590
+ return { kind: "project" };
591
+ const { branch, baseRef } = prepared;
508
592
  const workspace = path.join(output, "workspace");
509
593
  const bundle = path.join(output, "workspace.bundle");
510
594
  fs.cpSync(workspaceRoot, workspace, { recursive: true, verbatimSymlinks: true, filter: snapshotPayloadAllowed });
@@ -520,11 +604,7 @@ function restoreWorkspace(snapshot, manifest, projectRoot, ddFlowHome) {
520
604
  throw new AppError("snapshot_checksum_mismatch", "Snapshot workspace checksum does not match manifest", 1, { snapshot });
521
605
  if (!fs.existsSync(bundle) || fileHash(bundle) !== manifest.workspace.bundle_sha256)
522
606
  throw new AppError("snapshot_checksum_mismatch", "Snapshot workspace Git bundle does not match manifest", 1, { snapshot });
523
- const managedRoot = physicalPath(manifest.dd_flow_home);
524
- const sourceRoot = physicalPath(manifest.workspace.source_root);
525
- const workspaceRoot = isInside(managedRoot, sourceRoot)
526
- ? path.join(ddFlowHome, path.relative(managedRoot, sourceRoot))
527
- : path.join(path.dirname(projectRoot), "workspace");
607
+ const workspaceRoot = restoredWorkspaceRoot(manifest, projectRoot, ddFlowHome);
528
608
  if (fs.existsSync(workspaceRoot))
529
609
  throw new AppError("snapshot_workspace_target_exists", "Snapshot feature workspace target already exists", 1, { workspace_root: workspaceRoot });
530
610
  runGit(projectRoot, ["fetch", bundle, `+refs/heads/${manifest.workspace.branch}:refs/heads/${manifest.workspace.branch}`]);
@@ -535,14 +615,32 @@ function restoreWorkspace(snapshot, manifest, projectRoot, ddFlowHome) {
535
615
  restoreIndex(snapshot, "workspace", manifest.workspace, workspaceRoot);
536
616
  return fs.realpathSync(workspaceRoot);
537
617
  }
618
+ function restoredWorkspaceRoot(manifest, projectRoot, ddFlowHome) {
619
+ if (manifest.workspace.kind === "project")
620
+ return projectRoot;
621
+ const managedRoot = physicalPath(manifest.dd_flow_home), sourceRoot = physicalPath(manifest.workspace.source_root);
622
+ return isInside(managedRoot, sourceRoot) ? path.join(ddFlowHome, path.relative(managedRoot, sourceRoot)) : path.join(path.dirname(projectRoot), "workspace");
623
+ }
624
+ function assertRestoreTargetSeparation(snapshot, targets) {
625
+ const roots = [snapshot, ...targets].map(physicalPath);
626
+ for (let i = 0; i < roots.length; i++)
627
+ for (let j = i + 1; j < roots.length; j++) {
628
+ if (isInside(roots[i], roots[j]) || isInside(roots[j], roots[i]))
629
+ throw new AppError("snapshot_path_conflict", "Snapshot source and restore targets must not overlap", 2, { source: roots[i], target: roots[j] });
630
+ }
631
+ }
538
632
  function sourceWorkspaceRoot(manifest) {
539
633
  return manifest.workspace.kind === "project" ? manifest.project_root : manifest.workspace.source_root;
540
634
  }
541
- function snapshotProjectGit(projectRoot, output) {
635
+ function prepareSnapshotProjectGit(projectRoot) {
542
636
  const branch = git(projectRoot, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
543
637
  const head = git(projectRoot, ["rev-parse", "HEAD"]);
544
638
  if (!branch || !head)
545
639
  throw new AppError("snapshot_project_git_invalid", "Snapshot requires a project on a named Git branch with a readable HEAD", 1, { project_root: projectRoot });
640
+ return { branch, head };
641
+ }
642
+ function snapshotProjectGit(projectRoot, output) {
643
+ const { branch, head } = prepareSnapshotProjectGit(projectRoot);
546
644
  const bundle = path.join(output, "project.bundle");
547
645
  runGit(projectRoot, ["bundle", "create", bundle, "--all"]);
548
646
  return { branch, head, bundle_sha256: fileHash(bundle), ...snapshotIndex(projectRoot, output, "project") };
@@ -673,10 +771,12 @@ function verifySnapshotPayload(snapshot, manifest) {
673
771
  throw new AppError("snapshot_checksum_mismatch", "Snapshot runtime checksum does not match", 1);
674
772
  verifySnapshotModes(snapshot, manifest);
675
773
  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
- }
774
+ for (const payload of payloads)
775
+ verifyGitSnapshotPayload(snapshot, payload);
776
+ }
777
+ function verifyGitSnapshotPayload(snapshot, payload) {
778
+ 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))
779
+ throw new AppError("snapshot_checksum_mismatch", "Snapshot payload or Git index checksum does not match", 1, { payload: payload.name });
680
780
  }
681
781
  function git(root, args) {
682
782
  const result = spawnSync("git", ["-C", root, ...args], { encoding: "utf8" });
@@ -4,26 +4,19 @@ import path from "node:path";
4
4
  import { AppError } from "../shared/errors.js";
5
5
  import { harnessKey } from "./harness-config.js";
6
6
  import { vnextStages } from "../domain/stage-catalog.js";
7
+ import { prepareFileBytes, prepareJsonText } from "../cli/input-preparation.js";
7
8
  /** Read once so the frozen policy and its provenance describe the same bytes. */
8
9
  export function loadExecutionRoutingInput(file) {
9
- const source_path = path.resolve(file);
10
- let bytes;
11
- let value;
12
- try {
13
- bytes = fs.readFileSync(source_path);
14
- value = JSON.parse(bytes.toString("utf8"));
15
- }
16
- catch {
17
- throw new AppError("execution_profile_invalid", "Execution routing input must be a readable JSON file", 2, { source_path });
18
- }
10
+ const { file: source_path, bytes } = prepareFileBytes(file, "execution routing");
11
+ const value = prepareJsonText(bytes.toString("utf8"), { file: source_path, label: "execution routing" }).value;
19
12
  if (!value || typeof value !== "object" || Array.isArray(value)) {
20
- throw new AppError("execution_profile_invalid", "Execution routing input must be an object", 2, { source_path });
13
+ throw new AppError("execution_profile_invalid", "Execution routing input must be an object", 2, { source_path, phase: "prepare", effect: "no_effect", recoverable: true });
21
14
  }
22
15
  const input = value;
23
16
  if (input.schema_id !== "dd-flow/execution-routing@1"
24
17
  || Object.keys(input).some(key => key !== "schema_id" && key !== "execution")
25
18
  || !isExecutionRouting(input.execution)) {
26
- throw new AppError("execution_profile_invalid", "Execution routing input does not match dd-flow/execution-routing@1", 2, { source_path });
19
+ throw new AppError("execution_profile_invalid", "Execution routing input does not match dd-flow/execution-routing@1", 2, { source_path, phase: "prepare", effect: "no_effect", recoverable: true });
27
20
  }
28
21
  return { execution: input.execution, bytes, source_path, source_checksum: crypto.createHash("sha256").update(bytes).digest("hex") };
29
22
  }