feat(stats): surface "favorite tools" (most-run bash commands) on /stats

The surfacing half of the per-agent favorite-tools stat. Reads a
`bash_commands(ts INTEGER NOT NULL, head TEXT NOT NULL)` table from the
per-agent turn-stats.sqlite — one row per bash task, written by
hive-bash-mcp (the capture half, landing separately) — and rolls up the
top-10 command heads for a doughnut, mirroring the existing
tool_breakdown.

- stats.rs: new `Snapshot.bash_breakdown` + `read_bash_breakdown()`.
  The read is guarded: a missing `bash_commands` table (capture hasn't
  shipped / agent hasn't run a bash task) maps to an empty list, never
  an error — the snapshot degrades gracefully. Unit tests cover both
  the absent-table and populated cases (incl. window cutoff + ordering).
- frontend: a "favorite tools (bash)" doughnut card on the agent /stats
  page, kept hidden until bash_breakdown has data so it never shows a
  permanently-empty doughnut while capture is pending.

Part of #1433 (does not close it — pairs with the hive-bash-mcp capture
half). Inert until the capture lands; merge order with it is irrelevant.
This commit is contained in:
iris 2026-06-06 00:08:55 +02:00 committed by mara
commit a5914274ba
3 changed files with 117 additions and 0 deletions

View file

@ -33,6 +33,10 @@
<div class="stats-card wide"><h3>token cost per bucket (sum across inferences)</h3><div class="chart-wrap"><canvas id="chart-cost"></canvas></div></div>
<div class="stats-card wide"><h3>turns by model per bucket — model drives token cost</h3><div class="chart-wrap"><canvas id="chart-model"></canvas></div></div>
<div class="stats-card"><h3>top tools</h3><div class="chart-wrap"><canvas id="chart-tools"></canvas></div></div>
<!-- "favorite tools": most-run shell commands. Hidden until the
bash_commands capture (hive-bash-mcp) has recorded data, so the
card never shows a permanently-empty doughnut. -->
<div class="stats-card" id="card-bash" hidden><h3>favorite tools (bash)</h3><div class="chart-wrap"><canvas id="chart-bash"></canvas></div></div>
<div class="stats-card"><h3>wake source mix</h3><div class="chart-wrap"><canvas id="chart-wake"></canvas></div></div>
<div class="stats-card"><h3>result mix</h3><div class="chart-wrap"><canvas id="chart-result"></canvas></div></div>
<div class="stats-card wide"><h3>result trend per bucket — errors / rate-limits / compactions over time</h3><div class="chart-wrap"><canvas id="chart-result-trend"></canvas></div></div>

View file

@ -340,8 +340,26 @@ window.Chart = Chart;
});
}
// "favorite tools" doughnut: most-run shell commands. The capture
// (hive-bash-mcp -> bash_commands table) lands separately, so until
// there's data we hide the whole card rather than show an empty
// doughnut. Runs independently of turn_count (a bash task is tied to
// a turn, but we don't want to couple the two reads).
function renderBashCard(s) {
const card = document.getElementById('card-bash');
const items = s.bash_breakdown || [];
if (!items.length) {
if (card) card.hidden = true;
destroy('chart-bash');
return;
}
if (card) card.hidden = false;
renderKeyCount('chart-bash', items, 'no bash commands');
}
function render(s) {
renderSummary(s);
renderBashCard(s);
if (s.turn_count === 0) {
paintEmpty('chart-turns', 'no turns in window');
paintEmpty('chart-duration', 'no turns in window');

View file

@ -90,6 +90,13 @@ pub struct Snapshot {
pub buckets: Vec<Bucket>,
/// Top tools by call count across the window. Capped to 10.
pub tool_breakdown: Vec<KeyCount>,
/// Top shell commands ("favorite tools") by invocation count across
/// the window, capped to 10. Normalised command heads recorded per
/// bash task into the `bash_commands` table by hive-bash-mcp. Empty
/// until that capture lands (or on any agent that hasn't run a bash
/// task) — the table is created lazily by the writer, so a read
/// before the first insert returns an empty list, not an error.
pub bash_breakdown: Vec<KeyCount>,
pub wake_mix: Vec<KeyCount>,
pub result_mix: Vec<KeyCount>,
/// Distinct models seen in the window, sorted. Each bucket's
@ -176,6 +183,7 @@ fn empty_snapshot(window: Window) -> Snapshot {
turn_count: 0,
buckets,
tool_breakdown: Vec::new(),
bash_breakdown: Vec::new(),
wake_mix: Vec::new(),
result_mix: Vec::new(),
models: Vec::new(),
@ -276,6 +284,7 @@ fn snapshot(path: &Path, window: Window) -> Result<Snapshot> {
turn_count,
buckets,
tool_breakdown: top_n(tool_totals, 10),
bash_breakdown: read_bash_breakdown(&conn, from).unwrap_or_default(),
wake_mix: top_n(wake_totals, 20),
result_mix: top_n(result_totals, 20),
models,
@ -284,6 +293,36 @@ fn snapshot(path: &Path, window: Window) -> Result<Snapshot> {
})
}
/// Aggregate the top shell-command heads ("favorite tools") over
/// `[from, now]` from the `bash_commands` table — one row per bash task
/// (`ts INTEGER NOT NULL, head TEXT NOT NULL`), written by hive-bash-mcp.
///
/// Returns `Err` (which the caller maps to an empty list) when the
/// table doesn't exist yet — the writer creates it lazily on first
/// insert, so any agent that hasn't run a bash task since the capture
/// shipped simply has no table. Decoupling it this way means the read
/// side is inert-until-data and needs no schema coordination here.
fn read_bash_breakdown(conn: &Connection, from: i64) -> rusqlite::Result<Vec<KeyCount>> {
let mut stmt = conn.prepare(
"SELECT head, COUNT(*) AS n
FROM bash_commands
WHERE ts >= ?1
GROUP BY head",
)?;
let rows = stmt.query_map([from], |row| {
Ok((
row.get::<_, String>(0)?,
u64_from_i64(row.get::<_, i64>(1)?),
))
})?;
let mut totals: HashMap<String, u64> = HashMap::new();
for r in rows {
let (head, n) = r?;
*totals.entry(head).or_insert(0) += n;
}
Ok(top_n(totals, 10))
}
struct Row {
started_at: i64,
duration_ms: i64,
@ -581,4 +620,60 @@ mod tests {
assert_eq!(s.bucket_seconds, 86_400);
assert!(s.buckets.len() >= 7);
}
/// `bash_breakdown` degrades gracefully when the `bash_commands`
/// table hasn't been created yet (the capture side hasn't shipped /
/// run on this agent). A `seed_db` DB has no such table, so the read
/// must yield an empty list rather than erroring the whole snapshot.
#[test]
fn bash_breakdown_empty_without_table() {
let db = tmp_db();
let _ = std::fs::remove_file(&db);
seed_db(&db, &[(now_secs() - 100, 1000, "opus", "recv", "ok", "{}")]);
let s = snapshot(&db, Window::Day).unwrap();
assert!(s.bash_breakdown.is_empty());
}
/// With a populated `bash_commands` table, `bash_breakdown` rolls up
/// per-head counts (busiest first) and respects the window cutoff.
#[test]
fn bash_breakdown_aggregates_heads() {
let db = tmp_db();
let _ = std::fs::remove_file(&db);
seed_db(&db, &[]);
let now = now_secs();
let conn = Connection::open(&db).unwrap();
conn.execute_batch("CREATE TABLE bash_commands (ts INTEGER NOT NULL, head TEXT NOT NULL);")
.unwrap();
// 3x cargo + 2x git inside the window, 1x rg outside it.
for (ts, head) in [
(now - 100, "cargo"),
(now - 200, "cargo"),
(now - 300, "cargo"),
(now - 400, "git"),
(now - 500, "git"),
(now - (2 * 24 * 3600), "rg"), // older than the 24h window
] {
conn.execute(
"INSERT INTO bash_commands (ts, head) VALUES (?1, ?2)",
params![ts, head],
)
.unwrap();
}
let s = snapshot(&db, Window::Day).unwrap();
let map: HashMap<_, _> = s
.bash_breakdown
.iter()
.map(|kc| (kc.key.clone(), kc.count))
.collect();
assert_eq!(map.get("cargo").copied(), Some(3));
assert_eq!(map.get("git").copied(), Some(2));
// `rg` fell outside the 24h window — excluded.
assert_eq!(map.get("rg").copied(), None);
// top_n orders busiest first.
assert_eq!(
s.bash_breakdown.first().map(|kc| kc.key.as_str()),
Some("cargo")
);
}
}