summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorClaudomator Agent <agent@claudomator>2026-03-11 19:05:12 +0000
committerClaudomator Agent <agent@claudomator>2026-03-11 19:05:12 +0000
commit55c20922cc7a671787fe94fdd53a7eb72ebd2596 (patch)
tree01492aec1a365348ce21281fef4e36b4372661e3
parent1bcc40f7fd83bc603201b14577eebe5a482ba68e (diff)
feat: add Stats tab with task distribution and execution health metrics
- Export computeTaskStats and computeExecutionStats from app.js - Add renderStatsPanel with state count grid, KPI row (total/success-rate/cost/avg-duration), and outcome bar chart - Wire stats tab into switchTab and poll for live refresh - Add Stats tab button and panel to index.html - Add CSS for .stats-counts, .stats-kpis, .stats-bar-chart using existing state color variables - Add docs/stats-tab-plan.md with component structure and data flow - 14 new unit tests in web/test/stats.test.mjs (140 total, all passing) No backend changes — derives all metrics from existing /api/tasks and /api/executions endpoints. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--docs/stats-tab-plan.md147
-rw-r--r--internal/api/docs/RAW_NARRATIVE.md9
-rw-r--r--web/app.js228
-rw-r--r--web/index.html2
-rw-r--r--web/style.css159
-rw-r--r--web/test/stats.test.mjs153
6 files changed, 698 insertions, 0 deletions
diff --git a/docs/stats-tab-plan.md b/docs/stats-tab-plan.md
new file mode 100644
index 0000000..6b6dcc4
--- /dev/null
+++ b/docs/stats-tab-plan.md
@@ -0,0 +1,147 @@
+# Stats Tab — Implementation Plan
+
+Generated: 2026-03-11
+
+---
+
+## Goal
+
+Add a **Stats** tab to the Claudomator web UI that shows:
+1. Task state distribution (counts by state)
+2. Execution health metrics for the last 24h (total runs, success rate, total cost, avg duration)
+3. A simple visual bar chart of execution outcomes
+
+---
+
+## Data Sources (no backend changes needed)
+
+| Metric | Endpoint | Fields Used |
+|--------|----------|-------------|
+| Task state counts | `GET /api/tasks` | `state` |
+| Execution history | `GET /api/executions?since=24h` | `state`, `cost_usd`, `duration_ms`, `started_at`, `finished_at` |
+
+Both endpoints already exist. No new API endpoints required.
+
+---
+
+## Component Structure
+
+```
+<div data-panel="stats" hidden>
+ <div class="stats-section">
+ <h2>Task Overview</h2>
+ <div class="stats-counts"> <!-- grid: one box per non-zero state -->
+ <div class="stats-count-box" data-state="RUNNING">
+ <span class="stats-count-number">3</span>
+ <span class="stats-count-label">RUNNING</span>
+ </div>
+ ...
+ </div>
+ </div>
+
+ <div class="stats-section">
+ <h2>Executions (Last 24h)</h2>
+ <div class="stats-kpis"> <!-- 4 KPI boxes -->
+ <div class="stats-kpi-box">
+ <span class="stats-kpi-value">42</span>
+ <span class="stats-kpi-label">Total Runs</span>
+ </div>
+ <div class="stats-kpi-box">
+ <span class="stats-kpi-value">88%</span>
+ <span class="stats-kpi-label">Success Rate</span>
+ </div>
+ <div class="stats-kpi-box">
+ <span class="stats-kpi-value">$1.24</span>
+ <span class="stats-kpi-label">Total Cost</span>
+ </div>
+ <div class="stats-kpi-box">
+ <span class="stats-kpi-value">4m 12s</span>
+ <span class="stats-kpi-label">Avg Duration</span>
+ </div>
+ </div>
+ <div class="stats-bar-chart"> <!-- bar chart of outcome distribution -->
+ <!-- one bar per outcome (completed/failed/cancelled/etc.) -->
+ </div>
+ </div>
+</div>
+```
+
+---
+
+## Data Transformation Functions (exported for testing)
+
+### `computeTaskStats(tasks)`
+Input: `Task[]`
+Output:
+```js
+{
+ byState: { PENDING: 2, RUNNING: 1, COMPLETED: 45, ... } // all states present in tasks
+}
+```
+
+### `computeExecutionStats(executions)`
+Input: `RecentExecution[]`
+Output:
+```js
+{
+ total: 42,
+ successRate: 0.88, // fraction (0–1)
+ totalCostUSD: 1.24,
+ avgDurationMs: 252000, // null if no finished executions
+ byOutcome: { completed: 37, failed: 3, cancelled: 2, ... }
+}
+```
+
+---
+
+## Files Changed
+
+| File | Change |
+|------|--------|
+| `web/index.html` | Add Stats tab button + `<div data-panel="stats">` |
+| `web/app.js` | Add `computeTaskStats`, `computeExecutionStats`, `renderStatsPanel`; update `switchTab` and `poll` |
+| `web/style.css` | Add `.stats-*` CSS rules |
+| `web/test/stats.test.mjs` | Unit tests for `computeTaskStats` and `computeExecutionStats` |
+
+---
+
+## Visualization Approach
+
+No external library. Pure CSS:
+- **State count boxes**: small colored badges using existing `--state-*` CSS variables
+- **KPI boxes**: large number + label in a 4-column grid
+- **Bar chart**: flex row of divs with percentage widths, colored per outcome state
+
+---
+
+## Tab Integration
+
+In `switchTab(name)`:
+- When `name === 'stats'`: fetch tasks + recent executions, then call `renderStatsPanel`
+- Hide `#btn-new-task` (same as other non-tasks tabs)
+
+In `poll()`:
+- If stats tab is active: re-render stats after fetching tasks
+
+---
+
+## Tests (TDD — write first, then implement)
+
+`web/test/stats.test.mjs`:
+1. `computeTaskStats` groups tasks by state correctly
+2. `computeTaskStats` returns zero counts for missing states
+3. `computeExecutionStats` calculates total, success rate, cost, avg duration
+4. `computeExecutionStats` handles empty array (zero total, null avg duration)
+5. `computeExecutionStats` calculates success rate = 0 when all failed
+6. `computeExecutionStats` ignores executions with no `duration_ms` in avg calculation
+
+---
+
+## Implementation Order (TDD)
+
+1. Write `web/test/stats.test.mjs` — all tests fail (red)
+2. Add `computeTaskStats` and `computeExecutionStats` to `app.js` — tests pass (green)
+3. Add `renderStatsPanel` and integrate into `switchTab` / `poll`
+4. Add HTML panel to `index.html`
+5. Add CSS to `style.css`
+6. Manual smoke test
diff --git a/internal/api/docs/RAW_NARRATIVE.md b/internal/api/docs/RAW_NARRATIVE.md
new file mode 100644
index 0000000..8fe69b6
--- /dev/null
+++ b/internal/api/docs/RAW_NARRATIVE.md
@@ -0,0 +1,9 @@
+
+--- 2026-03-11T19:04:51Z ---
+run the Go test suite with race detector and fail if coverage < 80%
+
+--- 2026-03-11T19:04:51Z ---
+do something
+
+--- 2026-03-11T19:04:51Z ---
+do something
diff --git a/web/app.js b/web/app.js
index 2c1e481..2b6330b 100644
--- a/web/app.js
+++ b/web/app.js
@@ -297,6 +297,57 @@ export function setTaskFilterTab(tab) {
localStorage.setItem('taskFilterTab', tab);
}
+// ── Stats computations ─────────────────────────────────────────────────────────
+
+/**
+ * Computes task state distribution from a task array.
+ * Returns { byState: { [state]: count } } — only states with tasks are included.
+ */
+export function computeTaskStats(tasks) {
+ const byState = {};
+ for (const t of tasks) {
+ byState[t.state] = (byState[t.state] || 0) + 1;
+ }
+ return { byState };
+}
+
+/**
+ * Computes execution health metrics from a RecentExecution array.
+ * Returns:
+ * { total, successRate, totalCostUSD, avgDurationMs, byOutcome }
+ * where successRate is a fraction (0–1), avgDurationMs is null if no durations.
+ */
+export function computeExecutionStats(executions) {
+ if (executions.length === 0) {
+ return { total: 0, successRate: 0, totalCostUSD: 0, avgDurationMs: null, byOutcome: {} };
+ }
+
+ let completed = 0;
+ let totalCost = 0;
+ let durationSum = 0;
+ let durationCount = 0;
+ const byOutcome = {};
+
+ for (const e of executions) {
+ const outcome = e.state || 'unknown';
+ byOutcome[outcome] = (byOutcome[outcome] || 0) + 1;
+ if (outcome === 'completed') completed++;
+ totalCost += e.cost_usd || 0;
+ if (e.duration_ms != null) {
+ durationSum += e.duration_ms;
+ durationCount++;
+ }
+ }
+
+ return {
+ total: executions.length,
+ successRate: executions.length > 0 ? completed / executions.length : 0,
+ totalCostUSD: totalCost,
+ avgDurationMs: durationCount > 0 ? Math.round(durationSum / durationCount) : null,
+ byOutcome,
+ };
+}
+
export function updateFilterTabs() {
const current = getTaskFilterTab();
document.querySelectorAll('.filter-tab[data-filter]').forEach(el => {
@@ -813,6 +864,11 @@ async function handleStartNextTask(btn) {
// ── Polling ───────────────────────────────────────────────────────────────────
+function isStatsTabActive() {
+ const panel = document.querySelector('[data-panel="stats"]');
+ return panel && !panel.hasAttribute('hidden');
+}
+
async function poll() {
try {
const tasks = await fetchTasks();
@@ -828,6 +884,11 @@ async function poll() {
if (histEl) histEl.innerHTML = '<p class="task-meta">Could not load execution history.</p>';
});
}
+ if (isStatsTabActive()) {
+ fetchRecentExecutions(BASE_PATH, fetch)
+ .then(execs => renderStatsPanel(tasks, execs))
+ .catch(() => {});
+ }
} catch {
document.querySelector('.task-list').innerHTML =
'<div id="loading">Could not reach server.</div>';
@@ -1915,6 +1976,163 @@ function renderRunningHistory(executions) {
histEl.appendChild(table);
}
+// ── Stats rendering ───────────────────────────────────────────────────────────
+
+// State display order for the task overview grid.
+const STATS_STATE_ORDER = [
+ 'RUNNING', 'QUEUED', 'READY', 'BLOCKED',
+ 'PENDING', 'COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED',
+];
+
+function formatDurationMs(ms) {
+ if (ms == null) return '—';
+ const s = Math.round(ms / 1000);
+ if (s < 60) return `${s}s`;
+ const m = Math.floor(s / 60);
+ const rs = s % 60;
+ if (m < 60) return rs > 0 ? `${m}m ${rs}s` : `${m}m`;
+ const h = Math.floor(m / 60);
+ const rm = m % 60;
+ return rm > 0 ? `${h}h ${rm}m` : `${h}h`;
+}
+
+function renderStatsPanel(tasks, executions) {
+ const panel = document.querySelector('[data-panel="stats"]');
+ if (!panel) return;
+
+ const taskStats = computeTaskStats(tasks);
+ const execStats = computeExecutionStats(executions);
+
+ panel.innerHTML = '';
+
+ // ── Task Overview ──────────────────────────────────────────────────────────
+ const taskSection = document.createElement('div');
+ taskSection.className = 'stats-section';
+
+ const taskHeading = document.createElement('h2');
+ taskHeading.textContent = 'Task Overview';
+ taskSection.appendChild(taskHeading);
+
+ const countsGrid = document.createElement('div');
+ countsGrid.className = 'stats-counts';
+
+ const orderedStates = STATS_STATE_ORDER.filter(s => taskStats.byState[s] > 0);
+ const otherStates = Object.keys(taskStats.byState).filter(s => !STATS_STATE_ORDER.includes(s));
+
+ for (const state of [...orderedStates, ...otherStates]) {
+ const count = taskStats.byState[state] || 0;
+ if (count === 0) continue;
+ const box = document.createElement('div');
+ box.className = 'stats-count-box';
+ box.dataset.state = state;
+
+ const num = document.createElement('span');
+ num.className = 'stats-count-number';
+ num.textContent = String(count);
+
+ const label = document.createElement('span');
+ label.className = 'stats-count-label';
+ label.textContent = state.replace(/_/g, ' ');
+
+ box.appendChild(num);
+ box.appendChild(label);
+ countsGrid.appendChild(box);
+ }
+
+ if (orderedStates.length === 0 && otherStates.length === 0) {
+ const empty = document.createElement('p');
+ empty.className = 'task-meta';
+ empty.textContent = 'No tasks yet.';
+ countsGrid.appendChild(empty);
+ }
+
+ taskSection.appendChild(countsGrid);
+ panel.appendChild(taskSection);
+
+ // ── Execution Health ───────────────────────────────────────────────────────
+ const execSection = document.createElement('div');
+ execSection.className = 'stats-section';
+
+ const execHeading = document.createElement('h2');
+ execHeading.textContent = 'Executions (Last 24h)';
+ execSection.appendChild(execHeading);
+
+ const kpisRow = document.createElement('div');
+ kpisRow.className = 'stats-kpis';
+
+ const kpis = [
+ { label: 'Total Runs', value: String(execStats.total) },
+ { label: 'Success Rate', value: execStats.total > 0 ? `${Math.round(execStats.successRate * 100)}%` : '—' },
+ { label: 'Total Cost', value: execStats.totalCostUSD > 0 ? `$${execStats.totalCostUSD.toFixed(2)}` : '$0.00' },
+ { label: 'Avg Duration', value: formatDurationMs(execStats.avgDurationMs) },
+ ];
+
+ for (const kpi of kpis) {
+ const box = document.createElement('div');
+ box.className = 'stats-kpi-box';
+
+ const val = document.createElement('span');
+ val.className = 'stats-kpi-value';
+ val.textContent = kpi.value;
+
+ const lbl = document.createElement('span');
+ lbl.className = 'stats-kpi-label';
+ lbl.textContent = kpi.label;
+
+ box.appendChild(val);
+ box.appendChild(lbl);
+ kpisRow.appendChild(box);
+ }
+ execSection.appendChild(kpisRow);
+
+ // Bar chart of outcome distribution.
+ if (execStats.total > 0) {
+ const chartSection = document.createElement('div');
+ chartSection.className = 'stats-bar-chart';
+
+ const chartLabel = document.createElement('p');
+ chartLabel.className = 'stats-bar-chart-label';
+ chartLabel.textContent = 'Outcome breakdown';
+ chartSection.appendChild(chartLabel);
+
+ const bars = document.createElement('div');
+ bars.className = 'stats-bars';
+
+ for (const [outcome, count] of Object.entries(execStats.byOutcome)) {
+ const pct = (count / execStats.total) * 100;
+ const row = document.createElement('div');
+ row.className = 'stats-bar-row';
+
+ const barLabel = document.createElement('span');
+ barLabel.className = 'stats-bar-row-label';
+ barLabel.textContent = outcome.replace(/_/g, ' ');
+
+ const barTrack = document.createElement('div');
+ barTrack.className = 'stats-bar-track';
+
+ const barFill = document.createElement('div');
+ barFill.className = 'stats-bar-fill';
+ barFill.dataset.state = outcome.toUpperCase();
+ barFill.style.width = `${pct.toFixed(1)}%`;
+
+ const barCount = document.createElement('span');
+ barCount.className = 'stats-bar-count';
+ barCount.textContent = `${count} (${Math.round(pct)}%)`;
+
+ barTrack.appendChild(barFill);
+ row.appendChild(barLabel);
+ row.appendChild(barTrack);
+ row.appendChild(barCount);
+ bars.appendChild(row);
+ }
+
+ chartSection.appendChild(bars);
+ execSection.appendChild(chartSection);
+ }
+
+ panel.appendChild(execSection);
+}
+
// ── Tab switching ─────────────────────────────────────────────────────────────
function switchTab(name) {
@@ -1948,6 +2166,16 @@ function switchTab(name) {
if (histEl) histEl.innerHTML = '<p class="task-meta">Could not load execution history.</p>';
});
}
+
+ if (name === 'stats') {
+ Promise.all([
+ fetchTasks(),
+ fetchRecentExecutions(BASE_PATH, fetch),
+ ]).then(([tasks, execs]) => renderStatsPanel(tasks, execs)).catch(() => {
+ const panel = document.querySelector('[data-panel="stats"]');
+ if (panel) panel.innerHTML = '<p class="task-meta">Could not load stats.</p>';
+ });
+ }
}
// ── Boot ──────────────────────────────────────────────────────────────────────
diff --git a/web/index.html b/web/index.html
index 0b4ee35..438216f 100644
--- a/web/index.html
+++ b/web/index.html
@@ -18,6 +18,7 @@
<button class="tab active" data-tab="tasks">Tasks</button>
<button class="tab" data-tab="active">Active</button>
<button class="tab" data-tab="running">Running</button>
+ <button class="tab" data-tab="stats">Stats</button>
</nav>
<main id="app">
<div data-panel="tasks">
@@ -38,6 +39,7 @@
<div class="running-current"></div>
<div class="running-history"></div>
</div>
+ <div data-panel="stats" hidden></div>
</main>
<dialog id="task-modal">
diff --git a/web/style.css b/web/style.css
index 1b2b344..feedcce 100644
--- a/web/style.css
+++ b/web/style.css
@@ -1199,3 +1199,162 @@ dialog label select:focus {
margin-top: 0.25rem;
text-align: right;
}
+
+/* ── Stats tab ────────────────────────────────────────────────────────────── */
+
+[data-panel="stats"] {
+ padding: 1rem 1.5rem;
+ max-width: 900px;
+}
+
+.stats-section {
+ margin-bottom: 2rem;
+}
+
+.stats-section h2 {
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin: 0 0 0.75rem;
+}
+
+/* Task state count boxes */
+.stats-counts {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.stats-count-box {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 0.75rem 1.25rem;
+ border-radius: 8px;
+ background: var(--card-bg);
+ border: 1px solid var(--border);
+ min-width: 80px;
+}
+
+.stats-count-box[data-state="RUNNING"] { border-color: var(--state-running); }
+.stats-count-box[data-state="QUEUED"] { border-color: var(--state-queued); }
+.stats-count-box[data-state="READY"] { border-color: var(--state-running); }
+.stats-count-box[data-state="BLOCKED"] { border-color: var(--state-blocked); }
+.stats-count-box[data-state="PENDING"] { border-color: var(--state-pending); }
+.stats-count-box[data-state="COMPLETED"] { border-color: var(--state-completed); }
+.stats-count-box[data-state="FAILED"] { border-color: var(--state-failed); }
+.stats-count-box[data-state="TIMED_OUT"] { border-color: var(--state-timed-out); }
+.stats-count-box[data-state="CANCELLED"] { border-color: var(--state-cancelled); }
+.stats-count-box[data-state="BUDGET_EXCEEDED"] { border-color: var(--state-budget-exceeded); }
+
+.stats-count-number {
+ font-size: 1.75rem;
+ font-weight: 700;
+ line-height: 1;
+}
+
+.stats-count-label {
+ font-size: 0.7rem;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--text-muted);
+ margin-top: 0.25rem;
+ white-space: nowrap;
+}
+
+/* KPI row */
+.stats-kpis {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 0.75rem;
+ margin-bottom: 1.25rem;
+}
+
+@media (max-width: 600px) {
+ .stats-kpis {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+.stats-kpi-box {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 1rem;
+ border-radius: 8px;
+ background: var(--card-bg);
+ border: 1px solid var(--border);
+ text-align: center;
+}
+
+.stats-kpi-value {
+ font-size: 1.5rem;
+ font-weight: 700;
+ line-height: 1;
+}
+
+.stats-kpi-label {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ margin-top: 0.25rem;
+ white-space: nowrap;
+}
+
+/* Outcome bar chart */
+.stats-bar-chart-label {
+ font-size: 0.8rem;
+ color: var(--text-muted);
+ margin: 0 0 0.5rem;
+}
+
+.stats-bars {
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+}
+
+.stats-bar-row {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.stats-bar-row-label {
+ font-size: 0.78rem;
+ text-transform: uppercase;
+ color: var(--text-muted);
+ width: 120px;
+ flex-shrink: 0;
+ white-space: nowrap;
+}
+
+.stats-bar-track {
+ flex: 1;
+ height: 16px;
+ background: var(--border);
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.stats-bar-fill {
+ height: 100%;
+ border-radius: 4px;
+ background: var(--state-pending);
+ transition: width 0.3s ease;
+}
+
+.stats-bar-fill[data-state="COMPLETED"] { background: var(--state-completed); }
+.stats-bar-fill[data-state="FAILED"] { background: var(--state-failed); }
+.stats-bar-fill[data-state="CANCELLED"] { background: var(--state-cancelled); }
+.stats-bar-fill[data-state="TIMED_OUT"] { background: var(--state-timed-out); }
+.stats-bar-fill[data-state="BUDGET_EXCEEDED"] { background: var(--state-budget-exceeded); }
+.stats-bar-fill[data-state="RUNNING"] { background: var(--state-running); }
+
+.stats-bar-count {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ width: 80px;
+ flex-shrink: 0;
+}
diff --git a/web/test/stats.test.mjs b/web/test/stats.test.mjs
new file mode 100644
index 0000000..a7fe657
--- /dev/null
+++ b/web/test/stats.test.mjs
@@ -0,0 +1,153 @@
+// stats.test.mjs — Unit tests for stats tab computation functions.
+//
+// Run with: node --test web/test/stats.test.mjs
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { computeTaskStats, computeExecutionStats } from '../app.js';
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+
+function makeTask(state) {
+ return { id: state, name: `task-${state}`, state };
+}
+
+function makeExec(state, costUSD = 0, durationMs = null) {
+ const started = new Date('2024-01-01T10:00:00Z');
+ const finished = durationMs != null
+ ? new Date(started.getTime() + durationMs).toISOString()
+ : null;
+ return {
+ id: `exec-${Math.random()}`,
+ task_id: 'task-1',
+ task_name: 'Test Task',
+ state,
+ started_at: started.toISOString(),
+ finished_at: finished,
+ duration_ms: durationMs,
+ cost_usd: costUSD,
+ exit_code: state === 'completed' ? 0 : 1,
+ };
+}
+
+// ── computeTaskStats ───────────────────────────────────────────────────────────
+
+describe('computeTaskStats', () => {
+ it('groups tasks by state', () => {
+ const tasks = [
+ makeTask('RUNNING'), makeTask('RUNNING'),
+ makeTask('PENDING'),
+ makeTask('COMPLETED'), makeTask('COMPLETED'), makeTask('COMPLETED'),
+ ];
+ const stats = computeTaskStats(tasks);
+ assert.equal(stats.byState.RUNNING, 2);
+ assert.equal(stats.byState.PENDING, 1);
+ assert.equal(stats.byState.COMPLETED, 3);
+ });
+
+ it('only includes states that have tasks', () => {
+ const tasks = [makeTask('RUNNING')];
+ const stats = computeTaskStats(tasks);
+ assert.equal(stats.byState.RUNNING, 1);
+ assert.equal(stats.byState.PENDING, undefined);
+ assert.equal(stats.byState.COMPLETED, undefined);
+ });
+
+ it('returns empty byState for empty task list', () => {
+ const stats = computeTaskStats([]);
+ assert.deepEqual(stats.byState, {});
+ });
+
+ it('counts all distinct states correctly', () => {
+ const states = ['PENDING', 'QUEUED', 'RUNNING', 'READY', 'COMPLETED',
+ 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED', 'BLOCKED'];
+ const tasks = states.map(makeTask);
+ const stats = computeTaskStats(tasks);
+ for (const s of states) {
+ assert.equal(stats.byState[s], 1, `expected count 1 for state ${s}`);
+ }
+ });
+});
+
+// ── computeExecutionStats ──────────────────────────────────────────────────────
+
+describe('computeExecutionStats', () => {
+ it('returns zeros for empty executions', () => {
+ const stats = computeExecutionStats([]);
+ assert.equal(stats.total, 0);
+ assert.equal(stats.successRate, 0);
+ assert.equal(stats.totalCostUSD, 0);
+ assert.equal(stats.avgDurationMs, null);
+ assert.deepEqual(stats.byOutcome, {});
+ });
+
+ it('calculates total correctly', () => {
+ const execs = [makeExec('completed'), makeExec('failed'), makeExec('cancelled')];
+ const stats = computeExecutionStats(execs);
+ assert.equal(stats.total, 3);
+ });
+
+ it('calculates success rate as fraction of completed out of total', () => {
+ const execs = [
+ makeExec('completed'), makeExec('completed'), makeExec('completed'),
+ makeExec('failed'),
+ ];
+ const stats = computeExecutionStats(execs);
+ assert.equal(stats.successRate, 0.75);
+ });
+
+ it('returns success rate 0 when all executions failed', () => {
+ const execs = [makeExec('failed'), makeExec('failed')];
+ const stats = computeExecutionStats(execs);
+ assert.equal(stats.successRate, 0);
+ });
+
+ it('returns success rate 1 when all executions completed', () => {
+ const execs = [makeExec('completed'), makeExec('completed')];
+ const stats = computeExecutionStats(execs);
+ assert.equal(stats.successRate, 1);
+ });
+
+ it('sums total cost correctly', () => {
+ const execs = [makeExec('completed', 0.5), makeExec('completed', 0.25), makeExec('failed', 0.1)];
+ const stats = computeExecutionStats(execs);
+ assert.ok(Math.abs(stats.totalCostUSD - 0.85) < 0.0001, `expected 0.85, got ${stats.totalCostUSD}`);
+ });
+
+ it('calculates average duration from executions with duration_ms', () => {
+ const execs = [
+ makeExec('completed', 0, 60000), // 1 min
+ makeExec('completed', 0, 120000), // 2 min
+ makeExec('failed', 0, 30000), // 30 sec
+ ];
+ const stats = computeExecutionStats(execs);
+ assert.equal(stats.avgDurationMs, 70000); // (60000+120000+30000)/3
+ });
+
+ it('ignores executions without duration_ms in avg calculation', () => {
+ const execs = [
+ makeExec('completed', 0, 60000),
+ makeExec('running', 0, null), // still running, no duration
+ ];
+ const stats = computeExecutionStats(execs);
+ assert.equal(stats.avgDurationMs, 60000);
+ });
+
+ it('returns null avgDurationMs when no executions have duration_ms', () => {
+ const execs = [makeExec('running', 0, null)];
+ const stats = computeExecutionStats(execs);
+ assert.equal(stats.avgDurationMs, null);
+ });
+
+ it('groups executions by outcome in byOutcome', () => {
+ const execs = [
+ makeExec('completed'), makeExec('completed'),
+ makeExec('failed'),
+ makeExec('cancelled'),
+ ];
+ const stats = computeExecutionStats(execs);
+ assert.equal(stats.byOutcome.completed, 2);
+ assert.equal(stats.byOutcome.failed, 1);
+ assert.equal(stats.byOutcome.cancelled, 1);
+ });
+});