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:
parent
1569d55f78
commit
a5914274ba
3 changed files with 117 additions and 0 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue