@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
@@ -6,7 +6,7 @@ import { createContext } from "../runtime/context.js";
6
6
  import { AppError } from "../shared/errors.js";
7
7
  import { getResourceDatabase } from "../storage/database.js";
8
8
  import { managedProcessBinding, processIsAlive, processTreeIsAlive, resourceHome } from "./managed-processes.js";
9
- import { runtimeBudgetStatus, scopeLocalProcessRole, validateRuntimeScopeManifest } from "./runtime-budget.js";
9
+ import { runtimeBudgetStatus, scopeLocalProcessRole, validateRetainedRuntimeScopeManifest, prepareRuntimeScopeFence } from "./runtime-budget.js";
10
10
  import { nativeInventoryVersion, retainedDaemonOperations } from "./run-control.js";
11
11
  import { adapterSessionId, runHarnessAdapter } from "./harness-adapter.js";
12
12
  import { recordControllerResume, resumeRunController } from "./run-controller-recovery.js";
@@ -163,7 +163,7 @@ function publishScopeRelease(context, intent, runs, native) {
163
163
  const db = getResourceDatabase(resourceHome(context));
164
164
  if (runs.some(run => run.prepared !== true) || native.some(owner => owner?.prepared !== true))
165
165
  throw new AppError("runtime_scope_preparation_required", "Every owned RUN and native Session must be prepared", 1);
166
- const manifest = validateRuntimeScopeManifest(intent.manifest, intent.scope_id);
166
+ const manifest = validateRetainedRuntimeScopeManifest(intent.manifest, intent.scope_id);
167
167
  const artifactRow = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control-capture' AND resource_key = ?", [intent.capture_key]);
168
168
  const artifact = artifactRow ? JSON.parse(artifactRow.metadata_json) : null;
169
169
  const journal = path.join(path.dirname(manifest.manifest_path), "events.jsonl");
@@ -482,11 +482,14 @@ async function retireScopeNative(context, intent, source, assertCurrent) {
482
482
  }
483
483
  /** Freeze the preparation plan under the same arbiter as stop. This accepts
484
484
  * ownership work, not productive dispatch; only all-role release may unfence. */
485
- export function recordRuntimeScopeResume(context, input) {
486
- if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(input.requestId) || !Number.isSafeInteger(input.generation) || input.generation < 1 || !/^[a-f0-9]{64}$/.test(input.captureKey))
485
+ export function prepareRuntimeScopeResumeInput(input) {
486
+ prepareRuntimeScopeFence(input.scopeId, input.requestId);
487
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(input.requestId) || !Number.isSafeInteger(input.generation) || input.generation < 1 || typeof input.captureKey !== "string" || !/^[a-f0-9]{64}$/.test(input.captureKey))
487
488
  throw new AppError("runtime_scope_resume_invalid", "Scope resume requires its exact generation, capture and request identity", 2);
488
- const db = getResourceDatabase(resourceHome(context)), key = hash(JSON.stringify([input.scopeId, input.requestId]));
489
- db.beginWriteTransaction();
489
+ }
490
+ export function prepareRuntimeScopeResumeRequest(context, input) {
491
+ prepareRuntimeScopeResumeInput(input);
492
+ const db = getResourceDatabase(resourceHome(context), "read_existing"), key = hash(JSON.stringify([input.scopeId, input.requestId]));
490
493
  try {
491
494
  const latest = runtimeBudgetStatus(context, input.scopeId);
492
495
  const control = latest.control;
@@ -497,7 +500,6 @@ export function recordRuntimeScopeResume(context, input) {
497
500
  throw new AppError("runtime_scope_resume_conflict", "Resume request identity was used for another source", 1);
498
501
  const active = latest.resume;
499
502
  const sameRequest = active?.generation === input.generation && active.request_id === input.requestId;
500
- db.exec("COMMIT");
501
503
  return { ...receipt, ...(sameRequest ? active : {}), ...(sameRequest && active.status === "released" ? { release: latest.release } : {}), current: sameRequest && active.current === true, reused: true };
502
504
  }
503
505
  if (latest.fence || control?.generation !== input.generation)
@@ -509,7 +511,7 @@ export function recordRuntimeScopeResume(context, input) {
509
511
  const drain = drainRow ? JSON.parse(drainRow.metadata_json) : null;
510
512
  if (drain?.scope_id !== input.scopeId || drain.generation !== input.generation || drain.requested_mode !== control.requested_mode || drain.physical_settled !== true || drain.capture?.journal?.artifact_key !== input.captureKey || drain.capture.journal.trailing_bytes !== 0 || !Array.isArray(drain.capture.run_captures) || drain.capture.run_captures.some((run) => run.status !== "verified"))
511
513
  throw new AppError("runtime_scope_capture_required", "Resume requires a complete all-role drain and verified RUN captures", 1);
512
- const manifest = validateRuntimeScopeManifest(drain.capture.manifest, input.scopeId);
514
+ const manifest = validateRetainedRuntimeScopeManifest(drain.capture.manifest, input.scopeId);
513
515
  if (JSON.stringify(manifest) !== JSON.stringify(latest.manifest))
514
516
  throw new AppError("runtime_scope_manifest_changed", "Resume scope manifest changed after capture", 1);
515
517
  const bindings = latest.processes.map(record => managedProcessBinding(record, resourceHome(context)));
@@ -530,12 +532,32 @@ export function recordRuntimeScopeResume(context, input) {
530
532
  throw new AppError("runtime_scope_journal_changed", "Resume requires the current immutable EVAL journal capture", 1);
531
533
  const runs = drain.capture.run_captures.map((run) => ({ ...run, resume_request_id: `scope-resume:${hash(JSON.stringify([input.scopeId, input.generation, input.requestId, run.runtime_home, run.project_id, run.run_id]))}` }));
532
534
  const receipt = { scope_id: input.scopeId, generation: input.generation, request_id: input.requestId, source_request_id: control.request_id, capture_key: input.captureKey, status: "preparing", dispatch_blocked: true, manifest, process_bindings: bindings, runs, native: drain.capture.native_bindings, probes: drain.capture.probe_operations, requested_at: context.now() };
533
- const payload = JSON.stringify(receipt), now = context.now();
534
- 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-resume-request',?,?,?,?,NULL,?,?,?)", [key, input.scopeId, key, now, payload, now, now]);
535
- 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-resume',?,?,?,?,NULL,?,?,?) ON CONFLICT(resource_kind,resource_key) DO UPDATE SET lease_token = excluded.lease_token, metadata_json = excluded.metadata_json, updated_at = excluded.updated_at", [input.scopeId, input.scopeId, key, now, payload, now, now]);
536
- db.exec("COMMIT");
537
535
  return { ...receipt, current: true, reused: false };
538
536
  }
537
+ finally {
538
+ db.close?.();
539
+ }
540
+ }
541
+ export function recordRuntimeScopeResume(context, input) {
542
+ const prepared = prepareRuntimeScopeResumeRequest(context, input);
543
+ if (prepared.reused)
544
+ return prepared;
545
+ const db = getResourceDatabase(resourceHome(context)), key = hash(JSON.stringify([input.scopeId, input.requestId]));
546
+ db.beginWriteTransaction();
547
+ try {
548
+ // Recheck mutable capture/ownership authority under the existing arbiter.
549
+ const result = prepareRuntimeScopeResumeRequest(context, input);
550
+ if (!result.reused) {
551
+ const receipt = { ...result };
552
+ delete receipt.current;
553
+ delete receipt.reused;
554
+ const payload = JSON.stringify(receipt), now = context.now();
555
+ 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-resume-request',?,?,?,?,NULL,?,?,?)", [key, input.scopeId, key, now, payload, now, now]);
556
+ 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-resume',?,?,?,?,NULL,?,?,?) ON CONFLICT(resource_kind,resource_key) DO UPDATE SET lease_token = excluded.lease_token, metadata_json = excluded.metadata_json, updated_at = excluded.updated_at", [input.scopeId, input.scopeId, key, now, payload, now, now]);
557
+ }
558
+ db.exec("COMMIT");
559
+ return result;
560
+ }
539
561
  catch (error) {
540
562
  db.exec("ROLLBACK");
541
563
  throw error;
@@ -7,7 +7,7 @@ import { setTimeout as delay } from "node:timers/promises";
7
7
  import { AppError } from "../shared/errors.js";
8
8
  import { getResourceDatabase } from "../storage/database.js";
9
9
  import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, processIsAlive, processStartedAt, registerManagedProcess, resourceHome } from "./managed-processes.js";
10
- import { publicRuntimeScopeWorker, requestRuntimeScopeControl, runtimeBudgetStatus } from "./runtime-budget.js";
10
+ import { publicRuntimeScopeWorker, requestRuntimeScopeControl, runtimeBudgetStatus, prepareRuntimeScopeFence, parseRuntimeBudget } from "./runtime-budget.js";
11
11
  import { controlRuntimeScopeRuns } from "./runtime-scope-control.js";
12
12
  import { prepareRuntimeScopeResume, recordRuntimeScopeResume } from "./runtime-scope-resume.js";
13
13
  import { recoveryObservationBudget, renewRecoveryObservation, withRecoveryObservation } from "./recovery-observation-budget.js";
@@ -30,15 +30,29 @@ function birth() {
30
30
  throw new AppError("runtime_scope_owner_unknown", "Scope owner birth identity is unavailable", 1);
31
31
  return { pid: process.pid, pid_started_at: started };
32
32
  }
33
- export async function reconcileRuntimeScopeControl(context, scopeId, generation, requestId) {
34
- const control = runtimeBudgetStatus(context, scopeId).control;
35
- if (!control || control.generation !== generation)
36
- throw new AppError("runtime_scope_control_superseded", "Reconciliation requires the current controlled scope generation", 1);
33
+ export function prepareRuntimeScopeReconciliation(context, scopeId, generation, requestId) {
34
+ prepareRuntimeScopeFence(scopeId, requestId);
35
+ if (!Number.isSafeInteger(generation) || generation < 1)
36
+ throw new AppError("validation", "Scope reconciliation requires a positive integer generation", 2);
37
37
  renewRecoveryObservation({}, requestId);
38
+ const db = getResourceDatabase(resourceHome(context), "read_existing");
39
+ try {
40
+ const row = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control' AND resource_key = ?", [scopeId]);
41
+ const control = row ? JSON.parse(row.metadata_json) : null;
42
+ if (!control || control.generation !== generation)
43
+ throw new AppError("runtime_scope_control_superseded", "Reconciliation requires the current controlled scope generation", 1);
44
+ return control;
45
+ }
46
+ finally {
47
+ db.close?.();
48
+ }
49
+ }
50
+ export async function reconcileRuntimeScopeControl(context, scopeId, generation, requestId) {
51
+ const control = prepareRuntimeScopeReconciliation(context, scopeId, generation, requestId);
38
52
  return launchRuntimeScopeControl(context, scopeId, control.request_id, control.requested_mode, undefined, requestId);
39
53
  }
40
- export async function launchRuntimeScopeControl(context, scopeId, requestId, mode, manifest, reconcileId, resumeId) {
41
- const control = requestRuntimeScopeControl(context, scopeId, requestId, mode, manifest), db = getResourceDatabase(resourceHome(context)), key = keyFor(scopeId, control.generation);
54
+ export async function launchRuntimeScopeControl(context, scopeId, requestId, mode, manifest, reconcileId, resumeId, prepared) {
55
+ const control = requestRuntimeScopeControl(context, scopeId, requestId, mode, manifest, prepared), db = getResourceDatabase(resourceHome(context)), key = keyFor(scopeId, control.generation);
42
56
  let worker;
43
57
  db.beginWriteTransaction();
44
58
  try {
@@ -103,19 +117,38 @@ export async function launchRuntimeScopeControl(context, scopeId, requestId, mod
103
117
  const retained = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control-worker' AND resource_key = ?", [key]);
104
118
  return { scope_id: scopeId, control, current: true, settled: false, worker: publicRuntimeScopeWorker(JSON.parse(retained.metadata_json)) };
105
119
  }
120
+ export function prepareRuntimeScopeServing(context, scopeId, generation) {
121
+ parseRuntimeBudget(JSON.stringify({ schema_id: "dd-flow/runtime-budget@1", scope_id: scopeId, per_harness: {} }));
122
+ if (!Number.isSafeInteger(generation) || generation < 1)
123
+ throw new AppError("validation", "Scope worker requires a positive integer generation", 2);
124
+ const token = context.env.DD_FLOW_SCOPE_CONTROL_TOKEN;
125
+ if (!token)
126
+ throw new AppError("runtime_scope_owner_unknown", "Scope worker requires its launch token", 1);
127
+ const key = keyFor(scopeId, generation), db = getResourceDatabase(resourceHome(context), "read_existing");
128
+ try {
129
+ requireStartingScopeWorker(db, key, token);
130
+ }
131
+ finally {
132
+ db.close?.();
133
+ }
134
+ return { key, token };
135
+ }
136
+ function requireStartingScopeWorker(db, key, token) {
137
+ const row = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control-worker' AND resource_key = ? AND lease_token = ?", [key, token]);
138
+ if (!row)
139
+ throw new AppError("runtime_scope_owner_unknown", "Scope worker claim changed", 1);
140
+ const worker = JSON.parse(row.metadata_json);
141
+ if (worker.owner || worker.status !== "starting")
142
+ throw new AppError("runtime_scope_already_owned", "Scope control already has a physical owner", 1);
143
+ return worker;
144
+ }
106
145
  export async function serveRuntimeScopeControl(context, scopeId, generation) {
107
- if (!Number.isSafeInteger(generation) || generation < 1 || !context.env.DD_FLOW_SCOPE_CONTROL_TOKEN)
108
- throw new AppError("runtime_scope_owner_unknown", "Scope worker requires its launch token and generation", 1);
109
- const db = getResourceDatabase(resourceHome(context)), key = keyFor(scopeId, generation), token = context.env.DD_FLOW_SCOPE_CONTROL_TOKEN;
146
+ const { key, token } = prepareRuntimeScopeServing(context, scopeId, generation);
147
+ const db = getResourceDatabase(resourceHome(context));
110
148
  let worker;
111
149
  db.beginWriteTransaction();
112
150
  try {
113
- const row = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control-worker' AND resource_key = ? AND lease_token = ?", [key, token]);
114
- if (!row)
115
- throw new AppError("runtime_scope_owner_unknown", "Scope worker claim changed", 1);
116
- worker = JSON.parse(row.metadata_json);
117
- if (worker.owner || worker.status !== "starting")
118
- throw new AppError("runtime_scope_already_owned", "Scope control already has a physical owner", 1);
151
+ worker = requireStartingScopeWorker(db, key, token);
119
152
  worker.owner = birth();
120
153
  worker.status = "running";
121
154
  db.run("UPDATE managed_resources SET metadata_json = ?, updated_at = ? WHERE resource_kind = 'scope-control-worker' AND resource_key = ? AND lease_token = ?", [JSON.stringify(worker), context.now(), key, token]);
@@ -7,22 +7,23 @@ import { requireProjectByRoot } from "./projects.js";
7
7
  import { resolveRun } from "./runs.js";
8
8
  import { codeExecutionEnvironment, runCheck } from "./code-checks.js";
9
9
  import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessStatus, processTreeIsAlive, registerManagedProcess, reservePorts, resourceHome, stopManagedProcess } from "./managed-processes.js";
10
- /** Foreground supervisor: the tool may yield while this command owns the service.
11
- * Its ready receipt is emitted immediately; a separate stop ends this command.
12
- * Readiness is infrastructure evidence, never a successful scenario verdict. */
13
- export async function startRuntimeService(context, input) {
10
+ export function prepareRuntimeService(context, input) {
14
11
  if (!Number.isFinite(input.timeoutMs) || input.timeoutMs <= 0)
15
12
  throw new AppError("validation", "Readiness timeout must be positive and finite", 2);
16
- if (!input.command.trim() || !input.ports.length || input.ports.some(name => !/^[a-z][a-z0-9_]*$/.test(name)) || new Set(input.ports).size !== input.ports.length || !input.ports.includes(input.readyPort) || !input.readyPath.startsWith("/") || input.readyPath.startsWith("//"))
13
+ if (typeof input.command !== "string" || !input.command.trim() || !Array.isArray(input.ports) || !input.ports.length || input.ports.some(name => typeof name !== "string" || !/^[a-z][a-z0-9_]*$/.test(name)) || new Set(input.ports).size !== input.ports.length || !input.ports.includes(input.readyPort) || typeof input.readyPath !== "string" || !input.readyPath.startsWith("/") || input.readyPath.startsWith("//"))
17
14
  throw new AppError("validation", "Declare unique lowercase port names, a ready-port from that list and a local /ready-path", 2);
18
15
  const project = requireProjectByRoot(context, path.resolve(input.projectRoot));
19
16
  const run = resolveRun(context, project.id, input.runId);
20
17
  if (!run?.run_root || !run.workspace_root)
21
18
  throw new AppError("not_found", "Registered RUN with a materialized workspace is required", 2);
22
- input = { ...input, runId: run.id };
23
19
  const generation = assertRunMutationAllowed(context, project.id, run.id);
20
+ return { project, run, runHome: run.run_root, generation };
21
+ }
22
+ export async function startRuntimeService(context, input) {
23
+ const { project, run, runHome, generation } = prepareRuntimeService(context, input);
24
+ input = { ...input, runId: run.id };
24
25
  const id = `PROC-${crypto.randomUUID()}`;
25
- const directory = path.join(run.run_root, "runtime", id);
26
+ const directory = path.join(runHome, "runtime", id);
26
27
  fs.mkdirSync(directory, { recursive: true });
27
28
  const record = registerManagedProcess(context, { id, kind: "runtime-service", ownerId: `run:${input.runId}`, ownerPid: process.pid, runId: input.runId, projectId: project.id, stdoutPath: path.join(directory, "stdout.log"), stderrPath: path.join(directory, "stderr.log"), metadata: { command: input.command } });
28
29
  const receiptPath = path.join(directory, "service.json");
@@ -95,7 +95,10 @@ export function validateSchema(options) {
95
95
  }
96
96
  const filePath = path.resolve(options.file);
97
97
  const data = Object.hasOwn(options, "data") ? options.data : readJson(filePath, "input file");
98
- const schemaResolution = resolveSchema(options);
98
+ // `data` may have come from stdin or from the prepare phase. Do not reopen
99
+ // `options.file` merely to infer the RUN-bound schema: apart from being a
100
+ // second read of caller input, `<stdin>` is not a filesystem path at all.
101
+ const schemaResolution = resolveSchema(options, data);
99
102
  const schema = readJson(schemaResolution.path, "schema");
100
103
  // Ajv's default export only carries the draft-07 meta-schema. New stage
101
104
  // contracts may deliberately use draft 2020-12, so choose the validator
@@ -133,8 +136,8 @@ export function validateSchema(options) {
133
136
  errors: []
134
137
  };
135
138
  }
136
- function resolveSchema(options) {
137
- const bound = resolveRunBoundSchema(options);
139
+ function resolveSchema(options, data) {
140
+ const bound = resolveRunBoundSchema(options, data);
138
141
  if (bound)
139
142
  return bound;
140
143
  const fileNames = [`${options.schemaName}.schema.json`];
@@ -180,10 +183,9 @@ const historicalSchemaRegistry = {
180
183
  checksum: "d9889af7f2c0afb47e2abab330aea3e1750879d0738d87d38d2d20e3fe0403f3"
181
184
  }]
182
185
  };
183
- function resolveRunBoundSchema(options) {
186
+ function resolveRunBoundSchema(options, data) {
184
187
  if (!options.runRoot || options.schemaDir)
185
188
  return null;
186
- const data = readJson(path.resolve(options.file), "input file");
187
189
  const root = asRecord(data);
188
190
  const run = root ? asRecord(objectValue(root, "run")) : undefined;
189
191
  const inferredRunId = run ? objectValue(run, "run_id") : objectValue(root ?? {}, "run_id");
@@ -268,13 +270,13 @@ function readJson(file, label) {
268
270
  text = fs.readFileSync(file, "utf8");
269
271
  }
270
272
  catch (error) {
271
- throw new AppError("validation", `Cannot read ${label}: ${file}`, 2, { cause: String(error) });
273
+ throw new AppError(label === "input file" ? "validation" : "schema_registry_invalid", `Cannot read ${label}: ${file}`, label === "input file" ? 2 : 1, { cause: String(error) });
272
274
  }
273
275
  try {
274
276
  return JSON.parse(text);
275
277
  }
276
278
  catch (error) {
277
- throw new AppError("invalid_json", `Invalid JSON in ${label}: ${file}`, 2, { cause: String(error) });
279
+ throw new AppError(label === "input file" ? "invalid_json" : "schema_registry_invalid", `Invalid JSON in ${label}: ${file}`, label === "input file" ? 2 : 1, { cause: String(error) });
278
280
  }
279
281
  }
280
282
  function formatAjvError(error) {
@@ -1,9 +1,9 @@
1
1
  import crypto from "node:crypto";
2
- import fs from "node:fs";
3
2
  import { continuationPolicies, flowKinds } from "../domain/contracts.js";
4
3
  import { AppError } from "../shared/errors.js";
5
- import { commandOption, commandPosition, parseLifecycleCommand } from "./lifecycle-command.js";
4
+ import { hookSessionIdentity } from "./hooks.js";
6
5
  import { parseJsonObject } from "../shared/json.js";
6
+ import { prepareTextFile } from "../cli/input-preparation.js";
7
7
  import { resolveProjectRoot } from "../storage/paths.js";
8
8
  import { appendAudit } from "./audit.js";
9
9
  import { cancelLaneWaitersForWorker } from "./lanes.js";
@@ -30,8 +30,8 @@ export function recordFlowSessionObservation(context, input) {
30
30
  const open = context.db.get(`SELECT id, run_id, protocol_id FROM flow_session_segments
31
31
  WHERE project_id = ? AND session_id = ? AND ended_at IS NULL ORDER BY started_at DESC, id DESC LIMIT 1`, [input.projectId, input.sessionId]);
32
32
  const now = context.now();
33
- const runId = input.runId ?? null;
34
- const protocolId = input.protocolId ?? null;
33
+ const runId = input.runId === undefined ? open?.run_id ?? null : input.runId;
34
+ const protocolId = input.protocolId === undefined ? open?.protocol_id ?? null : input.protocolId;
35
35
  if (open && open.run_id === runId && open.protocol_id === protocolId) {
36
36
  return { changed: false, segment_id: open.id };
37
37
  }
@@ -42,27 +42,6 @@ export function recordFlowSessionObservation(context, input) {
42
42
  VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?)`, [input.projectId, input.sessionId, runId, protocolId, now, input.cwd ?? null, input.toolName ?? null, input.eventKey]);
43
43
  return { changed: true, segment_id: Number(result.lastInsertRowid) };
44
44
  }
45
- export function bindObservedFlowSession(context, project, payload, sessionId) {
46
- if (resolveProjectRoot(payload.project_root) !== project.root) {
47
- throw new AppError("project_mismatch", "Observed session payload project_root does not match hook cwd project", 1, {
48
- expected: project.root,
49
- actual: payload.project_root
50
- });
51
- }
52
- const session = upsertFlowSession(context, project, payload, sessionId);
53
- if (session.session_kind !== "orchestrator") {
54
- const now = context.now();
55
- for (const unit of payload.coverage_units ?? []) {
56
- if (!unit.job_id)
57
- continue;
58
- context.db.run(`UPDATE flow_jobs SET status = 'running', worker_session_id = ?, attempts = attempts + 1, started_at = COALESCE(started_at, ?), updated_at = ?
59
- WHERE project_id = ? AND run_id = ? AND job_id = ? AND status IN ('pending', 'failed')`, [session.session_id, now, now, project.id, session.run_id ?? "", unit.job_id]);
60
- }
61
- }
62
- if (session.run_id)
63
- refreshRunSessionProjection(context, project.id, session.run_id);
64
- return session;
65
- }
66
45
  export function registerFlowJob(context, input) {
67
46
  const jobId = input.jobId ?? `JOB-${input.runId}-${input.planItemId}`.replace(/[^A-Za-z0-9_-]+/g, "-");
68
47
  const now = context.now();
@@ -76,9 +55,33 @@ export function flowJobsForRun(context, projectId, runId) {
76
55
  return context.db.all("SELECT * FROM flow_jobs WHERE project_id = ? AND run_id = ? ORDER BY plan_item_id", [projectId, runId]);
77
56
  }
78
57
  export function registerFlowSession(context, input) {
79
- const payload = decodeFlowSessionPayload(input);
58
+ const payload = input.preparedPayload ?? prepareFlowSessionPayload(input);
80
59
  const project = requireProjectByRoot(context, resolveProjectRoot(payload.project_root));
81
- const session = upsertFlowSession(context, project, payload, input.sessionId);
60
+ if (payload.run_id && !context.db.get("SELECT id FROM runs WHERE project_id = ? AND id = ?", [project.id, payload.run_id])) {
61
+ throw new AppError("not_found", `Run is not registered: ${payload.run_id}`, 1, { run_id: payload.run_id, effect: "no_effect" });
62
+ }
63
+ const identity = input.hookEventId ? hookSessionIdentity(context, project.id, input.hookEventId) : undefined;
64
+ if (!identity && input.sessionId && payload.provider_session_id && payload.provider_session_id !== input.sessionId) {
65
+ throw new AppError("validation", "--session-id conflicts with payload provider_session_id", 2);
66
+ }
67
+ const session = upsertFlowSession(context, project, identity ? {
68
+ ...payload, harness: identity.harness, provider_session_id: identity.providerSessionId,
69
+ parent_session_id: identity.parentSessionId, agent_id: identity.agentId,
70
+ provider: identity.provider, model: identity.model, reasoning: identity.reasoning,
71
+ mode: identity.mode, agent_type: identity.agentType
72
+ } : { ...payload, ...(input.sessionId ? { provider_session_id: input.sessionId } : {}) }, identity?.sessionId ?? input.sessionId);
73
+ recordFlowSessionObservation(context, { projectId: project.id, sessionId: session.session_id,
74
+ runId: session.run_id, protocolId: session.protocol_id, cwd: session.cwd,
75
+ eventKey: `session-register-${crypto.randomUUID()}` });
76
+ // Logical Work/job binding belongs to the successful CLI registration, never
77
+ // to observation of model-supplied payload by a pre-tool hook.
78
+ if (session.session_kind !== "orchestrator")
79
+ for (const unit of payload.coverage_units ?? []) {
80
+ if (!unit.job_id)
81
+ continue;
82
+ context.db.run(`UPDATE flow_jobs SET status = 'running', worker_session_id = ?, attempts = attempts + 1, started_at = COALESCE(started_at, ?), updated_at = ?
83
+ WHERE project_id = ? AND run_id = ? AND job_id = ? AND status IN ('pending', 'failed')`, [session.session_id, context.now(), context.now(), project.id, session.run_id ?? "", unit.job_id]);
84
+ }
82
85
  if (session.run_id)
83
86
  appendFlowRunTimelineEvent(context, project.id, session.run_id, { type: "session_bound", session_id: session.session_id });
84
87
  if (session.run_id)
@@ -195,52 +198,15 @@ export function updateFlowSessionContinuation(context, projectId, sessionId, act
195
198
  refreshRunSessionProjection(context, projectId, session.run_id);
196
199
  return nextCount;
197
200
  }
198
- export function flowSessionPayloadFromRegisterCommand(command) {
199
- const invocation = typeof command === "string" ? invocationFromCommand(command) : command;
200
- if (!invocation)
201
- return undefined;
202
- if (invocation.operation === "stage_start")
203
- return flowSessionPayloadFromStageStartCommand(invocation);
204
- if (invocation.operation !== "session_register")
205
- return undefined;
206
- const payloadBase64 = commandOption(invocation, "payload-base64");
207
- const payloadJson = commandOption(invocation, "payload-json");
208
- const payloadFile = commandOption(invocation, "payload-file");
209
- if (!payloadBase64 && !payloadJson && !payloadFile) {
210
- return undefined;
211
- }
212
- return decodeFlowSessionPayload({ payloadBase64, payloadJson, payloadFile });
213
- }
214
- function flowSessionPayloadFromStageStartCommand(invocation) {
215
- const projectRoot = commandOption(invocation, "project-root");
216
- const stage = commandOption(invocation, "stage");
217
- const run = commandPosition(invocation, 0);
218
- if (!projectRoot || !stage || !run || run.startsWith("--"))
219
- return undefined;
220
- return {
221
- project_root: projectRoot,
222
- flow_kind: ["code", "implementation", "readiness", "merge"].includes(stage) ? "implementation" : "planning",
223
- run_id: run,
224
- protocol_id: null,
225
- worker_id: null,
226
- workspace_path: null,
227
- continuation_policy: "go_router",
228
- session_kind: "orchestrator",
229
- current_stage: stage,
230
- coverage_units: []
231
- };
232
- }
233
- function invocationFromCommand(command) {
234
- const analysis = parseLifecycleCommand(command);
235
- return analysis.kind === "none" ? undefined : analysis.invocation;
236
- }
237
- function decodeFlowSessionPayload(input) {
201
+ export function prepareFlowSessionPayload(input) {
202
+ if ([input.payloadFile, input.payloadBase64, input.payloadJson].filter(value => value !== undefined).length !== 1)
203
+ throw new AppError("usage", "session register requires exactly one of --payload-file, --payload-base64, or --payload-json", 2);
238
204
  if (input.payloadFile) {
239
- return normalizeFlowSessionPayload(parseJsonObject(fs.readFileSync(input.payloadFile, "utf8"), "session payload"));
205
+ return normalizeFlowSessionPayload(parseJsonObject(prepareTextFile(input.payloadFile, "session payload").text, "session payload"));
240
206
  }
241
207
  if (input.payloadBase64) {
242
208
  if (/\$[A-Za-z_{(]/.test(input.payloadBase64)) {
243
- throw new AppError("validation", "session register --payload-base64 must be a literal base64 value because Codex PreToolUse hooks run before shell variable expansion; use --payload-file from prompts", 2);
209
+ throw new AppError("validation", "session register received an unexpanded shell variable as --payload-base64; expand it before invoking the CLI or use --payload-file", 2);
244
210
  }
245
211
  return normalizeFlowSessionPayload(parseJsonObject(Buffer.from(input.payloadBase64, "base64").toString("utf8"), "session payload"));
246
212
  }
@@ -1,19 +1,24 @@
1
1
  import { AppError } from "../shared/errors.js";
2
2
  import { resolveProjectRoot } from "../storage/paths.js";
3
3
  import { requireProjectByRoot } from "./projects.js";
4
- import { blockFlowRunStage, unblockFlowRunStage } from "./runs.js";
4
+ import { blockFlowRunStage, unblockFlowRunStage, prepareFlowRunStageBlock, prepareFlowRunStageUnblock } from "./runs.js";
5
5
  import { flowCommand } from "./stage-pause.js";
6
6
  import { refreshRunWorkProjection } from "./work-registry.js";
7
- export function blockStageForRuntime(context, input) {
7
+ export function prepareStageRuntimeBlock(context, input) {
8
+ if (typeof input.code !== "string" || !input.code.trim() || typeof input.summary !== "string" || !input.summary.trim())
9
+ throw new AppError("validation", "stage block requires a code and summary", 2);
8
10
  const projectRoot = resolveProjectRoot(input.projectRoot);
9
11
  const project = requireProjectByRoot(context, projectRoot);
10
12
  const run = requireRun(context, project.id, input.runId);
11
13
  const work = requireWork(context, project.id, run.id, input.workId);
12
14
  if (work.status !== "running")
13
15
  throw new AppError("invalid_work_state", "Only a running Work can be blocked", 1, { work_id: work.work_id, status: work.status });
14
- if (!input.code.trim() || !input.summary.trim())
15
- throw new AppError("validation", "stage block requires a code and summary", 2);
16
- const state = blockFlowRunStage(context, { ...input, projectRoot });
16
+ const flow = prepareFlowRunStageBlock(context, { ...input, projectRoot });
17
+ return { projectRoot, project, work, flow };
18
+ }
19
+ export function blockStageForRuntime(context, input) {
20
+ const { projectRoot, project, work, flow } = prepareStageRuntimeBlock(context, input);
21
+ const state = blockFlowRunStage(context, { ...input, projectRoot }, flow);
17
22
  const now = context.now();
18
23
  context.db.run("UPDATE works SET status = 'paused', updated_at = ? WHERE work_id = ? AND status = 'running'", [now, work.work_id]);
19
24
  refreshRunWorkProjection(context, project.id, work.run_id);
@@ -30,14 +35,19 @@ export function blockStageForRuntime(context, input) {
30
35
  state
31
36
  };
32
37
  }
33
- export function unblockStageAfterRuntimeRepair(context, input) {
38
+ export function prepareStageRuntimeUnblock(context, input) {
34
39
  const projectRoot = resolveProjectRoot(input.projectRoot);
35
40
  const project = requireProjectByRoot(context, projectRoot);
36
41
  const run = requireRun(context, project.id, input.runId);
37
42
  const work = requireWork(context, project.id, run.id, input.workId);
38
43
  if (work.status !== "paused")
39
44
  throw new AppError("invalid_work_state", "stage unblock requires its paused Work", 1, { work_id: work.work_id, status: work.status });
40
- const state = unblockFlowRunStage(context, { projectRoot, runId: work.run_id, stage: input.stage, workId: work.work_id });
45
+ const flow = prepareFlowRunStageUnblock(context, { ...input, projectRoot, runId: work.run_id });
46
+ return { projectRoot, project, work, flow };
47
+ }
48
+ export function unblockStageAfterRuntimeRepair(context, input) {
49
+ const { projectRoot, project, work, flow } = prepareStageRuntimeUnblock(context, input);
50
+ const state = unblockFlowRunStage(context, { projectRoot, runId: work.run_id, stage: input.stage, workId: work.work_id }, flow);
41
51
  const now = context.now();
42
52
  context.db.run("UPDATE works SET status = 'running', updated_at = ? WHERE work_id = ? AND status = 'paused'", [now, work.work_id]);
43
53
  refreshRunWorkProjection(context, project.id, work.run_id);
@@ -2,33 +2,42 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { AppError } from "../shared/errors.js";
5
- import { parseJsonObject } from "../shared/json.js";
5
+ import { prepareFileBytes, prepareJsonText, prepareOutputFile } from "../cli/input-preparation.js";
6
6
  export function loadExternalStageContext(input) {
7
- const sourcePath = path.resolve(input.file);
8
- if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isFile())
9
- throw new AppError("not_found", "Stage context file is missing", 2, { context_file: sourcePath });
10
- const bytes = fs.readFileSync(sourcePath);
7
+ const { file: sourcePath, bytes } = prepareFileBytes(input.file, "Stage context");
11
8
  const materializedSha256 = sha256(bytes);
12
9
  if (materializedSha256 !== input.expectedSha256)
13
10
  throw new AppError("context_checksum_mismatch", "Stage context checksum does not match --context-sha256", 2, { context_file: sourcePath, expected_sha256: input.expectedSha256, actual_sha256: materializedSha256 });
14
- const value = parseJsonObject(bytes.toString("utf8"), "stage context");
11
+ const value = prepareJsonText(bytes.toString("utf8"), { file: sourcePath, label: "Stage context" }).value;
15
12
  validateContext(value, input.stage);
16
- return { value, sourcePath, materializedSha256 };
13
+ return { value, sourcePath, materializedSha256, bytes };
17
14
  }
18
- /** Installs exactly one immutable external context for a stage attempt. */
19
- export function installExternalStageContext(input) {
20
- fs.mkdirSync(input.stageRoot, { recursive: true });
15
+ export function prepareExternalStageContext(input) {
16
+ const destination = externalStageContextDestination(input);
17
+ if (!fs.existsSync(destination))
18
+ prepareOutputFile(destination);
19
+ return destination;
20
+ }
21
+ function externalStageContextDestination(input) {
21
22
  const destination = path.join(input.stageRoot, "stage-context.json");
22
23
  if (fs.existsSync(destination)) {
23
24
  const existing = sha256(fs.readFileSync(destination));
24
25
  if (existing !== input.loaded.materializedSha256)
25
26
  throw new AppError("stage_context_replacement", "A different stage context is already installed for this Stage", 2, { stage_context: destination, expected_sha256: input.loaded.materializedSha256, actual_sha256: existing });
26
27
  }
27
- else {
28
+ return destination;
29
+ }
30
+ /** Installs exactly one immutable external context for a stage attempt. */
31
+ export function installExternalStageContext(input) {
32
+ // Publication may follow earlier effects. Recheck immutable identity here,
33
+ // but never reclassify a late output failure as a no-effect input rejection.
34
+ const destination = externalStageContextDestination(input);
35
+ fs.mkdirSync(input.stageRoot, { recursive: true });
36
+ if (!fs.existsSync(destination)) {
28
37
  // The receipt is the hash of the runner-materialized bytes, not of a
29
38
  // convenient reserialization. Preserve those exact bytes as evidence.
30
39
  const temporary = `${destination}.${process.pid}.tmp`;
31
- fs.copyFileSync(input.loaded.sourcePath, temporary);
40
+ fs.writeFileSync(temporary, input.loaded.bytes);
32
41
  fs.renameSync(temporary, destination);
33
42
  }
34
43
  return {
@@ -76,15 +85,32 @@ export function renderExternalStageContext(input) {
76
85
  ].join("\n");
77
86
  }
78
87
  function validateContext(value, expectedStage) {
88
+ const object = (item) => Boolean(item && typeof item === "object" && !Array.isArray(item));
89
+ const text = (item) => typeof item === "string" && Boolean(item.trim());
90
+ if (!object(value))
91
+ throw new AppError("validation", "Stage context must be an object", 2);
79
92
  if (value.schema_id !== "dd-flow/stage-context@1" && value.schema_id !== "dd-eval/stage-context@1")
80
93
  throw new AppError("validation", "Stage context must use dd-flow/stage-context@1", 2);
81
94
  if (value.stage !== expectedStage)
82
95
  throw new AppError("validation", "Stage context does not match --stage", 2, { expected_stage: expectedStage, context_stage: value.stage });
83
- if (!value.objective?.trim())
96
+ if (!text(value.objective))
84
97
  throw new AppError("validation", "Stage context objective is required", 2);
85
- for (const source of value.sources ?? []) {
86
- if (!source.role?.trim() || !source.path?.trim() || !source.reason?.trim())
87
- throw new AppError("validation", "Every stage context source needs role, path and reason", 2);
98
+ for (const key of ["task_input", "sources", "accepted_decisions", "dynamic_roles"]) {
99
+ if (value[key] === undefined)
100
+ continue;
101
+ if (!Array.isArray(value[key]))
102
+ throw new AppError("validation", `Stage context ${key} must be an array`, 2);
103
+ for (const item of value[key]) {
104
+ const valid = key === "accepted_decisions" ? text(item) || object(item) && (text(item.id) || text(item.statement)) && [item.id, item.statement].every(field => field === undefined || text(field))
105
+ : object(item) && text(item.role) && (key === "dynamic_roles" ? item.reason === undefined || text(item.reason)
106
+ : text(item.path) && (key === "sources" ? text(item.reason) && (item.required === undefined || typeof item.required === "boolean") && (item.root === undefined || typeof item.root === "string" && ["project", "workspace", "run"].includes(item.root)) : item.sha256 === undefined || text(item.sha256)));
107
+ if (!valid)
108
+ throw new AppError("validation", `Invalid stage context ${key} item`, 2, { parameter: key });
109
+ }
110
+ }
111
+ for (const key of ["semantic_package_sha256", "context_slice_sha256"]) {
112
+ if (value[key] !== undefined && !text(value[key]))
113
+ throw new AppError("validation", `Stage context ${key} must be a nonempty string`, 2);
88
114
  }
89
115
  }
90
116
  function sha256(value) { return crypto.createHash("sha256").update(value).digest("hex"); }