@shapeshift-labs/frontier-loom-ui 0.1.2 → 0.1.4
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 +8 -8
- package/dist/cli.js +1 -6
- package/dist/cli.js.map +1 -1
- package/dist/client.js +710 -13
- package/dist/client.js.map +1 -1
- package/dist/public/styles.css +168 -0
- package/dist/server.js +1701 -84
- package/dist/server.js.map +1 -1
- package/features/loom-dashboard-substrate-integration.json +58 -0
- package/package.json +6 -2
package/dist/server.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import { watch } from 'node:fs';
|
|
1
|
+
import { existsSync, watch } from 'node:fs';
|
|
2
2
|
import { spawn, spawnSync } from 'node:child_process';
|
|
3
3
|
import fs from 'node:fs/promises';
|
|
4
4
|
import http from 'node:http';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { FRONTIER_RUN_VERSION } from '@shapeshift-labs/frontier-run';
|
|
8
|
+
import { FRONTIER_SEMANTIC_LEASE_EVENT_KIND, FRONTIER_SEMANTIC_LEASE_RECORD_KIND, FRONTIER_SEMANTIC_LEASE_STATE_KIND } from '@shapeshift-labs/frontier-lease';
|
|
9
|
+
import { FRONTIER_SWARM_GIT_APPLY_LEDGER_KIND, FRONTIER_SWARM_GIT_LINK_REPAIR_KIND, FRONTIER_SWARM_GIT_WORKSPACE_MANIFEST_KIND, FRONTIER_SWARM_GIT_WORKSPACE_PROOF_KIND } from '@shapeshift-labs/frontier-swarm-git';
|
|
7
10
|
import { estimateCodexModelCost, readCodexDashboardSnapshot } from '@shapeshift-labs/frontier-swarm-codex';
|
|
11
|
+
import { FRONTIER_TEST_GATE_EXECUTION_KIND } from '@shapeshift-labs/frontier-test';
|
|
8
12
|
const packageDir = path.dirname(fileURLToPath(import.meta.url));
|
|
9
13
|
const HEALTH_JSON_PARSE_MAX_BYTES = 16 * 1024 * 1024;
|
|
10
14
|
const TASK_DETAIL_PATCH_MAX_BYTES = 512 * 1024;
|
|
@@ -22,11 +26,15 @@ const LIFETIME_DASHBOARD_MAX_AUTONOMOUS_DECISION_FILES = 400;
|
|
|
22
26
|
const LIFETIME_DASHBOARD_MAX_DRAIN_RUNS = 6;
|
|
23
27
|
const LIFETIME_DASHBOARD_MAX_ACTIVE_PID_RUNS = 32;
|
|
24
28
|
const LIFETIME_DASHBOARD_MAX_QUEUE_TASKS = 500;
|
|
25
|
-
const LIFETIME_DASHBOARD_SOURCE_TIMEOUT_MS =
|
|
29
|
+
const LIFETIME_DASHBOARD_SOURCE_TIMEOUT_MS = 8000;
|
|
30
|
+
const LIFETIME_DASHBOARD_MAX_SUBSTRATE_FILES = 800;
|
|
31
|
+
const LIFETIME_DASHBOARD_SUBSTRATE_MAX_BYTES = 4 * 1024 * 1024;
|
|
26
32
|
const LIFETIME_DASHBOARD_RESET_FILE = '.loom-ui-reset.json';
|
|
27
33
|
const REVIEW_DECISIONS_FILE = '.loom-ui-review-decisions.json';
|
|
28
|
-
const
|
|
34
|
+
const LIVE_RUN_GRAPH_EVENTS_FILE = 'live-run-graph-events.jsonl';
|
|
29
35
|
let dashboardSnapshotCache;
|
|
36
|
+
let dashboardSnapshotCacheGeneration = 0;
|
|
37
|
+
const dashboardStreamListeners = new Set();
|
|
30
38
|
export function createLoomUiServer(options = {}) {
|
|
31
39
|
const normalized = normalizeServerOptions(options);
|
|
32
40
|
const server = http.createServer(async (request, response) => {
|
|
@@ -96,6 +104,9 @@ async function handleRequest(request, response, options) {
|
|
|
96
104
|
else if (request.method === 'GET' && url.pathname === '/vendor/frontier-dom/jsx-runtime.js') {
|
|
97
105
|
await serveFile(response, resolveFrontierDomRuntime(), 'application/javascript; charset=utf-8');
|
|
98
106
|
}
|
|
107
|
+
else if (request.method === 'GET' && url.pathname === '/favicon.ico') {
|
|
108
|
+
serveFavicon(response);
|
|
109
|
+
}
|
|
99
110
|
else if (request.method === 'GET') {
|
|
100
111
|
const file = staticFile(options.staticDir, url.pathname);
|
|
101
112
|
await serveFile(response, file, contentType(file));
|
|
@@ -115,29 +126,48 @@ async function streamDashboard(request, response, options) {
|
|
|
115
126
|
response.write(': connected\n\n');
|
|
116
127
|
let closed = false;
|
|
117
128
|
let pending = false;
|
|
129
|
+
let queued = false;
|
|
118
130
|
let lastSignature = '';
|
|
131
|
+
let lastSnapshot;
|
|
132
|
+
const writeSnapshot = (snapshot) => {
|
|
133
|
+
const signature = dashboardStreamSignature(snapshot);
|
|
134
|
+
const body = JSON.stringify(snapshot);
|
|
135
|
+
if (signature !== lastSignature) {
|
|
136
|
+
lastSignature = signature;
|
|
137
|
+
lastSnapshot = snapshot;
|
|
138
|
+
response.write(`data: ${body}\n\n`);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
119
141
|
const send = async () => {
|
|
120
|
-
if (closed
|
|
142
|
+
if (closed)
|
|
121
143
|
return;
|
|
144
|
+
if (pending) {
|
|
145
|
+
queued = true;
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
122
148
|
pending = true;
|
|
149
|
+
queued = false;
|
|
123
150
|
try {
|
|
124
151
|
const snapshot = await readDashboardSnapshotCached(options);
|
|
125
|
-
|
|
126
|
-
const body = JSON.stringify(snapshot);
|
|
127
|
-
if (signature !== lastSignature) {
|
|
128
|
-
lastSignature = signature;
|
|
129
|
-
response.write(`data: ${body}\n\n`);
|
|
130
|
-
}
|
|
152
|
+
writeSnapshot(snapshot);
|
|
131
153
|
}
|
|
132
154
|
catch (error) {
|
|
133
155
|
response.write(`event: error\ndata: ${JSON.stringify({ ok: false, error: errorMessage(error) })}\n\n`);
|
|
134
156
|
}
|
|
135
157
|
finally {
|
|
136
158
|
pending = false;
|
|
159
|
+
if (queued && !closed) {
|
|
160
|
+
queued = false;
|
|
161
|
+
setTimeout(() => {
|
|
162
|
+
void send();
|
|
163
|
+
}, 0);
|
|
164
|
+
}
|
|
137
165
|
}
|
|
138
166
|
};
|
|
139
167
|
const trigger = debounce(send, 120);
|
|
140
|
-
const directTrigger = () => {
|
|
168
|
+
const directTrigger = (hint) => {
|
|
169
|
+
if (hint && lastSnapshot)
|
|
170
|
+
writeSnapshot(applyDashboardStreamHint(lastSnapshot, hint));
|
|
141
171
|
void send();
|
|
142
172
|
};
|
|
143
173
|
const watchers = await createDashboardWatchers(options, trigger);
|
|
@@ -168,6 +198,27 @@ function dashboardStreamSignature(snapshot) {
|
|
|
168
198
|
const { generatedAt: _generatedAt, ...stableSnapshot } = snapshot;
|
|
169
199
|
return JSON.stringify(stableSnapshot);
|
|
170
200
|
}
|
|
201
|
+
function applyDashboardStreamHint(snapshot, hint) {
|
|
202
|
+
if (!hint.humanActionAnswer || !snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot))
|
|
203
|
+
return snapshot;
|
|
204
|
+
const record = snapshot;
|
|
205
|
+
const existing = recordArray(record.humanActionAnswers);
|
|
206
|
+
const alreadyPresent = existing.some((row) => {
|
|
207
|
+
return textValue(row.code, '') === hint.humanActionAnswer?.code
|
|
208
|
+
&& textValue(row.answer, '') === hint.humanActionAnswer?.answer
|
|
209
|
+
&& numberValue(row.at) === hint.humanActionAnswer?.at;
|
|
210
|
+
});
|
|
211
|
+
const humanActionAnswers = alreadyPresent ? existing : [...existing, hint.humanActionAnswer];
|
|
212
|
+
return {
|
|
213
|
+
...record,
|
|
214
|
+
generatedAt: Date.now(),
|
|
215
|
+
humanActionAnswers,
|
|
216
|
+
sources: {
|
|
217
|
+
...recordValue(record.sources),
|
|
218
|
+
...(hint.answerPath ? { humanActionAnswers: hint.answerPath } : {})
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
}
|
|
171
222
|
function debounce(fn, delayMs) {
|
|
172
223
|
let timer;
|
|
173
224
|
return () => {
|
|
@@ -216,23 +267,16 @@ function watchDirectory(root, recursive, onChange) {
|
|
|
216
267
|
}
|
|
217
268
|
}
|
|
218
269
|
async function dashboardWatchRoots(options) {
|
|
219
|
-
const inputs = [options.run, options.collection, options.continuation].filter((value) => Boolean(value));
|
|
220
270
|
const roots = [];
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
roots.push(
|
|
227
|
-
|
|
228
|
-
if (
|
|
229
|
-
|
|
230
|
-
if (await fileExists(agentRuns))
|
|
231
|
-
roots.push(agentRuns);
|
|
232
|
-
const loomQueues = path.join(options.cwd, '.loom', 'queues');
|
|
233
|
-
if (await fileExists(loomQueues))
|
|
234
|
-
roots.push(loomQueues);
|
|
235
|
-
}
|
|
271
|
+
const agentRuns = path.join(options.cwd, 'agent-runs');
|
|
272
|
+
if (await fileExists(agentRuns))
|
|
273
|
+
roots.push(agentRuns);
|
|
274
|
+
const loomRoot = path.join(options.cwd, '.loom');
|
|
275
|
+
if (await fileExists(loomRoot))
|
|
276
|
+
roots.push(loomRoot);
|
|
277
|
+
const loomQueues = path.join(options.cwd, '.loom', 'queues');
|
|
278
|
+
if (await fileExists(loomQueues))
|
|
279
|
+
roots.push(loomQueues);
|
|
236
280
|
return uniquePaths(roots);
|
|
237
281
|
}
|
|
238
282
|
function uniquePaths(values) {
|
|
@@ -240,7 +284,6 @@ function uniquePaths(values) {
|
|
|
240
284
|
}
|
|
241
285
|
function normalizeServerOptions(options) {
|
|
242
286
|
return {
|
|
243
|
-
...options,
|
|
244
287
|
cwd: path.resolve(options.cwd ?? process.cwd()),
|
|
245
288
|
host: options.host ?? '127.0.0.1',
|
|
246
289
|
port: normalizePort(options.port),
|
|
@@ -266,13 +309,12 @@ function dashboardInput(options) {
|
|
|
266
309
|
};
|
|
267
310
|
}
|
|
268
311
|
async function readDashboardSnapshot(options) {
|
|
269
|
-
|
|
270
|
-
return readLifetimeDashboardSnapshot(options);
|
|
271
|
-
return readScopedDashboardSnapshot(options);
|
|
312
|
+
return readLifetimeDashboardSnapshot(options);
|
|
272
313
|
}
|
|
273
314
|
async function readDashboardSnapshotCached(options) {
|
|
274
315
|
const key = JSON.stringify(dashboardInput(options));
|
|
275
316
|
const now = Date.now();
|
|
317
|
+
const previousValue = dashboardSnapshotCache?.key === key ? dashboardSnapshotCache.value : undefined;
|
|
276
318
|
if (dashboardSnapshotCache?.key === key) {
|
|
277
319
|
if (dashboardSnapshotCache.value !== undefined && now - dashboardSnapshotCache.at < DASHBOARD_SNAPSHOT_CACHE_MS) {
|
|
278
320
|
return dashboardSnapshotCache.value;
|
|
@@ -280,21 +322,52 @@ async function readDashboardSnapshotCached(options) {
|
|
|
280
322
|
if (dashboardSnapshotCache.pending)
|
|
281
323
|
return dashboardSnapshotCache.pending;
|
|
282
324
|
}
|
|
325
|
+
const generation = dashboardSnapshotCacheGeneration;
|
|
283
326
|
const pending = readDashboardSnapshot(options).then((value) => {
|
|
284
|
-
|
|
285
|
-
|
|
327
|
+
const stableValue = stableDashboardSnapshotValue(previousValue, value);
|
|
328
|
+
if (generation === dashboardSnapshotCacheGeneration) {
|
|
329
|
+
dashboardSnapshotCache = { key, at: Date.now(), value: stableValue };
|
|
330
|
+
}
|
|
331
|
+
return stableValue;
|
|
286
332
|
}, (error) => {
|
|
287
|
-
if (dashboardSnapshotCache?.key === key) {
|
|
333
|
+
if (generation === dashboardSnapshotCacheGeneration && dashboardSnapshotCache?.key === key) {
|
|
288
334
|
dashboardSnapshotCache = dashboardSnapshotCache.value === undefined
|
|
289
335
|
? undefined
|
|
290
336
|
: { key, at: dashboardSnapshotCache.at, value: dashboardSnapshotCache.value };
|
|
291
337
|
}
|
|
292
338
|
throw error;
|
|
293
339
|
});
|
|
294
|
-
dashboardSnapshotCache = { key, at: now, value:
|
|
340
|
+
dashboardSnapshotCache = { key, at: now, value: previousValue, pending };
|
|
295
341
|
return pending;
|
|
296
342
|
}
|
|
343
|
+
function stableDashboardSnapshotValue(previous, next) {
|
|
344
|
+
if (!shouldKeepPreviousDashboardSnapshot(previous, next))
|
|
345
|
+
return next;
|
|
346
|
+
return previous;
|
|
347
|
+
}
|
|
348
|
+
function shouldKeepPreviousDashboardSnapshot(previous, next) {
|
|
349
|
+
const previousRecord = recordValue(previous);
|
|
350
|
+
const nextRecord = recordValue(next);
|
|
351
|
+
if (!Object.keys(previousRecord).length || !Object.keys(nextRecord).length)
|
|
352
|
+
return false;
|
|
353
|
+
const nextSources = recordValue(nextRecord.sources);
|
|
354
|
+
const timedOutSources = numberValue(nextSources.timedOutSourceCount);
|
|
355
|
+
if (!timedOutSources)
|
|
356
|
+
return false;
|
|
357
|
+
const previousJobCount = dashboardSnapshotJobCount(previousRecord);
|
|
358
|
+
const nextJobCount = dashboardSnapshotJobCount(nextRecord);
|
|
359
|
+
if (!previousJobCount || previousJobCount <= nextJobCount)
|
|
360
|
+
return false;
|
|
361
|
+
const previousSources = recordValue(previousRecord.sources);
|
|
362
|
+
const previousLoaded = numberValue(previousSources.loadedSourceCount);
|
|
363
|
+
const nextLoaded = numberValue(nextSources.loadedSourceCount);
|
|
364
|
+
return nextJobCount === 0 || !nextLoaded || nextLoaded < previousLoaded;
|
|
365
|
+
}
|
|
366
|
+
function dashboardSnapshotJobCount(snapshot) {
|
|
367
|
+
return recordArray(snapshot.jobs).length || numberValue(recordValue(snapshot.summary).jobCount);
|
|
368
|
+
}
|
|
297
369
|
function invalidateDashboardSnapshotCache() {
|
|
370
|
+
dashboardSnapshotCacheGeneration += 1;
|
|
298
371
|
dashboardSnapshotCache = undefined;
|
|
299
372
|
}
|
|
300
373
|
async function readScopedDashboardSnapshot(options, readOptions = {}) {
|
|
@@ -307,16 +380,25 @@ async function readScopedDashboardSnapshot(options, readOptions = {}) {
|
|
|
307
380
|
return activeRunSnapshot ?? snapshot;
|
|
308
381
|
const answers = await readHumanActionAnswers(options);
|
|
309
382
|
const record = snapshot;
|
|
383
|
+
const collectionGraph = await readDashboardRunGraphSummary(options, record);
|
|
310
384
|
const jobs = Array.isArray(record.jobs) ? record.jobs : [];
|
|
311
385
|
const activeJobs = recordArray(activeRunSnapshot?.jobs);
|
|
312
386
|
if (shouldPreferActiveRunSnapshot(jobs, activeJobs)) {
|
|
313
387
|
const activeAgentRows = activeAgentsFromJobs(activeJobs);
|
|
388
|
+
const activeGraph = recordValue(activeRunSnapshot?.graph);
|
|
389
|
+
const graph = Object.keys(activeGraph).length ? activeGraph : collectionGraph;
|
|
314
390
|
return {
|
|
315
391
|
...activeRunSnapshot,
|
|
316
392
|
collectionJobs: jobs,
|
|
317
393
|
activeAgents: activeAgentRows,
|
|
394
|
+
health: lifetimeHealthSummary(activeJobs),
|
|
318
395
|
humanActions: recordArray(record.humanActions),
|
|
319
396
|
humanActionAnswers: answers,
|
|
397
|
+
...(graph ? { graph } : {}),
|
|
398
|
+
summary: {
|
|
399
|
+
...recordValue(activeRunSnapshot?.summary),
|
|
400
|
+
...(graph ? { graph } : {})
|
|
401
|
+
},
|
|
320
402
|
sources: {
|
|
321
403
|
...recordValue(record.sources),
|
|
322
404
|
...recordValue(activeRunSnapshot?.sources),
|
|
@@ -334,9 +416,26 @@ async function readScopedDashboardSnapshot(options, readOptions = {}) {
|
|
|
334
416
|
}
|
|
335
417
|
};
|
|
336
418
|
}
|
|
419
|
+
const normalizedSnapshot = normalizeCoordinatorFacingSnapshot(record);
|
|
337
420
|
const mergedJobs = applyCoordinatorReviewDecisions(mergeActiveRunJobTelemetry(jobs, activeJobs), decisions).map(withRecomputedCostFields);
|
|
421
|
+
const normalizedSummary = recordValue(normalizedSnapshot.summary);
|
|
422
|
+
const adjustedSummary = reviewDecisionAdjustedSummary(normalizedSummary, mergedJobs);
|
|
423
|
+
const rawCollectionSummary = recordValue(recordValue(recordValue(record.raw).collection).summary);
|
|
424
|
+
const semanticSummary = {
|
|
425
|
+
...rawCollectionSummary,
|
|
426
|
+
...adjustedSummary,
|
|
427
|
+
bucketCounts: recordValue(normalizedSummary.bucketCounts)
|
|
428
|
+
};
|
|
429
|
+
const graph = withDashboardRunGraphJobHealth(collectionGraph, mergedJobs);
|
|
338
430
|
return {
|
|
339
|
-
...
|
|
431
|
+
...normalizedSnapshot,
|
|
432
|
+
...(graph ? { graph } : {}),
|
|
433
|
+
summary: {
|
|
434
|
+
...adjustedSummary,
|
|
435
|
+
...(graph ? { graph } : {})
|
|
436
|
+
},
|
|
437
|
+
health: lifetimeHealthSummary(mergedJobs),
|
|
438
|
+
semantic: semanticWithHealth(recordValue(normalizedSnapshot.semantic), semanticSummary, mergedJobs),
|
|
340
439
|
jobs: mergedJobs,
|
|
341
440
|
activeAgents: activeAgentsFromJobs(mergedJobs),
|
|
342
441
|
humanActionAnswers: answers,
|
|
@@ -348,31 +447,316 @@ async function readScopedDashboardSnapshot(options, readOptions = {}) {
|
|
|
348
447
|
}
|
|
349
448
|
};
|
|
350
449
|
}
|
|
450
|
+
async function readDashboardRunGraphSummary(options, snapshot) {
|
|
451
|
+
const embedded = recordValue(recordValue(recordValue(snapshot.raw).collection).runGraph);
|
|
452
|
+
if (Object.keys(embedded).length) {
|
|
453
|
+
return summarizeDashboardRunGraph(embedded, {
|
|
454
|
+
sourceFile: textValue(embedded.id, 'embedded'),
|
|
455
|
+
sourceKind: 'embedded-run-graph',
|
|
456
|
+
sourceStatus: 'collected'
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
const source = await resolveDashboardRunGraphSource(options, snapshot);
|
|
460
|
+
if (source?.file) {
|
|
461
|
+
return summarizeDashboardRunGraph(recordValue(await readJsonFile(source.file)), {
|
|
462
|
+
sourceFile: path.relative(options.cwd, source.file).replaceAll(path.sep, '/'),
|
|
463
|
+
sourceKind: 'collected-run-graph',
|
|
464
|
+
sourceStatus: 'collected'
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
if (source?.expectedFile) {
|
|
468
|
+
return missingDashboardRunGraphSummary(options.cwd, {
|
|
469
|
+
expectedFile: source.expectedFile,
|
|
470
|
+
sourceKind: 'collected-run-graph',
|
|
471
|
+
warning: 'Collected dashboard source has no run-graph.json.'
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
return undefined;
|
|
475
|
+
}
|
|
476
|
+
async function resolveDashboardRunGraphSource(options, snapshot) {
|
|
477
|
+
const sources = recordValue(snapshot.sources);
|
|
478
|
+
const candidates = uniquePaths([
|
|
479
|
+
textValue(options.collection, ''),
|
|
480
|
+
textValue(sources.collectionDir, ''),
|
|
481
|
+
path.dirname(textValue(sources.collectionFile, ''))
|
|
482
|
+
].filter(Boolean));
|
|
483
|
+
let expectedFile;
|
|
484
|
+
for (const candidate of candidates) {
|
|
485
|
+
const absolute = path.resolve(options.cwd, candidate);
|
|
486
|
+
if (!isPathInside(options.cwd, absolute))
|
|
487
|
+
continue;
|
|
488
|
+
const stat = await fs.lstat(absolute).catch(() => undefined);
|
|
489
|
+
if (!stat)
|
|
490
|
+
continue;
|
|
491
|
+
const file = stat.isDirectory()
|
|
492
|
+
? path.join(absolute, 'run-graph.json')
|
|
493
|
+
: path.basename(absolute) === 'run-graph.json'
|
|
494
|
+
? absolute
|
|
495
|
+
: path.join(path.dirname(absolute), 'run-graph.json');
|
|
496
|
+
expectedFile ??= file;
|
|
497
|
+
const fileStat = await fs.stat(file).catch(() => undefined);
|
|
498
|
+
if (fileStat?.isFile() && isPathInside(options.cwd, file))
|
|
499
|
+
return { file, expectedFile: file };
|
|
500
|
+
}
|
|
501
|
+
return expectedFile ? { expectedFile } : undefined;
|
|
502
|
+
}
|
|
503
|
+
function missingDashboardRunGraphSummary(cwd, input) {
|
|
504
|
+
const sourceFile = path.relative(cwd, input.expectedFile).replaceAll(path.sep, '/');
|
|
505
|
+
const warning = `${input.warning} Expected ${sourceFile}.`;
|
|
506
|
+
return {
|
|
507
|
+
sourceFile,
|
|
508
|
+
sourceFiles: [sourceFile],
|
|
509
|
+
sourceKind: input.sourceKind,
|
|
510
|
+
sourceKinds: [input.sourceKind],
|
|
511
|
+
sourceStatus: 'missing',
|
|
512
|
+
sourceStatuses: ['missing'],
|
|
513
|
+
graphMissing: true,
|
|
514
|
+
graphMissingWarningCount: 1,
|
|
515
|
+
graphMissingWarnings: [warning],
|
|
516
|
+
nodeCount: 0,
|
|
517
|
+
edgeCount: 0,
|
|
518
|
+
blockerCount: 0,
|
|
519
|
+
openBlockerCount: 0,
|
|
520
|
+
humanQuestionCount: 0,
|
|
521
|
+
openHumanQuestionCount: 0,
|
|
522
|
+
safeMergeCandidateCount: 0,
|
|
523
|
+
decisionCount: 0,
|
|
524
|
+
terminalDecisionCount: 0,
|
|
525
|
+
terminalAcceptedCount: 0,
|
|
526
|
+
terminalRejectedCount: 0,
|
|
527
|
+
terminalRerunCount: 0,
|
|
528
|
+
gateCount: 0,
|
|
529
|
+
gatePassedCount: 0,
|
|
530
|
+
gateFailedCount: 0,
|
|
531
|
+
staleCount: 0,
|
|
532
|
+
openStaleCount: 0,
|
|
533
|
+
rerunCount: 0,
|
|
534
|
+
openRerunCount: 0,
|
|
535
|
+
staleRerunCleanupCount: 0,
|
|
536
|
+
status: 'missing',
|
|
537
|
+
summaryLine: warning,
|
|
538
|
+
recentEvents: []
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
function summarizeDashboardRunGraph(graph, input) {
|
|
542
|
+
const summary = recordValue(graph.summary);
|
|
543
|
+
const nodes = recordArray(graph.nodes);
|
|
544
|
+
const edges = recordArray(graph.edges);
|
|
545
|
+
const nodeCount = numberValue(summary.nodeCount) || nodes.length;
|
|
546
|
+
const edgeCount = numberValue(summary.edgeCount) || edges.length;
|
|
547
|
+
if (!nodeCount && !edgeCount && !input.graphMissing)
|
|
548
|
+
return undefined;
|
|
549
|
+
const gateNodes = nodes.filter((node) => normalized(node.kind) === 'gate');
|
|
550
|
+
const decisionNodes = nodes.filter((node) => normalized(node.kind) === 'decision');
|
|
551
|
+
const terminalDecisionNodes = decisionNodes.filter(runGraphNodeIsTerminalDecision);
|
|
552
|
+
const recentEvents = nodes
|
|
553
|
+
.filter((node) => numberValue(node.generatedAt))
|
|
554
|
+
.sort((left, right) => numberValue(left.generatedAt) - numberValue(right.generatedAt))
|
|
555
|
+
.slice(-8)
|
|
556
|
+
.map((node) => ({
|
|
557
|
+
type: textValue(node.kind, 'graph'),
|
|
558
|
+
at: numberValue(node.generatedAt),
|
|
559
|
+
jobId: textValue(node.jobId, ''),
|
|
560
|
+
taskId: textValue(node.taskId, ''),
|
|
561
|
+
status: textValue(node.status ?? node.outcome, ''),
|
|
562
|
+
message: textValue(node.label ?? node.id, 'graph node')
|
|
563
|
+
}));
|
|
564
|
+
const blockerCount = nodes.filter(runGraphNodeIsBlocker).length;
|
|
565
|
+
const humanQuestionCount = nodes.filter(runGraphNodeIsHumanQuestion).length;
|
|
566
|
+
const safeMergeCandidateCount = nodes.filter(runGraphNodeIsSafeMergeCandidate).length;
|
|
567
|
+
const terminalAcceptedCount = terminalDecisionNodes.filter(runGraphNodeIsAcceptedTerminalDecision).length;
|
|
568
|
+
const terminalRejectedCount = terminalDecisionNodes.filter(runGraphNodeIsRejectedTerminalDecision).length;
|
|
569
|
+
const terminalRerunCount = terminalDecisionNodes.filter(runGraphNodeIsRerunTerminalDecision).length;
|
|
570
|
+
const gateFailedCount = gateNodes.filter((node) => normalized(node.status) === 'failed').length;
|
|
571
|
+
const staleCount = firstNumber(summary.staleCount, summary.staleAgainstHeadCount, summary['stale-against-head']) || nodes.filter(runGraphNodeIsStale).length;
|
|
572
|
+
const rerunCount = firstNumber(summary.rerunCount, summary['rerun-work']) || nodes.filter(runGraphNodeIsRerun).length;
|
|
573
|
+
const staleRerunCleanupCount = nodes.filter(runGraphNodeIsStaleRerunCleanup).length;
|
|
574
|
+
const graphMissingWarnings = uniquePaths(input.graphMissingWarnings ?? []);
|
|
575
|
+
const status = blockerCount
|
|
576
|
+
? 'blocked'
|
|
577
|
+
: humanQuestionCount
|
|
578
|
+
? 'questions'
|
|
579
|
+
: gateFailedCount
|
|
580
|
+
? 'review'
|
|
581
|
+
: terminalRerunCount
|
|
582
|
+
? 'rerun'
|
|
583
|
+
: input.graphMissing && !safeMergeCandidateCount
|
|
584
|
+
? 'missing'
|
|
585
|
+
: safeMergeCandidateCount || terminalAcceptedCount
|
|
586
|
+
? 'ready'
|
|
587
|
+
: 'clear';
|
|
588
|
+
return {
|
|
589
|
+
sourceFile: input.sourceFile,
|
|
590
|
+
sourceFiles: input.sourceFile ? [input.sourceFile] : [],
|
|
591
|
+
sourceKind: input.sourceKind,
|
|
592
|
+
sourceKinds: [input.sourceKind],
|
|
593
|
+
sourceStatus: input.sourceStatus,
|
|
594
|
+
sourceStatuses: [input.sourceStatus],
|
|
595
|
+
graphMissing: input.graphMissing === true,
|
|
596
|
+
graphMissingWarningCount: graphMissingWarnings.length,
|
|
597
|
+
graphMissingWarnings,
|
|
598
|
+
...(input.liveEventCount !== undefined ? { liveEventCount: input.liveEventCount } : {}),
|
|
599
|
+
nodeCount,
|
|
600
|
+
edgeCount,
|
|
601
|
+
blockerCount,
|
|
602
|
+
openBlockerCount: blockerCount,
|
|
603
|
+
humanQuestionCount,
|
|
604
|
+
openHumanQuestionCount: humanQuestionCount,
|
|
605
|
+
safeMergeCandidateCount,
|
|
606
|
+
decisionCount: numberValue(summary.decisionCount) || decisionNodes.length,
|
|
607
|
+
terminalDecisionCount: terminalDecisionNodes.length,
|
|
608
|
+
terminalAcceptedCount,
|
|
609
|
+
terminalRejectedCount,
|
|
610
|
+
terminalRerunCount,
|
|
611
|
+
gateCount: numberValue(summary.gateCount) || gateNodes.length,
|
|
612
|
+
gatePassedCount: gateNodes.filter((node) => normalized(node.status) === 'passed').length,
|
|
613
|
+
gateFailedCount,
|
|
614
|
+
staleCount,
|
|
615
|
+
openStaleCount: staleCount,
|
|
616
|
+
rerunCount,
|
|
617
|
+
openRerunCount: rerunCount,
|
|
618
|
+
staleRerunCleanupCount,
|
|
619
|
+
status,
|
|
620
|
+
summaryLine: `${nodeCount} nodes, ${edgeCount} edges, ${numberValue(summary.decisionCount) || decisionNodes.length} decisions, and ${numberValue(summary.gateCount) || gateNodes.length} gates.`,
|
|
621
|
+
recentEvents
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
function runGraphNodeIsBlocker(node) {
|
|
625
|
+
const status = normalized(node.status);
|
|
626
|
+
const outcome = normalized(node.outcome);
|
|
627
|
+
return status === 'blocked' || outcome === 'blocked' || outcome === 'conflict' || outcome === 'human-needed';
|
|
628
|
+
}
|
|
629
|
+
function runGraphNodeIsHumanQuestion(node) {
|
|
630
|
+
const status = normalized(node.status);
|
|
631
|
+
const outcome = normalized(node.outcome);
|
|
632
|
+
return status.includes('human') || outcome.includes('human') || status.includes('question') || outcome.includes('question');
|
|
633
|
+
}
|
|
634
|
+
function runGraphNodeIsSafeMergeCandidate(node) {
|
|
635
|
+
if (normalized(node.kind) !== 'candidate')
|
|
636
|
+
return false;
|
|
637
|
+
const bucket = normalized(node.bucket);
|
|
638
|
+
const data = recordValue(node.data);
|
|
639
|
+
return bucket === 'ready-to-apply' || data.autoMergeable === true || normalized(data.mergeReadiness) === 'ready-to-apply';
|
|
640
|
+
}
|
|
641
|
+
function runGraphNodeIsTerminalDecision(node) {
|
|
642
|
+
const kind = normalized(node.kind);
|
|
643
|
+
if (kind === 'terminal-outcome')
|
|
644
|
+
return true;
|
|
645
|
+
if (kind !== 'decision')
|
|
646
|
+
return false;
|
|
647
|
+
return textValue(node.id, '').startsWith('decision:terminal:')
|
|
648
|
+
|| normalized(recordValue(node.data).terminal) === 'true'
|
|
649
|
+
|| runGraphTerminalOutcome(node) !== '';
|
|
650
|
+
}
|
|
651
|
+
function runGraphNodeIsAcceptedTerminalDecision(node) {
|
|
652
|
+
return ['accepted', 'accepted-applied', 'applied', 'committed', 'completed', 'ok', 'ready', 'ready-to-apply', 'verified-patch'].includes(runGraphTerminalOutcome(node));
|
|
653
|
+
}
|
|
654
|
+
function runGraphNodeIsRejectedTerminalDecision(node) {
|
|
655
|
+
return ['blocked', 'conflict', 'conflict-blocked', 'failed', 'rejected'].includes(runGraphTerminalOutcome(node));
|
|
656
|
+
}
|
|
657
|
+
function runGraphNodeIsRerunTerminalDecision(node) {
|
|
658
|
+
return ['needs-rerun', 'rerun', 'rerun-work', 'stale', 'stale-against-head'].includes(runGraphTerminalOutcome(node));
|
|
659
|
+
}
|
|
660
|
+
function runGraphTerminalOutcome(node) {
|
|
661
|
+
const data = recordValue(node.data);
|
|
662
|
+
const values = [
|
|
663
|
+
node.outcome,
|
|
664
|
+
node.status,
|
|
665
|
+
data.outcome,
|
|
666
|
+
data.status,
|
|
667
|
+
data.mergeReadiness,
|
|
668
|
+
data.disposition
|
|
669
|
+
].map(normalized).filter(Boolean);
|
|
670
|
+
return values.find((value) => [
|
|
671
|
+
'accepted',
|
|
672
|
+
'accepted-applied',
|
|
673
|
+
'applied',
|
|
674
|
+
'blocked',
|
|
675
|
+
'committed',
|
|
676
|
+
'completed',
|
|
677
|
+
'conflict',
|
|
678
|
+
'conflict-blocked',
|
|
679
|
+
'failed',
|
|
680
|
+
'needs-rerun',
|
|
681
|
+
'ok',
|
|
682
|
+
'ready',
|
|
683
|
+
'ready-to-apply',
|
|
684
|
+
'rejected',
|
|
685
|
+
'rerun',
|
|
686
|
+
'rerun-work',
|
|
687
|
+
'stale',
|
|
688
|
+
'stale-against-head',
|
|
689
|
+
'verified-patch'
|
|
690
|
+
].includes(value)) ?? '';
|
|
691
|
+
}
|
|
692
|
+
function runGraphNodeIsStale(node) {
|
|
693
|
+
if (normalized(node.kind) === 'bucket')
|
|
694
|
+
return false;
|
|
695
|
+
const data = recordValue(node.data);
|
|
696
|
+
const values = [
|
|
697
|
+
node.bucket,
|
|
698
|
+
node.status,
|
|
699
|
+
node.outcome,
|
|
700
|
+
data.disposition,
|
|
701
|
+
data.mergeReadiness,
|
|
702
|
+
...(stringArray(data.reasons))
|
|
703
|
+
].map(normalized);
|
|
704
|
+
return data.staleAgainstHead === true || values.some((value) => value.includes('stale'));
|
|
705
|
+
}
|
|
706
|
+
function runGraphNodeIsRerun(node) {
|
|
707
|
+
if (normalized(node.kind) === 'bucket')
|
|
708
|
+
return false;
|
|
709
|
+
const data = recordValue(node.data);
|
|
710
|
+
const values = [
|
|
711
|
+
node.bucket,
|
|
712
|
+
node.status,
|
|
713
|
+
node.outcome,
|
|
714
|
+
data.disposition,
|
|
715
|
+
data.mergeReadiness,
|
|
716
|
+
...(stringArray(data.reasons))
|
|
717
|
+
].map(normalized);
|
|
718
|
+
return values.some((value) => value.includes('rerun'));
|
|
719
|
+
}
|
|
720
|
+
function runGraphNodeIsStaleRerunCleanup(node) {
|
|
721
|
+
if (!runGraphNodeIsTerminalDecision(node))
|
|
722
|
+
return false;
|
|
723
|
+
if (!runGraphNodeIsStale(node) && !runGraphNodeIsRerun(node))
|
|
724
|
+
return false;
|
|
725
|
+
return runGraphNodeIsAcceptedTerminalDecision(node) || runGraphNodeIsRejectedTerminalDecision(node) || runGraphNodeIsRerunTerminalDecision(node);
|
|
726
|
+
}
|
|
351
727
|
async function readLifetimeDashboardSnapshot(options) {
|
|
352
728
|
const sources = await discoverLifetimeDashboardSources(options.cwd);
|
|
353
729
|
const snapshots = [];
|
|
354
730
|
let skippedSourceCount = 0;
|
|
355
731
|
let timedOutSourceCount = 0;
|
|
356
|
-
|
|
732
|
+
const sourceResults = await Promise.all(sources.slice(0, LIFETIME_DASHBOARD_MAX_SOURCES).map(async (source) => {
|
|
357
733
|
try {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
734
|
+
return {
|
|
735
|
+
source,
|
|
736
|
+
snapshot: await withTimeout((async () => {
|
|
737
|
+
const scopedSnapshot = await readScopedDashboardSnapshot({
|
|
738
|
+
...options,
|
|
739
|
+
run: source.run,
|
|
740
|
+
collection: source.collection,
|
|
741
|
+
continuation: source.continuation
|
|
742
|
+
}, { includeActiveRun: false });
|
|
743
|
+
return enrichLifetimeRunSnapshotEvidence(options.cwd, source, recordValue(scopedSnapshot));
|
|
744
|
+
})(), LIFETIME_DASHBOARD_SOURCE_TIMEOUT_MS, `lifetime source timed out: ${source.path}`)
|
|
745
|
+
};
|
|
369
746
|
}
|
|
370
747
|
catch (error) {
|
|
748
|
+
return { source, error };
|
|
749
|
+
}
|
|
750
|
+
}));
|
|
751
|
+
for (const result of sourceResults) {
|
|
752
|
+
if ('error' in result) {
|
|
371
753
|
skippedSourceCount++;
|
|
372
|
-
if (error instanceof Error && error.message.startsWith('lifetime source timed out:'))
|
|
754
|
+
if (result.error instanceof Error && result.error.message.startsWith('lifetime source timed out:'))
|
|
373
755
|
timedOutSourceCount++;
|
|
374
756
|
continue;
|
|
375
757
|
}
|
|
758
|
+
if (Object.keys(result.snapshot).length)
|
|
759
|
+
snapshots.push({ source: result.source, snapshot: result.snapshot });
|
|
376
760
|
}
|
|
377
761
|
const lifetime = await combineLifetimeDashboardSnapshots(options, sources, snapshots, mergeReviewDecisionLists(await readCoordinatorReviewDecisions(options.cwd), await readAutonomousMergeDecisions(options.cwd)), await readLifetimeQueueBacklog(options.cwd));
|
|
378
762
|
const lifetimeWithSourceHealth = {
|
|
@@ -819,6 +1203,339 @@ async function findLifetimeDashboardArtifactFiles(root, input) {
|
|
|
819
1203
|
await walk(root, 0);
|
|
820
1204
|
return out;
|
|
821
1205
|
}
|
|
1206
|
+
async function readDashboardSubstrateSummary(cwd) {
|
|
1207
|
+
const roots = uniquePaths([
|
|
1208
|
+
path.join(cwd, 'agent-runs'),
|
|
1209
|
+
path.join(cwd, '.loom')
|
|
1210
|
+
]);
|
|
1211
|
+
const files = [];
|
|
1212
|
+
for (const root of roots) {
|
|
1213
|
+
const stat = await fs.stat(root).catch(() => undefined);
|
|
1214
|
+
if (!stat?.isDirectory())
|
|
1215
|
+
continue;
|
|
1216
|
+
files.push(...await findDashboardSubstrateFiles(root, {
|
|
1217
|
+
maxDepth: LIFETIME_DASHBOARD_SCAN_MAX_DEPTH + 1,
|
|
1218
|
+
maxFiles: Math.max(1, LIFETIME_DASHBOARD_MAX_SUBSTRATE_FILES - files.length)
|
|
1219
|
+
}));
|
|
1220
|
+
if (files.length >= LIFETIME_DASHBOARD_MAX_SUBSTRATE_FILES)
|
|
1221
|
+
break;
|
|
1222
|
+
}
|
|
1223
|
+
const records = [];
|
|
1224
|
+
for (const file of uniquePaths(files)) {
|
|
1225
|
+
for (const record of await readDashboardSubstrateRecords(file)) {
|
|
1226
|
+
records.push({ file, record });
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
return summarizeDashboardSubstrateRecords(cwd, records);
|
|
1230
|
+
}
|
|
1231
|
+
async function findDashboardSubstrateFiles(root, input) {
|
|
1232
|
+
const out = [];
|
|
1233
|
+
const skipDirs = new Set(['.git', 'node_modules', 'dist', 'coverage', '.cache', 'patch-scores', 'artifact-index']);
|
|
1234
|
+
async function walk(current, depth) {
|
|
1235
|
+
if (out.length >= input.maxFiles || depth > input.maxDepth)
|
|
1236
|
+
return;
|
|
1237
|
+
const entries = await fs.readdir(current, { withFileTypes: true }).catch(() => []);
|
|
1238
|
+
for (const entry of entries) {
|
|
1239
|
+
if (out.length >= input.maxFiles)
|
|
1240
|
+
return;
|
|
1241
|
+
const absolute = path.join(current, entry.name);
|
|
1242
|
+
if (entry.isDirectory()) {
|
|
1243
|
+
if (skipDirs.has(entry.name))
|
|
1244
|
+
continue;
|
|
1245
|
+
await walk(absolute, depth + 1);
|
|
1246
|
+
}
|
|
1247
|
+
else if (entry.isFile() && dashboardSubstrateFileNameMatches(entry.name)) {
|
|
1248
|
+
out.push(absolute);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
await walk(root, 0);
|
|
1253
|
+
return out;
|
|
1254
|
+
}
|
|
1255
|
+
function dashboardSubstrateFileNameMatches(name) {
|
|
1256
|
+
return name === 'run-events.jsonl'
|
|
1257
|
+
|| name === LIVE_RUN_GRAPH_EVENTS_FILE
|
|
1258
|
+
|| name === 'apply-ledger.json'
|
|
1259
|
+
|| name === 'workspace-proof.json'
|
|
1260
|
+
|| name === 'workspace-manifest.json'
|
|
1261
|
+
|| name === 'link-repair.json'
|
|
1262
|
+
|| /(?:semantic-)?lease(?:s|-state|-events|-records)?\.(?:json|jsonl)$/u.test(name)
|
|
1263
|
+
|| /gate-executions?\.(?:json|jsonl)$/u.test(name);
|
|
1264
|
+
}
|
|
1265
|
+
async function readDashboardSubstrateRecords(file) {
|
|
1266
|
+
const stat = await fs.stat(file).catch(() => undefined);
|
|
1267
|
+
if (!stat?.isFile() || stat.size > LIFETIME_DASHBOARD_SUBSTRATE_MAX_BYTES)
|
|
1268
|
+
return [];
|
|
1269
|
+
const text = await fs.readFile(file, 'utf8').catch(() => '');
|
|
1270
|
+
if (!text.trim())
|
|
1271
|
+
return [];
|
|
1272
|
+
if (file.endsWith('.jsonl')) {
|
|
1273
|
+
return text.split(/\r?\n/u)
|
|
1274
|
+
.map((line) => line.trim())
|
|
1275
|
+
.filter(Boolean)
|
|
1276
|
+
.map(parseJsonRecord)
|
|
1277
|
+
.filter((record) => Boolean(record && dashboardSubstrateRecordKind(record)));
|
|
1278
|
+
}
|
|
1279
|
+
const parsed = parseJsonValue(text);
|
|
1280
|
+
return flattenDashboardSubstrateRecords(parsed).filter((record) => Boolean(dashboardSubstrateRecordKind(record)));
|
|
1281
|
+
}
|
|
1282
|
+
function parseJsonRecord(text) {
|
|
1283
|
+
return recordValue(parseJsonValue(text));
|
|
1284
|
+
}
|
|
1285
|
+
function parseJsonValue(text) {
|
|
1286
|
+
try {
|
|
1287
|
+
return JSON.parse(text);
|
|
1288
|
+
}
|
|
1289
|
+
catch {
|
|
1290
|
+
return undefined;
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
function flattenDashboardSubstrateRecords(value) {
|
|
1294
|
+
const record = recordValue(value);
|
|
1295
|
+
if (!Object.keys(record).length) {
|
|
1296
|
+
return Array.isArray(value) ? value.flatMap(flattenDashboardSubstrateRecords) : [];
|
|
1297
|
+
}
|
|
1298
|
+
if (dashboardSubstrateRecordKind(record))
|
|
1299
|
+
return [record];
|
|
1300
|
+
const out = [];
|
|
1301
|
+
for (const key of ['events', 'leases', 'records', 'executions', 'entries']) {
|
|
1302
|
+
out.push(...recordArray(record[key]).flatMap(flattenDashboardSubstrateRecords));
|
|
1303
|
+
}
|
|
1304
|
+
return out;
|
|
1305
|
+
}
|
|
1306
|
+
function dashboardSubstrateRecordKind(record) {
|
|
1307
|
+
const kind = textValue(record.kind, '');
|
|
1308
|
+
if (kind === 'frontier.run.event' && numberValue(record.version) === FRONTIER_RUN_VERSION)
|
|
1309
|
+
return kind;
|
|
1310
|
+
if (kind === 'frontier.run.dashboard' && numberValue(record.version) === FRONTIER_RUN_VERSION)
|
|
1311
|
+
return kind;
|
|
1312
|
+
if (kind === FRONTIER_SEMANTIC_LEASE_STATE_KIND)
|
|
1313
|
+
return kind;
|
|
1314
|
+
if (kind === FRONTIER_SEMANTIC_LEASE_RECORD_KIND)
|
|
1315
|
+
return kind;
|
|
1316
|
+
if (kind === FRONTIER_SEMANTIC_LEASE_EVENT_KIND)
|
|
1317
|
+
return kind;
|
|
1318
|
+
if (kind === FRONTIER_TEST_GATE_EXECUTION_KIND)
|
|
1319
|
+
return kind;
|
|
1320
|
+
if (kind === FRONTIER_SWARM_GIT_WORKSPACE_MANIFEST_KIND)
|
|
1321
|
+
return kind;
|
|
1322
|
+
if (kind === FRONTIER_SWARM_GIT_WORKSPACE_PROOF_KIND)
|
|
1323
|
+
return kind;
|
|
1324
|
+
if (kind === FRONTIER_SWARM_GIT_APPLY_LEDGER_KIND)
|
|
1325
|
+
return kind;
|
|
1326
|
+
if (kind === FRONTIER_SWARM_GIT_LINK_REPAIR_KIND)
|
|
1327
|
+
return kind;
|
|
1328
|
+
return '';
|
|
1329
|
+
}
|
|
1330
|
+
function summarizeDashboardSubstrateRecords(cwd, records) {
|
|
1331
|
+
const runIds = new Set();
|
|
1332
|
+
const eventTypeCounts = {};
|
|
1333
|
+
const gateKindCounts = {};
|
|
1334
|
+
const sourceFiles = uniquePaths(records.map(({ file }) => path.relative(cwd, file).replaceAll(path.sep, '/')));
|
|
1335
|
+
const summary = {
|
|
1336
|
+
kind: 'frontier.loom-ui.dashboard-substrate',
|
|
1337
|
+
version: 1,
|
|
1338
|
+
generatedAt: Date.now(),
|
|
1339
|
+
sourceFiles,
|
|
1340
|
+
sourceCount: sourceFiles.length,
|
|
1341
|
+
run: { eventCount: 0, dashboardCount: 0, runIds: [], eventTypeCounts },
|
|
1342
|
+
leases: { stateCount: 0, recordCount: 0, eventCount: 0, activeCount: 0, grantedCount: 0, deniedCount: 0, releasedCount: 0, expiredCount: 0, scopeCount: 0 },
|
|
1343
|
+
gates: { executionCount: 0, passedCount: 0, failedCount: 0, warningCount: 0, requiredFailedCount: 0, durationMs: 0, kindCounts: gateKindCounts },
|
|
1344
|
+
git: { workspaceManifestCount: 0, workspaceProofCount: 0, applyLedgerCount: 0, linkRepairCount: 0, appliedCount: 0, committedCount: 0, failedCount: 0, skippedCount: 0, changedPathCount: 0 }
|
|
1345
|
+
};
|
|
1346
|
+
for (const { record } of records) {
|
|
1347
|
+
switch (dashboardSubstrateRecordKind(record)) {
|
|
1348
|
+
case 'frontier.run.event':
|
|
1349
|
+
summary.run.eventCount += 1;
|
|
1350
|
+
if (textValue(record.runId, ''))
|
|
1351
|
+
runIds.add(textValue(record.runId, ''));
|
|
1352
|
+
incrementRecordCount(eventTypeCounts, textValue(record.type, 'unknown'));
|
|
1353
|
+
break;
|
|
1354
|
+
case 'frontier.run.dashboard':
|
|
1355
|
+
summary.run.dashboardCount += 1;
|
|
1356
|
+
if (textValue(record.runId, ''))
|
|
1357
|
+
runIds.add(textValue(record.runId, ''));
|
|
1358
|
+
break;
|
|
1359
|
+
case FRONTIER_SEMANTIC_LEASE_STATE_KIND:
|
|
1360
|
+
summary.leases.stateCount += 1;
|
|
1361
|
+
summarizeLeaseRecords(summary, recordArray(record.leases));
|
|
1362
|
+
for (const event of recordArray(record.events)) {
|
|
1363
|
+
summary.leases.eventCount += 1;
|
|
1364
|
+
summarizeLeaseEvent(summary, event);
|
|
1365
|
+
}
|
|
1366
|
+
break;
|
|
1367
|
+
case FRONTIER_SEMANTIC_LEASE_RECORD_KIND:
|
|
1368
|
+
summarizeLeaseRecords(summary, [record]);
|
|
1369
|
+
break;
|
|
1370
|
+
case FRONTIER_SEMANTIC_LEASE_EVENT_KIND:
|
|
1371
|
+
summary.leases.eventCount += 1;
|
|
1372
|
+
summarizeLeaseEvent(summary, record);
|
|
1373
|
+
break;
|
|
1374
|
+
case FRONTIER_TEST_GATE_EXECUTION_KIND:
|
|
1375
|
+
summarizeGateExecution(summary, record);
|
|
1376
|
+
break;
|
|
1377
|
+
case FRONTIER_SWARM_GIT_WORKSPACE_MANIFEST_KIND:
|
|
1378
|
+
summary.git.workspaceManifestCount += 1;
|
|
1379
|
+
break;
|
|
1380
|
+
case FRONTIER_SWARM_GIT_WORKSPACE_PROOF_KIND:
|
|
1381
|
+
summary.git.workspaceProofCount += 1;
|
|
1382
|
+
summary.git.changedPathCount += stringArray(record.reportedChangedPaths).length;
|
|
1383
|
+
break;
|
|
1384
|
+
case FRONTIER_SWARM_GIT_APPLY_LEDGER_KIND:
|
|
1385
|
+
summarizeGitApplyLedger(summary, record);
|
|
1386
|
+
break;
|
|
1387
|
+
case FRONTIER_SWARM_GIT_LINK_REPAIR_KIND:
|
|
1388
|
+
summary.git.linkRepairCount += 1;
|
|
1389
|
+
break;
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
summary.run.runIds = Array.from(runIds).sort();
|
|
1393
|
+
summary.graph = substrateDashboardGraphSummary(summary);
|
|
1394
|
+
return summary;
|
|
1395
|
+
}
|
|
1396
|
+
function summarizeLeaseRecords(summary, leases) {
|
|
1397
|
+
for (const lease of leases) {
|
|
1398
|
+
summary.leases.recordCount += 1;
|
|
1399
|
+
summary.leases.scopeCount += Math.max(stringArray(lease.scopeKeys).length, recordArray(lease.scopes).length);
|
|
1400
|
+
const status = normalized(lease.status);
|
|
1401
|
+
if (status === 'granted') {
|
|
1402
|
+
summary.leases.grantedCount += 1;
|
|
1403
|
+
summary.leases.activeCount += 1;
|
|
1404
|
+
}
|
|
1405
|
+
else if (status === 'denied')
|
|
1406
|
+
summary.leases.deniedCount += 1;
|
|
1407
|
+
else if (status === 'released')
|
|
1408
|
+
summary.leases.releasedCount += 1;
|
|
1409
|
+
else if (status === 'expired')
|
|
1410
|
+
summary.leases.expiredCount += 1;
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
function summarizeLeaseEvent(summary, event) {
|
|
1414
|
+
const type = normalized(event.type);
|
|
1415
|
+
if (type.endsWith('granted'))
|
|
1416
|
+
summary.leases.grantedCount += 1;
|
|
1417
|
+
else if (type.endsWith('denied'))
|
|
1418
|
+
summary.leases.deniedCount += 1;
|
|
1419
|
+
else if (type.endsWith('released'))
|
|
1420
|
+
summary.leases.releasedCount += 1;
|
|
1421
|
+
else if (type.endsWith('expired'))
|
|
1422
|
+
summary.leases.expiredCount += 1;
|
|
1423
|
+
}
|
|
1424
|
+
function summarizeGateExecution(summary, execution) {
|
|
1425
|
+
summary.gates.executionCount += 1;
|
|
1426
|
+
summary.gates.durationMs += numberValue(execution.durationMs);
|
|
1427
|
+
incrementRecordCount(summary.gates.kindCounts, textValue(execution.gateKind, 'unknown'));
|
|
1428
|
+
const status = normalized(execution.status);
|
|
1429
|
+
if (status === 'passed')
|
|
1430
|
+
summary.gates.passedCount += 1;
|
|
1431
|
+
else if (status === 'failed') {
|
|
1432
|
+
summary.gates.failedCount += 1;
|
|
1433
|
+
if (execution.required !== false)
|
|
1434
|
+
summary.gates.requiredFailedCount += 1;
|
|
1435
|
+
}
|
|
1436
|
+
else if (status === 'warning' || status === 'blocked' || status === 'unknown') {
|
|
1437
|
+
summary.gates.warningCount += 1;
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
function summarizeGitApplyLedger(summary, ledger) {
|
|
1441
|
+
summary.git.applyLedgerCount += 1;
|
|
1442
|
+
const ledgerSummary = recordValue(ledger.summary);
|
|
1443
|
+
summary.git.appliedCount += numberValue(ledgerSummary.applied);
|
|
1444
|
+
summary.git.committedCount += numberValue(ledgerSummary.committed);
|
|
1445
|
+
summary.git.failedCount += numberValue(ledgerSummary.failed);
|
|
1446
|
+
summary.git.skippedCount += numberValue(ledgerSummary.skipped);
|
|
1447
|
+
for (const entry of recordArray(ledger.entries)) {
|
|
1448
|
+
summary.git.changedPathCount += stringArray(entry.changedPaths).length;
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
function substrateDashboardGraphSummary(substrate) {
|
|
1452
|
+
const nodeCount = substrate.run.eventCount
|
|
1453
|
+
+ substrate.run.dashboardCount
|
|
1454
|
+
+ substrate.leases.recordCount
|
|
1455
|
+
+ substrate.leases.eventCount
|
|
1456
|
+
+ substrate.gates.executionCount
|
|
1457
|
+
+ substrate.git.workspaceManifestCount
|
|
1458
|
+
+ substrate.git.workspaceProofCount
|
|
1459
|
+
+ substrate.git.applyLedgerCount
|
|
1460
|
+
+ substrate.git.linkRepairCount;
|
|
1461
|
+
if (!nodeCount)
|
|
1462
|
+
return undefined;
|
|
1463
|
+
const gateFailedCount = substrate.gates.failedCount;
|
|
1464
|
+
const blockerCount = substrate.gates.requiredFailedCount + substrate.git.failedCount + substrate.leases.deniedCount;
|
|
1465
|
+
return {
|
|
1466
|
+
sourceFile: substrate.sourceFiles[0],
|
|
1467
|
+
sourceFiles: substrate.sourceFiles,
|
|
1468
|
+
sourceKind: 'frontier-substrate-rollup',
|
|
1469
|
+
sourceKinds: dashboardSubstrateSourceKinds(substrate),
|
|
1470
|
+
sourceStatus: 'collected',
|
|
1471
|
+
sourceStatuses: ['collected'],
|
|
1472
|
+
nodeCount,
|
|
1473
|
+
edgeCount: 0,
|
|
1474
|
+
blockerCount,
|
|
1475
|
+
openBlockerCount: blockerCount,
|
|
1476
|
+
humanQuestionCount: 0,
|
|
1477
|
+
openHumanQuestionCount: 0,
|
|
1478
|
+
safeMergeCandidateCount: substrate.git.appliedCount + substrate.git.committedCount,
|
|
1479
|
+
decisionCount: substrate.leases.eventCount,
|
|
1480
|
+
terminalDecisionCount: 0,
|
|
1481
|
+
terminalAcceptedCount: substrate.git.appliedCount + substrate.git.committedCount,
|
|
1482
|
+
terminalRejectedCount: substrate.git.failedCount,
|
|
1483
|
+
terminalRerunCount: 0,
|
|
1484
|
+
gateCount: substrate.gates.executionCount,
|
|
1485
|
+
gatePassedCount: substrate.gates.passedCount,
|
|
1486
|
+
gateFailedCount,
|
|
1487
|
+
staleCount: 0,
|
|
1488
|
+
openStaleCount: 0,
|
|
1489
|
+
rerunCount: 0,
|
|
1490
|
+
openRerunCount: 0,
|
|
1491
|
+
staleRerunCleanupCount: 0,
|
|
1492
|
+
status: blockerCount ? 'blocked' : gateFailedCount ? 'review' : substrate.git.appliedCount || substrate.git.committedCount ? 'ready' : 'clear',
|
|
1493
|
+
summaryLine: `${nodeCount} substrate records, ${substrate.gates.executionCount} gate executions, ${substrate.leases.activeCount} active leases.`,
|
|
1494
|
+
recentEvents: []
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
function dashboardSubstrateSourceKinds(substrate) {
|
|
1498
|
+
const kinds = [];
|
|
1499
|
+
if (substrate.run.eventCount || substrate.run.dashboardCount)
|
|
1500
|
+
kinds.push('frontier-run');
|
|
1501
|
+
if (substrate.leases.recordCount || substrate.leases.eventCount || substrate.leases.stateCount)
|
|
1502
|
+
kinds.push('frontier-lease');
|
|
1503
|
+
if (substrate.gates.executionCount)
|
|
1504
|
+
kinds.push('frontier-test');
|
|
1505
|
+
if (substrate.git.workspaceManifestCount || substrate.git.workspaceProofCount || substrate.git.applyLedgerCount || substrate.git.linkRepairCount)
|
|
1506
|
+
kinds.push('frontier-swarm-git');
|
|
1507
|
+
return kinds;
|
|
1508
|
+
}
|
|
1509
|
+
function mergeDashboardGraphSummaries(left, right) {
|
|
1510
|
+
if (!left)
|
|
1511
|
+
return right;
|
|
1512
|
+
if (!right)
|
|
1513
|
+
return left;
|
|
1514
|
+
const sourceFiles = uniquePaths([...stringArray(left.sourceFiles), ...stringArray(right.sourceFiles), textValue(left.sourceFile, ''), textValue(right.sourceFile, '')].filter(Boolean));
|
|
1515
|
+
const sourceKinds = uniquePaths([...stringArray(left.sourceKinds), ...stringArray(right.sourceKinds), textValue(left.sourceKind, ''), textValue(right.sourceKind, '')].filter(Boolean));
|
|
1516
|
+
const sourceStatuses = uniquePaths([...stringArray(left.sourceStatuses), ...stringArray(right.sourceStatuses), textValue(left.sourceStatus, ''), textValue(right.sourceStatus, '')].filter(Boolean));
|
|
1517
|
+
const sumKeys = ['nodeCount', 'edgeCount', 'blockerCount', 'openBlockerCount', 'humanQuestionCount', 'openHumanQuestionCount', 'safeMergeCandidateCount', 'decisionCount', 'terminalDecisionCount', 'terminalAcceptedCount', 'terminalRejectedCount', 'terminalRerunCount', 'gateCount', 'gatePassedCount', 'gateFailedCount', 'staleCount', 'openStaleCount', 'rerunCount', 'openRerunCount', 'staleRerunCleanupCount'];
|
|
1518
|
+
const out = {
|
|
1519
|
+
...left,
|
|
1520
|
+
sourceFile: sourceFiles[0],
|
|
1521
|
+
sourceFiles,
|
|
1522
|
+
sourceKind: 'lifetime-rollup',
|
|
1523
|
+
sourceKinds,
|
|
1524
|
+
sourceStatus: sourceStatuses.length === 1 ? sourceStatuses[0] : 'mixed',
|
|
1525
|
+
sourceStatuses,
|
|
1526
|
+
recentEvents: [...recordArray(left.recentEvents), ...recordArray(right.recentEvents)].slice(-12)
|
|
1527
|
+
};
|
|
1528
|
+
for (const key of sumKeys)
|
|
1529
|
+
out[key] = numberValue(left[key]) + numberValue(right[key]);
|
|
1530
|
+
const blockerCount = numberValue(out.openBlockerCount);
|
|
1531
|
+
const gateFailedCount = numberValue(out.gateFailedCount);
|
|
1532
|
+
out.status = blockerCount ? 'blocked' : gateFailedCount ? 'review' : numberValue(out.safeMergeCandidateCount) ? 'ready' : 'clear';
|
|
1533
|
+
out.summaryLine = `${numberValue(out.nodeCount)} nodes, ${numberValue(out.edgeCount)} edges, ${numberValue(out.decisionCount)} decisions, and ${numberValue(out.gateCount)} gates across loaded runs and substrate records.`;
|
|
1534
|
+
return out;
|
|
1535
|
+
}
|
|
1536
|
+
function incrementRecordCount(record, key) {
|
|
1537
|
+
record[key || 'unknown'] = (record[key || 'unknown'] ?? 0) + 1;
|
|
1538
|
+
}
|
|
822
1539
|
async function combineLifetimeDashboardSnapshots(options, discoveredSources, snapshots, reviewDecisions, queueBacklog) {
|
|
823
1540
|
const drainedRunRoots = new Set(snapshots
|
|
824
1541
|
.filter((entry) => entry.source.kind === 'run' && isDrainedAutonomousRunSnapshot(entry.snapshot))
|
|
@@ -852,13 +1569,19 @@ async function combineLifetimeDashboardSnapshots(options, discoveredSources, sna
|
|
|
852
1569
|
});
|
|
853
1570
|
}), reviewDecisions))).slice(0, LIFETIME_DASHBOARD_MAX_JOBS);
|
|
854
1571
|
const humanActionAnswers = await readHumanActionAnswers(options);
|
|
1572
|
+
const substrate = await readDashboardSubstrateSummary(options.cwd);
|
|
1573
|
+
const substrateGraph = recordValue(substrate.graph);
|
|
1574
|
+
const graph = withDashboardRunGraphJobHealth(mergeDashboardGraphSummaries(lifetimeDecisionGraphSummary(visibleSnapshots), Object.keys(substrateGraph).length ? substrateGraph : undefined), jobs);
|
|
855
1575
|
const summary = {
|
|
856
1576
|
...lifetimeDashboardSummary(jobs),
|
|
857
1577
|
coordinationDelayCount: autoDrainDelays.length,
|
|
858
|
-
dirtyAutoDrainSkipCount: autoDrainDelays.filter((record) => record.skippedReason === 'dirty-worktree').length
|
|
1578
|
+
dirtyAutoDrainSkipCount: autoDrainDelays.filter((record) => record.skippedReason === 'dirty-worktree').length,
|
|
1579
|
+
substrateRecordCount: numberValue(substrateGraph.nodeCount),
|
|
1580
|
+
substrateSourceCount: substrate.sourceCount,
|
|
1581
|
+
...(graph ? { graph } : {})
|
|
859
1582
|
};
|
|
860
1583
|
const queueOverlay = lifetimeQueueBacklogOverlay(queueBacklog, jobs);
|
|
861
|
-
const latestGeneratedAt = Math.max(Date.now(), numberValue(queueBacklog.generatedAt), ...visibleSnapshots.map((entry) => numberValue(entry.snapshot.generatedAt)), ...discoveredSources.map((source) => source.mtimeMs));
|
|
1584
|
+
const latestGeneratedAt = Math.max(Date.now(), numberValue(queueBacklog.generatedAt), substrate.generatedAt, ...visibleSnapshots.map((entry) => numberValue(entry.snapshot.generatedAt)), ...discoveredSources.map((source) => source.mtimeMs));
|
|
862
1585
|
const events = visibleSnapshots.flatMap(({ source, snapshot }) => recordArray(snapshot.events).map((event) => ({
|
|
863
1586
|
...event,
|
|
864
1587
|
sourceLabel: source.label,
|
|
@@ -880,14 +1603,18 @@ async function combineLifetimeDashboardSnapshots(options, discoveredSources, sna
|
|
|
880
1603
|
suppressedCollectionSourceCount: snapshots.length - visibleSnapshots.length,
|
|
881
1604
|
queueSourceCount: queueBacklog.sourceCount,
|
|
882
1605
|
coordinationDelayCount: autoDrainDelays.length,
|
|
1606
|
+
substrateSourceCount: substrate.sourceCount,
|
|
1607
|
+
...(substrate.sourceCount ? { substrateFiles: substrate.sourceFiles } : {}),
|
|
883
1608
|
...(humanActionAnswers.length ? { humanActionAnswers: await humanActionAnswerLogPath(options) } : {}),
|
|
884
1609
|
...(reviewDecisions.length ? { coordinatorReviewDecisions: coordinatorReviewDecisionPath(options.cwd) } : {})
|
|
885
1610
|
},
|
|
886
1611
|
summary,
|
|
887
|
-
semantic: lifetimeSemanticSummary(jobs),
|
|
1612
|
+
semantic: semanticWithHealth(lifetimeSemanticSummary(jobs), summary, jobs),
|
|
888
1613
|
health: lifetimeHealthSummary(jobs),
|
|
889
1614
|
quality: {},
|
|
890
1615
|
timeSeries: lifetimeTimeSeries(jobs, events),
|
|
1616
|
+
...(graph ? { graph } : {}),
|
|
1617
|
+
substrate,
|
|
891
1618
|
lanes: lifetimeLaneRows(jobs),
|
|
892
1619
|
capacity: lifetimeCapacitySummary(queueBacklog, jobs, queueOverlay.entries),
|
|
893
1620
|
jobs,
|
|
@@ -912,6 +1639,8 @@ async function combineLifetimeDashboardSnapshots(options, discoveredSources, sna
|
|
|
912
1639
|
loadedSourceCount: visibleSnapshots.length,
|
|
913
1640
|
suppressedCollectionSourceCount: snapshots.length - visibleSnapshots.length,
|
|
914
1641
|
autoDrainDelays,
|
|
1642
|
+
...(graph ? { graphSourceFiles: graph.sourceFiles } : {}),
|
|
1643
|
+
...(substrate.sourceCount ? { substrate } : {}),
|
|
915
1644
|
sources: discoveredSources.slice(0, LIFETIME_DASHBOARD_MAX_SOURCES),
|
|
916
1645
|
manifests: queueBacklog.manifests,
|
|
917
1646
|
queueSources: queueBacklog.paths
|
|
@@ -919,6 +1648,136 @@ async function combineLifetimeDashboardSnapshots(options, discoveredSources, sna
|
|
|
919
1648
|
}
|
|
920
1649
|
};
|
|
921
1650
|
}
|
|
1651
|
+
function lifetimeDecisionGraphSummary(snapshots) {
|
|
1652
|
+
const graphs = snapshots.map(({ source, snapshot }) => {
|
|
1653
|
+
const graph = recordValue(snapshot.graph ?? recordValue(snapshot.summary).graph);
|
|
1654
|
+
return Object.keys(graph).length ? { source, graph } : undefined;
|
|
1655
|
+
}).filter((entry) => Boolean(entry));
|
|
1656
|
+
if (!graphs.length)
|
|
1657
|
+
return undefined;
|
|
1658
|
+
const sourceFiles = uniquePaths(graphs.flatMap(({ graph }) => stringArray(graph.sourceFiles).length
|
|
1659
|
+
? stringArray(graph.sourceFiles)
|
|
1660
|
+
: [textValue(graph.sourceFile, '')]).filter(Boolean));
|
|
1661
|
+
const sourceKinds = uniquePaths(graphs.map(({ graph }) => textValue(graph.sourceKind, '')).filter(Boolean));
|
|
1662
|
+
const sourceStatuses = uniquePaths(graphs.map(({ graph }) => textValue(graph.sourceStatus, '')).filter(Boolean));
|
|
1663
|
+
const graphMissingWarnings = uniquePaths(graphs.flatMap(({ graph }) => stringArray(graph.graphMissingWarnings)));
|
|
1664
|
+
const nodeCount = graphNumberSum(graphs, 'nodeCount');
|
|
1665
|
+
const edgeCount = graphNumberSum(graphs, 'edgeCount');
|
|
1666
|
+
const blockerCount = graphNumberSum(graphs, 'blockerCount');
|
|
1667
|
+
const openBlockerCount = graphNumberSum(graphs, 'openBlockerCount');
|
|
1668
|
+
const humanQuestionCount = graphNumberSum(graphs, 'humanQuestionCount');
|
|
1669
|
+
const openHumanQuestionCount = graphNumberSum(graphs, 'openHumanQuestionCount');
|
|
1670
|
+
const safeMergeCandidateCount = graphNumberSum(graphs, 'safeMergeCandidateCount');
|
|
1671
|
+
const decisionCount = graphNumberSum(graphs, 'decisionCount');
|
|
1672
|
+
const terminalDecisionCount = graphNumberSum(graphs, 'terminalDecisionCount');
|
|
1673
|
+
const terminalAcceptedCount = graphNumberSum(graphs, 'terminalAcceptedCount');
|
|
1674
|
+
const terminalRejectedCount = graphNumberSum(graphs, 'terminalRejectedCount');
|
|
1675
|
+
const terminalRerunCount = graphNumberSum(graphs, 'terminalRerunCount');
|
|
1676
|
+
const gateCount = graphNumberSum(graphs, 'gateCount');
|
|
1677
|
+
const gateFailedCount = graphNumberSum(graphs, 'gateFailedCount');
|
|
1678
|
+
const staleCount = graphNumberSum(graphs, 'staleCount');
|
|
1679
|
+
const openStaleCount = graphNumberSum(graphs, 'openStaleCount');
|
|
1680
|
+
const rerunCount = graphNumberSum(graphs, 'rerunCount');
|
|
1681
|
+
const openRerunCount = graphNumberSum(graphs, 'openRerunCount');
|
|
1682
|
+
const staleRerunCleanupCount = graphNumberSum(graphs, 'staleRerunCleanupCount');
|
|
1683
|
+
const recentEvents = graphs.flatMap(({ source, graph }) => recordArray(graph.recentEvents).map((event) => ({
|
|
1684
|
+
...event,
|
|
1685
|
+
sourceLabel: source.label,
|
|
1686
|
+
at: numberValue(event.at) || source.mtimeMs
|
|
1687
|
+
}))).sort((left, right) => numberValue(left.at) - numberValue(right.at)).slice(-12);
|
|
1688
|
+
const status = openBlockerCount ? 'blocked' : openHumanQuestionCount ? 'questions' : gateFailedCount ? 'review' : safeMergeCandidateCount ? 'ready' : 'clear';
|
|
1689
|
+
return {
|
|
1690
|
+
sourceFile: sourceFiles[0],
|
|
1691
|
+
sourceFiles,
|
|
1692
|
+
sourceKind: 'lifetime-rollup',
|
|
1693
|
+
sourceKinds,
|
|
1694
|
+
sourceStatus: sourceStatuses.length === 1 ? sourceStatuses[0] : sourceStatuses.length ? 'mixed' : 'unknown',
|
|
1695
|
+
sourceStatuses,
|
|
1696
|
+
graphMissing: graphMissingWarnings.length > 0,
|
|
1697
|
+
graphMissingWarningCount: graphMissingWarnings.length,
|
|
1698
|
+
graphMissingWarnings: graphMissingWarnings.slice(0, 12),
|
|
1699
|
+
nodeCount,
|
|
1700
|
+
edgeCount,
|
|
1701
|
+
blockerCount,
|
|
1702
|
+
openBlockerCount,
|
|
1703
|
+
humanQuestionCount,
|
|
1704
|
+
openHumanQuestionCount,
|
|
1705
|
+
safeMergeCandidateCount,
|
|
1706
|
+
decisionCount,
|
|
1707
|
+
terminalDecisionCount,
|
|
1708
|
+
terminalAcceptedCount,
|
|
1709
|
+
terminalRejectedCount,
|
|
1710
|
+
terminalRerunCount,
|
|
1711
|
+
gateCount,
|
|
1712
|
+
gatePassedCount: graphNumberSum(graphs, 'gatePassedCount'),
|
|
1713
|
+
gateFailedCount,
|
|
1714
|
+
staleCount,
|
|
1715
|
+
openStaleCount,
|
|
1716
|
+
rerunCount,
|
|
1717
|
+
openRerunCount,
|
|
1718
|
+
staleRerunCleanupCount,
|
|
1719
|
+
status,
|
|
1720
|
+
summaryLine: `${nodeCount} nodes, ${edgeCount} edges, ${decisionCount} decisions, and ${gateCount} gates across loaded runs.`,
|
|
1721
|
+
recentEvents
|
|
1722
|
+
};
|
|
1723
|
+
}
|
|
1724
|
+
function graphNumberSum(graphs, key) {
|
|
1725
|
+
return graphs.reduce((sum, entry) => sum + numberValue(entry.graph[key]), 0);
|
|
1726
|
+
}
|
|
1727
|
+
function withDashboardRunGraphJobHealth(graph, jobs) {
|
|
1728
|
+
if (!graph)
|
|
1729
|
+
return undefined;
|
|
1730
|
+
const staleJobs = jobs.filter(dashboardGraphJobIsStale);
|
|
1731
|
+
const rerunJobs = jobs.filter(dashboardGraphJobIsRerun);
|
|
1732
|
+
const staleCleanupCount = staleJobs.filter(dashboardGraphJobIsCleanup).length;
|
|
1733
|
+
const rerunCleanupCount = rerunJobs.filter(dashboardGraphJobIsCleanup).length;
|
|
1734
|
+
const staleCount = Math.max(numberValue(graph.staleCount), staleJobs.length);
|
|
1735
|
+
const rerunCount = Math.max(numberValue(graph.rerunCount), rerunJobs.length);
|
|
1736
|
+
const staleRerunCleanupCount = Math.max(numberValue(graph.staleRerunCleanupCount), staleCleanupCount + rerunCleanupCount);
|
|
1737
|
+
return {
|
|
1738
|
+
...graph,
|
|
1739
|
+
staleCount,
|
|
1740
|
+
rerunCount,
|
|
1741
|
+
staleRerunCleanupCount,
|
|
1742
|
+
openStaleCount: Math.max(numberValue(graph.openStaleCount), Math.max(0, staleJobs.length - staleCleanupCount)),
|
|
1743
|
+
openRerunCount: Math.max(numberValue(graph.openRerunCount), Math.max(0, rerunJobs.length - rerunCleanupCount))
|
|
1744
|
+
};
|
|
1745
|
+
}
|
|
1746
|
+
function dashboardGraphJobIsStale(job) {
|
|
1747
|
+
const values = [
|
|
1748
|
+
job.bucket,
|
|
1749
|
+
job.originalBucket,
|
|
1750
|
+
job.status,
|
|
1751
|
+
job.originalStatus,
|
|
1752
|
+
job.disposition,
|
|
1753
|
+
job.originalDisposition,
|
|
1754
|
+
job.mergeReadiness,
|
|
1755
|
+
...(stringArray(job.reasons)),
|
|
1756
|
+
...(stringArray(job.originalReasons))
|
|
1757
|
+
].map(normalized);
|
|
1758
|
+
return job.staleAgainstHead === true || values.some((value) => value.includes('stale'));
|
|
1759
|
+
}
|
|
1760
|
+
function dashboardGraphJobIsRerun(job) {
|
|
1761
|
+
const values = [
|
|
1762
|
+
job.bucket,
|
|
1763
|
+
job.originalBucket,
|
|
1764
|
+
job.status,
|
|
1765
|
+
job.originalStatus,
|
|
1766
|
+
job.disposition,
|
|
1767
|
+
job.originalDisposition,
|
|
1768
|
+
job.mergeReadiness,
|
|
1769
|
+
...(stringArray(job.reasons)),
|
|
1770
|
+
...(stringArray(job.originalReasons))
|
|
1771
|
+
].map(normalized);
|
|
1772
|
+
return values.some((value) => value.includes('rerun'));
|
|
1773
|
+
}
|
|
1774
|
+
function dashboardGraphJobIsCleanup(job) {
|
|
1775
|
+
if (isResolvedCoordinatorReviewRecord(job))
|
|
1776
|
+
return true;
|
|
1777
|
+
const status = normalized(job.status);
|
|
1778
|
+
const decision = normalized(job.coordinatorDecisionStatus ?? recordValue(job.coordinatorDecision).status);
|
|
1779
|
+
return status === 'completed' && ['accepted', 'accepted-applied', 'applied', 'committed', 'rejected', 'rerun', 'superseded'].includes(decision);
|
|
1780
|
+
}
|
|
922
1781
|
function lifetimeScopedId(source, id) {
|
|
923
1782
|
return `${source.id}:${id}`.replaceAll(/[^\w:.-]+/g, '-');
|
|
924
1783
|
}
|
|
@@ -1217,6 +2076,7 @@ function readDrainPidManifestJob(cwd, coordinatorRunDir, entry, planJob, now) {
|
|
|
1217
2076
|
const lane = textValue(planJob?.lane ?? task.lane, drainCoordinatorLane(jobId, runKind));
|
|
1218
2077
|
return withRecomputedCostFields({
|
|
1219
2078
|
id: jobId,
|
|
2079
|
+
jobId,
|
|
1220
2080
|
originalJobId: jobId,
|
|
1221
2081
|
taskId: textValue(planJob?.taskId ?? task.id, jobId),
|
|
1222
2082
|
title: textValue(planJob?.title ?? task.title, runKind === 'coordinator' ? `Coordinate lane review for ${lane}` : `Continue ${lane} work`),
|
|
@@ -1408,7 +2268,7 @@ function mergeLifetimeDrainCoordinatorSnapshot(lifetime, drain) {
|
|
|
1408
2268
|
...recordValue(lifetime.sources),
|
|
1409
2269
|
...recordValue(drain?.sources)
|
|
1410
2270
|
},
|
|
1411
|
-
summary:
|
|
2271
|
+
summary: lifetimeSummaryWithExistingGraph(lifetime, jobs),
|
|
1412
2272
|
health: lifetimeHealthSummary(jobs),
|
|
1413
2273
|
lanes: lifetimeLaneRows(jobs),
|
|
1414
2274
|
jobs,
|
|
@@ -1437,7 +2297,7 @@ function mergeLifetimeActiveRunSnapshot(lifetime, active) {
|
|
|
1437
2297
|
...recordValue(lifetime.sources),
|
|
1438
2298
|
...recordValue(active?.sources)
|
|
1439
2299
|
},
|
|
1440
|
-
summary:
|
|
2300
|
+
summary: lifetimeSummaryWithExistingGraph(lifetime, jobs),
|
|
1441
2301
|
health: lifetimeHealthSummary(jobs),
|
|
1442
2302
|
lanes: lifetimeLaneRows(jobs),
|
|
1443
2303
|
capacity: lifetimeCapacitySummary({
|
|
@@ -1795,6 +2655,22 @@ function applyCoordinatorReviewDecisions(jobs, decisions) {
|
|
|
1795
2655
|
return resolved ? markCoordinatorReviewResolved(decided, status) : decided;
|
|
1796
2656
|
});
|
|
1797
2657
|
}
|
|
2658
|
+
function reviewDecisionAdjustedSummary(summary, jobs) {
|
|
2659
|
+
const recomputed = lifetimeDashboardSummary(jobs);
|
|
2660
|
+
const bucketCounts = countJobsByBucket(jobs);
|
|
2661
|
+
const openReviewCount = jobs.filter(isOpenCoordinatorReviewRecord).length;
|
|
2662
|
+
const resolvedReviewCount = jobs.filter(isResolvedCoordinatorReviewRecord).length;
|
|
2663
|
+
return {
|
|
2664
|
+
...summary,
|
|
2665
|
+
...recomputed,
|
|
2666
|
+
bucketCounts: {
|
|
2667
|
+
total: jobs.length,
|
|
2668
|
+
...bucketCounts
|
|
2669
|
+
},
|
|
2670
|
+
needsCoordinatorReviewCount: openReviewCount,
|
|
2671
|
+
reviewResolvedCount: resolvedReviewCount
|
|
2672
|
+
};
|
|
2673
|
+
}
|
|
1798
2674
|
function normalizeCoordinatorFacingJob(record) {
|
|
1799
2675
|
const status = coordinatorFacingMachineLabel(record.status);
|
|
1800
2676
|
let bucket = coordinatorFacingMachineLabel(record.bucket);
|
|
@@ -1813,11 +2689,56 @@ function normalizeCoordinatorFacingJob(record) {
|
|
|
1813
2689
|
disposition: coordinatorFacingMachineLabel(record.disposition),
|
|
1814
2690
|
mergeReadiness: coordinatorFacingMachineLabel(record.mergeReadiness)
|
|
1815
2691
|
};
|
|
2692
|
+
normalizedRecord = normalizeUnreasonedBlockedJob(normalizedRecord);
|
|
1816
2693
|
normalizedRecord = normalizeHistoricalEvidenceFailureJob(normalizedRecord);
|
|
1817
2694
|
if (!isResolvedCoordinatorReviewRecord(normalizedRecord))
|
|
1818
2695
|
return normalizedRecord;
|
|
1819
2696
|
return markCoordinatorReviewResolved(normalizedRecord, textValue(normalizedRecord.coordinatorDecisionStatus ?? normalizedRecord.disposition, 'review-resolved'));
|
|
1820
2697
|
}
|
|
2698
|
+
function normalizeUnreasonedBlockedJob(record) {
|
|
2699
|
+
const status = normalized(record.status);
|
|
2700
|
+
const bucket = normalized(record.bucket);
|
|
2701
|
+
if (status !== 'blocked' && bucket !== 'blocked')
|
|
2702
|
+
return record;
|
|
2703
|
+
if (hasExplicitBlockedEvidence(record))
|
|
2704
|
+
return record;
|
|
2705
|
+
return {
|
|
2706
|
+
...record,
|
|
2707
|
+
originalBucket: record.originalBucket ?? record.bucket,
|
|
2708
|
+
originalStatus: record.originalStatus ?? record.status,
|
|
2709
|
+
originalDisposition: record.originalDisposition ?? record.disposition,
|
|
2710
|
+
bucket: 'needs-coordinator-review',
|
|
2711
|
+
status: 'completed',
|
|
2712
|
+
disposition: 'needs-coordinator-review',
|
|
2713
|
+
mergeReadiness: 'needs-coordinator-review',
|
|
2714
|
+
reasons: uniquePaths([...stringArray(record.reasons), 'blocked-status-without-blocker-evidence']),
|
|
2715
|
+
collectReasonClasses: uniquePaths([...stringArray(record.collectReasonClasses), 'run status needs coordinator classification'])
|
|
2716
|
+
};
|
|
2717
|
+
}
|
|
2718
|
+
function hasExplicitBlockedEvidence(record) {
|
|
2719
|
+
const fields = [
|
|
2720
|
+
record.disposition,
|
|
2721
|
+
record.mergeReadiness,
|
|
2722
|
+
record.semanticReadiness,
|
|
2723
|
+
record.outcome,
|
|
2724
|
+
record.health
|
|
2725
|
+
].map(normalized);
|
|
2726
|
+
if (fields.some((field) => ['blocked', 'conflict', 'conflict-blocked', 'human-blocked', 'human-needed'].includes(field)))
|
|
2727
|
+
return true;
|
|
2728
|
+
if (numberValue(record.blockerCount) > 0 || numberValue(record.openBlockerCount) > 0)
|
|
2729
|
+
return true;
|
|
2730
|
+
if (numberValue(record.humanActionCount) > 0 || numberValue(record.openHumanQuestionCount) > 0)
|
|
2731
|
+
return true;
|
|
2732
|
+
if (numberValue(record.commandsFailed) > 0 || numberValue(record.failedCheckCount) > 0)
|
|
2733
|
+
return true;
|
|
2734
|
+
const classes = [
|
|
2735
|
+
...stringArray(record.reasons),
|
|
2736
|
+
...stringArray(record.collectReasonClasses),
|
|
2737
|
+
...stringArray(record.blockers),
|
|
2738
|
+
...stringArray(record.humanQuestions)
|
|
2739
|
+
].map(normalized);
|
|
2740
|
+
return classes.some((value) => /\b(?:blocked|blocker|conflict|human-needed|human-blocked|human-question|failed-evidence|quota-deferred|timeout|denied)\b/u.test(value));
|
|
2741
|
+
}
|
|
1821
2742
|
function normalizeHistoricalEvidenceFailureJob(record) {
|
|
1822
2743
|
if (!isHistoricalOwnershipRescopeCandidate(record))
|
|
1823
2744
|
return record;
|
|
@@ -1965,10 +2886,10 @@ function coordinatorReviewDecisionMatches(job, decision) {
|
|
|
1965
2886
|
if (!idMatches)
|
|
1966
2887
|
return false;
|
|
1967
2888
|
const decisionSource = textValue(decision.source ?? decision.sourceCollection ?? decision.sourceRun ?? decision.sourceLabel, '');
|
|
2889
|
+
if (isHistoricalReviewDrainDecision(decision) && historicalReviewDrainDecisionMatches(job, decision))
|
|
2890
|
+
return true;
|
|
1968
2891
|
if (!decisionSource)
|
|
1969
2892
|
return true;
|
|
1970
|
-
if (isHistoricalReviewDrainDecision(decision))
|
|
1971
|
-
return historicalReviewDrainDecisionMatches(job, decision);
|
|
1972
2893
|
const jobSources = [
|
|
1973
2894
|
textValue(job.sourceLabel, ''),
|
|
1974
2895
|
textValue(job.sourceCollection, ''),
|
|
@@ -2081,6 +3002,13 @@ function lifetimeDashboardSummary(jobs) {
|
|
|
2081
3002
|
bucketCounts: countJobsByBucket(jobs)
|
|
2082
3003
|
};
|
|
2083
3004
|
}
|
|
3005
|
+
function lifetimeSummaryWithExistingGraph(lifetime, jobs) {
|
|
3006
|
+
const graph = recordValue(lifetime.graph ?? recordValue(lifetime.summary).graph);
|
|
3007
|
+
return {
|
|
3008
|
+
...lifetimeDashboardSummary(jobs),
|
|
3009
|
+
...(Object.keys(graph).length ? { graph } : {})
|
|
3010
|
+
};
|
|
3011
|
+
}
|
|
2084
3012
|
function countJobsByBucket(jobs) {
|
|
2085
3013
|
const counts = {};
|
|
2086
3014
|
for (const job of jobs) {
|
|
@@ -2346,6 +3274,404 @@ function lifetimeSemanticSummary(jobs) {
|
|
|
2346
3274
|
conflicts: jobs.filter((job) => textValue(job.semanticReadiness, '') === 'blocked').length
|
|
2347
3275
|
};
|
|
2348
3276
|
}
|
|
3277
|
+
function semanticWithHealth(semantic, summary, jobs) {
|
|
3278
|
+
const existingHealth = recordValue(semantic.health);
|
|
3279
|
+
const sourceSummary = { ...recordValue(summary.bucketCounts), ...summary };
|
|
3280
|
+
const imports = recordValue(semantic.import);
|
|
3281
|
+
const edit = recordValue(semantic.edit);
|
|
3282
|
+
const script = recordValue(edit.script);
|
|
3283
|
+
const projection = recordValue(edit.projection);
|
|
3284
|
+
const replay = recordValue(semantic.replay);
|
|
3285
|
+
const admission = recordValue(semantic.admission);
|
|
3286
|
+
const jobAdmission = recordValue(admission.jobs);
|
|
3287
|
+
const scriptAdmission = recordValue(admission.scripts);
|
|
3288
|
+
const jobAdmissionStatusCounts = numberRecordValue(recordValue(jobAdmission.statusCounts));
|
|
3289
|
+
const scriptAdmissionStatusCounts = numberRecordValue(recordValue(scriptAdmission.statusCounts));
|
|
3290
|
+
const parserLosses = firstNumber(sourceSummary.semanticImportLossCount, imports.lossCount);
|
|
3291
|
+
const parserLossSeverityCounts = numberRecordValue(recordValue(sourceSummary.semanticImportLossesBySeverity ?? imports.lossSeverityCounts));
|
|
3292
|
+
const parserWarnings = firstNumber(sourceSummary.semanticImportWarningCount, imports.warningCount);
|
|
3293
|
+
const expectedMissingReasonCodes = uniquePaths([
|
|
3294
|
+
...stringArray(sourceSummary.semanticImportExpectedMissingReasonCodes),
|
|
3295
|
+
...stringArray(imports.expectedMissingReasonCodes)
|
|
3296
|
+
]);
|
|
3297
|
+
const ledger = recordValue(sourceSummary.applyLedger);
|
|
3298
|
+
const ledgerFailed = numberValue(ledger.failed);
|
|
3299
|
+
const ledgerSkipped = numberValue(ledger.skipped);
|
|
3300
|
+
const ledgerLanded = firstNumber(ledger.landed, sourceSummary.landed);
|
|
3301
|
+
const autoMergeCandidates = Math.max(firstNumber(sourceSummary.semanticEditScriptAutoMergeCandidates, script.autoMergeCandidateCount, semantic.autoMerge), numberValue(jobAdmission.autoMergeCandidateCount), numberValue(scriptAdmission.autoMergeCandidateCount));
|
|
3302
|
+
const scriptConflicts = firstNumber(sourceSummary.semanticEditScriptConflicts, script.conflictCount);
|
|
3303
|
+
const replayConflicts = firstNumber(sourceSummary.semanticEditReplayConflicts, replay.conflictCount, semantic.conflicts);
|
|
3304
|
+
const conflictCount = scriptConflicts + replayConflicts +
|
|
3305
|
+
admissionStatusCount(jobAdmissionStatusCounts, 'conflict') +
|
|
3306
|
+
admissionStatusCount(scriptAdmissionStatusCounts, 'conflict');
|
|
3307
|
+
const scriptBlocked = firstNumber(sourceSummary.semanticEditScriptBlocked, script.blockedCount);
|
|
3308
|
+
const projectionBlocked = firstNumber(sourceSummary.semanticEditProjectionBlocked, projection.blockedCount);
|
|
3309
|
+
const replayBlocked = firstNumber(sourceSummary.semanticEditReplayBlocked, replay.blockedCount);
|
|
3310
|
+
const lineageBlocked = firstNumber(sourceSummary.semanticLineageBlocked);
|
|
3311
|
+
const blockedCount = scriptBlocked + projectionBlocked + replayBlocked + lineageBlocked +
|
|
3312
|
+
admissionStatusCount(jobAdmissionStatusCounts, 'blocked') +
|
|
3313
|
+
admissionStatusCount(scriptAdmissionStatusCounts, 'blocked');
|
|
3314
|
+
const staleCount = firstNumber(sourceSummary.semanticEditScriptStale, script.staleCount) +
|
|
3315
|
+
firstNumber(sourceSummary.semanticEditReplayStale, replay.staleCount);
|
|
3316
|
+
const needsPortCount = firstNumber(sourceSummary.semanticEditScriptNeedsPort, script.needsPortCount) +
|
|
3317
|
+
firstNumber(sourceSummary.semanticEditReplayNeedsPort, replay.needsPortCount);
|
|
3318
|
+
const reviewRequiredCount = firstNumber(sourceSummary.semanticEditScriptReviewRequired, script.reviewRequiredCount) +
|
|
3319
|
+
firstNumber(sourceSummary.semanticLineageNeedsReview) +
|
|
3320
|
+
firstNumber(sourceSummary.semanticLineageAmbiguous) +
|
|
3321
|
+
admissionStatusCount(jobAdmissionStatusCounts, 'review-required') +
|
|
3322
|
+
admissionStatusCount(scriptAdmissionStatusCounts, 'review-required');
|
|
3323
|
+
const reviewReasonCodes = uniquePaths([
|
|
3324
|
+
...expectedMissingReasonCodes,
|
|
3325
|
+
...stringArray(sourceSummary.semanticLineageReasonCodes),
|
|
3326
|
+
...stringArray(sourceSummary.semanticEditScriptReasonCodes),
|
|
3327
|
+
...stringArray(sourceSummary.semanticEditProjectionReasonCodes),
|
|
3328
|
+
...stringArray(sourceSummary.semanticEditReplayReasonCodes),
|
|
3329
|
+
...stringArray(replay.reasonCodes)
|
|
3330
|
+
]).slice(0, 12);
|
|
3331
|
+
const proofFailed = firstNumber(sourceSummary.semanticProofSpecFailedObligations);
|
|
3332
|
+
const openCoordinatorReviewCount = jobs.filter(isOpenCoordinatorReviewRecord).length;
|
|
3333
|
+
const synthesizedResearchCompleteCount = jobs.filter(isSynthesizedResearchCompleteRecord).length;
|
|
3334
|
+
const failedCount = numberValue(parserLossSeverityCounts.error) + proofFailed + ledgerFailed + conflictCount + blockedCount;
|
|
3335
|
+
const warningCount = parserWarnings + reviewRequiredCount + ledgerSkipped + staleCount + needsPortCount + openCoordinatorReviewCount;
|
|
3336
|
+
const passedCount = firstNumber(replay.acceptedCleanCount, semantic.acceptedClean) +
|
|
3337
|
+
numberValue(replay.alreadyAppliedCount) +
|
|
3338
|
+
ledgerLanded +
|
|
3339
|
+
autoMergeCandidates;
|
|
3340
|
+
const gateReasonCodes = uniquePaths([
|
|
3341
|
+
...reviewReasonCodes,
|
|
3342
|
+
...(numberValue(parserLossSeverityCounts.error) ? ['semantic-parser-error-loss'] : []),
|
|
3343
|
+
...(proofFailed ? ['semantic-proof-obligation-failed'] : []),
|
|
3344
|
+
...(ledgerFailed ? ['apply-ledger-failed'] : []),
|
|
3345
|
+
...(conflictCount ? ['semantic-conflict'] : []),
|
|
3346
|
+
...(blockedCount ? ['semantic-blocked'] : []),
|
|
3347
|
+
...(openCoordinatorReviewCount ? ['open-coordinator-review'] : [])
|
|
3348
|
+
]).slice(0, 12);
|
|
3349
|
+
const parserHealth = {
|
|
3350
|
+
lossCount: parserLosses,
|
|
3351
|
+
lossSeverityCounts: parserLossSeverityCounts,
|
|
3352
|
+
warningCount: parserWarnings,
|
|
3353
|
+
expectedMissingReasonCodes
|
|
3354
|
+
};
|
|
3355
|
+
const ledgerHealth = {
|
|
3356
|
+
totalCount: numberValue(ledger.total),
|
|
3357
|
+
landedCount: ledgerLanded,
|
|
3358
|
+
skippedCount: ledgerSkipped,
|
|
3359
|
+
failedCount: ledgerFailed
|
|
3360
|
+
};
|
|
3361
|
+
const mergeHealth = {
|
|
3362
|
+
autoMergeCandidateCount: autoMergeCandidates,
|
|
3363
|
+
reviewRequiredCount,
|
|
3364
|
+
conflictCount,
|
|
3365
|
+
staleCount,
|
|
3366
|
+
blockedCount,
|
|
3367
|
+
needsPortCount,
|
|
3368
|
+
reasonCodes: reviewReasonCodes
|
|
3369
|
+
};
|
|
3370
|
+
const gatesHealth = {
|
|
3371
|
+
status: semanticGateStatus({ failedCount, warningCount, passedCount }),
|
|
3372
|
+
passedCount,
|
|
3373
|
+
warningCount,
|
|
3374
|
+
failedCount,
|
|
3375
|
+
reasonCodes: gateReasonCodes
|
|
3376
|
+
};
|
|
3377
|
+
const outcomesHealth = {
|
|
3378
|
+
openCoordinatorReviewCount,
|
|
3379
|
+
synthesizedResearchCompleteCount
|
|
3380
|
+
};
|
|
3381
|
+
return {
|
|
3382
|
+
...semantic,
|
|
3383
|
+
import: {
|
|
3384
|
+
...imports,
|
|
3385
|
+
lossCount: parserLosses,
|
|
3386
|
+
lossSeverityCounts: parserLossSeverityCounts
|
|
3387
|
+
},
|
|
3388
|
+
health: {
|
|
3389
|
+
...existingHealth,
|
|
3390
|
+
parser: { ...parserHealth, ...recordValue(existingHealth.parser) },
|
|
3391
|
+
ledger: { ...ledgerHealth, ...recordValue(existingHealth.ledger) },
|
|
3392
|
+
merge: { ...mergeHealth, ...recordValue(existingHealth.merge) },
|
|
3393
|
+
gates: { ...gatesHealth, ...recordValue(existingHealth.gates) },
|
|
3394
|
+
outcomes: { ...outcomesHealth, ...recordValue(existingHealth.outcomes) },
|
|
3395
|
+
admission: semanticAdmissionHealth({
|
|
3396
|
+
sourceSummary,
|
|
3397
|
+
semantic,
|
|
3398
|
+
existingHealth,
|
|
3399
|
+
imports,
|
|
3400
|
+
replay,
|
|
3401
|
+
jobAdmission,
|
|
3402
|
+
scriptAdmission,
|
|
3403
|
+
jobAdmissionStatusCounts,
|
|
3404
|
+
scriptAdmissionStatusCounts,
|
|
3405
|
+
jobs,
|
|
3406
|
+
parserLosses,
|
|
3407
|
+
parserWarnings,
|
|
3408
|
+
ledgerLanded,
|
|
3409
|
+
autoMergeCandidates,
|
|
3410
|
+
conflictCount,
|
|
3411
|
+
staleCount,
|
|
3412
|
+
reviewRequiredCount,
|
|
3413
|
+
openCoordinatorReviewCount,
|
|
3414
|
+
expectedMissingReasonCodes
|
|
3415
|
+
})
|
|
3416
|
+
}
|
|
3417
|
+
};
|
|
3418
|
+
}
|
|
3419
|
+
const SEMANTIC_ADMISSION_STATUS_KEYS = [
|
|
3420
|
+
'safe-merged',
|
|
3421
|
+
'safe-with-losses',
|
|
3422
|
+
'conflict',
|
|
3423
|
+
'no-op',
|
|
3424
|
+
'stale',
|
|
3425
|
+
'missing-sidecar',
|
|
3426
|
+
'coordinator-review',
|
|
3427
|
+
'tests-missing'
|
|
3428
|
+
];
|
|
3429
|
+
function semanticAdmissionHealth(input) {
|
|
3430
|
+
const existingAdmission = recordValue(input.existingHealth.admission);
|
|
3431
|
+
const reasonCodeCounts = semanticAdmissionReasonCodeCounts(input);
|
|
3432
|
+
const statusCounts = emptySemanticAdmissionStatusCounts();
|
|
3433
|
+
addSemanticAdmissionStatusCounts(statusCounts, input.jobAdmissionStatusCounts);
|
|
3434
|
+
addSemanticAdmissionStatusCounts(statusCounts, input.scriptAdmissionStatusCounts);
|
|
3435
|
+
addSemanticAdmissionStatusCounts(statusCounts, numberRecordValue(recordValue(existingAdmission.statusCounts)));
|
|
3436
|
+
const replaySafeCount = firstNumber(input.replay.acceptedCleanCount, input.semantic.acceptedClean) +
|
|
3437
|
+
numberValue(input.replay.alreadyAppliedCount);
|
|
3438
|
+
setRecordMinimum(statusCounts, 'safe-merged', Math.max(replaySafeCount, input.ledgerLanded, input.autoMergeCandidates));
|
|
3439
|
+
setRecordMinimum(statusCounts, 'safe-with-losses', Math.max(numberValue(reasonCodeCounts['lossy-import']), input.parserLosses + input.parserWarnings > 0 && numberValue(statusCounts['safe-merged']) > 0 ? input.parserLosses + input.parserWarnings : 0));
|
|
3440
|
+
setRecordMinimum(statusCounts, 'conflict', Math.max(input.conflictCount, numberValue(reasonCodeCounts['symbol-conflict']) + numberValue(reasonCodeCounts['effect-conflict'])));
|
|
3441
|
+
setRecordMinimum(statusCounts, 'stale', Math.max(input.staleCount, numberValue(reasonCodeCounts['stale-source-hash']), numberValue(input.sourceSummary['stale-against-head'])));
|
|
3442
|
+
setRecordMinimum(statusCounts, 'missing-sidecar', Math.max(numberValue(reasonCodeCounts['missing-sidecar']), input.expectedMissingReasonCodes.filter((reason) => semanticAdmissionReasonCode(reason) === 'missing-sidecar').length));
|
|
3443
|
+
setRecordMinimum(statusCounts, 'coordinator-review', Math.max(input.reviewRequiredCount, input.openCoordinatorReviewCount, numberValue(input.sourceSummary['needs-coordinator-review'])));
|
|
3444
|
+
setRecordMinimum(statusCounts, 'tests-missing', numberValue(reasonCodeCounts['tests-missing']));
|
|
3445
|
+
return {
|
|
3446
|
+
...existingAdmission,
|
|
3447
|
+
statusCounts,
|
|
3448
|
+
reasonCodeCounts,
|
|
3449
|
+
totalCount: sumNumberRecordValues(statusCounts),
|
|
3450
|
+
reasonTotalCount: sumNumberRecordValues(reasonCodeCounts)
|
|
3451
|
+
};
|
|
3452
|
+
}
|
|
3453
|
+
function emptySemanticAdmissionStatusCounts() {
|
|
3454
|
+
return SEMANTIC_ADMISSION_STATUS_KEYS.reduce((out, key) => {
|
|
3455
|
+
out[key] = 0;
|
|
3456
|
+
return out;
|
|
3457
|
+
}, {});
|
|
3458
|
+
}
|
|
3459
|
+
function addSemanticAdmissionStatusCounts(out, counts) {
|
|
3460
|
+
for (const [status, count] of Object.entries(counts)) {
|
|
3461
|
+
const key = semanticAdmissionStateKey(status);
|
|
3462
|
+
if (key && count > 0)
|
|
3463
|
+
out[key] += count;
|
|
3464
|
+
}
|
|
3465
|
+
}
|
|
3466
|
+
function semanticAdmissionReasonCodeCounts(input) {
|
|
3467
|
+
const counts = {};
|
|
3468
|
+
const existingAdmission = recordValue(input.existingHealth.admission);
|
|
3469
|
+
const admission = recordValue(input.semantic.admission);
|
|
3470
|
+
addReasonCodeCountRecord(counts, recordValue(input.sourceSummary.semanticAdmissionReasonCodeCounts));
|
|
3471
|
+
addReasonCodeCountRecord(counts, recordValue(input.sourceSummary.semanticCollectAdmissionReasonCodeCounts));
|
|
3472
|
+
addReasonCodeCountRecord(counts, recordValue(input.sourceSummary.semanticEditAdmissionReasonCodeCounts));
|
|
3473
|
+
addReasonCodeCountRecord(counts, recordValue(input.sourceSummary.semanticEditScriptAdmissionReasonCodeCounts));
|
|
3474
|
+
addReasonCodeCountRecord(counts, recordValue(admission.reasonCodeCounts));
|
|
3475
|
+
addReasonCodeCountRecord(counts, recordValue(input.jobAdmission.reasonCodeCounts));
|
|
3476
|
+
addReasonCodeCountRecord(counts, recordValue(input.scriptAdmission.reasonCodeCounts));
|
|
3477
|
+
addReasonCodeCountRecord(counts, recordValue(existingAdmission.reasonCodeCounts));
|
|
3478
|
+
addReasonCodeSignals(counts, [
|
|
3479
|
+
...stringArray(input.sourceSummary.semanticAdmissionReasonCodes),
|
|
3480
|
+
...stringArray(input.sourceSummary.semanticCollectAdmissionReasonCodes),
|
|
3481
|
+
...stringArray(input.sourceSummary.semanticImportExpectedMissingReasonCodes),
|
|
3482
|
+
...stringArray(input.sourceSummary.semanticLineageReasonCodes),
|
|
3483
|
+
...stringArray(input.sourceSummary.semanticEditScriptReasonCodes),
|
|
3484
|
+
...stringArray(input.sourceSummary.semanticEditProjectionReasonCodes),
|
|
3485
|
+
...stringArray(input.sourceSummary.semanticEditReplayReasonCodes),
|
|
3486
|
+
...input.expectedMissingReasonCodes,
|
|
3487
|
+
...stringArray(input.imports.expectedMissingReasonCodes),
|
|
3488
|
+
...stringArray(input.replay.reasonCodes)
|
|
3489
|
+
]);
|
|
3490
|
+
for (const job of input.jobs) {
|
|
3491
|
+
addReasonCodeSignals(counts, [
|
|
3492
|
+
textValue(job.semanticAdmissionStatus, ''),
|
|
3493
|
+
textValue(job.semanticReadiness, ''),
|
|
3494
|
+
textValue(job.bucket, ''),
|
|
3495
|
+
textValue(job.disposition, ''),
|
|
3496
|
+
textValue(job.mergeReadiness, ''),
|
|
3497
|
+
...stringArray(job.semanticReadinessReasons),
|
|
3498
|
+
...stringArray(job.collectReasonClasses),
|
|
3499
|
+
...stringArray(job.reasons)
|
|
3500
|
+
]);
|
|
3501
|
+
}
|
|
3502
|
+
setRecordMinimum(counts, 'lossy-import', input.parserLosses + input.parserWarnings);
|
|
3503
|
+
setRecordMinimum(counts, 'stale-source-hash', input.staleCount);
|
|
3504
|
+
setRecordMinimum(counts, 'symbol-conflict', input.conflictCount);
|
|
3505
|
+
setRecordMinimum(counts, 'coordinator-review', input.reviewRequiredCount + input.openCoordinatorReviewCount);
|
|
3506
|
+
return counts;
|
|
3507
|
+
}
|
|
3508
|
+
function addReasonCodeCountRecord(out, counts) {
|
|
3509
|
+
for (const [key, value] of Object.entries(counts)) {
|
|
3510
|
+
const count = numberValue(value);
|
|
3511
|
+
if (count <= 0)
|
|
3512
|
+
continue;
|
|
3513
|
+
const code = semanticAdmissionReasonCode(key) ?? normalizedStatusKey(key);
|
|
3514
|
+
out[code] = (out[code] ?? 0) + count;
|
|
3515
|
+
}
|
|
3516
|
+
}
|
|
3517
|
+
function addReasonCodeSignals(out, values) {
|
|
3518
|
+
for (const value of values) {
|
|
3519
|
+
const code = semanticAdmissionReasonCode(value);
|
|
3520
|
+
if (code)
|
|
3521
|
+
out[code] = (out[code] ?? 0) + 1;
|
|
3522
|
+
}
|
|
3523
|
+
}
|
|
3524
|
+
function semanticAdmissionStateKey(value) {
|
|
3525
|
+
const signal = normalizedStatusKey(value);
|
|
3526
|
+
if (!signal)
|
|
3527
|
+
return undefined;
|
|
3528
|
+
if (signal === 'safe-merged' ||
|
|
3529
|
+
signal === 'safe' ||
|
|
3530
|
+
signal === 'accepted' ||
|
|
3531
|
+
signal === 'accepted-clean' ||
|
|
3532
|
+
signal === 'clean' ||
|
|
3533
|
+
signal === 'ready' ||
|
|
3534
|
+
signal === 'pass' ||
|
|
3535
|
+
signal === 'auto-merge-candidate' ||
|
|
3536
|
+
signal === 'already-applied' ||
|
|
3537
|
+
signal === 'portable')
|
|
3538
|
+
return 'safe-merged';
|
|
3539
|
+
if (signal === 'safe-with-losses' ||
|
|
3540
|
+
signal === 'lossy' ||
|
|
3541
|
+
signal === 'lossy-import' ||
|
|
3542
|
+
signal === 'warning' ||
|
|
3543
|
+
signal === 'warnings' ||
|
|
3544
|
+
signal === 'clean-with-losses')
|
|
3545
|
+
return 'safe-with-losses';
|
|
3546
|
+
if (signal === 'conflict' ||
|
|
3547
|
+
signal === 'conflicts' ||
|
|
3548
|
+
signal === 'symbol-conflict' ||
|
|
3549
|
+
signal === 'effect-conflict' ||
|
|
3550
|
+
signal === 'blocked')
|
|
3551
|
+
return 'conflict';
|
|
3552
|
+
if (signal === 'no-op' ||
|
|
3553
|
+
signal === 'noop' ||
|
|
3554
|
+
signal === 'not-applicable' ||
|
|
3555
|
+
signal === 'no-semantic-edit-script' ||
|
|
3556
|
+
signal === 'evidence-only')
|
|
3557
|
+
return 'no-op';
|
|
3558
|
+
if (signal === 'stale' ||
|
|
3559
|
+
signal === 'rerun' ||
|
|
3560
|
+
signal === 'stale-against-head' ||
|
|
3561
|
+
signal === 'stale-source-hash')
|
|
3562
|
+
return 'stale';
|
|
3563
|
+
if (signal === 'missing-sidecar' ||
|
|
3564
|
+
signal === 'empty-sidecar')
|
|
3565
|
+
return 'missing-sidecar';
|
|
3566
|
+
if (signal === 'coordinator-review' ||
|
|
3567
|
+
signal === 'needs-coordinator-review' ||
|
|
3568
|
+
signal === 'needs-coordinator-port' ||
|
|
3569
|
+
signal === 'needs-human-port' ||
|
|
3570
|
+
signal === 'needs-human-review' ||
|
|
3571
|
+
signal === 'review' ||
|
|
3572
|
+
signal === 'review-required' ||
|
|
3573
|
+
signal === 'needs-review' ||
|
|
3574
|
+
signal === 'needs-port')
|
|
3575
|
+
return 'coordinator-review';
|
|
3576
|
+
if (signal === 'tests-missing' ||
|
|
3577
|
+
signal === 'missing-tests')
|
|
3578
|
+
return 'tests-missing';
|
|
3579
|
+
return undefined;
|
|
3580
|
+
}
|
|
3581
|
+
function semanticAdmissionReasonCode(value) {
|
|
3582
|
+
const signal = normalizedStatusKey(value);
|
|
3583
|
+
if (!signal)
|
|
3584
|
+
return undefined;
|
|
3585
|
+
if (signal.includes('tests-missing') || signal.includes('missing-tests') || (signal.includes('test') && signal.includes('missing')))
|
|
3586
|
+
return 'tests-missing';
|
|
3587
|
+
if (signal.includes('missing-sidecar') ||
|
|
3588
|
+
signal.includes('empty-sidecar') ||
|
|
3589
|
+
signal.includes('missing-semantic-import-sidecar') ||
|
|
3590
|
+
(signal.includes('missing') && signal.includes('sidecar')))
|
|
3591
|
+
return 'missing-sidecar';
|
|
3592
|
+
if (signal.includes('stale-source-hash') ||
|
|
3593
|
+
signal.includes('stale-against-head') ||
|
|
3594
|
+
signal.includes('stale') ||
|
|
3595
|
+
((signal.includes('current') || signal.includes('head')) && (signal.includes('hash') || signal.includes('anchor') || signal.includes('source') || signal.includes('base'))))
|
|
3596
|
+
return 'stale-source-hash';
|
|
3597
|
+
if (signal.includes('effect-conflict') || (signal.includes('effect') && (signal.includes('conflict') || signal.includes('mismatch') || signal.includes('blocked'))))
|
|
3598
|
+
return 'effect-conflict';
|
|
3599
|
+
if (signal.includes('symbol-conflict') ||
|
|
3600
|
+
signal.includes('semantic-conflict') ||
|
|
3601
|
+
signal.includes('symbol-anchor') ||
|
|
3602
|
+
signal.includes('anchor-content-mismatch') ||
|
|
3603
|
+
signal.includes('anchor-changed') ||
|
|
3604
|
+
signal.includes('conflict'))
|
|
3605
|
+
return 'symbol-conflict';
|
|
3606
|
+
if (signal.includes('lossy-import') ||
|
|
3607
|
+
signal.includes('parser-error-loss') ||
|
|
3608
|
+
signal.includes('loss'))
|
|
3609
|
+
return 'lossy-import';
|
|
3610
|
+
if (signal.includes('coordinator-review') ||
|
|
3611
|
+
signal.includes('open-coordinator-review') ||
|
|
3612
|
+
signal.includes('review-required') ||
|
|
3613
|
+
signal.includes('needs-review') ||
|
|
3614
|
+
signal.includes('needs-port') ||
|
|
3615
|
+
signal.includes('human-port'))
|
|
3616
|
+
return 'coordinator-review';
|
|
3617
|
+
return undefined;
|
|
3618
|
+
}
|
|
3619
|
+
function setRecordMinimum(record, key, value) {
|
|
3620
|
+
if (value > (record[key] ?? 0))
|
|
3621
|
+
record[key] = value;
|
|
3622
|
+
}
|
|
3623
|
+
function sumNumberRecordValues(record) {
|
|
3624
|
+
return Object.values(record).reduce((sum, value) => sum + numberValue(value), 0);
|
|
3625
|
+
}
|
|
3626
|
+
function isSynthesizedResearchCompleteRecord(job) {
|
|
3627
|
+
if (normalized(job.status) !== 'completed' || isOpenCoordinatorReviewRecord(job))
|
|
3628
|
+
return false;
|
|
3629
|
+
const signals = [
|
|
3630
|
+
job.lane,
|
|
3631
|
+
job.title,
|
|
3632
|
+
job.workKind,
|
|
3633
|
+
job.disposition,
|
|
3634
|
+
job.mergeReadiness,
|
|
3635
|
+
...stringArray(job.reasons),
|
|
3636
|
+
...stringArray(job.collectReasonClasses)
|
|
3637
|
+
].map(normalized);
|
|
3638
|
+
return signals.some((signal) => signal === 'discovery-only' ||
|
|
3639
|
+
signal.includes('research') ||
|
|
3640
|
+
signal.includes('synthesized') ||
|
|
3641
|
+
signal.includes('collector-workspace-only-recovery') ||
|
|
3642
|
+
signal.includes('generated-by-collector'));
|
|
3643
|
+
}
|
|
3644
|
+
function numberRecordValue(value) {
|
|
3645
|
+
const out = {};
|
|
3646
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
3647
|
+
const count = numberValue(entry);
|
|
3648
|
+
if (count > 0)
|
|
3649
|
+
out[key] = count;
|
|
3650
|
+
}
|
|
3651
|
+
return out;
|
|
3652
|
+
}
|
|
3653
|
+
function admissionStatusCount(record, wanted) {
|
|
3654
|
+
return Object.entries(record).reduce((sum, [key, value]) => normalizedStatusKey(key) === wanted ? sum + value : sum, 0);
|
|
3655
|
+
}
|
|
3656
|
+
function normalizedStatusKey(value) {
|
|
3657
|
+
return textValue(value, '').trim().replace(/[\s_]+/g, '-').toLowerCase();
|
|
3658
|
+
}
|
|
3659
|
+
function semanticGateStatus(input) {
|
|
3660
|
+
if (input.failedCount > 0)
|
|
3661
|
+
return 'blocked';
|
|
3662
|
+
if (input.warningCount > 0)
|
|
3663
|
+
return 'review';
|
|
3664
|
+
if (input.passedCount > 0)
|
|
3665
|
+
return 'pass';
|
|
3666
|
+
return 'unknown';
|
|
3667
|
+
}
|
|
3668
|
+
function firstNumber(...values) {
|
|
3669
|
+
for (const value of values) {
|
|
3670
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
3671
|
+
return value;
|
|
3672
|
+
}
|
|
3673
|
+
return 0;
|
|
3674
|
+
}
|
|
2349
3675
|
async function lifetimeRoutingSummary(cwd, entries) {
|
|
2350
3676
|
const routingRows = entries.map(({ snapshot }) => recordValue(snapshot.routing)).filter((entry) => Object.keys(entry).length);
|
|
2351
3677
|
const sidecars = await readLifetimeRoutingSidecars(cwd, entries.map(({ source }) => source));
|
|
@@ -2462,6 +3788,7 @@ async function readActiveRunSnapshot(options, readOptions = {}) {
|
|
|
2462
3788
|
const actualInputTokens = jobs.reduce((sum, job) => sum + numberValue(job.actualInputTokens), 0);
|
|
2463
3789
|
const estimatedInputTokens = jobs.reduce((sum, job) => sum + numberValue(job.estimatedInputTokens), 0);
|
|
2464
3790
|
const cachedInputTokens = jobs.reduce((sum, job) => sum + numberValue(job.cachedInputTokens), 0);
|
|
3791
|
+
const graph = await readDashboardLiveRunGraphSummary(options.cwd, runDir, jobs, now);
|
|
2465
3792
|
return {
|
|
2466
3793
|
ok: true,
|
|
2467
3794
|
generatedAt: now,
|
|
@@ -2482,16 +3809,20 @@ async function readActiveRunSnapshot(options, readOptions = {}) {
|
|
|
2482
3809
|
running: runningCount,
|
|
2483
3810
|
completed: completedCount,
|
|
2484
3811
|
'failed-evidence': failedCount
|
|
2485
|
-
}
|
|
3812
|
+
},
|
|
3813
|
+
...(graph ? { graph } : {})
|
|
2486
3814
|
},
|
|
3815
|
+
health: lifetimeHealthSummary(jobs),
|
|
2487
3816
|
lanes: activeRunLanes(jobs),
|
|
2488
3817
|
jobs,
|
|
2489
3818
|
activeAgents: activeAgentsFromJobs(jobs),
|
|
2490
3819
|
events: activeRunEvents(jobs),
|
|
3820
|
+
...(graph ? { graph } : {}),
|
|
2491
3821
|
sources: {
|
|
2492
3822
|
run: runDir,
|
|
2493
3823
|
activeRun: pidPath,
|
|
2494
|
-
plan: planPath
|
|
3824
|
+
plan: planPath,
|
|
3825
|
+
...(graph ? { graph: textValue(graph.sourceFile, '') } : {})
|
|
2495
3826
|
},
|
|
2496
3827
|
raw: {
|
|
2497
3828
|
activeRun: {
|
|
@@ -2502,6 +3833,143 @@ async function readActiveRunSnapshot(options, readOptions = {}) {
|
|
|
2502
3833
|
}
|
|
2503
3834
|
};
|
|
2504
3835
|
}
|
|
3836
|
+
async function readDashboardLiveRunGraphSummary(cwd, runDir, jobs, now) {
|
|
3837
|
+
const file = path.join(runDir, LIVE_RUN_GRAPH_EVENTS_FILE);
|
|
3838
|
+
const stat = await fs.stat(file).catch(() => undefined);
|
|
3839
|
+
if (stat?.isFile() && stat.size <= CODEX_EVENTS_USAGE_MAX_BYTES) {
|
|
3840
|
+
const events = await readLiveRunGraphEvents(file);
|
|
3841
|
+
if (events.length) {
|
|
3842
|
+
return summarizeDashboardRunGraph(liveRunGraphFromEvents(events, path.basename(runDir), now), {
|
|
3843
|
+
sourceFile: path.relative(cwd, file).replaceAll(path.sep, '/'),
|
|
3844
|
+
sourceKind: 'live-run-graph-events',
|
|
3845
|
+
sourceStatus: 'live',
|
|
3846
|
+
liveEventCount: events.length
|
|
3847
|
+
});
|
|
3848
|
+
}
|
|
3849
|
+
}
|
|
3850
|
+
return activeRunGraphFallback(cwd, runDir, jobs, now, stat?.isFile() && stat.size > CODEX_EVENTS_USAGE_MAX_BYTES
|
|
3851
|
+
? `${LIVE_RUN_GRAPH_EVENTS_FILE} is too large to parse for dashboard health.`
|
|
3852
|
+
: `${LIVE_RUN_GRAPH_EVENTS_FILE} is missing; projected graph health from the active PID manifest.`);
|
|
3853
|
+
}
|
|
3854
|
+
async function readLiveRunGraphEvents(file) {
|
|
3855
|
+
const text = await fs.readFile(file, 'utf8').catch(() => '');
|
|
3856
|
+
const out = [];
|
|
3857
|
+
for (const line of text.split(/\r?\n/u)) {
|
|
3858
|
+
const trimmed = line.trim();
|
|
3859
|
+
if (!trimmed)
|
|
3860
|
+
continue;
|
|
3861
|
+
const parsed = safeJsonObject(trimmed);
|
|
3862
|
+
if (parsed && textValue(parsed.kind, '') === 'frontier.swarm-codex.live-run-graph-event')
|
|
3863
|
+
out.push(parsed);
|
|
3864
|
+
}
|
|
3865
|
+
return out;
|
|
3866
|
+
}
|
|
3867
|
+
function liveRunGraphFromEvents(events, runId, generatedAt) {
|
|
3868
|
+
const nodes = new Map();
|
|
3869
|
+
const edges = new Map();
|
|
3870
|
+
let latestGeneratedAt = generatedAt;
|
|
3871
|
+
for (const event of events) {
|
|
3872
|
+
latestGeneratedAt = Math.max(latestGeneratedAt, numberValue(event.generatedAt));
|
|
3873
|
+
for (const node of recordArray(event.nodes)) {
|
|
3874
|
+
const id = textValue(node.id, '');
|
|
3875
|
+
if (!id)
|
|
3876
|
+
continue;
|
|
3877
|
+
const current = nodes.get(id);
|
|
3878
|
+
nodes.set(id, current ? mergeDashboardRunGraphNode(current, node) : node);
|
|
3879
|
+
}
|
|
3880
|
+
for (const edge of recordArray(event.edges)) {
|
|
3881
|
+
const id = textValue(edge.id, `${textValue(edge.kind, 'edge')}:${textValue(edge.from, '')}->${textValue(edge.to, '')}`);
|
|
3882
|
+
if (id)
|
|
3883
|
+
edges.set(id, { ...edge, id });
|
|
3884
|
+
}
|
|
3885
|
+
}
|
|
3886
|
+
const nodeList = Array.from(nodes.values());
|
|
3887
|
+
const edgeList = Array.from(edges.values());
|
|
3888
|
+
return {
|
|
3889
|
+
kind: 'frontier.swarm-codex.run-graph',
|
|
3890
|
+
version: 1,
|
|
3891
|
+
id: `live:${runId}`,
|
|
3892
|
+
generatedAt: latestGeneratedAt,
|
|
3893
|
+
nodes: nodeList,
|
|
3894
|
+
edges: edgeList,
|
|
3895
|
+
summary: {
|
|
3896
|
+
nodeCount: nodeList.length,
|
|
3897
|
+
edgeCount: edgeList.length,
|
|
3898
|
+
decisionCount: nodeList.filter((node) => normalized(node.kind) === 'decision').length,
|
|
3899
|
+
gateCount: nodeList.filter((node) => normalized(node.kind) === 'gate').length
|
|
3900
|
+
}
|
|
3901
|
+
};
|
|
3902
|
+
}
|
|
3903
|
+
function mergeDashboardRunGraphNode(left, right) {
|
|
3904
|
+
return {
|
|
3905
|
+
...left,
|
|
3906
|
+
...right,
|
|
3907
|
+
data: {
|
|
3908
|
+
...recordValue(left.data),
|
|
3909
|
+
...recordValue(right.data)
|
|
3910
|
+
}
|
|
3911
|
+
};
|
|
3912
|
+
}
|
|
3913
|
+
function activeRunGraphFallback(cwd, runDir, jobs, now, warning) {
|
|
3914
|
+
if (!jobs.length)
|
|
3915
|
+
return undefined;
|
|
3916
|
+
const nodes = [];
|
|
3917
|
+
const edges = [];
|
|
3918
|
+
for (const job of jobs) {
|
|
3919
|
+
const jobId = textValue(job.id ?? job.jobId, 'job');
|
|
3920
|
+
const jobNodeId = `job:${jobId}`;
|
|
3921
|
+
nodes.push({
|
|
3922
|
+
id: jobNodeId,
|
|
3923
|
+
kind: 'job',
|
|
3924
|
+
label: textValue(job.title ?? jobId, jobId),
|
|
3925
|
+
jobId,
|
|
3926
|
+
taskId: textValue(job.taskId, ''),
|
|
3927
|
+
lane: textValue(job.lane, ''),
|
|
3928
|
+
status: textValue(job.status, ''),
|
|
3929
|
+
generatedAt: numberValue(job.generatedAt) || now
|
|
3930
|
+
});
|
|
3931
|
+
if (textValue(job.status, '') !== 'running') {
|
|
3932
|
+
const decisionNodeId = `decision:terminal:${jobId}`;
|
|
3933
|
+
nodes.push({
|
|
3934
|
+
id: decisionNodeId,
|
|
3935
|
+
kind: 'decision',
|
|
3936
|
+
label: textValue(job.status, 'terminal'),
|
|
3937
|
+
jobId,
|
|
3938
|
+
taskId: textValue(job.taskId, ''),
|
|
3939
|
+
lane: textValue(job.lane, ''),
|
|
3940
|
+
status: textValue(job.status, ''),
|
|
3941
|
+
outcome: textValue(job.disposition ?? job.mergeReadiness ?? job.status, ''),
|
|
3942
|
+
generatedAt: numberValue(job.finishedAt) || numberValue(job.generatedAt) || now
|
|
3943
|
+
});
|
|
3944
|
+
edges.push({
|
|
3945
|
+
id: `decides:${decisionNodeId}->${jobNodeId}`,
|
|
3946
|
+
kind: 'decides',
|
|
3947
|
+
from: decisionNodeId,
|
|
3948
|
+
to: jobNodeId
|
|
3949
|
+
});
|
|
3950
|
+
}
|
|
3951
|
+
}
|
|
3952
|
+
return summarizeDashboardRunGraph({
|
|
3953
|
+
kind: 'frontier.swarm-codex.run-graph',
|
|
3954
|
+
version: 1,
|
|
3955
|
+
id: `active-pid:${path.basename(runDir)}`,
|
|
3956
|
+
generatedAt: now,
|
|
3957
|
+
nodes,
|
|
3958
|
+
edges,
|
|
3959
|
+
summary: {
|
|
3960
|
+
nodeCount: nodes.length,
|
|
3961
|
+
edgeCount: edges.length,
|
|
3962
|
+
decisionCount: nodes.filter((node) => normalized(node.kind) === 'decision').length,
|
|
3963
|
+
gateCount: 0
|
|
3964
|
+
}
|
|
3965
|
+
}, {
|
|
3966
|
+
sourceFile: path.relative(cwd, path.join(runDir, LIVE_RUN_GRAPH_EVENTS_FILE)).replaceAll(path.sep, '/'),
|
|
3967
|
+
sourceKind: 'active-pid-fallback',
|
|
3968
|
+
sourceStatus: 'live',
|
|
3969
|
+
graphMissing: true,
|
|
3970
|
+
graphMissingWarnings: [warning]
|
|
3971
|
+
});
|
|
3972
|
+
}
|
|
2505
3973
|
async function activeRunJob(cwd, runDir, entry, planJob, now, readOptions = {}) {
|
|
2506
3974
|
const jobId = textValue(entry.jobId, 'job');
|
|
2507
3975
|
const jobDir = path.join(runDir, jobId);
|
|
@@ -2537,6 +4005,8 @@ async function activeRunJob(cwd, runDir, entry, planJob, now, readOptions = {})
|
|
|
2537
4005
|
const commandEvidence = commandEvidenceFromRecords(merge, evidenceRecord);
|
|
2538
4006
|
return withRecomputedCostFields({
|
|
2539
4007
|
id: jobId,
|
|
4008
|
+
jobId,
|
|
4009
|
+
originalJobId: jobId,
|
|
2540
4010
|
taskId: textValue(planJob?.taskId ?? task.id, jobId),
|
|
2541
4011
|
title: textValue(planJob?.title ?? task.title, jobId),
|
|
2542
4012
|
lane: textValue(planJob?.lane ?? task.lane, 'active-run'),
|
|
@@ -2650,7 +4120,7 @@ function mergeActiveRunJobTelemetry(jobs, activeJobs) {
|
|
|
2650
4120
|
return jobs;
|
|
2651
4121
|
const byKey = new Map();
|
|
2652
4122
|
for (const activeJob of activeJobs) {
|
|
2653
|
-
if (!
|
|
4123
|
+
if (!hasActiveRunJobState(activeJob))
|
|
2654
4124
|
continue;
|
|
2655
4125
|
for (const key of jobTelemetryKeys(activeJob))
|
|
2656
4126
|
byKey.set(key, activeJob);
|
|
@@ -2666,6 +4136,7 @@ function mergeActiveRunJobTelemetry(jobs, activeJobs) {
|
|
|
2666
4136
|
return record;
|
|
2667
4137
|
return {
|
|
2668
4138
|
...record,
|
|
4139
|
+
...activeRunJobStateFields(activeJob),
|
|
2669
4140
|
...(numberValue(activeJob.actualInputTokens) ? { actualInputTokens: numberValue(activeJob.actualInputTokens) } : {}),
|
|
2670
4141
|
...(numberValue(activeJob.inputTokens) ? { inputTokens: numberValue(activeJob.inputTokens) } : {}),
|
|
2671
4142
|
...(numberValue(activeJob.estimatedInputTokens) ? { estimatedInputTokens: numberValue(activeJob.estimatedInputTokens) } : {}),
|
|
@@ -2681,6 +4152,31 @@ function mergeActiveRunJobTelemetry(jobs, activeJobs) {
|
|
|
2681
4152
|
};
|
|
2682
4153
|
});
|
|
2683
4154
|
}
|
|
4155
|
+
function hasActiveRunJobState(job) {
|
|
4156
|
+
return hasTokenTelemetry(job)
|
|
4157
|
+
|| Boolean(textValue(job.status, ''))
|
|
4158
|
+
|| stringArray(job.evidencePaths).length > 0
|
|
4159
|
+
|| stringArray(job.changedPaths).length > 0
|
|
4160
|
+
|| Boolean(textValue(job.patchPath, ''));
|
|
4161
|
+
}
|
|
4162
|
+
function activeRunJobStateFields(job) {
|
|
4163
|
+
const evidencePaths = stringArray(job.evidencePaths);
|
|
4164
|
+
const changedPaths = stringArray(job.changedPaths);
|
|
4165
|
+
const artifactPaths = stringArray(job.artifactPaths);
|
|
4166
|
+
return {
|
|
4167
|
+
...(textValue(job.status, '') ? { status: textValue(job.status, '') } : {}),
|
|
4168
|
+
...(textValue(job.bucket, '') ? { bucket: textValue(job.bucket, '') } : {}),
|
|
4169
|
+
...(textValue(job.disposition, '') ? { disposition: textValue(job.disposition, '') } : {}),
|
|
4170
|
+
...(textValue(job.mergeReadiness, '') ? { mergeReadiness: textValue(job.mergeReadiness, '') } : {}),
|
|
4171
|
+
...(textValue(job.patchPath, '') ? { patchPath: textValue(job.patchPath, '') } : {}),
|
|
4172
|
+
...(artifactPaths.length ? { artifactPaths: uniquePaths([...artifactPaths, ...stringArray(job.artifactPaths)]) } : {}),
|
|
4173
|
+
...(changedPaths.length ? { changedPaths, changedPathCount: changedPaths.length } : {}),
|
|
4174
|
+
...(evidencePaths.length ? { evidencePaths, evidencePathCount: evidencePaths.length } : {}),
|
|
4175
|
+
...(numberValue(job.commandsPassed) ? { commandsPassed: numberValue(job.commandsPassed) } : {}),
|
|
4176
|
+
...(numberValue(job.commandsFailed) ? { commandsFailed: numberValue(job.commandsFailed) } : {}),
|
|
4177
|
+
...(stringArray(job.collectReasonClasses).length ? { collectReasonClasses: stringArray(job.collectReasonClasses) } : {})
|
|
4178
|
+
};
|
|
4179
|
+
}
|
|
2684
4180
|
function jobTelemetryKeys(job) {
|
|
2685
4181
|
return Array.from(new Set([
|
|
2686
4182
|
textValue(job.id, ''),
|
|
@@ -2833,9 +4329,10 @@ async function readJsonFile(file) {
|
|
|
2833
4329
|
}
|
|
2834
4330
|
}
|
|
2835
4331
|
function withRecomputedCostFields(record) {
|
|
4332
|
+
const sourceRecord = withSourceOutputState(record);
|
|
2836
4333
|
const model = textValue(record.model ?? record.pricingModel ?? record.pricingMatchedModel, '');
|
|
2837
4334
|
if (!model || !hasCostTokenEvidence(record))
|
|
2838
|
-
return
|
|
4335
|
+
return sourceRecord;
|
|
2839
4336
|
const cost = estimateCodexModelCost({
|
|
2840
4337
|
model,
|
|
2841
4338
|
estimatedInputTokens: firstCostTokenNumber(record.estimatedInputTokens, record.estimated_input_tokens),
|
|
@@ -2845,7 +4342,7 @@ function withRecomputedCostFields(record) {
|
|
|
2845
4342
|
outputTokens: optionalCostTokenNumber(record.actualOutputTokens, record.outputTokens, record.completionTokens, record.responseTokens, record.actual_output_tokens, record.output_tokens, record.completion_tokens, record.response_tokens)
|
|
2846
4343
|
});
|
|
2847
4344
|
return {
|
|
2848
|
-
...
|
|
4345
|
+
...sourceRecord,
|
|
2849
4346
|
billableInputTokens: cost.billableInputTokens,
|
|
2850
4347
|
priceKnown: cost.priceKnown,
|
|
2851
4348
|
...(cost.pricingModel ? { pricingModel: cost.pricingModel } : {}),
|
|
@@ -2865,6 +4362,89 @@ function withRecomputedCostFields(record) {
|
|
|
2865
4362
|
...(cost.unknownPricingReason ? { unknownPricingReason: cost.unknownPricingReason } : { unknownPricingReason: undefined })
|
|
2866
4363
|
};
|
|
2867
4364
|
}
|
|
4365
|
+
function withSourceOutputState(record) {
|
|
4366
|
+
if (textValue(record.sourceOutputState, ''))
|
|
4367
|
+
return record;
|
|
4368
|
+
const summary = sourceOutputSummary(record);
|
|
4369
|
+
if (!summary.state)
|
|
4370
|
+
return record;
|
|
4371
|
+
return {
|
|
4372
|
+
...record,
|
|
4373
|
+
sourceOutputState: summary.state,
|
|
4374
|
+
sourceOutputLabel: summary.label,
|
|
4375
|
+
sourceOutputDetail: summary.detail
|
|
4376
|
+
};
|
|
4377
|
+
}
|
|
4378
|
+
function sourceOutputSummary(record) {
|
|
4379
|
+
const changedPathCount = numberValue(record.changedPathCount) || stringArray(record.changedPaths).length;
|
|
4380
|
+
const evidencePathCount = numberValue(record.evidencePathCount) || stringArray(record.evidencePaths).length;
|
|
4381
|
+
const hasPatch = sourceOutputHasPatchArtifact(record);
|
|
4382
|
+
const signals = sourceOutputSignals(record);
|
|
4383
|
+
const recoveryStatus = sourceOutputRecoveryStatus(record);
|
|
4384
|
+
const workspaceRecovery = Boolean(recoveryStatus) || signals.some((signal) => signal.includes('collector-workspace-only-recovery'));
|
|
4385
|
+
const failedPatch = recoveryStatus === 'failed-patch'
|
|
4386
|
+
|| signals.some((signal) => signal.includes('collector-workspace-only-recovery-failed-patch') || signal === 'empty patch' || signal === 'empty-patch');
|
|
4387
|
+
if (failedPatch)
|
|
4388
|
+
return {
|
|
4389
|
+
state: 'recovered-patch-failed',
|
|
4390
|
+
label: changedPathCount ? `${changedPathCount} ${changedPathCount === 1 ? 'path' : 'paths'}, patch failed` : 'patch generation failed',
|
|
4391
|
+
detail: changedPathCount
|
|
4392
|
+
? 'Source changed, but recovered patch generation failed.'
|
|
4393
|
+
: 'Patch generation failed before a source diff could be attached.'
|
|
4394
|
+
};
|
|
4395
|
+
if (workspaceRecovery && (hasPatch || changedPathCount))
|
|
4396
|
+
return {
|
|
4397
|
+
state: 'recovered-patch',
|
|
4398
|
+
label: changedPathCount ? `${changedPathCount} recovered ${changedPathCount === 1 ? 'path' : 'paths'}` : 'recovered patch',
|
|
4399
|
+
detail: changedPathCount
|
|
4400
|
+
? 'Source changed and the collector recovered a patch from the worker workspace.'
|
|
4401
|
+
: 'The collector recovered a patch, but changed paths are not indexed yet.'
|
|
4402
|
+
};
|
|
4403
|
+
if (!changedPathCount && hasPatch)
|
|
4404
|
+
return {
|
|
4405
|
+
state: 'patch-unindexed',
|
|
4406
|
+
label: 'patch not indexed yet',
|
|
4407
|
+
detail: 'A patch artifact exists, but changed paths are not indexed yet.'
|
|
4408
|
+
};
|
|
4409
|
+
if (!changedPathCount && evidencePathCount)
|
|
4410
|
+
return {
|
|
4411
|
+
state: 'evidence-only',
|
|
4412
|
+
label: 'evidence only',
|
|
4413
|
+
detail: 'No source files changed; this task produced evidence only.'
|
|
4414
|
+
};
|
|
4415
|
+
if (!changedPathCount)
|
|
4416
|
+
return {
|
|
4417
|
+
state: 'no-source-files',
|
|
4418
|
+
label: 'no source files',
|
|
4419
|
+
detail: 'No source files are reported for this task.'
|
|
4420
|
+
};
|
|
4421
|
+
return { state: '', label: '', detail: '' };
|
|
4422
|
+
}
|
|
4423
|
+
function sourceOutputSignals(record) {
|
|
4424
|
+
return uniquePaths([
|
|
4425
|
+
...stringArray(record.collectReasonClasses),
|
|
4426
|
+
...stringArray(record.reasons),
|
|
4427
|
+
...stringArray(record.semanticReadinessReasons),
|
|
4428
|
+
textValue(record.disposition, ''),
|
|
4429
|
+
textValue(record.mergeReadiness, ''),
|
|
4430
|
+
textValue(record.status, '')
|
|
4431
|
+
].map(normalized));
|
|
4432
|
+
}
|
|
4433
|
+
function sourceOutputRecoveryStatus(record) {
|
|
4434
|
+
const metadata = recordValue(record.metadata);
|
|
4435
|
+
const swarmCodex = recordValue(metadata.frontierSwarmCodex);
|
|
4436
|
+
const workspaceOnly = recordValue(swarmCodex.workspaceOnlyCollection);
|
|
4437
|
+
return normalized(workspaceOnly.recoveryStatus);
|
|
4438
|
+
}
|
|
4439
|
+
function sourceOutputHasPatchArtifact(record) {
|
|
4440
|
+
const paths = [
|
|
4441
|
+
textValue(record.patchPath, ''),
|
|
4442
|
+
textValue(record.patchArtifactPath, ''),
|
|
4443
|
+
...stringArray(record.artifactPaths),
|
|
4444
|
+
...stringArray(record.evidencePaths)
|
|
4445
|
+
];
|
|
4446
|
+
return paths.some((entry) => /\.patch(?:$|[?#])/.test(entry));
|
|
4447
|
+
}
|
|
2868
4448
|
function hasCostTokenEvidence(record) {
|
|
2869
4449
|
return [
|
|
2870
4450
|
record.actualInputTokens,
|
|
@@ -3174,30 +4754,59 @@ function rawRunJobIdMatches(requestedId, jobId) {
|
|
|
3174
4754
|
return requestedId === jobId || requestedId.endsWith(`:${jobId}`) || requestedId.endsWith(`-${jobId}`);
|
|
3175
4755
|
}
|
|
3176
4756
|
async function findCollectionBundle(options, jobId) {
|
|
3177
|
-
const
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
4757
|
+
const requestedIds = new Set([jobId, unscopedLifetimeTaskKey(jobId)]);
|
|
4758
|
+
for (const collectionFile of await collectionBundleLookupFiles(options)) {
|
|
4759
|
+
const collection = JSON.parse(await fs.readFile(collectionFile, 'utf8'));
|
|
4760
|
+
const defaultOutputDir = textValue(collection.outDir, '') || path.dirname(collectionFile);
|
|
4761
|
+
const buckets = recordValue(collection.buckets);
|
|
4762
|
+
for (const entries of Object.values(buckets)) {
|
|
4763
|
+
if (!Array.isArray(entries))
|
|
4764
|
+
continue;
|
|
4765
|
+
for (const entry of entries) {
|
|
4766
|
+
const row = recordValue(entry);
|
|
4767
|
+
const bundle = recordValue(row.bundle);
|
|
4768
|
+
const ids = [textValue(row.jobId, ''), textValue(bundle.jobId, ''), textValue(bundle.taskId, '')].filter(Boolean);
|
|
4769
|
+
if (ids.some((id) => requestedIds.has(id) || requestedIds.has(unscopedLifetimeTaskKey(id)))) {
|
|
4770
|
+
return { bundle, outputDir: textValue(row.outputDir, '') || defaultOutputDir };
|
|
4771
|
+
}
|
|
4772
|
+
}
|
|
4773
|
+
}
|
|
4774
|
+
const jobs = Array.isArray(collection.jobs) ? collection.jobs : [];
|
|
4775
|
+
for (const job of jobs) {
|
|
4776
|
+
const row = recordValue(job);
|
|
4777
|
+
const ids = [textValue(row.id ?? row.jobId, ''), textValue(row.originalJobId, ''), textValue(row.taskId, '')].filter(Boolean);
|
|
4778
|
+
if (ids.some((id) => requestedIds.has(id) || requestedIds.has(unscopedLifetimeTaskKey(id)))) {
|
|
4779
|
+
return { bundle: row, outputDir: textValue(row.outputDir, '') || defaultOutputDir };
|
|
3190
4780
|
}
|
|
3191
4781
|
}
|
|
3192
|
-
}
|
|
3193
|
-
const jobs = Array.isArray(collection.jobs) ? collection.jobs : [];
|
|
3194
|
-
for (const job of jobs) {
|
|
3195
|
-
const row = recordValue(job);
|
|
3196
|
-
if (textValue(row.id ?? row.jobId, '') === jobId)
|
|
3197
|
-
return { bundle: row, outputDir: textValue(row.outputDir, '') };
|
|
3198
4782
|
}
|
|
3199
4783
|
return undefined;
|
|
3200
4784
|
}
|
|
4785
|
+
async function collectionBundleLookupFiles(options) {
|
|
4786
|
+
const direct = await resolveArtifactFile(options.cwd, options.collection, ['collection.json', 'coordinator-query.json']);
|
|
4787
|
+
if (direct)
|
|
4788
|
+
return [direct];
|
|
4789
|
+
const out = [];
|
|
4790
|
+
for (const source of await discoverLifetimeDashboardSources(options.cwd)) {
|
|
4791
|
+
if (source.kind !== 'collection' || !source.collection)
|
|
4792
|
+
continue;
|
|
4793
|
+
const root = path.resolve(options.cwd, source.collection);
|
|
4794
|
+
if (!isPathInside(options.cwd, root))
|
|
4795
|
+
continue;
|
|
4796
|
+
const stat = await fs.lstat(root).catch(() => undefined);
|
|
4797
|
+
if (!stat)
|
|
4798
|
+
continue;
|
|
4799
|
+
const candidates = stat.isDirectory()
|
|
4800
|
+
? [path.join(root, 'collection.json'), path.join(root, 'coordinator-query.json')]
|
|
4801
|
+
: [root];
|
|
4802
|
+
for (const candidate of candidates) {
|
|
4803
|
+
const candidateStat = await fs.stat(candidate).catch(() => undefined);
|
|
4804
|
+
if (candidateStat?.isFile())
|
|
4805
|
+
out.push(candidate);
|
|
4806
|
+
}
|
|
4807
|
+
}
|
|
4808
|
+
return uniquePaths(out);
|
|
4809
|
+
}
|
|
3201
4810
|
async function resolveArtifactFile(cwd, input, names) {
|
|
3202
4811
|
if (!input)
|
|
3203
4812
|
return undefined;
|
|
@@ -3236,13 +4845,13 @@ async function writeHumanActionAnswer(options, body) {
|
|
|
3236
4845
|
source: 'frontier-loom-ui'
|
|
3237
4846
|
};
|
|
3238
4847
|
await fs.appendFile(answerPath, JSON.stringify(record) + '\n', 'utf8');
|
|
3239
|
-
notifyDashboardStreams();
|
|
4848
|
+
notifyDashboardStreams({ humanActionAnswer: record, answerPath });
|
|
3240
4849
|
return { ok: true, code, answerPath };
|
|
3241
4850
|
}
|
|
3242
|
-
function notifyDashboardStreams() {
|
|
4851
|
+
function notifyDashboardStreams(hint) {
|
|
3243
4852
|
invalidateDashboardSnapshotCache();
|
|
3244
4853
|
for (const listener of dashboardStreamListeners)
|
|
3245
|
-
listener();
|
|
4854
|
+
listener(hint);
|
|
3246
4855
|
}
|
|
3247
4856
|
async function readHumanActionAnswers(options) {
|
|
3248
4857
|
const answerPath = await humanActionAnswerLogPath(options);
|
|
@@ -3714,6 +5323,10 @@ function writeJson(response, status, value) {
|
|
|
3714
5323
|
response.writeHead(status, responseHeaders('application/json; charset=utf-8'));
|
|
3715
5324
|
response.end(JSON.stringify(value, null, 2));
|
|
3716
5325
|
}
|
|
5326
|
+
function serveFavicon(response) {
|
|
5327
|
+
response.writeHead(200, responseHeaders('image/svg+xml; charset=utf-8'));
|
|
5328
|
+
response.end('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16" rx="4" fill="#0b0d0f"/><path d="M4 5h8v2H6v2h5v2H4z" fill="#d6d9de"/></svg>');
|
|
5329
|
+
}
|
|
3717
5330
|
async function readJsonBody(request, maxBytes) {
|
|
3718
5331
|
let bytes = 0;
|
|
3719
5332
|
const chunks = [];
|
|
@@ -3737,7 +5350,11 @@ function responseHeaders(contentType) {
|
|
|
3737
5350
|
};
|
|
3738
5351
|
}
|
|
3739
5352
|
function resolveFrontierDomRuntime() {
|
|
3740
|
-
|
|
5353
|
+
const candidates = [
|
|
5354
|
+
path.join(packageDir, '..', 'node_modules', '@shapeshift-labs', 'frontier-dom', 'dist', 'jsx-runtime.js'),
|
|
5355
|
+
path.join(packageDir, '..', '..', 'frontier-dom', 'dist', 'jsx-runtime.js')
|
|
5356
|
+
];
|
|
5357
|
+
return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0];
|
|
3741
5358
|
}
|
|
3742
5359
|
function contentType(file) {
|
|
3743
5360
|
if (file.endsWith('.js'))
|