From ed94896372686ce3a032e8f3d76144eb83e2d8cc Mon Sep 17 00:00:00 2001 From: Claudomator Agent Date: Wed, 11 Mar 2026 11:01:21 +0000 Subject: feat: require agents to write a final summary before exiting Add a mandatory '## Final Summary' section to planningPreamble instructing agents to output a 2-5 sentence summary paragraph (headed by '## Summary') as their last output before exiting. Adds three tests to verify the section and its required content are present in the preamble. Co-Authored-By: Claude Sonnet 4.6 --- internal/executor/preamble.go | 11 +++++++++++ internal/executor/preamble_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 internal/executor/preamble_test.go (limited to 'internal') diff --git a/internal/executor/preamble.go b/internal/executor/preamble.go index e50c16f..1993361 100644 --- a/internal/executor/preamble.go +++ b/internal/executor/preamble.go @@ -46,6 +46,17 @@ The sandbox is rejected if there are any uncommitted modifications. --- +## Final Summary (mandatory) + +Before exiting, write a final summary paragraph (2-5 sentences) as your last output. Start it with "## Summary" on its own line. Describe: +- What was accomplished +- Key decisions made +- Any issues or follow-ups needed + +This summary will be extracted and displayed in the task UI. + +--- + ` 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) + } + } +} -- cgit v1.2.3 From 55c20922cc7a671787fe94fdd53a7eb72ebd2596 Mon Sep 17 00:00:00 2001 From: Claudomator Agent Date: Wed, 11 Mar 2026 19:05:12 +0000 Subject: feat: add Stats tab with task distribution and execution health metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/stats-tab-plan.md | 147 ++++++++++++++++++++++++ internal/api/docs/RAW_NARRATIVE.md | 9 ++ web/app.js | 228 +++++++++++++++++++++++++++++++++++++ web/index.html | 2 + web/style.css | 159 ++++++++++++++++++++++++++ web/test/stats.test.mjs | 153 +++++++++++++++++++++++++ 6 files changed, 698 insertions(+) create mode 100644 docs/stats-tab-plan.md create mode 100644 internal/api/docs/RAW_NARRATIVE.md create mode 100644 web/test/stats.test.mjs (limited to 'internal') 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 + +``` + +``` + +--- + +## 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 + `
` | +| `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 = '

Could not load execution history.

'; }); } + if (isStatsTabActive()) { + fetchRecentExecutions(BASE_PATH, fetch) + .then(execs => renderStatsPanel(tasks, execs)) + .catch(() => {}); + } } catch { document.querySelector('.task-list').innerHTML = '
Could not reach server.
'; @@ -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 = '

Could not load execution history.

'; }); } + + 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 = '

Could not load stats.

'; + }); + } } // ── 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 @@ +
@@ -38,6 +39,7 @@
+
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); + }); +}); -- cgit v1.2.3