@deksden-com/dd-flow-cli 0.9.0-beta.11 → 0.9.0-beta.110

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 (162) hide show
  1. package/CHANGELOG.md +520 -0
  2. package/README.md +68 -0
  3. package/dist/build-info.json +8 -8
  4. package/dist/cli/command-inputs.js +305 -0
  5. package/dist/cli/help.js +89 -13
  6. package/dist/cli/hook-ingress.js +77 -0
  7. package/dist/cli/input-preparation.js +116 -0
  8. package/dist/cli/native-hook-ingress.js +48 -0
  9. package/dist/cli/run-cli.js +1465 -384
  10. package/dist/cli.js +8 -2
  11. package/dist/harness-runtime/DD-ZCODE.md +99 -0
  12. package/dist/harness-runtime/bin/dd-agy.mjs +52 -0
  13. package/dist/harness-runtime/bin/dd-codex.mjs +26 -0
  14. package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
  15. package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
  16. package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
  17. package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
  18. package/dist/harness-runtime/lib/agy-hooks.d.mts +1 -0
  19. package/dist/harness-runtime/lib/agy-hooks.mjs +9 -0
  20. package/dist/harness-runtime/lib/daemon-operations.mjs +226 -0
  21. package/dist/harness-runtime/lib/dd-agy-daemon.d.mts +6 -0
  22. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +500 -0
  23. package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
  24. package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
  25. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +149 -0
  26. package/dist/harness-runtime/lib/dd-codex.mjs +563 -0
  27. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
  28. package/dist/harness-runtime/lib/dd-droid.mjs +475 -0
  29. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +388 -0
  30. package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
  31. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +219 -0
  32. package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
  33. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +645 -0
  34. package/dist/harness-runtime/lib/dd-zcode.mjs +982 -0
  35. package/dist/harness-runtime/lib/delegation-instructions.d.mts +13 -0
  36. package/dist/harness-runtime/lib/delegation-instructions.mjs +144 -0
  37. package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
  38. package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
  39. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  40. package/dist/harness-runtime/lib/managed-daemon.mjs +327 -0
  41. package/dist/harness-runtime/lib/model-observations.mjs +77 -0
  42. package/dist/harness-runtime/lib/native-hook-command.d.mts +3 -0
  43. package/dist/harness-runtime/lib/native-hook-command.mjs +108 -0
  44. package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
  45. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  46. package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
  47. package/dist/harness-runtime/lib/process-json.mjs +71 -0
  48. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  49. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  50. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  51. package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
  52. package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
  53. package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
  54. package/dist/harness-runtime/lib/zcode-usage.mjs +54 -0
  55. package/dist/protocol/local-files.js +18 -9
  56. package/dist/runtime/context.js +2 -2
  57. package/dist/schemas/agent-profile.schema.json +1 -1
  58. package/dist/schemas/code-review-result.schema.json +1 -1
  59. package/dist/schemas/code-work-batch.schema.json +3 -3
  60. package/dist/schemas/code-work-result.schema.json +3 -3
  61. package/dist/schemas/plan-review-result.schema.json +2 -2
  62. package/dist/schemas/run-control-receipt.schema.json +99 -0
  63. package/dist/schemas/run-control-request.schema.json +36 -0
  64. package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
  65. package/dist/services/canon.js +11 -8
  66. package/dist/services/cleanup.js +132 -39
  67. package/dist/services/cli-operation-classifier.js +22 -24
  68. package/dist/services/code-checks.js +589 -106
  69. package/dist/services/codex-hook-delivery.js +28 -0
  70. package/dist/services/command-context.js +80 -0
  71. package/dist/services/config.js +11 -8
  72. package/dist/services/continuation-outcome.js +20 -0
  73. package/dist/services/controller-fanout.js +173 -0
  74. package/dist/services/dashboard.js +103 -31
  75. package/dist/services/engines.js +121 -76
  76. package/dist/services/eval-snapshots.js +994 -97
  77. package/dist/services/execution-policy.js +181 -0
  78. package/dist/services/external-work-launch.js +166 -0
  79. package/dist/services/harness-adapter.js +147 -24
  80. package/dist/services/harness-config.js +2 -0
  81. package/dist/services/hooks.js +702 -327
  82. package/dist/services/lanes.js +78 -65
  83. package/dist/services/lifecycle-command.js +113 -18
  84. package/dist/services/lifecycle-contract.js +30 -0
  85. package/dist/services/lifecycle-invocations.js +1379 -0
  86. package/dist/services/managed-daemon-binding.js +38 -0
  87. package/dist/services/managed-processes.js +302 -34
  88. package/dist/services/merge-queue.js +228 -149
  89. package/dist/services/merge-server.js +66 -28
  90. package/dist/services/migrations.js +37 -16
  91. package/dist/services/native-daemon-history.js +49 -0
  92. package/dist/services/native-session-control.js +39 -0
  93. package/dist/services/plan-runtime.js +26 -11
  94. package/dist/services/plans.js +28 -25
  95. package/dist/services/projects.js +20 -15
  96. package/dist/services/prompts.js +25 -16
  97. package/dist/services/protocols.js +160 -86
  98. package/dist/services/recovery-observation-budget.js +61 -0
  99. package/dist/services/recovery-snapshot-database.js +142 -0
  100. package/dist/services/repair-intents.js +40 -0
  101. package/dist/services/review-copy.js +136 -0
  102. package/dist/services/run-control-receipt.js +56 -0
  103. package/dist/services/run-control-worker.js +351 -0
  104. package/dist/services/run-control.js +888 -0
  105. package/dist/services/run-controller-adapter.js +231 -0
  106. package/dist/services/run-controller-capture.js +167 -0
  107. package/dist/services/run-controller-process.js +180 -0
  108. package/dist/services/run-controller-recovery.js +221 -0
  109. package/dist/services/run-controller-state.js +36 -0
  110. package/dist/services/run-controller.js +798 -0
  111. package/dist/services/run-engine-bindings.js +1 -1
  112. package/dist/services/run-fork.js +142 -0
  113. package/dist/services/run-observations.js +112 -0
  114. package/dist/services/run-projection.js +22 -64
  115. package/dist/services/run-recovery-runtime.js +80 -0
  116. package/dist/services/run-recovery.js +345 -0
  117. package/dist/services/runs.js +536 -181
  118. package/dist/services/runtime-budget.js +413 -0
  119. package/dist/services/runtime-command.js +43 -0
  120. package/dist/services/runtime-scope-capture.js +52 -0
  121. package/dist/services/runtime-scope-control.js +460 -0
  122. package/dist/services/runtime-scope-resume.js +577 -0
  123. package/dist/services/runtime-scope-stop.js +159 -0
  124. package/dist/services/runtime-scope-worker.js +242 -0
  125. package/dist/services/runtime-service.js +97 -0
  126. package/dist/services/schema-validation.js +10 -8
  127. package/dist/services/scope-writer-cleanup.js +26 -0
  128. package/dist/services/sessions.js +35 -69
  129. package/dist/services/stage-blocker.js +17 -7
  130. package/dist/services/stage-context.js +50 -19
  131. package/dist/services/stage-lifecycle.js +100 -103
  132. package/dist/services/stage-pause.js +88 -75
  133. package/dist/services/stage-report-renderer.js +116 -6
  134. package/dist/services/stage-work-graph.js +35 -0
  135. package/dist/services/usage.js +35 -98
  136. package/dist/services/vnext-code-review.js +197 -84
  137. package/dist/services/vnext-code.js +278 -251
  138. package/dist/services/vnext-execution-profile.js +5 -3
  139. package/dist/services/vnext-fanout.js +172 -17
  140. package/dist/services/vnext-merge.js +345 -111
  141. package/dist/services/vnext-plan-review.js +171 -111
  142. package/dist/services/vnext-plan.js +180 -87
  143. package/dist/services/vnext-protocolize.js +320 -74
  144. package/dist/services/vnext-specify.js +115 -77
  145. package/dist/services/work-registry.js +1001 -187
  146. package/dist/services/workspace-bootstrap.js +76 -0
  147. package/dist/services/workspace-files.js +83 -0
  148. package/dist/services/worktrees.js +88 -36
  149. package/dist/shared/entity-references.js +8 -0
  150. package/dist/shared/errors.js +12 -0
  151. package/dist/storage/database.js +692 -35
  152. package/dist/storage/foreign-key-transaction.js +39 -0
  153. package/dist/storage/immutable-input.js +45 -0
  154. package/dist/storage/import-guard.js +102 -0
  155. package/dist/storage/native-cleanup.js +52 -0
  156. package/dist/storage/paths.js +17 -4
  157. package/dist/storage/projection-lock.js +73 -0
  158. package/dist/storage/work-references.js +23 -0
  159. package/dist/storage/writer-contract.js +77 -0
  160. package/dist/storage/writer-migration.js +85 -0
  161. package/package.json +24 -13
  162. package/tools/repair-paused-run-status.mjs +84 -0
@@ -2,10 +2,12 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
+ import { withProjectionLock } from "../storage/projection-lock.js";
6
+ import { reconcileRunStageReports } from "./stage-report-renderer.js";
5
7
  import { formatFullId, isFullEntityId, isShortEntityId, parseFullEntityId } from "../domain/entity-ids.js";
6
8
  import { checksumForFlowFlagValues, isFlowFlagDowngrade, loadCanonicalFlowContract, loadProjectFlowContract, resolveFlowFlags } from "../domain/flow-contract.js";
7
9
  import { AppError } from "../shared/errors.js";
8
- import { ensureDir, projectRunHome, projectRunJsonPath, resolveProjectRoot } from "../storage/paths.js";
10
+ import { ensureDir, canonicalPath, projectRunHome, projectRunJsonPath, resolveProjectRoot } from "../storage/paths.js";
9
11
  import { appendAudit } from "./audit.js";
10
12
  import { registerProject, requireProjectByRoot } from "./projects.js";
11
13
  import { buildRunFlowGuidance } from "./flow-guidance.js";
@@ -15,8 +17,11 @@ import { refreshRunSessionProjection } from "./run-projection.js";
15
17
  import { resolveCanonRoot } from "./canon.js";
16
18
  import { bindCurrentEngineToRun } from "./engines.js";
17
19
  import { executionProfilePath, loadVnextExecutionProfile } from "./vnext-execution-profile.js";
18
- import { isLegalVnextTransition, vnextStages } from "../domain/stage-catalog.js";
20
+ import { loadVnextWorkspacePolicy } from "./vnext-workspace-policy.js";
21
+ import { assertSameSessionMerge, loadExecutionRoutingInput, resolveStageTransition, snapshotAgentProfiles } from "./execution-policy.js";
22
+ import { isLegalVnextTransition, vnextStageDirectory, vnextStages } from "../domain/stage-catalog.js";
19
23
  import { publicSessionIdentity } from "./session-identity.js";
24
+ import { flowCommand } from "./stage-pause.js";
20
25
  const runSchemaId = "dd-flow/flow-run@3";
21
26
  const runtimeSchemaId = "dd-flow/flow-run@3";
22
27
  const runIdType = "RUN";
@@ -35,22 +40,26 @@ const allowedRunFlowKinds = [
35
40
  "review",
36
41
  "custom"
37
42
  ];
38
- export function startFlowRun(context, input) {
43
+ export function prepareFlowRunStart(context, input) {
44
+ const subjectId = requiredPlain(input.subjectId, "subject-id");
45
+ const subjectType = requiredPlain(input.subjectType, "subject-type");
46
+ const slug = normalizeSlug(input.slug);
47
+ for (const [name, value] of [["task profile", input.taskProfile], ["protocol overrides", input.protocolOverrides], ["RUN overrides", input.runOverrides]]) {
48
+ if (value !== undefined && (!value || typeof value !== "object" || Array.isArray(value)))
49
+ throw new AppError("validation", `${name} must be a JSON object`, 2);
50
+ }
39
51
  const projectRoot = resolveProjectRoot(input.projectRoot);
52
+ const flowKind = parseRunFlowKind(input.flowKind);
53
+ const vnext = flowKind === "vnext_protocolize" || flowKind === "vnext_specify";
54
+ if (input.executionRoutingFile !== undefined && !vnext)
55
+ throw new AppError("usage", "Execution routing input is supported only for vNext RUNs", 2);
56
+ const routingInput = input.executionRoutingFile !== undefined ? loadExecutionRoutingInput(input.executionRoutingFile) : undefined;
57
+ const executionProfile = vnext ? snapshotVnextExecutionProfile(projectRoot, context.ddFlowHome, routingInput) : undefined;
40
58
  const flowContract = input.flowKind === "mb-upgrade"
41
59
  ? loadUpgradeFlowContract(context, projectRoot)
42
60
  : loadProjectFlowContract(projectRoot);
43
- registerProject(context, { root: projectRoot });
44
- const project = requireProjectByRoot(context, projectRoot);
45
61
  const workspaceRoot = resolveWorkspaceRoot(input.workspaceRoot ?? projectRoot);
46
- const flowKind = parseRunFlowKind(input.flowKind);
47
- const slug = normalizeSlug(input.slug);
48
- const runId = nextRunId(context, project.id, slug);
49
- const { shortId } = parseFullEntityId(runId);
50
62
  const now = context.now();
51
- const executionProfile = (flowKind === "vnext_protocolize" || flowKind === "vnext_specify")
52
- ? snapshotVnextExecutionProfile(projectRoot)
53
- : undefined;
54
63
  const flowFlags = resolveFlowFlags(flowContract, {
55
64
  flowKind,
56
65
  ...(input.preset ? { preset: input.preset } : {}),
@@ -60,6 +69,14 @@ export function startFlowRun(context, input) {
60
69
  snapshotRevision: 1,
61
70
  now
62
71
  });
72
+ return { projectRoot, flowKind, routingInput, executionProfile, workspaceRoot, slug, subjectId, subjectType, now, flowFlags };
73
+ }
74
+ export function startFlowRun(context, input, prepared = prepareFlowRunStart(context, input)) {
75
+ const { projectRoot, flowKind, routingInput, executionProfile, workspaceRoot, slug, subjectId, subjectType, now, flowFlags } = prepared;
76
+ registerProject(context, { root: projectRoot });
77
+ const project = requireProjectByRoot(context, projectRoot);
78
+ const runId = nextRunId(context, project.id, slug);
79
+ const { shortId } = parseFullEntityId(runId);
63
80
  const runHome = projectRunHome(context.ddFlowHome, project.id, runId);
64
81
  const runDirRelative = path.posix.join("runs", runId);
65
82
  const runtimePath = projectRunJsonPath(context.ddFlowHome, project.id, runId);
@@ -69,8 +86,8 @@ export function startFlowRun(context, input) {
69
86
  short_id: shortId,
70
87
  flow_kind: flowKind,
71
88
  subject: {
72
- id: requiredPlain(input.subjectId, "subject-id"),
73
- title: requiredPlain(input.subjectId, "subject-id")
89
+ id: subjectId,
90
+ title: subjectId
74
91
  },
75
92
  project: {
76
93
  id: project.id,
@@ -126,9 +143,10 @@ export function startFlowRun(context, input) {
126
143
  };
127
144
  ensureDir(path.dirname(runtimePath));
128
145
  ensureDir(runHome);
146
+ if (routingInput)
147
+ fs.writeFileSync(path.join(runHome, "execution-routing.input.json"), routingInput.bytes, { flag: "wx", mode: 0o600 });
129
148
  bindCurrentEngineToRun(context, { projectRoot, runId, runRoot: runHome });
130
149
  const persistedIndex = persistedVnextIndex(index);
131
- writeJsonFile(runtimePath, runtimeSnapshotForIndex(persistedIndex, 1));
132
150
  context.db.run(`INSERT INTO runs
133
151
  (id, short_id, slug, project_id, project_root, workspace_root, flow_kind, subject_type, subject_id,
134
152
  status, verdict, next_action, runtime_path, run_dir, run_index_path, run_home_path, run_root, layout_version, artifact_root_kind,
@@ -141,7 +159,7 @@ export function startFlowRun(context, input) {
141
159
  project.root,
142
160
  workspaceRoot,
143
161
  flowKind,
144
- requiredPlain(input.subjectType, "subject-type"),
162
+ subjectType,
145
163
  index.subject.id,
146
164
  index.status,
147
165
  index.verdict,
@@ -162,8 +180,9 @@ export function startFlowRun(context, input) {
162
180
  eventType: "flow_run.started",
163
181
  payload: { run_id: runId, flow_kind: flowKind, subject: index.subject, workspace_root: workspaceRoot, run_home: runHome }
164
182
  });
165
- appendRunTimeline(runHome, { at: now, type: "run_started", run_id: runId });
166
- appendRunTimeline(runHome, {
183
+ context.db.afterCommit(() => publishRunProjections(context, project.id, runId));
184
+ appendRunTimeline(context, project.id, runId, { at: now, type: "run_started", run_id: runId });
185
+ appendRunTimeline(context, project.id, runId, {
167
186
  at: now,
168
187
  type: "flow_flags_resolved",
169
188
  run_id: runId,
@@ -173,10 +192,10 @@ export function startFlowRun(context, input) {
173
192
  });
174
193
  return { ok: true, run: flowRunSummary(requireRunById(context, project.id, runId)), index };
175
194
  }
176
- function snapshotVnextExecutionProfile(projectRoot) {
195
+ function snapshotVnextExecutionProfile(projectRoot, ddFlowHome, routingInput) {
177
196
  const profile = loadVnextExecutionProfile(projectRoot);
178
197
  const sourcePath = executionProfilePath(projectRoot);
179
- return {
198
+ const frozen = freezeRunExecutionRouting(ddFlowHome, {
180
199
  schema_id: "dd-flow/run-execution-profile@1",
181
200
  source_path: sourcePath,
182
201
  source_checksum: crypto.createHash("sha256").update(fs.readFileSync(sourcePath)).digest("hex"),
@@ -188,9 +207,47 @@ function snapshotVnextExecutionProfile(projectRoot) {
188
207
  merge_delivery: profile.merge_delivery,
189
208
  merge_cleanup: profile.merge_cleanup,
190
209
  stop_target: profile.stop_target,
191
- code_bootstrap: profile.code_bootstrap
210
+ code_bootstrap: profile.code_bootstrap,
211
+ ...(profile.execution ? { execution: profile.execution } : {})
192
212
  }
193
- };
213
+ }, routingInput);
214
+ if (frozen.settings.merge_mode === "same_session" && frozen.settings.stop_target === "merge_completed"
215
+ && fs.existsSync(path.join(projectRoot, ".memory-bank", "dd-flow", "project-workspace.json"))
216
+ && loadVnextWorkspacePolicy(projectRoot).workspace.route === "feature_worktree") {
217
+ throw new AppError("merge_session_workspace_conflict", "Feature-worktree MERGE targets the integration checkout; use server MERGE or a qualified new Session", 2, { project_root: projectRoot });
218
+ }
219
+ return frozen;
220
+ }
221
+ /** Fresh RUN and fresh stage-entry experiments freeze through the same resolver. */
222
+ export function freezeRunExecutionRouting(ddFlowHome, base, input) {
223
+ const execution = input?.execution ?? base.settings.execution;
224
+ const profiles = execution ? snapshotAgentProfiles(ddFlowHome, execution) : undefined;
225
+ if (execution && profiles && base.settings.merge_mode === "same_session" && base.settings.stop_target === "merge_completed") {
226
+ const mode = base.settings.code_review_mode;
227
+ assertSameSessionMerge({ ddFlowHome, policy: execution, profiles, previousStages: mode === "off" ? ["code"] : mode === "auto" ? ["code", "code-review"] : ["code-review"] });
228
+ }
229
+ return { ...base, ...(profiles ? { agent_profiles: profiles } : {}),
230
+ ...(input ? { routing_input: { source_path: input.source_path, source_checksum: input.source_checksum, artifact: "execution-routing.input.json" } } : {}),
231
+ settings: { ...base.settings, ...(execution ? { execution } : {}) } };
232
+ }
233
+ /** Called only after a stage-entry import has archived and detached source owners. */
234
+ export function importFlowRunExecutionProfile(context, input) {
235
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
236
+ const run = resolveRun(context, project.id, input.runId);
237
+ assertRunMutationAllowed(context, project.id, run.id);
238
+ const index = authoritativeIndex(run);
239
+ const lineage = JSON.parse(fs.readFileSync(input.lineageFile, "utf8"));
240
+ if (lineage.schema_id !== "dd-flow/snapshot-runtime-lineage@1" || lineage.run_id !== run.id || path.resolve(lineage.restored_runtime ?? "") !== path.resolve(context.ddFlowHome)
241
+ || !vnextStages.some(stage => stage.id === input.stage)
242
+ || (context.db.get("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'run_controllers'") && context.db.get("SELECT 1 FROM run_controllers WHERE project_id = ? AND run_id = ? LIMIT 1", [project.id, run.id]))
243
+ || index.stage_runs.some(stage => stage.stage === input.stage || !["done", "skipped"].includes(stage.status))
244
+ || context.db.get("SELECT 1 FROM sessions WHERE project_id = ? AND run_id = ? AND status IN ('active','idle') LIMIT 1", [project.id, run.id])) {
245
+ throw new AppError("snapshot_execution_active", "Execution policy can be selected only at a detached stage-entry import", 1);
246
+ }
247
+ index.execution_profile = { ...input.profile, stage_entry_import: { stage: input.stage, lineage_file: input.lineageFile } };
248
+ index.updated_at = context.now();
249
+ persistRunState(context, project, run, index);
250
+ appendRunTimeline(context, project.id, run.id, { at: index.updated_at, type: "execution_policy_imported", stage: input.stage, lineage_file: input.lineageFile, source_checksum: input.profile.routing_input?.source_checksum });
194
251
  }
195
252
  /**
196
253
  * A vNext route may materialize a feature checkout after its semantic
@@ -200,6 +257,7 @@ function snapshotVnextExecutionProfile(projectRoot) {
200
257
  export function rebindFlowRunWorkspace(context, input) {
201
258
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
202
259
  const run = resolveRun(context, project.id, input.runId);
260
+ assertRunMutationAllowed(context, project.id, run.id);
203
261
  const workspaceRoot = resolveWorkspaceRoot(input.workspaceRoot);
204
262
  const index = authoritativeIndex(run);
205
263
  const now = context.now();
@@ -210,7 +268,7 @@ export function rebindFlowRunWorkspace(context, input) {
210
268
  context.db.run("UPDATE runs SET workspace_root = ? WHERE project_id = ? AND id = ?", [workspaceRoot, project.id, run.id]);
211
269
  persistRunState(context, project, { ...run, workspace_root: workspaceRoot }, index);
212
270
  appendAudit(context, { projectId: project.id, eventType: "flow_run.workspace_rebound", payload: { run_id: run.id, workspace_root: workspaceRoot, reason: input.reason } });
213
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "workspace_rebound", run_id: run.id, workspace_root: workspaceRoot, reason: input.reason, git: facts });
271
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "workspace_rebound", run_id: run.id, workspace_root: workspaceRoot, reason: input.reason, git: facts });
214
272
  return { ok: true, run_id: run.id, project_root: project.root, workspace_root: workspaceRoot, git: facts };
215
273
  }
216
274
  function loadUpgradeFlowContract(context, projectRoot) {
@@ -229,7 +287,41 @@ export function getFlowRunStatus(context, input) {
229
287
  const run = resolveRun(context, project.id, input.runId);
230
288
  const index = authoritativeIndex(run);
231
289
  const runtime = readRuntimeSnapshot(run.runtime_path);
232
- return { ok: true, run: flowRunSummary(run), index, ...(runtime ? { runtime } : {}), flow_guidance: guidanceForRun(context, run, index) };
290
+ return { ok: true, run: flowRunSummary(run), index, continuation: runContinuation(context, run, index), ...(runtime ? { runtime } : {}), flow_guidance: guidanceForRun(context, run, index) };
291
+ }
292
+ /** A read-only projection of the next legal lifecycle action.
293
+ * It is derived from the persisted RUN/Work facts so controllers never infer a
294
+ * transition from a model's prose or a hard-coded stage array. */
295
+ function runContinuation(context, run, index) {
296
+ if (["done", "failed", "cancelled"].includes(run.status))
297
+ return { kind: "terminal", stage: null, attempt: null, work_id: null, reason: run.status, command: null };
298
+ const control = runControlDirective(context, run.project_id, run.id);
299
+ if (control)
300
+ return { kind: "wait", stage: index.current_stage ?? null, attempt: null, work_id: null, reason: "operator_control", command: null, control };
301
+ const active = index.stage_runs.find((stage) => !["done", "skipped"].includes(stage.status));
302
+ if (active) {
303
+ const runningWork = context.db.get("SELECT work_id FROM works WHERE project_id = ? AND run_id = ? AND status = 'running' ORDER BY updated_at DESC LIMIT 1", [run.project_id, run.id]);
304
+ const kind = active.status === "running" ? "continue_stage" : active.status === "paused" || active.status === "waiting_for_user" ? "paused" : "blocked";
305
+ return { kind, stage: active.stage, attempt: active.attempt ?? null, work_id: active.pause?.work_id ?? runningWork?.work_id ?? null, reason: active.status, command: null };
306
+ }
307
+ if (run.status === "waiting_for_user" || run.status === "paused")
308
+ return { kind: "paused", stage: index.current_stage ?? null, attempt: null, work_id: null, reason: run.status, command: null };
309
+ if (run.status === "blocked")
310
+ return { kind: "blocked", stage: index.current_stage ?? null, attempt: null, work_id: null, reason: run.status, command: null };
311
+ if (run.flow_kind === "vnext_specify" || run.flow_kind === "vnext_protocolize") {
312
+ const completed = index.stage_runs.filter((stage) => stage.status === "done" || stage.status === "skipped").sort((left, right) => right.order - left.order)[0];
313
+ const next = completed ? vnextStages.find((stage) => stage.id === completed.stage)?.next.find((candidate) => candidate !== "plan-review" || index.settings?.plan_review?.mode !== "off") : "specify";
314
+ if (!next)
315
+ return { kind: "terminal", stage: null, attempt: null, work_id: null, reason: "flow_complete", command: null };
316
+ const command = `${flowCommand(context)} stage start ${run.id} --stage ${next} --project-root ${JSON.stringify(run.project_root)} --json`;
317
+ const profile = index.execution_profile;
318
+ const execution = profile?.settings.execution ? profile.agent_profiles
319
+ ? { status: "resolved", ...resolveStageTransition({ ddFlowHome: context.ddFlowHome, policy: profile.settings.execution, profiles: profile.agent_profiles, projectRoot: run.project_root, workspaceRoot: run.workspace_root, ...(completed ? { fromStage: completed.stage } : {}), stage: next, stageSessionMode: profile.settings.stage_session_mode, mergeMode: profile.settings.merge_mode }) }
320
+ : { status: "unfrozen", reason: "execution_profile_not_frozen" }
321
+ : undefined;
322
+ return { kind: "start_stage", stage: next, attempt: null, work_id: null, reason: "legal_successor", command, ...(execution ? { execution } : {}) };
323
+ }
324
+ return { kind: "wait", stage: index.current_stage ?? null, attempt: null, work_id: null, reason: "no_vnext_continuation", command: null };
233
325
  }
234
326
  export function getFlowRunConfig(context, input) {
235
327
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -250,9 +342,12 @@ export function getFlowRunVariables(context, input) {
250
342
  return { ok: true, run_id: run.id, variables };
251
343
  }
252
344
  export function setFlowRunVariable(context, input) {
345
+ return setRunVariable(context, input, input.value, prepareFlowRunVariable(context, input));
346
+ }
347
+ export function prepareFlowRunVariable(context, input) {
253
348
  if (!input.key.startsWith("user."))
254
349
  throw new AppError("validation", "Only user.* RUN variables may be set directly", 2, { key: input.key });
255
- return setRunVariable(context, input, input.value);
350
+ return prepareRunVariable(context, input);
256
351
  }
257
352
  /** Controllers own policy.* and runtime.* values; agents never call this directly. */
258
353
  export function setRuntimeRunVariable(context, input) {
@@ -261,20 +356,7 @@ export function setRuntimeRunVariable(context, input) {
261
356
  return setRunVariable(context, input, input.value);
262
357
  }
263
358
  export function setFlowRunConfig(context, input) {
264
- if (input.key !== "plan_review.mode" && input.key !== "code_review.mode") {
265
- throw new AppError("validation", `Unknown RUN config key: ${input.key}`, 2);
266
- }
267
- if (!["auto", "off", "standard", "deep"].includes(input.value)) {
268
- throw new AppError("validation", "--value for review mode must be auto, off, standard, or deep", 2);
269
- }
270
- const reason = requiredPlain(input.reason, "reason");
271
- const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
272
- const run = resolveRun(context, project.id, input.runId);
273
- const index = authoritativeIndex(run);
274
- const stageName = input.key === "plan_review.mode" ? "plan-review" : "code-review";
275
- if (index.stage_runs.some((stage) => stage.stage === stageName)) {
276
- throw new AppError("invalid_run_config_state", `${input.key} is frozen after ${stageName} starts`, 2, { run_id: run.id });
277
- }
359
+ const { reason, project, run, index } = prepareFlowRunConfig(context, input);
278
360
  const now = context.now();
279
361
  const settings = runSettings(index);
280
362
  const previous = input.key === "plan_review.mode" ? settings.plan_review : settings.code_review;
@@ -289,13 +371,32 @@ export function setFlowRunConfig(context, input) {
289
371
  index.updated_at = now;
290
372
  persistRunState(context, project, run, index);
291
373
  appendAudit(context, { projectId: project.id, eventType: "flow_run.config_set", payload: { run_id: run.id, key: input.key, value: mode, source, reason } });
292
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_config_set", run_id: run.id, key: input.key, value: mode, source, reason });
374
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "run_config_set", run_id: run.id, key: input.key, value: mode, source, reason });
293
375
  return { ok: true, run_id: run.id, settings: runSettings(index), idempotent: false };
294
376
  }
377
+ export function prepareFlowRunConfig(context, input) {
378
+ if (input.key !== "plan_review.mode" && input.key !== "code_review.mode") {
379
+ throw new AppError("validation", `Unknown RUN config key: ${input.key}`, 2);
380
+ }
381
+ if (!["auto", "off", "standard", "deep"].includes(input.value)) {
382
+ throw new AppError("validation", "--value for review mode must be auto, off, standard, or deep", 2);
383
+ }
384
+ const reason = requiredPlain(input.reason, "reason");
385
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
386
+ const run = resolveRun(context, project.id, input.runId);
387
+ assertRunMutationAllowed(context, project.id, run.id);
388
+ const index = authoritativeIndex(run);
389
+ const stageName = input.key === "plan_review.mode" ? "plan-review" : "code-review";
390
+ if (index.stage_runs.some((stage) => stage.stage === stageName)) {
391
+ throw new AppError("invalid_run_config_state", `${input.key} is frozen after ${stageName} starts`, 2, { run_id: run.id });
392
+ }
393
+ return { reason, project, run, index };
394
+ }
295
395
  /** Freeze an automatic review request once from already accepted policy or PLAN facts. */
296
396
  export function freezeFlowRunReviewMode(context, input) {
297
397
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
298
398
  const run = resolveRun(context, project.id, input.runId);
399
+ assertRunMutationAllowed(context, project.id, run.id);
299
400
  const index = authoritativeIndex(run);
300
401
  const settings = runSettings(index);
301
402
  const key = input.review === "plan" ? "plan_review" : "code_review";
@@ -312,8 +413,8 @@ export function getFlowRunFlagsStatus(context, input) {
312
413
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
313
414
  const run = resolveRun(context, project.id, input.runId);
314
415
  const index = authoritativeIndex(run);
315
- const runtime = readRuntimeSnapshot(run.runtime_path);
316
- const flags = runtime?.flow_flags ?? index.flow_flags;
416
+ const legacyRuntime = index.flow_flags ? null : readRuntimeSnapshot(run.runtime_path);
417
+ const flags = index.flow_flags ?? legacyRuntime?.flow_flags;
317
418
  if (!flags) {
318
419
  return {
319
420
  ok: true,
@@ -331,29 +432,31 @@ export function getFlowRunFlagsStatus(context, input) {
331
432
  snapshot_revision: flags.snapshot_revision,
332
433
  snapshot_checksum: flags.snapshot_checksum,
333
434
  flow_flags: flags,
334
- runtime_revision: runtime?.runtime_revision ?? index.runtime_revision ?? null
435
+ runtime_revision: index.runtime_revision ?? legacyRuntime?.runtime_revision ?? null
335
436
  };
336
437
  }
337
438
  export function reviseFlowRunFlags(context, input) {
338
439
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
339
440
  const run = resolveRun(context, project.id, input.runId);
441
+ assertRunMutationAllowed(context, project.id, run.id);
340
442
  const idempotencyKey = requiredPlain(input.idempotencyKey, "idempotency-key");
341
- context.db.exec("BEGIN IMMEDIATE");
342
- let committed = false;
343
- try {
443
+ const priorMutation = context.db.get("SELECT result_json FROM flow_run_flag_mutations WHERE project_id = ? AND run_id = ? AND idempotency_key = ?", [project.id, run.id, idempotencyKey]);
444
+ if (priorMutation)
445
+ return { ...JSON.parse(priorMutation.result_json), idempotent: true };
446
+ // Canonical contract bytes are prepared before taking the writer. Revision
447
+ // and idempotency are compared again against SQL authority under the lock.
448
+ const contract = loadProjectFlowContract(run.project_root);
449
+ return context.db.writeTransaction(() => {
344
450
  const existingMutation = context.db.get("SELECT result_json FROM flow_run_flag_mutations WHERE project_id = ? AND run_id = ? AND idempotency_key = ?", [project.id, run.id, idempotencyKey]);
345
451
  if (existingMutation) {
346
452
  const result = { ...JSON.parse(existingMutation.result_json), idempotent: true };
347
- context.db.exec("COMMIT");
348
- committed = true;
349
453
  return result;
350
454
  }
351
455
  // Read the authority only after the write lock: two callers with the same
352
456
  // expected revision must not both pass the compare-and-set check.
353
457
  const lockedRun = requireRunById(context, project.id, run.id);
354
458
  const index = authoritativeIndex(lockedRun);
355
- const runtime = readRuntimeSnapshot(lockedRun.runtime_path);
356
- const current = runtime?.flow_flags ?? index.flow_flags;
459
+ const current = index.flow_flags;
357
460
  if (!current) {
358
461
  throw new AppError("validation", "Legacy RUN has no flow-flag snapshot; revise is unavailable", 2, { run_id: run.id });
359
462
  }
@@ -370,7 +473,6 @@ export function reviseFlowRunFlags(context, input) {
370
473
  });
371
474
  }
372
475
  const now = context.now();
373
- const contract = loadProjectFlowContract(lockedRun.project_root);
374
476
  const next = resolveFlowFlags(contract, {
375
477
  flowKind: index.flow_kind,
376
478
  preset: input.preset ?? current.preset.applied,
@@ -426,14 +528,12 @@ export function reviseFlowRunFlags(context, input) {
426
528
  context.db.run(`INSERT INTO flow_run_flag_mutations
427
529
  (project_id, run_id, idempotency_key, expected_revision, resulting_revision, result_json, created_at)
428
530
  VALUES (?, ?, ?, ?, ?, ?, ?)`, [project.id, run.id, idempotencyKey, input.expectedRevision, next.snapshot_revision, JSON.stringify(result), now]);
429
- context.db.exec("COMMIT");
430
- committed = true;
431
531
  appendAudit(context, {
432
532
  projectId: project.id,
433
533
  eventType: "flow_run.flags_revised",
434
534
  payload: { run_id: run.id, snapshot_revision: next.snapshot_revision, snapshot_checksum: next.snapshot_checksum, ...(reason ? { reason } : {}) }
435
535
  });
436
- appendRunTimeline(runArtifactRoot(run), {
536
+ appendRunTimeline(context, project.id, run.id, {
437
537
  at: now,
438
538
  type: "flow_flags_revised",
439
539
  run_id: run.id,
@@ -443,12 +543,7 @@ export function reviseFlowRunFlags(context, input) {
443
543
  ...(reason ? { reason } : {})
444
544
  });
445
545
  return result;
446
- }
447
- catch (error) {
448
- if (!committed)
449
- context.db.exec("ROLLBACK");
450
- throw error;
451
- }
546
+ }, "runs.reviseFlowFlags");
452
547
  }
453
548
  export function listFlowRuns(context, input) {
454
549
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -459,24 +554,34 @@ export function listFlowRuns(context, input) {
459
554
  .map(flowRunSummary);
460
555
  return { ok: true, project, runs };
461
556
  }
462
- export function attachFlowRunStage(context, input) {
557
+ export function prepareFlowRunStageAttachment(context, input) {
463
558
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
464
559
  const run = resolveRun(context, project.id, input.runId);
465
- const index = authoritativeIndex(run);
466
- const now = context.now();
560
+ assertRunMutationAllowed(context, project.id, run.id);
467
561
  const stage = requiredPlain(input.stage, "stage");
468
- const dir = requiredStageDir(input.dir);
562
+ const dir = prepareStageDirectory(input.dir);
563
+ const index = authoritativeIndex(run);
469
564
  const status = parseStageStatus(input.status);
470
565
  const existing = index.stage_runs.find((item) => item.stage === stage);
471
566
  assertVnextStageStart(run, index, stage, status, existing);
567
+ return { project, run, index, stage, dir, status, existing };
568
+ }
569
+ export function attachFlowRunStage(context, input) {
570
+ // Mutable stage state is re-read immediately before archiving/persisting.
571
+ const { project, run, index, stage, dir, status, existing } = prepareFlowRunStageAttachment(context, input);
572
+ const runRoot = runArtifactRoot(run);
573
+ if (fs.existsSync(path.join(runRoot, `.archive-${dir}.json`)))
574
+ archiveExistingStageAttempt(context, project.id, run.id, runRoot, dir);
575
+ const now = context.now();
472
576
  if (status === "running" && existing) {
473
- archiveExistingStageAttempt(runArtifactRoot(run), dir);
577
+ archiveExistingStageAttempt(context, project.id, run.id, runRoot, dir);
474
578
  }
475
579
  const stageRun = {
476
580
  ...(existing ?? { order: index.stage_runs.length + 1 }),
477
581
  stage,
478
582
  dir,
479
583
  status,
584
+ ...(input.inputSha256 ? { input_sha256: input.inputSha256 } : {}),
480
585
  ...(input.dataSchemaId ? { data_schema_id: input.dataSchemaId } : existing?.data_schema_id ? { data_schema_id: existing.data_schema_id } : {}),
481
586
  updated_at: now
482
587
  };
@@ -497,7 +602,8 @@ export function attachFlowRunStage(context, input) {
497
602
  eventType: "flow_run.stage_attached",
498
603
  payload: { run_id: run.id, stage, dir, status }
499
604
  });
500
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_attached", run_id: run.id, stage, status, attempt: stageRun.attempt ?? null });
605
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "stage_attached", run_id: run.id, stage, status, attempt: stageRun.attempt ?? null });
606
+ context.db.afterCommit(() => reconcileRunStageReports(context, project.id, run.id, stage));
501
607
  // A stage transition must not rewrite every Session in the RUN. A RUN may
502
608
  // contain independent reviewer/worker sessions; their stage is set by the
503
609
  // stage/work binding that actually owns that Session.
@@ -506,66 +612,108 @@ export function attachFlowRunStage(context, input) {
506
612
  const updatedRun = requireRunById(context, project.id, run.id);
507
613
  return { ok: true, run: flowRunSummary(updatedRun), stage_run: stageRun, index, flow_guidance: guidanceForRun(context, updatedRun, index) };
508
614
  }
509
- export function completeFlowRunStage(context, input) {
615
+ /** Keep synchronous stage settlement atomic; files are projections, SQLite is authoritative. */
616
+ export function withStageSettlement(context, operation) {
617
+ return context.db.writeTransaction(operation, "stage.settle");
618
+ }
619
+ export function prepareFlowRunStageCompletion(context, input) {
620
+ for (const [name, value] of Object.entries({ stageReport: input.stageReport, data: input.data, dataSchemaId: input.dataSchemaId, report: input.report })) {
621
+ if (value !== undefined)
622
+ requiredPlain(value, name);
623
+ }
624
+ if (input.aliases !== undefined) {
625
+ if (!Array.isArray(input.aliases))
626
+ throw new AppError("validation", "artifact aliases must be an array", 2);
627
+ for (const alias of input.aliases)
628
+ requiredPlain(alias, "artifact alias");
629
+ }
510
630
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
511
631
  const run = resolveRun(context, project.id, input.runId);
632
+ if (["done", "failed", "skipped"].includes(input.status))
633
+ assertStageSettlementAllowed(context, project.id, run.id, input.stage);
634
+ else
635
+ assertRunMutationAllowed(context, project.id, run.id);
512
636
  const index = authoritativeIndex(run);
513
- const now = context.now();
514
637
  const stage = requiredPlain(input.stage, "stage");
515
638
  const status = parseStageStatus(input.status);
516
639
  const existing = index.stage_runs.find((item) => item.stage === stage);
517
640
  if (!existing) {
518
641
  throw new AppError("not_found", `Run stage is not attached: ${stage}`, 1, { run_id: run.id, stage });
519
642
  }
520
- const stageRun = {
521
- ...existing,
522
- status,
523
- ...(input.stageReport ? { stage_report: input.stageReport } : {}),
524
- ...(input.data ? { data: input.data } : {}),
525
- ...(input.dataSchemaId ? { data_schema_id: input.dataSchemaId } : {}),
526
- ...(input.report ? { report: input.report } : {}),
527
- ...(input.aliases && input.aliases.length > 0 ? { artifact_aliases: input.aliases } : {}),
528
- updated_at: now,
529
- ...(status === "done" || status === "waiting_for_user" || status === "blocked" || status === "failed" || status === "skipped"
530
- ? { completed_at: now, duration_ms: durationMs(existing.started_at ?? null, now), git: gitFacts(run.workspace_root) }
531
- : {})
532
- };
533
- upsertStage(index, stageRun);
534
- if (status === "waiting_for_user") {
535
- index.status = "waiting_for_user";
536
- index.verdict = "waiting_for_user";
537
- }
538
- else if (status === "blocked" || status === "failed") {
539
- index.status = status;
540
- index.verdict = status;
541
- }
542
- else if (status === "done" && index.status === "waiting_for_user") {
543
- index.status = "running";
544
- index.verdict = "running";
545
- }
546
- index.current_stage = stage;
547
- index.attempts = index.stage_runs.map((item) => ({ stage: item.stage, attempt_number: Number((item.attempt ?? "try-001").replace("try-", "")) || 1, root: item.dir, archive: null, status: item.status, started_at: item.started_at ?? now, finished_at: item.completed_at ?? null }));
548
- index.updated_at = now;
549
- index.execution = {
550
- project_root: index.execution?.project_root ?? run.project_root,
551
- workspace_root: index.execution?.workspace_root ?? run.workspace_root,
552
- git: gitFacts(run.workspace_root)
553
- };
554
- persistRunState(context, project, run, index);
555
- refreshRunSessionProjection(context, project.id, run.id);
556
- appendAudit(context, {
557
- projectId: project.id,
558
- eventType: "flow_run.stage_completed",
559
- payload: { run_id: run.id, stage, status, stage_report: input.stageReport ?? null, data: input.data ?? null }
560
- });
561
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_completed", run_id: run.id, stage, status, attempt: stageRun.attempt ?? null });
643
+ const dataPath = input.data ? path.join(runArtifactRoot(run), existing.dir, input.data) : null;
644
+ const dataSha256 = input.dataSha256 ?? (dataPath && fs.existsSync(dataPath) ? crypto.createHash("sha256").update(fs.readFileSync(dataPath)).digest("hex") : null);
645
+ if (dataSha256 && !/^[a-f0-9]{64}$/.test(dataSha256))
646
+ throw new AppError("validation", "Stage data SHA-256 is invalid", 2);
647
+ return { project, run, index, stage, status, existing, dataSha256, git: input.git ?? gitFacts(run.workspace_root), generation: ["done", "failed", "skipped"].includes(status) ? assertStageSettlementAllowed(context, project.id, run.id, stage) : assertRunMutationAllowed(context, project.id, run.id) };
648
+ }
649
+ export function completeFlowRunStage(context, input, prepared = prepareFlowRunStageCompletion(context, input)) {
650
+ const { project, run, stage, status, dataSha256, git, generation } = prepared;
651
+ const completed = context.db.writeTransaction(() => {
652
+ if (["done", "failed", "skipped"].includes(status))
653
+ assertStageSettlementAllowed(context, project.id, run.id, stage, generation);
654
+ else
655
+ assertRunMutationAllowed(context, project.id, run.id, generation);
656
+ const latest = requireRunById(context, project.id, run.id);
657
+ const index = authoritativeIndex(latest);
658
+ const existing = index.stage_runs.find((item) => item.stage === stage);
659
+ if (!existing || existing.attempt !== prepared.existing.attempt || index.runtime_revision !== prepared.index.runtime_revision) {
660
+ throw new AppError("stage_revision_changed", "Stage attempt changed during finish preparation", 1, { run_id: run.id, stage, expected_attempt: prepared.existing.attempt, actual_attempt: existing?.attempt ?? null });
661
+ }
662
+ const now = context.now();
663
+ const stageRun = {
664
+ ...existing,
665
+ status,
666
+ ...(input.stageReport ? { stage_report: input.stageReport } : {}),
667
+ ...(input.data ? { data: input.data } : {}),
668
+ ...(dataSha256 ? { data_sha256: dataSha256 } : {}),
669
+ ...(input.dataSchemaId ? { data_schema_id: input.dataSchemaId } : {}),
670
+ ...(input.report ? { report: input.report } : {}),
671
+ ...(input.usage ? { usage: input.usage } : {}),
672
+ ...(input.aliases && input.aliases.length > 0 ? { artifact_aliases: input.aliases } : {}),
673
+ updated_at: now,
674
+ ...(status === "done" || status === "waiting_for_user" || status === "blocked" || status === "failed" || status === "skipped"
675
+ ? { completed_at: now, duration_ms: durationMs(existing.started_at ?? null, now), git }
676
+ : {})
677
+ };
678
+ upsertStage(index, stageRun);
679
+ if (status === "waiting_for_user") {
680
+ index.status = "waiting_for_user";
681
+ index.verdict = "waiting_for_user";
682
+ }
683
+ else if (status === "blocked" || status === "failed") {
684
+ index.status = status;
685
+ index.verdict = status;
686
+ }
687
+ else if (status === "done" && index.status === "waiting_for_user") {
688
+ index.status = "running";
689
+ index.verdict = "running";
690
+ }
691
+ index.current_stage = stage;
692
+ index.attempts = index.stage_runs.map((item) => ({ stage: item.stage, attempt_number: Number((item.attempt ?? "try-001").replace("try-", "")) || 1, root: item.dir, archive: null, status: item.status, started_at: item.started_at ?? now, finished_at: item.completed_at ?? null }));
693
+ index.updated_at = now;
694
+ index.execution = {
695
+ project_root: index.execution?.project_root ?? run.project_root,
696
+ workspace_root: index.execution?.workspace_root ?? run.workspace_root,
697
+ git
698
+ };
699
+ persistRunState(context, project, latest, index);
700
+ context.db.afterCommit(() => refreshRunSessionProjection(context, project.id, run.id));
701
+ appendAudit(context, {
702
+ projectId: project.id,
703
+ eventType: "flow_run.stage_completed",
704
+ payload: { run_id: run.id, stage, status, stage_report: input.stageReport ?? null, data: input.data ?? null }
705
+ });
706
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "stage_completed", run_id: run.id, stage, status, attempt: stageRun.attempt ?? null });
707
+ return { index, stageRun };
708
+ }, "runs.completeFlowRunStage");
562
709
  const updatedRun = requireRunById(context, project.id, run.id);
563
- return { ok: true, run: flowRunSummary(updatedRun), stage_run: stageRun, index, flow_guidance: guidanceForRun(context, updatedRun, index) };
710
+ return { ok: true, run: flowRunSummary(updatedRun), stage_run: completed.stageRun, index: completed.index, flow_guidance: guidanceForRun(context, updatedRun, completed.index) };
564
711
  }
565
712
  /** Pause one live stage without completing it or creating a new attempt. */
566
713
  export function pauseFlowRunStage(context, input) {
567
714
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
568
715
  const run = resolveRun(context, project.id, input.runId);
716
+ assertRunMutationAllowed(context, project.id, run.id);
569
717
  const index = authoritativeIndex(run);
570
718
  const stage = requiredPlain(input.stage, "stage");
571
719
  const stageRun = index.stage_runs.find((item) => item.stage === stage);
@@ -594,13 +742,14 @@ export function pauseFlowRunStage(context, input) {
594
742
  persistRunState(context, project, run, index);
595
743
  refreshRunSessionProjection(context, project.id, run.id);
596
744
  appendAudit(context, { projectId: project.id, eventType: "flow_run.stage_paused", payload: { run_id: run.id, stage, work_id: input.workId, pause_id: input.pauseId, reason: "waiting_for_user" } });
597
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_waiting_for_user", run_id: run.id, stage, work_id: input.workId, pause_id: input.pauseId, question_path: input.questionPath });
745
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "stage_waiting_for_user", run_id: run.id, stage, work_id: input.workId, pause_id: input.pauseId, question_path: input.questionPath });
598
746
  return { run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
599
747
  }
600
748
  /** Resume the same stage, Work and attempt after a user answer is recorded. */
601
749
  export function resumeFlowRunStage(context, input) {
602
750
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
603
751
  const run = resolveRun(context, project.id, input.runId);
752
+ assertRunMutationAllowed(context, project.id, run.id);
604
753
  const index = authoritativeIndex(run);
605
754
  const stage = requiredPlain(input.stage, "stage");
606
755
  const stageRun = index.stage_runs.find((item) => item.stage === stage);
@@ -624,19 +773,28 @@ export function resumeFlowRunStage(context, input) {
624
773
  persistRunState(context, project, run, index);
625
774
  refreshRunSessionProjection(context, project.id, run.id);
626
775
  appendAudit(context, { projectId: project.id, eventType: "flow_run.stage_resumed", payload: { run_id: run.id, stage, work_id: input.workId, pause_id: pause.id, answer_path: input.answerPath } });
627
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "user_answer_received", run_id: run.id, stage, work_id: input.workId, pause_id: pause.id, answer_path: input.answerPath });
628
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_resumed", run_id: run.id, stage, work_id: input.workId, pause_id: pause.id, paused_ms: pausedMs });
776
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "user_answer_received", run_id: run.id, stage, work_id: input.workId, pause_id: pause.id, answer_path: input.answerPath });
777
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "stage_resumed", run_id: run.id, stage, work_id: input.workId, pause_id: pause.id, paused_ms: pausedMs });
629
778
  return { run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
630
779
  }
631
780
  /** Pause the current stage for a non-user runtime blocker. */
632
- export function blockFlowRunStage(context, input) {
781
+ export function prepareFlowRunStageBlock(context, input) {
782
+ if (!["engine", "harness", "environment"].includes(input.kind) || typeof input.retryable !== "boolean")
783
+ throw new AppError("validation", "stage block requires a valid kind and boolean retryable", 2);
784
+ requiredPlain(input.code, "code");
785
+ requiredPlain(input.summary, "summary");
633
786
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
634
787
  const run = resolveRun(context, project.id, input.runId);
788
+ assertRunMutationAllowed(context, project.id, run.id);
635
789
  const index = authoritativeIndex(run);
636
790
  const stage = requiredPlain(input.stage, "stage");
637
791
  const stageRun = index.stage_runs.find((item) => item.stage === stage);
638
792
  if (!stageRun || stageRun.status !== "running")
639
793
  throw new AppError("invalid_stage_state", "Only a running stage can be blocked", 1, { run_id: run.id, stage, status: stageRun?.status ?? "missing" });
794
+ return { project, run, index, stage, stageRun };
795
+ }
796
+ export function blockFlowRunStage(context, input, prepared = prepareFlowRunStageBlock(context, input)) {
797
+ const { project, run, index, stage, stageRun } = prepared;
640
798
  const now = context.now();
641
799
  stageRun.status = "blocked";
642
800
  stageRun.updated_at = now;
@@ -650,20 +808,24 @@ export function blockFlowRunStage(context, input) {
650
808
  persistRunState(context, project, run, index);
651
809
  refreshRunSessionProjection(context, project.id, run.id);
652
810
  appendAudit(context, { projectId: project.id, eventType: "flow_run.stage_blocked", payload: { run_id: run.id, stage, work_id: input.workId, kind: input.kind, code: input.code, retryable: input.retryable } });
653
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_blocked", run_id: run.id, stage, work_id: input.workId, kind: input.kind, code: input.code, summary: input.summary, retryable: input.retryable });
811
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "stage_blocked", run_id: run.id, stage, work_id: input.workId, kind: input.kind, code: input.code, summary: input.summary, retryable: input.retryable });
654
812
  return { run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
655
813
  }
656
814
  /** Continue the same stage after its non-user blocker was repaired externally. */
657
- export function unblockFlowRunStage(context, input) {
815
+ export function prepareFlowRunStageUnblock(context, input) {
658
816
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
659
817
  const run = resolveRun(context, project.id, input.runId);
818
+ assertRunMutationAllowed(context, project.id, run.id);
660
819
  const index = authoritativeIndex(run);
661
820
  const stage = requiredPlain(input.stage, "stage");
662
821
  const stageRun = index.stage_runs.find((item) => item.stage === stage);
663
822
  if (!stageRun || stageRun.status !== "blocked" || !stageRun.blocker || stageRun.blocker.work_id !== input.workId)
664
823
  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" });
824
+ return { project, run, index, stage, stageRun, blocker: stageRun.blocker };
825
+ }
826
+ export function unblockFlowRunStage(context, input, prepared = prepareFlowRunStageUnblock(context, input)) {
827
+ const { project, run, index, stage, stageRun, blocker } = prepared;
665
828
  const now = context.now();
666
- const blocker = stageRun.blocker;
667
829
  const blockedMs = durationMs(blocker.blocked_at, now) ?? 0;
668
830
  stageRun.status = "running";
669
831
  stageRun.updated_at = now;
@@ -678,7 +840,7 @@ export function unblockFlowRunStage(context, input) {
678
840
  persistRunState(context, project, run, index);
679
841
  refreshRunSessionProjection(context, project.id, run.id);
680
842
  appendAudit(context, { projectId: project.id, eventType: "flow_run.stage_unblocked", payload: { run_id: run.id, stage, work_id: input.workId, code: blocker.code } });
681
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_unblocked", run_id: run.id, stage, work_id: input.workId, code: blocker.code, blocked_ms: blockedMs });
843
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "stage_unblocked", run_id: run.id, stage, work_id: input.workId, code: blocker.code, blocked_ms: blockedMs });
682
844
  return { run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
683
845
  }
684
846
  function operationalRunStatus(context, projectId, runId) {
@@ -687,11 +849,36 @@ function operationalRunStatus(context, projectId, runId) {
687
849
  const leaves = unfinished.filter((work) => !parents.has(work.work_id));
688
850
  return leaves.length > 0 && leaves.every((work) => work.status === "paused") ? "paused" : "running";
689
851
  }
690
- export function completeFlowRun(context, input) {
691
- const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
852
+ /** Keep the logical RUN projection aligned with recovery authority. Stage/Work
853
+ * evidence and verdicts survive a reversible stop; terminal runs never reopen. */
854
+ export function setFlowRunRecoveryPaused(context, projectId, runId, paused) {
855
+ const run = resolveRun(context, projectId, runId);
856
+ if (!["running", "paused"].includes(run.status))
857
+ return;
858
+ const index = authoritativeIndex(run);
859
+ const status = paused || index.stage_runs.some(stage => ["paused", "waiting_for_user", "blocked"].includes(stage.status))
860
+ ? "paused" : operationalRunStatus(context, projectId, runId);
861
+ if (run.status === status && index.status === status)
862
+ return;
863
+ index.status = status;
864
+ index.updated_at = context.now();
865
+ const project = requireProjectByRoot(context, run.project_root);
866
+ persistRunState(context, project, run, index);
867
+ }
868
+ export function prepareFlowRunCompletion(context, input) {
869
+ if (input.manualOverrideReason !== undefined) {
870
+ requiredPlain(input.manualOverrideReason, "override reason");
871
+ if (!["cancelled", "failed"].includes(input.status))
872
+ throw new AppError("validation", "Manual RUN override may only cancel or fail a RUN", 2);
873
+ }
874
+ if (input.verdict !== undefined)
875
+ requiredPlain(input.verdict, "verdict");
876
+ if (input.nextAction !== undefined)
877
+ requiredPlain(input.nextAction, "next action");
878
+ const project = requireProjectByRoot(context, input.manualOverrideReason ? canonicalPath(input.projectRoot, false) : resolveProjectRoot(input.projectRoot));
692
879
  const run = resolveRun(context, project.id, input.runId);
880
+ assertRunMutationAllowed(context, project.id, run.id);
693
881
  const index = authoritativeIndex(run);
694
- const now = context.now();
695
882
  const status = parseRunStatus(input.status);
696
883
  if (!['done', 'cancelled', 'failed'].includes(status)) {
697
884
  throw new AppError('validation', 'completeFlowRun accepts only done, cancelled, or failed; use stage block/unblock for a recoverable blocker', 2, { status });
@@ -701,6 +888,11 @@ export function completeFlowRun(context, input) {
701
888
  if (activeWork)
702
889
  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 });
703
890
  }
891
+ return { project, run, index, status };
892
+ }
893
+ export function completeFlowRun(context, input) {
894
+ const { project, run, index, status } = prepareFlowRunCompletion(context, input);
895
+ const now = context.now();
704
896
  if (input.manualOverrideReason && (status === "cancelled" || status === "failed")) {
705
897
  closeOpenStagesForOverride(index, status, now);
706
898
  closeOpenWorksForOverride(context, project.id, run.id, status, now);
@@ -734,7 +926,7 @@ export function completeFlowRun(context, input) {
734
926
  eventType: input.manualOverrideReason ? "flow_run.manual_override" : "flow_run.completed",
735
927
  payload: { run_id: run.id, status, verdict: index.verdict, next_action: index.next_action, ...(input.manualOverrideReason ? { reason: input.manualOverrideReason } : {}) }
736
928
  });
737
- appendRunTimeline(runArtifactRoot(run), { at: now, type: input.manualOverrideReason ? "run_manual_override" : "run_completed", run_id: run.id, status, ...(input.manualOverrideReason ? { reason: input.manualOverrideReason } : {}) });
929
+ appendRunTimeline(context, project.id, run.id, { at: now, type: input.manualOverrideReason ? "run_manual_override" : "run_completed", run_id: run.id, status, ...(input.manualOverrideReason ? { reason: input.manualOverrideReason } : {}) });
738
930
  const updatedRun = requireRunById(context, project.id, run.id);
739
931
  return { ok: true, run: flowRunSummary(updatedRun), index, usage, flow_guidance: guidanceForRun(context, updatedRun, index) };
740
932
  }
@@ -743,16 +935,85 @@ export function advanceFlowRun(context, input) {
743
935
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
744
936
  const run = resolveRun(context, project.id, input.runId);
745
937
  const index = authoritativeIndex(run);
938
+ if (input.settlementStage && input.status === "running" && index.current_stage === input.settlementStage && index.stage_runs.some(stage => stage.stage === input.settlementStage && ["done", "skipped"].includes(stage.status)))
939
+ assertStageSettlementAllowed(context, project.id, run.id, input.settlementStage);
940
+ else
941
+ assertRunMutationAllowed(context, project.id, run.id);
942
+ const control = runControlDirective(context, project.id, run.id);
746
943
  const now = context.now();
747
944
  index.status = input.status;
748
945
  index.verdict = input.verdict;
749
- index.next_action = input.nextAction ?? null;
946
+ index.next_action = control ? null : input.nextAction ?? null;
750
947
  index.updated_at = now;
751
948
  persistRunState(context, project, run, index);
752
- appendAudit(context, { projectId: project.id, eventType: "flow_run.progressed", payload: { run_id: run.id, status: input.status, verdict: input.verdict, next_action: index.next_action } });
753
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_progressed", run_id: run.id, status: input.status, next_action: index.next_action });
949
+ appendAudit(context, { projectId: project.id, eventType: "flow_run.progressed", payload: { run_id: run.id, status: index.status, verdict: input.verdict, next_action: index.next_action } });
950
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "run_progressed", run_id: run.id, status: index.status, next_action: index.next_action });
754
951
  const updatedRun = requireRunById(context, project.id, run.id);
755
- return { ok: true, run: flowRunSummary(updatedRun), index, flow_guidance: guidanceForRun(context, updatedRun, index) };
952
+ return { ok: true, ...(control ? { control } : {}), run: flowRunSummary(updatedRun), index, flow_guidance: guidanceForRun(context, updatedRun, index) };
953
+ }
954
+ /**
955
+ * The only legal backward edge in vNext: an integration gate exposed a
956
+ * product defect, so the source must be repaired and reviewed again. Normal
957
+ * stage start remains fail-closed for completed stages.
958
+ */
959
+ export function prepareVnextMergeSourceRepairAttempt(context, input) {
960
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
961
+ const run = resolveRun(context, project.id, input.runId);
962
+ assertRunMutationAllowed(context, project.id, run.id);
963
+ const index = authoritativeIndex(run);
964
+ const priorRepair = index.variables?.["merge.source_repair"];
965
+ if (priorRepair?.merge_request_id === input.mergeRequestId && priorRepair.cycle)
966
+ return { run_id: run.id, cycle: priorRepair.cycle };
967
+ const code = index.stage_runs.find((item) => item.stage === "code");
968
+ const review = index.stage_runs.find((item) => item.stage === "code-review");
969
+ const merge = index.stage_runs.find((item) => item.stage === "merge");
970
+ if (!code || code.status !== "done" || !review || review.status !== "done" || !merge || merge.status !== "running") {
971
+ throw new AppError("invalid_merge_repair_state", "Source repair requires accepted CODE/CODE-REVIEW and a running MERGE attempt", 2, {
972
+ run_id: run.id,
973
+ code: code?.status ?? null,
974
+ code_review: review?.status ?? null,
975
+ merge: merge?.status ?? null
976
+ });
977
+ }
978
+ const root = runArtifactRoot(run);
979
+ for (const stage of ["code", "code-review", "merge"])
980
+ archiveExistingStageAttempt(context, project.id, run.id, root, vnextStageDirectory(stage));
981
+ const now = context.now();
982
+ const restarted = {
983
+ ...code,
984
+ status: "running",
985
+ started_at: now,
986
+ updated_at: now,
987
+ attempt: nextAttempt(code)
988
+ };
989
+ delete restarted.completed_at;
990
+ delete restarted.duration_ms;
991
+ delete restarted.stage_report;
992
+ delete restarted.data;
993
+ delete restarted.report;
994
+ delete restarted.artifact_aliases;
995
+ const cycle = `MRR-${input.mergeRequestId}`;
996
+ index.stage_runs = index.stage_runs.filter((item) => item.stage !== "code" && item.stage !== "code-review" && item.stage !== "merge");
997
+ index.stage_runs.push(restarted);
998
+ index.variables = { ...(index.variables ?? {}), "merge.source_repair": { cycle, merge_request_id: input.mergeRequestId, prepared_at: now } };
999
+ index.status = "running";
1000
+ index.verdict = "merge_source_repair";
1001
+ index.next_action = "start_code_repair";
1002
+ index.current_stage = "code";
1003
+ index.updated_at = now;
1004
+ index.attempts = index.stage_runs.map((item) => ({
1005
+ stage: item.stage,
1006
+ attempt_number: Number((item.attempt ?? "try-001").replace("try-", "")) || 1,
1007
+ root: item.dir,
1008
+ archive: null,
1009
+ status: item.status,
1010
+ started_at: item.started_at ?? now,
1011
+ finished_at: item.completed_at ?? null
1012
+ }));
1013
+ persistRunState(context, project, run, index);
1014
+ appendAudit(context, { projectId: project.id, eventType: "flow_run.merge_source_repair_prepared", payload: { run_id: run.id, merge_request_id: input.mergeRequestId, cycle } });
1015
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "merge_source_repair_prepared", run_id: run.id, merge_request_id: input.mergeRequestId, cycle });
1016
+ return { run_id: run.id, cycle };
756
1017
  }
757
1018
  function closeOpenStagesForOverride(index, status, now) {
758
1019
  const stageStatus = status === "cancelled" ? "skipped" : "failed";
@@ -800,7 +1061,7 @@ function closeRunOrchestrators(context, projectId, run, status, now) {
800
1061
  context.db.run(`UPDATE flow_session_segments SET ended_at = ?
801
1062
  WHERE project_id = ? AND run_id = ? AND session_id IN (${active.map(() => "?").join(", ")}) AND ended_at IS NULL`, [now, projectId, run.id, ...active.map((session) => session.session_id)]);
802
1063
  for (const session of active) {
803
- appendRunTimeline(runArtifactRoot(run), {
1064
+ appendRunTimeline(context, projectId, run.id, {
804
1065
  at: now,
805
1066
  type: "session_stopped",
806
1067
  run_id: run.id,
@@ -923,7 +1184,7 @@ export function getFlowRunSessions(context, input) {
923
1184
  }
924
1185
  export function appendFlowRunTimelineEvent(context, projectId, runId, event) {
925
1186
  const run = requireRunById(context, projectId, runId);
926
- appendRunTimeline(runArtifactRoot(run), { run_id: run.id, ...event });
1187
+ appendRunTimeline(context, projectId, run.id, { run_id: run.id, ...event });
927
1188
  }
928
1189
  function guidanceForRun(context, run, index) {
929
1190
  if (run.flow_kind === "vnext_specify")
@@ -1018,32 +1279,31 @@ function vnextSpecifyGuidance(run, index) {
1018
1279
  };
1019
1280
  }
1020
1281
  function persistRunState(context, project, run, index, options = {}) {
1282
+ // Fenced productive admission is a logical pause, even while physical work
1283
+ // drains. Late admitted Stage results must not revive a stopped RUN.
1284
+ if (index.status === "running" && ["draining", "sealed", "resuming"].includes(recoveryGuard(context, project.id, run.id)?.status ?? ""))
1285
+ index.status = "paused";
1021
1286
  const runtimePath = run.runtime_path || projectRunJsonPath(context.ddFlowHome, project.id, run.id);
1022
- const previousRuntime = readRuntimeSnapshot(runtimePath);
1023
- const runtimeRevision = Math.max(index.runtime_revision ?? 0, previousRuntime?.runtime_revision ?? 0) + 1;
1024
- index.runtime_revision = runtimeRevision;
1025
- const history = [...(previousRuntime?.flag_revision_history ?? [])];
1026
- if (options.flagRevision) {
1027
- history.push(options.flagRevision);
1028
- }
1029
- const persistedIndex = persistedVnextIndex(index);
1030
- ensureDir(path.dirname(runtimePath));
1031
- writeJsonFile(runtimePath, runtimeSnapshotForIndex(persistedIndex, runtimeRevision, history));
1032
- context.db.run(`UPDATE runs
1033
- SET status = ?, verdict = ?, next_action = ?, index_json = ?, runtime_path = ?, run_index_path = ?,
1034
- updated_at = ?, completed_at = ?
1035
- WHERE project_id = ? AND id = ?`, [
1036
- index.status,
1037
- index.verdict,
1038
- index.next_action,
1039
- JSON.stringify(persistedIndex),
1040
- runtimePath,
1041
- runtimePath,
1042
- index.updated_at,
1043
- index.completed_at ?? null,
1044
- project.id,
1045
- run.id
1046
- ]);
1287
+ context.db.writeTransaction(() => {
1288
+ const stored = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [project.id, run.id]);
1289
+ if (!stored)
1290
+ throw new AppError("not_found", "RUN is not registered", 1, { run_id: run.id });
1291
+ const previous = JSON.parse(stored.index_json);
1292
+ const actualRevision = previous.runtime_revision ?? 0;
1293
+ const expectedRevision = index.runtime_revision ?? 0;
1294
+ if (actualRevision !== expectedRevision)
1295
+ throw new AppError("run_revision_changed", "RUN changed during preparation", 1, { run_id: run.id, expected_revision: expectedRevision, actual_revision: actualRevision });
1296
+ index.runtime_revision = actualRevision + 1;
1297
+ const history = [...(previous.flag_revision_history ?? [])];
1298
+ if (options.flagRevision)
1299
+ history.push(options.flagRevision);
1300
+ index.flag_revision_history = history.slice(-32);
1301
+ const updated = context.db.run(`UPDATE runs SET status = ?, verdict = ?, next_action = ?, index_json = ?, runtime_path = ?, run_index_path = ?, updated_at = ?, completed_at = ?
1302
+ WHERE project_id = ? AND id = ? AND index_json = ?`, [index.status, index.verdict, index.next_action, JSON.stringify(persistedVnextIndex(index)), runtimePath, runtimePath, index.updated_at, index.completed_at ?? null, project.id, run.id, stored.index_json]);
1303
+ if (updated.changes !== 1)
1304
+ throw new AppError("run_revision_changed", "RUN changed during preparation", 1, { run_id: run.id });
1305
+ context.db.afterCommit(() => publishRunProjections(context, project.id, run.id));
1306
+ }, "runs.persistRunState");
1047
1307
  }
1048
1308
  /** vNext stores derived stage/work state only once: stage_runs, Works and Sessions. */
1049
1309
  function persistedVnextIndex(index) {
@@ -1085,35 +1345,64 @@ function materializedRunVariables(index) {
1085
1345
  ...(index.variables ?? {})
1086
1346
  };
1087
1347
  }
1088
- function setRunVariable(context, input, value) {
1348
+ function prepareRunVariable(context, input) {
1089
1349
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
1090
1350
  const run = resolveRun(context, project.id, input.runId);
1351
+ assertRunMutationAllowed(context, project.id, run.id);
1091
1352
  const index = authoritativeIndex(run);
1353
+ return { project, run, index };
1354
+ }
1355
+ function setRunVariable(context, input, value, prepared = prepareRunVariable(context, input)) {
1356
+ const { project, run, index } = prepared;
1092
1357
  if (index.variables?.[input.key] === value)
1093
1358
  return { ok: true, run_id: run.id, key: input.key, value, idempotent: true };
1094
1359
  index.variables = { ...(index.variables ?? {}), [input.key]: value };
1095
1360
  index.updated_at = context.now();
1096
1361
  persistRunState(context, project, run, index);
1097
- appendRunTimeline(runArtifactRoot(run), { at: index.updated_at, type: "run_variable_set", run_id: run.id, key: input.key });
1362
+ appendRunTimeline(context, project.id, run.id, { at: index.updated_at, type: "run_variable_set", run_id: run.id, key: input.key });
1098
1363
  return { ok: true, run_id: run.id, key: input.key, value, idempotent: false };
1099
1364
  }
1100
- function appendRunTimeline(runHome, event) {
1101
- ensureDir(runHome);
1102
- const timelinePath = path.join(runHome, "timeline.jsonl");
1103
- let sequence = 0;
1104
- if (fs.existsSync(timelinePath)) {
1105
- const lines = fs.readFileSync(timelinePath, "utf8").trim().split(/\r?\n/).filter(Boolean);
1106
- const last = lines.length > 0 ? JSON.parse(lines[lines.length - 1]) : undefined;
1107
- sequence = typeof last?.sequence === "number" ? last.sequence : lines.length;
1108
- }
1109
- const safeEvent = sanitizeTimelineValue(event);
1110
- const enriched = {
1111
- event_id: crypto.randomUUID(),
1112
- sequence: sequence + 1,
1113
- at: typeof safeEvent.at === "string" ? safeEvent.at : new Date().toISOString(),
1114
- ...safeEvent
1115
- };
1116
- fs.appendFileSync(timelinePath, `${JSON.stringify(enriched)}\n`);
1365
+ function appendRunTimeline(context, projectId, runId, event) {
1366
+ context.db.writeTransaction(() => {
1367
+ const last = context.db.get("SELECT payload_json FROM audit_events WHERE project_id = ? AND event_type = 'flow_run.timeline' AND json_extract(payload_json, '$.run_id') = ? ORDER BY id DESC LIMIT 1", [projectId, runId]);
1368
+ const sequence = last ? Number(JSON.parse(last.payload_json).sequence) + 1 : 1;
1369
+ const safeEvent = sanitizeTimelineValue(event);
1370
+ const enriched = { event_id: crypto.randomUUID(), sequence, at: typeof safeEvent.at === "string" ? safeEvent.at : context.now(), ...safeEvent };
1371
+ appendAudit(context, { projectId, eventType: "flow_run.timeline", payload: { run_id: runId, sequence, event: enriched } });
1372
+ context.db.afterCommit(() => publishRunProjections(context, projectId, runId));
1373
+ }, "runs.appendRunTimeline");
1374
+ }
1375
+ /** Rebuild projections from committed SQL after taking a per-RUN filesystem lock. */
1376
+ export function publishRunProjections(context, projectId, runId) {
1377
+ const run = context.db.get("SELECT * FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
1378
+ if (!run)
1379
+ throw new AppError("not_found", "RUN is not registered", 1, { run_id: runId });
1380
+ const runtimePath = run.runtime_path || projectRunJsonPath(context.ddFlowHome, projectId, runId);
1381
+ ensureDir(path.dirname(runtimePath));
1382
+ withProjectionLock(`${runtimePath}.publish-lock`, () => {
1383
+ // A delayed publisher must observe the latest commit after acquiring its lock.
1384
+ const latest = context.db.get("SELECT * FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
1385
+ if (!latest)
1386
+ throw new AppError("not_found", "RUN is not registered", 1, { run_id: runId });
1387
+ const index = authoritativeIndex(latest);
1388
+ const mutations = context.db.all("SELECT resulting_revision, result_json, created_at, idempotency_key FROM flow_run_flag_mutations WHERE project_id = ? AND run_id = ? ORDER BY resulting_revision DESC LIMIT 32", [projectId, runId]);
1389
+ const history = mutations.reverse().map((row) => ({
1390
+ revision: row.resulting_revision,
1391
+ checksum: String(JSON.parse(row.result_json).snapshot_checksum ?? ""),
1392
+ at: row.created_at,
1393
+ idempotency_key: row.idempotency_key
1394
+ }));
1395
+ writeJsonFile(runtimePath, runtimeSnapshotForIndex(index, index.runtime_revision ?? 1, history.length ? history : index.flag_revision_history ?? []));
1396
+ const rows = context.db.all("SELECT payload_json FROM audit_events WHERE project_id = ? AND event_type = 'flow_run.timeline' AND json_extract(payload_json, '$.run_id') = ? ORDER BY id", [projectId, runId]);
1397
+ if (latest.run_root) {
1398
+ const timelinePath = path.join(latest.run_root, "timeline.jsonl");
1399
+ ensureDir(path.dirname(timelinePath));
1400
+ const bytes = rows.map((row) => JSON.stringify(JSON.parse(row.payload_json).event)).join("\n");
1401
+ const temporary = `${timelinePath}.tmp-${process.pid}-${crypto.randomUUID()}`;
1402
+ fs.writeFileSync(temporary, bytes ? `${bytes}\n` : "");
1403
+ fs.renameSync(temporary, timelinePath);
1404
+ }
1405
+ });
1117
1406
  }
1118
1407
  function runtimeSnapshotForIndex(index, runtimeRevision, flagRevisionHistory = []) {
1119
1408
  const rest = { ...index };
@@ -1139,10 +1428,12 @@ function readRuntimeSnapshot(file) {
1139
1428
  function authoritativeIndex(run) {
1140
1429
  try {
1141
1430
  const state = JSON.parse(run.index_json);
1431
+ if (!state || typeof state !== "object" || Array.isArray(state) || !Array.isArray(state.stage_runs))
1432
+ throw new Error("RUN index must contain stage_runs");
1142
1433
  return { ...state, schema_id: runSchemaId };
1143
1434
  }
1144
1435
  catch (error) {
1145
- throw new AppError("validation", "RUN mechanical state in SQLite is missing or invalid", 2, { run_id: run.id, cause: String(error) });
1436
+ throw new AppError("runtime_state_invalid", "RUN mechanical state in SQLite is missing or invalid", 1, { run_id: run.id, cause: String(error) });
1146
1437
  }
1147
1438
  }
1148
1439
  function sanitizeTimelineValue(value, key) {
@@ -1203,7 +1494,7 @@ function upsertStage(index, stageRun) {
1203
1494
  }
1204
1495
  index.stage_runs.sort((a, b) => a.order - b.order);
1205
1496
  }
1206
- function resolveRun(context, projectId, idOrAlias) {
1497
+ export function resolveRun(context, projectId, idOrAlias) {
1207
1498
  if (isFullEntityId(idOrAlias)) {
1208
1499
  return requireRunById(context, projectId, idOrAlias);
1209
1500
  }
@@ -1297,7 +1588,7 @@ function parseStageStatus(value) {
1297
1588
  }
1298
1589
  return value;
1299
1590
  }
1300
- function requiredStageDir(value) {
1591
+ export function prepareStageDirectory(value) {
1301
1592
  const dir = requiredPlain(value, "dir");
1302
1593
  if (!/^\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*$/.test(dir)) {
1303
1594
  throw new AppError("validation", "--dir must use NN-stage-slug format", 2, { dir });
@@ -1305,6 +1596,8 @@ function requiredStageDir(value) {
1305
1596
  return dir;
1306
1597
  }
1307
1598
  function requiredPlain(value, label) {
1599
+ if (typeof value !== "string")
1600
+ throw new AppError("validation", `${label} must be a string`, 2);
1308
1601
  const trimmed = value.trim();
1309
1602
  if (!trimmed) {
1310
1603
  throw new AppError("validation", `${label} must not be empty`, 2);
@@ -1312,6 +1605,8 @@ function requiredPlain(value, label) {
1312
1605
  return trimmed;
1313
1606
  }
1314
1607
  function normalizeSlug(value) {
1608
+ if (typeof value !== "string")
1609
+ throw new AppError("validation", "--slug must be a string", 2);
1315
1610
  const slug = value
1316
1611
  .normalize("NFKD")
1317
1612
  .replace(/[\u0300-\u036f]/g, "")
@@ -1329,26 +1624,84 @@ function resolveWorkspaceRoot(workspaceRoot) {
1329
1624
  if (!fs.existsSync(absolute)) {
1330
1625
  throw new AppError("not_found", `Workspace root does not exist: ${absolute}`, 1);
1331
1626
  }
1627
+ if (!fs.statSync(absolute).isDirectory())
1628
+ throw new AppError("validation", "Workspace root must be a directory", 2, { workspace_root: absolute });
1332
1629
  return fs.realpathSync(absolute);
1333
1630
  }
1334
- function archiveExistingStageAttempt(runRoot, stageDir) {
1631
+ /** Move one completed stage attempt and update only known durable path columns.
1632
+ * Receipt bytes remain immutable; readers resolve their new location through the DB. */
1633
+ export function archiveExistingStageAttempt(context, projectId, runId, runRoot, stageDir) {
1335
1634
  const currentStageDir = path.join(runRoot, stageDir);
1336
1635
  if (!fs.existsSync(currentStageDir) || !fs.statSync(currentStageDir).isDirectory()) {
1337
1636
  return;
1338
1637
  }
1339
- const entries = fs.readdirSync(currentStageDir).filter((entry) => !/^try-\d{3}$/.test(entry));
1340
- if (entries.length === 0) {
1638
+ const journal = path.join(runRoot, `.archive-${stageDir}.json`);
1639
+ const pending = fs.existsSync(journal) ? JSON.parse(fs.readFileSync(journal, "utf8")) : null;
1640
+ const row = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
1641
+ const prior = row ? JSON.parse(row.index_json) : null;
1642
+ const live = prior?.stage_runs.find((stage) => stage.dir === stageDir);
1643
+ const generation = row ? assertRunMutationAllowed(context, projectId, runId) : 0;
1644
+ if (pending && (pending.project_id && pending.project_id !== projectId || pending.run_id && pending.run_id !== runId))
1645
+ throw new AppError("archive_owner_changed", "Archive journal belongs to another RUN", 1, { journal });
1646
+ if (pending?.generation !== undefined && pending.generation !== generation)
1647
+ throw new AppError("archive_owner_changed", "RUN generation changed while an archive was pending", 1, { journal, expected_generation: pending.generation, generation });
1648
+ if (pending?.prior_attempt && live?.attempt !== pending.prior_attempt && live?.attempt !== pending.next_attempt)
1649
+ throw new AppError("archive_owner_changed", "Stage attempt changed while an archive was pending", 1, { journal, prior_attempt: pending.prior_attempt, actual_attempt: live?.attempt ?? null });
1650
+ const entries = pending?.entries ?? fs.readdirSync(currentStageDir).filter((entry) => !/^try-\d{3}$/.test(entry));
1651
+ if (entries.length === 0 && !pending) {
1341
1652
  return;
1342
1653
  }
1343
1654
  let attempt = 1;
1344
1655
  while (fs.existsSync(path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`))) {
1345
1656
  attempt += 1;
1346
1657
  }
1347
- const archiveDir = path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`);
1658
+ const archiveDir = pending?.archiveDir ?? path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`);
1659
+ const intent = pending ?? { archiveDir, entries, stage_run: live ?? null, project_id: projectId, run_id: runId, prior_attempt: live?.attempt ?? null, next_attempt: nextAttempt(live), runtime_revision: prior?.runtime_revision ?? 0, generation };
1660
+ if (!pending) {
1661
+ writeJsonFile(journal, intent);
1662
+ }
1348
1663
  ensureDir(archiveDir);
1349
1664
  for (const entry of entries) {
1350
- fs.renameSync(path.join(currentStageDir, entry), path.join(archiveDir, entry));
1665
+ const source = path.join(currentStageDir, entry);
1666
+ const target = path.join(archiveDir, entry);
1667
+ if (fs.existsSync(source)) {
1668
+ if (fs.existsSync(target))
1669
+ throw new AppError("archive_conflict", "Both live and archived artifact exist; preserve both for recovery", 2, { source, target });
1670
+ fs.renameSync(source, target);
1671
+ }
1672
+ else if (!fs.existsSync(target))
1673
+ throw new AppError("archive_artifact_missing", "Artifact is absent from both archive locations", 2, { source, target });
1351
1674
  }
1675
+ context.db.writeTransaction(() => {
1676
+ const current = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
1677
+ const currentIndex = current ? JSON.parse(current.index_json) : null;
1678
+ const currentAttempt = currentIndex?.stage_runs.find((stage) => stage.dir === stageDir)?.attempt;
1679
+ if (currentIndex && intent.runtime_revision !== undefined && (currentIndex.runtime_revision ?? 0) !== intent.runtime_revision && currentAttempt !== intent.next_attempt)
1680
+ throw new AppError("archive_owner_changed", "RUN revision changed while an archive was pending", 1, { journal });
1681
+ if (intent.prior_attempt && currentAttempt !== intent.prior_attempt && currentAttempt !== intent.next_attempt)
1682
+ throw new AppError("archive_owner_changed", "Stage attempt changed while an archive was pending", 1, { journal });
1683
+ if (row && generation !== assertRunMutationAllowed(context, projectId, runId))
1684
+ throw new AppError("archive_owner_changed", "RUN generation changed while an archive was pending", 1, { journal });
1685
+ if (intent.next_attempt === undefined || currentAttempt !== intent.next_attempt) {
1686
+ const oldPrefix = `${currentStageDir}${path.sep}`;
1687
+ const newPrefix = `${archiveDir}${path.sep}`;
1688
+ const rewrite = (table, column, where, values) => {
1689
+ context.db.run(`UPDATE ${table} SET ${column} = ? || substr(${column}, ?) WHERE ${column} LIKE ? AND substr(${column}, ?, 4) != 'try-' AND ${where}`, [newPrefix, oldPrefix.length + 1, `${oldPrefix}%`, oldPrefix.length + 1, ...values]);
1690
+ };
1691
+ const workScope = "work_id IN (SELECT work_id FROM works WHERE project_id = ? AND run_id = ?)";
1692
+ rewrite("work_sessions", "prompt_path", workScope, [projectId, runId]);
1693
+ rewrite("work_sessions", "result_path", workScope, [projectId, runId]);
1694
+ const receiptScope = "project_id = ? AND run_id = ?";
1695
+ for (const column of ["stdout_path", "stderr_path", "receipt_path"])
1696
+ rewrite("check_receipts", column, receiptScope, [projectId, runId]);
1697
+ }
1698
+ context.db.afterCommit(() => {
1699
+ if (!fs.existsSync(journal))
1700
+ return;
1701
+ // Retain original Stage facts with the immutable attempt after SQL paths commit.
1702
+ fs.renameSync(journal, path.join(archiveDir, "archive.json"));
1703
+ });
1704
+ });
1352
1705
  }
1353
1706
  function writeJsonFile(file, value) {
1354
1707
  const tmpFile = `${file}.tmp-${process.pid}-${Date.now()}`;
@@ -1373,3 +1726,5 @@ export function gitFacts(workspaceRoot) {
1373
1726
  status: status === null ? "unavailable" : status.length > 0 ? "dirty" : "clean"
1374
1727
  };
1375
1728
  }
1729
+ import { assertRunMutationAllowed, assertStageSettlementAllowed, recoveryGuard } from "./run-recovery.js";
1730
+ import { runControlDirective } from "./run-control.js";