@deksden-com/dd-flow-cli 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/dist/build-info.json +15 -0
- package/dist/cli/help.js +133 -14
- package/dist/cli/run-cli.js +242 -8
- package/dist/schemas/archived-flow-manifest.schema.json +112 -0
- package/dist/schemas/compatibility.schema.json +26 -0
- package/dist/schemas/flow-guidance.schema.json +73 -0
- package/dist/schemas/flow-run-index.schema.json +30 -4
- package/dist/schemas/global-dashboard-data.schema.json +68 -0
- package/dist/schemas/mb-sdlc-review-report.schema.json +242 -0
- package/dist/schemas/merge-stage-report.schema.json +61 -1
- package/dist/schemas/plan-stage-report.schema.json +255 -0
- package/dist/schemas/project-dashboard-data.schema.json +98 -0
- package/dist/schemas/project-flow-pack-manifest.schema.json +127 -0
- package/dist/schemas/protocol-dashboard-data.schema.json +89 -0
- package/dist/schemas/status-report.schema.json +186 -0
- package/dist/schemas/version-report.schema.json +22 -0
- package/dist/services/build-info.js +114 -0
- package/dist/services/canon.js +298 -0
- package/dist/services/cleanup.js +14 -1
- package/dist/services/config.js +25 -0
- package/dist/services/dashboard.js +655 -10
- package/dist/services/flow-guidance.js +214 -0
- package/dist/services/ids.js +106 -0
- package/dist/services/lanes.js +6 -2
- package/dist/services/merge-queue.js +68 -4
- package/dist/services/merge-worker.js +177 -0
- package/dist/services/projects.js +7 -1
- package/dist/services/protocols.js +648 -17
- package/dist/services/runs.js +98 -21
- package/dist/services/schema-validation.js +88 -9
- package/dist/services/sessions.js +3 -2
- package/dist/services/status.js +306 -0
- package/dist/services/version-status.js +284 -0
- package/dist/storage/database.js +13 -0
- package/dist/storage/paths.js +21 -0
- package/package.json +2 -2
|
@@ -4,9 +4,10 @@ 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, protocolDashboardHtmlPath, protocolDashboardJsonPath, readProjectConfig } from "./config.js";
|
|
8
8
|
import { queueForProject } from "./merge-queue.js";
|
|
9
9
|
import { requireProjectByRoot } from "./projects.js";
|
|
10
|
+
import { protocolRunDiagnostics, protocolSetBoardForProtocol, protocolSetBoardsForProject, readProtocolRuntimeState, requireProtocol } from "./protocols.js";
|
|
10
11
|
import { activeFlowSessionsForProject } from "./sessions.js";
|
|
11
12
|
export function getCmuxStatus(context, input) {
|
|
12
13
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
@@ -14,6 +15,13 @@ export function getCmuxStatus(context, input) {
|
|
|
14
15
|
}
|
|
15
16
|
export function renderDashboard(context, input) {
|
|
16
17
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
18
|
+
const format = parseDashboardFormat(input.format);
|
|
19
|
+
if (format === "html") {
|
|
20
|
+
if (input.protocol) {
|
|
21
|
+
return renderProtocolDashboardHtml(context, project, input.protocol, input.output);
|
|
22
|
+
}
|
|
23
|
+
return renderProjectDashboardHtml(context, project, input.output);
|
|
24
|
+
}
|
|
17
25
|
const config = readProjectConfig(context, project.id);
|
|
18
26
|
const output = input.output
|
|
19
27
|
? path.resolve(path.isAbsolute(input.output) ? input.output : path.join(project.root, input.output))
|
|
@@ -23,6 +31,10 @@ export function renderDashboard(context, input) {
|
|
|
23
31
|
return { ok: true, project_root: project.root, dashboard: { path: output, bytes: Buffer.byteLength(markdown) } };
|
|
24
32
|
}
|
|
25
33
|
export function renderGlobalDashboard(context, input = {}) {
|
|
34
|
+
const format = parseDashboardFormat(input.format);
|
|
35
|
+
if (format === "html") {
|
|
36
|
+
return renderGlobalDashboardHtml(context, input.output);
|
|
37
|
+
}
|
|
26
38
|
const output = input.output
|
|
27
39
|
? path.resolve(input.output)
|
|
28
40
|
: globalDashboardMarkdownPath(context);
|
|
@@ -33,15 +45,17 @@ export function renderGlobalDashboard(context, input = {}) {
|
|
|
33
45
|
export function openDashboard(context, input) {
|
|
34
46
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
35
47
|
const config = readProjectConfig(context, project.id);
|
|
48
|
+
const format = parseDashboardFormat(input.format);
|
|
36
49
|
if (input.viewer && input.viewer !== "cmux") {
|
|
37
50
|
throw new AppError("validation", "--viewer must be cmux", 2);
|
|
38
51
|
}
|
|
39
|
-
const dashboardPath = dashboardMarkdownPath(project.root, config);
|
|
40
|
-
return openCmuxDashboard(context, project.root, dashboardPath);
|
|
52
|
+
const dashboardPath = format === "html" ? projectDashboardHtmlPath(context, project.id) : dashboardMarkdownPath(project.root, config);
|
|
53
|
+
return openCmuxDashboard(context, project.root, dashboardPath, format);
|
|
41
54
|
}
|
|
42
55
|
export function refreshDashboard(context, input) {
|
|
43
56
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
44
57
|
const config = readProjectConfig(context, project.id);
|
|
58
|
+
const format = parseDashboardFormat(input.format);
|
|
45
59
|
const openMode = input.open ?? "auto";
|
|
46
60
|
if (!["auto", "true", "false"].includes(openMode)) {
|
|
47
61
|
throw new AppError("validation", "--open must be auto, true, or false", 2);
|
|
@@ -54,9 +68,12 @@ export function refreshDashboard(context, input) {
|
|
|
54
68
|
open: { ok: true, skipped: true, reason: "dashboard_project_disabled" }
|
|
55
69
|
};
|
|
56
70
|
}
|
|
57
|
-
const rendered = renderDashboard(context, { projectRoot: project.root });
|
|
71
|
+
const rendered = renderDashboard(context, { projectRoot: project.root, format, ...(input.protocol ? { protocol: input.protocol } : {}) });
|
|
58
72
|
const global = config.dashboard.global
|
|
59
|
-
? renderGlobalDashboard(context, {
|
|
73
|
+
? renderGlobalDashboard(context, {
|
|
74
|
+
output: format === "html" ? globalDashboardHtmlPath(context) : globalDashboardMarkdownPath(context, config),
|
|
75
|
+
format
|
|
76
|
+
})
|
|
60
77
|
: { dashboard: { skipped: true, reason: "dashboard_global_disabled" } };
|
|
61
78
|
const shouldOpen = openMode === "true" || (openMode === "auto" && config.integrations.cmux.mode !== "off");
|
|
62
79
|
return {
|
|
@@ -64,7 +81,7 @@ export function refreshDashboard(context, input) {
|
|
|
64
81
|
project_root: project.root,
|
|
65
82
|
dashboard: rendered.dashboard,
|
|
66
83
|
global_dashboard: global.dashboard,
|
|
67
|
-
open: shouldOpen ? openCmuxDashboard(context, project.root, rendered.dashboard.path) : { ok: true, skipped: true, reason: "open_disabled" }
|
|
84
|
+
open: shouldOpen ? openCmuxDashboard(context, project.root, rendered.dashboard.path, format) : { ok: true, skipped: true, reason: "open_disabled" }
|
|
68
85
|
};
|
|
69
86
|
}
|
|
70
87
|
export function refreshGlobalDashboard(context, input = {}) {
|
|
@@ -102,13 +119,27 @@ export function dashboardSummary(context, projectId, projectRoot) {
|
|
|
102
119
|
return {
|
|
103
120
|
project_markdown_path: dashboardMarkdownPath(projectRoot, config),
|
|
104
121
|
global_markdown_path: globalDashboardMarkdownPath(context, config),
|
|
122
|
+
project_html_path: projectDashboardHtmlPath(context, projectId),
|
|
123
|
+
project_json_path: projectDashboardJsonPath(context, projectId),
|
|
124
|
+
global_html_path: globalDashboardHtmlPath(context),
|
|
125
|
+
global_json_path: globalDashboardJsonPath(context),
|
|
105
126
|
auto_refresh: config.dashboard.auto_refresh,
|
|
106
127
|
project_enabled: config.dashboard.project,
|
|
107
128
|
global_enabled: config.dashboard.global,
|
|
108
129
|
cmux_mode: config.integrations.cmux.mode
|
|
109
130
|
};
|
|
110
131
|
}
|
|
111
|
-
function
|
|
132
|
+
export function dashboardData(context, input) {
|
|
133
|
+
if (input.global) {
|
|
134
|
+
return { ok: true, dashboard: buildGlobalDashboardData(context, globalDashboardHtmlPath(context)) };
|
|
135
|
+
}
|
|
136
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot ?? process.cwd()));
|
|
137
|
+
if (input.protocol) {
|
|
138
|
+
return { ok: true, dashboard: buildProtocolDashboardData(context, project, input.protocol, protocolDashboardHtmlPath(context, project.id, input.protocol)) };
|
|
139
|
+
}
|
|
140
|
+
return { ok: true, dashboard: buildProjectDashboardData(context, project, projectDashboardHtmlPath(context, project.id)) };
|
|
141
|
+
}
|
|
142
|
+
function openCmuxDashboard(context, projectRoot, dashboardPath, format = "markdown") {
|
|
112
143
|
const project = requireProjectByRoot(context, resolveProjectRoot(projectRoot));
|
|
113
144
|
const config = readProjectConfig(context, project.id);
|
|
114
145
|
if (config.integrations.cmux.mode === "off") {
|
|
@@ -122,9 +153,10 @@ function openCmuxDashboard(context, projectRoot, dashboardPath) {
|
|
|
122
153
|
return { ok: true, skipped: true, reason: "cmux_unavailable", cmux };
|
|
123
154
|
}
|
|
124
155
|
if (context.env.DD_FLOW_CMUX_DRY_RUN === "true") {
|
|
125
|
-
return { ok: true, opened: false, dry_run: true, command:
|
|
156
|
+
return { ok: true, opened: false, dry_run: true, command: cmuxOpenCommand(format, dashboardPath) };
|
|
126
157
|
}
|
|
127
|
-
const
|
|
158
|
+
const command = cmuxOpenCommand(format, dashboardPath);
|
|
159
|
+
const result = spawnSync(command[0], command.slice(1), { encoding: "utf8" });
|
|
128
160
|
if (result.status !== 0) {
|
|
129
161
|
if (config.integrations.cmux.mode === "required") {
|
|
130
162
|
throw new AppError("cmux_open_failed", "cmux markdown open failed", 1, {
|
|
@@ -134,7 +166,12 @@ function openCmuxDashboard(context, projectRoot, dashboardPath) {
|
|
|
134
166
|
}
|
|
135
167
|
return { ok: true, skipped: true, reason: "cmux_open_failed", status: result.status, stderr: result.stderr.trim() };
|
|
136
168
|
}
|
|
137
|
-
return { ok: true, opened: true, command
|
|
169
|
+
return { ok: true, opened: true, command };
|
|
170
|
+
}
|
|
171
|
+
function cmuxOpenCommand(format, dashboardPath) {
|
|
172
|
+
return format === "html"
|
|
173
|
+
? ["cmux", "browser", "open", pathToFileUrl(dashboardPath)]
|
|
174
|
+
: ["cmux", "markdown", "open", dashboardPath];
|
|
138
175
|
}
|
|
139
176
|
function detectCmux(context) {
|
|
140
177
|
if (context.env.DD_FLOW_CMUX_AVAILABLE === "true") {
|
|
@@ -148,6 +185,279 @@ function detectCmux(context) {
|
|
|
148
185
|
? { available: true, source: "path", version: result.stdout.trim() }
|
|
149
186
|
: { available: false, source: "path", reason: result.error?.message ?? result.stderr.trim() ?? "cmux not found" };
|
|
150
187
|
}
|
|
188
|
+
function parseDashboardFormat(value) {
|
|
189
|
+
const format = value ?? "markdown";
|
|
190
|
+
if (format !== "markdown" && format !== "html") {
|
|
191
|
+
throw new AppError("validation", "--format must be markdown or html", 2, { format });
|
|
192
|
+
}
|
|
193
|
+
return format;
|
|
194
|
+
}
|
|
195
|
+
function renderProjectDashboardHtml(context, project, output) {
|
|
196
|
+
const htmlPath = output
|
|
197
|
+
? path.resolve(path.isAbsolute(output) ? output : path.join(project.root, output))
|
|
198
|
+
: projectDashboardHtmlPath(context, project.id);
|
|
199
|
+
const jsonPath = projectDashboardJsonPath(context, project.id);
|
|
200
|
+
const data = buildProjectDashboardData(context, project, htmlPath);
|
|
201
|
+
const html = renderDashboardHtmlPage(data);
|
|
202
|
+
writeJsonFile(jsonPath, data);
|
|
203
|
+
writeTextFile(htmlPath, html);
|
|
204
|
+
const protocolCards = Array.isArray(data.protocol_cards) ? data.protocol_cards : [];
|
|
205
|
+
const protocolPages = protocolCards
|
|
206
|
+
.filter((card) => card.generate_page !== false)
|
|
207
|
+
.slice(0, 24)
|
|
208
|
+
.map((card) => renderProtocolDashboardHtml(context, project, String(card.id)))
|
|
209
|
+
.map((result) => ({
|
|
210
|
+
id: String(result.protocol_id),
|
|
211
|
+
path: result.dashboard.path,
|
|
212
|
+
json_path: result.dashboard.json_path
|
|
213
|
+
}));
|
|
214
|
+
return {
|
|
215
|
+
ok: true,
|
|
216
|
+
project_root: project.root,
|
|
217
|
+
dashboard: {
|
|
218
|
+
path: htmlPath,
|
|
219
|
+
json_path: jsonPath,
|
|
220
|
+
bytes: Buffer.byteLength(html),
|
|
221
|
+
json_bytes: Buffer.byteLength(JSON.stringify(data))
|
|
222
|
+
},
|
|
223
|
+
protocol_pages: protocolPages
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
function renderProtocolDashboardHtml(context, project, protocolId, output) {
|
|
227
|
+
const htmlPath = output
|
|
228
|
+
? path.resolve(path.isAbsolute(output) ? output : path.join(project.root, output))
|
|
229
|
+
: protocolDashboardHtmlPath(context, project.id, protocolId);
|
|
230
|
+
const jsonPath = protocolDashboardJsonPath(context, project.id, protocolId);
|
|
231
|
+
const data = buildProtocolDashboardData(context, project, protocolId, htmlPath);
|
|
232
|
+
const html = renderDashboardHtmlPage(data);
|
|
233
|
+
writeJsonFile(jsonPath, data);
|
|
234
|
+
writeTextFile(htmlPath, html);
|
|
235
|
+
return {
|
|
236
|
+
ok: true,
|
|
237
|
+
project_root: project.root,
|
|
238
|
+
protocol_id: protocolId,
|
|
239
|
+
dashboard: {
|
|
240
|
+
path: htmlPath,
|
|
241
|
+
json_path: jsonPath,
|
|
242
|
+
bytes: Buffer.byteLength(html),
|
|
243
|
+
json_bytes: Buffer.byteLength(JSON.stringify(data))
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
function renderGlobalDashboardHtml(context, output) {
|
|
248
|
+
const htmlPath = output ? path.resolve(output) : globalDashboardHtmlPath(context);
|
|
249
|
+
const jsonPath = globalDashboardJsonPath(context);
|
|
250
|
+
const data = buildGlobalDashboardData(context, htmlPath);
|
|
251
|
+
const html = renderDashboardHtmlPage(data);
|
|
252
|
+
writeJsonFile(jsonPath, data);
|
|
253
|
+
writeTextFile(htmlPath, html);
|
|
254
|
+
return {
|
|
255
|
+
ok: true,
|
|
256
|
+
dashboard: {
|
|
257
|
+
path: htmlPath,
|
|
258
|
+
json_path: jsonPath,
|
|
259
|
+
bytes: Buffer.byteLength(html),
|
|
260
|
+
json_bytes: Buffer.byteLength(JSON.stringify(data))
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
function buildGlobalDashboardData(context, htmlPath) {
|
|
265
|
+
const projects = context.db.all("SELECT * FROM projects ORDER BY status ASC, updated_at DESC, root ASC");
|
|
266
|
+
const activeProjects = projects.filter((project) => project.status === "active");
|
|
267
|
+
const projectCards = projects.map((project) => {
|
|
268
|
+
const protocols = protocolsForProject(context, project.id);
|
|
269
|
+
const activeProtocols = protocols.filter((protocol) => isActiveProtocolStatus(protocol.status));
|
|
270
|
+
const queue = queueForProject(context, project.id);
|
|
271
|
+
const locks = context.db.all("SELECT lane, worker_id, status, expires_at FROM lane_locks WHERE project_id = ? AND status = 'active'", [project.id]);
|
|
272
|
+
const sessions = activeFlowSessionsForProject(context, project.id);
|
|
273
|
+
const openDefs = protocols.reduce((count, protocol) => count + jsonArray(protocol.active_def_json).length + jsonArray(protocol.blockers_json).length, 0);
|
|
274
|
+
return {
|
|
275
|
+
id: project.id,
|
|
276
|
+
title: path.basename(project.root) || project.id,
|
|
277
|
+
root: project.root,
|
|
278
|
+
short_root: compactPath(project.root),
|
|
279
|
+
status: project.status,
|
|
280
|
+
root_exists: fs.existsSync(project.root),
|
|
281
|
+
display_status: fs.existsSync(project.root) ? normalizeDisplayStatus(project.status) : "stale",
|
|
282
|
+
action_level: openDefs > 0 || locks.length > 0 ? "watch" : "none",
|
|
283
|
+
updated_at: project.updated_at,
|
|
284
|
+
href: projectDashboardHtmlPath(context, project.id),
|
|
285
|
+
active_protocols: activeProtocols.slice(0, 6).map(protocolCardMini),
|
|
286
|
+
metrics: {
|
|
287
|
+
active_protocols: activeProtocols.length,
|
|
288
|
+
queue: queue.filter((job) => isActiveQueueStatus(job.status)).length,
|
|
289
|
+
locks: locks.length,
|
|
290
|
+
sessions: sessions.length,
|
|
291
|
+
open_defs: openDefs
|
|
292
|
+
},
|
|
293
|
+
warnings: fs.existsSync(project.root) ? [] : ["project_root_missing"]
|
|
294
|
+
};
|
|
295
|
+
});
|
|
296
|
+
return {
|
|
297
|
+
schema_id: "dd-flow/global-dashboard-data@1",
|
|
298
|
+
generated_at: context.now(),
|
|
299
|
+
target_language: "ru",
|
|
300
|
+
page: { kind: "global", screen_id: "SCR-DD-FLOW-GLOBAL-DASHBOARD", title: "dd-flow global dashboard", html_path: htmlPath, json_path: globalDashboardJsonPath(context) },
|
|
301
|
+
links: {
|
|
302
|
+
self: link("Global dashboard", htmlPath, "available"),
|
|
303
|
+
markdown_fallback: link("Markdown fallback", globalDashboardMarkdownPath(context), "available")
|
|
304
|
+
},
|
|
305
|
+
metrics: [
|
|
306
|
+
metric("projects", activeProjects.length, "known", "projects.status=active"),
|
|
307
|
+
metric("active_protocols", projectCards.reduce((sum, card) => sum + Number(card.metrics.active_protocols), 0), "known", "protocols"),
|
|
308
|
+
metric("running_sessions", projectCards.reduce((sum, card) => sum + Number(card.metrics.sessions), 0), "known", "flow_sessions"),
|
|
309
|
+
metric("active_locks", projectCards.reduce((sum, card) => sum + Number(card.metrics.locks), 0), "known", "lane_locks"),
|
|
310
|
+
metric("open_defs", projectCards.reduce((sum, card) => sum + Number(card.metrics.open_defs), 0), "known", "protocols.active_def/blockers")
|
|
311
|
+
],
|
|
312
|
+
project_cards: projectCards,
|
|
313
|
+
warnings: projectCards.filter((card) => !card.root_exists).map((card) => ({ code: "project_root_missing", project_id: card.id }))
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
function buildProjectDashboardData(context, project, htmlPath) {
|
|
317
|
+
const protocols = protocolsForProject(context, project.id);
|
|
318
|
+
const queue = queueForProject(context, project.id);
|
|
319
|
+
const sessions = activeFlowSessionsForProject(context, project.id);
|
|
320
|
+
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]);
|
|
321
|
+
const activeProtocols = protocols.filter((protocol) => isActiveProtocolStatus(protocol.status));
|
|
322
|
+
const protocolCards = protocols.slice(0, 48).map((protocol, index) => buildProtocolCard(context, project, protocol, index < activeProtocols.length + 12));
|
|
323
|
+
const openDefs = protocols.reduce((count, protocol) => count + jsonArray(protocol.active_def_json).length + jsonArray(protocol.blockers_json).length, 0);
|
|
324
|
+
const protocolSetBoards = protocolSetBoardsForProject(context, project.id, project.root);
|
|
325
|
+
const reviewRuns = recentReviewRunsForProject(context, project.id, 8);
|
|
326
|
+
return {
|
|
327
|
+
schema_id: "dd-flow/project-dashboard-data@1",
|
|
328
|
+
generated_at: context.now(),
|
|
329
|
+
target_language: "ru",
|
|
330
|
+
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) },
|
|
331
|
+
project: {
|
|
332
|
+
id: project.id,
|
|
333
|
+
title: path.basename(project.root) || project.id,
|
|
334
|
+
root: project.root,
|
|
335
|
+
status: project.status,
|
|
336
|
+
updated_at: project.updated_at
|
|
337
|
+
},
|
|
338
|
+
links: {
|
|
339
|
+
self: link("Project dashboard", htmlPath, "available"),
|
|
340
|
+
global_dashboard: link("Global dashboard", globalDashboardHtmlPath(context), "available"),
|
|
341
|
+
markdown_fallback: link("Markdown fallback", dashboardMarkdownPath(project.root, readProjectConfig(context, project.id)), "available")
|
|
342
|
+
},
|
|
343
|
+
metrics: [
|
|
344
|
+
metric("active_protocols", activeProtocols.length, "known", "protocols"),
|
|
345
|
+
metric("completed_protocols", protocols.filter((protocol) => !isActiveProtocolStatus(protocol.status)).length, "known", "protocols"),
|
|
346
|
+
metric("merge_queue", queue.filter((job) => isActiveQueueStatus(job.status)).length, "known", "merge_queue"),
|
|
347
|
+
metric("active_locks", locks.filter((lock) => lock.status === "active").length, "known", "lane_locks"),
|
|
348
|
+
metric("open_defs", openDefs, "known", "protocols.active_def/blockers"),
|
|
349
|
+
metric("running_sessions", sessions.length, "known", "flow_sessions"),
|
|
350
|
+
metric("review_runs", reviewRuns.length, "known", "flow_runs.flow_kind=mb-sdlc-review|review")
|
|
351
|
+
],
|
|
352
|
+
protocol_cards: protocolCards,
|
|
353
|
+
protocol_set_boards: protocolSetBoards,
|
|
354
|
+
review_runs: reviewRuns,
|
|
355
|
+
merge_queue: queue.slice(0, 12).map((job) => ({
|
|
356
|
+
protocol_id: job.protocol_id,
|
|
357
|
+
status: job.status,
|
|
358
|
+
display_status: normalizeDisplayStatus(job.status),
|
|
359
|
+
action_level: ["ready", "requeued"].includes(job.status) ? "actionable" : job.status === "claimed" ? "watch" : "none",
|
|
360
|
+
owner: job.claimed_by_session_id,
|
|
361
|
+
note: job.last_reason,
|
|
362
|
+
updated_at: job.updated_at
|
|
363
|
+
})),
|
|
364
|
+
sessions: sessions.slice(0, 12).map((session) => ({
|
|
365
|
+
id: session.session_id,
|
|
366
|
+
flow_kind: session.flow_kind,
|
|
367
|
+
display_flow_kind: normalizeFlowKind(session.flow_kind),
|
|
368
|
+
status: session.status,
|
|
369
|
+
display_status: normalizeDisplayStatus(session.status),
|
|
370
|
+
worker_id: session.worker_id,
|
|
371
|
+
workspace_path: session.workspace_path,
|
|
372
|
+
current_stage: session.current_stage,
|
|
373
|
+
next_action: session.next_action,
|
|
374
|
+
updated_at: session.updated_at
|
|
375
|
+
})),
|
|
376
|
+
locks: locks.slice(0, 12).map((lock) => ({ lane: lock.lane, worker_id: lock.worker_id, status: lock.status, expires_at: lock.expires_at })),
|
|
377
|
+
warnings: fs.existsSync(project.root) ? [] : [{ code: "project_root_missing", path: project.root }]
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
function buildProtocolDashboardData(context, project, protocolId, htmlPath) {
|
|
381
|
+
const protocol = protocolsForProject(context, project.id).find((candidate) => candidate.id === protocolId);
|
|
382
|
+
if (!protocol) {
|
|
383
|
+
throw new AppError("not_found", `Protocol is not registered: ${protocolId}`, 1, { protocol_id: protocolId });
|
|
384
|
+
}
|
|
385
|
+
const runtimeProtocol = requireProtocol(context, protocol.id);
|
|
386
|
+
const runtimeState = readProtocolRuntimeState(context, runtimeProtocol).state;
|
|
387
|
+
const runDiagnostics = protocolRunDiagnostics(context, runtimeProtocol, runtimeState);
|
|
388
|
+
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
|
|
389
|
+
FROM flow_runs WHERE project_id = ? AND subject_type = 'protocol' AND subject_id = ?
|
|
390
|
+
ORDER BY updated_at DESC, id DESC`, [project.id, protocolId]);
|
|
391
|
+
const primaryRun = runs.find((run) => run.status === "running") ?? runs[0];
|
|
392
|
+
const warnings = [...runDiagnostics.diagnostics];
|
|
393
|
+
const protocolSetBoard = protocolSetBoardForProtocol(context, project.id, project.root, protocolId);
|
|
394
|
+
const reviewRuns = recentReviewRunsForProtocol(context, project.id, protocolId, 8);
|
|
395
|
+
const runHistory = runs.map((run) => {
|
|
396
|
+
const index = safeRunIndex(run.index_json, warnings, run.id);
|
|
397
|
+
return {
|
|
398
|
+
id: run.id,
|
|
399
|
+
short_id: run.short_id,
|
|
400
|
+
flow_kind: run.flow_kind,
|
|
401
|
+
display_flow_kind: normalizeFlowKind(run.flow_kind),
|
|
402
|
+
status: run.status,
|
|
403
|
+
display_status: normalizeDisplayStatus(run.status),
|
|
404
|
+
verdict: run.verdict,
|
|
405
|
+
next_action: run.next_action,
|
|
406
|
+
run_index_path: run.run_index_path,
|
|
407
|
+
updated_at: run.updated_at,
|
|
408
|
+
completed_at: run.completed_at,
|
|
409
|
+
stages: Array.isArray(index?.stage_runs) ? index.stage_runs.map((stage) => stageLink(project.root, run, stage)) : []
|
|
410
|
+
};
|
|
411
|
+
});
|
|
412
|
+
return {
|
|
413
|
+
schema_id: "dd-flow/protocol-dashboard-data@1",
|
|
414
|
+
generated_at: context.now(),
|
|
415
|
+
target_language: "ru",
|
|
416
|
+
page: { kind: "protocol", screen_id: "SCR-DD-FLOW-PROTOCOL-PAGE", title: `${shortId(protocolId)} protocol`, html_path: htmlPath, json_path: protocolDashboardJsonPath(context, project.id, protocolId) },
|
|
417
|
+
project: {
|
|
418
|
+
id: project.id,
|
|
419
|
+
title: path.basename(project.root) || project.id,
|
|
420
|
+
root: project.root
|
|
421
|
+
},
|
|
422
|
+
protocol: {
|
|
423
|
+
id: protocol.id,
|
|
424
|
+
short_id: shortId(protocol.id),
|
|
425
|
+
title: protocol.id,
|
|
426
|
+
raw_status: protocol.status,
|
|
427
|
+
raw_stage: protocol.stage,
|
|
428
|
+
display_status: normalizeDisplayStatus(protocol.status),
|
|
429
|
+
action_level: actionLevel(protocol.status, jsonArray(protocol.blockers_json).length + jsonArray(protocol.active_def_json).length),
|
|
430
|
+
next_action: protocol.next_action,
|
|
431
|
+
updated_at: protocol.updated_at,
|
|
432
|
+
blockers: jsonArray(protocol.blockers_json),
|
|
433
|
+
active_def: jsonArray(protocol.active_def_json),
|
|
434
|
+
diagnostics: runDiagnostics.diagnostics,
|
|
435
|
+
source_path: path.join(project.root, ".memory-bank", "protocol", `${protocol.id}.md`),
|
|
436
|
+
summary_path: path.join(project.root, ".memory-bank", "protocol", protocol.id, "summary.md")
|
|
437
|
+
},
|
|
438
|
+
links: {
|
|
439
|
+
self: link("Protocol page", htmlPath, "available"),
|
|
440
|
+
project_dashboard: link("Project dashboard", projectDashboardHtmlPath(context, project.id), "available"),
|
|
441
|
+
global_dashboard: link("Global dashboard", globalDashboardHtmlPath(context), "available"),
|
|
442
|
+
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"),
|
|
443
|
+
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")
|
|
444
|
+
},
|
|
445
|
+
primary_run_id: primaryRun?.id ?? null,
|
|
446
|
+
primary_run_reason: primaryRun ? (primaryRun.status === "running" ? "latest running run" : "latest updated run") : "no runs registered",
|
|
447
|
+
protocol_set_board: protocolSetBoard,
|
|
448
|
+
review_runs: reviewRuns,
|
|
449
|
+
run_history: runHistory,
|
|
450
|
+
stage_pipeline: latestStages(runHistory),
|
|
451
|
+
metrics: [
|
|
452
|
+
metric("runs", runs.length, "known", "flow_runs"),
|
|
453
|
+
metric("open_defs", jsonArray(protocol.active_def_json).length, "known", "protocols.active_def_json"),
|
|
454
|
+
metric("blockers", jsonArray(protocol.blockers_json).length, "known", "protocols.blockers_json"),
|
|
455
|
+
metric("completed_runs", runs.filter((run) => run.status === "done").length, "known", "flow_runs"),
|
|
456
|
+
metric("review_runs", reviewRuns.length, "known", "flow_runs.flow_kind=mb-sdlc-review|review")
|
|
457
|
+
],
|
|
458
|
+
warnings
|
|
459
|
+
};
|
|
460
|
+
}
|
|
151
461
|
function renderProjectDashboardMarkdown(context, project, output) {
|
|
152
462
|
const protocols = protocolsForProject(context, project.id);
|
|
153
463
|
const activeProtocols = protocols.filter((protocol) => isActiveProtocolStatus(protocol.status));
|
|
@@ -402,6 +712,341 @@ function statusIcon(status) {
|
|
|
402
712
|
return "⚪";
|
|
403
713
|
return "•";
|
|
404
714
|
}
|
|
715
|
+
function metric(id, value, status, source) {
|
|
716
|
+
return {
|
|
717
|
+
id,
|
|
718
|
+
label: id.replace(/_/g, " "),
|
|
719
|
+
value,
|
|
720
|
+
status,
|
|
721
|
+
source
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
function link(label, href, status, reason) {
|
|
725
|
+
return {
|
|
726
|
+
label,
|
|
727
|
+
href,
|
|
728
|
+
path_kind: path.isAbsolute(href) ? "absolute" : "relative",
|
|
729
|
+
status,
|
|
730
|
+
...(reason ? { degraded_reason: reason } : {})
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
function protocolCardMini(protocol) {
|
|
734
|
+
return {
|
|
735
|
+
id: protocol.id,
|
|
736
|
+
short_id: shortId(protocol.id),
|
|
737
|
+
stage: protocol.stage,
|
|
738
|
+
status: protocol.status,
|
|
739
|
+
display_status: normalizeDisplayStatus(protocol.status)
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
function buildProtocolCard(context, project, protocol, generatePage) {
|
|
743
|
+
const summary = planSummaryForProtocol(context, protocol.id);
|
|
744
|
+
const activeDefCount = jsonArray(protocol.active_def_json).length;
|
|
745
|
+
const blockerCount = jsonArray(protocol.blockers_json).length;
|
|
746
|
+
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
|
|
747
|
+
FROM flow_runs WHERE project_id = ? AND subject_type = 'protocol' AND subject_id = ?
|
|
748
|
+
ORDER BY updated_at DESC, id DESC LIMIT 1`, [project.id, protocol.id]);
|
|
749
|
+
let diagnostics = [];
|
|
750
|
+
try {
|
|
751
|
+
const runtimeProtocol = requireProtocol(context, protocol.id);
|
|
752
|
+
const runtimeState = readProtocolRuntimeState(context, runtimeProtocol).state;
|
|
753
|
+
diagnostics = protocolRunDiagnostics(context, runtimeProtocol, runtimeState).diagnostics;
|
|
754
|
+
}
|
|
755
|
+
catch {
|
|
756
|
+
diagnostics = [];
|
|
757
|
+
}
|
|
758
|
+
return {
|
|
759
|
+
id: protocol.id,
|
|
760
|
+
short_id: shortId(protocol.id),
|
|
761
|
+
title: protocol.id,
|
|
762
|
+
href: protocolDashboardHtmlPath(context, project.id, protocol.id),
|
|
763
|
+
raw_status: protocol.status,
|
|
764
|
+
raw_stage: protocol.stage,
|
|
765
|
+
display_status: normalizeDisplayStatus(protocol.status),
|
|
766
|
+
action_level: actionLevel(protocol.status, activeDefCount + blockerCount),
|
|
767
|
+
flow_kind: latestRun?.flow_kind ?? "unknown",
|
|
768
|
+
display_flow_kind: normalizeFlowKind(latestRun?.flow_kind ?? "unknown"),
|
|
769
|
+
current_stage: protocol.stage,
|
|
770
|
+
next_action: protocol.next_action,
|
|
771
|
+
plan: summary,
|
|
772
|
+
active_def_count: activeDefCount,
|
|
773
|
+
blocker_count: blockerCount,
|
|
774
|
+
latest_run_id: latestRun?.id ?? null,
|
|
775
|
+
latest_run_status: latestRun?.status ?? "unknown",
|
|
776
|
+
diagnostics,
|
|
777
|
+
warning_count: diagnostics.length,
|
|
778
|
+
updated_at: protocol.updated_at,
|
|
779
|
+
generate_page: generatePage
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
function recentReviewRunsForProject(context, projectId, limit) {
|
|
783
|
+
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
|
|
784
|
+
FROM flow_runs
|
|
785
|
+
WHERE project_id = ? AND flow_kind IN ('mb-sdlc-review', 'review')
|
|
786
|
+
ORDER BY updated_at DESC, id DESC
|
|
787
|
+
LIMIT ?`, [projectId, limit]);
|
|
788
|
+
return runs.map(reviewRunSummary);
|
|
789
|
+
}
|
|
790
|
+
function recentReviewRunsForProtocol(context, projectId, protocolId, limit) {
|
|
791
|
+
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
|
|
792
|
+
FROM flow_runs
|
|
793
|
+
WHERE project_id = ?
|
|
794
|
+
AND flow_kind IN ('mb-sdlc-review', 'review')
|
|
795
|
+
AND subject_type = 'protocol'
|
|
796
|
+
AND subject_id = ?
|
|
797
|
+
ORDER BY updated_at DESC, id DESC
|
|
798
|
+
LIMIT ?`, [projectId, protocolId, limit]);
|
|
799
|
+
return runs.map(reviewRunSummary);
|
|
800
|
+
}
|
|
801
|
+
function reviewRunSummary(run) {
|
|
802
|
+
const index = safeParseIndex(run.index_json);
|
|
803
|
+
const stages = Array.isArray(index?.stage_runs) ? index.stage_runs : [];
|
|
804
|
+
const reviewStage = stages.find((stage) => stage.stage === "review") ?? stages[stages.length - 1];
|
|
805
|
+
return {
|
|
806
|
+
id: run.id,
|
|
807
|
+
short_id: run.short_id,
|
|
808
|
+
flow_kind: run.flow_kind,
|
|
809
|
+
display_flow_kind: normalizeFlowKind(run.flow_kind),
|
|
810
|
+
subject_type: run.subject_type,
|
|
811
|
+
subject_id: run.subject_id,
|
|
812
|
+
status: run.status,
|
|
813
|
+
display_status: normalizeDisplayStatus(run.status),
|
|
814
|
+
verdict: run.verdict,
|
|
815
|
+
next_action: run.next_action,
|
|
816
|
+
run_index_path: run.run_index_path,
|
|
817
|
+
report: reviewStage ? stageLink("", run, reviewStage).report : link("Run index", run.run_index_path, fs.existsSync(run.run_index_path) ? "available" : "missing"),
|
|
818
|
+
updated_at: run.updated_at,
|
|
819
|
+
completed_at: run.completed_at
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
function safeParseIndex(text) {
|
|
823
|
+
try {
|
|
824
|
+
const parsed = JSON.parse(text);
|
|
825
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
826
|
+
}
|
|
827
|
+
catch {
|
|
828
|
+
return null;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
function normalizeDisplayStatus(status) {
|
|
832
|
+
if (["active", "running", "in_progress"].includes(status))
|
|
833
|
+
return "running";
|
|
834
|
+
if (["waiting_user", "waiting_for_user"].includes(status))
|
|
835
|
+
return "waiting_for_user";
|
|
836
|
+
if (["ready_for_merge"].includes(status))
|
|
837
|
+
return "ready_for_merge";
|
|
838
|
+
if (["ready", "queued_for_merge", "claimed", "requeued"].includes(status))
|
|
839
|
+
return "queued";
|
|
840
|
+
if (["closed", "closed_local", "done", "merged"].includes(status))
|
|
841
|
+
return "done";
|
|
842
|
+
if (["blocked"].includes(status))
|
|
843
|
+
return "blocked";
|
|
844
|
+
if (["failed", "error"].includes(status))
|
|
845
|
+
return "failed";
|
|
846
|
+
if (["cancelled"].includes(status))
|
|
847
|
+
return "cancelled";
|
|
848
|
+
if (["registered", "pending", "priming", "specify", "plan", "implementation", "readiness", "integration"].includes(status))
|
|
849
|
+
return "active";
|
|
850
|
+
if (["archived", "expired", "stopped"].includes(status))
|
|
851
|
+
return "stale";
|
|
852
|
+
return "unknown";
|
|
853
|
+
}
|
|
854
|
+
function normalizeFlowKind(flowKind) {
|
|
855
|
+
if (flowKind === "mb_sdlc")
|
|
856
|
+
return "mb-sdlc";
|
|
857
|
+
if (flowKind === "planning" || flowKind === "implementation")
|
|
858
|
+
return "coding";
|
|
859
|
+
if (flowKind === "merge_worker" || flowKind === "merge_job")
|
|
860
|
+
return "merge";
|
|
861
|
+
if (flowKind === "memory_flow")
|
|
862
|
+
return "custom";
|
|
863
|
+
if ([
|
|
864
|
+
"mb-sdlc",
|
|
865
|
+
"coding",
|
|
866
|
+
"interactive",
|
|
867
|
+
"finish",
|
|
868
|
+
"merge",
|
|
869
|
+
"mb-init",
|
|
870
|
+
"mb-upgrade",
|
|
871
|
+
"mb-audit",
|
|
872
|
+
"mb-fix",
|
|
873
|
+
"mb-distill",
|
|
874
|
+
"mb-upgrade-review",
|
|
875
|
+
"mb-sdlc-review",
|
|
876
|
+
"review",
|
|
877
|
+
"release",
|
|
878
|
+
"deploy",
|
|
879
|
+
"publish",
|
|
880
|
+
"experiment",
|
|
881
|
+
"custom"
|
|
882
|
+
].includes(flowKind)) {
|
|
883
|
+
return flowKind;
|
|
884
|
+
}
|
|
885
|
+
return "unknown";
|
|
886
|
+
}
|
|
887
|
+
function actionLevel(status, problemCount) {
|
|
888
|
+
if (problemCount > 0 || ["blocked", "failed", "error"].includes(status))
|
|
889
|
+
return "blocked";
|
|
890
|
+
if (["waiting_user", "waiting_for_user", "ready_for_merge", "ready", "queued_for_merge", "requeued"].includes(status))
|
|
891
|
+
return "actionable";
|
|
892
|
+
if (["running", "active", "claimed", "in_progress"].includes(status))
|
|
893
|
+
return "watch";
|
|
894
|
+
return "none";
|
|
895
|
+
}
|
|
896
|
+
function safeRunIndex(text, warnings, runId) {
|
|
897
|
+
try {
|
|
898
|
+
const parsed = JSON.parse(text);
|
|
899
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
900
|
+
}
|
|
901
|
+
catch {
|
|
902
|
+
warnings.push({ code: "run_index_invalid_json", run_id: runId });
|
|
903
|
+
return null;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
function stageLink(projectRoot, run, stage) {
|
|
907
|
+
const item = stage && typeof stage === "object" && !Array.isArray(stage) ? stage : {};
|
|
908
|
+
const report = typeof item.stage_report === "string" ? item.stage_report : undefined;
|
|
909
|
+
const href = report ? path.join(path.dirname(run.run_index_path), report) : "";
|
|
910
|
+
return {
|
|
911
|
+
order: typeof item.order === "number" ? item.order : 0,
|
|
912
|
+
stage: String(item.stage ?? "unknown"),
|
|
913
|
+
dir: String(item.dir ?? ""),
|
|
914
|
+
status: String(item.status ?? "unknown"),
|
|
915
|
+
display_status: normalizeDisplayStatus(String(item.status ?? "unknown")),
|
|
916
|
+
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"),
|
|
917
|
+
data: typeof item.data === "string" ? path.join(path.dirname(run.run_index_path), item.data) : null,
|
|
918
|
+
run_index: path.relative(projectRoot, run.run_index_path)
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
function latestStages(runHistory) {
|
|
922
|
+
const byStage = new Map();
|
|
923
|
+
for (const run of runHistory) {
|
|
924
|
+
const stages = Array.isArray(run.stages) ? run.stages : [];
|
|
925
|
+
for (const stage of stages) {
|
|
926
|
+
const key = String(stage.stage ?? "unknown");
|
|
927
|
+
if (!byStage.has(key))
|
|
928
|
+
byStage.set(key, { ...stage, run_id: run.id });
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
return [...byStage.values()].sort((a, b) => Number(a.order ?? 0) - Number(b.order ?? 0));
|
|
932
|
+
}
|
|
933
|
+
function renderDashboardHtmlPage(data) {
|
|
934
|
+
const json = JSON.stringify(data, null, 2).replace(/<\//g, "<\\/");
|
|
935
|
+
return `<!doctype html>
|
|
936
|
+
<html lang="ru">
|
|
937
|
+
<head>
|
|
938
|
+
<meta charset="utf-8">
|
|
939
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
940
|
+
<title>dd-flow dashboard</title>
|
|
941
|
+
<style>
|
|
942
|
+
: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)}
|
|
943
|
+
[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)}
|
|
944
|
+
[data-theme=light]{color-scheme:light}
|
|
945
|
+
@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)}}
|
|
946
|
+
*{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}
|
|
947
|
+
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}
|
|
948
|
+
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)}
|
|
949
|
+
.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)}
|
|
950
|
+
.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}
|
|
951
|
+
.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}
|
|
952
|
+
[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}}
|
|
953
|
+
</style>
|
|
954
|
+
</head>
|
|
955
|
+
<body>
|
|
956
|
+
<script id="dashboard-data" type="application/json">${json}</script>
|
|
957
|
+
<main class="page" id="dashboard-root" data-testid="dashboard-root">
|
|
958
|
+
<div class="topbar">
|
|
959
|
+
<div class="breadcrumb" id="breadcrumb"></div>
|
|
960
|
+
<div class="switches">
|
|
961
|
+
<div class="seg" role="group" aria-label="View mode" data-testid="view-switch">
|
|
962
|
+
<button type="button" data-view-button="compact">Compact</button>
|
|
963
|
+
<button type="button" data-view-button="expanded">Expanded</button>
|
|
964
|
+
<button type="button" data-view-button="focus">Focus</button>
|
|
965
|
+
</div>
|
|
966
|
+
<div class="seg" role="group" aria-label="Theme mode" data-testid="theme-switch">
|
|
967
|
+
<button type="button" data-theme-button="system">System</button>
|
|
968
|
+
<button type="button" data-theme-button="light">Light</button>
|
|
969
|
+
<button type="button" data-theme-button="dark">Dark</button>
|
|
970
|
+
</div>
|
|
971
|
+
</div>
|
|
972
|
+
</div>
|
|
973
|
+
<section class="hero">
|
|
974
|
+
<section class="card headline">
|
|
975
|
+
<div><div class="eyebrow" id="eyebrow"></div><h1 id="title"></h1><p class="lead" id="lead"></p></div>
|
|
976
|
+
<div class="meta" id="meta"></div>
|
|
977
|
+
</section>
|
|
978
|
+
<aside class="card verdict"><div class="verdictLabel">dd-flow dashboard</div><div class="verdictText" id="verdictText"></div><div class="sub" id="verdictNote"></div></aside>
|
|
979
|
+
</section>
|
|
980
|
+
<section class="metricGrid" id="metrics" data-testid="metric-strip"></section>
|
|
981
|
+
<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>
|
|
982
|
+
<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>
|
|
983
|
+
<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>
|
|
984
|
+
<section class="card panel"><div class="sectionHead"><h2>Навигация и артефакты</h2></div><div class="list" id="links"></div></section>
|
|
985
|
+
<section class="card panel"><div class="sectionHead"><h2>Warnings</h2></div><div class="list" id="warnings"></div></section>
|
|
986
|
+
</main>
|
|
987
|
+
<script>
|
|
988
|
+
const data = JSON.parse(document.getElementById("dashboard-data").textContent);
|
|
989
|
+
const root = document.getElementById("dashboard-root");
|
|
990
|
+
const storagePrefix = "dd-flow.dashboard.";
|
|
991
|
+
const page = data.page || {};
|
|
992
|
+
root.dataset.screenId = page.screen_id || "";
|
|
993
|
+
function text(id, value){ document.getElementById(id).textContent = value == null ? "" : String(value); }
|
|
994
|
+
function el(tag, className, value){ const node = document.createElement(tag); if(className) node.className = className; if(value != null) node.textContent = String(value); return node; }
|
|
995
|
+
function safeHref(href){ const value = String(href || ""); return /^javascript:/i.test(value) ? "" : value; }
|
|
996
|
+
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; }
|
|
997
|
+
function statusBadge(value){ const b = el("span","badge " + String(value || "unknown"), value || "unknown"); return b; }
|
|
998
|
+
function metricValue(metric){ return metric && metric.value !== null && metric.value !== undefined ? metric.value : "N/A"; }
|
|
999
|
+
function pageKind(){ return page.kind || "dashboard"; }
|
|
1000
|
+
function cards(){ if(pageKind()==="global") return data.project_cards || []; if(pageKind()==="project") return data.protocol_cards || []; return data.run_history || []; }
|
|
1001
|
+
function focusable(card){ return ["actionable","blocked","watch"].includes(card.action_level) || ["blocked","failed","stale","unknown","waiting_for_user","ready_for_merge","queued"].includes(card.display_status) || Number(card.active_def_count || card.metrics?.open_defs || 0) > 0; }
|
|
1002
|
+
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(); } }
|
|
1003
|
+
document.querySelectorAll("[data-theme-button]").forEach(b => b.addEventListener("click", () => setMode("theme", b.dataset.themeButton)));
|
|
1004
|
+
document.querySelectorAll("[data-view-button]").forEach(b => b.addEventListener("click", () => setMode("view", b.dataset.viewButton)));
|
|
1005
|
+
function renderHeader(){
|
|
1006
|
+
text("breadcrumb", ["dd-flow", page.kind, data.project?.title || data.project?.id, data.protocol?.short_id].filter(Boolean).join(" / "));
|
|
1007
|
+
text("eyebrow", page.screen_id || "dd-flow");
|
|
1008
|
+
text("title", page.title || "dd-flow dashboard");
|
|
1009
|
+
const lead = pageKind()==="global" ? "Общий обзор локальной машины: проекты, активные протоколы, очереди, locks и degraded состояния." : pageKind()==="project" ? "Рабочая карта проекта: протоколы, сессии, очередь merge, locks и DEF." : "Страница протокола как задачи: состояние, run history, stage reports и next action.";
|
|
1010
|
+
text("lead", lead); text("verdictText", pageKind()); text("verdictNote", data.protocol?.next_action || data.primary_run_reason || "fresh local static dashboard");
|
|
1011
|
+
const meta = document.getElementById("meta"); meta.replaceChildren();
|
|
1012
|
+
[["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); });
|
|
1013
|
+
}
|
|
1014
|
+
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); }); }
|
|
1015
|
+
function renderCards(){
|
|
1016
|
+
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");
|
|
1017
|
+
text("cardsTitle", pageKind()==="global" ? "Проекты" : pageKind()==="project" ? "Протоколы" : "Run history");
|
|
1018
|
+
const box=document.getElementById("cards"); box.replaceChildren();
|
|
1019
|
+
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";
|
|
1020
|
+
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);
|
|
1021
|
+
const sub = el("div","sub", card.root || card.current_stage || card.next_action || card.run_index_path || ""); sub.title = sub.textContent; c.append(sub);
|
|
1022
|
+
const badges=el("div","badges"); [card.display_status || card.status, card.display_flow_kind || card.flow_kind, card.action_level].filter(Boolean).forEach(v => badges.append(statusBadge(v))); c.append(badges);
|
|
1023
|
+
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); });
|
|
1024
|
+
}
|
|
1025
|
+
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); }); } }
|
|
1026
|
+
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); }); }); }
|
|
1027
|
+
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); }); }
|
|
1028
|
+
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)))); }
|
|
1029
|
+
renderHeader(); renderMetrics(); renderLinks(); renderSetBoard(); renderReviewRuns(); renderWarnings();
|
|
1030
|
+
const savedTheme = ["system","light","dark"].includes(localStorage.getItem(storagePrefix+"theme")) ? localStorage.getItem(storagePrefix+"theme") : "system";
|
|
1031
|
+
const savedView = ["compact","expanded","focus"].includes(localStorage.getItem(storagePrefix+"view")) ? localStorage.getItem(storagePrefix+"view") : "compact";
|
|
1032
|
+
setMode("theme", savedTheme); setMode("view", savedView);
|
|
1033
|
+
</script>
|
|
1034
|
+
</body>
|
|
1035
|
+
</html>`;
|
|
1036
|
+
}
|
|
1037
|
+
function pathToFileUrl(filePath) {
|
|
1038
|
+
return `file://${path.resolve(filePath).split(path.sep).map(encodeURIComponent).join("/")}`;
|
|
1039
|
+
}
|
|
1040
|
+
function writeJsonFile(output, data) {
|
|
1041
|
+
ensureDir(path.dirname(output));
|
|
1042
|
+
const tmpFile = `${output}.tmp-${process.pid}-${Date.now()}`;
|
|
1043
|
+
fs.writeFileSync(tmpFile, `${JSON.stringify(data, null, 2)}\n`);
|
|
1044
|
+
fs.renameSync(tmpFile, output);
|
|
1045
|
+
}
|
|
1046
|
+
function writeTextFile(output, text) {
|
|
1047
|
+
ensureDir(path.dirname(output));
|
|
1048
|
+
fs.writeFileSync(output, text);
|
|
1049
|
+
}
|
|
405
1050
|
function writeMarkdown(output, markdown) {
|
|
406
1051
|
ensureDir(path.dirname(output));
|
|
407
1052
|
fs.writeFileSync(output, markdown);
|