diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-03-13 03:17:04 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-03-13 03:17:04 +0000 |
| commit | fe414fac958330c2302d9175d66e1b338e5b1864 (patch) | |
| tree | ef4941f5d01e84e7868e6b92bd0e6cecdcc2a64f | |
| parent | d5f83f8662c9f9c0fb52b206b06d4dd54a7788b4 (diff) | |
| parent | 55c20922cc7a671787fe94fdd53a7eb72ebd2596 (diff) | |
merge: resolve conflicts with local/master (stats tab + summary styles)
Keep file-based summary approach (CLAUDOMATOR_SUMMARY_FILE) from HEAD.
Combine Q&A History and Stats tab CSS from both branches.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | docs/stats-tab-plan.md | 147 | ||||
| -rw-r--r-- | internal/api/docs/RAW_NARRATIVE.md | 9 | ||||
| -rw-r--r-- | internal/executor/preamble.go | 1 | ||||
| -rw-r--r-- | internal/executor/preamble_test.go | 26 | ||||
| -rw-r--r-- | web/app.js | 266 | ||||
| -rw-r--r-- | web/index.html | 2 | ||||
| -rw-r--r-- | web/style.css | 182 | ||||
| -rw-r--r-- | web/test/stats.test.mjs | 153 |
8 files changed, 785 insertions, 1 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 index 7944463..3c7768a 100644 --- a/internal/api/docs/RAW_NARRATIVE.md +++ b/internal/api/docs/RAW_NARRATIVE.md @@ -115,3 +115,12 @@ do something --- 2026-03-13T02:27:38Z --- do something + +--- 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/internal/executor/preamble.go b/internal/executor/preamble.go index bc5c32c..5e57852 100644 --- a/internal/executor/preamble.go +++ b/internal/executor/preamble.go @@ -56,7 +56,6 @@ and the outcome. Write it to the path in $CLAUDOMATOR_SUMMARY_FILE: This summary is displayed in the task UI so the user knows what happened. --- - ` func withPlanningPreamble(instructions string) string { diff --git a/internal/executor/preamble_test.go b/internal/executor/preamble_test.go new file mode 100644 index 0000000..448ad3a --- /dev/null +++ b/internal/executor/preamble_test.go @@ -0,0 +1,26 @@ +package executor + +import ( + "strings" + "testing" +) + +func TestPlanningPreamble_ContainsFinalSummarySection(t *testing.T) { + if !strings.Contains(planningPreamble, "## Final Summary (mandatory)") { + t.Error("planningPreamble missing '## Final Summary (mandatory)' heading") + } +} + +func TestPlanningPreamble_SummaryRequiresMarkdownHeader(t *testing.T) { + if !strings.Contains(planningPreamble, `Start it with "## Summary"`) { + t.Error("planningPreamble does not instruct agent to start summary with '## Summary'") + } +} + +func TestPlanningPreamble_SummaryDescribesRequiredContent(t *testing.T) { + for _, phrase := range []string{"What was accomplished", "Key decisions made", "Any issues or follow-ups"} { + if !strings.Contains(planningPreamble, phrase) { + t.Errorf("planningPreamble missing required summary content description: %q", phrase) + } + } +} @@ -301,6 +301,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 => { @@ -817,6 +868,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(); @@ -832,6 +888,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>'; @@ -1363,6 +1424,16 @@ function renderTaskPanel(task, executions) { overview.appendChild(overviewGrid); content.appendChild(overview); + // ── Summary ── + if (task.summary) { + const summarySection = makeSection("Summary"); + const summaryText = document.createElement("div"); + summaryText.className = "task-summary-text"; + summaryText.textContent = task.summary; + summarySection.appendChild(summaryText); + content.appendChild(summarySection); + } + // ── Agent Config ── const a = task.agent || {}; const agentSection = makeSection('Agent Config'); @@ -1390,6 +1461,34 @@ function renderTaskPanel(task, executions) { agentSection.appendChild(agentGrid); content.appendChild(agentSection); + // ── Q&A History ── + let interactions = []; + if (task.interactions) { + try { interactions = JSON.parse(task.interactions); } catch {} + } + if (interactions.length > 0) { + const qaSection = makeSection("Q&A History"); + const timeline = document.createElement("div"); + timeline.className = "qa-timeline"; + for (const item of interactions) { + const entry = document.createElement("div"); + entry.className = `qa-item qa-${item.type}`; + const label = document.createElement("span"); + label.className = "qa-label"; + label.textContent = item.type === "question" ? "Agent asked:" : "User answered:"; + const text = document.createElement("div"); + text.className = "qa-content"; + text.textContent = item.content; + const ts = document.createElement("span"); + ts.className = "qa-timestamp"; + ts.textContent = item.timestamp ? formatDate(item.timestamp) : ""; + entry.append(label, text, ts); + timeline.appendChild(entry); + } + qaSection.appendChild(timeline); + content.appendChild(qaSection); + } + // ── Execution Settings ── const settingsSection = makeSection('Execution Settings'); const settingsGrid = document.createElement('div'); @@ -1925,6 +2024,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) { @@ -1958,6 +2214,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 342e0b3..67e4962 100644 --- a/web/style.css +++ b/web/style.css @@ -1170,6 +1170,29 @@ dialog label select:focus { margin-top: 0.5rem; } +.task-summary-text { + padding: 0.75rem 1rem; + background: var(--surface-1, #f8f9fa); + border-radius: 6px; + line-height: 1.5; + white-space: pre-wrap; +} +.qa-timeline { + display: flex; + flex-direction: column; + gap: 0.75rem; +} +.qa-item { + padding: 0.5rem 0.75rem; + border-left: 3px solid; + border-radius: 0 4px 4px 0; +} +.qa-question { border-color: #3b82f6; background: rgba(59,130,246,0.05); } +.qa-answer { border-color: #10b981; background: rgba(16,185,129,0.05); } +.qa-label { font-weight: 600; font-size: 0.85em; display: block; margin-bottom: 0.25rem; } +.qa-content { white-space: pre-wrap; } +.qa-timestamp { font-size: 0.75em; color: var(--text-muted, #6b7280); margin-top: 0.25rem; display: block; } + .inline-edit-success { color: var(--state-completed); font-size: 0.82rem; @@ -1214,3 +1237,162 @@ dialog label select:focus { color: var(--accent, #60a5fa); font-style: italic; } + +/* ── 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); + }); +}); |
