@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
@@ -37,7 +37,14 @@ const allowedRunFlowKinds = [
37
37
  "review",
38
38
  "custom"
39
39
  ];
40
- export function startFlowRun(context, input) {
40
+ export function prepareFlowRunStart(context, input) {
41
+ const subjectId = requiredPlain(input.subjectId, "subject-id");
42
+ const subjectType = requiredPlain(input.subjectType, "subject-type");
43
+ const slug = normalizeSlug(input.slug);
44
+ for (const [name, value] of [["task profile", input.taskProfile], ["protocol overrides", input.protocolOverrides], ["RUN overrides", input.runOverrides]]) {
45
+ if (value !== undefined && (!value || typeof value !== "object" || Array.isArray(value)))
46
+ throw new AppError("validation", `${name} must be a JSON object`, 2);
47
+ }
41
48
  const projectRoot = resolveProjectRoot(input.projectRoot);
42
49
  const flowKind = parseRunFlowKind(input.flowKind);
43
50
  const vnext = flowKind === "vnext_protocolize" || flowKind === "vnext_specify";
@@ -48,12 +55,7 @@ export function startFlowRun(context, input) {
48
55
  const flowContract = input.flowKind === "mb-upgrade"
49
56
  ? loadUpgradeFlowContract(context, projectRoot)
50
57
  : loadProjectFlowContract(projectRoot);
51
- registerProject(context, { root: projectRoot });
52
- const project = requireProjectByRoot(context, projectRoot);
53
58
  const workspaceRoot = resolveWorkspaceRoot(input.workspaceRoot ?? projectRoot);
54
- const slug = normalizeSlug(input.slug);
55
- const runId = nextRunId(context, project.id, slug);
56
- const { shortId } = parseFullEntityId(runId);
57
59
  const now = context.now();
58
60
  const flowFlags = resolveFlowFlags(flowContract, {
59
61
  flowKind,
@@ -64,6 +66,14 @@ export function startFlowRun(context, input) {
64
66
  snapshotRevision: 1,
65
67
  now
66
68
  });
69
+ return { projectRoot, flowKind, routingInput, executionProfile, workspaceRoot, slug, subjectId, subjectType, now, flowFlags };
70
+ }
71
+ export function startFlowRun(context, input, prepared = prepareFlowRunStart(context, input)) {
72
+ const { projectRoot, flowKind, routingInput, executionProfile, workspaceRoot, slug, subjectId, subjectType, now, flowFlags } = prepared;
73
+ registerProject(context, { root: projectRoot });
74
+ const project = requireProjectByRoot(context, projectRoot);
75
+ const runId = nextRunId(context, project.id, slug);
76
+ const { shortId } = parseFullEntityId(runId);
67
77
  const runHome = projectRunHome(context.ddFlowHome, project.id, runId);
68
78
  const runDirRelative = path.posix.join("runs", runId);
69
79
  const runtimePath = projectRunJsonPath(context.ddFlowHome, project.id, runId);
@@ -73,8 +83,8 @@ export function startFlowRun(context, input) {
73
83
  short_id: shortId,
74
84
  flow_kind: flowKind,
75
85
  subject: {
76
- id: requiredPlain(input.subjectId, "subject-id"),
77
- title: requiredPlain(input.subjectId, "subject-id")
86
+ id: subjectId,
87
+ title: subjectId
78
88
  },
79
89
  project: {
80
90
  id: project.id,
@@ -147,7 +157,7 @@ export function startFlowRun(context, input) {
147
157
  project.root,
148
158
  workspaceRoot,
149
159
  flowKind,
150
- requiredPlain(input.subjectType, "subject-type"),
160
+ subjectType,
151
161
  index.subject.id,
152
162
  index.status,
153
163
  index.verdict,
@@ -323,9 +333,12 @@ export function getFlowRunVariables(context, input) {
323
333
  return { ok: true, run_id: run.id, variables };
324
334
  }
325
335
  export function setFlowRunVariable(context, input) {
336
+ return setRunVariable(context, input, input.value, prepareFlowRunVariable(context, input));
337
+ }
338
+ export function prepareFlowRunVariable(context, input) {
326
339
  if (!input.key.startsWith("user."))
327
340
  throw new AppError("validation", "Only user.* RUN variables may be set directly", 2, { key: input.key });
328
- return setRunVariable(context, input, input.value);
341
+ return prepareRunVariable(context, input);
329
342
  }
330
343
  /** Controllers own policy.* and runtime.* values; agents never call this directly. */
331
344
  export function setRuntimeRunVariable(context, input) {
@@ -334,21 +347,7 @@ export function setRuntimeRunVariable(context, input) {
334
347
  return setRunVariable(context, input, input.value);
335
348
  }
336
349
  export function setFlowRunConfig(context, input) {
337
- if (input.key !== "plan_review.mode" && input.key !== "code_review.mode") {
338
- throw new AppError("validation", `Unknown RUN config key: ${input.key}`, 2);
339
- }
340
- if (!["auto", "off", "standard", "deep"].includes(input.value)) {
341
- throw new AppError("validation", "--value for review mode must be auto, off, standard, or deep", 2);
342
- }
343
- const reason = requiredPlain(input.reason, "reason");
344
- const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
345
- const run = resolveRun(context, project.id, input.runId);
346
- assertRunMutationAllowed(context, project.id, run.id);
347
- const index = authoritativeIndex(run);
348
- const stageName = input.key === "plan_review.mode" ? "plan-review" : "code-review";
349
- if (index.stage_runs.some((stage) => stage.stage === stageName)) {
350
- throw new AppError("invalid_run_config_state", `${input.key} is frozen after ${stageName} starts`, 2, { run_id: run.id });
351
- }
350
+ const { reason, project, run, index } = prepareFlowRunConfig(context, input);
352
351
  const now = context.now();
353
352
  const settings = runSettings(index);
354
353
  const previous = input.key === "plan_review.mode" ? settings.plan_review : settings.code_review;
@@ -366,6 +365,24 @@ export function setFlowRunConfig(context, input) {
366
365
  appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_config_set", run_id: run.id, key: input.key, value: mode, source, reason });
367
366
  return { ok: true, run_id: run.id, settings: runSettings(index), idempotent: false };
368
367
  }
368
+ export function prepareFlowRunConfig(context, input) {
369
+ if (input.key !== "plan_review.mode" && input.key !== "code_review.mode") {
370
+ throw new AppError("validation", `Unknown RUN config key: ${input.key}`, 2);
371
+ }
372
+ if (!["auto", "off", "standard", "deep"].includes(input.value)) {
373
+ throw new AppError("validation", "--value for review mode must be auto, off, standard, or deep", 2);
374
+ }
375
+ const reason = requiredPlain(input.reason, "reason");
376
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
377
+ const run = resolveRun(context, project.id, input.runId);
378
+ assertRunMutationAllowed(context, project.id, run.id);
379
+ const index = authoritativeIndex(run);
380
+ const stageName = input.key === "plan_review.mode" ? "plan-review" : "code-review";
381
+ if (index.stage_runs.some((stage) => stage.stage === stageName)) {
382
+ throw new AppError("invalid_run_config_state", `${input.key} is frozen after ${stageName} starts`, 2, { run_id: run.id });
383
+ }
384
+ return { reason, project, run, index };
385
+ }
369
386
  /** Freeze an automatic review request once from already accepted policy or PLAN facts. */
370
387
  export function freezeFlowRunReviewMode(context, input) {
371
388
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -535,17 +552,22 @@ export function listFlowRuns(context, input) {
535
552
  .map(flowRunSummary);
536
553
  return { ok: true, project, runs };
537
554
  }
538
- export function attachFlowRunStage(context, input) {
555
+ export function prepareFlowRunStageAttachment(context, input) {
539
556
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
540
557
  const run = resolveRun(context, project.id, input.runId);
541
558
  assertRunMutationAllowed(context, project.id, run.id);
542
559
  const index = authoritativeIndex(run);
543
- const now = context.now();
544
560
  const stage = requiredPlain(input.stage, "stage");
545
- const dir = requiredStageDir(input.dir);
561
+ const dir = prepareStageDirectory(input.dir);
546
562
  const status = parseStageStatus(input.status);
547
563
  const existing = index.stage_runs.find((item) => item.stage === stage);
548
564
  assertVnextStageStart(run, index, stage, status, existing);
565
+ return { project, run, index, stage, dir, status, existing };
566
+ }
567
+ export function attachFlowRunStage(context, input) {
568
+ // Mutable stage state is re-read immediately before archiving/persisting.
569
+ const { project, run, index, stage, dir, status, existing } = prepareFlowRunStageAttachment(context, input);
570
+ const now = context.now();
549
571
  if (status === "running" && existing) {
550
572
  archiveExistingStageAttempt(context, project.id, run.id, runArtifactRoot(run), dir);
551
573
  }
@@ -587,7 +609,17 @@ export function attachFlowRunStage(context, input) {
587
609
  export function withStageSettlement(context, operation) {
588
610
  return context.db.writeTransaction(operation);
589
611
  }
590
- export function completeFlowRunStage(context, input) {
612
+ export function prepareFlowRunStageCompletion(context, input) {
613
+ for (const [name, value] of Object.entries({ stageReport: input.stageReport, data: input.data, dataSchemaId: input.dataSchemaId, report: input.report })) {
614
+ if (value !== undefined)
615
+ requiredPlain(value, name);
616
+ }
617
+ if (input.aliases !== undefined) {
618
+ if (!Array.isArray(input.aliases))
619
+ throw new AppError("validation", "artifact aliases must be an array", 2);
620
+ for (const alias of input.aliases)
621
+ requiredPlain(alias, "artifact alias");
622
+ }
591
623
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
592
624
  const run = resolveRun(context, project.id, input.runId);
593
625
  if (["done", "failed", "skipped"].includes(input.status))
@@ -595,13 +627,17 @@ export function completeFlowRunStage(context, input) {
595
627
  else
596
628
  assertRunMutationAllowed(context, project.id, run.id);
597
629
  const index = authoritativeIndex(run);
598
- const now = context.now();
599
630
  const stage = requiredPlain(input.stage, "stage");
600
631
  const status = parseStageStatus(input.status);
601
632
  const existing = index.stage_runs.find((item) => item.stage === stage);
602
633
  if (!existing) {
603
634
  throw new AppError("not_found", `Run stage is not attached: ${stage}`, 1, { run_id: run.id, stage });
604
635
  }
636
+ return { project, run, index, stage, status, existing };
637
+ }
638
+ export function completeFlowRunStage(context, input) {
639
+ const { project, run, index, stage, status, existing } = prepareFlowRunStageCompletion(context, input);
640
+ const now = context.now();
605
641
  const stageRun = {
606
642
  ...existing,
607
643
  status,
@@ -717,7 +753,11 @@ export function resumeFlowRunStage(context, input) {
717
753
  return { run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
718
754
  }
719
755
  /** Pause the current stage for a non-user runtime blocker. */
720
- export function blockFlowRunStage(context, input) {
756
+ export function prepareFlowRunStageBlock(context, input) {
757
+ if (!["engine", "harness", "environment"].includes(input.kind) || typeof input.retryable !== "boolean")
758
+ throw new AppError("validation", "stage block requires a valid kind and boolean retryable", 2);
759
+ requiredPlain(input.code, "code");
760
+ requiredPlain(input.summary, "summary");
721
761
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
722
762
  const run = resolveRun(context, project.id, input.runId);
723
763
  assertRunMutationAllowed(context, project.id, run.id);
@@ -726,6 +766,10 @@ export function blockFlowRunStage(context, input) {
726
766
  const stageRun = index.stage_runs.find((item) => item.stage === stage);
727
767
  if (!stageRun || stageRun.status !== "running")
728
768
  throw new AppError("invalid_stage_state", "Only a running stage can be blocked", 1, { run_id: run.id, stage, status: stageRun?.status ?? "missing" });
769
+ return { project, run, index, stage, stageRun };
770
+ }
771
+ export function blockFlowRunStage(context, input, prepared = prepareFlowRunStageBlock(context, input)) {
772
+ const { project, run, index, stage, stageRun } = prepared;
729
773
  const now = context.now();
730
774
  stageRun.status = "blocked";
731
775
  stageRun.updated_at = now;
@@ -743,7 +787,7 @@ export function blockFlowRunStage(context, input) {
743
787
  return { run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
744
788
  }
745
789
  /** Continue the same stage after its non-user blocker was repaired externally. */
746
- export function unblockFlowRunStage(context, input) {
790
+ export function prepareFlowRunStageUnblock(context, input) {
747
791
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
748
792
  const run = resolveRun(context, project.id, input.runId);
749
793
  assertRunMutationAllowed(context, project.id, run.id);
@@ -752,8 +796,11 @@ export function unblockFlowRunStage(context, input) {
752
796
  const stageRun = index.stage_runs.find((item) => item.stage === stage);
753
797
  if (!stageRun || stageRun.status !== "blocked" || !stageRun.blocker || stageRun.blocker.work_id !== input.workId)
754
798
  throw new AppError("invalid_stage_state", "Stage unblock requires its matching runtime blocker", 1, { run_id: run.id, stage, work_id: input.workId, status: stageRun?.status ?? "missing" });
799
+ return { project, run, index, stage, stageRun, blocker: stageRun.blocker };
800
+ }
801
+ export function unblockFlowRunStage(context, input, prepared = prepareFlowRunStageUnblock(context, input)) {
802
+ const { project, run, index, stage, stageRun, blocker } = prepared;
755
803
  const now = context.now();
756
- const blocker = stageRun.blocker;
757
804
  const blockedMs = durationMs(blocker.blocked_at, now) ?? 0;
758
805
  stageRun.status = "running";
759
806
  stageRun.updated_at = now;
@@ -793,12 +840,20 @@ export function setFlowRunRecoveryPaused(context, projectId, runId, paused) {
793
840
  const project = requireProjectByRoot(context, run.project_root);
794
841
  persistRunState(context, project, run, index);
795
842
  }
796
- export function completeFlowRun(context, input) {
843
+ export function prepareFlowRunCompletion(context, input) {
844
+ if (input.manualOverrideReason !== undefined) {
845
+ requiredPlain(input.manualOverrideReason, "override reason");
846
+ if (!["cancelled", "failed"].includes(input.status))
847
+ throw new AppError("validation", "Manual RUN override may only cancel or fail a RUN", 2);
848
+ }
849
+ if (input.verdict !== undefined)
850
+ requiredPlain(input.verdict, "verdict");
851
+ if (input.nextAction !== undefined)
852
+ requiredPlain(input.nextAction, "next action");
797
853
  const project = requireProjectByRoot(context, input.manualOverrideReason ? canonicalPath(input.projectRoot, false) : resolveProjectRoot(input.projectRoot));
798
854
  const run = resolveRun(context, project.id, input.runId);
799
855
  assertRunMutationAllowed(context, project.id, run.id);
800
856
  const index = authoritativeIndex(run);
801
- const now = context.now();
802
857
  const status = parseRunStatus(input.status);
803
858
  if (!['done', 'cancelled', 'failed'].includes(status)) {
804
859
  throw new AppError('validation', 'completeFlowRun accepts only done, cancelled, or failed; use stage block/unblock for a recoverable blocker', 2, { status });
@@ -808,6 +863,11 @@ export function completeFlowRun(context, input) {
808
863
  if (activeWork)
809
864
  throw new AppError("active_work", "RUN cannot complete while Work remains active", 2, { run_id: run.id, work_id: activeWork.work_id, status: activeWork.status });
810
865
  }
866
+ return { project, run, index, status };
867
+ }
868
+ export function completeFlowRun(context, input) {
869
+ const { project, run, index, status } = prepareFlowRunCompletion(context, input);
870
+ const now = context.now();
811
871
  if (input.manualOverrideReason && (status === "cancelled" || status === "failed")) {
812
872
  closeOpenStagesForOverride(index, status, now);
813
873
  closeOpenWorksForOverride(context, project.id, run.id, status, now);
@@ -1273,11 +1333,15 @@ function materializedRunVariables(index) {
1273
1333
  ...(index.variables ?? {})
1274
1334
  };
1275
1335
  }
1276
- function setRunVariable(context, input, value) {
1336
+ function prepareRunVariable(context, input) {
1277
1337
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
1278
1338
  const run = resolveRun(context, project.id, input.runId);
1279
1339
  assertRunMutationAllowed(context, project.id, run.id);
1280
1340
  const index = authoritativeIndex(run);
1341
+ return { project, run, index };
1342
+ }
1343
+ function setRunVariable(context, input, value, prepared = prepareRunVariable(context, input)) {
1344
+ const { project, run, index } = prepared;
1281
1345
  if (index.variables?.[input.key] === value)
1282
1346
  return { ok: true, run_id: run.id, key: input.key, value, idempotent: true };
1283
1347
  index.variables = { ...(index.variables ?? {}), [input.key]: value };
@@ -1328,10 +1392,12 @@ function readRuntimeSnapshot(file) {
1328
1392
  function authoritativeIndex(run) {
1329
1393
  try {
1330
1394
  const state = JSON.parse(run.index_json);
1395
+ if (!state || typeof state !== "object" || Array.isArray(state) || !Array.isArray(state.stage_runs))
1396
+ throw new Error("RUN index must contain stage_runs");
1331
1397
  return { ...state, schema_id: runSchemaId };
1332
1398
  }
1333
1399
  catch (error) {
1334
- throw new AppError("validation", "RUN mechanical state in SQLite is missing or invalid", 2, { run_id: run.id, cause: String(error) });
1400
+ throw new AppError("runtime_state_invalid", "RUN mechanical state in SQLite is missing or invalid", 1, { run_id: run.id, cause: String(error) });
1335
1401
  }
1336
1402
  }
1337
1403
  function sanitizeTimelineValue(value, key) {
@@ -1486,7 +1552,7 @@ function parseStageStatus(value) {
1486
1552
  }
1487
1553
  return value;
1488
1554
  }
1489
- function requiredStageDir(value) {
1555
+ export function prepareStageDirectory(value) {
1490
1556
  const dir = requiredPlain(value, "dir");
1491
1557
  if (!/^\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*$/.test(dir)) {
1492
1558
  throw new AppError("validation", "--dir must use NN-stage-slug format", 2, { dir });
@@ -1494,6 +1560,8 @@ function requiredStageDir(value) {
1494
1560
  return dir;
1495
1561
  }
1496
1562
  function requiredPlain(value, label) {
1563
+ if (typeof value !== "string")
1564
+ throw new AppError("validation", `${label} must be a string`, 2);
1497
1565
  const trimmed = value.trim();
1498
1566
  if (!trimmed) {
1499
1567
  throw new AppError("validation", `${label} must not be empty`, 2);
@@ -1501,6 +1569,8 @@ function requiredPlain(value, label) {
1501
1569
  return trimmed;
1502
1570
  }
1503
1571
  function normalizeSlug(value) {
1572
+ if (typeof value !== "string")
1573
+ throw new AppError("validation", "--slug must be a string", 2);
1504
1574
  const slug = value
1505
1575
  .normalize("NFKD")
1506
1576
  .replace(/[\u0300-\u036f]/g, "")
@@ -1518,6 +1588,8 @@ function resolveWorkspaceRoot(workspaceRoot) {
1518
1588
  if (!fs.existsSync(absolute)) {
1519
1589
  throw new AppError("not_found", `Workspace root does not exist: ${absolute}`, 1);
1520
1590
  }
1591
+ if (!fs.statSync(absolute).isDirectory())
1592
+ throw new AppError("validation", "Workspace root must be a directory", 2, { workspace_root: absolute });
1521
1593
  return fs.realpathSync(absolute);
1522
1594
  }
1523
1595
  /** Move one completed stage attempt and update only known durable path columns.
@@ -1,28 +1,49 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import { DatabaseSync } from "node:sqlite";
4
5
  import { setTimeout as delay } from "node:timers/promises";
5
- import { getResourceDatabase } from "../storage/database.js";
6
+ import { getResourceDatabase, withResourceReadSnapshot } from "../storage/database.js";
6
7
  import { AppError } from "../shared/errors.js";
7
8
  import { harnessKey } from "./harness-config.js";
8
9
  import { managedProcessBinding, resourceHome, processIsAlive } from "./managed-processes.js";
9
10
  import { recoveryRuntimeAdmission } from "./run-recovery-runtime.js";
11
+ import { prepareTextFile, prepareJsonFile, prepareError } from "../cli/input-preparation.js";
10
12
  const digest = (value) => crypto.createHash("sha256").update(value).digest("hex");
11
13
  export function validateRuntimeScopeManifest(value, scopeId) {
12
14
  const item = value;
13
15
  if (!item || typeof item !== "object" || Array.isArray(item) || Object.keys(item).some(key => !["schema_id", "scope_id", "manifest_path", "manifest_sha256", "execution_ids"].includes(key)) || item.schema_id !== "dd-flow/runtime-scope-manifest@1" || item.scope_id !== scopeId || typeof item.manifest_path !== "string" || !path.isAbsolute(item.manifest_path) || typeof item.manifest_sha256 !== "string" || !/^[a-f0-9]{64}$/.test(item.manifest_sha256) || !Array.isArray(item.execution_ids) || item.execution_ids.some(id => typeof id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(id)) || new Set(item.execution_ids).size !== item.execution_ids.length)
14
16
  throw new AppError("runtime_scope_manifest_invalid", "Scope manifest requires frozen source identity and unique execution IDs", 2);
15
- const bytes = fs.readFileSync(item.manifest_path, "utf8");
17
+ const bytes = prepareTextFile(item.manifest_path, "scope manifest").text;
16
18
  if (digest(bytes) !== item.manifest_sha256)
17
19
  throw new AppError("runtime_scope_manifest_changed", "EVAL manifest no longer matches its frozen identity", 1);
18
- const source = JSON.parse(bytes);
20
+ let source;
21
+ try {
22
+ source = JSON.parse(bytes);
23
+ }
24
+ catch {
25
+ throw new AppError("runtime_scope_manifest_corrupt", "Retained EVAL manifest contains invalid JSON", 1);
26
+ }
27
+ if (!source || typeof source !== "object" || Array.isArray(source))
28
+ throw new AppError("runtime_scope_manifest_corrupt", "Retained EVAL manifest must be an object", 1);
19
29
  if (source.run_id !== scopeId || !Array.isArray(source.executions) || JSON.stringify(source.executions.map(execution => execution?.id)) !== JSON.stringify(item.execution_ids))
20
30
  throw new AppError("runtime_scope_manifest_invalid", "Scope execution inventory does not match the retained EVAL manifest", 2);
21
- return { schema_id: item.schema_id, scope_id: scopeId, manifest_path: item.manifest_path, manifest_sha256: item.manifest_sha256, execution_ids: item.execution_ids };
31
+ return { schema_id: item.schema_id, scope_id: scopeId, manifest_path: item.manifest_path, manifest_sha256: item.manifest_sha256, execution_ids: [...item.execution_ids] };
22
32
  }
23
33
  export function publicRuntimeScopeWorker(worker) {
24
34
  return { status: worker.status, generation: worker.generation, process_id: worker.process_id ?? null, owner_pid: worker.owner?.pid ?? null, error: worker.error ?? null, snapshot: worker.snapshot ?? null };
25
35
  }
36
+ /** Captured manifests are runtime authority, not a fresh CLI argument. */
37
+ export function validateRetainedRuntimeScopeManifest(value, scopeId) {
38
+ try {
39
+ return validateRuntimeScopeManifest(value, scopeId);
40
+ }
41
+ catch (error) {
42
+ if (error instanceof AppError && error.exitCode === 2)
43
+ throw new AppError("runtime_scope_manifest_corrupt", "Retained scope manifest is invalid or unavailable", 1, { scope_id: scopeId, cause_code: error.code });
44
+ throw error;
45
+ }
46
+ }
26
47
  /** Prepared native owners cannot send the first recovery Turn until the scope
27
48
  * arbiter publishes their exact release. Fence disappearance alone is not ACK. */
28
49
  export async function waitForRuntimeScopeRecovery(context, input, assertOwner) {
@@ -63,30 +84,56 @@ export async function waitForRuntimeScopeRecovery(context, input, assertOwner) {
63
84
  }
64
85
  /** Admission intent only. A persisted request is not physical settlement.
65
86
  * The resource registry arbitrates all runtime homes in the selected EVAL. */
66
- export function requestRuntimeScopeControl(context, scopeId, requestId, mode, manifest) {
67
- parseRuntimeBudget(JSON.stringify({ schema_id: "dd-flow/runtime-budget@1", scope_id: scopeId, per_harness: {} }));
68
- if (!requestId || requestId.length > 256 || !["pause", "stop"].includes(mode))
69
- throw new AppError("runtime_budget_invalid", "Scope control requires a bounded request identity and pause or stop", 2);
87
+ export function prepareRuntimeScopeControl(scopeId, requestId, mode, manifest, context) {
88
+ prepareRuntimeScopeFence(scopeId, requestId);
89
+ if (!["pause", "stop"].includes(mode))
90
+ throw new AppError("runtime_budget_invalid", "Scope control requires pause or stop", 2);
91
+ const file = context && path.join(resourceHome(context), "runtime.sqlite");
92
+ if (file && fs.existsSync(file)) {
93
+ const db = new DatabaseSync(file, { readOnly: true });
94
+ try {
95
+ db.exec("PRAGMA busy_timeout = 4000");
96
+ // Another process may have created the file but not its first schema yet.
97
+ // No table means no retained receipt; execute rechecks after initialization.
98
+ const ready = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'managed_resources'").get();
99
+ const prior = ready ? db.prepare("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control-request' AND resource_key = ?").get(digest(JSON.stringify([scopeId, requestId]))) : undefined;
100
+ if (prior)
101
+ return { receipt: scopeControlReceipt(prior.metadata_json, mode) };
102
+ }
103
+ finally {
104
+ db.close();
105
+ }
106
+ }
107
+ return { manifest: manifest === undefined ? undefined : validateRuntimeScopeManifest(manifest, scopeId) };
108
+ }
109
+ function scopeControlReceipt(json, mode) {
110
+ const receipt = JSON.parse(json);
111
+ if (receipt.requested_mode !== mode)
112
+ throw new AppError("runtime_scope_control_conflict", "Scope request identity was already used for another mode", 1);
113
+ return receipt;
114
+ }
115
+ export function requestRuntimeScopeControl(context, scopeId, requestId, mode, manifest, prepared = prepareRuntimeScopeControl(scopeId, requestId, mode, manifest, context)) {
116
+ if (prepared.receipt)
117
+ return prepared.receipt;
118
+ const declaration = prepared.manifest;
70
119
  const db = getResourceDatabase(resourceHome(context));
71
120
  const requestKey = digest(JSON.stringify([scopeId, requestId]));
72
121
  db.beginWriteTransaction();
73
122
  try {
74
- if (manifest !== undefined) {
75
- const declaration = validateRuntimeScopeManifest(manifest, scopeId), payload = JSON.stringify(declaration);
123
+ const prior = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control-request' AND resource_key = ?", [requestKey]);
124
+ if (prior) {
125
+ const receipt = scopeControlReceipt(prior.metadata_json, mode);
126
+ db.exec("COMMIT");
127
+ return receipt;
128
+ }
129
+ if (declaration !== undefined) {
130
+ const payload = JSON.stringify(declaration);
76
131
  const saved = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-manifest' AND resource_key = ?", [scopeId]);
77
132
  if (saved && saved.metadata_json !== payload)
78
133
  throw new AppError("runtime_scope_manifest_changed", "Scope already belongs to another frozen EVAL manifest", 1);
79
134
  if (!saved)
80
135
  db.run("INSERT INTO managed_resources (resource_kind,resource_key,owner_id,lease_token,lease_expires_at,process_id,metadata_json,created_at,updated_at) VALUES ('scope-manifest',?,?,?,?,NULL,?,?,?)", [scopeId, scopeId, declaration.manifest_sha256, context.now(), payload, context.now(), context.now()]);
81
136
  }
82
- const prior = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control-request' AND resource_key = ?", [requestKey]);
83
- if (prior) {
84
- const receipt = JSON.parse(prior.metadata_json);
85
- if (receipt.requested_mode !== mode)
86
- throw new AppError("runtime_scope_control_conflict", "Scope request identity was already used for another mode", 1);
87
- db.exec("COMMIT");
88
- return receipt;
89
- }
90
137
  if (db.get("SELECT 1 FROM managed_resources WHERE resource_kind = 'provider-fence' AND resource_key = ?", [scopeId]))
91
138
  throw new AppError("runtime_scope_stopped", "Terminal EVAL cancellation cannot become recoverable control", 1);
92
139
  const current = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control' AND resource_key = ?", [scopeId]);
@@ -130,31 +177,43 @@ export function scopeLocalProcessRole(record) {
130
177
  }
131
178
  /** Local checks and EVAL observers share the scope fence, not provider capacity. */
132
179
  export function admitRuntimeCheck(context, id, leaseToken) {
133
- const db = getResourceDatabase(resourceHome(context));
134
- db.beginWriteTransaction();
180
+ if (typeof id !== "string" || !id.trim() || typeof leaseToken !== "string" || !leaseToken.trim())
181
+ throw new AppError("validation", "Check admission requires a process ID and lease token", 2);
182
+ const db = getResourceDatabase(resourceHome(context), "read_existing");
135
183
  try {
136
- const record = db.get("SELECT * FROM managed_processes WHERE id = ?", [id]);
184
+ // One statement observes the owner and both admission fences together,
185
+ // without taking a writer lock or initializing an absent registry.
186
+ const record = db.get(`SELECT p.*,
187
+ EXISTS(SELECT 1 FROM managed_resources WHERE resource_kind = 'provider-fence' AND resource_key = json_extract(p.metadata_json, '$.budget.scope_id')) AS scope_fenced,
188
+ (SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control' AND resource_key = json_extract(p.metadata_json, '$.budget.scope_id')) AS scope_control
189
+ FROM managed_processes p WHERE p.id = ?`, [id]);
137
190
  if (!record || record.lease_token !== leaseToken || !["eval-baseline", "eval-observer", "check"].includes(record.kind) || record.state !== "running" || !processIsAlive(record))
138
191
  throw new AppError("process_ownership_unknown", "Local process requires its confirmed live owner", 1);
139
- const metadata = JSON.parse(record.metadata_json);
140
- const budget = parseRuntimeBudget(metadata.budget ? JSON.stringify(metadata.budget) : undefined);
141
- if (!budget || (record.kind !== "check" ? scopeLocalProcessRole(record) === null : metadata.role !== "check" || !record.check_id))
142
- throw new AppError("runtime_budget_invalid", "Local process requires a retained EVAL scope and operation", 2);
143
- assertScopeAdmission(context, budget.scope_id);
144
- db.exec("COMMIT");
192
+ let budget;
193
+ try {
194
+ const metadata = JSON.parse(record.metadata_json);
195
+ const retained = parseRuntimeBudget(metadata.budget ? JSON.stringify(metadata.budget) : undefined);
196
+ if (!retained || (record.kind !== "check" ? scopeLocalProcessRole(record) === null : metadata.role !== "check" || !record.check_id))
197
+ throw new Error("Missing retained EVAL scope or operation");
198
+ budget = retained;
199
+ }
200
+ catch (error) {
201
+ throw new AppError("runtime_artifact_invalid", "Local process has invalid retained scope or operation metadata", 1, { process_id: record.id, cause: String(error) });
202
+ }
203
+ if (record.scope_fenced)
204
+ throw new AppError("runtime_scope_stopped", "This runtime scope no longer admits productive dispatch", 1, { scope_id: budget.scope_id });
205
+ if (record.scope_control)
206
+ throw new AppError("runtime_scope_controlled", "This runtime scope is under operator control", 1, { control: JSON.parse(record.scope_control) });
145
207
  return { admitted: true, process_id: record.id, scope_id: budget.scope_id };
146
208
  }
147
- catch (error) {
148
- db.exec("ROLLBACK");
149
- throw error;
209
+ finally {
210
+ db.close?.();
150
211
  }
151
212
  }
152
213
  /** Scope selection uses retained registration, never a current profile or PID search. */
153
214
  export function runtimeBudgetStatus(context, scopeId) {
154
215
  parseRuntimeBudget(JSON.stringify({ schema_id: "dd-flow/runtime-budget@1", scope_id: scopeId, per_harness: {} }));
155
- const db = getResourceDatabase(resourceHome(context));
156
- db.exec("SAVEPOINT runtime_scope_status");
157
- try {
216
+ return withResourceReadSnapshot(resourceHome(context), db => {
158
217
  const fence = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'provider-fence' AND resource_key = ?", [scopeId]);
159
218
  const control = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control' AND resource_key = ?", [scopeId]);
160
219
  const processes = db.all("SELECT * FROM managed_processes WHERE json_extract(metadata_json, '$.budget.scope_id') = ? ORDER BY id", [scopeId]);
@@ -173,21 +232,18 @@ export function runtimeBudgetStatus(context, scopeId) {
173
232
  const worker = workerGeneration ? db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control-worker' AND resource_key = ?", [digest(JSON.stringify([scopeId, workerGeneration]))]) : null;
174
233
  const drain = intent ? db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control-drain' AND resource_key = ?", [digest(JSON.stringify([scopeId, intent.generation]))]) : null;
175
234
  const result = { scope_id: scopeId, dispatch_blocked: Boolean(fence || control), fence: fence ? JSON.parse(fence.metadata_json) : null, control: intent, worker: worker ? publicRuntimeScopeWorker(JSON.parse(worker.metadata_json)) : null, drain: drain ? JSON.parse(drain.metadata_json) : null, manifest: manifest ? JSON.parse(manifest.metadata_json) : null, resume: resume ? { ...resume, dispatch_blocked: Boolean(fence || control), current: resume.status === "released" ? releaseCurrent : !fence && intent?.generation === resume.generation } : null, release: release ? { ...release, current: releaseCurrent } : null, processes, provider_turns: providerTurns };
176
- db.exec("RELEASE runtime_scope_status");
177
235
  return result;
178
- }
179
- catch (error) {
180
- db.exec("ROLLBACK TO runtime_scope_status");
181
- db.exec("RELEASE runtime_scope_status");
182
- throw error;
183
- }
236
+ });
184
237
  }
185
238
  /** Persist admission shutdown independently of client/process lease expiry.
186
239
  * This is a dispatch fence, not evidence that the existing native tree stopped. */
187
- export function fenceRuntimeBudget(context, scopeId, requestId) {
240
+ export function prepareRuntimeScopeFence(scopeId, requestId) {
188
241
  parseRuntimeBudget(JSON.stringify({ schema_id: "dd-flow/runtime-budget@1", scope_id: scopeId, per_harness: {} }));
189
- if (!requestId || requestId.length > 256)
190
- throw new AppError("runtime_budget_invalid", "Scope fence requires a bounded request identity", 2);
242
+ if (typeof requestId !== "string" || !requestId.trim() || requestId.length > 256)
243
+ throw new AppError("runtime_budget_invalid", "Scope control requires a nonempty request identity of at most 256 characters", 2);
244
+ }
245
+ export function fenceRuntimeBudget(context, scopeId, requestId) {
246
+ prepareRuntimeScopeFence(scopeId, requestId);
191
247
  const db = getResourceDatabase(resourceHome(context));
192
248
  db.beginWriteTransaction();
193
249
  try {
@@ -323,9 +379,25 @@ function scopeRecoveryLoadAllowed(context, scopeId, record, input) {
323
379
  AND json_extract(o.input_json, '$.request_id') = ? AND json_extract(o.input_json, '$.control_id') = ctl.control_id`, [source.control_id, record.owner_id, record.project_id, record.run_id, source.recovery_id, source.resume_request_id]);
324
380
  return Boolean(prepared) && recoveryRuntimeAdmission(context, record, { stateDir: metadata.state_dir, daemonId: input.daemonId, operationId: input.operationId, operation: input.operation, nativeSessionId: input.sessionId });
325
381
  }
326
- /** Release uses the daemon's durable reply or fresh whole-tree observation.
327
- * Unknown/error replies and client timeouts are deliberately not release proof. */
328
- export function releaseProviderTurns(context, record, input) {
382
+ export function prepareProviderTurnRelease(input) {
383
+ if (Boolean(input.operationId) === Boolean(input.observationFile))
384
+ return prepareError(new AppError("usage", "Provide exactly one of --operation-id or --observation-file", 2));
385
+ if (!input.observationFile)
386
+ return { observation: null };
387
+ // This file is a retained daemon receipt, not model-authored input. Broken
388
+ // receipt syntax/schema is infrastructure failure, never a retryable typo.
389
+ let observed;
390
+ try {
391
+ observed = prepareJsonFile(input.observationFile, "budget observation").value;
392
+ }
393
+ catch {
394
+ throw new AppError("runtime_budget_evidence_invalid", "Cannot read valid JSON from the daemon budget observation", 1);
395
+ }
396
+ if (!observed || typeof observed !== "object" || Array.isArray(observed) || typeof observed.daemon_id !== "string" || typeof observed.session_id !== "string" || !observed.session_id || typeof observed.settled !== "boolean" || !Array.isArray(observed.operation_ids) || observed.operation_ids.some(id => typeof id !== "string" || !id))
397
+ throw new AppError("runtime_budget_evidence_invalid", "Budget observation has an invalid receipt shape", 1);
398
+ return { observation: observed };
399
+ }
400
+ export function releaseProviderTurns(context, record, input, prepared = prepareProviderTurnRelease(input)) {
329
401
  const metadata = JSON.parse(record.metadata_json);
330
402
  if (!metadata.state_dir)
331
403
  throw new AppError("process_ownership_unknown", "Budget release has no retained state directory", 1);
@@ -338,8 +410,8 @@ export function releaseProviderTurns(context, record, input) {
338
410
  const parent = path.resolve(metadata.state_dir, "budget-observations");
339
411
  if (path.dirname(path.resolve(input.observationFile)) !== parent)
340
412
  throw new AppError("runtime_budget_evidence_invalid", "Budget observation is outside its daemon directory", 2);
341
- const observed = JSON.parse(fs.readFileSync(input.observationFile, "utf8"));
342
- if (observed.daemon_id !== input.daemonId || observed.settled !== true || typeof observed.session_id !== "string" || !observed.session_id || !Array.isArray(observed.operation_ids) || observed.operation_ids.some(id => typeof id !== "string" || !id))
413
+ const observed = prepared.observation;
414
+ if (!observed || observed.daemon_id !== input.daemonId || observed.settled !== true)
343
415
  throw new AppError("runtime_budget_evidence_invalid", "Budget release requires fresh settled Session evidence", 1);
344
416
  sessionId = observed.session_id;
345
417
  operationIds = observed.operation_ids;
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import { AppError } from "../shared/errors.js";
5
5
  import { getResourceDatabase } from "../storage/database.js";
6
6
  import { resourceHome } from "./managed-processes.js";
7
- import { validateRuntimeScopeManifest } from "./runtime-budget.js";
7
+ import { validateRetainedRuntimeScopeManifest } from "./runtime-budget.js";
8
8
  /** Archive a complete append-only prefix, not a claim that no later evidence
9
9
  * can arrive. The caller holds the registry transaction; never take the EVAL
10
10
  * journal lock here (its writer may itself be waiting for the registry).
@@ -34,7 +34,7 @@ export function captureScopeJournal(context, control, manifest) {
34
34
  if (request?.data?.request_id !== control.request_id || request.data.mode !== control.requested_mode)
35
35
  throw new AppError("runtime_scope_control_superseded", "EVAL journal belongs to another control request", 1);
36
36
  const manifestBytes = fs.readFileSync(manifest.manifest_path, "utf8");
37
- validateRuntimeScopeManifest(manifest, control.scope_id);
37
+ validateRetainedRuntimeScopeManifest(manifest, control.scope_id);
38
38
  const hash = (value) => crypto.createHash("sha256").update(value).digest("hex");
39
39
  if (hash(manifestBytes) !== manifest.manifest_sha256 || !fs.readFileSync(source).subarray(0, prefix.length).equals(prefix))
40
40
  throw new AppError("runtime_scope_journal_changed", "EVAL source changed during capture", 1);
@@ -5,7 +5,7 @@ import { createContext } from "../runtime/context.js";
5
5
  import { getResourceDatabase } from "../storage/database.js";
6
6
  import { AppError } from "../shared/errors.js";
7
7
  import { finishManagedProcess, managedLeaseReleased, managedProcessBinding, managedProcessStatus, processTreeIsAlive, resourceHome, stopManagedProcess } from "./managed-processes.js";
8
- import { requestRuntimeScopeControl, runtimeBudgetStatus, scopeLocalProcessRole, validateRuntimeScopeManifest } from "./runtime-budget.js";
8
+ import { requestRuntimeScopeControl, runtimeBudgetStatus, scopeLocalProcessRole, validateRetainedRuntimeScopeManifest } from "./runtime-budget.js";
9
9
  import { nativeInventoryVersion, recordRunControlIntent, retainedDaemonOperations, runControlStatus } from "./run-control.js";
10
10
  import { requestRunControl } from "./run-control-worker.js";
11
11
  import { adapterSessionId } from "./harness-adapter.js";
@@ -92,7 +92,7 @@ export function retainScopeDrain(context, control, processes, runs, native, prob
92
92
  let manifest = null, journal = null, manifestReason = "scope_manifest_required";
93
93
  if (latest.manifest) {
94
94
  try {
95
- manifest = validateRuntimeScopeManifest(latest.manifest, control.scope_id);
95
+ manifest = validateRetainedRuntimeScopeManifest(latest.manifest, control.scope_id);
96
96
  journal = captureScopeJournal(context, control, manifest);
97
97
  manifestReason = "scope_operation_reconciliation_required";
98
98
  }