@shapeshift-labs/frontier-loom-ui 0.1.3 → 0.1.5

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/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 = 2500;
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 dashboardStreamListeners = new Set();
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 || pending)
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
- const signature = dashboardStreamSignature(snapshot);
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
- for (const input of inputs) {
222
- const absolute = path.resolve(options.cwd, input);
223
- const stat = await fs.lstat(absolute).catch(() => undefined);
224
- if (!stat)
225
- continue;
226
- roots.push(stat.isDirectory() ? absolute : path.dirname(absolute));
227
- }
228
- if (!inputs.length) {
229
- const agentRuns = path.join(options.cwd, 'agent-runs');
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
- if (!options.run && !options.collection && !options.continuation)
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
- dashboardSnapshotCache = { key, at: Date.now(), value };
285
- return value;
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: dashboardSnapshotCache?.key === key ? dashboardSnapshotCache.value : undefined, pending };
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
- ...normalizeCoordinatorFacingSnapshot(record),
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
- for (const source of sources.slice(0, LIFETIME_DASHBOARD_MAX_SOURCES)) {
732
+ const sourceResults = await Promise.all(sources.slice(0, LIFETIME_DASHBOARD_MAX_SOURCES).map(async (source) => {
357
733
  try {
358
- const snapshot = await withTimeout((async () => {
359
- const scopedSnapshot = await readScopedDashboardSnapshot({
360
- ...options,
361
- run: source.run,
362
- collection: source.collection,
363
- continuation: source.continuation
364
- }, { includeActiveRun: false });
365
- return enrichLifetimeRunSnapshotEvidence(options.cwd, source, recordValue(scopedSnapshot));
366
- })(), LIFETIME_DASHBOARD_SOURCE_TIMEOUT_MS, `lifetime source timed out: ${source.path}`);
367
- if (Object.keys(snapshot).length)
368
- snapshots.push({ source, snapshot });
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: lifetimeDashboardSummary(jobs),
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: lifetimeDashboardSummary(jobs),
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 (!hasTokenTelemetry(activeJob))
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 record;
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
- ...record,
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 collectionFile = await resolveArtifactFile(options.cwd, options.collection, ['collection.json', 'coordinator-query.json']);
3178
- if (!collectionFile)
3179
- return undefined;
3180
- const collection = JSON.parse(await fs.readFile(collectionFile, 'utf8'));
3181
- const buckets = recordValue(collection.buckets);
3182
- for (const entries of Object.values(buckets)) {
3183
- if (!Array.isArray(entries))
3184
- continue;
3185
- for (const entry of entries) {
3186
- const row = recordValue(entry);
3187
- const bundle = recordValue(row.bundle);
3188
- if (textValue(row.jobId, '') === jobId || textValue(bundle.jobId, '') === jobId) {
3189
- return { bundle, outputDir: textValue(row.outputDir, '') };
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
- return path.join(packageDir, '..', 'node_modules', '@shapeshift-labs', 'frontier-dom', 'dist', 'jsx-runtime.js');
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'))