@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.
- package/CHANGELOG.md +12 -0
- package/README.md +10 -3
- package/dist/build-info.json +5 -5
- package/dist/cli/help.js +24 -11
- package/dist/cli/run-cli.js +117 -28
- package/dist/domain/flow-contract.js +15 -4
- package/dist/domain/session-coverage.js +88 -0
- package/dist/runtime/context.js +8 -2
- package/dist/schemas/code-stage-report.schema.json +7 -2
- package/dist/schemas/engine-manifest.schema.json +22 -0
- package/dist/schemas/flow-contract.schema.json +15 -13
- package/dist/schemas/flow-run.schema.json +1 -0
- package/dist/schemas/mb-upgrade-migration-report.schema.json +3 -1
- package/dist/schemas/merge-stage-report-legacy-0.4.2.schema.json +24 -0
- package/dist/schemas/run-engine-binding.schema.json +37 -0
- package/dist/schemas/stage-prompt.schema.json +9 -5
- package/dist/schemas/stage-start-response.schema.json +6 -5
- package/dist/services/canon.js +15 -1
- package/dist/services/cleanup.js +77 -0
- package/dist/services/cli-operation-classifier.js +52 -8
- package/dist/services/compatibility-preflight.js +1 -1
- package/dist/services/dashboard.js +2 -2
- package/dist/services/engines.js +408 -30
- package/dist/services/hooks.js +28 -15
- package/dist/services/lanes.js +0 -4
- package/dist/services/merge-queue.js +48 -0
- package/dist/services/merge-worker.js +3 -4
- package/dist/services/migrations.js +307 -44
- package/dist/services/plan-runtime.js +4 -4
- package/dist/services/plans.js +5 -3
- package/dist/services/protocols.js +23 -2
- package/dist/services/run-engine-bindings.js +157 -0
- package/dist/services/run-projection.js +18 -4
- package/dist/services/runs.js +54 -7
- package/dist/services/schema-validation.js +96 -0
- package/dist/services/sessions.js +33 -73
- package/dist/services/stage-lifecycle.js +298 -45
- package/dist/services/status.js +8 -3
- package/dist/storage/database.js +32 -11
- 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,
|
package/dist/services/plans.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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])
|
|
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
|
-
|
|
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) {
|
package/dist/services/runs.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
158
|
-
const
|
|
159
|
-
|
|
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
|
}
|