@atolis-hq/wake 0.2.21 → 0.2.23

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.
@@ -85,6 +85,15 @@ export const indexHtml = `<!DOCTYPE html>
85
85
  .tile { background: #1a1d23; border-radius: 10px; padding: 0.6rem 0.9rem; min-width: 120px; }
86
86
  .tile .n { font-size: 1.3rem; font-weight: 700; }
87
87
  .tile .l { color: #9aa2ad; font-size: 0.72rem; text-transform: uppercase; }
88
+ .toolbar { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; margin-bottom: 1rem; }
89
+ .toolbar label { display: inline-flex; align-items: center; gap: 0.35rem; color: #9aa2ad; font-size: 0.8rem; }
90
+ select { background: #1a1d23; border: 1px solid #2c313a; color: #e8e8e8; padding: 0.3rem 0.5rem; border-radius: 6px; font-size: 0.8rem; }
91
+ select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(45, 212, 191, 0.15); }
92
+ .metric-bar { width: 160px; height: 0.5rem; background: #101216; border-radius: 999px; overflow: hidden; }
93
+ .metric-bar-fill { height: 100%; background: var(--accent); border-radius: 999px; }
94
+ .stacked-bar { display: flex; width: min(520px, 45vw); min-width: 220px; height: 0.75rem; background: #101216; border-radius: 999px; overflow: hidden; }
95
+ .stacked-segment { height: 100%; min-width: 1px; }
96
+ .metric-table-value { display: flex; align-items: center; gap: 0.55rem; }
88
97
  .amber { color: #ffcf7f; }
89
98
  .red { color: #ff8f7f; }
90
99
  .ok { color: #7fe3a3; }
@@ -114,6 +123,7 @@ export const indexHtml = `<!DOCTYPE html>
114
123
  <button data-view="board" class="active">Board</button>
115
124
  <button data-view="activity">Activity</button>
116
125
  <button data-view="runs">Runs</button>
126
+ <button data-view="analytics">Analytics</button>
117
127
  <button data-view="config">Config</button>
118
128
  <button data-view="health">Health</button>
119
129
  </nav>
@@ -132,9 +142,32 @@ export const indexHtml = `<!DOCTYPE html>
132
142
  const API = '/api/v1';
133
143
  const CONDITIONS = ['needs-human', 'active', 'ready', 'waiting', 'stalled', 'finished'];
134
144
  let currentView = 'board';
145
+ let analyticsWindow = '7d';
146
+ let analyticsMetric = 'runs-over-time';
147
+ let activeViewRequest = null;
148
+ let activeViewRequestId = 0;
135
149
 
136
- async function getJson(path) {
137
- const res = await fetch(API + path);
150
+ const METRIC_OPTIONS = [
151
+ ['runs-over-time', 'Runs over time'],
152
+ ['runs-by-status-over-time', 'Runs by status over time'],
153
+ ['runs-by-status', 'Runs by status'],
154
+ ['runs-by-action', 'Runs by action/stage'],
155
+ ['runs-by-repo', 'Runs by repo'],
156
+ ['runs-by-runner', 'Runs by runner'],
157
+ ['runs-by-model', 'Runs by model'],
158
+ ['runs-by-tier', 'Runs by tier'],
159
+ ['tokens-over-time', 'Tokens over time'],
160
+ ['tokens-by-action', 'Tokens by action/stage'],
161
+ ['tokens-by-runner', 'Tokens by runner'],
162
+ ['tokens-by-model', 'Tokens by model'],
163
+ ['duration-by-action', 'Duration by action/stage'],
164
+ ['duration-over-time', 'Duration over time'],
165
+ ['work-items-over-time', 'Work items completed over time'],
166
+ ['work-item-durations', 'Work item e2e duration'],
167
+ ];
168
+
169
+ async function getJson(path, signal) {
170
+ const res = await fetch(API + path, signal ? { signal } : undefined);
138
171
  if (!res.ok) throw new Error(path + ' -> ' + res.status);
139
172
  return res.json();
140
173
  }
@@ -167,6 +200,14 @@ function el(tag, attrs, children) {
167
200
  return node;
168
201
  }
169
202
 
203
+ function isActiveRequest(requestId) {
204
+ return activeViewRequest && activeViewRequest.id === requestId;
205
+ }
206
+
207
+ function loadingNode(label) {
208
+ return el('p', { class: 'meta', text: 'Loading ' + label + '...' });
209
+ }
210
+
170
211
  async function renderStatusBar() {
171
212
  try {
172
213
  const status = await getJson('/status');
@@ -203,8 +244,9 @@ async function forceTickNow() {
203
244
  }
204
245
  }
205
246
 
206
- async function renderBoard() {
207
- const board = await getJson('/board');
247
+ async function renderBoard(context) {
248
+ const board = await getJson('/board', context.signal);
249
+ if (!isActiveRequest(context.requestId)) return;
208
250
  const main = document.getElementById('main');
209
251
  main.innerHTML = '';
210
252
  const columns = el('div', { class: 'columns' }, CONDITIONS.map((cond) => {
@@ -388,8 +430,9 @@ function renderTranscripts(transcripts) {
388
430
  return root;
389
431
  }
390
432
 
391
- async function renderActivity() {
392
- const events = await getJson('/events?limit=200');
433
+ async function renderActivity(context) {
434
+ const events = await getJson('/events?limit=200', context.signal);
435
+ if (!isActiveRequest(context.requestId)) return;
393
436
  const main = document.getElementById('main');
394
437
  main.innerHTML = '';
395
438
  const table = el('table', {}, [
@@ -415,8 +458,239 @@ function fmtTokens(tokenUsage) {
415
458
  return total >= 1000 ? (total / 1000).toFixed(1) + 'k' : String(total);
416
459
  }
417
460
 
418
- async function renderRuns() {
419
- const runs = await getJson('/runs');
461
+ function fmtNumber(value) {
462
+ return Number(value || 0).toLocaleString();
463
+ }
464
+
465
+ function fmtDuration(ms) {
466
+ return ms === undefined || ms === null ? '' : fmtMs(ms);
467
+ }
468
+
469
+ function metricBar(value, max) {
470
+ const pct = max > 0 ? Math.max(2, Math.round((value / max) * 100)) : 0;
471
+ return el('div', { class: 'metric-bar' }, [
472
+ el('div', { class: 'metric-bar-fill', style: 'width:' + pct + '%' }),
473
+ ]);
474
+ }
475
+
476
+ function stackedBar(segments) {
477
+ const total = segments.reduce((sum, segment) => sum + segment.value, 0);
478
+ return el('div', { class: 'stacked-bar' }, segments.filter((segment) => segment.value > 0).map((segment) =>
479
+ el('div', {
480
+ class: 'stacked-segment',
481
+ title: segment.label + ': ' + fmtNumber(segment.value),
482
+ style: 'width:' + ((segment.value / total) * 100).toFixed(2) + '%;background:' + segment.color,
483
+ }),
484
+ ));
485
+ }
486
+
487
+ function selectBox(value, options, onChange) {
488
+ return el('select', { onchange: (event) => onChange(event.target.value) }, options.map((option) =>
489
+ el('option', {
490
+ value: option[0],
491
+ text: option[1],
492
+ ...(option[0] === value ? { selected: 'selected' } : {}),
493
+ }),
494
+ ));
495
+ }
496
+
497
+ function metricValueCell(value, max, format) {
498
+ return el('td', {}, [
499
+ el('div', { class: 'metric-table-value' }, [
500
+ el('span', { text: format(value) }),
501
+ metricBar(value, max),
502
+ ]),
503
+ ]);
504
+ }
505
+
506
+ function renderCountTable(rows, label) {
507
+ if (rows.length === 0) return el('p', { class: 'meta', text: 'No data for this window.' });
508
+ const max = Math.max(...rows.map((row) => row.count));
509
+ return el('table', {}, [
510
+ el('tr', {}, [el('th', { text: label }), el('th', { text: 'runs' })]),
511
+ ...rows.map((row) => el('tr', {}, [
512
+ el('td', { text: row.key }),
513
+ metricValueCell(row.count, max, fmtNumber),
514
+ ])),
515
+ ]);
516
+ }
517
+
518
+ function renderTokenTable(rows, label) {
519
+ if (rows.length === 0) return el('p', { class: 'meta', text: 'No data for this window.' });
520
+ const max = Math.max(...rows.map((row) => row.tokens));
521
+ return el('table', {}, [
522
+ el('tr', {}, [
523
+ el('th', { text: label }),
524
+ el('th', { text: 'tokens' }),
525
+ el('th', { text: 'avg/run' }),
526
+ el('th', { text: 'cost' }),
527
+ el('th', { text: 'avg cost/run' }),
528
+ el('th', { text: 'runs' }),
529
+ ]),
530
+ ...rows.map((row) => el('tr', {}, [
531
+ el('td', { text: row.key }),
532
+ metricValueCell(row.tokens, max, fmtNumber),
533
+ el('td', { text: fmtNumber(row.averageTokens || 0) }),
534
+ el('td', { text: fmtCost(row.costUsd) }),
535
+ el('td', { text: fmtCost(row.averageCostUsd) }),
536
+ el('td', { text: fmtNumber(row.count || 0) }),
537
+ ])),
538
+ ]);
539
+ }
540
+
541
+ function renderDurationTable(rows, label) {
542
+ if (rows.length === 0) return el('p', { class: 'meta', text: 'No data for this window.' });
543
+ const max = Math.max(...rows.map((row) => row.totalMs || row.medianMs));
544
+ return el('table', {}, [
545
+ el('tr', {}, [
546
+ el('th', { text: label }),
547
+ el('th', { text: 'total' }),
548
+ el('th', { text: 'median' }),
549
+ el('th', { text: 'average' }),
550
+ el('th', { text: 'runs' }),
551
+ ]),
552
+ ...rows.map((row) => el('tr', {}, [
553
+ el('td', { text: row.key || row.label }),
554
+ metricValueCell(row.totalMs || 0, max, fmtDuration),
555
+ el('td', { text: fmtDuration(row.medianMs) }),
556
+ el('td', { text: fmtDuration(row.averageMs) }),
557
+ el('td', { text: fmtNumber(row.count) }),
558
+ ])),
559
+ ]);
560
+ }
561
+
562
+ function renderRunsOverTime(rows) {
563
+ return el('table', {}, [
564
+ el('tr', {}, [el('th', { text: 'bucket' }), el('th', { text: 'runs' }), el('th', { text: 'status split' })]),
565
+ ...rows.map((row) => el('tr', {}, [
566
+ el('td', { text: row.label }),
567
+ el('td', { text: fmtNumber(row.total) }),
568
+ el('td', {}, [stackedBar([
569
+ { label: 'completed', value: row.completed, color: '#7fe3a3' },
570
+ { label: 'blocked', value: row.blocked, color: '#ffcf7f' },
571
+ { label: 'awaiting approval', value: row.awaitingApproval, color: '#7fb3ff' },
572
+ { label: 'failed', value: row.failed, color: '#ff8f7f' },
573
+ { label: 'other', value: row.other, color: '#9aa2ad' },
574
+ ])]),
575
+ ])),
576
+ ]);
577
+ }
578
+
579
+ function renderRunsByStatusOverTime(rows) {
580
+ return el('table', {}, [
581
+ el('tr', {}, [
582
+ el('th', { text: 'bucket' }),
583
+ el('th', { text: 'completed' }),
584
+ el('th', { text: 'blocked' }),
585
+ el('th', { text: 'awaiting approval' }),
586
+ el('th', { text: 'failed' }),
587
+ el('th', { text: 'other' }),
588
+ el('th', { text: 'total' }),
589
+ ]),
590
+ ...rows.map((row) => el('tr', {}, [
591
+ el('td', { text: row.label }),
592
+ el('td', { text: fmtNumber(row.completed) }),
593
+ el('td', { text: fmtNumber(row.blocked) }),
594
+ el('td', { text: fmtNumber(row.awaitingApproval) }),
595
+ el('td', { text: fmtNumber(row.failed) }),
596
+ el('td', { text: fmtNumber(row.other) }),
597
+ el('td', { text: fmtNumber(row.total) }),
598
+ ])),
599
+ ]);
600
+ }
601
+
602
+ function renderTokensOverTime(rows) {
603
+ return el('table', {}, [
604
+ el('tr', {}, [el('th', { text: 'bucket' }), el('th', { text: 'tokens' }), el('th', { text: 'token split' }), el('th', { text: 'cost' })]),
605
+ ...rows.map((row) => el('tr', {}, [
606
+ el('td', { text: row.label }),
607
+ el('td', { text: fmtNumber(row.tokens) }),
608
+ el('td', {}, [stackedBar([
609
+ { label: 'input', value: row.inputTokens, color: '#7fb3ff' },
610
+ { label: 'output', value: row.outputTokens, color: '#7fe3a3' },
611
+ { label: 'cache creation', value: row.cacheCreationInputTokens, color: '#c79bff' },
612
+ { label: 'cache read', value: row.cacheReadInputTokens, color: '#ffcf7f' },
613
+ ])]),
614
+ el('td', { text: fmtCost(row.costUsd) }),
615
+ ])),
616
+ ]);
617
+ }
618
+
619
+ function renderWorkItemsOverTime(rows) {
620
+ const max = Math.max(0, ...rows.map((row) => row.completed));
621
+ return el('table', {}, [
622
+ el('tr', {}, [el('th', { text: 'bucket' }), el('th', { text: 'completed' })]),
623
+ ...rows.map((row) => el('tr', {}, [
624
+ el('td', { text: row.label }),
625
+ metricValueCell(row.completed, max, fmtNumber),
626
+ ])),
627
+ ]);
628
+ }
629
+
630
+ function renderWorkItemDurations(rows) {
631
+ if (rows.length === 0) return el('p', { class: 'meta', text: 'No completed work items for this window.' });
632
+ const max = Math.max(...rows.map((row) => row.durationMs));
633
+ return el('table', {}, [
634
+ el('tr', {}, ['work item', 'duration', 'completed'].map((h) => el('th', { text: h }))),
635
+ ...rows.map((row) => el('tr', {}, [
636
+ el('td', { text: row.repo + '#' + row.issueNumber }),
637
+ metricValueCell(row.durationMs, max, fmtDuration),
638
+ el('td', { text: row.completedAt }),
639
+ ])),
640
+ ]);
641
+ }
642
+
643
+ function renderMetricDetail(detail) {
644
+ if (detail.kind === 'runs-over-time') return renderRunsOverTime(detail.rows);
645
+ if (detail.kind === 'runs-by-status-over-time') return renderRunsByStatusOverTime(detail.rows);
646
+ if (detail.kind === 'run-counts') return renderCountTable(detail.rows, detail.group);
647
+ if (detail.kind === 'tokens-over-time') return renderTokensOverTime(detail.rows);
648
+ if (detail.kind === 'token-counts') return renderTokenTable(detail.rows, detail.group);
649
+ if (detail.kind === 'durations') return renderDurationTable(detail.rows, detail.group);
650
+ if (detail.kind === 'duration-over-time') return renderDurationTable(detail.rows, 'bucket');
651
+ if (detail.kind === 'work-items-over-time') return renderWorkItemsOverTime(detail.rows);
652
+ if (detail.kind === 'work-item-durations') return renderWorkItemDurations(detail.rows);
653
+ return el('p', { class: 'meta', text: 'Unsupported metric.' });
654
+ }
655
+
656
+ async function renderAnalytics(context) {
657
+ const metrics = await getJson('/metrics?window=' + encodeURIComponent(analyticsWindow) + '&metric=' + encodeURIComponent(analyticsMetric), context.signal);
658
+ if (!isActiveRequest(context.requestId)) return;
659
+ const main = document.getElementById('main');
660
+ main.innerHTML = '';
661
+ main.appendChild(el('div', { class: 'toolbar' }, [
662
+ el('label', {}, [
663
+ document.createTextNode('Window'),
664
+ selectBox(analyticsWindow, [['1d', '1d'], ['3d', '3d'], ['5d', '5d'], ['7d', '7d']], (value) => {
665
+ analyticsWindow = value;
666
+ switchView('analytics');
667
+ }),
668
+ ]),
669
+ el('label', {}, [
670
+ document.createTextNode('Metric'),
671
+ selectBox(analyticsMetric, METRIC_OPTIONS, (value) => {
672
+ analyticsMetric = value;
673
+ switchView('analytics');
674
+ }),
675
+ ]),
676
+ ]));
677
+ main.appendChild(el('div', { class: 'tiles' }, [
678
+ tile('Runs', fmtNumber(metrics.summary.totalRuns)),
679
+ tile('Completed', fmtNumber(metrics.summary.completedRuns)),
680
+ tile('Blocked/approval', fmtNumber(metrics.summary.blockedRuns + metrics.summary.awaitingApprovalRuns)),
681
+ tile('Failed', fmtNumber(metrics.summary.failedRuns)),
682
+ tile('Tokens', fmtNumber(metrics.summary.totalTokens)),
683
+ tile('Cost', fmtCost(metrics.summary.totalCostUsd)),
684
+ tile('Median run', fmtDuration(metrics.summary.medianRunDurationMs) || '—'),
685
+ tile('Work done', fmtNumber(metrics.summary.completedWorkItems)),
686
+ tile('Median e2e', fmtDuration(metrics.summary.medianWorkItemDurationMs) || '—'),
687
+ ]));
688
+ main.appendChild(renderMetricDetail(metrics.detail));
689
+ }
690
+
691
+ async function renderRuns(context) {
692
+ const runs = await getJson('/runs', context.signal);
693
+ if (!isActiveRequest(context.requestId)) return;
420
694
  const main = document.getElementById('main');
421
695
  main.innerHTML = '';
422
696
  const table = el('table', {}, [
@@ -431,8 +705,9 @@ async function renderRuns() {
431
705
  main.appendChild(table);
432
706
  }
433
707
 
434
- async function renderConfig() {
435
- const data = await getJson('/config');
708
+ async function renderConfig(context) {
709
+ const data = await getJson('/config', context.signal);
710
+ if (!isActiveRequest(context.requestId)) return;
436
711
  const main = document.getElementById('main');
437
712
  main.innerHTML = '';
438
713
  main.appendChild(el('h3', { text: 'Routing table' }));
@@ -449,7 +724,7 @@ async function renderConfig() {
449
724
  btn.textContent = 'Unpausing…';
450
725
  try {
451
726
  await postJson('/runners/' + encodeURIComponent(c.runnerName) + '/unpause');
452
- await renderConfig();
727
+ switchView('config');
453
728
  } catch (err) {
454
729
  btn.disabled = false;
455
730
  btn.textContent = 'Unpause';
@@ -467,8 +742,9 @@ async function renderConfig() {
467
742
  main.appendChild(el('pre', { text: JSON.stringify(data.config, null, 2) }));
468
743
  }
469
744
 
470
- async function renderHealth() {
471
- const health = await getJson('/health');
745
+ async function renderHealth(context) {
746
+ const health = await getJson('/health', context.signal);
747
+ if (!isActiveRequest(context.requestId)) return;
472
748
  const main = document.getElementById('main');
473
749
  main.innerHTML = '';
474
750
  const runnerNames = Object.keys(health.pause.runnerHealth || {});
@@ -512,14 +788,25 @@ function tile(label, value) {
512
788
  return el('div', { class: 'tile' }, [el('div', { class: 'n', text: value }), el('div', { class: 'l', text: label })]);
513
789
  }
514
790
 
515
- const renderers = { board: renderBoard, activity: renderActivity, runs: renderRuns, config: renderConfig, health: renderHealth };
791
+ const renderers = { board: renderBoard, activity: renderActivity, runs: renderRuns, analytics: renderAnalytics, config: renderConfig, health: renderHealth };
516
792
 
517
- function switchView(view) {
793
+ function switchView(view, options) {
794
+ if (activeViewRequest) activeViewRequest.controller.abort();
795
+ const controller = new AbortController();
796
+ const requestId = ++activeViewRequestId;
797
+ activeViewRequest = { id: requestId, controller };
518
798
  currentView = view;
519
799
  for (const btn of document.querySelectorAll('nav button')) {
520
800
  btn.classList.toggle('active', btn.dataset.view === view);
521
801
  }
522
- renderers[view]();
802
+ const main = document.getElementById('main');
803
+ if (!options || options.showLoading !== false) {
804
+ main.replaceChildren(loadingNode(view));
805
+ }
806
+ renderers[view]({ signal: controller.signal, requestId }).catch((err) => {
807
+ if (err.name === 'AbortError' || !isActiveRequest(requestId)) return;
808
+ main.replaceChildren(el('p', { class: 'meta red', text: 'Failed to load ' + view + ': ' + err.message }));
809
+ });
523
810
  }
524
811
 
525
812
  for (const btn of document.querySelectorAll('nav button')) {
@@ -539,7 +826,7 @@ renderStatusBar();
539
826
  switchView('board');
540
827
  setInterval(() => {
541
828
  renderStatusBar();
542
- renderers[currentView]();
829
+ switchView(currentView, { showLoading: false });
543
830
  }, 7000);
544
831
  </script>
545
832
  </body>
@@ -364,6 +364,520 @@ export async function buildRuns(input) {
364
364
  .filter((run) => input.repo === undefined || run.repo === input.repo)
365
365
  .sort((left, right) => right.startedAt.localeCompare(left.startedAt));
366
366
  }
367
+ const metricsWindows = new Set(['1d', '3d', '5d', '7d']);
368
+ const metricsMetrics = new Set([
369
+ 'runs-over-time',
370
+ 'runs-by-status-over-time',
371
+ 'runs-by-status',
372
+ 'runs-by-action',
373
+ 'runs-by-repo',
374
+ 'runs-by-runner',
375
+ 'runs-by-model',
376
+ 'runs-by-tier',
377
+ 'tokens-over-time',
378
+ 'tokens-by-action',
379
+ 'tokens-by-runner',
380
+ 'tokens-by-model',
381
+ 'duration-by-action',
382
+ 'duration-over-time',
383
+ 'work-items-over-time',
384
+ 'work-item-durations',
385
+ ]);
386
+ function parseMetricsWindow(value) {
387
+ return metricsWindows.has(value) ? value : '7d';
388
+ }
389
+ function parseMetricsMetric(value) {
390
+ return metricsMetrics.has(value) ? value : 'runs-over-time';
391
+ }
392
+ function utcDayStartMs(date) {
393
+ return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
394
+ }
395
+ function monthDayLabel(date) {
396
+ const months = [
397
+ 'Jan',
398
+ 'Feb',
399
+ 'Mar',
400
+ 'Apr',
401
+ 'May',
402
+ 'Jun',
403
+ 'Jul',
404
+ 'Aug',
405
+ 'Sep',
406
+ 'Oct',
407
+ 'Nov',
408
+ 'Dec',
409
+ ];
410
+ return `${months[date.getUTCMonth()]} ${date.getUTCDate()}`;
411
+ }
412
+ function pad2(value) {
413
+ return String(value).padStart(2, '0');
414
+ }
415
+ function createTimeBuckets(window, now) {
416
+ const dayMs = 24 * 60 * 60 * 1000;
417
+ const hourMs = 60 * 60 * 1000;
418
+ const todayStart = utcDayStartMs(now);
419
+ if (window === '1d') {
420
+ return Array.from({ length: 24 }, (_, hour) => {
421
+ const startMs = todayStart + hour * hourMs;
422
+ const day = new Date(startMs);
423
+ return {
424
+ bucket: `${day.toISOString().slice(0, 10)}T${pad2(hour)}`,
425
+ label: `${pad2(hour)}:00`,
426
+ startMs,
427
+ endMs: startMs + hourMs,
428
+ };
429
+ });
430
+ }
431
+ if (window === '3d') {
432
+ const start = todayStart - 2 * dayMs;
433
+ return Array.from({ length: 12 }, (_, index) => {
434
+ const startMs = start + index * 6 * hourMs;
435
+ const day = new Date(startMs);
436
+ const hour = day.getUTCHours();
437
+ return {
438
+ bucket: `${day.toISOString().slice(0, 10)}T${pad2(hour)}`,
439
+ label: `${monthDayLabel(day)} ${pad2(hour)}:00`,
440
+ startMs,
441
+ endMs: startMs + 6 * hourMs,
442
+ };
443
+ });
444
+ }
445
+ const days = window === '5d' ? 5 : 7;
446
+ const start = todayStart - (days - 1) * dayMs;
447
+ return Array.from({ length: days }, (_, index) => {
448
+ const startMs = start + index * dayMs;
449
+ const day = new Date(startMs);
450
+ return {
451
+ bucket: day.toISOString().slice(0, 10),
452
+ label: monthDayLabel(day),
453
+ startMs,
454
+ endMs: startMs + dayMs,
455
+ };
456
+ });
457
+ }
458
+ function inMetricsWindow(timestamp, buckets) {
459
+ const at = Date.parse(timestamp);
460
+ const first = buckets[0];
461
+ const last = buckets.at(-1);
462
+ return (Number.isFinite(at) &&
463
+ first !== undefined &&
464
+ last !== undefined &&
465
+ at >= first.startMs &&
466
+ at < last.endMs);
467
+ }
468
+ function findBucket(timestamp, buckets) {
469
+ const at = Date.parse(timestamp);
470
+ if (!Number.isFinite(at)) {
471
+ return undefined;
472
+ }
473
+ return buckets.find((bucket) => at >= bucket.startMs && at < bucket.endMs);
474
+ }
475
+ async function listRunsForBuckets(stateStore, buckets) {
476
+ const dates = [...new Set(buckets.map((bucket) => bucket.bucket.slice(0, 10)))];
477
+ const runsById = new Map();
478
+ const recordsByDate = await Promise.all(dates.map((date) => stateStore.listRunRecordsForDate(date)));
479
+ for (const records of recordsByDate) {
480
+ for (const record of records) {
481
+ runsById.set(record.runId, record);
482
+ }
483
+ }
484
+ return [...runsById.values()].sort((left, right) => left.startedAt.localeCompare(right.startedAt));
485
+ }
486
+ function runTokenTotal(run) {
487
+ const usage = run.tokenUsage;
488
+ if (usage === undefined) {
489
+ return 0;
490
+ }
491
+ return (usage.inputTokens +
492
+ usage.outputTokens +
493
+ (usage.cacheCreationInputTokens ?? 0) +
494
+ (usage.cacheReadInputTokens ?? 0));
495
+ }
496
+ function median(values) {
497
+ if (values.length === 0) {
498
+ return undefined;
499
+ }
500
+ const sorted = values.slice().sort((left, right) => left - right);
501
+ const middle = Math.floor(sorted.length / 2);
502
+ if (sorted.length % 2 === 1) {
503
+ return sorted[middle];
504
+ }
505
+ return ((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2;
506
+ }
507
+ function average(values) {
508
+ return values.length === 0
509
+ ? 0
510
+ : values.reduce((total, value) => total + value, 0) / values.length;
511
+ }
512
+ function runDurationMs(run) {
513
+ if (run.finishedAt === undefined) {
514
+ return undefined;
515
+ }
516
+ const started = Date.parse(run.startedAt);
517
+ const finished = Date.parse(run.finishedAt);
518
+ if (!Number.isFinite(started) || !Number.isFinite(finished) || finished < started) {
519
+ return undefined;
520
+ }
521
+ return finished - started;
522
+ }
523
+ function groupCount(entries, keyFor) {
524
+ const counts = new Map();
525
+ for (const entry of entries) {
526
+ const key = keyFor(entry);
527
+ counts.set(key, (counts.get(key) ?? 0) + 1);
528
+ }
529
+ return [...counts.entries()]
530
+ .map(([key, count]) => ({ key, count }))
531
+ .sort((left, right) => right.count - left.count || left.key.localeCompare(right.key));
532
+ }
533
+ function groupTokens(runs, keyFor) {
534
+ const groups = new Map();
535
+ for (const run of runs) {
536
+ const key = keyFor(run);
537
+ const existing = groups.get(key) ?? { count: 0, tokens: 0, costUsd: 0 };
538
+ existing.count += 1;
539
+ existing.tokens += runTokenTotal(run);
540
+ existing.costUsd += run.tokenUsage?.costUsd ?? 0;
541
+ groups.set(key, existing);
542
+ }
543
+ return [...groups.entries()]
544
+ .map(([key, value]) => ({
545
+ key,
546
+ ...value,
547
+ averageTokens: value.count === 0 ? 0 : value.tokens / value.count,
548
+ averageCostUsd: value.count === 0 ? 0 : value.costUsd / value.count,
549
+ }))
550
+ .sort((left, right) => right.tokens - left.tokens || left.key.localeCompare(right.key));
551
+ }
552
+ function groupDurations(runs, keyFor) {
553
+ const groups = new Map();
554
+ for (const run of runs) {
555
+ const duration = runDurationMs(run);
556
+ if (duration === undefined) {
557
+ continue;
558
+ }
559
+ const key = keyFor(run);
560
+ groups.set(key, [...(groups.get(key) ?? []), duration]);
561
+ }
562
+ return [...groups.entries()]
563
+ .map(([key, values]) => ({
564
+ key,
565
+ count: values.length,
566
+ totalMs: values.reduce((total, value) => total + value, 0),
567
+ averageMs: average(values),
568
+ medianMs: median(values) ?? 0,
569
+ }))
570
+ .sort((left, right) => right.medianMs - left.medianMs || left.key.localeCompare(right.key));
571
+ }
572
+ function runnerKey(run) {
573
+ return run.routing?.runnerName ?? 'unknown';
574
+ }
575
+ function tierKey(run) {
576
+ return run.routing?.tier ?? 'unknown';
577
+ }
578
+ function modelKey(run, config) {
579
+ const runnerName = run.routing?.runnerName;
580
+ if (runnerName === undefined) {
581
+ return 'unknown';
582
+ }
583
+ const runner = config.runners[runnerName];
584
+ if (runner === undefined || runner.kind === 'fake') {
585
+ return 'unknown';
586
+ }
587
+ return runner.models[run.action] ?? runner.models.default ?? runner.model;
588
+ }
589
+ function completedAtForItem(item) {
590
+ const doneEntry = item.wake.stageHistory.find((entry) => entry.stage === 'done');
591
+ if (doneEntry !== undefined) {
592
+ return doneEntry.changedAt;
593
+ }
594
+ if (item.issue.state === 'closed') {
595
+ return item.issue.updatedAt;
596
+ }
597
+ if (isTerminalStage(item.wake.stage)) {
598
+ return item.wake.stageHistory.at(-1)?.changedAt ?? item.issue.updatedAt;
599
+ }
600
+ return undefined;
601
+ }
602
+ function startedAtForItem(item) {
603
+ return item.issue.createdAt ?? item.wake.stageHistory[0]?.changedAt;
604
+ }
605
+ function completedWorkItemDurations(items, buckets) {
606
+ const rows = [];
607
+ for (const item of items) {
608
+ const completedAt = completedAtForItem(item);
609
+ const startedAt = startedAtForItem(item);
610
+ if (completedAt === undefined ||
611
+ startedAt === undefined ||
612
+ !inMetricsWindow(completedAt, buckets)) {
613
+ continue;
614
+ }
615
+ const completedMs = Date.parse(completedAt);
616
+ const startedMs = Date.parse(startedAt);
617
+ if (!Number.isFinite(completedMs) || !Number.isFinite(startedMs) || completedMs < startedMs) {
618
+ continue;
619
+ }
620
+ rows.push({
621
+ key: item.workItemKey,
622
+ repo: item.issue.repo,
623
+ issueNumber: item.issue.number,
624
+ durationMs: completedMs - startedMs,
625
+ completedAt,
626
+ });
627
+ }
628
+ return rows.sort((left, right) => right.completedAt.localeCompare(left.completedAt));
629
+ }
630
+ function buildMetricsSummary(runs, workItemDurations) {
631
+ const runDurations = runs
632
+ .map((run) => runDurationMs(run))
633
+ .filter((duration) => duration !== undefined);
634
+ return {
635
+ totalRuns: runs.length,
636
+ completedRuns: runs.filter((run) => run.status === 'completed').length,
637
+ blockedRuns: runs.filter((run) => run.status === 'blocked').length,
638
+ awaitingApprovalRuns: runs.filter((run) => run.status === 'awaiting-approval').length,
639
+ failedRuns: runs.filter((run) => run.status === 'failed').length,
640
+ totalTokens: runs.reduce((total, run) => total + runTokenTotal(run), 0),
641
+ totalCostUsd: runs.reduce((total, run) => total + (run.tokenUsage?.costUsd ?? 0), 0),
642
+ medianRunDurationMs: median(runDurations),
643
+ completedWorkItems: workItemDurations.length,
644
+ medianWorkItemDurationMs: median(workItemDurations.map((row) => row.durationMs)),
645
+ };
646
+ }
647
+ export async function buildMetrics(input) {
648
+ const window = parseMetricsWindow(input.window);
649
+ const metric = parseMetricsMetric(input.metric);
650
+ const buckets = createTimeBuckets(window, input.now);
651
+ const [allRuns, items] = await Promise.all([
652
+ listRunsForBuckets(input.stateStore, buckets),
653
+ input.stateStore.listIssueStates(),
654
+ ]);
655
+ const runs = allRuns.filter((run) => inMetricsWindow(run.startedAt, buckets));
656
+ const workItemDurations = completedWorkItemDurations(items, buckets);
657
+ const summary = buildMetricsSummary(runs, workItemDurations);
658
+ const runCountsForBucket = (bucket) => {
659
+ const bucketRuns = runs.filter((run) => findBucket(run.startedAt, buckets)?.bucket === bucket.bucket);
660
+ return {
661
+ bucket: bucket.bucket,
662
+ label: bucket.label,
663
+ total: bucketRuns.length,
664
+ completed: bucketRuns.filter((run) => run.status === 'completed').length,
665
+ blocked: bucketRuns.filter((run) => run.status === 'blocked').length,
666
+ awaitingApproval: bucketRuns.filter((run) => run.status === 'awaiting-approval').length,
667
+ failed: bucketRuns.filter((run) => run.status === 'failed').length,
668
+ other: bucketRuns.filter((run) => !['completed', 'blocked', 'awaiting-approval', 'failed'].includes(run.status)).length,
669
+ };
670
+ };
671
+ const tokenCountsForBucket = (bucket) => {
672
+ const bucketRuns = runs.filter((run) => findBucket(run.startedAt, buckets)?.bucket === bucket.bucket);
673
+ return {
674
+ bucket: bucket.bucket,
675
+ label: bucket.label,
676
+ tokens: bucketRuns.reduce((total, run) => total + runTokenTotal(run), 0),
677
+ inputTokens: bucketRuns.reduce((total, run) => total + (run.tokenUsage?.inputTokens ?? 0), 0),
678
+ outputTokens: bucketRuns.reduce((total, run) => total + (run.tokenUsage?.outputTokens ?? 0), 0),
679
+ cacheCreationInputTokens: bucketRuns.reduce((total, run) => total + (run.tokenUsage?.cacheCreationInputTokens ?? 0), 0),
680
+ cacheReadInputTokens: bucketRuns.reduce((total, run) => total + (run.tokenUsage?.cacheReadInputTokens ?? 0), 0),
681
+ costUsd: bucketRuns.reduce((total, run) => total + (run.tokenUsage?.costUsd ?? 0), 0),
682
+ };
683
+ };
684
+ switch (metric) {
685
+ case 'runs-by-status':
686
+ return {
687
+ window,
688
+ metric,
689
+ generatedAt: input.now.toISOString(),
690
+ summary,
691
+ detail: {
692
+ kind: 'run-counts',
693
+ group: 'status',
694
+ rows: groupCount(runs, (run) => run.status),
695
+ },
696
+ };
697
+ case 'runs-by-status-over-time':
698
+ return {
699
+ window,
700
+ metric,
701
+ generatedAt: input.now.toISOString(),
702
+ summary,
703
+ detail: {
704
+ kind: 'runs-by-status-over-time',
705
+ rows: buckets.map(runCountsForBucket),
706
+ },
707
+ };
708
+ case 'runs-by-action':
709
+ return {
710
+ window,
711
+ metric,
712
+ generatedAt: input.now.toISOString(),
713
+ summary,
714
+ detail: {
715
+ kind: 'run-counts',
716
+ group: 'action',
717
+ rows: groupCount(runs, (run) => run.action),
718
+ },
719
+ };
720
+ case 'runs-by-repo':
721
+ return {
722
+ window,
723
+ metric,
724
+ generatedAt: input.now.toISOString(),
725
+ summary,
726
+ detail: {
727
+ kind: 'run-counts',
728
+ group: 'repo',
729
+ rows: groupCount(runs, (run) => run.repo),
730
+ },
731
+ };
732
+ case 'runs-by-runner':
733
+ return {
734
+ window,
735
+ metric,
736
+ generatedAt: input.now.toISOString(),
737
+ summary,
738
+ detail: {
739
+ kind: 'run-counts',
740
+ group: 'runner',
741
+ rows: groupCount(runs, runnerKey),
742
+ },
743
+ };
744
+ case 'runs-by-model':
745
+ return {
746
+ window,
747
+ metric,
748
+ generatedAt: input.now.toISOString(),
749
+ summary,
750
+ detail: {
751
+ kind: 'run-counts',
752
+ group: 'model',
753
+ rows: groupCount(runs, (run) => modelKey(run, input.config)),
754
+ },
755
+ };
756
+ case 'runs-by-tier':
757
+ return {
758
+ window,
759
+ metric,
760
+ generatedAt: input.now.toISOString(),
761
+ summary,
762
+ detail: {
763
+ kind: 'run-counts',
764
+ group: 'tier',
765
+ rows: groupCount(runs, tierKey),
766
+ },
767
+ };
768
+ case 'tokens-over-time':
769
+ return {
770
+ window,
771
+ metric,
772
+ generatedAt: input.now.toISOString(),
773
+ summary,
774
+ detail: { kind: 'tokens-over-time', rows: buckets.map(tokenCountsForBucket) },
775
+ };
776
+ case 'tokens-by-action':
777
+ return {
778
+ window,
779
+ metric,
780
+ generatedAt: input.now.toISOString(),
781
+ summary,
782
+ detail: {
783
+ kind: 'token-counts',
784
+ group: 'action',
785
+ rows: groupTokens(runs, (run) => run.action),
786
+ },
787
+ };
788
+ case 'tokens-by-runner':
789
+ return {
790
+ window,
791
+ metric,
792
+ generatedAt: input.now.toISOString(),
793
+ summary,
794
+ detail: {
795
+ kind: 'token-counts',
796
+ group: 'runner',
797
+ rows: groupTokens(runs, runnerKey),
798
+ },
799
+ };
800
+ case 'tokens-by-model':
801
+ return {
802
+ window,
803
+ metric,
804
+ generatedAt: input.now.toISOString(),
805
+ summary,
806
+ detail: {
807
+ kind: 'token-counts',
808
+ group: 'model',
809
+ rows: groupTokens(runs, (run) => modelKey(run, input.config)),
810
+ },
811
+ };
812
+ case 'duration-by-action':
813
+ return {
814
+ window,
815
+ metric,
816
+ generatedAt: input.now.toISOString(),
817
+ summary,
818
+ detail: {
819
+ kind: 'durations',
820
+ group: 'action',
821
+ rows: groupDurations(runs, (run) => run.action),
822
+ },
823
+ };
824
+ case 'duration-over-time':
825
+ return {
826
+ window,
827
+ metric,
828
+ generatedAt: input.now.toISOString(),
829
+ summary,
830
+ detail: {
831
+ kind: 'duration-over-time',
832
+ rows: buckets.map((bucket) => {
833
+ const values = runs
834
+ .filter((run) => findBucket(run.startedAt, buckets)?.bucket === bucket.bucket)
835
+ .map((run) => runDurationMs(run))
836
+ .filter((duration) => duration !== undefined);
837
+ return {
838
+ bucket: bucket.bucket,
839
+ label: bucket.label,
840
+ count: values.length,
841
+ totalMs: values.reduce((total, value) => total + value, 0),
842
+ averageMs: average(values),
843
+ medianMs: median(values) ?? 0,
844
+ };
845
+ }),
846
+ },
847
+ };
848
+ case 'work-items-over-time':
849
+ return {
850
+ window,
851
+ metric,
852
+ generatedAt: input.now.toISOString(),
853
+ summary,
854
+ detail: {
855
+ kind: 'work-items-over-time',
856
+ rows: buckets.map((bucket) => ({
857
+ bucket: bucket.bucket,
858
+ label: bucket.label,
859
+ completed: workItemDurations.filter((row) => findBucket(row.completedAt, buckets)?.bucket === bucket.bucket).length,
860
+ })),
861
+ },
862
+ };
863
+ case 'work-item-durations':
864
+ return {
865
+ window,
866
+ metric,
867
+ generatedAt: input.now.toISOString(),
868
+ summary,
869
+ detail: { kind: 'work-item-durations', rows: workItemDurations },
870
+ };
871
+ case 'runs-over-time':
872
+ return {
873
+ window,
874
+ metric,
875
+ generatedAt: input.now.toISOString(),
876
+ summary,
877
+ detail: { kind: 'runs-over-time', rows: buckets.map(runCountsForBucket) },
878
+ };
879
+ }
880
+ }
367
881
  const secretKeyPattern = /token|secret|key|password/i;
368
882
  function redact(value, keyHint = '') {
369
883
  if (secretKeyPattern.test(keyHint) && typeof value === 'string') {
@@ -5,7 +5,7 @@ import { configuredTicketSource } from '../../domain/sources.js';
5
5
  import { createEventEnvelope } from '../../lib/event-log.js';
6
6
  import { writeJsonFile } from '../../lib/json-file.js';
7
7
  import { indexHtml } from './ui-assets.js';
8
- import { buildBoard, buildConfigView, buildEventsFeed, buildHealth, buildItemDetail, buildItemTranscripts, buildRuns, buildStatus, buildWorkspaces, } from './ui-data.js';
8
+ import { buildBoard, buildConfigView, buildEventsFeed, buildHealth, buildItemDetail, buildItemTranscripts, buildMetrics, buildRuns, buildStatus, buildWorkspaces, } from './ui-data.js';
9
9
  function sendJson(res, status, body) {
10
10
  const payload = JSON.stringify(body, null, 2);
11
11
  res.writeHead(status, {
@@ -218,6 +218,16 @@ async function handleRequest(req, res, options, now, projectionUpdater) {
218
218
  }));
219
219
  return;
220
220
  }
221
+ if (resource === 'metrics' && segments.length === 1) {
222
+ sendJson(res, 200, await buildMetrics({
223
+ stateStore,
224
+ config,
225
+ now: now(),
226
+ window: url.searchParams.get('window') ?? undefined,
227
+ metric: url.searchParams.get('metric') ?? undefined,
228
+ }));
229
+ return;
230
+ }
221
231
  if (resource === 'events' && segments.length === 1) {
222
232
  const limitParam = url.searchParams.get('limit');
223
233
  sendJson(res, 200, await buildEventsFeed({
@@ -5,6 +5,16 @@ function isAwaitingApproval(issue) {
5
5
  const context = issue.context;
6
6
  return context.lastRunSentinel === awaitingApprovalRunnerSentinel;
7
7
  }
8
+ function belowFailureRetryLimit(issue, config) {
9
+ if (config === undefined) {
10
+ return true;
11
+ }
12
+ const context = issue.context;
13
+ const failureCount = typeof context.failureCount === 'number' && Number.isInteger(context.failureCount)
14
+ ? context.failureCount
15
+ : 0;
16
+ return failureCount < config.retry.maxFailureRetries;
17
+ }
8
18
  // Commands are matched as a token at the start of a (trimmed) line, not as a
9
19
  // substring anywhere in the body — so "I have *not* /approved this yet" or a
10
20
  // quoted reply containing /approved does not approve the gate.
@@ -77,7 +87,7 @@ export function createPolicyEngine() {
77
87
  requiredAssignees: config.sources.github.policy.requiredAssignees,
78
88
  });
79
89
  },
80
- needsWakeAction(issue, workflow = builtInDefaultWorkflowDefinition) {
90
+ needsWakeAction(issue, workflow = builtInDefaultWorkflowDefinition, config) {
81
91
  const context = issue.context;
82
92
  const handledCommentId = typeof context.lastHandledCommentId === 'string' ? context.lastHandledCommentId : undefined;
83
93
  const lastCompletedAction = typeof context.lastCompletedAction === 'string' ? context.lastCompletedAction : undefined;
@@ -101,7 +111,7 @@ export function createPolicyEngine() {
101
111
  return false;
102
112
  }
103
113
  if (lastFailureClass === 'quota') {
104
- return true;
114
+ return belowFailureRetryLimit(issue, config);
105
115
  }
106
116
  const workflowAction = chooseWorkflowAction(issue, workflow);
107
117
  return workflowAction !== null && lastCompletedAction !== workflowAction.action;
@@ -219,7 +229,7 @@ export function createPolicyEngine() {
219
229
  const nextAction = resolveCustomCommand(issue, config)?.action ??
220
230
  this.chooseAction(issue, workflow) ??
221
231
  this.chooseRetryActionAfterHumanReply(issue, workflow);
222
- if (nextAction === null || !this.needsWakeAction(issue, workflow)) {
232
+ if (nextAction === null || !this.needsWakeAction(issue, workflow, config)) {
223
233
  return null;
224
234
  }
225
235
  return { action: nextAction, workflow };
@@ -203,9 +203,18 @@ async function applyEvent(current, event, ctx, config) {
203
203
  config !== undefined &&
204
204
  isCustomCommandAction(payload.action, config);
205
205
  const shouldClearSession = isForwardProgression || isFailed;
206
+ const currentFailureCount = typeof current.context.failureCount === 'number' &&
207
+ Number.isInteger(current.context.failureCount)
208
+ ? current.context.failureCount
209
+ : 0;
206
210
  const nextContext = {
207
211
  ...current.context,
208
212
  lastFailureClass: payload.failureClass,
213
+ failureCount: payload.failureClass !== undefined
214
+ ? currentFailureCount + 1
215
+ : payload.sentinel === doneRunnerSentinel || payload.sentinel === 'AWAITING_APPROVAL'
216
+ ? 0
217
+ : currentFailureCount,
209
218
  ...(payload.handledCommentId === undefined
210
219
  ? {}
211
220
  : { lastHandledCommentId: payload.handledCommentId }),
@@ -457,6 +457,11 @@ const wakeConfigBaseSchema = z.object({
457
457
  retainAfterWorkspaceCleanup: z.boolean().default(false),
458
458
  })
459
459
  .default({ enabled: false, retainAfterWorkspaceCleanup: false }),
460
+ retry: z
461
+ .object({
462
+ maxFailureRetries: z.number().int().positive().default(5),
463
+ })
464
+ .default({ maxFailureRetries: 5 }),
460
465
  runners: z.record(z.string(), runnerEntrySchema).default({
461
466
  fake: { kind: 'fake', cli: 'Fake' },
462
467
  'claude-haiku': {
@@ -656,6 +661,7 @@ export const wakeInfraConfigSchema = wakeConfigBaseSchema.pick({
656
661
  dev: true,
657
662
  scheduler: true,
658
663
  transcripts: true,
664
+ retry: true,
659
665
  ui: true,
660
666
  sources: true,
661
667
  sinks: true,
@@ -124,4 +124,4 @@ export function resolveWakeVersion(options = {}) {
124
124
  }
125
125
  return '0.1.0-dev';
126
126
  }
127
- export const wakeVersion = "g9570500";
127
+ export const wakeVersion = "gff20ef3";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.2.21",
3
+ "version": "0.2.23",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {