213 lines
7.1 KiB
Rust
213 lines
7.1 KiB
Rust
//! 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 <repo> && <tool> …`, 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<String> {
|
|
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<String> {
|
|
// 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"));
|
|
}
|
|
}
|