From 1569d55f7880c480e0acae033a5c067905ce53e2 Mon Sep 17 00:00:00 2001 From: damocles Date: Sat, 6 Jun 2026 00:18:55 +0200 Subject: [PATCH] feat: capture normalised bash command heads for the favorite-tools stat --- Cargo.lock | 1 + docs/persistence.md | 10 ++ hive-bash-mcp/Cargo.toml | 1 + hive-bash-mcp/src/lib.rs | 1 + hive-bash-mcp/src/paths.rs | 39 ++++--- hive-bash-mcp/src/runner.rs | 4 + hive-bash-mcp/src/stats.rs | 213 ++++++++++++++++++++++++++++++++++++ 7 files changed, 252 insertions(+), 17 deletions(-) create mode 100644 hive-bash-mcp/src/stats.rs diff --git a/Cargo.lock b/Cargo.lock index 128cacf4..105a7fd9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1258,6 +1258,7 @@ dependencies = [ "hive-sh4re", "libc", "rmcp", + "rusqlite", "schemars", "serde", "serde_json", diff --git a/docs/persistence.md b/docs/persistence.md index ded69289..a7733e97 100644 --- a/docs/persistence.md +++ b/docs/persistence.md @@ -101,6 +101,16 @@ Bin-loop helpers `build_row` + `record` land each row at `turn_end`; writes are best-effort, a sqlite hiccup logs + lets the turn loop continue. +A sibling `bash_commands(ts INTEGER, head TEXT)` table in the same +file is written by the `hive-bash-mcp` daemon (not the harness): one +row per executed bash task recording the normalised command head - +the basename of the first real command, looking past `cd repo &&` +prefixes, env-assignments, and prefix-runners like `sudo`/`env`. It +backs the "favorite tools" view on the /stats page (aggregated +host-side). Best-effort and created on first write +(`CREATE TABLE IF NOT EXISTS`), so it's simply absent until a bash +task runs. + `hive-c0re::stats_vacuum` runs hourly and deletes rows older than 90 days (`started_at < cutoff`). Age-only sweep, same pattern as `events_vacuum`. diff --git a/hive-bash-mcp/Cargo.toml b/hive-bash-mcp/Cargo.toml index ab7e09fd..68d470a0 100644 --- a/hive-bash-mcp/Cargo.toml +++ b/hive-bash-mcp/Cargo.toml @@ -11,6 +11,7 @@ anyhow.workspace = true hive-sh4re.workspace = true libc.workspace = true rmcp.workspace = true +rusqlite.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/hive-bash-mcp/src/lib.rs b/hive-bash-mcp/src/lib.rs index 4ca4eb93..ce6d4cf8 100644 --- a/hive-bash-mcp/src/lib.rs +++ b/hive-bash-mcp/src/lib.rs @@ -8,3 +8,4 @@ pub mod paths; pub mod protocol; pub mod runner; pub mod socket; +pub mod stats; diff --git a/hive-bash-mcp/src/paths.rs b/hive-bash-mcp/src/paths.rs index 87ff125e..178838a1 100644 --- a/hive-bash-mcp/src/paths.rs +++ b/hive-bash-mcp/src/paths.rs @@ -20,12 +20,13 @@ pub fn daemon_socket() -> PathBuf { .map_or_else(|| PathBuf::from(DEFAULT_DAEMON_SOCKET), PathBuf::from) } -/// Base directory for task files. Uses `HYPERHIVE_HARNESS_DIR` if set -/// (injected by hive-c0re meta flake after the harness/state split); -/// falls back to a sibling of the state dir for pre-split deployments. +/// Base harness directory. Uses `HYPERHIVE_HARNESS_DIR` if set (injected +/// by the hive-c0re meta flake after the harness/state split); falls +/// back to a `harness/` sibling of the state dir for pre-split +/// deployments. Shared by every per-agent harness artifact path below. #[must_use] -pub fn tasks_dir() -> PathBuf { - let base = if let Some(p) = std::env::var_os("HYPERHIVE_HARNESS_DIR") { +pub fn harness_dir() -> PathBuf { + if let Some(p) = std::env::var_os("HYPERHIVE_HARNESS_DIR") { PathBuf::from(p) } else { // Pre-split fallback: derive harness/ as a sibling of state/. @@ -34,8 +35,21 @@ pub fn tasks_dir() -> PathBuf { state_path .parent() .map_or_else(|| PathBuf::from(state), |p| p.join("harness")) - }; - base.join("bash-tasks") + } +} + +/// Base directory for task files. +#[must_use] +pub fn tasks_dir() -> PathBuf { + harness_dir().join("bash-tasks") +} + +/// Per-agent turn-stats sqlite — the harness writes one row per claude +/// turn here; the runner appends normalised bash-command heads to its +/// `bash_commands` table for the /stats "favorite tools" view. +#[must_use] +pub fn turn_stats_db() -> PathBuf { + harness_dir().join("hyperhive-turn-stats.sqlite") } /// Hyperhive control socket — the daemon writes wake signals here so @@ -56,16 +70,7 @@ pub fn hyperhive_socket() -> PathBuf { /// code across crates — keep them in sync if the fallback logic changes. #[must_use] pub fn mcp_loose_ends_dir() -> PathBuf { - let base = if let Some(p) = std::env::var_os("HYPERHIVE_HARNESS_DIR") { - PathBuf::from(p) - } else { - let state = std::env::var("HYPERHIVE_STATE_DIR").unwrap_or_default(); - let state_path = PathBuf::from(&state); - state_path - .parent() - .map_or_else(|| PathBuf::from(state), |p| p.join("harness")) - }; - base.join("mcp-loose-ends") + harness_dir().join("mcp-loose-ends") } /// Full path for a task's JSON metadata file. diff --git a/hive-bash-mcp/src/runner.rs b/hive-bash-mcp/src/runner.rs index 48c4c5bf..bf52b66d 100644 --- a/hive-bash-mcp/src/runner.rs +++ b/hive-bash-mcp/src/runner.rs @@ -303,6 +303,10 @@ async fn run_task(mut task: TaskFile, socket: &Path) { tracing::warn!(id = %id, error = ?e, "bash_runner: write running state failed"); } refresh_loose_ends(); + // Best-effort: tally the normalised command head for the /stats + // "favorite tools" view. Counted once per execution, regardless of + // exit status. Never fails the task. + crate::stats::record_command(&task.cmd); let out_path = paths::task_out(&id); let err_path = paths::task_err(&id); diff --git a/hive-bash-mcp/src/stats.rs b/hive-bash-mcp/src/stats.rs new file mode 100644 index 00000000..0d158fe9 --- /dev/null +++ b/hive-bash-mcp/src/stats.rs @@ -0,0 +1,213 @@ +//! Capture of normalised shell-command "heads" per bash task into the +//! per-agent turn-stats sqlite (`bash_commands` table), backing the +//! "favorite tools" doughnut on the /stats page. +//! +//! Best-effort: a missing db, a lock, or a parse miss is logged and +//! swallowed — recording a stat never affects task execution. The +//! read/aggregation side lives host-side in `hive-c0re` and reads the +//! same `bash_commands(ts INTEGER, head TEXT)` rows. +//! +//! Normalisation is deliberately rough (quoting-naive) — it just needs +//! to name the tool an operator would recognise. The dominant agent +//! pattern is `cd && …`, so we look past leading +//! `cd …`-style builtins and prefix-runners like `sudo`/`env` to find +//! the first real command, then take its basename. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use rusqlite::{Connection, params}; + +use crate::paths; + +/// Builtins whose presence as a segment head means the *real* command +/// is in a later segment (e.g. the `cargo` in `cd repo && cargo build`), +/// so we skip the whole segment. +const SEGMENT_BUILTINS: &[&str] = &[ + "cd", "pushd", "popd", "export", "set", "unset", "source", ".", ":", "true", "false", +]; + +/// Prefix-runners that wrap the command following them in the SAME +/// segment (e.g. the `cargo` in `env FOO=bar cargo build`). We skip the +/// runner plus any following flags / env-assignments and unwrap to the +/// wrapped command. +const PREFIX_RUNNERS: &[&str] = &[ + "env", "exec", "sudo", "doas", "nice", "ionice", "stdbuf", "time", "command", "nohup", +]; + +/// Does `tok` look like a `VAR=value` shell assignment? +fn is_assignment(tok: &str) -> bool { + match tok.split_once('=') { + Some((name, _)) if !name.is_empty() => name.chars().enumerate().all(|(i, c)| { + c == '_' + || if i == 0 { + c.is_ascii_alphabetic() + } else { + c.is_ascii_alphanumeric() + } + }), + _ => false, + } +} + +/// Strip a path prefix: `/nix/store/…/bin/git` -> `git`. +fn basename(tok: &str) -> &str { + tok.rsplit('/').next().unwrap_or(tok) +} + +/// Resolve the head command of a single segment (no sequencing +/// operators inside). `None` for an empty or builtin-only segment. +fn segment_head(seg: &str) -> Option { + let mut tokens = seg.split_whitespace().peekable(); + // Skip leading env-assignments (`FOO=bar cmd`). + while tokens.peek().is_some_and(|t| is_assignment(t)) { + tokens.next(); + } + let mut head = tokens.next()?; + // Unwrap prefix-runners: skip the runner, its option flags, and any + // further env-assignments, then take the wrapped command. Bounded + // loop guards against a pathological `sudo sudo sudo …`. + let mut guard = 0; + while PREFIX_RUNNERS.contains(&basename(head)) && guard < 8 { + guard += 1; + while tokens.peek().is_some_and(|t| t.starts_with('-')) { + tokens.next(); + } + while tokens.peek().is_some_and(|t| is_assignment(t)) { + tokens.next(); + } + match tokens.next() { + Some(next) => head = next, + None => return None, // bare `sudo` with nothing after it + } + } + let base = basename(head); + if base.is_empty() || SEGMENT_BUILTINS.contains(&base) { + return None; + } + Some(base.to_owned()) +} + +/// Extract a normalised tool name from a shell command line: the +/// basename of the first real command, looking past `cd repo &&` +/// prefixes, env-assignments, and prefix-runners like `sudo`/`env`. +/// `None` when no real command can be identified. +#[must_use] +pub fn command_head(cmd: &str) -> Option { + // Split into segments on shell sequencing operators. Naive: doesn't + // honour quotes/escapes — acceptable for a best-effort tally. Order + // matters: `||` before `|` so the former isn't shredded by the latter. + let mut segments: Vec<&str> = vec![cmd]; + for sep in ["&&", "||", ";", "|", "\n"] { + segments = segments.into_iter().flat_map(|s| s.split(sep)).collect(); + } + segments.into_iter().find_map(segment_head) +} + +/// Record one bash task's normalised command head. Best-effort: any +/// error (no db yet, locked, unparseable command) is logged at debug +/// and swallowed. +pub fn record_command(cmd: &str) { + let Some(head) = command_head(cmd) else { + return; + }; + if let Err(e) = record_head(&head) { + tracing::debug!(error = ?e, head = %head, "bash stats: record failed"); + } +} + +fn record_head(head: &str) -> rusqlite::Result<()> { + let conn = Connection::open(paths::turn_stats_db())?; + // The harness writes turn rows to this same db (rollback-journal, + // not WAL) and sibling bash tasks may write concurrently — wait out + // a transient lock rather than dropping the stat on SQLITE_BUSY. + conn.busy_timeout(std::time::Duration::from_secs(2))?; + conn.execute( + "CREATE TABLE IF NOT EXISTS bash_commands (ts INTEGER NOT NULL, head TEXT NOT NULL)", + [], + )?; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .cast_signed(); + conn.execute( + "INSERT INTO bash_commands (ts, head) VALUES (?1, ?2)", + params![now, head], + )?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn skips_leading_cd_to_the_real_tool() { + assert_eq!( + command_head("cd /repo && cargo build").as_deref(), + Some("cargo") + ); + assert_eq!( + command_head("cd /a/b && export X=1; hive-forge view 1").as_deref(), + Some("hive-forge") + ); + } + + #[test] + fn strips_store_path_to_basename() { + assert_eq!( + command_head("/nix/store/abc123/bin/git status").as_deref(), + Some("git") + ); + assert_eq!( + command_head("VAR=1 ./script.sh").as_deref(), + Some("script.sh") + ); + } + + #[test] + fn skips_leading_env_assignments() { + assert_eq!( + command_head("FOO=bar BAZ=qux make").as_deref(), + Some("make") + ); + } + + #[test] + fn unwraps_prefix_runners() { + assert_eq!( + command_head("sudo systemctl restart nginx").as_deref(), + Some("systemctl") + ); + assert_eq!( + command_head("env RUST_LOG=debug cargo test").as_deref(), + Some("cargo") + ); + assert_eq!(command_head("time nix build").as_deref(), Some("nix")); + } + + #[test] + fn first_command_of_a_pipeline() { + assert_eq!( + command_head("git log --oneline | head -5").as_deref(), + Some("git") + ); + } + + #[test] + fn none_for_empty_or_builtin_only() { + assert_eq!(command_head(""), None); + assert_eq!(command_head(" "), None); + assert_eq!(command_head("cd /only/dir"), None); + assert_eq!(command_head("sudo"), None); + } + + #[test] + fn assignment_detector() { + assert!(is_assignment("FOO=bar")); + assert!(is_assignment("RUST_LOG=debug")); + assert!(!is_assignment("--flag")); + assert!(!is_assignment("=leading")); + assert!(!is_assignment("git")); + } +}