feat(stats): hive-wide "favorite tools" rollup on ST4TS

Swarm-level companion to the per-agent favorite-tools doughnut (#1433).
Aggregates each agent's bash_commands(ts, head) table (written by
hive-bash-mcp) across the whole hive and surfaces the top-10 most-run
command heads on the dashboard ST4TS tab, alongside the existing model
mix.

- hive_stats.rs: AgentAgg gains a `bash` head→count map, filled by a new
  guarded `read_bash_heads()` that reuses read_agent's read-only
  connection. A missing `bash_commands` table (capture hasn't run for
  that agent) or any read error yields an empty map — isolated from
  read_agent's error path so it never drops an agent from the rollup.
  HiveStats gains `bash_mix: Vec<KeyCount>` (busiest-first, top 10).
  Unit tests cover the per-head tally + window cutoff and the
  missing-table degrade-to-empty path (in-memory sqlite).
- dashboard: a "favorite tools (bash commands across the swarm)" CSS-bar
  list on the ST4TS pane, mirroring the model-mix bars. Header + list
  stay hidden until bash_mix has data, so a fresh hive shows no empty
  block. (Dashboard ships no chart lib — bars, not a doughnut.)
- docs: dashboard.md ST4TS section documents the new rollup.

Closes #1449. Inert until the hive-bash-mcp capture (#1448, merged) has
recorded data across agents.
This commit is contained in:
iris 2026-06-06 07:47:43 +02:00 committed by mara
commit d9d2a52221
4 changed files with 130 additions and 0 deletions

View file

@ -194,6 +194,11 @@ pub struct HiveStats {
pub agents: Vec<AgentRollup>,
/// Turns per model across the whole swarm, busiest first.
pub model_mix: Vec<KeyCount>,
/// Most-run normalised bash-command heads ("favorite tools") across
/// the whole swarm, busiest first, capped to 10. Empty until the
/// hive-bash-mcp capture has recorded `bash_commands` rows on at
/// least one active agent.
pub bash_mix: Vec<KeyCount>,
}
#[derive(Default)]
@ -205,6 +210,10 @@ struct AgentAgg {
cache_creation: u64,
cost: f64,
models: HashMap<String, u64>,
/// Normalised bash-command head → invocation count, from the agent's
/// `bash_commands` table (written by hive-bash-mcp). Empty when that
/// capture hasn't run for this agent (table absent).
bash: HashMap<String, u64>,
}
fn now_secs() -> i64 {
@ -268,9 +277,38 @@ fn read_agent(path: &Path, from: i64, prices: &PriceTable) -> rusqlite::Result<A
}
*agg.models.entry(model).or_insert(0) += 1;
}
agg.bash = read_bash_heads(&conn, from);
Ok(agg)
}
/// Tally normalised bash-command heads from the agent's `bash_commands`
/// table (`ts INTEGER, head TEXT`, written by hive-bash-mcp) over
/// `[from, now]`. Best-effort + isolated from `read_agent`'s error path:
/// a missing table (capture hasn't run for this agent) or any read error
/// yields an empty map rather than propagating, so the favorite-tools
/// rollup simply omits that agent and never fails the whole endpoint.
/// `ts` is unix seconds, matching the `from` cutoff.
fn read_bash_heads(conn: &Connection, from: i64) -> HashMap<String, u64> {
let mut out: HashMap<String, u64> = HashMap::new();
let Ok(mut stmt) =
conn.prepare("SELECT head, COUNT(*) FROM bash_commands WHERE ts >= ?1 GROUP BY head")
else {
return out;
};
let Ok(rows) = stmt.query_map([from], |row| {
Ok((
row.get::<_, String>(0)?,
u64_from_i64(row.get::<_, i64>(1)?),
))
}) else {
return out;
};
for (head, count) in rows.flatten() {
*out.entry(head).or_insert(0) += count;
}
out
}
/// Build the swarm-wide rollup. Best-effort: a missing or unreadable
/// per-agent db is skipped (logged), never fatal.
#[must_use]
@ -280,6 +318,7 @@ pub fn hive_snapshot(window: Window, prices: &PriceTable) -> HiveStats {
let mut agents: Vec<AgentRollup> = Vec::new();
let mut model_mix: HashMap<String, u64> = HashMap::new();
let mut bash_mix: HashMap<String, u64> = HashMap::new();
let mut total_turns = 0u64;
let mut total_input = 0u64;
let mut total_output = 0u64;
@ -313,6 +352,9 @@ pub fn hive_snapshot(window: Window, prices: &PriceTable) -> HiveStats {
for (m, c) in &agg.models {
*model_mix.entry(m.clone()).or_insert(0) += c;
}
for (h, c) in &agg.bash {
*bash_mix.entry(h.clone()).or_insert(0) += c;
}
agents.push(AgentRollup {
name,
turns: agg.turns,
@ -333,6 +375,14 @@ pub fn hive_snapshot(window: Window, prices: &PriceTable) -> HiveStats {
.collect();
model_mix.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.key.cmp(&b.key)));
// Busiest commands first, capped to a top-10 "favorite tools" list.
let mut bash_mix: Vec<KeyCount> = bash_mix
.into_iter()
.map(|(key, count)| KeyCount { key, count })
.collect();
bash_mix.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.key.cmp(&b.key)));
bash_mix.truncate(10);
HiveStats {
window: window.label(),
from,
@ -346,5 +396,45 @@ pub fn hive_snapshot(window: Window, prices: &PriceTable) -> HiveStats {
est_cost_usd: total_cost,
agents,
model_mix,
bash_mix,
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `read_bash_heads` tallies per-head counts over the window and is
/// isolated from a missing table (returns empty, never errors).
#[test]
fn bash_heads_tally_and_window() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch("CREATE TABLE bash_commands (ts INTEGER NOT NULL, head TEXT NOT NULL);")
.unwrap();
let now = now_secs();
for (ts, head) in [
(now - 100, "cargo"),
(now - 200, "cargo"),
(now - 300, "git"),
(now - 10_000, "rg"), // outside a 1h-ish cutoff below
] {
conn.execute(
"INSERT INTO bash_commands (ts, head) VALUES (?1, ?2)",
rusqlite::params![ts, head],
)
.unwrap();
}
let heads = read_bash_heads(&conn, now - 3600);
assert_eq!(heads.get("cargo").copied(), Some(2));
assert_eq!(heads.get("git").copied(), Some(1));
assert_eq!(heads.get("rg").copied(), None); // window cutoff excludes it
}
/// A missing `bash_commands` table degrades to an empty tally rather
/// than erroring — the pre-capture window on a fresh agent.
#[test]
fn bash_heads_missing_table_is_empty() {
let conn = Connection::open_in_memory().unwrap();
assert!(read_bash_heads(&conn, 0).is_empty());
}
}