diff --git a/TODO.md b/TODO.md index 44b929fd..56c0ce61 100644 --- a/TODO.md +++ b/TODO.md @@ -1,10 +1,5 @@ # Hyperhive TODOs -> **Rough split for who picks up what:** harness ergonomics + host-side -> harness plumbing tend to be damocles' interest area; UI-polish work -> for the operator is not. Use that as a hint when picking up items, -> not a hard rule. - ## Architecture / Features - Shared space for all agents to access documents/files without manager routing @@ -23,8 +18,7 @@ ## Dashboard -- **Unified URL scheme via reverse proxy**: today every agent's web UI is reached at `:/`, so operators juggle a port list. Stand up nginx (or similar) terminating one domain that fans requests to `/agent//...` out to each container's web port, and to `/` for the main dashboard. Touches: a NixOS module on the host, the dashboard's per-agent link rendering, and the per-agent web server's base-path handling (currently assumes root). Lets bookmarks survive port reshuffles and unblocks per-agent stats links being relative URLs instead of hard-coded ports. -- **Delivered-reminder rollup on the per-agent stats page**: surface attempt / success / failure counts for reminders this agent fired (in the existing `/stats` page). Needs an `AgentRequest::ReminderRollup { since_secs }` / matching `ManagerRequest::ReminderRollup` RPC so the agent can pull the counts from the host's broker DB (the reminders table is host-owned; agent state doesn't have them). Deferred from the initial stats page so the first cut stays self-contained to data the agent already owns. +- **Delivered-reminder rollups**: per-agent delivered-count chip (last 24h) + histogram of attempts-vs-successes on the container row. Needs `Broker::count_delivered_reminders_since(agent, ts)` (cheap COUNT against the `reminders` table, `WHERE agent = ?1 AND sent_at >= ?2`). ## Security @@ -55,6 +49,8 @@ how often the friction bites in normal use. ## Telemetry - **Per-turn stats: host-side vacuum sweep**: the sink writes to `/state/hyperhive-turn-stats.sqlite` on each agent's state dir; needs a periodic retention sweep mirroring `events_vacuum.rs` so the table doesn't grow forever. Default keep-window: 90 days (turn-stats are denser than events but smaller per-row, ~200B each). +- **Surface per-turn stats on the agent web UI**: "N turns today" chip + rolling tool-call histogram tooltip on the model chip. (`open_threads` and `open_reminders` chips already landed via other paths — open-threads section on the page + reminder count chip on the container row.) Reads the per-agent `turn_stats.sqlite`. +- **Stats UI on the main dashboard**: per-agent rollups (avg turn duration, tokens-since-boot, top 5 tools) on the container row. Same data source, host-side aggregation query. ## Harness Behaviour diff --git a/hive-ag3nt/assets/index.html b/hive-ag3nt/assets/index.html index 368eccd4..3083990b 100644 --- a/hive-ag3nt/assets/index.html +++ b/hive-ag3nt/assets/index.html @@ -8,7 +8,6 @@

◆ … ◆

-

📊 stats →

loading…

diff --git a/hive-ag3nt/assets/stats.html b/hive-ag3nt/assets/stats.html deleted file mode 100644 index ce51217e..00000000 --- a/hive-ag3nt/assets/stats.html +++ /dev/null @@ -1,95 +0,0 @@ - - - - - hyperhive agent — stats - - - - - -
- ← live - dashboard -

◆ … ◆

-
- -
- - - -
- -
- -
-

turns per bucket

-

turn duration (ms) — p50 / p95 / avg

-

context tokens (last inference per turn) — avg / max

-

token cost per bucket (sum across inferences)

-

top tools

-

wake source mix

-

result mix

-
- - - - - - diff --git a/hive-ag3nt/assets/stats.js b/hive-ag3nt/assets/stats.js deleted file mode 100644 index 87499207..00000000 --- a/hive-ag3nt/assets/stats.js +++ /dev/null @@ -1,308 +0,0 @@ -// Per-agent stats page. Fetches /api/state for the title + dashboard link -// once on load, then /api/stats?window=... for the chart data — re-fetches -// when the operator clicks a window tab. - -(function () { - 'use strict'; - - const cssVar = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim(); - const palette = { - bg: cssVar('--bg'), - bgElev: cssVar('--bg-elev'), - fg: cssVar('--fg'), - muted: cssVar('--muted'), - purple: cssVar('--purple'), - cyan: cssVar('--cyan'), - pink: cssVar('--pink'), - amber: cssVar('--amber'), - green: cssVar('--green'), - red: cssVar('--red'), - border: cssVar('--border'), - }; - // Distinct hues for categorical charts (top tools / wake mix / result mix). - const wheel = [palette.purple, palette.cyan, palette.pink, palette.amber, - palette.green, palette.red, '#94e2d5', '#f9e2af', - '#74c7ec', '#b4befe']; - - // Apply Catppuccin defaults globally so each Chart inherits without per-call - // overrides. Chart.js v4 reads these on chart construction. - Chart.defaults.color = palette.fg; - Chart.defaults.borderColor = palette.border; - Chart.defaults.font.family = '"JetBrains Mono", "Fira Code", monospace'; - Chart.defaults.font.size = 11; - Chart.defaults.plugins.legend.labels.color = palette.fg; - - const charts = {}; - let currentWindow = '24h'; - - function fmtMs(ms) { - if (!Number.isFinite(ms) || ms <= 0) return '0'; - if (ms < 1000) return ms.toFixed(0) + 'ms'; - return (ms / 1000).toFixed(ms < 10000 ? 2 : 1) + 's'; - } - - function fmtInt(n) { - if (!Number.isFinite(n)) return '0'; - return new Intl.NumberFormat().format(Math.round(n)); - } - - function bucketLabel(ts, bucketSecs) { - const d = new Date(ts * 1000); - if (bucketSecs >= 86400) { - return d.toISOString().slice(5, 10); // MM-DD - } - return d.toISOString().slice(11, 16); // HH:MM - } - - function destroy(name) { - if (charts[name]) { - charts[name].destroy(); - delete charts[name]; - } - } - - function paintEmpty(canvasId, msg) { - destroy(canvasId); - const cv = document.getElementById(canvasId); - if (!cv) return; - const ctx = cv.getContext('2d'); - ctx.clearRect(0, 0, cv.width, cv.height); - ctx.fillStyle = palette.muted; - ctx.font = '12px monospace'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(msg, cv.width / 2, cv.height / 2); - } - - function renderSummary(s) { - const root = document.getElementById('summary'); - root.replaceChildren(); - const chips = [ - ['turns', fmtInt(s.turn_count)], - ['avg duration', fmtMs(s.duration_summary.avg_ms)], - ['p50 duration', fmtMs(s.duration_summary.p50_ms)], - ['p95 duration', fmtMs(s.duration_summary.p95_ms)], - ['window', s.window], - ]; - for (const [label, value] of chips) { - const chip = document.createElement('span'); - chip.className = 'chip'; - const l = document.createElement('span'); - l.className = 'label'; - l.textContent = label; - const v = document.createElement('span'); - v.className = 'value'; - v.textContent = value; - chip.append(l, v); - root.append(chip); - } - } - - function renderTurnsChart(s) { - const id = 'chart-turns'; - destroy(id); - const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds)); - const data = s.buckets.map((b) => b.turn_count); - charts[id] = new Chart(document.getElementById(id), { - type: 'bar', - data: { - labels, - datasets: [{ - label: 'turns', - data, - backgroundColor: palette.purple, - borderColor: palette.purple, - borderWidth: 1, - }], - }, - options: { - responsive: true, maintainAspectRatio: false, - plugins: { legend: { display: false } }, - scales: { - x: { grid: { color: palette.border } }, - y: { beginAtZero: true, grid: { color: palette.border }, ticks: { precision: 0 } }, - }, - }, - }); - } - - function renderDurationChart(s) { - const id = 'chart-duration'; - destroy(id); - const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds)); - const ds = (label, color, key) => ({ - label, data: s.buckets.map((b) => b[key]), - borderColor: color, backgroundColor: color + '33', - tension: 0.25, pointRadius: 0, borderWidth: 2, spanGaps: true, - }); - charts[id] = new Chart(document.getElementById(id), { - type: 'line', - data: { - labels, - datasets: [ - ds('p50', palette.cyan, 'p50_duration_ms'), - ds('p95', palette.pink, 'p95_duration_ms'), - ds('avg', palette.amber, 'avg_duration_ms'), - ], - }, - options: { - responsive: true, maintainAspectRatio: false, - scales: { - x: { grid: { color: palette.border } }, - y: { - beginAtZero: true, - grid: { color: palette.border }, - ticks: { callback: (v) => fmtMs(v) }, - }, - }, - }, - }); - } - - function renderCtxChart(s) { - const id = 'chart-ctx'; - destroy(id); - const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds)); - charts[id] = new Chart(document.getElementById(id), { - type: 'line', - data: { - labels, - datasets: [ - { - label: 'avg ctx', - data: s.buckets.map((b) => b.avg_ctx_tokens), - borderColor: palette.cyan, - backgroundColor: palette.cyan + '33', - tension: 0.25, pointRadius: 0, borderWidth: 2, spanGaps: true, - }, - { - label: 'max ctx', - data: s.buckets.map((b) => b.max_ctx_tokens), - borderColor: palette.amber, - backgroundColor: palette.amber + '33', - tension: 0.25, pointRadius: 0, borderWidth: 2, spanGaps: true, - }, - ], - }, - options: { - responsive: true, maintainAspectRatio: false, - scales: { - x: { grid: { color: palette.border } }, - y: { beginAtZero: true, grid: { color: palette.border }, ticks: { callback: (v) => fmtInt(v) } }, - }, - }, - }); - } - - function renderCostChart(s) { - const id = 'chart-cost'; - destroy(id); - const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds)); - // Stacked bars: cache_read (cheap) / cache_creation / input / output. - // Highlights "what's actually getting billed at full rate" vs cache hits. - charts[id] = new Chart(document.getElementById(id), { - type: 'bar', - data: { - labels, - datasets: [ - { label: 'cache_read', data: s.buckets.map((b) => b.cache_read_input_tokens), - backgroundColor: palette.muted }, - { label: 'cache_creation', data: s.buckets.map((b) => b.cache_creation_input_tokens), - backgroundColor: palette.cyan }, - { label: 'input', data: s.buckets.map((b) => b.input_tokens), - backgroundColor: palette.amber }, - { label: 'output', data: s.buckets.map((b) => b.output_tokens), - backgroundColor: palette.pink }, - ], - }, - options: { - responsive: true, maintainAspectRatio: false, - scales: { - x: { stacked: true, grid: { color: palette.border } }, - y: { stacked: true, beginAtZero: true, - grid: { color: palette.border }, ticks: { callback: (v) => fmtInt(v) } }, - }, - }, - }); - } - - function renderKeyCount(canvasId, items, emptyMsg) { - destroy(canvasId); - if (!items || items.length === 0) { - paintEmpty(canvasId, emptyMsg); - return; - } - const labels = items.map((kc) => kc.key); - const data = items.map((kc) => kc.count); - const colors = items.map((_, i) => wheel[i % wheel.length]); - charts[canvasId] = new Chart(document.getElementById(canvasId), { - type: 'doughnut', - data: { labels, datasets: [{ data, backgroundColor: colors, borderColor: palette.bg, borderWidth: 2 }] }, - options: { - responsive: true, maintainAspectRatio: false, - plugins: { legend: { position: 'right', labels: { boxWidth: 12 } } }, - }, - }); - } - - function render(s) { - renderSummary(s); - if (s.turn_count === 0) { - paintEmpty('chart-turns', 'no turns in window'); - paintEmpty('chart-duration', 'no turns in window'); - paintEmpty('chart-ctx', 'no turns in window'); - paintEmpty('chart-cost', 'no turns in window'); - paintEmpty('chart-tools', 'no tool calls'); - paintEmpty('chart-wake', 'no wakes'); - paintEmpty('chart-result', 'no results'); - return; - } - renderTurnsChart(s); - renderDurationChart(s); - renderCtxChart(s); - renderCostChart(s); - renderKeyCount('chart-tools', s.tool_breakdown, 'no tool calls'); - renderKeyCount('chart-wake', s.wake_mix, 'no wakes'); - renderKeyCount('chart-result', s.result_mix, 'no results'); - } - - async function loadStats() { - try { - const resp = await fetch('/api/stats?window=' + encodeURIComponent(currentWindow)); - if (!resp.ok) throw new Error('http ' + resp.status); - const snap = await resp.json(); - render(snap); - } catch (e) { - document.getElementById('summary').textContent = 'stats fetch failed: ' + e; - } - } - - async function loadIdentity() { - try { - const resp = await fetch('/api/state'); - if (!resp.ok) return; - const s = await resp.json(); - document.title = 'stats · ' + s.label; - document.getElementById('title').textContent = '◆ ' + s.label + ' ◆'; - const dl = document.getElementById('dashboard-link'); - dl.href = 'http://' + window.location.hostname + ':' + s.dashboard_port + '/'; - } catch (_) { /* non-fatal */ } - } - - function bindTabs() { - const tabs = document.getElementById('window-tabs'); - tabs.addEventListener('click', (ev) => { - const btn = ev.target.closest('button[data-w]'); - if (!btn) return; - currentWindow = btn.dataset.w; - for (const b of tabs.querySelectorAll('button')) b.classList.toggle('active', b === btn); - loadStats(); - }); - } - - document.addEventListener('DOMContentLoaded', () => { - bindTabs(); - loadIdentity(); - loadStats(); - }); -})(); diff --git a/hive-ag3nt/src/lib.rs b/hive-ag3nt/src/lib.rs index fa3a8f30..219e6445 100644 --- a/hive-ag3nt/src/lib.rs +++ b/hive-ag3nt/src/lib.rs @@ -8,7 +8,6 @@ pub mod login_session; pub mod mcp; pub mod paths; pub mod plugins; -pub mod stats; pub mod turn; pub mod turn_stats; pub mod web_ui; diff --git a/hive-ag3nt/src/stats.rs b/hive-ag3nt/src/stats.rs deleted file mode 100644 index eca7f67c..00000000 --- a/hive-ag3nt/src/stats.rs +++ /dev/null @@ -1,500 +0,0 @@ -//! Read-side aggregations over the per-agent `turn_stats.sqlite` for -//! the agent's `/stats` web page. Owned by the agent (same process -//! that writes the sink) so per-MCP extensions can register more -//! providers without the host needing to know their schemas. -//! -//! Best-effort: any sqlite error returns an empty snapshot rather than -//! propagating — the stats page is decorative, not authoritative, and -//! a missing db on a brand-new agent shouldn't 500 the route. - -use std::collections::HashMap; -use std::path::{Path, PathBuf}; - -use anyhow::{Context, Result}; -use rusqlite::{Connection, OpenFlags}; -use serde::Serialize; - -/// Window param accepted by `/api/stats?window=`. Each maps to a -/// total span + the bucket width used to roll up trend series. -#[derive(Debug, Clone, Copy)] -pub enum Window { - Day, - Week, - Month, -} - -impl Window { - pub fn parse(s: &str) -> Self { - match s { - "7d" => Self::Week, - "30d" => Self::Month, - _ => Self::Day, - } - } - - fn label(self) -> &'static str { - match self { - Self::Day => "24h", - Self::Week => "7d", - Self::Month => "30d", - } - } - - fn span_secs(self) -> i64 { - match self { - Self::Day => 24 * 3600, - Self::Week => 7 * 24 * 3600, - Self::Month => 30 * 24 * 3600, - } - } - - fn bucket_secs(self) -> i64 { - match self { - // hourly for the 24h view, daily for the longer ranges. - Self::Day => 3600, - Self::Week | Self::Month => 24 * 3600, - } - } -} - -#[derive(Debug, Serialize)] -pub struct Snapshot { - pub window: &'static str, - pub bucket_seconds: i64, - pub now: i64, - pub from: i64, - /// Total turns in the window. - pub turn_count: u64, - /// Time-bucketed trend series, oldest first. Always covers the - /// full window even for empty buckets (so charts paint a stable - /// x-axis instead of skipping gaps). - pub buckets: Vec, - /// Top tools by call count across the window. Capped to 10. - pub tool_breakdown: Vec, - pub wake_mix: Vec, - pub result_mix: Vec, - /// Across-window p50 / p95 / avg of `duration_ms`. Same numbers - /// as the per-bucket fields but aggregated over the whole window - /// for the headline summary chips. - pub duration_summary: DurationSummary, -} - -#[derive(Debug, Serialize)] -pub struct Bucket { - /// Unix timestamp of the bucket start. - pub ts: i64, - pub turn_count: u64, - pub avg_duration_ms: f64, - pub p50_duration_ms: f64, - pub p95_duration_ms: f64, - /// Sums across the bucket. JS picks how to combine them - /// (input + output for cost, etc.) so we don't bake a policy in. - pub input_tokens: u64, - pub output_tokens: u64, - pub cache_read_input_tokens: u64, - pub cache_creation_input_tokens: u64, - /// Mean of `last_input_tokens` across the bucket (the context - /// size at turn-end — useful for spotting drift toward compaction). - pub avg_ctx_tokens: f64, - pub max_ctx_tokens: u64, -} - -#[derive(Debug, Serialize)] -pub struct KeyCount { - pub key: String, - pub count: u64, -} - -#[derive(Debug, Default, Serialize)] -pub struct DurationSummary { - pub avg_ms: f64, - pub p50_ms: f64, - pub p95_ms: f64, -} - -#[must_use] -pub fn snapshot_default(window: Window) -> Snapshot { - let path = default_path(); - match snapshot(&path, window) { - Ok(s) => s, - Err(e) => { - tracing::warn!(error = ?e, path = %path.display(), "stats: snapshot failed"); - empty_snapshot(window) - } - } -} - -fn default_path() -> PathBuf { - crate::paths::state_dir().join("hyperhive-turn-stats.sqlite") -} - -fn empty_snapshot(window: Window) -> Snapshot { - let now = now_secs(); - let from = now - window.span_secs(); - let buckets = fill_buckets(from, now, window.bucket_secs(), &HashMap::new()); - Snapshot { - window: window.label(), - bucket_seconds: window.bucket_secs(), - now, - from, - turn_count: 0, - buckets, - tool_breakdown: Vec::new(), - wake_mix: Vec::new(), - result_mix: Vec::new(), - duration_summary: DurationSummary::default(), - } -} - -fn snapshot(path: &Path, window: Window) -> Result { - // Read-only open so an in-flight writer (the harness's own - // turn_stats sink) never blocks us and we can't corrupt the db - // via a query bug. - let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY) - .with_context(|| format!("open {} read-only", path.display()))?; - let now = now_secs(); - let from = now - window.span_secs(); - let bucket_secs = window.bucket_secs(); - - let mut stmt = conn.prepare( - "SELECT started_at, duration_ms, - input_tokens, output_tokens, - cache_read_input_tokens, cache_creation_input_tokens, - last_input_tokens, - tool_call_breakdown_json, - wake_from, result_kind - FROM turn_stats - WHERE started_at >= ?1 - ORDER BY started_at ASC", - )?; - let rows = stmt.query_map([from], |row| { - Ok(Row { - started_at: row.get(0)?, - duration_ms: row.get::<_, i64>(1)?, - input_tokens: u64_from_i64(row.get::<_, i64>(2)?), - output_tokens: u64_from_i64(row.get::<_, i64>(3)?), - cache_read_input_tokens: u64_from_i64(row.get::<_, i64>(4)?), - cache_creation_input_tokens: u64_from_i64(row.get::<_, i64>(5)?), - last_input_tokens: u64_from_i64(row.get::<_, i64>(6)?), - tool_breakdown_json: row.get::<_, Option>(7)?, - wake_from: row.get::<_, String>(8)?, - result_kind: row.get::<_, String>(9)?, - }) - })?; - - let mut by_bucket: HashMap = HashMap::new(); - let mut tool_totals: HashMap = HashMap::new(); - let mut wake_totals: HashMap = HashMap::new(); - let mut result_totals: HashMap = HashMap::new(); - let mut all_durations: Vec = Vec::new(); - let mut turn_count: u64 = 0; - - for r in rows { - let r = r?; - turn_count += 1; - let bucket_ts = (r.started_at / bucket_secs) * bucket_secs; - let acc = by_bucket.entry(bucket_ts).or_default(); - acc.turn_count += 1; - acc.durations.push(r.duration_ms.max(0)); - acc.input_tokens = acc.input_tokens.saturating_add(r.input_tokens); - acc.output_tokens = acc.output_tokens.saturating_add(r.output_tokens); - acc.cache_read_input_tokens = acc - .cache_read_input_tokens - .saturating_add(r.cache_read_input_tokens); - acc.cache_creation_input_tokens = acc - .cache_creation_input_tokens - .saturating_add(r.cache_creation_input_tokens); - acc.ctx_sum = acc.ctx_sum.saturating_add(r.last_input_tokens); - acc.ctx_max = acc.ctx_max.max(r.last_input_tokens); - - all_durations.push(r.duration_ms.max(0)); - *wake_totals.entry(r.wake_from).or_insert(0) += 1; - *result_totals.entry(r.result_kind).or_insert(0) += 1; - - if let Some(json) = r.tool_breakdown_json - && let Ok(map) = serde_json::from_str::>(&json) - { - for (k, v) in map { - *tool_totals.entry(k).or_insert(0) += v; - } - } - } - - let buckets = fill_buckets(from, now, bucket_secs, &by_bucket); - let duration_summary = summarize_durations(&mut all_durations); - - Ok(Snapshot { - window: window.label(), - bucket_seconds: bucket_secs, - now, - from, - turn_count, - buckets, - tool_breakdown: top_n(tool_totals, 10), - wake_mix: top_n(wake_totals, 20), - result_mix: top_n(result_totals, 20), - duration_summary, - }) -} - -struct Row { - started_at: i64, - duration_ms: i64, - input_tokens: u64, - output_tokens: u64, - cache_read_input_tokens: u64, - cache_creation_input_tokens: u64, - last_input_tokens: u64, - tool_breakdown_json: Option, - wake_from: String, - result_kind: String, -} - -#[derive(Default)] -struct BucketAcc { - turn_count: u64, - durations: Vec, - input_tokens: u64, - output_tokens: u64, - cache_read_input_tokens: u64, - cache_creation_input_tokens: u64, - ctx_sum: u64, - ctx_max: u64, -} - -fn fill_buckets( - from: i64, - now: i64, - bucket_secs: i64, - by_bucket: &HashMap, -) -> Vec { - let start = (from / bucket_secs) * bucket_secs; - let mut out = Vec::new(); - let mut ts = start; - while ts <= now { - let bucket = if let Some(acc) = by_bucket.get(&ts) { - let mut sorted = acc.durations.clone(); - sorted.sort_unstable(); - let avg = if sorted.is_empty() { - 0.0 - } else { - #[allow(clippy::cast_precision_loss)] - let sum_f = sorted.iter().sum::() as f64; - #[allow(clippy::cast_precision_loss)] - let len_f = sorted.len() as f64; - sum_f / len_f - }; - let p50 = percentile(&sorted, 50); - let p95 = percentile(&sorted, 95); - let avg_ctx = if acc.turn_count == 0 { - 0.0 - } else { - #[allow(clippy::cast_precision_loss)] - let sum_f = acc.ctx_sum as f64; - #[allow(clippy::cast_precision_loss)] - let cnt_f = acc.turn_count as f64; - sum_f / cnt_f - }; - Bucket { - ts, - turn_count: acc.turn_count, - avg_duration_ms: avg, - p50_duration_ms: p50, - p95_duration_ms: p95, - input_tokens: acc.input_tokens, - output_tokens: acc.output_tokens, - cache_read_input_tokens: acc.cache_read_input_tokens, - cache_creation_input_tokens: acc.cache_creation_input_tokens, - avg_ctx_tokens: avg_ctx, - max_ctx_tokens: acc.ctx_max, - } - } else { - Bucket { - ts, - turn_count: 0, - avg_duration_ms: 0.0, - p50_duration_ms: 0.0, - p95_duration_ms: 0.0, - input_tokens: 0, - output_tokens: 0, - cache_read_input_tokens: 0, - cache_creation_input_tokens: 0, - avg_ctx_tokens: 0.0, - max_ctx_tokens: 0, - } - }; - out.push(bucket); - ts += bucket_secs; - } - out -} - -fn summarize_durations(all: &mut [i64]) -> DurationSummary { - if all.is_empty() { - return DurationSummary::default(); - } - all.sort_unstable(); - #[allow(clippy::cast_precision_loss)] - let sum_f = all.iter().sum::() as f64; - #[allow(clippy::cast_precision_loss)] - let len_f = all.len() as f64; - DurationSummary { - avg_ms: sum_f / len_f, - p50_ms: percentile(all, 50), - p95_ms: percentile(all, 95), - } -} - -#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation, clippy::cast_sign_loss)] -fn percentile(sorted: &[i64], pct: u8) -> f64 { - if sorted.is_empty() { - return 0.0; - } - if sorted.len() == 1 { - return sorted[0] as f64; - } - // Nearest-rank, clamped. - let rank = ((f64::from(pct) / 100.0) * (sorted.len() as f64 - 1.0)).round() as usize; - sorted[rank.min(sorted.len() - 1)] as f64 -} - -fn top_n(map: HashMap, n: usize) -> Vec { - let mut v: Vec = map - .into_iter() - .map(|(key, count)| KeyCount { key, count }) - .collect(); - v.sort_unstable_by(|a, b| b.count.cmp(&a.count).then_with(|| a.key.cmp(&b.key))); - v.truncate(n); - v -} - -fn u64_from_i64(v: i64) -> u64 { - u64::try_from(v).unwrap_or(0) -} - -fn now_secs() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)) -} - -#[cfg(test)] -mod tests { - use super::*; - use rusqlite::params; - use std::sync::atomic::{AtomicU32, Ordering}; - - static SEQ: AtomicU32 = AtomicU32::new(0); - - fn tmp_db() -> PathBuf { - let n = SEQ.fetch_add(1, Ordering::SeqCst); - let pid = std::process::id(); - std::env::temp_dir().join(format!("hyperhive-stats-test-{pid}-{n}.sqlite")) - } - - fn seed_db(path: &Path, rows: &[(i64, i64, &str, &str, &str)]) { - let conn = Connection::open(path).unwrap(); - conn.execute_batch( - "CREATE TABLE turn_stats ( - id INTEGER PRIMARY KEY, - started_at INTEGER NOT NULL, - ended_at INTEGER NOT NULL, - duration_ms INTEGER NOT NULL, - model TEXT NOT NULL, - wake_from TEXT NOT NULL, - input_tokens INTEGER NOT NULL DEFAULT 0, - output_tokens INTEGER NOT NULL DEFAULT 0, - cache_read_input_tokens INTEGER NOT NULL DEFAULT 0, - cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0, - last_input_tokens INTEGER NOT NULL DEFAULT 0, - last_output_tokens INTEGER NOT NULL DEFAULT 0, - last_cache_read_input_tokens INTEGER NOT NULL DEFAULT 0, - last_cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0, - tool_call_count INTEGER NOT NULL DEFAULT 0, - tool_call_breakdown_json TEXT, - open_threads_count INTEGER, - open_reminders_count INTEGER, - result_kind TEXT NOT NULL, - note TEXT - );", - ) - .unwrap(); - for (started, dur, wake, result, tools_json) in rows { - conn.execute( - "INSERT INTO turn_stats - (started_at, ended_at, duration_ms, model, wake_from, - last_input_tokens, tool_call_breakdown_json, result_kind) - VALUES (?1, ?2, ?3, 'm', ?4, 1000, ?5, ?6)", - params![started, started + dur / 1000, dur, wake, tools_json, result], - ) - .unwrap(); - } - } - - #[test] - fn snapshot_aggregates_rows() { - let db = tmp_db(); - let _ = std::fs::remove_file(&db); - let now = now_secs(); - seed_db( - &db, - &[ - (now - 600, 5_000, "recv", "ok", r#"{"Read":2,"Bash":1}"#), - (now - 300, 10_000, "recv", "ok", r#"{"Read":3}"#), - (now - 100, 20_000, "operator", "failed", "{}"), - ], - ); - let s = snapshot(&db, Window::Day).unwrap(); - assert_eq!(s.turn_count, 3); - assert_eq!(s.window, "24h"); - assert_eq!(s.bucket_seconds, 3600); - let tool_map: HashMap<_, _> = s - .tool_breakdown - .iter() - .map(|kc| (kc.key.clone(), kc.count)) - .collect(); - assert_eq!(tool_map.get("Read").copied(), Some(5)); - assert_eq!(tool_map.get("Bash").copied(), Some(1)); - let wake_map: HashMap<_, _> = s - .wake_mix - .iter() - .map(|kc| (kc.key.clone(), kc.count)) - .collect(); - assert_eq!(wake_map.get("recv").copied(), Some(2)); - assert_eq!(wake_map.get("operator").copied(), Some(1)); - let result_map: HashMap<_, _> = s - .result_mix - .iter() - .map(|kc| (kc.key.clone(), kc.count)) - .collect(); - assert_eq!(result_map.get("ok").copied(), Some(2)); - assert_eq!(result_map.get("failed").copied(), Some(1)); - // Durations: [5000, 10000, 20000] → avg ≈ 11666.67, p50 = 10000, p95 ~ 20000 - assert!((s.duration_summary.avg_ms - 11_666.666_666_666_666).abs() < 1.0); - assert!((s.duration_summary.p50_ms - 10_000.0).abs() < 1.0); - assert!((s.duration_summary.p95_ms - 20_000.0).abs() < 1.0); - } - - #[test] - fn empty_window_still_paints_buckets() { - let db = tmp_db(); - let _ = std::fs::remove_file(&db); - seed_db(&db, &[]); - let s = snapshot(&db, Window::Day).unwrap(); - assert_eq!(s.turn_count, 0); - // 24h / 1h buckets = ~24-25 buckets covering the window. - assert!(s.buckets.len() >= 24); - assert!(s.buckets.iter().all(|b| b.turn_count == 0)); - } - - #[test] - fn week_uses_daily_buckets() { - let db = tmp_db(); - let _ = std::fs::remove_file(&db); - seed_db(&db, &[]); - let s = snapshot(&db, Window::Week).unwrap(); - assert_eq!(s.window, "7d"); - assert_eq!(s.bucket_seconds, 86_400); - assert!(s.buckets.len() >= 7); - } -} diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index 45a4a604..caca25ee 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -107,9 +107,6 @@ pub async fn serve( .route("/api/model", post(post_set_model)) .route("/api/new-session", post(post_new_session)) .route("/api/loose-ends", get(api_loose_ends)) - .route("/stats", get(serve_stats)) - .route("/static/stats.js", get(serve_stats_js)) - .route("/api/stats", get(api_stats)) .with_state(state); let addr = SocketAddr::from(([0, 0, 0, 0], port)); let listener = bind_with_retry(addr, "web UI").await?; @@ -201,33 +198,6 @@ async fn serve_marked_js() -> impl IntoResponse { ) } -async fn serve_stats() -> impl IntoResponse { - ( - [("content-type", "text/html; charset=utf-8")], - include_str!("../assets/stats.html"), - ) -} - -async fn serve_stats_js() -> impl IntoResponse { - ( - [("content-type", "application/javascript")], - include_str!("../assets/stats.js"), - ) -} - -#[derive(Deserialize)] -struct StatsQuery { - window: Option, -} - -async fn api_stats( - State(_state): State, - axum::extract::Query(q): axum::extract::Query, -) -> axum::Json { - let window = crate::stats::Window::parse(q.window.as_deref().unwrap_or("24h")); - axum::Json(crate::stats::snapshot_default(window)) -} - #[derive(Serialize)] struct StateSnapshot { /// Bus seq at the moment this snapshot was assembled. Clients dedupe diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index 3c8f4709..0c079f78 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -487,13 +487,6 @@ el('a', { class: 'name', href: url, target: '_blank', rel: 'noopener' }, c.name), el('span', { class: c.is_manager ? 'role role-m1nd' : 'role role-ag3nt' }, c.is_manager ? 'm1nd' : 'ag3nt'), - el('a', { - class: 'meta', - href: url + 'stats', - target: '_blank', - rel: 'noopener', - title: 'per-agent stats page (turn rate, durations, tokens, tool mix)', - }, '📊'), ); if (pending) { head.append(el('span', { class: 'pending-state' },