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:
parent
bd0b3efd8f
commit
d9d2a52221
4 changed files with 130 additions and 0 deletions
|
|
@ -321,6 +321,11 @@ charts): ST4TS is the swarm-level rollup.
|
|||
- **Busiest agents** table — one row per agent (most turns first):
|
||||
turns, input / output / cache-read tokens, est cost.
|
||||
- **Model mix** — turns per model across the swarm, as CSS bars.
|
||||
- **Favorite tools** — most-run normalised bash-command heads across the
|
||||
swarm (top 10, as CSS bars), aggregated from each agent's
|
||||
`bash_commands` table (written by the hive-bash-mcp capture). The
|
||||
header + list stay hidden until at least one agent has recorded a
|
||||
command, so the section never shows an empty block on a fresh hive.
|
||||
|
||||
Backed by `GET /api/stats-hive?window=<w>` in `hive-c0re`
|
||||
(`hive_stats.rs`): for every name from
|
||||
|
|
|
|||
|
|
@ -289,6 +289,11 @@
|
|||
<div id="hive-stats-agents"><p class="meta">loading…</p></div>
|
||||
<h3>◇ model mix (turns across the swarm)</h3>
|
||||
<div id="hive-stats-models"></div>
|
||||
<!-- "favorite tools": most-run bash commands across the swarm.
|
||||
Header + list hidden until the hive-bash-mcp capture has
|
||||
recorded data, so the section never shows an empty block. -->
|
||||
<h3 id="hive-stats-bash-h" hidden>◇ favorite tools (bash commands across the swarm)</h3>
|
||||
<div id="hive-stats-bash" hidden></div>
|
||||
</section>
|
||||
|
||||
<!-- FL0W: lives on its own page now (`/flow.html`). The
|
||||
|
|
|
|||
|
|
@ -4216,6 +4216,36 @@ window.marked = marked;
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "favorite tools": most-run bash commands across the swarm. Hidden
|
||||
// (header + list) until the capture has recorded data, so the
|
||||
// section never shows an empty block while capture is pre-data.
|
||||
const bh = $('hive-stats-bash');
|
||||
const bhH = $('hive-stats-bash-h');
|
||||
if (bh) {
|
||||
const bmix = s.bash_mix || [];
|
||||
if (!bmix.length) {
|
||||
bh.replaceChildren();
|
||||
bh.hidden = true;
|
||||
if (bhH) bhH.hidden = true;
|
||||
} else {
|
||||
bh.hidden = false;
|
||||
if (bhH) bhH.hidden = false;
|
||||
bh.replaceChildren();
|
||||
const max = bmix[0].count || 1;
|
||||
for (const kc of bmix) {
|
||||
const row = document.createElement('div'); row.className = 'hive-stats-bar';
|
||||
const lbl = document.createElement('span'); lbl.className = 'lbl'; lbl.textContent = kc.key;
|
||||
const track = document.createElement('span'); track.className = 'track';
|
||||
const fill = document.createElement('span'); fill.className = 'fill';
|
||||
fill.style.width = Math.max(2, Math.round(100 * kc.count / max)) + '%';
|
||||
track.append(fill);
|
||||
const cnt = document.createElement('span'); cnt.className = 'cnt'; cnt.textContent = hsFmtInt(kc.count);
|
||||
row.append(lbl, track, cnt);
|
||||
bh.append(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshHiveStats() {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue