@deksden-com/dd-flow-cli 0.5.0 → 0.7.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 (40) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +10 -3
  3. package/dist/build-info.json +5 -5
  4. package/dist/cli/help.js +24 -11
  5. package/dist/cli/run-cli.js +117 -28
  6. package/dist/domain/flow-contract.js +15 -4
  7. package/dist/domain/session-coverage.js +88 -0
  8. package/dist/runtime/context.js +8 -2
  9. package/dist/schemas/code-stage-report.schema.json +7 -2
  10. package/dist/schemas/engine-manifest.schema.json +22 -0
  11. package/dist/schemas/flow-contract.schema.json +15 -13
  12. package/dist/schemas/flow-run.schema.json +1 -0
  13. package/dist/schemas/mb-upgrade-migration-report.schema.json +3 -1
  14. package/dist/schemas/merge-stage-report-legacy-0.4.2.schema.json +24 -0
  15. package/dist/schemas/run-engine-binding.schema.json +37 -0
  16. package/dist/schemas/stage-prompt.schema.json +9 -5
  17. package/dist/schemas/stage-start-response.schema.json +6 -5
  18. package/dist/services/canon.js +15 -1
  19. package/dist/services/cleanup.js +77 -0
  20. package/dist/services/cli-operation-classifier.js +52 -8
  21. package/dist/services/compatibility-preflight.js +1 -1
  22. package/dist/services/dashboard.js +2 -2
  23. package/dist/services/engines.js +408 -30
  24. package/dist/services/hooks.js +28 -15
  25. package/dist/services/lanes.js +0 -4
  26. package/dist/services/merge-queue.js +48 -0
  27. package/dist/services/merge-worker.js +3 -4
  28. package/dist/services/migrations.js +307 -44
  29. package/dist/services/plan-runtime.js +4 -4
  30. package/dist/services/plans.js +5 -3
  31. package/dist/services/protocols.js +23 -2
  32. package/dist/services/run-engine-bindings.js +157 -0
  33. package/dist/services/run-projection.js +18 -4
  34. package/dist/services/runs.js +54 -7
  35. package/dist/services/schema-validation.js +96 -0
  36. package/dist/services/sessions.js +33 -73
  37. package/dist/services/stage-lifecycle.js +298 -45
  38. package/dist/services/status.js +8 -3
  39. package/dist/storage/database.js +32 -11
  40. package/package.json +1 -1
@@ -54,8 +54,8 @@ export function boundCanonicalPlan(context, input) {
54
54
  refreshProtocolRuns(context, input.projectId, input.protocolId);
55
55
  return canonical;
56
56
  }
57
- export function planWithProgress(context, input) {
58
- const canonical = boundCanonicalPlan(context, input);
57
+ export function planWithProgress(context, input, options = {}) {
58
+ const canonical = options.bind === false ? readCanonicalPlan(input.planPath, input.protocolId) : boundCanonicalPlan(context, input);
59
59
  const rows = context.db.all(`SELECT item_id, plan_revision, plan_sha256, status, summary, evidence_json, block_reason, user_required
60
60
  FROM plan_progress WHERE project_id = ? AND protocol_id = ?`, [input.projectId, input.protocolId]);
61
61
  const progress = new Map(rows.map((row) => [row.item_id, row]));
@@ -67,8 +67,8 @@ export function planWithProgress(context, input) {
67
67
  }
68
68
  };
69
69
  }
70
- export function planSummary(context, input) {
71
- const { canonical, plan } = planWithProgress(context, input);
70
+ export function planSummary(context, input, options = {}) {
71
+ const { canonical, plan } = planWithProgress(context, input, options);
72
72
  return {
73
73
  plan_id: plan.plan_id,
74
74
  revision: canonical.revision,
@@ -7,11 +7,11 @@ import { resolveProjectRoot } from "../storage/paths.js";
7
7
  import { planSummary, planWithProgress, updatePlanProgress } from "./plan-runtime.js";
8
8
  export function getPlanStatus(context, input) {
9
9
  const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
10
- const current = planWithProgress(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path });
10
+ const current = planWithProgress(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path }, { bind: false });
11
11
  return {
12
12
  ok: true,
13
13
  protocol_id: protocol.id,
14
- plan: planSummary(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path }),
14
+ plan: planSummary(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path }, { bind: false }),
15
15
  binding: { revision: current.canonical.revision, sha256: current.canonical.sha256, path: protocol.plan_path },
16
16
  blocked_items: current.plan.items
17
17
  .filter((item) => item.status === "blocked")
@@ -69,7 +69,9 @@ function updatePlanItem(context, projectRoot, protocolId, itemId, transform) {
69
69
  }
70
70
  function scopedProtocol(context, projectRoot, protocolId) {
71
71
  const project = requireProjectByRoot(context, resolveProjectRoot(projectRoot));
72
- return requireProtocol(context, protocolId, project.id);
72
+ const protocol = requireProtocol(context, protocolId, project.id);
73
+ readProtocolRuntimeState(context, protocol);
74
+ return protocol;
73
75
  }
74
76
  function assertDependenciesClosed(plan, item) {
75
77
  const openDependencies = item.depends_on
@@ -100,7 +100,7 @@ export function getProtocolStatus(context, input) {
100
100
  diagnostics: [...runtime.diagnostics, ...runDiagnostics.diagnostics, ...lifecycle.diagnostics],
101
101
  latest_run: runDiagnostics.latest_run,
102
102
  state,
103
- plan: plan ? planSummary(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path }) : state.plan,
103
+ plan: plan ? planSummary(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path }, { bind: false }) : state.plan,
104
104
  merge_queue: queue,
105
105
  branch_context: getProtocolBranchContext(context, { protocolId: protocol.id, projectRoot: project.root }).branch_context,
106
106
  flow_guidance: buildProtocolFlowGuidance({ state, latestRun: runDiagnostics.latest_run, queueStatus: queue?.status ?? null }),
@@ -734,6 +734,7 @@ function releaseRelatedMergeLocks(context, protocol, reason) {
734
734
  }
735
735
  export function readProtocolRuntimeState(context, protocol) {
736
736
  const diagnostics = [];
737
+ normalizeProtocolPlanPath(context, protocol, diagnostics);
737
738
  try {
738
739
  return { state: readStateFile(protocol.state_path, protocol.id), diagnostics };
739
740
  }
@@ -764,7 +765,7 @@ export function readProtocolRuntimeState(context, protocol) {
764
765
  }
765
766
  const plan = canonicalPlanIfPresent(context, protocol);
766
767
  const summary = plan
767
- ? planSummary(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path })
768
+ ? planSummary(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path }, { bind: false })
768
769
  : { plan_id: null, total: 0, done: 0, blocked: 0 };
769
770
  const flowContract = loadProjectFlowContract(protocol.project_root);
770
771
  const state = {
@@ -782,6 +783,9 @@ export function readProtocolRuntimeState(context, protocol) {
782
783
  flow_contract: flowContract,
783
784
  updated_at: protocol.updated_at
784
785
  };
786
+ if (!context.db.writable) {
787
+ return { state, diagnostics };
788
+ }
785
789
  const stableStatePath = runtimeStateJsonPath(context.ddFlowHome, protocol.project_id, protocol.id);
786
790
  const stablePlanPath = planJsonPath(protocol.project_root, protocol.id);
787
791
  ensureDir(path.dirname(stableStatePath));
@@ -798,6 +802,23 @@ export function readProtocolRuntimeState(context, protocol) {
798
802
  });
799
803
  return { state, diagnostics };
800
804
  }
805
+ function normalizeProtocolPlanPath(context, protocol, diagnostics) {
806
+ const canonicalPlanPath = planJsonPath(protocol.project_root, protocol.id);
807
+ if (protocol.plan_path === canonicalPlanPath || !fs.existsSync(canonicalPlanPath))
808
+ return;
809
+ diagnostics.push({
810
+ code: "protocol_plan_relocated",
811
+ severity: "warning",
812
+ old_path: protocol.plan_path,
813
+ path: canonicalPlanPath,
814
+ source: "project_canonical_plan",
815
+ recommended_action: "use the project canonical plan as the sole semantic plan source"
816
+ });
817
+ if (context.db.writable) {
818
+ context.db.run("UPDATE protocols SET plan_path = ?, updated_at = ? WHERE project_id = ? AND id = ?", [canonicalPlanPath, context.now(), protocol.project_id, protocol.id]);
819
+ }
820
+ protocol.plan_path = canonicalPlanPath;
821
+ }
801
822
  export function protocolRunDiagnostics(context, protocol, state) {
802
823
  const runs = linkedRunsForProtocol(context, protocol, 5);
803
824
  const latest = runs[0];
@@ -0,0 +1,157 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { AppError } from "../shared/errors.js";
4
+ export const runEngineBindingSchemaId = "dd-flow/run-engine-binding@1";
5
+ export function findRunHome(ddFlowHome, projectRoot, runIdOrAlias) {
6
+ const projectsRoot = path.join(ddFlowHome, "projects");
7
+ if (!fs.existsSync(projectsRoot))
8
+ return null;
9
+ const expectedRoot = realpathOrResolve(projectRoot);
10
+ const matches = [];
11
+ for (const projectId of fs.readdirSync(projectsRoot)) {
12
+ const runsRoot = path.join(projectsRoot, projectId, "runs");
13
+ if (!isDirectory(runsRoot))
14
+ continue;
15
+ for (const runId of fs.readdirSync(runsRoot)) {
16
+ if (!runIdMatches(runId, runIdOrAlias))
17
+ continue;
18
+ const runHome = path.join(runsRoot, runId);
19
+ const authorityPath = path.join(runHome, "run.json");
20
+ const bindingPath = path.join(runHome, "engine-binding.json");
21
+ const binding = readRunEngineBinding(bindingPath, { allowMissing: true });
22
+ const authorityRoot = binding?.project_root ?? projectRootFromAuthority(authorityPath);
23
+ if (!authorityRoot || realpathOrResolve(authorityRoot) !== expectedRoot)
24
+ continue;
25
+ matches.push({ project_id: projectId, run_id: runId, run_home: runHome, authority_path: authorityPath, binding_path: bindingPath });
26
+ }
27
+ }
28
+ if (matches.length > 1) {
29
+ throw new AppError("ambiguous_id", `RUN id is ambiguous for project: ${runIdOrAlias}`, 2, {
30
+ project_root: expectedRoot,
31
+ candidates: matches.map((match) => match.run_id)
32
+ });
33
+ }
34
+ return matches[0] ?? null;
35
+ }
36
+ export function readRunEngineBinding(file, options = {}) {
37
+ if (!fs.existsSync(file)) {
38
+ if (options.allowMissing)
39
+ return null;
40
+ throw new AppError("run_engine_binding_missing", "RUN engine binding is missing", 1, { path: file });
41
+ }
42
+ let value;
43
+ try {
44
+ value = JSON.parse(fs.readFileSync(file, "utf8"));
45
+ }
46
+ catch (error) {
47
+ throw new AppError("run_engine_binding_invalid", "RUN engine binding is not valid JSON", 2, { path: file, cause: String(error) });
48
+ }
49
+ if (!isRunEngineBinding(value)) {
50
+ throw new AppError("run_engine_binding_invalid", "RUN engine binding is invalid", 2, { path: file });
51
+ }
52
+ return value;
53
+ }
54
+ export function writeRunEngineBinding(file, binding) {
55
+ const existing = readRunEngineBinding(file, { allowMissing: true });
56
+ if (existing) {
57
+ if (sameEngine(existing.engine, binding.engine) && existing.run_id === binding.run_id && realpathOrResolve(existing.project_root) === realpathOrResolve(binding.project_root)) {
58
+ return { changed: false, binding: existing };
59
+ }
60
+ throw new AppError("run_engine_binding_immutable", "RUN engine binding cannot be changed", 1, {
61
+ path: file,
62
+ current: existing.engine,
63
+ requested: binding.engine
64
+ });
65
+ }
66
+ fs.mkdirSync(path.dirname(file), { recursive: true });
67
+ const temporary = `${file}.tmp-${process.pid}-${Date.now()}`;
68
+ fs.writeFileSync(temporary, `${JSON.stringify(binding, null, 2)}\n`);
69
+ fs.renameSync(temporary, file);
70
+ return { changed: true, binding };
71
+ }
72
+ export function allRunEngineBindings(ddFlowHome) {
73
+ const projectsRoot = path.join(ddFlowHome, "projects");
74
+ if (!fs.existsSync(projectsRoot))
75
+ return [];
76
+ const bindings = [];
77
+ for (const projectId of fs.readdirSync(projectsRoot)) {
78
+ const runsRoot = path.join(projectsRoot, projectId, "runs");
79
+ if (!isDirectory(runsRoot))
80
+ continue;
81
+ for (const runId of fs.readdirSync(runsRoot)) {
82
+ const binding = readRunEngineBinding(path.join(runsRoot, runId, "engine-binding.json"), { allowMissing: true });
83
+ if (binding)
84
+ bindings.push(binding);
85
+ }
86
+ }
87
+ return bindings;
88
+ }
89
+ function isRunEngineBinding(value) {
90
+ if (!value || typeof value !== "object" || Array.isArray(value))
91
+ return false;
92
+ const record = value;
93
+ const engine = record.engine;
94
+ const probe = record.probe;
95
+ return record.schema_id === runEngineBindingSchemaId
96
+ && typeof record.run_id === "string"
97
+ && typeof record.project_root === "string"
98
+ && typeof record.bound_at === "string"
99
+ && ["run_creation", "legacy_recovery"].includes(String(record.source))
100
+ && typeof record.reason === "string"
101
+ && Boolean(engine && typeof engine === "object" && !Array.isArray(engine)
102
+ && typeof engine.package_name === "string"
103
+ && typeof engine.package_version === "string"
104
+ && typeof engine.engine_version === "string"
105
+ && /^[a-f0-9]{64}$/.test(String(engine.integrity_checksum))
106
+ && typeof engine.snapshot_root === "string")
107
+ && Boolean(probe && typeof probe === "object" && !Array.isArray(probe)
108
+ && ["not_required", "passed"].includes(String(probe.status))
109
+ && (typeof probe.command === "string" || probe.command === null));
110
+ }
111
+ function sameEngine(left, right) {
112
+ return left.package_name === right.package_name
113
+ && left.package_version === right.package_version
114
+ && left.engine_version === right.engine_version
115
+ && left.integrity_checksum === right.integrity_checksum
116
+ && left.snapshot_root === right.snapshot_root;
117
+ }
118
+ function projectRootFromAuthority(file) {
119
+ try {
120
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
121
+ const execution = recordValue(value.execution);
122
+ const workspace = recordValue(value.workspace);
123
+ const project = recordValue(value.project);
124
+ return stringValue(execution?.project_root)
125
+ ?? stringValue(workspace?.project_root)
126
+ ?? stringValue(project?.root)
127
+ ?? stringValue(value.project_root);
128
+ }
129
+ catch {
130
+ return null;
131
+ }
132
+ }
133
+ function runIdMatches(fullId, idOrAlias) {
134
+ return fullId === idOrAlias || /^RUN-[0-9]+$/.test(idOrAlias) && fullId.startsWith(`${idOrAlias}-`);
135
+ }
136
+ function isDirectory(value) {
137
+ try {
138
+ return fs.statSync(value).isDirectory();
139
+ }
140
+ catch {
141
+ return false;
142
+ }
143
+ }
144
+ function realpathOrResolve(value) {
145
+ try {
146
+ return fs.realpathSync(value);
147
+ }
148
+ catch {
149
+ return path.resolve(value);
150
+ }
151
+ }
152
+ function recordValue(value) {
153
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
154
+ }
155
+ function stringValue(value) {
156
+ return typeof value === "string" && value.length > 0 ? value : null;
157
+ }
@@ -1,21 +1,33 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { reconcileSessionCoverageRows } from "../domain/session-coverage.js";
3
4
  export function refreshRunSessionProjection(context, projectId, runId) {
4
- const run = context.db.get("SELECT id, 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, 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
6
  if (!run)
6
7
  return;
7
8
  const index = JSON.parse(run.index_json);
8
- const sessions = 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, parent_session_id, role, session_kind, worker_id, current_stage, status,
9
10
  created_at, updated_at, stopped_at, coverage_units_json
10
- FROM flow_sessions WHERE project_id = ? AND run_id = ? ORDER BY created_at, session_id`, [projectId, runId]).map(sessionProjection);
11
+ FROM flow_sessions WHERE project_id = ? AND run_id = ? ORDER BY created_at, session_id`, [projectId, runId]);
12
+ const sessions = sessionRows.map(sessionProjection);
13
+ const coverage = reconcileSessionCoverageRows(sessionRows);
14
+ const sessionCoverage = {
15
+ status: coverage.status,
16
+ expected: coverage.expected_unit_ids,
17
+ observed: coverage.observed_unit_ids,
18
+ missing: coverage.missing_unit_ids,
19
+ diagnostics: coverage.diagnostics
20
+ };
11
21
  const plan = run.subject_type === "protocol" ? planProjection(context, projectId, run.subject_id, run.project_root) : {};
12
22
  const workers = workerProjection(context, projectId, runId);
13
23
  if (JSON.stringify(index.sessions ?? []) === JSON.stringify(sessions)
24
+ && JSON.stringify(index.session_coverage) === JSON.stringify(sessionCoverage)
14
25
  && JSON.stringify(index.plan_ref) === JSON.stringify(plan.plan_ref)
15
26
  && JSON.stringify(index.plan_progress) === JSON.stringify(plan.plan_progress)
16
27
  && JSON.stringify(index.workers ?? {}) === JSON.stringify(workers))
17
28
  return;
18
29
  index.sessions = sessions;
30
+ index.session_coverage = sessionCoverage;
19
31
  if (plan.plan_ref)
20
32
  index.plan_ref = plan.plan_ref;
21
33
  if (plan.plan_progress)
@@ -31,6 +43,7 @@ export function refreshRunSessionProjection(context, projectId, runId) {
31
43
  index.runtime_revision = runtimeRevision;
32
44
  const authoritative = runtime ?? { ...index, schema_id: "dd-flow/flow-run@2" };
33
45
  authoritative.sessions = sessions;
46
+ authoritative.session_coverage = sessionCoverage;
34
47
  if (plan.plan_ref)
35
48
  authoritative.plan_ref = plan.plan_ref;
36
49
  if (plan.plan_progress)
@@ -38,7 +51,8 @@ export function refreshRunSessionProjection(context, projectId, runId) {
38
51
  authoritative.workers = workers;
39
52
  authoritative.updated_at = index.updated_at;
40
53
  authoritative.runtime_revision = runtimeRevision;
41
- writeJson(run.runtime_path, authoritative);
54
+ if (run.status !== "discarded" || fs.existsSync(run.runtime_path))
55
+ writeJson(run.runtime_path, authoritative);
42
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]);
43
57
  }
44
58
  function planProjection(context, projectId, protocolId, projectRoot) {
@@ -3,7 +3,7 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { formatFullId, isFullEntityId, isShortEntityId, parseFullEntityId } from "../domain/entity-ids.js";
6
- import { checksumForFlowFlagValues, isFlowFlagDowngrade, loadProjectFlowContract, resolveFlowFlags } from "../domain/flow-contract.js";
6
+ import { checksumForFlowFlagValues, isFlowFlagDowngrade, loadCanonicalFlowContract, loadProjectFlowContract, resolveFlowFlags } from "../domain/flow-contract.js";
7
7
  import { AppError } from "../shared/errors.js";
8
8
  import { ensureDir, projectRunHome, projectRunJsonPath, resolveProjectRoot } from "../storage/paths.js";
9
9
  import { appendAudit } from "./audit.js";
@@ -12,6 +12,8 @@ import { buildRunFlowGuidance } from "./flow-guidance.js";
12
12
  import { readProtocolRuntimeState, requireProtocol } from "./protocols.js";
13
13
  import { checkpointRunUsage, usageForRun } from "./usage.js";
14
14
  import { refreshRunSessionProjection } from "./run-projection.js";
15
+ import { resolveCanonRoot } from "./canon.js";
16
+ import { bindCurrentEngineToRun } from "./engines.js";
15
17
  const runSchemaId = "dd-flow/flow-run@2";
16
18
  const runtimeSchemaId = "dd-flow/flow-run@2";
17
19
  const runIdType = "RUN";
@@ -30,7 +32,9 @@ const allowedRunFlowKinds = [
30
32
  ];
31
33
  export function startFlowRun(context, input) {
32
34
  const projectRoot = resolveProjectRoot(input.projectRoot);
33
- const flowContract = loadProjectFlowContract(projectRoot);
35
+ const flowContract = input.flowKind === "mb-upgrade"
36
+ ? loadUpgradeFlowContract(context, projectRoot)
37
+ : loadProjectFlowContract(projectRoot);
34
38
  registerProject(context, { root: projectRoot });
35
39
  const project = requireProjectByRoot(context, projectRoot);
36
40
  const workspaceRoot = resolveWorkspaceRoot(input.workspaceRoot ?? projectRoot);
@@ -107,6 +111,7 @@ export function startFlowRun(context, input) {
107
111
  };
108
112
  ensureDir(path.dirname(runtimePath));
109
113
  ensureDir(runHome);
114
+ bindCurrentEngineToRun(context, { projectRoot, runId, runHome });
110
115
  writeJsonFile(runtimePath, runtimeSnapshotForIndex(index, 1));
111
116
  context.db.run(`INSERT INTO flow_runs
112
117
  (id, short_id, slug, project_id, project_root, workspace_root, flow_kind, subject_type, subject_id,
@@ -151,14 +156,23 @@ export function startFlowRun(context, input) {
151
156
  });
152
157
  return { ok: true, run: flowRunSummary(requireRunById(context, project.id, runId)), index };
153
158
  }
159
+ function loadUpgradeFlowContract(context, projectRoot) {
160
+ const canon = resolveCanonRoot(context, context.env.DD_MEMORYBANK ? { explicitRoot: context.env.DD_MEMORYBANK } : {});
161
+ if (!canon.ok || !canon.canon) {
162
+ throw new AppError("canon_unavailable", "mb-upgrade RUN requires the pinned canonical Memory Bank", 1, {
163
+ project_root: projectRoot,
164
+ blockers: canon.blockers,
165
+ bootstrap: canon.bootstrap
166
+ });
167
+ }
168
+ return loadCanonicalFlowContract(canon.canon.flow_root);
169
+ }
154
170
  export function getFlowRunStatus(context, input) {
155
171
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
156
172
  const run = resolveRun(context, project.id, input.runId);
157
- refreshRunSessionProjection(context, project.id, run.id);
158
- const refreshedRun = requireRunById(context, project.id, run.id);
159
- const index = authoritativeIndex(refreshedRun);
160
- const runtime = readRuntimeSnapshot(refreshedRun.runtime_path);
161
- return { ok: true, run: flowRunSummary(refreshedRun), index, ...(runtime ? { runtime } : {}), flow_guidance: guidanceForRun(context, refreshedRun, index) };
173
+ const index = authoritativeIndex(run);
174
+ const runtime = readRuntimeSnapshot(run.runtime_path);
175
+ return { ok: true, run: flowRunSummary(run), index, ...(runtime ? { runtime } : {}), flow_guidance: guidanceForRun(context, run, index) };
162
176
  }
163
177
  export function getFlowRunFlagsStatus(context, input) {
164
178
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -428,6 +442,7 @@ export function completeFlowRun(context, input) {
428
442
  };
429
443
  }
430
444
  persistRunState(context, project, run, index);
445
+ closeRunOrchestrators(context, project.id, run, status, now);
431
446
  refreshRunSessionProjection(context, project.id, run.id);
432
447
  appendAudit(context, {
433
448
  projectId: project.id,
@@ -439,6 +454,30 @@ export function completeFlowRun(context, input) {
439
454
  const updatedRun = requireRunById(context, project.id, run.id);
440
455
  return { ok: true, run: flowRunSummary(updatedRun), index, flow_guidance: guidanceForRun(context, updatedRun, index) };
441
456
  }
457
+ function closeRunOrchestrators(context, projectId, run, status, now) {
458
+ if (!["done", "blocked", "cancelled", "failed"].includes(status))
459
+ return;
460
+ const active = context.db.all(`SELECT session_id FROM flow_sessions
461
+ WHERE project_id = ? AND run_id = ? AND session_kind = 'orchestrator'
462
+ AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')`, [projectId, run.id]);
463
+ if (active.length === 0)
464
+ return;
465
+ const reason = `run_${status}`;
466
+ context.db.run(`UPDATE flow_sessions SET status = 'stopped', stop_reason = ?, updated_at = ?, stopped_at = ?
467
+ WHERE project_id = ? AND run_id = ? AND session_kind = 'orchestrator'
468
+ AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')`, [reason, now, now, projectId, run.id]);
469
+ context.db.run(`UPDATE flow_session_segments SET ended_at = ?
470
+ 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)]);
471
+ for (const session of active) {
472
+ appendRunTimeline(runArtifactRoot(run), {
473
+ at: now,
474
+ type: "session_stopped",
475
+ run_id: run.id,
476
+ session_id: session.session_id,
477
+ reason
478
+ });
479
+ }
480
+ }
442
481
  const timelineSections = ["stages", "events", "sessions", "usage", "artifacts"];
443
482
  export function getFlowRunTimeline(context, input) {
444
483
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -511,6 +550,14 @@ export function appendFlowRunTimelineEvent(context, projectId, runId, event) {
511
550
  appendRunTimeline(runArtifactRoot(run), { run_id: run.id, ...event });
512
551
  }
513
552
  function guidanceForRun(context, run, index) {
553
+ if (run.status === "discarded") {
554
+ return buildRunFlowGuidance({
555
+ stageRuns: index.stage_runs,
556
+ protocolStage: "cancelled",
557
+ runId: run.id,
558
+ runDir: index.run_home?.relative_path ?? path.posix.join("runs", run.id)
559
+ });
560
+ }
514
561
  if (run.subject_type === "protocol") {
515
562
  try {
516
563
  const protocol = requireProtocol(context, run.subject_id, run.project_id);
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
5
5
  import { Ajv } from "ajv/dist/ajv.js";
6
6
  import { normalizeFlowContract } from "../domain/flow-contract.js";
7
7
  import { AppError } from "../shared/errors.js";
8
+ import { findRunHome, readRunEngineBinding } from "./run-engine-bindings.js";
8
9
  export function captureMemoryBankBaseline(input) {
9
10
  const projectRoot = path.resolve(input.projectRoot);
10
11
  const paths = normalizeMemoryBankPaths(projectRoot, input.paths);
@@ -116,6 +117,9 @@ export function validateSchema(options) {
116
117
  };
117
118
  }
118
119
  function resolveSchema(options) {
120
+ const bound = resolveRunBoundSchema(options);
121
+ if (bound)
122
+ return bound;
119
123
  const fileNames = [`${options.schemaName}.schema.json`];
120
124
  const projectRoot = path.resolve(options.projectRoot ?? process.cwd());
121
125
  const roots = [
@@ -145,6 +149,98 @@ function resolveSchema(options) {
145
149
  source: found.source
146
150
  };
147
151
  }
152
+ const historicalSchemaRegistry = {
153
+ "294fd5f24ec6a4f938f15dfd98a4b303c2e026753062c3c6c8b15e09029a1128": [{
154
+ name: "merge-stage-report",
155
+ id: "dd-flow/merge-stage-report@2/engine-0.4.2",
156
+ path: "dist/schemas/merge-stage-report-legacy-0.4.2.schema.json",
157
+ checksum: "d861630cafd51e71e5440dadac2628413501e8b063ff19bef7d16966dc7077e3"
158
+ }],
159
+ "0fc717e17c359242662b76d8c2518bf4c4237366221e69bb979d2959d5ae8b60": [{
160
+ name: "merge-stage-report",
161
+ id: "dd-flow/stage-report@1",
162
+ path: "dist/schemas/stage-report.schema.json",
163
+ checksum: "d9889af7f2c0afb47e2abab330aea3e1750879d0738d87d38d2d20e3fe0403f3"
164
+ }]
165
+ };
166
+ function resolveRunBoundSchema(options) {
167
+ if (!options.projectRoot || options.schemaDir)
168
+ return null;
169
+ const data = readJson(path.resolve(options.file), "input file");
170
+ const root = asRecord(data);
171
+ const run = root ? asRecord(objectValue(root, "run")) : undefined;
172
+ const runId = run ? objectValue(run, "run_id") : objectValue(root ?? {}, "run_id");
173
+ if (typeof runId !== "string")
174
+ return null;
175
+ const projectRoot = path.resolve(options.projectRoot);
176
+ const located = findRunHome(options.ddFlowHome ?? process.env.DD_FLOW_HOME ?? path.join(path.dirname(projectRoot), ".dd-flow"), projectRoot, runId);
177
+ if (!located || !isInside(located.run_home, path.resolve(options.file)))
178
+ return null;
179
+ const binding = readRunEngineBinding(located.binding_path, { allowMissing: true });
180
+ if (!binding)
181
+ return null;
182
+ const manifestPath = path.join(binding.engine.snapshot_root, "engine.json");
183
+ const manifest = readJson(manifestPath, "bound engine manifest");
184
+ const integrity = asRecord(objectValue(manifest, "integrity"));
185
+ if (manifest.package_version !== binding.engine.package_version || manifest.engine_version !== binding.engine.engine_version || integrity?.checksum !== binding.engine.integrity_checksum) {
186
+ throw new AppError("run_engine_binding_invalid", "RUN engine binding does not match its engine manifest", 1, { binding: binding.engine, manifest: manifestPath });
187
+ }
188
+ const registry = asRecord(objectValue(manifest, "schema_registry"));
189
+ const entries = Array.isArray(registry?.entries) ? registry.entries.filter((entry) => Boolean(asRecord(entry))) : [];
190
+ const registered = entries.map(asSchemaRegistryEntry).find((entry) => entry?.name === options.schemaName);
191
+ const historical = !registered ? historicalSchemaRegistry[binding.engine.integrity_checksum]?.find((entry) => entry.name === options.schemaName) : undefined;
192
+ const profile = registered ?? historical;
193
+ if (registry && !registered) {
194
+ throw new AppError("schema_not_found", `Schema not registered for RUN-bound engine: ${options.schemaName}`, 2, { run_id: runId, engine: binding.engine });
195
+ }
196
+ if (!profile) {
197
+ throw new AppError("schema_not_found", `Schema not found for RUN-bound engine: ${options.schemaName}`, 2, { run_id: runId, engine: binding.engine });
198
+ }
199
+ const relativePath = profile.path;
200
+ const schemaPath = historical
201
+ ? path.join(bundledSchemaDir(), path.basename(relativePath))
202
+ : resolveInside(binding.engine.snapshot_root, relativePath);
203
+ if (!schemaPath || !fs.existsSync(schemaPath)) {
204
+ throw new AppError("schema_not_found", `Schema not found for RUN-bound engine: ${options.schemaName}`, 2, { run_id: runId, engine: binding.engine, path: relativePath });
205
+ }
206
+ if (checksum(schemaPath) !== profile.checksum) {
207
+ throw new AppError("schema_registry_invalid", "RUN-bound engine schema does not match its registry checksum", 1, { run_id: runId, schema: options.schemaName, path: schemaPath });
208
+ }
209
+ const schema = readJson(schemaPath, "schema");
210
+ const id = objectValue(asRecord(schema) ?? {}, "$id");
211
+ if (id !== profile.id) {
212
+ throw new AppError("schema_registry_invalid", "RUN-bound engine schema does not match its registry id", 1, { run_id: runId, schema: options.schemaName, path: schemaPath });
213
+ }
214
+ return {
215
+ name: options.schemaName,
216
+ id: typeof id === "string" ? id : options.schemaName,
217
+ path: schemaPath,
218
+ source: historical ? "engine_legacy_registry" : "run_bound_engine",
219
+ engine: { package_version: binding.engine.package_version, engine_version: binding.engine.engine_version, integrity_checksum: binding.engine.integrity_checksum, snapshot_root: binding.engine.snapshot_root }
220
+ };
221
+ }
222
+ function asSchemaRegistryEntry(value) {
223
+ const name = objectValue(value, "name");
224
+ const id = objectValue(value, "id");
225
+ const schemaPath = objectValue(value, "path");
226
+ const entryChecksum = objectValue(value, "checksum");
227
+ return typeof name === "string" && typeof id === "string" && typeof schemaPath === "string" && typeof entryChecksum === "string"
228
+ ? { name, id, path: schemaPath, checksum: entryChecksum }
229
+ : null;
230
+ }
231
+ function resolveInside(root, relativePath) {
232
+ if (path.isAbsolute(relativePath))
233
+ return null;
234
+ const candidate = path.resolve(root, relativePath);
235
+ return isInside(root, candidate) ? candidate : null;
236
+ }
237
+ function isInside(root, candidate) {
238
+ const relative = path.relative(path.resolve(root), path.resolve(candidate));
239
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
240
+ }
241
+ function checksum(file) {
242
+ return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
243
+ }
148
244
  function bundledSchemaDir() {
149
245
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "schemas");
150
246
  }