@deksden-com/dd-flow-cli 0.2.0 → 0.3.1
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 +43 -0
- package/README.md +76 -5
- package/dist/build-info.json +10 -6
- package/dist/cli/help.js +165 -20
- package/dist/cli/run-cli.js +427 -17
- package/dist/schemas/compatibility.schema.json +105 -0
- package/dist/schemas/engine-manifest.schema.json +61 -0
- package/dist/schemas/flow-guidance.schema.json +90 -0
- package/dist/schemas/flow-run-index.schema.json +30 -4
- package/dist/schemas/global-dashboard-data.schema.json +126 -0
- package/dist/schemas/mb-sdlc-review-report.schema.json +242 -0
- package/dist/schemas/mb-upgrade-migration-report.schema.json +93 -0
- package/dist/schemas/plan-stage-report.schema.json +83 -0
- package/dist/schemas/project-dashboard-data.schema.json +122 -0
- package/dist/schemas/project-flow-pack-manifest.schema.json +5 -1
- package/dist/schemas/project-summary.schema.json +73 -0
- package/dist/schemas/protocol-dashboard-data.schema.json +112 -0
- package/dist/schemas/status-report.schema.json +38 -2
- package/dist/schemas/version-report.schema.json +22 -0
- package/dist/services/build-info.js +26 -3
- package/dist/services/canon.js +93 -22
- package/dist/services/cleanup.js +45 -1
- package/dist/services/cli-operation-classifier.js +104 -0
- package/dist/services/compatibility-preflight.js +124 -0
- package/dist/services/config.js +31 -0
- package/dist/services/dashboard-targets.js +95 -0
- package/dist/services/dashboard.js +972 -10
- package/dist/services/engines.js +532 -0
- package/dist/services/flow-guidance.js +221 -0
- package/dist/services/hooks.js +1 -1
- package/dist/services/ids.js +106 -0
- package/dist/services/lanes.js +333 -1
- package/dist/services/merge-queue.js +106 -16
- package/dist/services/merge-worker.js +67 -4
- package/dist/services/migrations.js +231 -0
- package/dist/services/project-summary.js +122 -0
- package/dist/services/projects.js +44 -3
- package/dist/services/protocol-lifecycle.js +144 -0
- package/dist/services/protocols.js +660 -7
- package/dist/services/runs.js +98 -21
- package/dist/services/schema-validation.js +84 -4
- package/dist/services/sessions.js +21 -4
- package/dist/services/status.js +199 -1
- package/dist/services/version-status.js +59 -9
- package/dist/storage/database.js +31 -0
- package/dist/storage/paths.js +33 -0
- package/package.json +3 -2
package/dist/services/runs.js
CHANGED
|
@@ -2,12 +2,16 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { formatFullId, isFullEntityId, isShortEntityId, parseFullEntityId } from "../domain/entity-ids.js";
|
|
4
4
|
import { AppError } from "../shared/errors.js";
|
|
5
|
-
import { ensureDir,
|
|
5
|
+
import { ensureDir, projectRunHome, projectRunIndexPath, projectRunJsonPath, resolveProjectRoot } from "../storage/paths.js";
|
|
6
6
|
import { appendAudit } from "./audit.js";
|
|
7
7
|
import { registerProject, requireProjectByRoot } from "./projects.js";
|
|
8
|
-
|
|
8
|
+
import { buildRunFlowGuidance } from "./flow-guidance.js";
|
|
9
|
+
import { readProtocolRuntimeState, requireProtocol } from "./protocols.js";
|
|
10
|
+
const runSchemaId = "dd-flow/flow-run-index@2";
|
|
11
|
+
const legacyRunSchemaId = "dd-flow/flow-run-index@1";
|
|
9
12
|
const runIdType = "RUN";
|
|
10
13
|
const allowedRunFlowKinds = [
|
|
14
|
+
"mb_sdlc",
|
|
11
15
|
"coding",
|
|
12
16
|
"experiment",
|
|
13
17
|
"mb-init",
|
|
@@ -15,6 +19,8 @@ const allowedRunFlowKinds = [
|
|
|
15
19
|
"mb-audit",
|
|
16
20
|
"mb-distill",
|
|
17
21
|
"mb-upgrade-review",
|
|
22
|
+
"mb-sdlc-review",
|
|
23
|
+
"review",
|
|
18
24
|
"custom"
|
|
19
25
|
];
|
|
20
26
|
export function startFlowRun(context, input) {
|
|
@@ -27,10 +33,10 @@ export function startFlowRun(context, input) {
|
|
|
27
33
|
const runId = nextRunId(context, slug);
|
|
28
34
|
const { shortId } = parseFullEntityId(runId);
|
|
29
35
|
const now = context.now();
|
|
30
|
-
const
|
|
31
|
-
const runIndexAbsolute =
|
|
32
|
-
const runDirRelative = path.
|
|
33
|
-
const runtimePath =
|
|
36
|
+
const runHome = projectRunHome(context.ddFlowHome, project.id, runId);
|
|
37
|
+
const runIndexAbsolute = projectRunIndexPath(context.ddFlowHome, project.id, runId);
|
|
38
|
+
const runDirRelative = path.posix.join("runs", runId);
|
|
39
|
+
const runtimePath = projectRunJsonPath(context.ddFlowHome, project.id, runId);
|
|
34
40
|
const index = {
|
|
35
41
|
schema_id: runSchemaId,
|
|
36
42
|
run_id: runId,
|
|
@@ -48,6 +54,18 @@ export function startFlowRun(context, input) {
|
|
|
48
54
|
root: workspaceRoot,
|
|
49
55
|
run_dir: runDirRelative
|
|
50
56
|
},
|
|
57
|
+
run_home: {
|
|
58
|
+
storage: "dd_flow_home",
|
|
59
|
+
path: runHome,
|
|
60
|
+
relative_path: runDirRelative
|
|
61
|
+
},
|
|
62
|
+
execution: {
|
|
63
|
+
project_root: project.root,
|
|
64
|
+
workspace_root: workspaceRoot
|
|
65
|
+
},
|
|
66
|
+
legacy: {
|
|
67
|
+
project_tasks_run_dir: null
|
|
68
|
+
},
|
|
51
69
|
stage_runs: [],
|
|
52
70
|
sessions: [],
|
|
53
71
|
artifacts: [],
|
|
@@ -58,13 +76,14 @@ export function startFlowRun(context, input) {
|
|
|
58
76
|
updated_at: now
|
|
59
77
|
};
|
|
60
78
|
ensureDir(path.dirname(runtimePath));
|
|
61
|
-
ensureDir(
|
|
79
|
+
ensureDir(runHome);
|
|
62
80
|
writeJsonFile(runtimePath, index);
|
|
63
81
|
writeJsonFile(runIndexAbsolute, index);
|
|
64
82
|
context.db.run(`INSERT INTO flow_runs
|
|
65
83
|
(id, short_id, slug, project_id, project_root, workspace_root, flow_kind, subject_type, subject_id,
|
|
66
|
-
status, verdict, next_action, runtime_path, run_dir, run_index_path,
|
|
67
|
-
|
|
84
|
+
status, verdict, next_action, runtime_path, run_dir, run_index_path, run_home_path, layout_version, artifact_root_kind,
|
|
85
|
+
index_json, created_at, updated_at, completed_at)
|
|
86
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)`, [
|
|
68
87
|
runId,
|
|
69
88
|
shortId,
|
|
70
89
|
slug,
|
|
@@ -78,8 +97,11 @@ export function startFlowRun(context, input) {
|
|
|
78
97
|
index.verdict,
|
|
79
98
|
index.next_action,
|
|
80
99
|
runtimePath,
|
|
81
|
-
|
|
100
|
+
runHome,
|
|
82
101
|
runIndexAbsolute,
|
|
102
|
+
runHome,
|
|
103
|
+
"home_run_v2",
|
|
104
|
+
"dd_flow_home",
|
|
83
105
|
JSON.stringify(index),
|
|
84
106
|
now,
|
|
85
107
|
now
|
|
@@ -87,14 +109,15 @@ export function startFlowRun(context, input) {
|
|
|
87
109
|
appendAudit(context, {
|
|
88
110
|
projectId: project.id,
|
|
89
111
|
eventType: "flow_run.started",
|
|
90
|
-
payload: { run_id: runId, flow_kind: flowKind, subject: index.subject, workspace_root: workspaceRoot }
|
|
112
|
+
payload: { run_id: runId, flow_kind: flowKind, subject: index.subject, workspace_root: workspaceRoot, run_home: runHome }
|
|
91
113
|
});
|
|
92
114
|
return { ok: true, run: flowRunSummary(requireRunById(context, project.id, runId)), index };
|
|
93
115
|
}
|
|
94
116
|
export function getFlowRunStatus(context, input) {
|
|
95
117
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
96
118
|
const run = resolveRun(context, project.id, input.runId);
|
|
97
|
-
|
|
119
|
+
const index = parseRunIndex(run.index_json);
|
|
120
|
+
return { ok: true, run: flowRunSummary(run), index, flow_guidance: guidanceForRun(context, run, index) };
|
|
98
121
|
}
|
|
99
122
|
export function listFlowRuns(context, input) {
|
|
100
123
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
@@ -114,6 +137,9 @@ export function attachFlowRunStage(context, input) {
|
|
|
114
137
|
const dir = requiredStageDir(input.dir);
|
|
115
138
|
const status = parseStageStatus(input.status);
|
|
116
139
|
const existing = index.stage_runs.find((item) => item.stage === stage);
|
|
140
|
+
if (status === "running" && existing) {
|
|
141
|
+
archiveExistingStageAttempt(runArtifactRoot(run), dir);
|
|
142
|
+
}
|
|
117
143
|
const stageRun = {
|
|
118
144
|
...(existing ?? { order: index.stage_runs.length + 1 }),
|
|
119
145
|
stage,
|
|
@@ -130,7 +156,8 @@ export function attachFlowRunStage(context, input) {
|
|
|
130
156
|
eventType: "flow_run.stage_attached",
|
|
131
157
|
payload: { run_id: run.id, stage, dir, status }
|
|
132
158
|
});
|
|
133
|
-
|
|
159
|
+
const updatedRun = requireRunById(context, project.id, run.id);
|
|
160
|
+
return { ok: true, run: flowRunSummary(updatedRun), stage_run: stageRun, index, flow_guidance: guidanceForRun(context, updatedRun, index) };
|
|
134
161
|
}
|
|
135
162
|
export function completeFlowRunStage(context, input) {
|
|
136
163
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
@@ -161,7 +188,8 @@ export function completeFlowRunStage(context, input) {
|
|
|
161
188
|
eventType: "flow_run.stage_completed",
|
|
162
189
|
payload: { run_id: run.id, stage, status, stage_report: input.stageReport ?? null, data: input.data ?? null }
|
|
163
190
|
});
|
|
164
|
-
|
|
191
|
+
const updatedRun = requireRunById(context, project.id, run.id);
|
|
192
|
+
return { ok: true, run: flowRunSummary(updatedRun), stage_run: stageRun, index, flow_guidance: guidanceForRun(context, updatedRun, index) };
|
|
165
193
|
}
|
|
166
194
|
export function completeFlowRun(context, input) {
|
|
167
195
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
@@ -182,11 +210,32 @@ export function completeFlowRun(context, input) {
|
|
|
182
210
|
eventType: "flow_run.completed",
|
|
183
211
|
payload: { run_id: run.id, status, verdict: index.verdict, next_action: index.next_action }
|
|
184
212
|
});
|
|
185
|
-
|
|
213
|
+
const updatedRun = requireRunById(context, project.id, run.id);
|
|
214
|
+
return { ok: true, run: flowRunSummary(updatedRun), index, flow_guidance: guidanceForRun(context, updatedRun, index) };
|
|
215
|
+
}
|
|
216
|
+
function guidanceForRun(context, run, index) {
|
|
217
|
+
if (run.subject_type === "protocol") {
|
|
218
|
+
try {
|
|
219
|
+
const protocol = requireProtocol(context, run.subject_id);
|
|
220
|
+
const state = readProtocolRuntimeState(context, protocol).state;
|
|
221
|
+
return buildRunFlowGuidance({
|
|
222
|
+
stageRuns: index.stage_runs,
|
|
223
|
+
protocolStage: state.stage,
|
|
224
|
+
...(state.flow_contract ? { contract: state.flow_contract } : {}),
|
|
225
|
+
runId: run.id,
|
|
226
|
+
runDir: index.run_home?.relative_path ?? index.workspace.run_dir
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
if (!(error instanceof AppError && error.code === "not_found"))
|
|
231
|
+
throw error;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return buildRunFlowGuidance({ stageRuns: index.stage_runs, runId: run.id, runDir: index.run_home?.relative_path ?? index.workspace.run_dir });
|
|
186
235
|
}
|
|
187
236
|
function persistRunIndex(context, project, run, index) {
|
|
188
|
-
const runtimePath =
|
|
189
|
-
const runIndexPath =
|
|
237
|
+
const runtimePath = run.runtime_path || projectRunJsonPath(context.ddFlowHome, project.id, run.id);
|
|
238
|
+
const runIndexPath = run.run_index_path || projectRunIndexPath(context.ddFlowHome, project.id, run.id);
|
|
190
239
|
ensureDir(path.dirname(runtimePath));
|
|
191
240
|
ensureDir(path.dirname(runIndexPath));
|
|
192
241
|
writeJsonFile(runtimePath, index);
|
|
@@ -281,6 +330,9 @@ function flowRunSummary(run) {
|
|
|
281
330
|
runtime_path: run.runtime_path,
|
|
282
331
|
run_dir: run.run_dir,
|
|
283
332
|
run_index_path: run.run_index_path,
|
|
333
|
+
run_home_path: run.run_home_path ?? null,
|
|
334
|
+
layout_version: run.layout_version ?? null,
|
|
335
|
+
artifact_root_kind: run.artifact_root_kind ?? null,
|
|
284
336
|
created_at: run.created_at,
|
|
285
337
|
updated_at: run.updated_at,
|
|
286
338
|
completed_at: run.completed_at
|
|
@@ -288,19 +340,20 @@ function flowRunSummary(run) {
|
|
|
288
340
|
}
|
|
289
341
|
function parseRunIndex(text) {
|
|
290
342
|
const index = JSON.parse(text);
|
|
291
|
-
if (index.schema_id !== runSchemaId) {
|
|
343
|
+
if (index.schema_id !== runSchemaId && index.schema_id !== legacyRunSchemaId) {
|
|
292
344
|
throw new AppError("validation", `Invalid run index schema: ${String(index.schema_id)}`, 2);
|
|
293
345
|
}
|
|
294
346
|
return index;
|
|
295
347
|
}
|
|
296
348
|
function parseRunFlowKind(value) {
|
|
297
|
-
|
|
349
|
+
const normalized = value === "mb-sdlc" ? "mb_sdlc" : value;
|
|
350
|
+
if (!allowedRunFlowKinds.includes(normalized)) {
|
|
298
351
|
throw new AppError("validation", "flow-kind is not supported for run start", 2, {
|
|
299
352
|
flow_kind: value,
|
|
300
353
|
allowed: allowedRunFlowKinds
|
|
301
354
|
});
|
|
302
355
|
}
|
|
303
|
-
return
|
|
356
|
+
return normalized;
|
|
304
357
|
}
|
|
305
358
|
function parseRunStatus(value) {
|
|
306
359
|
if (!["running", "done", "blocked", "cancelled", "failed"].includes(value)) {
|
|
@@ -354,6 +407,30 @@ function resolveWorkspaceRoot(workspaceRoot) {
|
|
|
354
407
|
}
|
|
355
408
|
return fs.realpathSync(absolute);
|
|
356
409
|
}
|
|
410
|
+
function archiveExistingStageAttempt(runRoot, stageDir) {
|
|
411
|
+
const currentStageDir = path.join(runRoot, stageDir);
|
|
412
|
+
if (!fs.existsSync(currentStageDir) || !fs.statSync(currentStageDir).isDirectory()) {
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const entries = fs.readdirSync(currentStageDir).filter((entry) => !/^try-\d{3}$/.test(entry));
|
|
416
|
+
if (entries.length === 0) {
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
let attempt = 1;
|
|
420
|
+
while (fs.existsSync(path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`))) {
|
|
421
|
+
attempt += 1;
|
|
422
|
+
}
|
|
423
|
+
const archiveDir = path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`);
|
|
424
|
+
ensureDir(archiveDir);
|
|
425
|
+
for (const entry of entries) {
|
|
426
|
+
fs.renameSync(path.join(currentStageDir, entry), path.join(archiveDir, entry));
|
|
427
|
+
}
|
|
428
|
+
}
|
|
357
429
|
function writeJsonFile(file, value) {
|
|
358
|
-
|
|
430
|
+
const tmpFile = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
431
|
+
fs.writeFileSync(tmpFile, `${JSON.stringify(value, null, 2)}\n`);
|
|
432
|
+
fs.renameSync(tmpFile, file);
|
|
433
|
+
}
|
|
434
|
+
function runArtifactRoot(run) {
|
|
435
|
+
return path.dirname(run.run_index_path);
|
|
359
436
|
}
|
|
@@ -16,6 +16,20 @@ const mandatoryMbUpgradeReviewAspectIds = [
|
|
|
16
16
|
"09-report-quality",
|
|
17
17
|
"10-agent-process-quality"
|
|
18
18
|
];
|
|
19
|
+
const mandatoryMbSdlcReviewAspectIds = [
|
|
20
|
+
"structure_navigation_review",
|
|
21
|
+
"feature_epic_layer_review",
|
|
22
|
+
"spec_conformance_review",
|
|
23
|
+
"architecture_harmonization_review",
|
|
24
|
+
"adr_decision_review",
|
|
25
|
+
"scenario_evidence_review",
|
|
26
|
+
"contract_traceability_review",
|
|
27
|
+
"frontmatter_crosslink_review",
|
|
28
|
+
"engineering_standards_review",
|
|
29
|
+
"operations_policy_review",
|
|
30
|
+
"protocol_delivery_trace_review",
|
|
31
|
+
"def_followup_review"
|
|
32
|
+
];
|
|
19
33
|
export function validateSchema(options) {
|
|
20
34
|
if (!schemaNamePattern.test(options.schemaName)) {
|
|
21
35
|
throw new AppError("usage", "--schema must be a schema name such as mb-upgrade-review-data", 2);
|
|
@@ -97,14 +111,17 @@ function formatAjvError(error) {
|
|
|
97
111
|
};
|
|
98
112
|
}
|
|
99
113
|
function validateSemanticSchema(schemaName, data) {
|
|
100
|
-
if (schemaName !== "mb-upgrade-review-data") {
|
|
101
|
-
return [];
|
|
102
|
-
}
|
|
103
114
|
const root = asRecord(data);
|
|
104
115
|
if (!root) {
|
|
105
116
|
return [];
|
|
106
117
|
}
|
|
107
|
-
|
|
118
|
+
if (schemaName === "mb-upgrade-review-data") {
|
|
119
|
+
return validateMbUpgradeReviewData(root);
|
|
120
|
+
}
|
|
121
|
+
if (schemaName === "mb-sdlc-review-report") {
|
|
122
|
+
return validateMbSdlcReviewReport(root);
|
|
123
|
+
}
|
|
124
|
+
return [];
|
|
108
125
|
}
|
|
109
126
|
function validateMbUpgradeReviewData(root) {
|
|
110
127
|
const errors = [];
|
|
@@ -159,6 +176,69 @@ function validateMbUpgradeReviewData(root) {
|
|
|
159
176
|
});
|
|
160
177
|
return errors;
|
|
161
178
|
}
|
|
179
|
+
function validateMbSdlcReviewReport(root) {
|
|
180
|
+
const errors = [];
|
|
181
|
+
const aspects = arrayValue(root, "aspect_coverage").filter(isRecord);
|
|
182
|
+
const findings = arrayValue(root, "findings_register").filter(isRecord);
|
|
183
|
+
const conformance = recordValue(root, "conformance_summary");
|
|
184
|
+
const verdict = conformance ? stringValue(conformance, "overall_verdict") : undefined;
|
|
185
|
+
const aspectIds = aspects.map((aspect) => stringValue(aspect, "aspect_id")).filter(isString);
|
|
186
|
+
for (const id of mandatoryMbSdlcReviewAspectIds) {
|
|
187
|
+
const count = aspectIds.filter((value) => value === id).length;
|
|
188
|
+
if (count !== 1) {
|
|
189
|
+
errors.push({
|
|
190
|
+
path: "/aspect_coverage",
|
|
191
|
+
message: `mandatory aspect ${id} must be present exactly once`,
|
|
192
|
+
keyword: "dd-flow/aspect-id"
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const findingIds = new Set(findings.map((finding) => stringValue(finding, "id")).filter(isString));
|
|
197
|
+
aspects.forEach((aspect, aspectIndex) => {
|
|
198
|
+
const refs = arrayValue(aspect, "findings").filter(isString);
|
|
199
|
+
refs.forEach((ref, refIndex) => {
|
|
200
|
+
if (!findingIds.has(ref)) {
|
|
201
|
+
errors.push({
|
|
202
|
+
path: `/aspect_coverage/${aspectIndex}/findings/${refIndex}`,
|
|
203
|
+
message: `finding reference does not exist: ${ref}`,
|
|
204
|
+
keyword: "dd-flow/finding-ref"
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
const decisions = recordValue(root, "critic_pass")
|
|
210
|
+
? arrayValue(recordValue(root, "critic_pass"), "decisions").filter(isRecord)
|
|
211
|
+
: [];
|
|
212
|
+
decisions.forEach((decision, decisionIndex) => {
|
|
213
|
+
const findingId = stringValue(decision, "finding_id");
|
|
214
|
+
const disposition = stringValue(decision, "disposition");
|
|
215
|
+
if (findingId && !findingIds.has(findingId)) {
|
|
216
|
+
errors.push({
|
|
217
|
+
path: `/critic_pass/decisions/${decisionIndex}/finding_id`,
|
|
218
|
+
message: `critic decision finding_id does not exist: ${findingId}`,
|
|
219
|
+
keyword: "dd-flow/finding-ref"
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
if (disposition === "accepted" && !findingId) {
|
|
223
|
+
errors.push({
|
|
224
|
+
path: `/critic_pass/decisions/${decisionIndex}/finding_id`,
|
|
225
|
+
message: "accepted critic decision must reference a final finding_id",
|
|
226
|
+
keyword: "dd-flow/accepted-finding-ref"
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
findings.forEach((finding, findingIndex) => {
|
|
231
|
+
const severity = stringValue(finding, "severity");
|
|
232
|
+
if ((verdict === "accepted" || verdict === "accepted_with_findings") && severity === "blocking") {
|
|
233
|
+
errors.push({
|
|
234
|
+
path: `/findings_register/${findingIndex}/severity`,
|
|
235
|
+
message: "accepted review verdict cannot coexist with blocking findings",
|
|
236
|
+
keyword: "dd-flow/accepted-blocking"
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
return errors;
|
|
241
|
+
}
|
|
162
242
|
function asRecord(value) {
|
|
163
243
|
return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
164
244
|
}
|
|
@@ -5,6 +5,7 @@ import { AppError } from "../shared/errors.js";
|
|
|
5
5
|
import { parseJsonObject } from "../shared/json.js";
|
|
6
6
|
import { resolveProjectRoot } from "../storage/paths.js";
|
|
7
7
|
import { appendAudit } from "./audit.js";
|
|
8
|
+
import { cancelLaneWaitersForWorker } from "./lanes.js";
|
|
8
9
|
export function registerFlowSession(context, input) {
|
|
9
10
|
const payload = decodeFlowSessionPayload(input);
|
|
10
11
|
const project = requireProjectByRoot(context, resolveProjectRoot(payload.project_root));
|
|
@@ -44,16 +45,21 @@ export function stopMergeWorker(context, input) {
|
|
|
44
45
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
45
46
|
const sessions = flowSessionsForProject(context, project.id, { workerId: input.workerId }).filter((session) => session.flow_kind === "merge_worker" && ["active", "pending", "stopping"].includes(session.status));
|
|
46
47
|
let lock_release = { ok: true, released: false, reason: "no_active_session" };
|
|
48
|
+
let waiter_cancellation = { ok: true, cancelled: 0 };
|
|
47
49
|
for (const session of sessions) {
|
|
48
|
-
|
|
50
|
+
const stopped = markSessionStopped(context, project, session, input.reason);
|
|
51
|
+
if (isStopResult(stopped)) {
|
|
52
|
+
lock_release = stopped.lock_release ?? lock_release;
|
|
53
|
+
waiter_cancellation = stopped.waiter_cancellation ?? waiter_cancellation;
|
|
54
|
+
}
|
|
49
55
|
}
|
|
50
56
|
appendAudit(context, {
|
|
51
57
|
projectId: project.id,
|
|
52
58
|
eventType: "flow_session.merge_worker_stopped",
|
|
53
59
|
reason: input.reason,
|
|
54
|
-
payload: { project_id: project.id, worker_id: input.workerId, stopped_sessions: sessions.length, lock_release }
|
|
60
|
+
payload: { project_id: project.id, worker_id: input.workerId, stopped_sessions: sessions.length, lock_release, waiter_cancellation }
|
|
55
61
|
});
|
|
56
|
-
return { ok: true, stopped_sessions: sessions.length, lock_release };
|
|
62
|
+
return { ok: true, stopped_sessions: sessions.length, lock_release, waiter_cancellation };
|
|
57
63
|
}
|
|
58
64
|
export function stoppedMergeWorkerState(context, projectId, workerId) {
|
|
59
65
|
const latest = context.db.get(`SELECT status, stop_reason FROM flow_sessions
|
|
@@ -268,6 +274,14 @@ function markSessionStopped(context, project, session, reason) {
|
|
|
268
274
|
SET status = 'stopped', stop_reason = ?, updated_at = ?, stopped_at = ?
|
|
269
275
|
WHERE project_id = ? AND session_id = ?`, [reason, now, now, project.id, session.session_id]);
|
|
270
276
|
let lockRelease = undefined;
|
|
277
|
+
let waiterCancellation = undefined;
|
|
278
|
+
if (session.worker_id) {
|
|
279
|
+
waiterCancellation = cancelLaneWaitersForWorker(context, {
|
|
280
|
+
projectRoot: project.root,
|
|
281
|
+
workerId: session.worker_id,
|
|
282
|
+
reason
|
|
283
|
+
});
|
|
284
|
+
}
|
|
271
285
|
if ((session.flow_kind === "merge_worker" || session.flow_kind === "merge_job") && session.worker_id) {
|
|
272
286
|
lockRelease = releaseMergeLockIfOwned(context, project.root, session.worker_id, reason);
|
|
273
287
|
}
|
|
@@ -278,7 +292,10 @@ function markSessionStopped(context, project, session, reason) {
|
|
|
278
292
|
payload: { project_id: project.id, session_id: session.session_id, flow_kind: session.flow_kind },
|
|
279
293
|
...(session.protocol_id ? { protocolId: session.protocol_id } : {})
|
|
280
294
|
});
|
|
281
|
-
return lockRelease;
|
|
295
|
+
return { lock_release: lockRelease, waiter_cancellation: waiterCancellation };
|
|
296
|
+
}
|
|
297
|
+
function isStopResult(value) {
|
|
298
|
+
return Boolean(value && typeof value === "object" && ("lock_release" in value || "waiter_cancellation" in value));
|
|
282
299
|
}
|
|
283
300
|
function releaseMergeLockIfOwned(context, projectRoot, workerId, reason) {
|
|
284
301
|
const project = requireProjectByRoot(context, resolveProjectRoot(projectRoot));
|
package/dist/services/status.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
1
4
|
import { getCanonStatus } from "./canon.js";
|
|
2
5
|
import { getCliBuildInfo } from "./build-info.js";
|
|
3
6
|
import { getProjectVersionStatus, resolveStatusProjectRoot } from "./version-status.js";
|
|
4
7
|
import { findProjectByRoot } from "./projects.js";
|
|
8
|
+
import { classifyCliOperation } from "./cli-operation-classifier.js";
|
|
9
|
+
import { compatibilityReport } from "./compatibility-preflight.js";
|
|
10
|
+
import { selectEngine } from "./engines.js";
|
|
5
11
|
export function getRuntimeStatus(context, input = {}) {
|
|
6
12
|
const cwd = process.cwd();
|
|
7
13
|
const projectRootResolution = resolveStatusProjectRoot({ requestedRoot: input.projectRoot, cwd });
|
|
@@ -16,12 +22,20 @@ export function getRuntimeStatus(context, input = {}) {
|
|
|
16
22
|
canon: resolvedCanonForProject
|
|
17
23
|
});
|
|
18
24
|
const cli = getCliBuildInfo();
|
|
25
|
+
const compatibilityManifest = resolvedCanonForProject ? readCompatibilityManifest(canon) : null;
|
|
26
|
+
const cliCompatibility = cliCompatibilityVerdict(cli, compatibilityManifest);
|
|
27
|
+
const engineSelection = projectRoot ? selectEngine(context, { projectRoot }) : null;
|
|
28
|
+
const registry = input.checkRegistry ? checkNpmRegistry(context, cli.package_name) : undefined;
|
|
19
29
|
return {
|
|
20
30
|
ok: true,
|
|
21
31
|
schema_id: "dd-flow/status-report@1",
|
|
22
32
|
dd_flow_home: context.ddFlowHome,
|
|
23
33
|
cwd,
|
|
24
|
-
cli
|
|
34
|
+
cli: {
|
|
35
|
+
...cli,
|
|
36
|
+
compatibility: cliCompatibility,
|
|
37
|
+
...(registry ? { registry } : {})
|
|
38
|
+
},
|
|
25
39
|
project: {
|
|
26
40
|
requested_root: projectRootResolution.requested_root,
|
|
27
41
|
root: projectRoot,
|
|
@@ -33,6 +47,12 @@ export function getRuntimeStatus(context, input = {}) {
|
|
|
33
47
|
flow_pack: projectVersionStatus?.flow_pack ?? null,
|
|
34
48
|
drift: projectVersionStatus?.drift ?? null
|
|
35
49
|
},
|
|
50
|
+
engine: engineSelection
|
|
51
|
+
? {
|
|
52
|
+
selection: engineSelection,
|
|
53
|
+
compatibility: compatibilityReport(engineSelection, classifyCliOperation(["status"]))
|
|
54
|
+
}
|
|
55
|
+
: null,
|
|
36
56
|
canon: {
|
|
37
57
|
...(asRecord(canonStatus) ?? {}),
|
|
38
58
|
resolved: canon ?? null,
|
|
@@ -46,6 +66,184 @@ function asRecord(value) {
|
|
|
46
66
|
function stringValue(value) {
|
|
47
67
|
return typeof value === "string" && value.length > 0 ? value : null;
|
|
48
68
|
}
|
|
69
|
+
function readCompatibilityManifest(canon) {
|
|
70
|
+
const record = asRecord(canon);
|
|
71
|
+
const memorybankRoot = stringValue(record?.memorybank_root);
|
|
72
|
+
if (!memorybankRoot)
|
|
73
|
+
return null;
|
|
74
|
+
const file = path.join(memorybankRoot, "dd-flow", "compatibility.json");
|
|
75
|
+
try {
|
|
76
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
77
|
+
const root = asRecord(parsed);
|
|
78
|
+
const cli = asRecord(root?.dd_flow_cli);
|
|
79
|
+
if (!root || !cli)
|
|
80
|
+
return null;
|
|
81
|
+
return {
|
|
82
|
+
schema_id: stringValue(root.schema_id),
|
|
83
|
+
memory_bank_version: stringValue(root.memory_bank_version),
|
|
84
|
+
dd_flow_cli: {
|
|
85
|
+
package_name: stringValue(cli.package_name),
|
|
86
|
+
min_version: stringValue(cli.min_version),
|
|
87
|
+
recommended_version: stringValue(cli.recommended_version),
|
|
88
|
+
status_contract: stringValue(cli.status_contract),
|
|
89
|
+
version_contract: stringValue(cli.version_contract),
|
|
90
|
+
flow_contract: stringValue(cli.flow_contract)
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function cliCompatibilityVerdict(cli, manifest) {
|
|
99
|
+
if (!manifest) {
|
|
100
|
+
return {
|
|
101
|
+
verdict: "unknown",
|
|
102
|
+
reason: "compatibility_manifest_missing",
|
|
103
|
+
package_name: cli.package_name,
|
|
104
|
+
installed_version: cli.version,
|
|
105
|
+
update_command: null
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
const expectedPackage = manifest.dd_flow_cli.package_name;
|
|
109
|
+
if (expectedPackage && expectedPackage !== cli.package_name) {
|
|
110
|
+
return {
|
|
111
|
+
verdict: "incompatible",
|
|
112
|
+
reason: "package_name_mismatch",
|
|
113
|
+
package_name: cli.package_name,
|
|
114
|
+
expected_package_name: expectedPackage,
|
|
115
|
+
installed_version: cli.version,
|
|
116
|
+
memory_bank_version: manifest.memory_bank_version,
|
|
117
|
+
min_version: manifest.dd_flow_cli.min_version,
|
|
118
|
+
recommended_version: manifest.dd_flow_cli.recommended_version,
|
|
119
|
+
update_command: updateCommand(expectedPackage, manifest.dd_flow_cli.recommended_version ?? manifest.dd_flow_cli.min_version)
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
const minVersion = manifest.dd_flow_cli.min_version;
|
|
123
|
+
const recommendedVersion = manifest.dd_flow_cli.recommended_version;
|
|
124
|
+
const installed = parseSemver(cli.version);
|
|
125
|
+
const min = minVersion ? parseSemver(minVersion) : null;
|
|
126
|
+
const recommended = recommendedVersion ? parseSemver(recommendedVersion) : null;
|
|
127
|
+
if (!installed || (minVersion && !min) || (recommendedVersion && !recommended)) {
|
|
128
|
+
return {
|
|
129
|
+
verdict: "unknown",
|
|
130
|
+
reason: "invalid_semver",
|
|
131
|
+
package_name: cli.package_name,
|
|
132
|
+
installed_version: cli.version,
|
|
133
|
+
memory_bank_version: manifest.memory_bank_version,
|
|
134
|
+
min_version: minVersion,
|
|
135
|
+
recommended_version: recommendedVersion,
|
|
136
|
+
update_command: null
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
if (min && compareSemver(installed, min) < 0) {
|
|
140
|
+
return {
|
|
141
|
+
verdict: "incompatible",
|
|
142
|
+
reason: "installed_below_min_version",
|
|
143
|
+
package_name: cli.package_name,
|
|
144
|
+
installed_version: cli.version,
|
|
145
|
+
memory_bank_version: manifest.memory_bank_version,
|
|
146
|
+
min_version: minVersion,
|
|
147
|
+
recommended_version: recommendedVersion,
|
|
148
|
+
update_command: updateCommand(cli.package_name, recommendedVersion ?? minVersion)
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
if (recommended && compareSemver(installed, recommended) < 0) {
|
|
152
|
+
return {
|
|
153
|
+
verdict: "outdated",
|
|
154
|
+
reason: "installed_below_recommended_version",
|
|
155
|
+
package_name: cli.package_name,
|
|
156
|
+
installed_version: cli.version,
|
|
157
|
+
memory_bank_version: manifest.memory_bank_version,
|
|
158
|
+
min_version: minVersion,
|
|
159
|
+
recommended_version: recommendedVersion,
|
|
160
|
+
update_command: updateCommand(cli.package_name, recommendedVersion)
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
verdict: "ok",
|
|
165
|
+
reason: "installed_satisfies_memory_bank_compatibility",
|
|
166
|
+
package_name: cli.package_name,
|
|
167
|
+
installed_version: cli.version,
|
|
168
|
+
memory_bank_version: manifest.memory_bank_version,
|
|
169
|
+
min_version: minVersion,
|
|
170
|
+
recommended_version: recommendedVersion,
|
|
171
|
+
status_contract: manifest.dd_flow_cli.status_contract,
|
|
172
|
+
version_contract: manifest.dd_flow_cli.version_contract,
|
|
173
|
+
flow_contract: manifest.dd_flow_cli.flow_contract,
|
|
174
|
+
update_command: null
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
function updateCommand(packageName, version) {
|
|
178
|
+
return `pnpm add -g ${packageName}${version ? `@${version}` : "@latest"}`;
|
|
179
|
+
}
|
|
180
|
+
function checkNpmRegistry(context, packageName) {
|
|
181
|
+
const cachePath = path.join(context.ddFlowHome, "cache", "npm", `${safeCacheName(packageName)}.json`);
|
|
182
|
+
const cached = readRegistryCache(cachePath);
|
|
183
|
+
const now = new Date().toISOString();
|
|
184
|
+
if (cached && Date.now() - cached.checkedAtMs < 15 * 60 * 1000) {
|
|
185
|
+
return { ...cached.payload, source: "npm_cache" };
|
|
186
|
+
}
|
|
187
|
+
const result = spawnSync("npm", ["view", packageName, "version", "--json"], { encoding: "utf8", timeout: 5000 });
|
|
188
|
+
if (result.status !== 0) {
|
|
189
|
+
return {
|
|
190
|
+
package_name: packageName,
|
|
191
|
+
latest: null,
|
|
192
|
+
checked_at: now,
|
|
193
|
+
source: "npm",
|
|
194
|
+
status: "degraded",
|
|
195
|
+
reason: result.error ? String(result.error) : result.stderr.trim() || "npm_view_failed"
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
const latest = parseRegistryVersion(result.stdout);
|
|
199
|
+
const payload = {
|
|
200
|
+
package_name: packageName,
|
|
201
|
+
latest,
|
|
202
|
+
checked_at: now,
|
|
203
|
+
source: "npm",
|
|
204
|
+
status: latest ? "ok" : "degraded",
|
|
205
|
+
...(latest ? {} : { reason: "npm_view_returned_no_version" })
|
|
206
|
+
};
|
|
207
|
+
writeRegistryCache(cachePath, payload);
|
|
208
|
+
return payload;
|
|
209
|
+
}
|
|
210
|
+
function readRegistryCache(file) {
|
|
211
|
+
try {
|
|
212
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
213
|
+
const record = asRecord(parsed);
|
|
214
|
+
const payload = asRecord(record?.payload);
|
|
215
|
+
const checkedAt = stringValue(record?.checked_at);
|
|
216
|
+
if (!payload || !checkedAt)
|
|
217
|
+
return null;
|
|
218
|
+
const checkedAtMs = Date.parse(checkedAt);
|
|
219
|
+
return Number.isNaN(checkedAtMs) ? null : { checkedAtMs, payload };
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
function writeRegistryCache(file, payload) {
|
|
226
|
+
try {
|
|
227
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
228
|
+
fs.writeFileSync(file, `${JSON.stringify({ checked_at: payload.checked_at, payload }, null, 2)}\n`);
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
// Registry cache is best effort; status remains valid without it.
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function parseRegistryVersion(value) {
|
|
235
|
+
try {
|
|
236
|
+
const parsed = JSON.parse(value);
|
|
237
|
+
return typeof parsed === "string" && parsed.length > 0 ? parsed : null;
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
const trimmed = value.trim().replace(/^"|"$/g, "");
|
|
241
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
function safeCacheName(value) {
|
|
245
|
+
return value.replace(/[^a-zA-Z0-9_.-]+/g, "_");
|
|
246
|
+
}
|
|
49
247
|
function canonForProjectStatus(value) {
|
|
50
248
|
const record = asRecord(value);
|
|
51
249
|
if (!record || typeof record.root !== "string")
|