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

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 (43) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/dist/build-info.json +5 -5
  3. package/dist/cli/help.js +3 -3
  4. package/dist/cli/run-cli.js +127 -8
  5. package/dist/runtime/context.js +3 -1
  6. package/dist/schemas/code-review-result.schema.json +1 -1
  7. package/dist/schemas/code-work-batch.schema.json +4 -3
  8. package/dist/schemas/code-work-result.schema.json +1 -1
  9. package/dist/schemas/harness-config.schema.json +23 -0
  10. package/dist/schemas/plan-review-decision.schema.json +1 -1
  11. package/dist/schemas/vnext-protocol-plan.schema.json +1 -1
  12. package/dist/services/cleanup.js +18 -8
  13. package/dist/services/code-checks.js +194 -44
  14. package/dist/services/engines.js +4 -4
  15. package/dist/services/eval-snapshots.js +10 -5
  16. package/dist/services/harness-config.js +66 -0
  17. package/dist/services/hooks.js +25 -22
  18. package/dist/services/lanes.js +1 -0
  19. package/dist/services/managed-processes.js +169 -0
  20. package/dist/services/merge-server.js +8 -2
  21. package/dist/services/portable-refs.js +57 -0
  22. package/dist/services/prompts.js +4 -2
  23. package/dist/services/run-engine-bindings.js +19 -61
  24. package/dist/services/run-projection.js +10 -8
  25. package/dist/services/runs.js +71 -9
  26. package/dist/services/schema-validation.js +11 -11
  27. package/dist/services/session-identity.js +19 -0
  28. package/dist/services/sessions.js +26 -11
  29. package/dist/services/stage-lifecycle.js +15 -8
  30. package/dist/services/stage-pause.js +35 -20
  31. package/dist/services/usage.js +74 -42
  32. package/dist/services/vnext-code-review.js +82 -41
  33. package/dist/services/vnext-code.js +98 -34
  34. package/dist/services/vnext-fanout.js +5 -12
  35. package/dist/services/vnext-merge.js +144 -65
  36. package/dist/services/vnext-plan-review.js +50 -35
  37. package/dist/services/vnext-plan.js +69 -21
  38. package/dist/services/vnext-protocolize.js +6 -6
  39. package/dist/services/vnext-specify.js +6 -6
  40. package/dist/services/work-registry.js +150 -40
  41. package/dist/storage/database.js +128 -2
  42. package/package.json +1 -1
  43. package/tools/audit-runtime-fix-boundaries.mjs +96 -0
@@ -15,6 +15,8 @@ import { refreshRunSessionProjection } from "./run-projection.js";
15
15
  import { resolveCanonRoot } from "./canon.js";
16
16
  import { bindCurrentEngineToRun } from "./engines.js";
17
17
  import { executionProfilePath, loadVnextExecutionProfile } from "./vnext-execution-profile.js";
18
+ import { isLegalVnextTransition, vnextStages } from "../domain/stage-catalog.js";
19
+ import { publicSessionIdentity } from "./session-identity.js";
18
20
  const runSchemaId = "dd-flow/flow-run@3";
19
21
  const runtimeSchemaId = "dd-flow/flow-run@3";
20
22
  const runIdType = "RUN";
@@ -124,7 +126,7 @@ export function startFlowRun(context, input) {
124
126
  };
125
127
  ensureDir(path.dirname(runtimePath));
126
128
  ensureDir(runHome);
127
- bindCurrentEngineToRun(context, { projectRoot, runId, runHome });
129
+ bindCurrentEngineToRun(context, { projectRoot, runId, runRoot: runHome });
128
130
  const persistedIndex = persistedVnextIndex(index);
129
131
  writeJsonFile(runtimePath, runtimeSnapshotForIndex(persistedIndex, 1));
130
132
  context.db.run(`INSERT INTO runs
@@ -290,6 +292,22 @@ export function setFlowRunConfig(context, input) {
290
292
  appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_config_set", run_id: run.id, key: input.key, value: mode, source, reason });
291
293
  return { ok: true, run_id: run.id, settings: runSettings(index), idempotent: false };
292
294
  }
295
+ /** Freeze an automatic review request once from already accepted policy or PLAN facts. */
296
+ export function freezeFlowRunReviewMode(context, input) {
297
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
298
+ const run = resolveRun(context, project.id, input.runId);
299
+ const index = authoritativeIndex(run);
300
+ const settings = runSettings(index);
301
+ const key = input.review === "plan" ? "plan_review" : "code_review";
302
+ const current = settings[key];
303
+ if (current.mode !== "auto")
304
+ return;
305
+ const now = context.now();
306
+ index.settings = { ...settings, [key]: { mode: input.mode, source: input.source, reason: requiredPlain(input.reason, "reason"), updated_at: now } };
307
+ index.updated_at = now;
308
+ persistRunState(context, project, run, index);
309
+ appendAudit(context, { projectId: project.id, eventType: "flow_run.review_mode_frozen", payload: { run_id: run.id, review: input.review, mode: input.mode, source: input.source, reason: input.reason } });
310
+ }
293
311
  export function getFlowRunFlagsStatus(context, input) {
294
312
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
295
313
  const run = resolveRun(context, project.id, input.runId);
@@ -450,6 +468,7 @@ export function attachFlowRunStage(context, input) {
450
468
  const dir = requiredStageDir(input.dir);
451
469
  const status = parseStageStatus(input.status);
452
470
  const existing = index.stage_runs.find((item) => item.stage === stage);
471
+ assertVnextStageStart(run, index, stage, status, existing);
453
472
  if (status === "running" && existing) {
454
473
  archiveExistingStageAttempt(runArtifactRoot(run), dir);
455
474
  }
@@ -674,8 +693,13 @@ export function completeFlowRun(context, input) {
674
693
  const index = authoritativeIndex(run);
675
694
  const now = context.now();
676
695
  const status = parseRunStatus(input.status);
677
- if (!['done', 'blocked', 'cancelled', 'failed'].includes(status)) {
678
- throw new AppError('validation', 'completeFlowRun accepts terminal statuses only; use advanceFlowRun for a live RUN', 2, { status });
696
+ if (!['done', 'cancelled', 'failed'].includes(status)) {
697
+ throw new AppError('validation', 'completeFlowRun accepts only done, cancelled, or failed; use stage block/unblock for a recoverable blocker', 2, { status });
698
+ }
699
+ if (!input.manualOverrideReason) {
700
+ const activeWork = context.db.get("SELECT work_id, status FROM works WHERE project_id = ? AND run_id = ? AND status IN ('created', 'running', 'paused') LIMIT 1", [project.id, run.id]);
701
+ if (activeWork)
702
+ 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 });
679
703
  }
680
704
  if (input.manualOverrideReason && (status === "cancelled" || status === "failed")) {
681
705
  closeOpenStagesForOverride(index, status, now);
@@ -685,7 +709,7 @@ export function completeFlowRun(context, input) {
685
709
  index.verdict = input.verdict ?? (status === "done" ? "accepted" : status);
686
710
  index.next_action = input.nextAction ?? null;
687
711
  index.updated_at = now;
688
- if (["done", "blocked", "cancelled", "failed"].includes(status)) {
712
+ if (["done", "cancelled", "failed"].includes(status)) {
689
713
  index.completed_at = now;
690
714
  index.finished_at = now;
691
715
  index.duration_ms = durationMs(index.started_at ?? run.created_at, now);
@@ -733,7 +757,7 @@ export function advanceFlowRun(context, input) {
733
757
  function closeOpenStagesForOverride(index, status, now) {
734
758
  const stageStatus = status === "cancelled" ? "skipped" : "failed";
735
759
  for (const stage of index.stage_runs) {
736
- if (stage.status !== "running" && stage.status !== "pending")
760
+ if (stage.status !== "running" && stage.status !== "pending" && stage.status !== "paused" && stage.status !== "blocked")
737
761
  continue;
738
762
  stage.status = stageStatus;
739
763
  stage.updated_at = now;
@@ -755,7 +779,7 @@ function closeOpenWorksForOverride(context, projectId, runId, status, now) {
755
779
  context.db.run(`UPDATE works
756
780
  SET status = ?, updated_at = ?, completed_at = ?
757
781
  WHERE project_id = ? AND run_id = ?
758
- AND status IN ('created', 'running')`, [workStatus, now, now, projectId, runId]);
782
+ AND status IN ('created', 'running', 'paused')`, [workStatus, now, now, projectId, runId]);
759
783
  context.db.run(`UPDATE work_sessions
760
784
  SET status = ?, updated_at = ?, completed_at = ?
761
785
  WHERE work_id IN (SELECT work_id FROM works WHERE project_id = ? AND run_id = ?)
@@ -881,7 +905,21 @@ export function getFlowRunSessions(context, input) {
881
905
  s.agent_id, s.worker_id, s.provider, s.model, s.reasoning, s.mode,
882
906
  s.agent_type, s.transcript_path
883
907
  ORDER BY s.created_at, s.session_id`, [project.id, run.id]);
884
- return { ok: true, schema_id: "dd-flow/run-sessions@3", run_id: run.id, sessions };
908
+ const identities = new Map(sessions.map((row) => [String(row.session_id), publicSessionIdentity({
909
+ harness: String(row.harness),
910
+ provider_session_id: typeof row.provider_session_id === "string" ? row.provider_session_id : null,
911
+ session_id: String(row.session_id)
912
+ })]));
913
+ return {
914
+ ok: true,
915
+ schema_id: "dd-flow/run-sessions@4",
916
+ run_id: run.id,
917
+ sessions: sessions.map(({ session_id, harness: _harness, provider_session_id: _providerSessionId, parent_session_id, ...row }) => ({
918
+ ...row,
919
+ session: identities.get(String(session_id)) ?? null,
920
+ ...(typeof parent_session_id === "string" ? { parent_session: identities.get(parent_session_id) ?? null } : {})
921
+ }))
922
+ };
885
923
  }
886
924
  export function appendFlowRunTimelineEvent(context, projectId, runId, event) {
887
925
  const run = requireRunById(context, projectId, runId);
@@ -1133,6 +1171,28 @@ function nextAttempt(existing) {
1133
1171
  const number = Number(existing.attempt.replace("try-", ""));
1134
1172
  return `try-${String(Number.isFinite(number) ? number + 1 : 1).padStart(3, "0")}`;
1135
1173
  }
1174
+ function assertVnextStageStart(run, index, stage, status, existing) {
1175
+ if (status !== "running" || (run.flow_kind !== "vnext_specify" && run.flow_kind !== "vnext_protocolize"))
1176
+ return;
1177
+ if (!vnextStages.some((candidate) => candidate.id === stage))
1178
+ return;
1179
+ if (existing) {
1180
+ if (existing.status === "running")
1181
+ throw new AppError("stage_already_running", "The same stage attempt is already running; continue it instead of starting another attempt", 2, { run_id: run.id, stage, attempt: existing.attempt ?? null });
1182
+ throw new AppError("illegal_stage_transition", "A completed or paused vNext stage cannot be started as a new implicit attempt", 2, { run_id: run.id, stage, status: existing.status });
1183
+ }
1184
+ const completed = index.stage_runs.filter((candidate) => candidate.status === "done" || candidate.status === "skipped").sort((left, right) => right.order - left.order)[0];
1185
+ if (!completed) {
1186
+ if (stage === "specify")
1187
+ return;
1188
+ throw new AppError("illegal_stage_transition", "The vNext flow must start with SPECIFY", 2, { run_id: run.id, stage });
1189
+ }
1190
+ if (!isLegalVnextTransition(completed.stage, stage))
1191
+ throw new AppError("illegal_stage_transition", "The requested stage is not a legal successor of the last accepted stage", 2, { run_id: run.id, from: completed.stage, to: stage });
1192
+ const unfinished = index.stage_runs.find((candidate) => !["done", "skipped"].includes(candidate.status));
1193
+ if (unfinished)
1194
+ throw new AppError("illegal_stage_transition", "Another stage is still active", 2, { run_id: run.id, active_stage: unfinished.stage, active_status: unfinished.status, to: stage });
1195
+ }
1136
1196
  function upsertStage(index, stageRun) {
1137
1197
  const position = index.stage_runs.findIndex((item) => item.stage === stageRun.stage);
1138
1198
  if (position === -1) {
@@ -1201,7 +1261,7 @@ function flowRunSummary(run) {
1201
1261
  runtime_path: run.runtime_path,
1202
1262
  run_dir: run.run_dir,
1203
1263
  run_index_path: run.run_index_path,
1204
- run_home_path: run.run_home_path ?? null,
1264
+ run_root: run.run_root,
1205
1265
  layout_version: run.layout_version ?? null,
1206
1266
  artifact_root_kind: run.artifact_root_kind ?? null,
1207
1267
  created_at: run.created_at,
@@ -1296,7 +1356,9 @@ function writeJsonFile(file, value) {
1296
1356
  fs.renameSync(tmpFile, file);
1297
1357
  }
1298
1358
  function runArtifactRoot(run) {
1299
- return run.run_home_path ?? path.dirname(run.runtime_path);
1359
+ if (!run.run_root)
1360
+ throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1, { run_id: run.id });
1361
+ return run.run_root;
1300
1362
  }
1301
1363
  export function gitFacts(workspaceRoot) {
1302
1364
  const read = (args) => {
@@ -6,7 +6,7 @@ import { Ajv } from "ajv/dist/ajv.js";
6
6
  import { Ajv2020 } from "ajv/dist/2020.js";
7
7
  import { normalizeFlowContract } from "../domain/flow-contract.js";
8
8
  import { AppError } from "../shared/errors.js";
9
- import { findRunHome, readRunEngineBinding } from "./run-engine-bindings.js";
9
+ import { readRunEngineBinding } from "./run-engine-bindings.js";
10
10
  export function captureMemoryBankBaseline(input) {
11
11
  const projectRoot = path.resolve(input.projectRoot);
12
12
  const paths = normalizeMemoryBankPaths(projectRoot, input.paths);
@@ -117,8 +117,8 @@ export function validateSchema(options) {
117
117
  }
118
118
  const valid = validate(data);
119
119
  const errors = valid ? [] : (validate.errors ?? []).map(formatAjvError);
120
- const semanticErrors = valid ? validateSemanticSchema(options.schemaName, data) : [];
121
- const allErrors = [...errors, ...semanticErrors];
120
+ const contractErrors = valid ? validateContractInvariants(options.schemaName, data) : [];
121
+ const allErrors = [...errors, ...contractErrors];
122
122
  if (allErrors.length > 0) {
123
123
  throw new AppError("schema_validation", `${path.basename(filePath)} does not match schema`, 2, {
124
124
  schema: schemaResolution,
@@ -181,7 +181,7 @@ const historicalSchemaRegistry = {
181
181
  }]
182
182
  };
183
183
  function resolveRunBoundSchema(options) {
184
- if (!options.projectRoot || options.schemaDir)
184
+ if (!options.runRoot || options.schemaDir)
185
185
  return null;
186
186
  const data = readJson(path.resolve(options.file), "input file");
187
187
  const root = asRecord(data);
@@ -190,13 +190,13 @@ function resolveRunBoundSchema(options) {
190
190
  const runId = options.runId ?? inferredRunId;
191
191
  if (typeof runId !== "string")
192
192
  return null;
193
- const projectRoot = path.resolve(options.projectRoot);
194
- const located = findRunHome(options.ddFlowHome ?? process.env.DD_FLOW_HOME ?? path.join(path.dirname(projectRoot), ".dd-flow"), projectRoot, runId);
195
- if (!located || !isInside(located.run_home, path.resolve(options.file)))
196
- return null;
197
- const binding = readRunEngineBinding(located.binding_path, { allowMissing: true });
193
+ const runRoot = path.resolve(options.runRoot);
194
+ const bindingPath = path.join(runRoot, "engine-binding.json");
195
+ const binding = readRunEngineBinding(bindingPath);
198
196
  if (!binding)
199
- return null;
197
+ throw new AppError("run_engine_binding_missing", "RUN engine binding is missing", 1, { path: bindingPath });
198
+ if (binding.run_id !== runId)
199
+ throw new AppError("run_engine_binding_invalid", "RUN engine binding belongs to another RUN", 1, { expected_run_id: runId, actual_run_id: binding.run_id, path: bindingPath });
200
200
  const manifestPath = path.join(binding.engine.snapshot_root, "engine.json");
201
201
  const manifest = readJson(manifestPath, "bound engine manifest");
202
202
  const integrity = asRecord(objectValue(manifest, "integrity"));
@@ -284,7 +284,7 @@ function formatAjvError(error) {
284
284
  keyword: error.keyword
285
285
  };
286
286
  }
287
- function validateSemanticSchema(schemaName, data) {
287
+ function validateContractInvariants(schemaName, data) {
288
288
  const root = asRecord(data);
289
289
  if (!root) {
290
290
  return [];
@@ -0,0 +1,19 @@
1
+ import crypto from "node:crypto";
2
+ import { AppError } from "../shared/errors.js";
3
+ /** Internal SQLite key. Never expose this as a native harness Session ID. */
4
+ export function storageSessionId(identity) {
5
+ const harness = requirePart(identity.harness_id, "harness_id");
6
+ const session = requirePart(identity.session_id, "session_id");
7
+ return `SES-${crypto.createHash("sha256").update(harness).update("\0").update(session).digest("hex").slice(0, 24)}`;
8
+ }
9
+ export function nativeSessionIdentity(harnessId, sessionId) {
10
+ return { harness_id: requirePart(harnessId, "harness_id"), session_id: requirePart(sessionId, "session_id") };
11
+ }
12
+ export function publicSessionIdentity(input) {
13
+ return nativeSessionIdentity(input.harness, input.provider_session_id ?? input.session_id);
14
+ }
15
+ function requirePart(value, field) {
16
+ if (!value || !value.trim())
17
+ throw new AppError("session_identity_invalid", `${field} must be a non-empty opaque value`, 1, { field });
18
+ return value;
19
+ }
@@ -11,6 +11,18 @@ import { checkpointSessionUsage } from "./usage.js";
11
11
  import { refreshRunSessionProjection } from "./run-projection.js";
12
12
  import { reconcileSessionCoverageRows } from "../domain/session-coverage.js";
13
13
  import { appendFlowRunTimelineEvent } from "./runs.js";
14
+ import { publicSessionIdentity, storageSessionId } from "./session-identity.js";
15
+ export function publicFlowSession(context, session) {
16
+ const identity = publicSessionIdentity(session);
17
+ const parent = session.parent_session_id
18
+ ? context.db.get("SELECT harness, provider_session_id, session_id FROM sessions WHERE project_id = ? AND session_id = ?", [session.project_id, session.parent_session_id])
19
+ : null;
20
+ const publicFields = { ...session };
21
+ delete publicFields.session_id;
22
+ delete publicFields.provider_session_id;
23
+ delete publicFields.parent_session_id;
24
+ return { ...publicFields, harness_id: identity.harness_id, session_id: identity.session_id, parent_session: parent ? publicSessionIdentity(parent) : null };
25
+ }
14
26
  export function recordFlowSessionObservation(context, input) {
15
27
  const duplicate = context.db.get("SELECT id FROM flow_session_segments WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
16
28
  if (duplicate)
@@ -86,7 +98,7 @@ export function registerFlowSession(context, input) {
86
98
  },
87
99
  ...(session.protocol_id ? { protocolId: session.protocol_id } : {})
88
100
  });
89
- return { ok: true, session };
101
+ return { ok: true, session: publicFlowSession(context, session) };
90
102
  }
91
103
  export function getFlowSessionStatus(context, input) {
92
104
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -96,7 +108,7 @@ export function getFlowSessionStatus(context, input) {
96
108
  });
97
109
  return {
98
110
  ok: true,
99
- sessions,
111
+ sessions: sessions.map((session) => publicFlowSession(context, session)),
100
112
  coverage: reconcileSessionCoverage(sessions)
101
113
  };
102
114
  }
@@ -111,13 +123,14 @@ export function stopFlowSession(context, input) {
111
123
  markSessionStopped(context, project, session, input.reason);
112
124
  if (session.run_id)
113
125
  refreshRunSessionProjection(context, project.id, session.run_id);
114
- return { ok: true, session: flowSessionById(context, project.id, input.sessionId) };
126
+ const updated = flowSessionById(context, project.id, input.sessionId);
127
+ return { ok: true, session: updated ? publicFlowSession(context, updated) : null };
115
128
  }
116
129
  export function syncFlowSessionUsage(context, input) {
117
130
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
118
131
  const session = requireFlowSession(context, project.id, input.sessionId);
119
132
  if (!session.run_id) {
120
- return { ok: true, session_id: session.session_id, extraction_status: "not_observable", diagnostic: "session_not_bound_to_run" };
133
+ return { ok: true, session: publicSessionIdentity(session), extraction_status: "not_observable", diagnostic: "session_not_bound_to_run" };
121
134
  }
122
135
  return { ok: true, snapshot: checkpointSessionUsage(context, session, { checkpoint: "manual_sync", stage: session.current_stage }) };
123
136
  }
@@ -158,7 +171,7 @@ export function activeFlowSessionsForProject(context, projectId) {
158
171
  }
159
172
  export function flowSessionsForProject(context, projectId, filter = {}) {
160
173
  if (filter.sessionId) {
161
- return context.db.all("SELECT * FROM sessions WHERE project_id = ? AND session_id = ? ORDER BY updated_at DESC", [projectId, filter.sessionId]);
174
+ return context.db.all("SELECT * FROM sessions WHERE project_id = ? AND (session_id = ? OR provider_session_id = ?) ORDER BY updated_at DESC", [projectId, filter.sessionId, filter.sessionId]);
162
175
  }
163
176
  if (filter.workerId) {
164
177
  return context.db.all("SELECT * FROM sessions WHERE project_id = ? AND worker_id = ? ORDER BY updated_at DESC", [projectId, filter.workerId]);
@@ -166,10 +179,7 @@ export function flowSessionsForProject(context, projectId, filter = {}) {
166
179
  return context.db.all("SELECT * FROM sessions WHERE project_id = ? ORDER BY updated_at DESC", [projectId]);
167
180
  }
168
181
  export function flowSessionById(context, projectId, sessionId) {
169
- return context.db.get("SELECT * FROM sessions WHERE project_id = ? AND session_id = ?", [
170
- projectId,
171
- sessionId
172
- ]);
182
+ return context.db.get("SELECT * FROM sessions WHERE project_id = ? AND (session_id = ? OR provider_session_id = ?)", [projectId, sessionId, sessionId]);
173
183
  }
174
184
  export function updateFlowSessionContinuation(context, projectId, sessionId, actionKey) {
175
185
  const session = flowSessionById(context, projectId, sessionId);
@@ -286,7 +296,12 @@ function normalizeFlowSessionPayload(payload) {
286
296
  }
287
297
  function upsertFlowSession(context, project, payload, forcedSessionId) {
288
298
  const now = context.now();
289
- const sessionId = forcedSessionId ?? payload.session_id ?? payload.worker_id ?? payload.protocol_id ?? crypto.randomUUID();
299
+ // `sessions.session_id` is a private storage key. The native ID is kept
300
+ // unchanged in provider_session_id and is always paired with its harness in
301
+ // public contracts. A caller may pass a forced key only after a trusted
302
+ // hook has already derived it from that pair.
303
+ const nativeSessionId = payload.provider_session_id ?? payload.session_id ?? payload.worker_id ?? payload.protocol_id ?? crypto.randomUUID();
304
+ const sessionId = forcedSessionId ?? storageSessionId({ harness_id: payload.harness ?? "codex-desktop", session_id: nativeSessionId });
290
305
  const workspacePath = payload.workspace_path ?? payload.cwd ?? project.root;
291
306
  context.db.run(`INSERT INTO sessions
292
307
  (session_id, project_id, harness, provider_session_id, agent_id, provider, model, reasoning, mode, agent_type, project_root, flow_kind, status, run_id, parent_session_id, role, aspect_id, plan_item_id, session_kind, protocol_id, worker_id, workspace_path,
@@ -334,7 +349,7 @@ function upsertFlowSession(context, project, payload, forcedSessionId) {
334
349
  sessionId,
335
350
  project.id,
336
351
  payload.harness ?? "codex-desktop",
337
- payload.provider_session_id ?? null,
352
+ nativeSessionId,
338
353
  payload.agent_id ?? null,
339
354
  payload.provider ?? null,
340
355
  payload.model ?? null,
@@ -53,7 +53,9 @@ function bootstrapStageRun(context, projectRoot, input) {
53
53
  if (!fs.existsSync(source) || !fs.statSync(source).isFile()) {
54
54
  throw new AppError("not_found", "--intake-file must point to an existing file", 1, { intake_file: intakeFile });
55
55
  }
56
- const intakeDir = path.join(started.run.run_home_path ?? path.dirname(started.run.id), "intake");
56
+ if (!started.run.run_root)
57
+ throw new AppError("runtime_missing", "New RUN has no artifact root", 1, { run_id: started.run.id });
58
+ const intakeDir = path.join(started.run.run_root, "intake");
57
59
  fs.mkdirSync(intakeDir, { recursive: true });
58
60
  fs.copyFileSync(source, path.join(intakeDir, "user-request.md"));
59
61
  }
@@ -216,7 +218,7 @@ export function startStage(context, input) {
216
218
  })),
217
219
  worker_prompt_markdown: prompt
218
220
  });
219
- validateSchema({ schemaName: "stage-prompt", file: promptDataPath, projectRoot, runId: attached.run.id, ddFlowHome: context.ddFlowHome });
221
+ validateSchema({ schemaName: "stage-prompt", file: promptDataPath, projectRoot, runId: attached.run.id, runRoot: runHome, ddFlowHome: context.ddFlowHome });
220
222
  atomicWrite(path.join(stageRoot, "stage-start.json"), {
221
223
  schema_id: "dd-flow/stage-start@2",
222
224
  run_id: attached.run.id,
@@ -273,7 +275,7 @@ export function finishStage(context, input) {
273
275
  const semanticFile = resolveStageFile(input.semanticFile ?? "@stage/stage-input.json", stageRoot, runHome);
274
276
  const receipt = beginFinishReceipt(context, stageRoot, semanticFile, view.run.id, input.stage);
275
277
  try {
276
- validateSchema({ schemaName: "stage-finish-input", file: semanticFile, projectRoot, runId, ddFlowHome: context.ddFlowHome });
278
+ validateSchema({ schemaName: "stage-finish-input", file: semanticFile, projectRoot, runId, runRoot: runHome, ddFlowHome: context.ddFlowHome });
277
279
  const semantic = readSemanticFile(semanticFile, runHome);
278
280
  const status = stringValue(semantic.status) ?? "done";
279
281
  if (!["done", "waiting_for_user", "blocked", "failed"].includes(status)) {
@@ -293,7 +295,7 @@ export function finishStage(context, input) {
293
295
  const reportPath = path.join(stageRoot, "stage-report.md");
294
296
  const htmlPath = path.join(stageRoot, "stage-report.html");
295
297
  atomicWrite(dataPath, report);
296
- validateSchema({ schemaName: stageReportSchemaName(input.stage, planFinish), file: dataPath, projectRoot, runId, ddFlowHome: context.ddFlowHome });
298
+ validateSchema({ schemaName: stageReportSchemaName(input.stage, planFinish), file: dataPath, projectRoot, runId, runRoot: runHome, ddFlowHome: context.ddFlowHome });
297
299
  atomicWrite(reportPath, renderMarkdown(report));
298
300
  atomicWrite(htmlPath, renderHtml(projectRoot, report));
299
301
  if (view.run.subject.type === "protocol") {
@@ -410,7 +412,12 @@ function nextActionForProtocolStage(stage) {
410
412
  return `continue_${stage}`;
411
413
  }
412
414
  function runHomePath(run) {
413
- return run.run_home_path ?? path.dirname(run.run_index_path);
415
+ if (!run.run_root)
416
+ throw new AppError("runtime_missing", "RUN has no artifact root", 1, { run_id: run.id });
417
+ return run.run_root;
418
+ }
419
+ function runStatePath(run) {
420
+ return path.join(runHomePath(run), "run.json");
414
421
  }
415
422
  export function defaultStageDir(stage, flowKind) {
416
423
  const normalized = stage === "implementation" ? "code" : stage;
@@ -702,7 +709,7 @@ function buildStageReport(context, view, stage, status, semantic, stageRoot, lin
702
709
  if (stage === "code" || stage === "implementation") {
703
710
  return {
704
711
  schema_id: "dd-flow/code-stage-report@2",
705
- run: { run_id: view.run.id, run_state: view.run.run_index_path },
712
+ run: { run_id: view.run.id, run_state: runStatePath(view.run) },
706
713
  stage: { name: stage, dir: path.basename(stageRoot), status },
707
714
  project: { id: view.run.project_id, title: path.basename(view.run.project_root) },
708
715
  subject: { id: view.run.subject.id, title: view.run.subject.id },
@@ -712,7 +719,7 @@ function buildStageReport(context, view, stage, status, semantic, stageRoot, lin
712
719
  summary: result,
713
720
  next_action: stringValue(semantic.next_action) ?? "Proceed to readiness."
714
721
  },
715
- breadcrumbs: [{ label: "RUN", href: view.run.run_index_path, status: "available" }],
722
+ breadcrumbs: [{ label: "RUN", href: runStatePath(view.run), status: "available" }],
716
723
  implemented_goals: [{ title: `Stage ${stage}`, summary: result }],
717
724
  acceptance_scenarios: acceptance.map((summary, index) => ({
718
725
  id: `SCN-${stage}-${index + 1}`,
@@ -763,7 +770,7 @@ function buildStageReport(context, view, stage, status, semantic, stageRoot, lin
763
770
  memory_bank_scope: "changed_files_and_links_only",
764
771
  status: "passed"
765
772
  },
766
- breadcrumbs: [{ label: "RUN", href: view.run.run_index_path, status: "available" }]
773
+ breadcrumbs: [{ label: "RUN", href: runStatePath(view.run), status: "available" }]
767
774
  };
768
775
  }
769
776
  function preparePlanFinish(context, projectRoot, view, stageRoot) {
@@ -7,7 +7,7 @@ import { resolveProjectRoot } from "../storage/paths.js";
7
7
  import { findRecentMatchingHookEvent, claimStageResumeHookEvent, stageResumeMatchKey } from "./hooks.js";
8
8
  import { requireProjectByRoot } from "./projects.js";
9
9
  import { appendFlowRunTimelineEvent, pauseFlowRunStage, resumeFlowRunStage } from "./runs.js";
10
- import { refreshRunWorkProjection } from "./work-registry.js";
10
+ import { bindSessionForResume, refreshRunWorkProjection } from "./work-registry.js";
11
11
  export function pauseStageForUser(context, input) {
12
12
  const projectRoot = resolveProjectRoot(input.projectRoot);
13
13
  const project = requireProjectByRoot(context, projectRoot);
@@ -25,8 +25,18 @@ export function pauseStageForUser(context, input) {
25
25
  fs.mkdirSync(pauseRoot, { recursive: true });
26
26
  fs.writeFileSync(questionPath, question);
27
27
  const now = context.now();
28
- context.db.run("UPDATE works SET status = 'paused', updated_at = ? WHERE work_id = ? AND status = 'running'", [now, work.work_id]);
29
- pauseFlowRunStage(context, { projectRoot, runId: run.id, stage: stage.stage, workId: work.work_id, pauseId, questionPath });
28
+ context.db.exec("BEGIN IMMEDIATE");
29
+ try {
30
+ const paused = context.db.run("UPDATE works SET status = 'paused', updated_at = ? WHERE work_id = ? AND status = 'running'", [now, work.work_id]);
31
+ if (paused.changes !== 1)
32
+ throw new AppError("invalid_work_state", "Work changed before pause could be accepted", 1, { work_id: work.work_id });
33
+ pauseFlowRunStage(context, { projectRoot, runId: run.id, stage: stage.stage, workId: work.work_id, pauseId, questionPath });
34
+ context.db.exec("COMMIT");
35
+ }
36
+ catch (error) {
37
+ context.db.exec("ROLLBACK");
38
+ throw error;
39
+ }
30
40
  refreshRunWorkProjection(context, project.id, run.id);
31
41
  const resume = stageResumeCommand(context, { runId: run.id, stage: stage.stage, workId: work.work_id, projectRoot });
32
42
  const resumeTemplate = `${resume} <<'USER_ANSWER'\n<paste the complete user answer exactly>\nUSER_ANSWER`;
@@ -61,26 +71,31 @@ export function resumeStageAfterUser(context, input) {
61
71
  errorCode: "trusted_stage_resume_required",
62
72
  operation: "stage resume"
63
73
  }).eventKey;
64
- const identity = claimStageResumeHookEvent(context, {
65
- projectId: project.id,
66
- runId: run.id,
67
- stage: stage.stage,
68
- workId: work.work_id,
69
- projectRoot,
70
- eventKey: hookEventId
71
- });
72
74
  const answerPath = path.join(path.dirname(stage.pause.question_path), "answer.md");
73
75
  fs.writeFileSync(answerPath, input.answer);
74
76
  const now = context.now();
75
77
  const workSession = context.db.get("SELECT id, session_id, prompt_path, result_path, status FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [work.work_id]);
76
78
  if (!workSession)
77
79
  throw new AppError("runtime_missing", "Paused Work has no open Session link", 1, { work_id: work.work_id });
78
- if (workSession.session_id !== identity.sessionId) {
79
- context.db.run("UPDATE work_sessions SET status = 'completed', updated_at = ?, completed_at = ? WHERE id = ?", [now, now, workSession.id]);
80
- context.db.run("INSERT INTO work_sessions (id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?, NULL)", [`WSES-${crypto.randomUUID()}`, work.work_id, identity.sessionId, identity.hookEventId, workSession.prompt_path, workSession.result_path, now, now]);
80
+ context.db.exec("BEGIN IMMEDIATE");
81
+ let identity;
82
+ try {
83
+ identity = claimStageResumeHookEvent(context, { projectId: project.id, runId: run.id, stage: stage.stage, workId: work.work_id, projectRoot, eventKey: hookEventId });
84
+ if (workSession.session_id !== identity.sessionId) {
85
+ bindSessionForResume(context, work.work_id, identity, now);
86
+ context.db.run("UPDATE work_sessions SET status = 'completed', updated_at = ?, completed_at = ? WHERE id = ?", [now, now, workSession.id]);
87
+ context.db.run("INSERT INTO work_sessions (id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?, NULL)", [`WSES-${crypto.randomUUID()}`, work.work_id, identity.sessionId, identity.hookEventId, workSession.prompt_path, workSession.result_path, now, now]);
88
+ }
89
+ const resumed = context.db.run("UPDATE works SET status = 'running', updated_at = ? WHERE work_id = ? AND status = 'paused'", [now, work.work_id]);
90
+ if (resumed.changes !== 1)
91
+ throw new AppError("invalid_work_state", "Work changed before resume could be accepted", 1, { work_id: work.work_id });
92
+ resumeFlowRunStage(context, { projectRoot, runId: run.id, stage: stage.stage, workId: work.work_id, answerPath });
93
+ context.db.exec("COMMIT");
94
+ }
95
+ catch (error) {
96
+ context.db.exec("ROLLBACK");
97
+ throw error;
81
98
  }
82
- context.db.run("UPDATE works SET status = 'running', updated_at = ? WHERE work_id = ? AND status = 'paused'", [now, work.work_id]);
83
- resumeFlowRunStage(context, { projectRoot, runId: run.id, stage: stage.stage, workId: work.work_id, answerPath });
84
99
  refreshRunWorkProjection(context, project.id, run.id);
85
100
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "stage_resume_session_bound", stage: stage.stage, work_id: work.work_id, session_id: identity.sessionId });
86
101
  const prompt = fs.readFileSync(workSession.prompt_path, "utf8");
@@ -152,7 +167,7 @@ export function flowCommand(context) {
152
167
  return context.ddFlowHome === defaultHome ? "dd-flow" : `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} dd-flow`;
153
168
  }
154
169
  function requireRun(context, projectId, id) {
155
- const rows = context.db.all("SELECT id, short_id, project_id, project_root, run_home_path, index_json FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [projectId, id, id]);
170
+ const rows = context.db.all("SELECT id, short_id, project_id, project_root, run_root, index_json FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [projectId, id, id]);
156
171
  if (rows.length !== 1)
157
172
  throw new AppError(rows.length ? "ambiguous_alias" : "not_found", rows.length ? "RUN alias is ambiguous" : "RUN is not registered", 1, { run_id: id });
158
173
  return rows[0];
@@ -171,9 +186,9 @@ function requireStage(run, stage, status) {
171
186
  return found;
172
187
  }
173
188
  function requireRunHome(run) {
174
- if (!run.run_home_path)
175
- throw new AppError("runtime_missing", "RUN has no artifact home", 1, { run_id: run.id });
176
- return run.run_home_path;
189
+ if (!run.run_root)
190
+ throw new AppError("runtime_missing", "RUN has no artifact root", 1, { run_id: run.id });
191
+ return run.run_root;
177
192
  }
178
193
  function nextPauseId(runHome) {
179
194
  const root = path.join(runHome, "intake", "hitl");