@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
|
@@ -4,16 +4,26 @@ import { spawnSync } from "node:child_process";
|
|
|
4
4
|
import { isActiveProtocolStatus, isActiveQueueStatus, isQueueHistoryStatus } from "../domain/contracts.js";
|
|
5
5
|
import { AppError } from "../shared/errors.js";
|
|
6
6
|
import { ensureDir, resolveProjectRoot } from "../storage/paths.js";
|
|
7
|
-
import { dashboardMarkdownPath, globalDashboardMarkdownPath, readProjectConfig } from "./config.js";
|
|
7
|
+
import { dashboardMarkdownPath, globalDashboardHtmlPath, globalDashboardJsonPath, globalDashboardMarkdownPath, projectDashboardHtmlPath, projectDashboardJsonPath, projectSummaryJsonPath, protocolDashboardHtmlPath, protocolDashboardJsonPath, readProjectConfig } from "./config.js";
|
|
8
8
|
import { queueForProject } from "./merge-queue.js";
|
|
9
|
+
import { publishProjectSummary } from "./project-summary.js";
|
|
9
10
|
import { requireProjectByRoot } from "./projects.js";
|
|
11
|
+
import { protocolRunDiagnostics, protocolSetBoardForProtocol, protocolSetBoardsForProject, readProtocolRuntimeState, requireProtocol } from "./protocols.js";
|
|
10
12
|
import { activeFlowSessionsForProject } from "./sessions.js";
|
|
13
|
+
import { normalizeProtocolLifecycle } from "./protocol-lifecycle.js";
|
|
11
14
|
export function getCmuxStatus(context, input) {
|
|
12
15
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
13
16
|
return { ok: true, project_root: project.root, cmux: detectCmux(context) };
|
|
14
17
|
}
|
|
15
18
|
export function renderDashboard(context, input) {
|
|
16
19
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
20
|
+
const format = parseDashboardFormat(input.format);
|
|
21
|
+
if (format === "html") {
|
|
22
|
+
if (input.protocol) {
|
|
23
|
+
return renderProtocolDashboardHtml(context, project, input.protocol, input.output);
|
|
24
|
+
}
|
|
25
|
+
return renderProjectDashboardHtml(context, project, input.output);
|
|
26
|
+
}
|
|
17
27
|
const config = readProjectConfig(context, project.id);
|
|
18
28
|
const output = input.output
|
|
19
29
|
? path.resolve(path.isAbsolute(input.output) ? input.output : path.join(project.root, input.output))
|
|
@@ -23,6 +33,10 @@ export function renderDashboard(context, input) {
|
|
|
23
33
|
return { ok: true, project_root: project.root, dashboard: { path: output, bytes: Buffer.byteLength(markdown) } };
|
|
24
34
|
}
|
|
25
35
|
export function renderGlobalDashboard(context, input = {}) {
|
|
36
|
+
const format = parseDashboardFormat(input.format);
|
|
37
|
+
if (format === "html") {
|
|
38
|
+
return renderGlobalDashboardHtml(context, input.output);
|
|
39
|
+
}
|
|
26
40
|
const output = input.output
|
|
27
41
|
? path.resolve(input.output)
|
|
28
42
|
: globalDashboardMarkdownPath(context);
|
|
@@ -33,15 +47,54 @@ export function renderGlobalDashboard(context, input = {}) {
|
|
|
33
47
|
export function openDashboard(context, input) {
|
|
34
48
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
35
49
|
const config = readProjectConfig(context, project.id);
|
|
50
|
+
const format = parseDashboardFormat(input.format, "html");
|
|
51
|
+
if (input.viewer && input.viewer !== "cmux") {
|
|
52
|
+
throw new AppError("validation", "--viewer must be cmux", 2);
|
|
53
|
+
}
|
|
54
|
+
const dashboardPath = format === "html" ? projectDashboardHtmlPath(context, project.id) : dashboardMarkdownPath(project.root, config);
|
|
55
|
+
return {
|
|
56
|
+
ok: true,
|
|
57
|
+
project_root: project.root,
|
|
58
|
+
dashboard: { path: dashboardPath },
|
|
59
|
+
open: openCmuxDashboard(context, project.root, dashboardPath, format)
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export function openGlobalDashboard(context, input = {}) {
|
|
36
63
|
if (input.viewer && input.viewer !== "cmux") {
|
|
37
64
|
throw new AppError("validation", "--viewer must be cmux", 2);
|
|
38
65
|
}
|
|
39
|
-
const
|
|
40
|
-
|
|
66
|
+
const format = parseDashboardFormat(input.format, "html");
|
|
67
|
+
const rendered = renderGlobalDashboard(context, { format });
|
|
68
|
+
return {
|
|
69
|
+
ok: true,
|
|
70
|
+
dashboard: rendered.dashboard,
|
|
71
|
+
open: openDashboardPath(context, rendered.dashboard.path, format)
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
export function openProtocolDashboard(context, input) {
|
|
75
|
+
if (input.viewer && input.viewer !== "cmux") {
|
|
76
|
+
throw new AppError("validation", "--viewer must be cmux", 2);
|
|
77
|
+
}
|
|
78
|
+
const format = parseDashboardFormat(input.format, "html");
|
|
79
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
80
|
+
if (format !== "html") {
|
|
81
|
+
throw new AppError("validation", "Protocol dashboard open supports --format html", 2, {
|
|
82
|
+
protocol: input.protocol
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
const rendered = renderProtocolDashboardHtml(context, project, input.protocol);
|
|
86
|
+
return {
|
|
87
|
+
ok: true,
|
|
88
|
+
project_root: project.root,
|
|
89
|
+
protocol_id: input.protocol,
|
|
90
|
+
dashboard: rendered.dashboard,
|
|
91
|
+
open: openCmuxDashboard(context, project.root, rendered.dashboard.path, format)
|
|
92
|
+
};
|
|
41
93
|
}
|
|
42
94
|
export function refreshDashboard(context, input) {
|
|
43
95
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
44
96
|
const config = readProjectConfig(context, project.id);
|
|
97
|
+
const format = parseDashboardFormat(input.format);
|
|
45
98
|
const openMode = input.open ?? "auto";
|
|
46
99
|
if (!["auto", "true", "false"].includes(openMode)) {
|
|
47
100
|
throw new AppError("validation", "--open must be auto, true, or false", 2);
|
|
@@ -54,22 +107,65 @@ export function refreshDashboard(context, input) {
|
|
|
54
107
|
open: { ok: true, skipped: true, reason: "dashboard_project_disabled" }
|
|
55
108
|
};
|
|
56
109
|
}
|
|
57
|
-
const rendered = renderDashboard(context, { projectRoot: project.root });
|
|
110
|
+
const rendered = renderDashboard(context, { projectRoot: project.root, format, ...(input.protocol ? { protocol: input.protocol } : {}) });
|
|
111
|
+
const projectSummary = input.protocol ? null : publishProjectSummary(context, { project });
|
|
58
112
|
const global = config.dashboard.global
|
|
59
|
-
? renderGlobalDashboard(context, {
|
|
113
|
+
? renderGlobalDashboard(context, {
|
|
114
|
+
output: format === "html" ? globalDashboardHtmlPath(context) : globalDashboardMarkdownPath(context, config),
|
|
115
|
+
format
|
|
116
|
+
})
|
|
60
117
|
: { dashboard: { skipped: true, reason: "dashboard_global_disabled" } };
|
|
61
118
|
const shouldOpen = openMode === "true" || (openMode === "auto" && config.integrations.cmux.mode !== "off");
|
|
62
119
|
return {
|
|
63
120
|
ok: true,
|
|
64
121
|
project_root: project.root,
|
|
65
122
|
dashboard: rendered.dashboard,
|
|
123
|
+
project_summary: projectSummary,
|
|
66
124
|
global_dashboard: global.dashboard,
|
|
67
|
-
open: shouldOpen ? openCmuxDashboard(context, project.root, rendered.dashboard.path) : { ok: true, skipped: true, reason: "open_disabled" }
|
|
125
|
+
open: shouldOpen ? openCmuxDashboard(context, project.root, rendered.dashboard.path, format) : { ok: true, skipped: true, reason: "open_disabled" }
|
|
68
126
|
};
|
|
69
127
|
}
|
|
70
128
|
export function refreshGlobalDashboard(context, input = {}) {
|
|
71
129
|
return renderGlobalDashboard(context, input);
|
|
72
130
|
}
|
|
131
|
+
export function refreshAllDashboards(context, input = {}) {
|
|
132
|
+
const format = parseDashboardFormat(input.format, "html");
|
|
133
|
+
const global = renderGlobalDashboard(context, { format });
|
|
134
|
+
const projects = context.db.all("SELECT * FROM projects WHERE status = 'active' ORDER BY id ASC");
|
|
135
|
+
const results = projects.map((project) => {
|
|
136
|
+
if (!fs.existsSync(project.root)) {
|
|
137
|
+
return { ok: true, project_id: project.id, project_root: project.root, skipped: true, reason: "project_root_missing" };
|
|
138
|
+
}
|
|
139
|
+
const config = readProjectConfig(context, project.id);
|
|
140
|
+
if (!config.dashboard.project) {
|
|
141
|
+
return { ok: true, project_id: project.id, project_root: project.root, skipped: true, reason: "dashboard_project_disabled" };
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
const projectSummary = publishProjectSummary(context, { project });
|
|
145
|
+
const rendered = renderDashboard(context, { projectRoot: project.root, format });
|
|
146
|
+
return { ok: true, project_id: project.id, project_root: project.root, dashboard: rendered.dashboard, project_summary: projectSummary };
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
return {
|
|
150
|
+
ok: false,
|
|
151
|
+
project_id: project.id,
|
|
152
|
+
project_root: project.root,
|
|
153
|
+
error: error instanceof Error ? error.message : String(error)
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
return {
|
|
158
|
+
ok: true,
|
|
159
|
+
dashboard: global.dashboard,
|
|
160
|
+
projects: results,
|
|
161
|
+
summary: {
|
|
162
|
+
total: results.length,
|
|
163
|
+
refreshed: results.filter((item) => item.ok && !("skipped" in item)).length,
|
|
164
|
+
skipped: results.filter((item) => "skipped" in item).length,
|
|
165
|
+
failed: results.filter((item) => !item.ok).length
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
}
|
|
73
169
|
export function autoRefreshDashboards(context, input) {
|
|
74
170
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
75
171
|
const config = readProjectConfig(context, project.id);
|
|
@@ -78,6 +174,7 @@ export function autoRefreshDashboards(context, input) {
|
|
|
78
174
|
}
|
|
79
175
|
const result = { ok: true, project_root: project.root };
|
|
80
176
|
if (config.dashboard.project) {
|
|
177
|
+
publishProjectSummary(context, { project });
|
|
81
178
|
const output = dashboardMarkdownPath(project.root, config);
|
|
82
179
|
const markdown = renderProjectDashboardMarkdown(context, project, output);
|
|
83
180
|
writeMarkdown(output, markdown);
|
|
@@ -102,13 +199,27 @@ export function dashboardSummary(context, projectId, projectRoot) {
|
|
|
102
199
|
return {
|
|
103
200
|
project_markdown_path: dashboardMarkdownPath(projectRoot, config),
|
|
104
201
|
global_markdown_path: globalDashboardMarkdownPath(context, config),
|
|
202
|
+
project_html_path: projectDashboardHtmlPath(context, projectId),
|
|
203
|
+
project_json_path: projectDashboardJsonPath(context, projectId),
|
|
204
|
+
global_html_path: globalDashboardHtmlPath(context),
|
|
205
|
+
global_json_path: globalDashboardJsonPath(context),
|
|
105
206
|
auto_refresh: config.dashboard.auto_refresh,
|
|
106
207
|
project_enabled: config.dashboard.project,
|
|
107
208
|
global_enabled: config.dashboard.global,
|
|
108
209
|
cmux_mode: config.integrations.cmux.mode
|
|
109
210
|
};
|
|
110
211
|
}
|
|
111
|
-
function
|
|
212
|
+
export function dashboardData(context, input) {
|
|
213
|
+
if (input.global) {
|
|
214
|
+
return { ok: true, dashboard: buildGlobalDashboardData(context, globalDashboardHtmlPath(context)) };
|
|
215
|
+
}
|
|
216
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot ?? process.cwd()));
|
|
217
|
+
if (input.protocol) {
|
|
218
|
+
return { ok: true, dashboard: buildProtocolDashboardData(context, project, input.protocol, protocolDashboardHtmlPath(context, project.id, input.protocol)) };
|
|
219
|
+
}
|
|
220
|
+
return { ok: true, dashboard: buildProjectDashboardData(context, project, projectDashboardHtmlPath(context, project.id)) };
|
|
221
|
+
}
|
|
222
|
+
function openCmuxDashboard(context, projectRoot, dashboardPath, format = "markdown") {
|
|
112
223
|
const project = requireProjectByRoot(context, resolveProjectRoot(projectRoot));
|
|
113
224
|
const config = readProjectConfig(context, project.id);
|
|
114
225
|
if (config.integrations.cmux.mode === "off") {
|
|
@@ -122,9 +233,10 @@ function openCmuxDashboard(context, projectRoot, dashboardPath) {
|
|
|
122
233
|
return { ok: true, skipped: true, reason: "cmux_unavailable", cmux };
|
|
123
234
|
}
|
|
124
235
|
if (context.env.DD_FLOW_CMUX_DRY_RUN === "true") {
|
|
125
|
-
return { ok: true, opened: false, dry_run: true, command:
|
|
236
|
+
return { ok: true, opened: false, dry_run: true, command: cmuxOpenCommand(format, dashboardPath) };
|
|
126
237
|
}
|
|
127
|
-
const
|
|
238
|
+
const command = cmuxOpenCommand(format, dashboardPath);
|
|
239
|
+
const result = spawnSync(command[0], command.slice(1), { encoding: "utf8" });
|
|
128
240
|
if (result.status !== 0) {
|
|
129
241
|
if (config.integrations.cmux.mode === "required") {
|
|
130
242
|
throw new AppError("cmux_open_failed", "cmux markdown open failed", 1, {
|
|
@@ -134,7 +246,27 @@ function openCmuxDashboard(context, projectRoot, dashboardPath) {
|
|
|
134
246
|
}
|
|
135
247
|
return { ok: true, skipped: true, reason: "cmux_open_failed", status: result.status, stderr: result.stderr.trim() };
|
|
136
248
|
}
|
|
137
|
-
return { ok: true, opened: true, command
|
|
249
|
+
return { ok: true, opened: true, command };
|
|
250
|
+
}
|
|
251
|
+
function openDashboardPath(context, dashboardPath, format) {
|
|
252
|
+
const cmux = detectCmux(context);
|
|
253
|
+
if (!cmux.available) {
|
|
254
|
+
return { ok: true, skipped: true, reason: "cmux_unavailable", cmux };
|
|
255
|
+
}
|
|
256
|
+
if (context.env.DD_FLOW_CMUX_DRY_RUN === "true") {
|
|
257
|
+
return { ok: true, opened: false, dry_run: true, command: cmuxOpenCommand(format, dashboardPath) };
|
|
258
|
+
}
|
|
259
|
+
const command = cmuxOpenCommand(format, dashboardPath);
|
|
260
|
+
const result = spawnSync(command[0], command.slice(1), { encoding: "utf8" });
|
|
261
|
+
if (result.status !== 0) {
|
|
262
|
+
return { ok: true, skipped: true, reason: "cmux_open_failed", status: result.status, stderr: result.stderr.trim() };
|
|
263
|
+
}
|
|
264
|
+
return { ok: true, opened: true, command };
|
|
265
|
+
}
|
|
266
|
+
function cmuxOpenCommand(format, dashboardPath) {
|
|
267
|
+
return format === "html"
|
|
268
|
+
? ["cmux", "browser", "open", pathToFileUrl(dashboardPath)]
|
|
269
|
+
: ["cmux", "markdown", "open", dashboardPath];
|
|
138
270
|
}
|
|
139
271
|
function detectCmux(context) {
|
|
140
272
|
if (context.env.DD_FLOW_CMUX_AVAILABLE === "true") {
|
|
@@ -148,6 +280,436 @@ function detectCmux(context) {
|
|
|
148
280
|
? { available: true, source: "path", version: result.stdout.trim() }
|
|
149
281
|
: { available: false, source: "path", reason: result.error?.message ?? result.stderr.trim() ?? "cmux not found" };
|
|
150
282
|
}
|
|
283
|
+
function parseDashboardFormat(value, defaultFormat = "markdown") {
|
|
284
|
+
const format = value ?? defaultFormat;
|
|
285
|
+
if (format !== "markdown" && format !== "html") {
|
|
286
|
+
throw new AppError("validation", "--format must be markdown or html", 2, { format });
|
|
287
|
+
}
|
|
288
|
+
return format;
|
|
289
|
+
}
|
|
290
|
+
function renderProjectDashboardHtml(context, project, output) {
|
|
291
|
+
const htmlPath = output
|
|
292
|
+
? path.resolve(path.isAbsolute(output) ? output : path.join(project.root, output))
|
|
293
|
+
: projectDashboardHtmlPath(context, project.id);
|
|
294
|
+
const jsonPath = projectDashboardJsonPath(context, project.id);
|
|
295
|
+
const data = buildProjectDashboardData(context, project, htmlPath);
|
|
296
|
+
const html = renderDashboardHtmlPage(data);
|
|
297
|
+
writeJsonFile(jsonPath, data);
|
|
298
|
+
writeTextFile(htmlPath, html);
|
|
299
|
+
const protocolCards = Array.isArray(data.protocol_cards) ? data.protocol_cards : [];
|
|
300
|
+
const protocolPages = protocolCards
|
|
301
|
+
.filter((card) => card.generate_page !== false)
|
|
302
|
+
.slice(0, 24)
|
|
303
|
+
.map((card) => renderProtocolDashboardHtml(context, project, String(card.id)))
|
|
304
|
+
.map((result) => ({
|
|
305
|
+
id: String(result.protocol_id),
|
|
306
|
+
path: result.dashboard.path,
|
|
307
|
+
json_path: result.dashboard.json_path
|
|
308
|
+
}));
|
|
309
|
+
return {
|
|
310
|
+
ok: true,
|
|
311
|
+
project_root: project.root,
|
|
312
|
+
dashboard: {
|
|
313
|
+
path: htmlPath,
|
|
314
|
+
json_path: jsonPath,
|
|
315
|
+
bytes: Buffer.byteLength(html),
|
|
316
|
+
json_bytes: Buffer.byteLength(JSON.stringify(data))
|
|
317
|
+
},
|
|
318
|
+
protocol_pages: protocolPages
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
function renderProtocolDashboardHtml(context, project, protocolId, output) {
|
|
322
|
+
const htmlPath = output
|
|
323
|
+
? path.resolve(path.isAbsolute(output) ? output : path.join(project.root, output))
|
|
324
|
+
: protocolDashboardHtmlPath(context, project.id, protocolId);
|
|
325
|
+
const jsonPath = protocolDashboardJsonPath(context, project.id, protocolId);
|
|
326
|
+
const data = buildProtocolDashboardData(context, project, protocolId, htmlPath);
|
|
327
|
+
const html = renderDashboardHtmlPage(data);
|
|
328
|
+
writeJsonFile(jsonPath, data);
|
|
329
|
+
writeTextFile(htmlPath, html);
|
|
330
|
+
return {
|
|
331
|
+
ok: true,
|
|
332
|
+
project_root: project.root,
|
|
333
|
+
protocol_id: protocolId,
|
|
334
|
+
dashboard: {
|
|
335
|
+
path: htmlPath,
|
|
336
|
+
json_path: jsonPath,
|
|
337
|
+
bytes: Buffer.byteLength(html),
|
|
338
|
+
json_bytes: Buffer.byteLength(JSON.stringify(data))
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
function renderGlobalDashboardHtml(context, output) {
|
|
343
|
+
const htmlPath = output ? path.resolve(output) : globalDashboardHtmlPath(context);
|
|
344
|
+
const jsonPath = globalDashboardJsonPath(context);
|
|
345
|
+
const data = buildGlobalDashboardData(context, htmlPath);
|
|
346
|
+
const html = renderDashboardHtmlPage(data);
|
|
347
|
+
writeJsonFile(jsonPath, data);
|
|
348
|
+
writeTextFile(htmlPath, html);
|
|
349
|
+
return {
|
|
350
|
+
ok: true,
|
|
351
|
+
dashboard: {
|
|
352
|
+
path: htmlPath,
|
|
353
|
+
json_path: jsonPath,
|
|
354
|
+
bytes: Buffer.byteLength(html),
|
|
355
|
+
json_bytes: Buffer.byteLength(JSON.stringify(data))
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
function buildGlobalDashboardData(context, htmlPath) {
|
|
360
|
+
const projects = context.db.all("SELECT * FROM projects ORDER BY status ASC, updated_at DESC, root ASC");
|
|
361
|
+
const activeProjects = projects.filter((project) => project.status === "active");
|
|
362
|
+
const summaryPolicy = globalDashboardSummaryPolicy();
|
|
363
|
+
const classified = projects.map((project) => classifyGlobalProjectSummary(context, project, summaryPolicy));
|
|
364
|
+
const projectCards = classified.flatMap((entry) => (entry.card ? [entry.card] : []));
|
|
365
|
+
const unsupportedProjects = classified.flatMap((entry) => (entry.unsupported ? [entry.unsupported] : []));
|
|
366
|
+
const summaryVersionGroups = buildSummaryVersionGroups(projectCards, unsupportedProjects);
|
|
367
|
+
return {
|
|
368
|
+
schema_id: "dd-flow/global-dashboard-data@1",
|
|
369
|
+
generated_at: context.now(),
|
|
370
|
+
target_language: "ru",
|
|
371
|
+
page: { kind: "global", screen_id: "SCR-DD-FLOW-GLOBAL-DASHBOARD", title: "dd-flow global dashboard", html_path: htmlPath, json_path: globalDashboardJsonPath(context) },
|
|
372
|
+
links: {
|
|
373
|
+
self: link("Global dashboard", htmlPath, "available"),
|
|
374
|
+
markdown_fallback: link("Markdown fallback", globalDashboardMarkdownPath(context), "available")
|
|
375
|
+
},
|
|
376
|
+
metrics: [
|
|
377
|
+
metric("projects", projectCards.filter((card) => card.status === "active").length, "known", "compatible project summaries"),
|
|
378
|
+
metric("total_projects", activeProjects.length, "known", "projects.status=active"),
|
|
379
|
+
metric("unsupported_projects", unsupportedProjects.filter((project) => project.status === "active").length, "known", "project summaries"),
|
|
380
|
+
metric("active_protocols", projectCards.reduce((sum, card) => sum + Number(card.metrics.active_protocols), 0), "known", "protocols"),
|
|
381
|
+
metric("running_sessions", projectCards.reduce((sum, card) => sum + Number(card.metrics.sessions), 0), "known", "flow_sessions"),
|
|
382
|
+
metric("active_locks", projectCards.reduce((sum, card) => sum + Number(card.metrics.locks), 0), "known", "lane_locks"),
|
|
383
|
+
metric("open_defs", projectCards.reduce((sum, card) => sum + Number(card.metrics.open_defs), 0), "known", "protocols.active_def/blockers")
|
|
384
|
+
],
|
|
385
|
+
supported_summary_versions: summaryPolicy.supported.map((contract) => ({
|
|
386
|
+
schema_id: contract.schema_id,
|
|
387
|
+
schema_version: contract.schema_version,
|
|
388
|
+
label: contract.label,
|
|
389
|
+
status: "supported"
|
|
390
|
+
})),
|
|
391
|
+
summary_version_groups: summaryVersionGroups,
|
|
392
|
+
unsupported_projects: unsupportedProjects,
|
|
393
|
+
project_cards: projectCards,
|
|
394
|
+
warnings: [
|
|
395
|
+
...projectCards.filter((card) => !card.root_exists).map((card) => ({ code: "project_root_missing", project_id: card.id })),
|
|
396
|
+
...unsupportedProjects.map((project) => ({ code: project.reason, project_id: project.id, summary_path: project.summary_path }))
|
|
397
|
+
]
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
function globalDashboardSummaryPolicy() {
|
|
401
|
+
return {
|
|
402
|
+
supported: [{ schema_id: "dd-flow/project-summary@1", schema_version: null, label: "project-summary@1" }],
|
|
403
|
+
install_hint: "Run the project-compatible dd-flow engine and refresh the project summary."
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
function classifyGlobalProjectSummary(context, project, policy) {
|
|
407
|
+
const summaryPath = projectSummaryJsonPath(context, project.id);
|
|
408
|
+
const parsed = readSummaryFile(summaryPath);
|
|
409
|
+
if (!parsed.ok) {
|
|
410
|
+
return {
|
|
411
|
+
card: null,
|
|
412
|
+
unsupported: unsupportedProject(project, {
|
|
413
|
+
reason: parsed.reason,
|
|
414
|
+
detail: parsed.detail,
|
|
415
|
+
summary_path: summaryPath,
|
|
416
|
+
install_hint: policy.install_hint
|
|
417
|
+
})
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
const summary = parsed.summary;
|
|
421
|
+
const schemaId = stringValue(summary.schema_id);
|
|
422
|
+
const schemaVersion = stringValue(summary.schema_version);
|
|
423
|
+
const supported = policy.supported.some((contract) => contract.schema_id === schemaId && (!contract.schema_version || contract.schema_version === schemaVersion));
|
|
424
|
+
if (!supported) {
|
|
425
|
+
return {
|
|
426
|
+
card: null,
|
|
427
|
+
unsupported: unsupportedProject(project, {
|
|
428
|
+
reason: "unsupported_summary_version",
|
|
429
|
+
detail: `Unsupported project summary contract: ${schemaId ?? "unknown"} ${schemaVersion ?? ""}`.trim(),
|
|
430
|
+
summary_path: summaryPath,
|
|
431
|
+
summary
|
|
432
|
+
})
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
return { card: projectCardFromSummary(context, project, summary), unsupported: null };
|
|
436
|
+
}
|
|
437
|
+
function readSummaryFile(file) {
|
|
438
|
+
if (!fs.existsSync(file)) {
|
|
439
|
+
return { ok: false, reason: "missing_summary", detail: "Published project summary is missing." };
|
|
440
|
+
}
|
|
441
|
+
try {
|
|
442
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
443
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
444
|
+
return { ok: false, reason: "invalid_summary", detail: "Published project summary is not a JSON object." };
|
|
445
|
+
}
|
|
446
|
+
return { ok: true, summary: parsed };
|
|
447
|
+
}
|
|
448
|
+
catch (error) {
|
|
449
|
+
return { ok: false, reason: "unreadable_summary", detail: error instanceof Error ? error.message : "Failed to read project summary." };
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
function unsupportedProject(project, input) {
|
|
453
|
+
const summary = input.summary;
|
|
454
|
+
return {
|
|
455
|
+
id: project.id,
|
|
456
|
+
title: stringValue(summary?.name) ?? (path.basename(project.root) || project.id),
|
|
457
|
+
root: project.root,
|
|
458
|
+
short_root: compactPath(project.root),
|
|
459
|
+
status: project.status,
|
|
460
|
+
root_exists: fs.existsSync(project.root),
|
|
461
|
+
display_status: "unsupported",
|
|
462
|
+
action_level: "watch",
|
|
463
|
+
reason: input.reason,
|
|
464
|
+
detail: input.detail,
|
|
465
|
+
memorybank_version: summary?.memorybank_version ?? null,
|
|
466
|
+
summary_schema_id: stringValue(summary?.schema_id),
|
|
467
|
+
summary_schema_version: stringValue(summary?.schema_version),
|
|
468
|
+
summary_path: input.summary_path,
|
|
469
|
+
required_engine_range: summary?.required_engine_range ?? null,
|
|
470
|
+
install_hint: input.install_hint ?? summary?.install_hint ?? null,
|
|
471
|
+
updated_at: stringValue(summary?.last_activity_at) ?? project.updated_at
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
function buildSummaryVersionGroups(projectCards, unsupportedProjects) {
|
|
475
|
+
const groups = new Map();
|
|
476
|
+
const add = (entry, compatible) => {
|
|
477
|
+
const schemaId = stringValue(entry.summary_schema_id) ?? "missing";
|
|
478
|
+
const schemaVersion = stringValue(entry.summary_schema_version) ?? "unknown";
|
|
479
|
+
const key = `${schemaId}:${schemaVersion}:${compatible ? "compatible" : "unsupported"}`;
|
|
480
|
+
const existing = groups.get(key) ??
|
|
481
|
+
{
|
|
482
|
+
schema_id: schemaId,
|
|
483
|
+
schema_version: schemaVersion,
|
|
484
|
+
compatible,
|
|
485
|
+
count: 0,
|
|
486
|
+
project_ids: [],
|
|
487
|
+
project_names: []
|
|
488
|
+
};
|
|
489
|
+
existing.count = numberValue(existing.count) + 1;
|
|
490
|
+
existing.project_ids.push(String(entry.id));
|
|
491
|
+
existing.project_names.push(String(entry.title));
|
|
492
|
+
groups.set(key, existing);
|
|
493
|
+
};
|
|
494
|
+
projectCards.forEach((card) => add(card, true));
|
|
495
|
+
unsupportedProjects.forEach((project) => add(project, false));
|
|
496
|
+
return [...groups.values()].sort((a, b) => String(a.schema_id).localeCompare(String(b.schema_id)) || String(a.schema_version).localeCompare(String(b.schema_version)));
|
|
497
|
+
}
|
|
498
|
+
function projectCardFromSummary(context, project, summary) {
|
|
499
|
+
const metrics = recordValue(summary.resource_summary);
|
|
500
|
+
const protocolCounts = recordValue(summary.protocol_counts);
|
|
501
|
+
const rootExists = Boolean(summary.root_exists);
|
|
502
|
+
const openDefs = numberValue(metrics?.open_defs);
|
|
503
|
+
const locks = numberValue(metrics?.locks);
|
|
504
|
+
return {
|
|
505
|
+
id: project.id,
|
|
506
|
+
title: stringValue(summary.name) ?? (path.basename(project.root) || project.id),
|
|
507
|
+
root: project.root,
|
|
508
|
+
short_root: compactPath(project.root),
|
|
509
|
+
status: project.status,
|
|
510
|
+
root_exists: rootExists,
|
|
511
|
+
display_status: rootExists ? normalizeDisplayStatus(project.status) : "stale",
|
|
512
|
+
action_level: openDefs > 0 || locks > 0 ? "watch" : "none",
|
|
513
|
+
updated_at: stringValue(summary.last_activity_at) ?? project.updated_at,
|
|
514
|
+
href: projectDashboardHtmlPath(context, project.id),
|
|
515
|
+
summary_source: "project_summary",
|
|
516
|
+
summary_schema_id: stringValue(summary.schema_id),
|
|
517
|
+
summary_schema_version: stringValue(summary.schema_version),
|
|
518
|
+
summary_path: stringValue(summary.summary_path),
|
|
519
|
+
memorybank_version: summary.memorybank_version ?? null,
|
|
520
|
+
cli_version: summary.cli_version ?? null,
|
|
521
|
+
engine_version: summary.engine_version ?? null,
|
|
522
|
+
active_protocols: Array.isArray(summary.active_protocols) ? summary.active_protocols : [],
|
|
523
|
+
lifecycle_summary: {
|
|
524
|
+
total: numberValue(protocolCounts?.active),
|
|
525
|
+
by_lifecycle: []
|
|
526
|
+
},
|
|
527
|
+
resource_summary: {
|
|
528
|
+
queued_protocols: numberValue(metrics?.queue),
|
|
529
|
+
active_locks: locks,
|
|
530
|
+
queued_waiters: numberValue(metrics?.waiters)
|
|
531
|
+
},
|
|
532
|
+
metrics: {
|
|
533
|
+
active_protocols: numberValue(protocolCounts?.active),
|
|
534
|
+
queue: numberValue(metrics?.queue),
|
|
535
|
+
locks,
|
|
536
|
+
waiters: numberValue(metrics?.waiters),
|
|
537
|
+
sessions: numberValue(metrics?.sessions),
|
|
538
|
+
open_defs: openDefs
|
|
539
|
+
},
|
|
540
|
+
warnings: Array.isArray(summary.warnings) ? summary.warnings : []
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
function buildProjectDashboardData(context, project, htmlPath) {
|
|
544
|
+
const protocols = protocolsForProject(context, project.id);
|
|
545
|
+
const queue = queueForProject(context, project.id);
|
|
546
|
+
const sessions = activeFlowSessionsForProject(context, project.id);
|
|
547
|
+
const locks = context.db.all("SELECT lane, worker_id, status, expires_at FROM lane_locks WHERE project_id = ? ORDER BY updated_at DESC, id DESC LIMIT 20", [project.id]);
|
|
548
|
+
const waiters = context.db.all("SELECT id, lane, worker_id, status, queued_at, expires_at, reason, updated_at FROM lane_waiters WHERE project_id = ? ORDER BY updated_at DESC, id DESC LIMIT 20", [project.id]);
|
|
549
|
+
const activeProtocols = protocols.filter((protocol) => isActiveProtocolStatus(protocol.status));
|
|
550
|
+
const protocolCards = protocols.slice(0, 48).map((protocol, index) => buildProtocolCard(context, project, protocol, index < activeProtocols.length + 12));
|
|
551
|
+
const openDefs = protocols.reduce((count, protocol) => count + jsonArray(protocol.active_def_json).length + jsonArray(protocol.blockers_json).length, 0);
|
|
552
|
+
const protocolSetBoards = protocolSetBoardsForProject(context, project.id, project.root);
|
|
553
|
+
const reviewRuns = recentReviewRunsForProject(context, project.id, 8);
|
|
554
|
+
const queuedProtocols = queue.slice(0, 12).map(queueItemDashboard);
|
|
555
|
+
return {
|
|
556
|
+
schema_id: "dd-flow/project-dashboard-data@1",
|
|
557
|
+
generated_at: context.now(),
|
|
558
|
+
target_language: "ru",
|
|
559
|
+
page: { kind: "project", screen_id: "SCR-DD-FLOW-PROJECT-DASHBOARD", title: `${path.basename(project.root)} dashboard`, html_path: htmlPath, json_path: projectDashboardJsonPath(context, project.id) },
|
|
560
|
+
project: {
|
|
561
|
+
id: project.id,
|
|
562
|
+
title: path.basename(project.root) || project.id,
|
|
563
|
+
root: project.root,
|
|
564
|
+
status: project.status,
|
|
565
|
+
updated_at: project.updated_at
|
|
566
|
+
},
|
|
567
|
+
links: {
|
|
568
|
+
self: link("Project dashboard", htmlPath, "available"),
|
|
569
|
+
global_dashboard: link("Global dashboard", globalDashboardHtmlPath(context), "available"),
|
|
570
|
+
markdown_fallback: link("Markdown fallback", dashboardMarkdownPath(project.root, readProjectConfig(context, project.id)), "available")
|
|
571
|
+
},
|
|
572
|
+
metrics: [
|
|
573
|
+
metric("active_protocols", activeProtocols.length, "known", "protocols"),
|
|
574
|
+
metric("completed_protocols", protocols.filter((protocol) => !isActiveProtocolStatus(protocol.status)).length, "known", "protocols"),
|
|
575
|
+
metric("merge_queue", queue.filter((job) => isActiveQueueStatus(job.status)).length, "known", "merge_queue"),
|
|
576
|
+
metric("active_locks", locks.filter((lock) => lock.status === "active").length, "known", "lane_locks"),
|
|
577
|
+
metric("queued_waiters", waiters.filter((waiter) => waiter.status === "queued").length, "known", "lane_waiters"),
|
|
578
|
+
metric("open_defs", openDefs, "known", "protocols.active_def/blockers"),
|
|
579
|
+
metric("running_sessions", sessions.length, "known", "flow_sessions"),
|
|
580
|
+
metric("review_runs", reviewRuns.length, "known", "flow_runs.flow_kind=mb-sdlc-review|review")
|
|
581
|
+
],
|
|
582
|
+
lifecycle_summary: lifecycleSummary(activeProtocols),
|
|
583
|
+
resource_summary: resourceSummary(queue, locks, waiters),
|
|
584
|
+
protocol_cards: protocolCards,
|
|
585
|
+
protocol_set_boards: protocolSetBoards,
|
|
586
|
+
review_runs: reviewRuns,
|
|
587
|
+
queued_protocols: queuedProtocols,
|
|
588
|
+
merge_queue: queuedProtocols,
|
|
589
|
+
sessions: sessions.slice(0, 12).map((session) => ({
|
|
590
|
+
id: session.session_id,
|
|
591
|
+
flow_kind: session.flow_kind,
|
|
592
|
+
display_flow_kind: normalizeFlowKind(session.flow_kind),
|
|
593
|
+
status: session.status,
|
|
594
|
+
display_status: normalizeDisplayStatus(session.status),
|
|
595
|
+
worker_id: session.worker_id,
|
|
596
|
+
workspace_path: session.workspace_path,
|
|
597
|
+
current_stage: session.current_stage,
|
|
598
|
+
next_action: session.next_action,
|
|
599
|
+
updated_at: session.updated_at
|
|
600
|
+
})),
|
|
601
|
+
locks: locks.slice(0, 12).map((lock) => ({ lane: lock.lane, worker_id: lock.worker_id, status: lock.status, expires_at: lock.expires_at })),
|
|
602
|
+
waiters: waiters.slice(0, 12).map((waiter) => ({
|
|
603
|
+
id: waiter.id,
|
|
604
|
+
lane: waiter.lane,
|
|
605
|
+
worker_id: waiter.worker_id,
|
|
606
|
+
status: waiter.status,
|
|
607
|
+
queued_at: waiter.queued_at,
|
|
608
|
+
expires_at: waiter.expires_at,
|
|
609
|
+
reason: waiter.reason,
|
|
610
|
+
updated_at: waiter.updated_at
|
|
611
|
+
})),
|
|
612
|
+
warnings: fs.existsSync(project.root) ? [] : [{ code: "project_root_missing", path: project.root }]
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
function buildProtocolDashboardData(context, project, protocolId, htmlPath) {
|
|
616
|
+
const protocol = protocolsForProject(context, project.id).find((candidate) => candidate.id === protocolId);
|
|
617
|
+
if (!protocol) {
|
|
618
|
+
throw new AppError("not_found", `Protocol is not registered: ${protocolId}`, 1, { protocol_id: protocolId });
|
|
619
|
+
}
|
|
620
|
+
const runtimeProtocol = requireProtocol(context, protocol.id);
|
|
621
|
+
const runtimeState = readProtocolRuntimeState(context, runtimeProtocol).state;
|
|
622
|
+
const lifecycle = normalizeProtocolLifecycle({ state: runtimeState });
|
|
623
|
+
const queueItem = queueForProject(context, project.id).find((item) => item.protocol_id === protocolId) ?? null;
|
|
624
|
+
const runDiagnostics = protocolRunDiagnostics(context, runtimeProtocol, runtimeState);
|
|
625
|
+
const runs = context.db.all(`SELECT id, short_id, flow_kind, subject_type, subject_id, status, verdict, next_action, run_index_path, index_json, created_at, updated_at, completed_at
|
|
626
|
+
FROM flow_runs WHERE project_id = ? AND subject_type = 'protocol' AND subject_id = ?
|
|
627
|
+
ORDER BY updated_at DESC, id DESC`, [project.id, protocolId]);
|
|
628
|
+
const primaryRun = runs.find((run) => run.status === "running") ?? runs[0];
|
|
629
|
+
const warnings = [...runDiagnostics.diagnostics];
|
|
630
|
+
const protocolSetBoard = protocolSetBoardForProtocol(context, project.id, project.root, protocolId);
|
|
631
|
+
const reviewRuns = recentReviewRunsForProtocol(context, project.id, protocolId, 8);
|
|
632
|
+
const runHistory = runs.map((run) => {
|
|
633
|
+
const index = safeRunIndex(run.index_json, warnings, run.id);
|
|
634
|
+
return {
|
|
635
|
+
id: run.id,
|
|
636
|
+
short_id: run.short_id,
|
|
637
|
+
flow_kind: run.flow_kind,
|
|
638
|
+
display_flow_kind: normalizeFlowKind(run.flow_kind),
|
|
639
|
+
status: run.status,
|
|
640
|
+
display_status: normalizeDisplayStatus(run.status),
|
|
641
|
+
verdict: run.verdict,
|
|
642
|
+
next_action: run.next_action,
|
|
643
|
+
run_index_path: run.run_index_path,
|
|
644
|
+
updated_at: run.updated_at,
|
|
645
|
+
completed_at: run.completed_at,
|
|
646
|
+
stages: Array.isArray(index?.stage_runs) ? index.stage_runs.map((stage) => stageLink(project.root, run, stage)) : []
|
|
647
|
+
};
|
|
648
|
+
});
|
|
649
|
+
return {
|
|
650
|
+
schema_id: "dd-flow/protocol-dashboard-data@1",
|
|
651
|
+
generated_at: context.now(),
|
|
652
|
+
target_language: "ru",
|
|
653
|
+
page: { kind: "protocol", screen_id: "SCR-DD-FLOW-PROTOCOL-PAGE", title: `${shortId(protocolId)} protocol`, html_path: htmlPath, json_path: protocolDashboardJsonPath(context, project.id, protocolId) },
|
|
654
|
+
project: {
|
|
655
|
+
id: project.id,
|
|
656
|
+
title: path.basename(project.root) || project.id,
|
|
657
|
+
root: project.root
|
|
658
|
+
},
|
|
659
|
+
protocol: {
|
|
660
|
+
id: protocol.id,
|
|
661
|
+
short_id: shortId(protocol.id),
|
|
662
|
+
title: protocol.id,
|
|
663
|
+
raw_status: protocol.status,
|
|
664
|
+
raw_stage: protocol.stage,
|
|
665
|
+
lifecycle,
|
|
666
|
+
lifecycle_stage: lifecycle.stage,
|
|
667
|
+
lifecycle_status: lifecycle.status,
|
|
668
|
+
display_status: normalizeDisplayStatus(protocol.status),
|
|
669
|
+
action_level: actionLevel(protocol.status, jsonArray(protocol.blockers_json).length + jsonArray(protocol.active_def_json).length),
|
|
670
|
+
next_action: protocol.next_action,
|
|
671
|
+
updated_at: protocol.updated_at,
|
|
672
|
+
blockers: jsonArray(protocol.blockers_json),
|
|
673
|
+
active_def: jsonArray(protocol.active_def_json),
|
|
674
|
+
diagnostics: runDiagnostics.diagnostics,
|
|
675
|
+
source_path: path.join(project.root, ".memory-bank", "protocol", `${protocol.id}.md`),
|
|
676
|
+
summary_path: path.join(project.root, ".memory-bank", "protocol", protocol.id, "summary.md")
|
|
677
|
+
},
|
|
678
|
+
resource: {
|
|
679
|
+
kind: "protocol",
|
|
680
|
+
lane: queueItem ? "merge" : null,
|
|
681
|
+
queue_status: queueItem?.status ?? null,
|
|
682
|
+
claim: queueItem?.claimed_by_session_id
|
|
683
|
+
? { protocol_id: protocol.id, worker_id: queueItem.claimed_by_session_id, claimed_at: queueItem.claimed_at, status: queueItem.status }
|
|
684
|
+
: null
|
|
685
|
+
},
|
|
686
|
+
queue_item: queueItem ? queueItemDashboard(queueItem) : null,
|
|
687
|
+
claim: queueItem?.claimed_by_session_id
|
|
688
|
+
? { protocol_id: protocol.id, worker_id: queueItem.claimed_by_session_id, claimed_at: queueItem.claimed_at, status: queueItem.status }
|
|
689
|
+
: null,
|
|
690
|
+
links: {
|
|
691
|
+
self: link("Protocol page", htmlPath, "available"),
|
|
692
|
+
project_dashboard: link("Project dashboard", projectDashboardHtmlPath(context, project.id), "available"),
|
|
693
|
+
global_dashboard: link("Global dashboard", globalDashboardHtmlPath(context), "available"),
|
|
694
|
+
protocol_source: link("Protocol markdown", path.join(project.root, ".memory-bank", "protocol", `${protocol.id}.md`), fs.existsSync(path.join(project.root, ".memory-bank", "protocol", `${protocol.id}.md`)) ? "available" : "missing"),
|
|
695
|
+
protocol_summary: link("Protocol summary", path.join(project.root, ".memory-bank", "protocol", protocol.id, "summary.md"), fs.existsSync(path.join(project.root, ".memory-bank", "protocol", protocol.id, "summary.md")) ? "available" : "missing")
|
|
696
|
+
},
|
|
697
|
+
primary_run_id: primaryRun?.id ?? null,
|
|
698
|
+
primary_run_reason: primaryRun ? (primaryRun.status === "running" ? "latest running run" : "latest updated run") : "no runs registered",
|
|
699
|
+
protocol_set_board: protocolSetBoard,
|
|
700
|
+
review_runs: reviewRuns,
|
|
701
|
+
run_history: runHistory,
|
|
702
|
+
stage_pipeline: latestStages(runHistory),
|
|
703
|
+
metrics: [
|
|
704
|
+
metric("runs", runs.length, "known", "flow_runs"),
|
|
705
|
+
metric("open_defs", jsonArray(protocol.active_def_json).length, "known", "protocols.active_def_json"),
|
|
706
|
+
metric("blockers", jsonArray(protocol.blockers_json).length, "known", "protocols.blockers_json"),
|
|
707
|
+
metric("completed_runs", runs.filter((run) => run.status === "done").length, "known", "flow_runs"),
|
|
708
|
+
metric("review_runs", reviewRuns.length, "known", "flow_runs.flow_kind=mb-sdlc-review|review")
|
|
709
|
+
],
|
|
710
|
+
warnings
|
|
711
|
+
};
|
|
712
|
+
}
|
|
151
713
|
function renderProjectDashboardMarkdown(context, project, output) {
|
|
152
714
|
const protocols = protocolsForProject(context, project.id);
|
|
153
715
|
const activeProtocols = protocols.filter((protocol) => isActiveProtocolStatus(protocol.status));
|
|
@@ -370,6 +932,45 @@ function queueStatusSummary(queue) {
|
|
|
370
932
|
}
|
|
371
933
|
return [...counts.entries()].map(([status, count]) => `${status}:${count}`).join(", ");
|
|
372
934
|
}
|
|
935
|
+
function lifecycleSummary(protocols) {
|
|
936
|
+
const counts = new Map();
|
|
937
|
+
for (const protocol of protocols) {
|
|
938
|
+
const lifecycle = normalizeProtocolLifecycle({ rawStage: protocol.stage, rawStatus: protocol.status });
|
|
939
|
+
const key = [lifecycle.stage, lifecycle.substage, lifecycle.status].filter(Boolean).join("/");
|
|
940
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
941
|
+
}
|
|
942
|
+
return {
|
|
943
|
+
total: protocols.length,
|
|
944
|
+
by_lifecycle: [...counts.entries()].map(([lifecycle, count]) => ({ lifecycle, count }))
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
function resourceSummary(queue, locks, waiters) {
|
|
948
|
+
return {
|
|
949
|
+
queued_protocols: queue.filter((item) => isActiveQueueStatus(item.status)).length,
|
|
950
|
+
active_locks: locks.filter((lock) => lock.status === "active").length,
|
|
951
|
+
queued_waiters: waiters.filter((waiter) => waiter.status === "queued").length
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
function queueItemDashboard(queueItem) {
|
|
955
|
+
return {
|
|
956
|
+
protocol_id: queueItem.protocol_id,
|
|
957
|
+
queue_item_id: queueItem.id,
|
|
958
|
+
status: queueItem.status,
|
|
959
|
+
display_status: normalizeDisplayStatus(queueItem.status),
|
|
960
|
+
action_level: ["ready", "requeued"].includes(queueItem.status) ? "actionable" : queueItem.status === "claimed" ? "watch" : "none",
|
|
961
|
+
claim: queueItem.claimed_by_session_id
|
|
962
|
+
? {
|
|
963
|
+
protocol_id: queueItem.protocol_id,
|
|
964
|
+
worker_id: queueItem.claimed_by_session_id,
|
|
965
|
+
claimed_at: queueItem.claimed_at,
|
|
966
|
+
status: queueItem.status
|
|
967
|
+
}
|
|
968
|
+
: null,
|
|
969
|
+
owner: queueItem.claimed_by_session_id,
|
|
970
|
+
note: queueItem.last_reason,
|
|
971
|
+
updated_at: queueItem.updated_at
|
|
972
|
+
};
|
|
973
|
+
}
|
|
373
974
|
function workspaceForProtocol(worktrees, protocolId) {
|
|
374
975
|
return worktrees.find((worktree) => worktree.protocol_id === protocolId)?.worktree_path;
|
|
375
976
|
}
|
|
@@ -389,6 +990,15 @@ function compactPath(value) {
|
|
|
389
990
|
function truncate(value, maxLength) {
|
|
390
991
|
return value.length > maxLength ? `${value.slice(0, Math.max(0, maxLength - 3))}...` : value;
|
|
391
992
|
}
|
|
993
|
+
function recordValue(value) {
|
|
994
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
995
|
+
}
|
|
996
|
+
function stringValue(value) {
|
|
997
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
998
|
+
}
|
|
999
|
+
function numberValue(value) {
|
|
1000
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
1001
|
+
}
|
|
392
1002
|
function statusIcon(status) {
|
|
393
1003
|
if (["active", "running", "ready", "ready_for_merge", "claimed", "in_progress"].includes(status))
|
|
394
1004
|
return "🟢";
|
|
@@ -402,6 +1012,358 @@ function statusIcon(status) {
|
|
|
402
1012
|
return "⚪";
|
|
403
1013
|
return "•";
|
|
404
1014
|
}
|
|
1015
|
+
function metric(id, value, status, source) {
|
|
1016
|
+
return {
|
|
1017
|
+
id,
|
|
1018
|
+
label: id.replace(/_/g, " "),
|
|
1019
|
+
value,
|
|
1020
|
+
status,
|
|
1021
|
+
source
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
function link(label, href, status, reason) {
|
|
1025
|
+
return {
|
|
1026
|
+
label,
|
|
1027
|
+
href,
|
|
1028
|
+
path_kind: path.isAbsolute(href) ? "absolute" : "relative",
|
|
1029
|
+
status,
|
|
1030
|
+
...(reason ? { degraded_reason: reason } : {})
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
function buildProtocolCard(context, project, protocol, generatePage) {
|
|
1034
|
+
const summary = planSummaryForProtocol(context, protocol.id);
|
|
1035
|
+
const activeDefCount = jsonArray(protocol.active_def_json).length;
|
|
1036
|
+
const blockerCount = jsonArray(protocol.blockers_json).length;
|
|
1037
|
+
const latestRun = context.db.get(`SELECT id, short_id, flow_kind, subject_type, subject_id, status, verdict, next_action, run_index_path, index_json, created_at, updated_at, completed_at
|
|
1038
|
+
FROM flow_runs WHERE project_id = ? AND subject_type = 'protocol' AND subject_id = ?
|
|
1039
|
+
ORDER BY updated_at DESC, id DESC LIMIT 1`, [project.id, protocol.id]);
|
|
1040
|
+
let diagnostics = [];
|
|
1041
|
+
let lifecycle = normalizeProtocolLifecycle({ rawStage: protocol.stage, rawStatus: protocol.status });
|
|
1042
|
+
try {
|
|
1043
|
+
const runtimeProtocol = requireProtocol(context, protocol.id);
|
|
1044
|
+
const runtimeState = readProtocolRuntimeState(context, runtimeProtocol).state;
|
|
1045
|
+
lifecycle = normalizeProtocolLifecycle({ state: runtimeState });
|
|
1046
|
+
diagnostics = [...protocolRunDiagnostics(context, runtimeProtocol, runtimeState).diagnostics, ...lifecycle.diagnostics];
|
|
1047
|
+
}
|
|
1048
|
+
catch {
|
|
1049
|
+
diagnostics = [];
|
|
1050
|
+
}
|
|
1051
|
+
return {
|
|
1052
|
+
id: protocol.id,
|
|
1053
|
+
short_id: shortId(protocol.id),
|
|
1054
|
+
title: protocol.id,
|
|
1055
|
+
href: protocolDashboardHtmlPath(context, project.id, protocol.id),
|
|
1056
|
+
raw_status: protocol.status,
|
|
1057
|
+
raw_stage: protocol.stage,
|
|
1058
|
+
lifecycle,
|
|
1059
|
+
lifecycle_stage: lifecycle.stage,
|
|
1060
|
+
lifecycle_status: lifecycle.status,
|
|
1061
|
+
resource: {
|
|
1062
|
+
kind: "protocol",
|
|
1063
|
+
lane: null,
|
|
1064
|
+
queue_status: null,
|
|
1065
|
+
claim: null
|
|
1066
|
+
},
|
|
1067
|
+
display_status: normalizeDisplayStatus(protocol.status),
|
|
1068
|
+
action_level: actionLevel(protocol.status, activeDefCount + blockerCount),
|
|
1069
|
+
flow_kind: latestRun?.flow_kind ?? "unknown",
|
|
1070
|
+
display_flow_kind: normalizeFlowKind(latestRun?.flow_kind ?? "unknown"),
|
|
1071
|
+
current_stage: protocol.stage,
|
|
1072
|
+
next_action: protocol.next_action,
|
|
1073
|
+
plan: summary,
|
|
1074
|
+
active_def_count: activeDefCount,
|
|
1075
|
+
blocker_count: blockerCount,
|
|
1076
|
+
latest_run_id: latestRun?.id ?? null,
|
|
1077
|
+
latest_run_status: latestRun?.status ?? "unknown",
|
|
1078
|
+
diagnostics,
|
|
1079
|
+
warning_count: diagnostics.length,
|
|
1080
|
+
updated_at: protocol.updated_at,
|
|
1081
|
+
generate_page: generatePage
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
function recentReviewRunsForProject(context, projectId, limit) {
|
|
1085
|
+
const runs = context.db.all(`SELECT id, short_id, flow_kind, subject_type, subject_id, status, verdict, next_action, run_index_path, index_json, created_at, updated_at, completed_at
|
|
1086
|
+
FROM flow_runs
|
|
1087
|
+
WHERE project_id = ? AND flow_kind IN ('mb-sdlc-review', 'review')
|
|
1088
|
+
ORDER BY updated_at DESC, id DESC
|
|
1089
|
+
LIMIT ?`, [projectId, limit]);
|
|
1090
|
+
return runs.map(reviewRunSummary);
|
|
1091
|
+
}
|
|
1092
|
+
function recentReviewRunsForProtocol(context, projectId, protocolId, limit) {
|
|
1093
|
+
const runs = context.db.all(`SELECT id, short_id, flow_kind, subject_type, subject_id, status, verdict, next_action, run_index_path, index_json, created_at, updated_at, completed_at
|
|
1094
|
+
FROM flow_runs
|
|
1095
|
+
WHERE project_id = ?
|
|
1096
|
+
AND flow_kind IN ('mb-sdlc-review', 'review')
|
|
1097
|
+
AND subject_type = 'protocol'
|
|
1098
|
+
AND subject_id = ?
|
|
1099
|
+
ORDER BY updated_at DESC, id DESC
|
|
1100
|
+
LIMIT ?`, [projectId, protocolId, limit]);
|
|
1101
|
+
return runs.map(reviewRunSummary);
|
|
1102
|
+
}
|
|
1103
|
+
function reviewRunSummary(run) {
|
|
1104
|
+
const index = safeParseIndex(run.index_json);
|
|
1105
|
+
const stages = Array.isArray(index?.stage_runs) ? index.stage_runs : [];
|
|
1106
|
+
const reviewStage = stages.find((stage) => stage.stage === "review") ?? stages[stages.length - 1];
|
|
1107
|
+
return {
|
|
1108
|
+
id: run.id,
|
|
1109
|
+
short_id: run.short_id,
|
|
1110
|
+
flow_kind: run.flow_kind,
|
|
1111
|
+
display_flow_kind: normalizeFlowKind(run.flow_kind),
|
|
1112
|
+
subject_type: run.subject_type,
|
|
1113
|
+
subject_id: run.subject_id,
|
|
1114
|
+
status: run.status,
|
|
1115
|
+
display_status: normalizeDisplayStatus(run.status),
|
|
1116
|
+
verdict: run.verdict,
|
|
1117
|
+
next_action: run.next_action,
|
|
1118
|
+
run_index_path: run.run_index_path,
|
|
1119
|
+
report: reviewStage ? stageLink("", run, reviewStage).report : link("Run index", run.run_index_path, fs.existsSync(run.run_index_path) ? "available" : "missing"),
|
|
1120
|
+
updated_at: run.updated_at,
|
|
1121
|
+
completed_at: run.completed_at
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
function safeParseIndex(text) {
|
|
1125
|
+
try {
|
|
1126
|
+
const parsed = JSON.parse(text);
|
|
1127
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
1128
|
+
}
|
|
1129
|
+
catch {
|
|
1130
|
+
return null;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
function normalizeDisplayStatus(status) {
|
|
1134
|
+
if (["active", "running", "in_progress"].includes(status))
|
|
1135
|
+
return "running";
|
|
1136
|
+
if (["waiting_user", "waiting_for_user"].includes(status))
|
|
1137
|
+
return "waiting_for_user";
|
|
1138
|
+
if (["ready_for_merge"].includes(status))
|
|
1139
|
+
return "ready_for_merge";
|
|
1140
|
+
if (["ready", "queued_for_merge", "claimed", "requeued"].includes(status))
|
|
1141
|
+
return "queued";
|
|
1142
|
+
if (["closed", "closed_local", "done", "merged"].includes(status))
|
|
1143
|
+
return "done";
|
|
1144
|
+
if (["blocked"].includes(status))
|
|
1145
|
+
return "blocked";
|
|
1146
|
+
if (["failed", "error"].includes(status))
|
|
1147
|
+
return "failed";
|
|
1148
|
+
if (["cancelled"].includes(status))
|
|
1149
|
+
return "cancelled";
|
|
1150
|
+
if (["registered", "pending", "priming", "specify", "plan", "implementation", "readiness", "integration"].includes(status))
|
|
1151
|
+
return "active";
|
|
1152
|
+
if (["archived", "expired", "stopped"].includes(status))
|
|
1153
|
+
return "stale";
|
|
1154
|
+
return "unknown";
|
|
1155
|
+
}
|
|
1156
|
+
function normalizeFlowKind(flowKind) {
|
|
1157
|
+
if (flowKind === "mb_sdlc")
|
|
1158
|
+
return "mb-sdlc";
|
|
1159
|
+
if (flowKind === "planning" || flowKind === "implementation")
|
|
1160
|
+
return "coding";
|
|
1161
|
+
if (flowKind === "merge_worker" || flowKind === "merge_job")
|
|
1162
|
+
return "merge";
|
|
1163
|
+
if (flowKind === "memory_flow")
|
|
1164
|
+
return "custom";
|
|
1165
|
+
if ([
|
|
1166
|
+
"mb-sdlc",
|
|
1167
|
+
"coding",
|
|
1168
|
+
"interactive",
|
|
1169
|
+
"finish",
|
|
1170
|
+
"merge",
|
|
1171
|
+
"mb-init",
|
|
1172
|
+
"mb-upgrade",
|
|
1173
|
+
"mb-audit",
|
|
1174
|
+
"mb-fix",
|
|
1175
|
+
"mb-distill",
|
|
1176
|
+
"mb-upgrade-review",
|
|
1177
|
+
"mb-sdlc-review",
|
|
1178
|
+
"review",
|
|
1179
|
+
"release",
|
|
1180
|
+
"deploy",
|
|
1181
|
+
"publish",
|
|
1182
|
+
"experiment",
|
|
1183
|
+
"custom"
|
|
1184
|
+
].includes(flowKind)) {
|
|
1185
|
+
return flowKind;
|
|
1186
|
+
}
|
|
1187
|
+
return "unknown";
|
|
1188
|
+
}
|
|
1189
|
+
function actionLevel(status, problemCount) {
|
|
1190
|
+
if (problemCount > 0 || ["blocked", "failed", "error"].includes(status))
|
|
1191
|
+
return "blocked";
|
|
1192
|
+
if (["waiting_user", "waiting_for_user", "ready_for_merge", "ready", "queued_for_merge", "requeued"].includes(status))
|
|
1193
|
+
return "actionable";
|
|
1194
|
+
if (["running", "active", "claimed", "in_progress"].includes(status))
|
|
1195
|
+
return "watch";
|
|
1196
|
+
return "none";
|
|
1197
|
+
}
|
|
1198
|
+
function safeRunIndex(text, warnings, runId) {
|
|
1199
|
+
try {
|
|
1200
|
+
const parsed = JSON.parse(text);
|
|
1201
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
1202
|
+
}
|
|
1203
|
+
catch {
|
|
1204
|
+
warnings.push({ code: "run_index_invalid_json", run_id: runId });
|
|
1205
|
+
return null;
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
function stageLink(projectRoot, run, stage) {
|
|
1209
|
+
const item = stage && typeof stage === "object" && !Array.isArray(stage) ? stage : {};
|
|
1210
|
+
const report = typeof item.stage_report === "string" ? item.stage_report : undefined;
|
|
1211
|
+
const href = report ? path.join(path.dirname(run.run_index_path), report) : "";
|
|
1212
|
+
return {
|
|
1213
|
+
order: typeof item.order === "number" ? item.order : 0,
|
|
1214
|
+
stage: String(item.stage ?? "unknown"),
|
|
1215
|
+
dir: String(item.dir ?? ""),
|
|
1216
|
+
status: String(item.status ?? "unknown"),
|
|
1217
|
+
display_status: normalizeDisplayStatus(String(item.status ?? "unknown")),
|
|
1218
|
+
report: report ? link("Stage report", href, fs.existsSync(href) ? "available" : "missing", fs.existsSync(href) ? undefined : "stage_report_missing") : link("Stage report", "", "pending", "stage_report_not_created_yet"),
|
|
1219
|
+
data: typeof item.data === "string" ? path.join(path.dirname(run.run_index_path), item.data) : null,
|
|
1220
|
+
run_index: path.relative(projectRoot, run.run_index_path)
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
function latestStages(runHistory) {
|
|
1224
|
+
const byStage = new Map();
|
|
1225
|
+
for (const run of runHistory) {
|
|
1226
|
+
const stages = Array.isArray(run.stages) ? run.stages : [];
|
|
1227
|
+
for (const stage of stages) {
|
|
1228
|
+
const key = String(stage.stage ?? "unknown");
|
|
1229
|
+
if (!byStage.has(key))
|
|
1230
|
+
byStage.set(key, { ...stage, run_id: run.id });
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
return [...byStage.values()].sort((a, b) => Number(a.order ?? 0) - Number(b.order ?? 0));
|
|
1234
|
+
}
|
|
1235
|
+
function renderDashboardHtmlPage(data) {
|
|
1236
|
+
const json = JSON.stringify(data, null, 2).replace(/<\//g, "<\\/");
|
|
1237
|
+
return `<!doctype html>
|
|
1238
|
+
<html lang="ru">
|
|
1239
|
+
<head>
|
|
1240
|
+
<meta charset="utf-8">
|
|
1241
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1242
|
+
<title>dd-flow dashboard</title>
|
|
1243
|
+
<style>
|
|
1244
|
+
:root{color-scheme:light dark;--bg:#eef0ea;--paper:#fbfaf4;--paper-2:#f1f4ee;--ink:#171b17;--muted:#626a63;--line:#c7d0c4;--line-strong:#9fab9d;--green:#176b5c;--green-soft:#dcebe4;--blue:#2d5d83;--blue-soft:#dbe8ee;--amber:#b56a24;--amber-soft:#f4e3c7;--red:#ad3f35;--shadow:0 18px 42px rgba(32,42,31,.10)}
|
|
1245
|
+
[data-theme=dark]{color-scheme:dark;--bg:#11161a;--paper:#192026;--paper-2:#202932;--ink:#f2eadc;--muted:#b5bbb8;--line:#33404a;--line-strong:#536471;--green:#79c8ae;--green-soft:#17372f;--blue:#8ebbd3;--blue-soft:#1d3342;--amber:#dfae62;--amber-soft:#3d2c16;--red:#e18078;--shadow:0 18px 42px rgba(0,0,0,.28)}
|
|
1246
|
+
[data-theme=light]{color-scheme:light}
|
|
1247
|
+
@media (prefers-color-scheme: dark){:root:not([data-theme=light]){color-scheme:dark;--bg:#11161a;--paper:#192026;--paper-2:#202932;--ink:#f2eadc;--muted:#b5bbb8;--line:#33404a;--line-strong:#536471;--green:#79c8ae;--green-soft:#17372f;--blue:#8ebbd3;--blue-soft:#1d3342;--amber:#dfae62;--amber-soft:#3d2c16;--red:#e18078;--shadow:0 18px 42px rgba(0,0,0,.28)}}
|
|
1248
|
+
*{box-sizing:border-box}body{margin:0;padding:14px;background:var(--bg);color:var(--ink);font-family:"Avenir Next","Gill Sans",Verdana,sans-serif;letter-spacing:0;overflow-x:hidden}
|
|
1249
|
+
body:before{content:"";position:fixed;inset:0;z-index:-1;pointer-events:none;background:repeating-linear-gradient(90deg,color-mix(in srgb,var(--green) 5%,transparent) 0 1px,transparent 1px 38px),repeating-linear-gradient(0deg,color-mix(in srgb,var(--green) 4%,transparent) 0 1px,transparent 1px 38px),linear-gradient(135deg,color-mix(in srgb,var(--paper) 32%,transparent),transparent 58%);filter:blur(1px);opacity:.54}
|
|
1250
|
+
a{color:var(--green);text-decoration:none}a:hover{text-decoration:underline}a:focus-visible,button:focus-visible{outline:2px solid var(--blue);outline-offset:2px}.page{width:min(1480px,100%);margin:0 auto;padding-bottom:20px}.topbar{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:10px}.breadcrumb{color:var(--muted);font-size:13px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.switches{display:flex;gap:8px;flex-wrap:wrap}.seg{display:inline-grid;grid-auto-flow:column;gap:4px;padding:5px;border:1px solid var(--line);border-radius:8px;background:color-mix(in srgb,var(--paper) 72%,transparent)}button{font:inherit}.seg button{border:0;border-radius:6px;padding:7px 10px;min-width:64px;background:transparent;color:var(--muted);cursor:pointer;font-size:13px}.seg button[aria-pressed=true]{background:var(--ink);color:var(--paper)}
|
|
1251
|
+
.hero{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:12px;margin-bottom:12px}.card{position:relative;min-width:0;border:1px solid var(--line);border-radius:8px;background:color-mix(in srgb,var(--paper) 94%,transparent);box-shadow:var(--shadow)}.headline{padding:14px;min-height:128px;display:flex;flex-direction:column;justify-content:space-between}.eyebrow{color:var(--green);font-size:13px;font-weight:900}h1{margin:2px 0 7px;font-family:"Iowan Old Style",Georgia,serif;font-size:clamp(28px,2.6vw,36px);line-height:1.02;letter-spacing:0}h2{margin:0;font-size:17px;line-height:1.15;font-weight:850}.lead{max-width:900px;color:var(--muted);font-size:14px;line-height:1.42}.meta{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}.pill{display:inline-flex;align-items:center;gap:6px;min-height:31px;padding:6px 9px;border:1px solid var(--line);border-radius:7px;background:color-mix(in srgb,var(--paper-2) 80%,transparent);color:var(--muted);font-size:13px;max-width:100%;overflow-wrap:anywhere}.pill b{color:var(--ink)}
|
|
1252
|
+
.verdict{display:grid;gap:10px;overflow:hidden;padding:16px;background:linear-gradient(180deg,var(--ink),color-mix(in srgb,var(--ink) 92%,var(--green)));color:var(--paper);border-color:color-mix(in srgb,var(--ink) 80%,var(--line))}.verdictLabel{color:color-mix(in srgb,var(--paper) 70%,transparent);font-size:12px;text-transform:uppercase;letter-spacing:.08em;font-weight:900}.verdictText{font-size:30px;line-height:1.02;font-weight:950;overflow-wrap:anywhere}.metricGrid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:8px;margin-bottom:12px}.metric{padding:11px;border:1px solid var(--line);border-radius:8px;background:color-mix(in srgb,var(--paper-2) 68%,transparent)}.metric strong{display:block;font-size:24px;line-height:1}.metric span{display:block;margin-top:4px;color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.05em}
|
|
1253
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:10px}.entity{display:grid;gap:9px;padding:12px;min-height:160px}.entity .primary{display:block;color:var(--ink);font-weight:900;font-size:16px;line-height:1.18;overflow-wrap:anywhere}.entity .primary:after{content:"";position:absolute;inset:0;border-radius:8px}.entity :is(a,button,details){position:relative;z-index:1}.sub{color:var(--muted);font-size:13px;line-height:1.35;overflow-wrap:anywhere}.badges{display:flex;flex-wrap:wrap;gap:6px}.badge{display:inline-flex;align-items:center;max-width:100%;padding:4px 7px;border-radius:999px;background:var(--green-soft);color:var(--green);font-size:12px;font-weight:800;overflow-wrap:anywhere}.badge.watch,.badge.queued,.badge.waiting_for_user,.badge.stale,.badge.unknown{background:var(--amber-soft);color:var(--amber)}.badge.blocked,.badge.failed{background:color-mix(in srgb,var(--red) 18%,transparent);color:var(--red)}.badge.done{background:var(--blue-soft);color:var(--blue)}.miniMetrics{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px}.miniMetrics div{padding:7px;border:1px solid var(--line);border-radius:7px;background:color-mix(in srgb,var(--paper) 74%,transparent);font-size:12px;color:var(--muted)}.miniMetrics b{display:block;color:var(--ink);font-size:16px}.panel{padding:14px;margin-bottom:12px}.sectionHead{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:10px}.list{display:grid;gap:7px}.row{display:grid;grid-template-columns:minmax(0,1fr) max-content;gap:10px;padding:10px;border:1px solid var(--line);border-radius:8px;background:color-mix(in srgb,var(--paper-2) 66%,transparent)}.row a{overflow-wrap:anywhere}.disabled{color:var(--muted);text-decoration:none;cursor:not-allowed}.warn{color:var(--amber);font-size:13px}.hidden{display:none!important}
|
|
1254
|
+
[data-view=compact] .entity{min-height:120px}[data-view=compact] .expandedOnly{display:none}[data-view=expanded] .entity{min-height:210px}@media(max-width:980px){.hero{grid-template-columns:1fr}.metricGrid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:640px){body{padding:10px}.topbar{align-items:stretch;flex-direction:column}.switches,.seg{width:100%}.seg{grid-auto-flow:column}.metricGrid{grid-template-columns:1fr}.row{grid-template-columns:1fr}}
|
|
1255
|
+
</style>
|
|
1256
|
+
</head>
|
|
1257
|
+
<body>
|
|
1258
|
+
<script id="dashboard-data" type="application/json">${json}</script>
|
|
1259
|
+
<main class="page" id="dashboard-root" data-testid="dashboard-root">
|
|
1260
|
+
<div class="topbar">
|
|
1261
|
+
<div class="breadcrumb" id="breadcrumb"></div>
|
|
1262
|
+
<div class="switches">
|
|
1263
|
+
<div class="seg" role="group" aria-label="View mode" data-testid="view-switch">
|
|
1264
|
+
<button type="button" data-view-button="compact">Compact</button>
|
|
1265
|
+
<button type="button" data-view-button="expanded">Expanded</button>
|
|
1266
|
+
<button type="button" data-view-button="focus">Focus</button>
|
|
1267
|
+
</div>
|
|
1268
|
+
<div class="seg" role="group" aria-label="Theme mode" data-testid="theme-switch">
|
|
1269
|
+
<button type="button" data-theme-button="system">System</button>
|
|
1270
|
+
<button type="button" data-theme-button="light">Light</button>
|
|
1271
|
+
<button type="button" data-theme-button="dark">Dark</button>
|
|
1272
|
+
</div>
|
|
1273
|
+
</div>
|
|
1274
|
+
</div>
|
|
1275
|
+
<section class="hero">
|
|
1276
|
+
<section class="card headline">
|
|
1277
|
+
<div><div class="eyebrow" id="eyebrow"></div><h1 id="title"></h1><p class="lead" id="lead"></p></div>
|
|
1278
|
+
<div class="meta" id="meta"></div>
|
|
1279
|
+
</section>
|
|
1280
|
+
<aside class="card verdict"><div class="verdictLabel">dd-flow dashboard</div><div class="verdictText" id="verdictText"></div><div class="sub" id="verdictNote"></div></aside>
|
|
1281
|
+
</section>
|
|
1282
|
+
<section class="metricGrid" id="metrics" data-testid="metric-strip"></section>
|
|
1283
|
+
<section class="card panel hidden" id="summaryVersionsPanel"><div class="sectionHead"><h2>Summary versions</h2><span class="pill" id="summaryVersionCount"></span></div><div class="list" id="summaryVersions"></div></section>
|
|
1284
|
+
<section class="card panel"><div class="sectionHead"><h2 id="cardsTitle"></h2><span class="pill" id="focusCount"></span></div><div class="grid" id="cards" data-testid="card-grid"></div></section>
|
|
1285
|
+
<section class="card panel hidden" id="unsupportedProjectsPanel"><div class="sectionHead"><h2>Unsupported projects</h2><span class="pill" id="unsupportedProjectCount"></span></div><div class="list" id="unsupportedProjects"></div></section>
|
|
1286
|
+
<section class="card panel hidden" id="setBoardPanel"><div class="sectionHead"><h2>Protocol set</h2><span class="pill" id="setBoardCount"></span></div><div class="list" id="setBoard"></div></section>
|
|
1287
|
+
<section class="card panel hidden" id="reviewRunsPanel"><div class="sectionHead"><h2>Review runs</h2><span class="pill" id="reviewRunCount"></span></div><div class="list" id="reviewRuns"></div></section>
|
|
1288
|
+
<section class="card panel"><div class="sectionHead"><h2>Навигация и артефакты</h2></div><div class="list" id="links"></div></section>
|
|
1289
|
+
<section class="card panel"><div class="sectionHead"><h2>Warnings</h2></div><div class="list" id="warnings"></div></section>
|
|
1290
|
+
</main>
|
|
1291
|
+
<script>
|
|
1292
|
+
const data = JSON.parse(document.getElementById("dashboard-data").textContent);
|
|
1293
|
+
const root = document.getElementById("dashboard-root");
|
|
1294
|
+
const storagePrefix = "dd-flow.dashboard.";
|
|
1295
|
+
const page = data.page || {};
|
|
1296
|
+
root.dataset.screenId = page.screen_id || "";
|
|
1297
|
+
function text(id, value){ document.getElementById(id).textContent = value == null ? "" : String(value); }
|
|
1298
|
+
function el(tag, className, value){ const node = document.createElement(tag); if(className) node.className = className; if(value != null) node.textContent = String(value); return node; }
|
|
1299
|
+
function safeHref(href){ const value = String(href || ""); return /^javascript:/i.test(value) ? "" : value; }
|
|
1300
|
+
function linkNode(label, href, className){ const a = document.createElement("a"); a.className = className || ""; a.textContent = label || href || "link"; a.href = safeHref(href); a.title = href || label || ""; return a; }
|
|
1301
|
+
function statusBadge(value){ const b = el("span","badge " + String(value || "unknown"), value || "unknown"); return b; }
|
|
1302
|
+
function metricValue(metric){ return metric && metric.value !== null && metric.value !== undefined ? metric.value : "N/A"; }
|
|
1303
|
+
function pageKind(){ return page.kind || "dashboard"; }
|
|
1304
|
+
function cards(){ if(pageKind()==="global") return data.project_cards || []; if(pageKind()==="project") return data.protocol_cards || []; return data.run_history || []; }
|
|
1305
|
+
function lifecycleText(card){ const lc = card.lifecycle || {}; return [lc.stage || card.lifecycle_stage || card.current_stage, lc.substage, lc.status || card.lifecycle_status || card.status].filter(Boolean).join(" / "); }
|
|
1306
|
+
function focusable(card){ const lc = card.lifecycle || {}; return ["actionable","blocked","watch"].includes(card.action_level) || ["blocked","failed","stale","unknown","waiting_for_user","ready_for_merge","queued"].includes(card.display_status) || ["blocked","waiting_for_user"].includes(lc.status) || Number(card.active_def_count || card.metrics?.open_defs || 0) > 0; }
|
|
1307
|
+
function setMode(kind, value){ if(kind==="theme"){ root.dataset.theme = value; if(value === "system") document.documentElement.removeAttribute("data-theme"); else document.documentElement.dataset.theme = value; localStorage.setItem(storagePrefix+"theme", value); document.querySelectorAll("[data-theme-button]").forEach(b => b.setAttribute("aria-pressed", String(b.dataset.themeButton === value))); } else { root.dataset.view = value; localStorage.setItem(storagePrefix+"view", value); document.querySelectorAll("[data-view-button]").forEach(b => b.setAttribute("aria-pressed", String(b.dataset.viewButton === value))); renderCards(); } }
|
|
1308
|
+
document.querySelectorAll("[data-theme-button]").forEach(b => b.addEventListener("click", () => setMode("theme", b.dataset.themeButton)));
|
|
1309
|
+
document.querySelectorAll("[data-view-button]").forEach(b => b.addEventListener("click", () => setMode("view", b.dataset.viewButton)));
|
|
1310
|
+
function renderHeader(){
|
|
1311
|
+
text("breadcrumb", ["dd-flow", page.kind, data.project?.title || data.project?.id, data.protocol?.short_id].filter(Boolean).join(" / "));
|
|
1312
|
+
text("eyebrow", page.screen_id || "dd-flow");
|
|
1313
|
+
text("title", page.title || "dd-flow dashboard");
|
|
1314
|
+
const lead = pageKind()==="global" ? "Общий обзор локальной машины: проекты, активные протоколы, очереди, locks и degraded состояния." : pageKind()==="project" ? "Рабочая карта проекта: протоколы, сессии, очередь merge, locks и DEF." : "Страница протокола как задачи: состояние, run history, stage reports и next action.";
|
|
1315
|
+
text("lead", lead); text("verdictText", pageKind()); text("verdictNote", data.protocol?.next_action || data.primary_run_reason || "fresh local static dashboard");
|
|
1316
|
+
const meta = document.getElementById("meta"); meta.replaceChildren();
|
|
1317
|
+
[["generated", data.generated_at],["schema", data.schema_id],["language", data.target_language]].forEach(([k,v]) => { const p=el("span","pill"); const b=el("b","",k); p.append(b, document.createTextNode(" "+String(v||""))); meta.append(p); });
|
|
1318
|
+
}
|
|
1319
|
+
function renderMetrics(){ const box=document.getElementById("metrics"); box.replaceChildren(); (data.metrics || []).forEach(m => { const item=el("article","metric"); item.title = [m.source,m.reason].filter(Boolean).join(" / "); item.append(el("strong","",metricValue(m)), el("span","",m.label || m.id)); box.append(item); }); }
|
|
1320
|
+
function renderSummaryVersions(){
|
|
1321
|
+
const panel=document.getElementById("summaryVersionsPanel"); const box=document.getElementById("summaryVersions"); box.replaceChildren();
|
|
1322
|
+
const groups = data.summary_version_groups || []; if(pageKind()!=="global" || !groups.length){ panel.classList.add("hidden"); return; }
|
|
1323
|
+
panel.classList.remove("hidden"); text("summaryVersionCount", groups.length + " groups");
|
|
1324
|
+
groups.forEach(group => { const row=el("div","row"); const names = (group.project_names || []).join(", "); const label = [group.schema_id || "missing", group.schema_version || "unknown", group.compatible ? "compatible" : "unsupported"].join(" / "); const body=el("div",""); body.append(el("div","",label), el("div","sub",names)); row.append(body, statusBadge(group.compatible ? "done" : "unsupported")); box.append(row); });
|
|
1325
|
+
}
|
|
1326
|
+
function renderCards(){
|
|
1327
|
+
const mode = root.dataset.view || "compact"; const all = cards(); const visible = mode === "focus" ? all.filter(focusable) : all; text("focusCount", mode === "focus" ? visible.length + " focus" : all.length + " total");
|
|
1328
|
+
text("cardsTitle", pageKind()==="global" ? "Проекты" : pageKind()==="project" ? "Протоколы" : "Run history");
|
|
1329
|
+
const box=document.getElementById("cards"); box.replaceChildren();
|
|
1330
|
+
visible.forEach(card => { const c=el("article","card entity"); c.dataset.testid = pageKind()==="global" ? "project-card" : pageKind()==="project" ? "protocol-card" : "run-card"; c.dataset.status = card.display_status || card.status || "unknown";
|
|
1331
|
+
const href = card.href || card.run_index_path || card.report?.href || ""; const primary = linkNode(card.short_id || card.title || card.id, href, "primary"); primary.setAttribute("aria-label", card.title || card.id || "card"); c.append(primary);
|
|
1332
|
+
const sub = el("div","sub", card.root || lifecycleText(card) || card.next_action || card.run_index_path || ""); sub.title = sub.textContent; c.append(sub);
|
|
1333
|
+
const badges=el("div","badges"); [card.display_status || card.status, lifecycleText(card), card.display_flow_kind || card.flow_kind, card.action_level].filter(Boolean).forEach(v => badges.append(statusBadge(v))); c.append(badges);
|
|
1334
|
+
const mini=el("div","miniMetrics expandedOnly"); const metrics = card.metrics || {}; [["queue",metrics.queue],["locks",metrics.locks],["defs",metrics.open_defs ?? card.active_def_count]].forEach(([k,v]) => { const m=el("div",""); m.append(el("b","",v ?? "0"), document.createTextNode(k)); mini.append(m); }); c.append(mini); box.append(c); });
|
|
1335
|
+
}
|
|
1336
|
+
function renderLinks(){ const box=document.getElementById("links"); box.replaceChildren(); Object.entries(data.links || {}).forEach(([key,value]) => { const row=el("div","row"); row.append(linkNode(value.label || key, value.href, value.status === "available" ? "" : "disabled"), statusBadge(value.status || "unknown")); box.append(row); }); if(pageKind()==="protocol"){ (data.stage_pipeline || []).forEach(stage => { const row=el("div","row"); row.append(linkNode(stage.stage + " / " + stage.status, stage.report?.href, stage.report?.status === "available" ? "" : "disabled"), statusBadge(stage.display_status)); box.append(row); }); } }
|
|
1337
|
+
function renderSetBoard(){ const panel=document.getElementById("setBoardPanel"); const box=document.getElementById("setBoard"); box.replaceChildren(); const boards = pageKind()==="protocol" ? [data.protocol_set_board].filter(Boolean) : (data.protocol_set_boards || []); if(!boards.length){ panel.classList.add("hidden"); return; } panel.classList.remove("hidden"); const total = boards.reduce((sum,b)=>sum + ((b.members || []).length),0); text("setBoardCount", total + " members"); boards.forEach(board => { const summary = board.summary || {}; const head=el("div","row"); head.append(el("div","", board.protocol_set || "protocol set"), el("span","badge", ["ready "+(summary.ready||0),"blocked "+(summary.blocked||0),"running "+(summary.running||0),"done "+(summary.done||0)].join(" / "))); box.append(head); (board.members || []).forEach(member => { const row=el("div","row"); const href = data.project?.root ? "file://" + String(member.path || "").split("/").map(encodeURIComponent).join("/") : ""; row.append(linkNode(member.id || "protocol", href, ""), statusBadge(member.set_status || "unknown")); box.append(row); }); }); }
|
|
1338
|
+
function renderReviewRuns(){ const panel=document.getElementById("reviewRunsPanel"); const box=document.getElementById("reviewRuns"); box.replaceChildren(); const runs = data.review_runs || []; if(!runs.length){ panel.classList.add("hidden"); return; } panel.classList.remove("hidden"); text("reviewRunCount", runs.length + " recent"); runs.forEach(run => { const row=el("div","row"); const href = run.report?.href || run.run_index_path || ""; const label = [run.short_id || run.id, run.subject_id].filter(Boolean).join(" / "); row.append(linkNode(label, href, ""), statusBadge(run.display_status || run.status)); box.append(row); }); }
|
|
1339
|
+
function renderUnsupportedProjects(){
|
|
1340
|
+
const panel=document.getElementById("unsupportedProjectsPanel"); const box=document.getElementById("unsupportedProjects"); box.replaceChildren();
|
|
1341
|
+
const projects = data.unsupported_projects || []; if(pageKind()!=="global" || !projects.length){ panel.classList.add("hidden"); return; }
|
|
1342
|
+
panel.classList.remove("hidden"); text("unsupportedProjectCount", projects.length + " projects");
|
|
1343
|
+
projects.forEach(project => { const row=el("div","row"); const body=el("div",""); body.append(el("div","",project.title || project.id), el("div","sub",[project.reason, project.detail, project.root].filter(Boolean).join(" / "))); row.append(body, statusBadge(project.reason || "unsupported")); box.append(row); });
|
|
1344
|
+
}
|
|
1345
|
+
function renderWarnings(){ const box=document.getElementById("warnings"); box.replaceChildren(); const warnings = data.warnings || []; if(!warnings.length){ box.append(el("div","sub","No degraded dashboard data.")); return; } warnings.forEach(w => box.append(el("div","warn", typeof w === "string" ? w : JSON.stringify(w)))); }
|
|
1346
|
+
renderHeader(); renderMetrics(); renderSummaryVersions(); renderLinks(); renderSetBoard(); renderReviewRuns(); renderUnsupportedProjects(); renderWarnings();
|
|
1347
|
+
const savedTheme = ["system","light","dark"].includes(localStorage.getItem(storagePrefix+"theme")) ? localStorage.getItem(storagePrefix+"theme") : "system";
|
|
1348
|
+
const savedView = ["compact","expanded","focus"].includes(localStorage.getItem(storagePrefix+"view")) ? localStorage.getItem(storagePrefix+"view") : "compact";
|
|
1349
|
+
setMode("theme", savedTheme); setMode("view", savedView);
|
|
1350
|
+
</script>
|
|
1351
|
+
</body>
|
|
1352
|
+
</html>`;
|
|
1353
|
+
}
|
|
1354
|
+
function pathToFileUrl(filePath) {
|
|
1355
|
+
return `file://${path.resolve(filePath).split(path.sep).map(encodeURIComponent).join("/")}`;
|
|
1356
|
+
}
|
|
1357
|
+
function writeJsonFile(output, data) {
|
|
1358
|
+
ensureDir(path.dirname(output));
|
|
1359
|
+
const tmpFile = `${output}.tmp-${process.pid}-${Date.now()}`;
|
|
1360
|
+
fs.writeFileSync(tmpFile, `${JSON.stringify(data, null, 2)}\n`);
|
|
1361
|
+
fs.renameSync(tmpFile, output);
|
|
1362
|
+
}
|
|
1363
|
+
function writeTextFile(output, text) {
|
|
1364
|
+
ensureDir(path.dirname(output));
|
|
1365
|
+
fs.writeFileSync(output, text);
|
|
1366
|
+
}
|
|
405
1367
|
function writeMarkdown(output, markdown) {
|
|
406
1368
|
ensureDir(path.dirname(output));
|
|
407
1369
|
fs.writeFileSync(output, markdown);
|