@deksden-com/dd-flow-cli 0.7.0 → 0.8.0

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 (69) hide show
  1. package/CHANGELOG.md +666 -0
  2. package/README.md +7 -2
  3. package/dist/build-info.json +5 -5
  4. package/dist/cli/help.js +88 -10
  5. package/dist/cli/run-cli.js +523 -28
  6. package/dist/domain/stage-catalog.js +22 -0
  7. package/dist/domain/validation.js +1 -1
  8. package/dist/schemas/code-review-decision.schema.json +26 -0
  9. package/dist/schemas/code-review-result.schema.json +14 -0
  10. package/dist/schemas/code-verification.schema.json +14 -0
  11. package/dist/schemas/code-work-batch.schema.json +24 -0
  12. package/dist/schemas/code-work-result.schema.json +16 -0
  13. package/dist/schemas/compatibility.schema.json +32 -0
  14. package/dist/schemas/flow-contract.schema.json +9 -5
  15. package/dist/schemas/flow-run.schema.json +16 -123
  16. package/dist/schemas/plan-aspect-map.schema.json +22 -0
  17. package/dist/schemas/plan-review-decision.schema.json +14 -0
  18. package/dist/schemas/plan-review-result.schema.json +42 -0
  19. package/dist/schemas/protocol-plan.schema.json +15 -182
  20. package/dist/schemas/stage-finish-input.schema.json +16 -2
  21. package/dist/schemas/stage-report.schema.json +8 -7
  22. package/dist/schemas/stage-start-response.schema.json +4 -2
  23. package/dist/schemas/status-report.schema.json +76 -0
  24. package/dist/schemas/vnext-protocol-plan.schema.json +37 -0
  25. package/dist/schemas/vnext-protocolize-result.schema.json +29 -0
  26. package/dist/schemas/vnext-specify.schema.json +45 -0
  27. package/dist/services/branch-context.js +1 -1
  28. package/dist/services/cleanup.js +8 -8
  29. package/dist/services/cli-operation-classifier.js +10 -2
  30. package/dist/services/code-checks.js +244 -0
  31. package/dist/services/config.js +7 -1
  32. package/dist/services/dashboard.js +12 -12
  33. package/dist/services/engines.js +1 -1
  34. package/dist/services/eval-snapshots.js +404 -0
  35. package/dist/services/hooks.js +774 -18
  36. package/dist/services/ids.js +16 -6
  37. package/dist/services/lanes.js +1 -1
  38. package/dist/services/merge-queue.js +5 -5
  39. package/dist/services/merge-worker.js +2 -2
  40. package/dist/services/migrations.js +2 -2
  41. package/dist/services/plan-runtime.js +1 -1
  42. package/dist/services/projects.js +4 -4
  43. package/dist/services/prompts.js +17 -11
  44. package/dist/services/protocols.js +8 -8
  45. package/dist/services/run-projection.js +49 -13
  46. package/dist/services/runs.js +504 -51
  47. package/dist/services/schema-validation.js +21 -3
  48. package/dist/services/sessions.js +51 -12
  49. package/dist/services/stage-blocker.js +57 -0
  50. package/dist/services/stage-context.js +90 -0
  51. package/dist/services/stage-lifecycle.js +198 -75
  52. package/dist/services/stage-pause.js +175 -0
  53. package/dist/services/stage-report-renderer.js +65 -0
  54. package/dist/services/usage.js +526 -18
  55. package/dist/services/vnext-code-review.js +305 -0
  56. package/dist/services/vnext-code.js +686 -0
  57. package/dist/services/vnext-contracts.js +1 -0
  58. package/dist/services/vnext-execution-profile.js +27 -0
  59. package/dist/services/vnext-fanout.js +79 -0
  60. package/dist/services/vnext-plan-review.js +499 -0
  61. package/dist/services/vnext-plan.js +552 -0
  62. package/dist/services/vnext-protocolize.js +542 -0
  63. package/dist/services/vnext-specify.js +595 -0
  64. package/dist/services/vnext-workspace-policy.js +87 -0
  65. package/dist/services/work-registry.js +522 -0
  66. package/dist/services/worktrees.js +58 -37
  67. package/dist/storage/database.js +263 -34
  68. package/dist/storage/paths.js +47 -1
  69. package/package.json +12 -12
@@ -4,8 +4,9 @@ import { formatFullId, parseFullEntityId } from "../domain/entity-ids.js";
4
4
  import { AppError } from "../shared/errors.js";
5
5
  import { resolveProjectRoot } from "../storage/paths.js";
6
6
  const kindConfig = {
7
- protocol: { type: "PRT", table: "protocols", fileRoot: ".memory-bank/protocol" },
8
- run: { type: "RUN", table: "flow_runs" }
7
+ protocol: { type: "PRT", table: "protocols", idField: "id", fileRoot: ".memory-bank/protocol" },
8
+ run: { type: "RUN", table: "runs", idField: "id" },
9
+ work: { type: "WRK", table: "works", idField: "work_id" }
9
10
  };
10
11
  export function previewNextEntityId(context, input) {
11
12
  const projectRoot = resolveProjectRoot(input.projectRoot);
@@ -14,7 +15,7 @@ export function previewNextEntityId(context, input) {
14
15
  const config = kindConfig[kind];
15
16
  const used = new Set();
16
17
  const project = context.db.get("SELECT id FROM projects WHERE root = ?", [projectRoot]);
17
- for (const id of databaseIds(context, config.table, config.type, project?.id)) {
18
+ for (const id of databaseIds(context, config.table, config.idField, config.type, project?.id)) {
18
19
  addSequence(used, id, config.type);
19
20
  }
20
21
  if (config.fileRoot) {
@@ -39,19 +40,28 @@ export function previewNextEntityId(context, input) {
39
40
  }
40
41
  };
41
42
  }
43
+ /** Work ids are globally allocated because work_id is the database primary key. */
44
+ export function nextWorkId(context, _projectId, slug) {
45
+ const used = new Set();
46
+ for (const row of context.db.all("SELECT work_id AS id FROM works WHERE work_id LIKE ?", ["WRK-%"]))
47
+ addSequence(used, row.id, "WRK");
48
+ return formatFullId("WRK", Math.max(0, ...used) + 1, normalizeSlug(slug));
49
+ }
42
50
  function parseEntityKind(value) {
43
51
  const normalized = value.toLowerCase();
44
52
  if (normalized === "protocol" || normalized === "prt")
45
53
  return "protocol";
46
54
  if (normalized === "run")
47
55
  return "run";
48
- throw new AppError("validation", "--type must be protocol or run", 2, { type: value });
56
+ if (normalized === "work" || normalized === "wrk")
57
+ return "work";
58
+ throw new AppError("validation", "--type must be protocol, run, or work", 2, { type: value });
49
59
  }
50
- function databaseIds(context, table, type, projectId) {
60
+ function databaseIds(context, table, idField, type, projectId) {
51
61
  if (!projectId)
52
62
  return [];
53
63
  return context.db
54
- .all(`SELECT id FROM ${table} WHERE project_id = ? AND id LIKE ?`, [projectId, `${type}-%`])
64
+ .all(`SELECT ${idField} AS id FROM ${table} WHERE project_id = ? AND ${idField} LIKE ?`, [projectId, `${type}-%`])
55
65
  .map((row) => row.id);
56
66
  }
57
67
  function filesystemIds(projectRoot, relativeRoot, type) {
@@ -459,7 +459,7 @@ function assertWorkerMayAcquireLaneLock(context, projectId, lane, workerId) {
459
459
  if (lane !== "merge") {
460
460
  return;
461
461
  }
462
- const latest = context.db.get(`SELECT status, stop_reason, flow_kind FROM flow_sessions
462
+ const latest = context.db.get(`SELECT status, stop_reason, flow_kind FROM sessions
463
463
  WHERE project_id = ? AND worker_id = ? AND flow_kind IN ('merge_worker', 'merge_job')
464
464
  ORDER BY updated_at DESC, rowid DESC LIMIT 1`, [projectId, workerId]);
465
465
  if (latest && ["stopped", "stopping"].includes(latest.status)) {
@@ -1,4 +1,4 @@
1
- import { loadProjectFlowContract } from "../domain/flow-contract.js";
1
+ import { flowContractForState } from "../domain/flow-contract.js";
2
2
  import { AppError } from "../shared/errors.js";
3
3
  import { appendAudit } from "./audit.js";
4
4
  import { requireProjectByRoot } from "./projects.js";
@@ -340,7 +340,7 @@ export function completeMergeJob(context, input) {
340
340
  SET status = 'merged', last_reason = ?, completed_at = ?, updated_at = ?
341
341
  WHERE id = ?`, [input.summary, now, now, job.id]);
342
342
  const state = readProtocolRuntimeState(context, protocol).state;
343
- const flowContract = loadProjectFlowContract(protocol.project_root);
343
+ const flowContract = flowContractForState(state);
344
344
  const targetStage = flowContract.merge_queue.complete.target_stage;
345
345
  const targetStatus = flowContract.stages[targetStage]?.terminal ? targetStage : "running";
346
346
  persistProtocolState(context, protocol, {
@@ -390,7 +390,7 @@ export function completeMergeBundle(context, input) {
390
390
  SET status = 'merged', last_reason = ?, completed_at = ?, updated_at = ?
391
391
  WHERE id = ?`, [input.summary, now, now, job.id]);
392
392
  const state = readProtocolRuntimeState(context, protocol).state;
393
- const flowContract = loadProjectFlowContract(protocol.project_root);
393
+ const flowContract = flowContractForState(state);
394
394
  const targetStage = flowContract.merge_queue.complete.target_stage;
395
395
  const targetStatus = flowContract.stages[targetStage]?.terminal ? targetStage : "running";
396
396
  persistProtocolState(context, protocol, {
@@ -691,7 +691,7 @@ function requireClaimedJob(context, projectId, protocolId, sessionId) {
691
691
  function transitionClaimedProtocolToIntegration(context, projectId, protocolId, workerId, now) {
692
692
  const protocol = requireProtocol(context, protocolId, projectId);
693
693
  const state = readProtocolRuntimeState(context, protocol).state;
694
- const flowContract = loadProjectFlowContract(protocol.project_root);
694
+ const flowContract = flowContractForState(state);
695
695
  if (!["ready_for_merge", "queued_for_merge"].includes(state.stage)) {
696
696
  throw new AppError("merge_protocol_not_ready", "Cannot claim a protocol whose runtime state is not ready for merge", 1, {
697
697
  protocol_id: protocolId,
@@ -733,7 +733,7 @@ function releaseLaneLockIfOwned(context, input) {
733
733
  }
734
734
  }
735
735
  function stopAfterCurrentIfRequested(context, projectId, projectRoot, workerId, reason) {
736
- const requested = context.db.get(`SELECT session_id FROM flow_sessions
736
+ const requested = context.db.get(`SELECT session_id FROM sessions
737
737
  WHERE project_id = ? AND worker_id = ? AND flow_kind = 'merge_worker'
738
738
  AND status = 'stopping' AND current_stage = 'stop_after_current'
739
739
  ORDER BY updated_at DESC, rowid DESC LIMIT 1`, [projectId, workerId]);
@@ -72,7 +72,7 @@ export function stopProjectMergeWorker(context, input) {
72
72
  if (claimed.length > 0) {
73
73
  const now = context.now();
74
74
  for (const session of targetSessions) {
75
- context.db.run(`UPDATE flow_sessions
75
+ context.db.run(`UPDATE sessions
76
76
  SET status = 'stopping', stop_reason = ?, current_stage = ?, next_action = ?, updated_at = ?
77
77
  WHERE project_id = ? AND session_id = ?`, [input.reason, "stop_after_current", "finish_current_merge_job_then_stop", now, project.id, session.session_id]);
78
78
  }
@@ -198,7 +198,7 @@ function detectMergeWorkerState(context, projectId) {
198
198
  return { state: "clear" };
199
199
  }
200
200
  function activeMergeWorkerSessions(context, projectId) {
201
- return context.db.all(`SELECT session_id, worker_id, status, current_stage, next_action, updated_at FROM flow_sessions
201
+ return context.db.all(`SELECT session_id, worker_id, status, current_stage, next_action, updated_at FROM sessions
202
202
  WHERE project_id = ? AND flow_kind = 'merge_worker' AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')
203
203
  ORDER BY updated_at DESC, rowid DESC`, [projectId]);
204
204
  }
@@ -486,12 +486,12 @@ function adjacentMigrationChain(source, target, impacts = []) {
486
486
  function activeStateSummary(context, _projectId) {
487
487
  return {
488
488
  protocols: context.db.all("SELECT id, project_id, status, stage FROM protocols WHERE status NOT IN ('closed', 'cancelled') ORDER BY updated_at DESC"),
489
- runs: context.db.all("SELECT id, project_id, status, verdict FROM flow_runs WHERE status = 'running' ORDER BY updated_at DESC"),
489
+ runs: context.db.all("SELECT id, project_id, status, verdict FROM runs WHERE status = 'running' ORDER BY updated_at DESC"),
490
490
  merge_queue: context.db.all("SELECT protocol_id, project_id, status FROM merge_queue WHERE status IN ('ready', 'claimed', 'requeued') ORDER BY updated_at DESC"),
491
491
  lane_locks: context.db.all("SELECT lane, project_id, worker_id, status FROM lane_locks WHERE status = 'active' ORDER BY updated_at DESC"),
492
492
  lane_waiters: context.db.all("SELECT id, lane, project_id, worker_id, status FROM lane_waiters WHERE status = 'queued' ORDER BY queued_at ASC, id ASC"),
493
493
  merge_sessions: context.db.all("SELECT session_id, project_id, status, current_protocol_id FROM merge_sessions WHERE status IN ('starting', 'active', 'stopping') ORDER BY updated_at DESC"),
494
- sessions: context.db.all("SELECT session_id, project_id, status FROM flow_sessions WHERE status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping') ORDER BY updated_at DESC")
494
+ sessions: context.db.all("SELECT session_id, project_id, status FROM sessions WHERE status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping') ORDER BY updated_at DESC")
495
495
  };
496
496
  }
497
497
  function activeStateBlockers(active) {
@@ -98,7 +98,7 @@ export function updatePlanProgress(context, input) {
98
98
  refreshProtocolRuns(context, input.projectId, input.protocolId);
99
99
  }
100
100
  function refreshProtocolRuns(context, projectId, protocolId) {
101
- for (const run of context.db.all("SELECT id FROM flow_runs WHERE project_id = ? AND subject_type = 'protocol' AND subject_id = ?", [projectId, protocolId]))
101
+ for (const run of context.db.all("SELECT id FROM runs WHERE project_id = ? AND subject_type = 'protocol' AND subject_id = ?", [projectId, protocolId]))
102
102
  refreshRunSessionProjection(context, projectId, run.id);
103
103
  }
104
104
  function mergeProgress(item, row, canonical) {
@@ -218,9 +218,9 @@ export function getProjectStatus(context, input) {
218
218
  merge_queue: mergeQueue,
219
219
  hook_status: hookStatusForProject(context, project.id),
220
220
  codex_home_profiles: codexHomeProfilesForProject(context, project.id),
221
- flow_sessions: activeFlowSessionBindingsForProject(context, project.id),
221
+ sessions: activeFlowSessionBindingsForProject(context, project.id),
222
222
  codex_session_bindings: activeCodexSessionBindingsForProject(context, project.id),
223
- codex_hook_events: codexHookEventsForProject(context, project.id),
223
+ hook_events: codexHookEventsForProject(context, project.id),
224
224
  worktrees,
225
225
  lane_status: {
226
226
  lanes: context.db.all("SELECT * FROM lanes WHERE project_id = ? ORDER BY name ASC", [project.id]),
@@ -319,10 +319,10 @@ function projectReferenceTables() {
319
319
  "codex_home_profiles",
320
320
  "codex_session_bindings",
321
321
  "project_config",
322
- "flow_sessions",
322
+ "sessions",
323
323
  "flow_session_segments",
324
324
  "flow_jobs",
325
- "codex_hook_events",
325
+ "hook_events",
326
326
  "worktree_records"
327
327
  ];
328
328
  }
@@ -69,12 +69,12 @@ export function renderWorkerPrompt(context, input) {
69
69
  runId: run.id,
70
70
  protocolId: protocol.id,
71
71
  planItemId: item.id,
72
- groupId: item.execution_context.write_scope[0] ?? null
72
+ groupId: item.execution_context.planned_write_areas[0] ?? null
73
73
  });
74
74
  const staticInputs = profile.static_files.map((file) => readStaticInput(projectRoot, file));
75
75
  const requiredRead = item.execution_context.required_read.map((file) => checkedReference(projectRoot, runHomePath(run), file, "required_read", true));
76
76
  const discoveryBoundary = item.execution_context.discovery_boundary.map((file) => checkedReference(projectRoot, runHomePath(run), file, "discovery_boundary", false));
77
- const writeScope = item.execution_context.write_scope.map((file) => checkedReference(projectRoot, undefined, file, "write_scope", false));
77
+ const plannedWriteAreas = item.execution_context.planned_write_areas.map((file) => checkedReference(projectRoot, undefined, file, "planned_write_areas", false));
78
78
  const runHome = runHomePath(run);
79
79
  const outputDir = path.join(runHome, stage.dir, "subagents", item.id);
80
80
  assertWithin(runHome, outputDir, "output directory");
@@ -85,7 +85,7 @@ export function renderWorkerPrompt(context, input) {
85
85
  workspaceRoot,
86
86
  requiredRead,
87
87
  discoveryBoundary,
88
- writeScope,
88
+ plannedWriteAreas,
89
89
  outputDir,
90
90
  staticInputs,
91
91
  ...(genericTask ? { handoff: genericTask.handoff } : {})
@@ -107,7 +107,7 @@ export function renderWorkerPrompt(context, input) {
107
107
  canon_version: readCanonVersion(projectRoot),
108
108
  renderer_version: getCliVersionReport().cli.version,
109
109
  static_inputs: staticInputs.map(({ path: inputPath, sha256 }) => ({ path: inputPath, sha256 })),
110
- validation: { required_read: requiredRead, discovery_boundary: discoveryBoundary, write_scope: writeScope },
110
+ validation: { required_read: requiredRead, discovery_boundary: discoveryBoundary, planned_write_areas: plannedWriteAreas },
111
111
  ...(genericTask ? { task_handoff: genericTask.handoff } : {})
112
112
  });
113
113
  writeJson(reportPath, {
@@ -121,7 +121,7 @@ export function renderWorkerPrompt(context, input) {
121
121
  stage: input.stage,
122
122
  workspace_root: workspaceRoot,
123
123
  output: { launch_prompt: promptPath, prompt_stack: stackPath },
124
- validation: { required_read: requiredRead, discovery_boundary: discoveryBoundary, write_scope: writeScope },
124
+ validation: { required_read: requiredRead, discovery_boundary: discoveryBoundary, planned_write_areas: plannedWriteAreas },
125
125
  ...(genericTask ? { task_handoff: genericTask.handoff } : {})
126
126
  });
127
127
  return {
@@ -204,7 +204,7 @@ function protocolIdForRun(context, projectId, runId) {
204
204
  return run.subject_id;
205
205
  }
206
206
  function requireRun(context, projectId, runId) {
207
- const row = context.db.get("SELECT id, project_id, subject_type, subject_id, workspace_root, run_index_path, run_home_path, index_json FROM flow_runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [projectId, runId, runId]);
207
+ const row = context.db.get("SELECT id, project_id, subject_type, subject_id, workspace_root, run_index_path, run_home_path, index_json FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [projectId, runId, runId]);
208
208
  if (!row)
209
209
  throw new AppError("not_found", `Run is not found: ${runId}`, 1);
210
210
  return row;
@@ -290,9 +290,10 @@ function renderPrompt(input) {
290
290
  `- Non-goals: ${(item.semantic_spine?.non_goals ?? []).join("; ") || "none"}`,
291
291
  `- Acceptance contribution: ${item.semantic_spine?.acceptance_contribution ?? "not_applicable"}`,
292
292
  "",
293
- "## Runtime",
294
- `- Workspace: \`${input.workspaceRoot}\``,
295
- `- Allowed writes: ${input.writeScope.map((value) => `\`${value}\``).join(", ") || "none"}`,
293
+ "## Hard Write Boundary",
294
+ `- All project writes must stay under: \`${input.workspaceRoot}\``,
295
+ "- Do not write outside this workspace, to another RUN, or into Git/worktree control data.",
296
+ "- Accepted requirements and non-goals remain binding; planned write areas do not.",
296
297
  `- Checks: ${(item.execution_context?.checks ?? []).map((value) => `\`${value}\``).join(", ") || "none"}`,
297
298
  `- Report directory: \`${input.outputDir}\``,
298
299
  "",
@@ -307,10 +308,15 @@ function renderPrompt(input) {
307
308
  ""
308
309
  ]
309
310
  : []),
310
- "## Bounded Discovery",
311
+ "## Discovery Hints",
311
312
  ...input.discoveryBoundary.map((value) => `- \`${value}\``),
312
313
  "",
313
- "Read the static instructions below before acting. Report the sources actually read and any additions inside the discovery boundary. If an essential source is outside that boundary, stop with a plan question or DEF rather than expanding scope silently.",
314
+ "## Planned Write Areas",
315
+ "These are SOFT coordination hints for parallel workers, not an allowlist or permission boundary.",
316
+ `- Areas: ${input.plannedWriteAreas.map((value) => `\`${value}\``).join(", ") || "none predicted"}`,
317
+ "- You may read or change any project-local file needed to complete the accepted task. Report every actual changed path.",
318
+ "",
319
+ "Read the static instructions below before acting. Read every required source first; discovery hints do not prohibit necessary additional project-local reads. Report material additional sources and changes.",
314
320
  "",
315
321
  ...staticInputs.flatMap((entry) => [`## Static Input: ${entry.path}`, "", entry.content.trim(), ""])
316
322
  ].join("\n");
@@ -47,7 +47,7 @@ export function registerProtocol(context, input) {
47
47
  JSON.stringify(state.blockers),
48
48
  JSON.stringify(state.active_def),
49
49
  runtimeStateJsonPath(context.ddFlowHome, project.id, protocolId),
50
- planJsonPath(projectRoot, protocolId),
50
+ planJsonPath(workspacePath ?? projectRoot, protocolId),
51
51
  existing?.created_at ?? now,
52
52
  now
53
53
  ];
@@ -107,9 +107,9 @@ export function getProtocolStatus(context, input) {
107
107
  worktree: context.db.get("SELECT * FROM worktree_records WHERE project_id = ? AND protocol_id = ?", [protocol.project_id, protocol.id]),
108
108
  hook_status: hookStatusForProject(context, protocol.project_id),
109
109
  codex_home_profiles: codexHomeProfilesForProject(context, protocol.project_id),
110
- flow_sessions: activeFlowSessionBindingsForProject(context, protocol.project_id).filter((session) => session.protocol_id === protocol.id),
110
+ sessions: activeFlowSessionBindingsForProject(context, protocol.project_id).filter((session) => session.protocol_id === protocol.id),
111
111
  codex_session_bindings: activeCodexSessionBindingsForProject(context, protocol.project_id).filter((binding) => binding.protocol_id === protocol.id),
112
- codex_hook_events: codexHookEventsForProject(context, protocol.project_id).filter((event) => event.protocol_id === protocol.id),
112
+ hook_events: codexHookEventsForProject(context, protocol.project_id).filter((event) => event.protocol_id === protocol.id),
113
113
  audit: getAuditEvents(context, protocol.project_id, protocol.id)
114
114
  };
115
115
  }
@@ -502,7 +502,7 @@ export function cancelProtocol(context, input) {
502
502
  }
503
503
  let closedSessions = 0;
504
504
  if (input.closeSessions) {
505
- const result = context.db.run(`UPDATE flow_sessions
505
+ const result = context.db.run(`UPDATE sessions
506
506
  SET status = 'stopped', stop_reason = ?, updated_at = ?, stopped_at = ?
507
507
  WHERE project_id = ?
508
508
  AND protocol_id = ?
@@ -705,7 +705,7 @@ function removeLocalFeatureBranch(projectRoot, branch, force) {
705
705
  return { ok: true, skipped: false, reason: force ? "branch_force_deleted" : "branch_deleted", branch };
706
706
  }
707
707
  function releaseRelatedMergeLocks(context, protocol, reason) {
708
- const workers = context.db.all(`SELECT DISTINCT worker_id FROM flow_sessions
708
+ const workers = context.db.all(`SELECT DISTINCT worker_id FROM sessions
709
709
  WHERE project_id = ? AND protocol_id = ? AND worker_id IS NOT NULL`, [protocol.project_id, protocol.id]).map((row) => row.worker_id).filter((workerId) => Boolean(workerId));
710
710
  const job = context.db.get("SELECT claimed_by_session_id FROM merge_queue WHERE project_id = ? AND protocol_id = ?", [protocol.project_id, protocol.id]);
711
711
  if (job?.claimed_by_session_id) {
@@ -866,7 +866,7 @@ export function persistProtocolState(context, protocol, state) {
866
866
  ]);
867
867
  if (state.status === "closed" || state.stage === "closed") {
868
868
  const now = context.now();
869
- context.db.run(`UPDATE flow_sessions
869
+ context.db.run(`UPDATE sessions
870
870
  SET status = 'stopped', stop_reason = 'protocol closed', updated_at = ?, stopped_at = ?
871
871
  WHERE project_id = ?
872
872
  AND protocol_id = ?
@@ -1216,14 +1216,14 @@ function readinessMissingFields(state) {
1216
1216
  }
1217
1217
  function linkedRunsForProtocol(context, protocol, limit) {
1218
1218
  return context.db.all(`SELECT id, short_id, flow_kind, subject_type, subject_id, status, verdict, next_action, run_index_path, index_json, created_at, updated_at, completed_at
1219
- FROM flow_runs
1219
+ FROM runs
1220
1220
  WHERE project_id = ? AND subject_type = 'protocol' AND subject_id = ?
1221
1221
  ORDER BY updated_at DESC, id DESC
1222
1222
  LIMIT ?`, [protocol.project_id, protocol.id, limit]);
1223
1223
  }
1224
1224
  function resolveLinkedRun(context, protocol, runIdOrAlias) {
1225
1225
  const matches = context.db.all(`SELECT id, short_id, flow_kind, subject_type, subject_id, status, verdict, next_action, run_index_path, index_json, created_at, updated_at, completed_at
1226
- FROM flow_runs
1226
+ FROM runs
1227
1227
  WHERE project_id = ? AND (id = ? OR short_id = ?)
1228
1228
  ORDER BY updated_at DESC, id DESC`, [protocol.project_id, runIdOrAlias, runIdOrAlias]);
1229
1229
  if (matches.length === 1)
@@ -2,16 +2,23 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { reconcileSessionCoverageRows } from "../domain/session-coverage.js";
4
4
  export function refreshRunSessionProjection(context, projectId, runId) {
5
- const run = context.db.get("SELECT id, status, project_root, subject_type, subject_id, runtime_path, run_index_path, index_json FROM flow_runs WHERE project_id = ? AND id = ?", [projectId, runId]);
5
+ const run = context.db.get("SELECT id, flow_kind, status, project_root, subject_type, subject_id, runtime_path, run_index_path, index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
6
6
  if (!run)
7
7
  return;
8
8
  const index = JSON.parse(run.index_json);
9
- const sessionRows = context.db.all(`SELECT session_id, parent_session_id, role, session_kind, worker_id, current_stage, status,
9
+ const sessionRows = context.db.all(`SELECT session_id, harness, provider_session_id, parent_session_id, role, session_kind, worker_id, current_stage, status,
10
10
  created_at, updated_at, stopped_at, coverage_units_json
11
- FROM flow_sessions WHERE project_id = ? AND run_id = ? ORDER BY created_at, session_id`, [projectId, runId]);
11
+ FROM sessions WHERE project_id = ? AND run_id = ? ORDER BY created_at, session_id`, [projectId, runId]);
12
12
  const sessions = sessionRows.map(sessionProjection);
13
+ const workRows = context.db.all("SELECT work_id, parent_work_id, status, depends_on_json, launch_policy, started_at, completed_at FROM works WHERE project_id = ? AND run_id = ? ORDER BY created_at, work_id", [projectId, runId]);
14
+ const works = workRows.map((work) => ({ work_id: work.work_id, parent_work_id: work.parent_work_id, status: work.status, depends_on: parseStringArray(work.depends_on_json), launch_policy: work.launch_policy, started_at: work.started_at, completed_at: work.completed_at }));
15
+ const rootWorkId = workRows.find((work) => work.parent_work_id === null)?.work_id ?? null;
13
16
  const coverage = reconcileSessionCoverageRows(sessionRows);
14
- const sessionCoverage = {
17
+ const sessionCoverage = run.flow_kind === "vnext_specify" && coverage.expected_unit_ids.length === 0 ? {
18
+ status: "not_applicable",
19
+ expected: [], observed: [], missing: [],
20
+ diagnostics: ["worker_coverage_not_required"]
21
+ } : {
15
22
  status: coverage.status,
16
23
  expected: coverage.expected_unit_ids,
17
24
  observed: coverage.observed_unit_ids,
@@ -20,19 +27,35 @@ export function refreshRunSessionProjection(context, projectId, runId) {
20
27
  };
21
28
  const plan = run.subject_type === "protocol" ? planProjection(context, projectId, run.subject_id, run.project_root) : {};
22
29
  const workers = workerProjection(context, projectId, runId);
30
+ const vnext = run.flow_kind === "vnext_specify" || run.flow_kind === "vnext_protocolize";
31
+ const hadLegacyCoverage = vnext && ("session_coverage" in index || "usage_coverage" in index);
32
+ if (vnext) {
33
+ delete index.session_coverage;
34
+ delete index.usage_coverage;
35
+ delete index.workers;
36
+ delete index.current_stage;
37
+ delete index.attempts;
38
+ }
23
39
  if (JSON.stringify(index.sessions ?? []) === JSON.stringify(sessions)
24
- && JSON.stringify(index.session_coverage) === JSON.stringify(sessionCoverage)
40
+ && JSON.stringify(index.works ?? []) === JSON.stringify(works)
41
+ && index.root_work_id === rootWorkId
42
+ && (vnext || JSON.stringify(index.session_coverage) === JSON.stringify(sessionCoverage))
25
43
  && JSON.stringify(index.plan_ref) === JSON.stringify(plan.plan_ref)
26
44
  && JSON.stringify(index.plan_progress) === JSON.stringify(plan.plan_progress)
27
- && JSON.stringify(index.workers ?? {}) === JSON.stringify(workers))
45
+ && (vnext || JSON.stringify(index.workers ?? {}) === JSON.stringify(workers))
46
+ && !hadLegacyCoverage)
28
47
  return;
29
48
  index.sessions = sessions;
30
- index.session_coverage = sessionCoverage;
49
+ index.works = works;
50
+ index.root_work_id = rootWorkId;
51
+ if (!vnext)
52
+ index.session_coverage = sessionCoverage;
31
53
  if (plan.plan_ref)
32
54
  index.plan_ref = plan.plan_ref;
33
55
  if (plan.plan_progress)
34
56
  index.plan_progress = plan.plan_progress;
35
- index.workers = workers;
57
+ if (!vnext)
58
+ index.workers = workers;
36
59
  index.updated_at = context.now();
37
60
  const runtime = readJson(run.runtime_path);
38
61
  const runtimeRevision = typeof runtime?.runtime_revision === "number"
@@ -41,19 +64,30 @@ export function refreshRunSessionProjection(context, projectId, runId) {
41
64
  ? index.runtime_revision + 1
42
65
  : 1;
43
66
  index.runtime_revision = runtimeRevision;
44
- const authoritative = runtime ?? { ...index, schema_id: "dd-flow/flow-run@2" };
67
+ const authoritative = { ...(runtime ?? {}), ...index, schema_id: String(index.schema_id ?? runtime?.schema_id ?? "dd-flow/flow-run@3") };
45
68
  authoritative.sessions = sessions;
46
- authoritative.session_coverage = sessionCoverage;
69
+ authoritative.works = works;
70
+ authoritative.root_work_id = rootWorkId;
71
+ if (vnext) {
72
+ delete authoritative.session_coverage;
73
+ delete authoritative.usage_coverage;
74
+ delete authoritative.workers;
75
+ delete authoritative.current_stage;
76
+ delete authoritative.attempts;
77
+ }
78
+ else
79
+ authoritative.session_coverage = sessionCoverage;
47
80
  if (plan.plan_ref)
48
81
  authoritative.plan_ref = plan.plan_ref;
49
82
  if (plan.plan_progress)
50
83
  authoritative.plan_progress = plan.plan_progress;
51
- authoritative.workers = workers;
84
+ if (!vnext)
85
+ authoritative.workers = workers;
52
86
  authoritative.updated_at = index.updated_at;
53
87
  authoritative.runtime_revision = runtimeRevision;
54
88
  if (run.status !== "discarded" || fs.existsSync(run.runtime_path))
55
89
  writeJson(run.runtime_path, authoritative);
56
- context.db.run("UPDATE flow_runs SET index_json = ?, updated_at = ? WHERE project_id = ? AND id = ?", [JSON.stringify(index), index.updated_at, projectId, runId]);
90
+ context.db.run("UPDATE runs SET index_json = ?, updated_at = ? WHERE project_id = ? AND id = ?", [JSON.stringify(index), index.updated_at, projectId, runId]);
57
91
  }
58
92
  function planProjection(context, projectId, protocolId, projectRoot) {
59
93
  const binding = context.db.get("SELECT plan_path, plan_revision, plan_sha256 FROM plan_bindings WHERE project_id = ? AND protocol_id = ?", [projectId, protocolId]);
@@ -107,10 +141,12 @@ function parseStringArray(value) {
107
141
  function sessionProjection(row) {
108
142
  return {
109
143
  session_id: row.session_id,
144
+ harness: row.harness,
145
+ provider_session_id: row.provider_session_id,
110
146
  parent_session_id: row.parent_session_id,
111
147
  role: row.role,
112
148
  session_kind: row.session_kind,
113
- worker_id: row.worker_id,
149
+ work_id: row.worker_id,
114
150
  current_stage: row.current_stage,
115
151
  status: row.status,
116
152
  created_at: row.created_at,