swactor/crates/runtime-dashboard/src/investigate.rs
Zachery Aaron Shores-Chmielewski e538f06f70 feat: tui, agent interface, stats hook
Runtime dashboard now features a TUI option and an interface for LLM tool use. Removed some bloat from stats collecting and replaced with a hook funciton to dump runtime stats into.
2026-02-11 22:20:33 +07:00

498 lines
17 KiB
Rust

//! Line-oriented diagnostic protocol for LLM-driven runtime investigation.
//!
//! Send text commands on stdin, receive JSON responses on stdout (one per line).
//! All human-readable diagnostics go to stderr.
//!
//! # Protocol
//!
//! ```text
//! → overview
//! ← {"ok":true,"command":"overview","data":{"workers":4,"actors":120,...}}
//!
//! → hot 5
//! ← {"ok":true,"command":"hot","data":[{"address":"a1b2...","worker_id":2,"mailbox_depth":47},..]}
//!
//! → diff 2
//! ← {"ok":true,"command":"diff","data":{"elapsed_s":2.0,"delta_messages":8432,"msg_per_sec":4216.0,...}}
//! ```
use std::io::{self, BufRead, Write};
use std::sync::Arc;
use std::time::{Duration, Instant};
use serde::Serialize;
use swactor::runtime::Runtime;
use swactor::stats::RuntimeStats;
use crate::collector::StatsCollector;
/// Run the investigate REPL. Blocks until stdin is closed or `quit` is received.
pub fn run_investigate(runtime: Arc<Runtime>, collector: Arc<StatsCollector>) -> io::Result<()> {
let stdin = io::stdin();
let mut stdout = io::stdout();
eprintln!("swactor investigate protocol ready — send `help` for commands");
for line in stdin.lock().lines() {
let line = line?;
let line = line.trim();
if line.is_empty() {
continue;
}
let parts: Vec<&str> = line.split_whitespace().collect();
let cmd = parts[0];
let args = &parts[1..];
let response = match cmd {
"help" => cmd_help(),
"overview" => cmd_overview(&runtime, &collector),
"workers" => cmd_workers(&runtime),
"worker" => cmd_worker(&runtime, &collector, args),
"actors" => cmd_actors(&runtime, &collector, args),
"actor" => cmd_actor(&runtime, &collector, args),
"hot" => cmd_hot(&runtime, &collector, args),
"phases" => cmd_phases(&runtime, args),
"diff" => cmd_diff(&runtime, &collector, args),
"quit" | "exit" => break,
_ => err_response(cmd, &format!("unknown command `{cmd}` — try `help`")),
};
stdout.write_all(response.as_bytes())?;
stdout.write_all(b"\n")?;
stdout.flush()?;
}
Ok(())
}
// ── Helpers ─────────────────────────────────────────────────────────────
fn ok_response(cmd: &str, data: impl Serialize) -> String {
serde_json::to_string(&serde_json::json!({
"ok": true,
"command": cmd,
"data": data,
}))
.unwrap_or_else(|e| err_response(cmd, &format!("serialization error: {e}")))
}
fn err_response(cmd: &str, msg: &str) -> String {
serde_json::to_string(&serde_json::json!({
"ok": false,
"command": cmd,
"error": msg,
}))
.unwrap()
}
fn format_addr(addr: &swactor::actor::ActorAddress) -> String {
format!("{addr}")
}
fn full_hex(addr: &swactor::actor::ActorAddress) -> String {
addr.0.iter().map(|b| format!("{b:02x}")).collect()
}
// ── Helpers ─────────────────────────────────────────────────────────────
fn enriched_stats(rt: &Runtime, col: &StatsCollector) -> RuntimeStats {
let mut s = rt.stats();
col.enrich(&mut s);
s
}
// ── Commands ────────────────────────────────────────────────────────────
fn cmd_help() -> String {
ok_response(
"help",
serde_json::json!({
"commands": [
{"name": "overview", "usage": "overview", "description": "Summary: worker count, actor count, total messages, mailbox depth, panics"},
{"name": "workers", "usage": "workers", "description": "Per-worker stats: actors, mailbox depth, messages, sends (local/cross/inbox), panics"},
{"name": "worker", "usage": "worker <id>", "description": "Single worker detail with tick-phase timing breakdown"},
{"name": "actors", "usage": "actors [--sort mailbox|worker|address] [--limit N] [--worker W]", "description": "List actors with optional sorting, limit, and worker filter"},
{"name": "actor", "usage": "actor <hex_prefix>", "description": "Find actor(s) whose address starts with the given hex prefix"},
{"name": "hot", "usage": "hot [N]", "description": "Top N actors by mailbox depth (default 10)"},
{"name": "phases", "usage": "phases [worker_id]", "description": "Tick-phase time breakdown (all workers or one)"},
{"name": "diff", "usage": "diff <seconds>", "description": "Collect two snapshots N seconds apart, report deltas and rates"},
{"name": "quit", "usage": "quit", "description": "Exit the investigate session"},
]
}),
)
}
fn cmd_overview(rt: &Runtime, col: &StatsCollector) -> String {
let stats = enriched_stats(rt, col);
let total_msgs: u64 = stats.workers.iter().map(|w| w.messages_processed).sum();
let total_mailbox: usize = stats.workers.iter().map(|w| w.mailbox_depth).sum();
let total_panics: u64 = stats.workers.iter().map(|w| w.panics).sum();
let total_type_mismatches: u64 = stats.workers.iter().map(|w| w.type_mismatches).sum();
let total_local: u64 = stats.workers.iter().map(|w| w.local_sends).sum();
let total_cross: u64 = stats.workers.iter().map(|w| w.cross_sends).sum();
let total_inbox: u64 = stats.workers.iter().map(|w| w.inbox_sends).sum();
ok_response(
"overview",
serde_json::json!({
"workers": stats.num_workers,
"actors": stats.actor_details.len(),
"total_messages_processed": total_msgs,
"total_mailbox_depth": total_mailbox,
"total_panics": total_panics,
"total_type_mismatches": total_type_mismatches,
"sends": {
"local": total_local,
"cross_worker": total_cross,
"inbox": total_inbox,
},
}),
)
}
fn cmd_workers(rt: &Runtime) -> String {
let stats = rt.stats();
let workers: Vec<_> = stats
.workers
.iter()
.map(|w| {
serde_json::json!({
"id": w.id,
"actors": w.num_actors,
"mailbox_depth": w.mailbox_depth,
"messages_processed": w.messages_processed,
"local_sends": w.local_sends,
"cross_sends": w.cross_sends,
"inbox_sends": w.inbox_sends,
"type_mismatches": w.type_mismatches,
"panics": w.panics,
})
})
.collect();
ok_response("workers", workers)
}
fn cmd_worker(rt: &Runtime, col: &StatsCollector, args: &[&str]) -> String {
let id: usize = match args.first().and_then(|s| s.parse().ok()) {
Some(id) => id,
None => return err_response("worker", "usage: worker <id>"),
};
let stats = enriched_stats(rt, col);
let w = match stats.workers.iter().find(|w| w.id == id) {
Some(w) => w,
None => {
return err_response(
"worker",
&format!("worker {id} not found (have 0..{})", stats.num_workers),
)
}
};
// Tick phase breakdown for this worker
let timings = stats.tick_timings.get(id).cloned().unwrap_or_default();
let phase_breakdown = compute_phase_breakdown(&timings);
let actors_on_worker: Vec<_> = stats
.actor_details
.iter()
.filter(|a| a.worker_id == id)
.map(|a| {
serde_json::json!({
"address": format_addr(&a.address),
"mailbox_depth": a.mailbox_depth,
"last_msg_type": a.last_msg_type,
"messages_processed": a.messages_processed,
"poisoned": a.poisoned,
})
})
.collect();
ok_response(
"worker",
serde_json::json!({
"id": w.id,
"actors": w.num_actors,
"mailbox_depth": w.mailbox_depth,
"messages_processed": w.messages_processed,
"local_sends": w.local_sends,
"cross_sends": w.cross_sends,
"inbox_sends": w.inbox_sends,
"type_mismatches": w.type_mismatches,
"panics": w.panics,
"tick_phases": phase_breakdown,
"actor_details": actors_on_worker,
}),
)
}
fn cmd_actors(rt: &Runtime, col: &StatsCollector, args: &[&str]) -> String {
let stats = enriched_stats(rt, col);
let mut actors = stats.actor_details.clone();
// Parse flags
let mut sort_by = "mailbox";
let mut limit: usize = usize::MAX;
let mut worker_filter: Option<usize> = None;
let mut i = 0;
while i < args.len() {
match args[i] {
"--sort" if i + 1 < args.len() => {
sort_by = args[i + 1];
i += 2;
}
"--limit" if i + 1 < args.len() => {
limit = args[i + 1].parse().unwrap_or(usize::MAX);
i += 2;
}
"--worker" if i + 1 < args.len() => {
worker_filter = args[i + 1].parse().ok();
i += 2;
}
_ => {
i += 1;
}
}
}
if let Some(wid) = worker_filter {
actors.retain(|a| a.worker_id == wid);
}
match sort_by {
"mailbox" => actors.sort_by(|a, b| b.mailbox_depth.cmp(&a.mailbox_depth)),
"worker" => actors.sort_by_key(|a| a.worker_id),
"address" => actors.sort_by(|a, b| a.address.0.cmp(&b.address.0)),
other => return err_response("actors", &format!("unknown sort field `{other}` — use mailbox|worker|address")),
}
actors.truncate(limit);
let rows: Vec<_> = actors
.iter()
.map(|a| {
serde_json::json!({
"address": format_addr(&a.address),
"address_full": full_hex(&a.address),
"worker_id": a.worker_id,
"mailbox_depth": a.mailbox_depth,
"last_msg_type": a.last_msg_type,
"messages_processed": a.messages_processed,
"poisoned": a.poisoned,
})
})
.collect();
ok_response(
"actors",
serde_json::json!({
"total": stats.actor_details.len(),
"returned": rows.len(),
"sort": sort_by,
"actors": rows,
}),
)
}
fn cmd_actor(rt: &Runtime, col: &StatsCollector, args: &[&str]) -> String {
let prefix = match args.first() {
Some(p) => *p,
None => return err_response("actor", "usage: actor <hex_prefix>"),
};
let stats = enriched_stats(rt, col);
let matches: Vec<_> = stats
.actor_details
.iter()
.filter(|a| full_hex(&a.address).starts_with(prefix))
.map(|a| {
serde_json::json!({
"address": format_addr(&a.address),
"address_full": full_hex(&a.address),
"worker_id": a.worker_id,
"mailbox_depth": a.mailbox_depth,
"last_msg_type": a.last_msg_type,
"messages_processed": a.messages_processed,
"poisoned": a.poisoned,
})
})
.collect();
ok_response(
"actor",
serde_json::json!({
"prefix": prefix,
"matches": matches.len(),
"actors": matches,
}),
)
}
fn cmd_hot(rt: &Runtime, col: &StatsCollector, args: &[&str]) -> String {
let n: usize = args.first().and_then(|s| s.parse().ok()).unwrap_or(10);
let stats = enriched_stats(rt, col);
let mut actors = stats.actor_details.clone();
actors.sort_by(|a, b| b.mailbox_depth.cmp(&a.mailbox_depth));
actors.truncate(n);
let rows: Vec<_> = actors
.iter()
.map(|a| {
serde_json::json!({
"address": format_addr(&a.address),
"address_full": full_hex(&a.address),
"worker_id": a.worker_id,
"mailbox_depth": a.mailbox_depth,
"last_msg_type": a.last_msg_type,
"messages_processed": a.messages_processed,
"poisoned": a.poisoned,
})
})
.collect();
ok_response("hot", rows)
}
fn cmd_phases(rt: &Runtime, args: &[&str]) -> String {
let stats = rt.stats();
let worker_filter: Option<usize> = args.first().and_then(|s| s.parse().ok());
let phase_names = [
"spawn_drain",
"transfer_drain",
"tick_all",
"spawn_drain_2",
"pending_local",
"stats_publish",
];
let mut results = Vec::new();
for (i, timings) in stats.tick_timings.iter().enumerate() {
if let Some(wid) = worker_filter {
if i != wid {
continue;
}
}
let breakdown = compute_phase_breakdown(timings);
results.push(serde_json::json!({
"worker_id": i,
"ticks_sampled": timings.len(),
"phases": breakdown,
"phase_names": phase_names,
}));
}
ok_response("phases", results)
}
fn cmd_diff(rt: &Runtime, col: &StatsCollector, args: &[&str]) -> String {
let secs: f64 = match args.first().and_then(|s| s.parse().ok()) {
Some(s) if s > 0.0 && s <= 30.0 => s,
Some(_) => return err_response("diff", "seconds must be between 0 and 30"),
None => return err_response("diff", "usage: diff <seconds>"),
};
let before = enriched_stats(rt, col);
let t0 = Instant::now();
std::thread::sleep(Duration::from_secs_f64(secs));
let after = enriched_stats(rt, col);
let elapsed = t0.elapsed().as_secs_f64();
let msgs_before: u64 = before.workers.iter().map(|w| w.messages_processed).sum();
let msgs_after: u64 = after.workers.iter().map(|w| w.messages_processed).sum();
let delta_msgs = msgs_after.saturating_sub(msgs_before);
let local_before: u64 = before.workers.iter().map(|w| w.local_sends).sum();
let local_after: u64 = after.workers.iter().map(|w| w.local_sends).sum();
let cross_before: u64 = before.workers.iter().map(|w| w.cross_sends).sum();
let cross_after: u64 = after.workers.iter().map(|w| w.cross_sends).sum();
let mailbox_before: usize = before.workers.iter().map(|w| w.mailbox_depth).sum();
let mailbox_after: usize = after.workers.iter().map(|w| w.mailbox_depth).sum();
let per_worker: Vec<_> = after
.workers
.iter()
.enumerate()
.map(|(i, w)| {
let prev = before.workers.get(i);
let d = prev
.map(|p| w.messages_processed.saturating_sub(p.messages_processed))
.unwrap_or(0);
serde_json::json!({
"worker_id": i,
"delta_messages": d,
"msg_per_sec": d as f64 / elapsed,
"actors_before": prev.map(|p| p.num_actors).unwrap_or(0),
"actors_after": w.num_actors,
"mailbox_before": prev.map(|p| p.mailbox_depth).unwrap_or(0),
"mailbox_after": w.mailbox_depth,
})
})
.collect();
ok_response(
"diff",
serde_json::json!({
"elapsed_s": elapsed,
"actors_before": before.actor_details.len(),
"actors_after": after.actor_details.len(),
"delta_messages": delta_msgs,
"msg_per_sec": delta_msgs as f64 / elapsed,
"delta_local_sends": local_after.saturating_sub(local_before),
"delta_cross_sends": cross_after.saturating_sub(cross_before),
"mailbox_before": mailbox_before,
"mailbox_after": mailbox_after,
"per_worker": per_worker,
}),
)
}
// ── Phase breakdown helper ──────────────────────────────────────────────
fn compute_phase_breakdown(
timings: &[swactor::stats::TickTiming],
) -> serde_json::Value {
if timings.is_empty() {
return serde_json::json!({
"ticks": 0,
"active_pct": 0.0,
"avg_tick_us": 0.0,
"phases_us": [0, 0, 0, 0, 0, 0],
"phases_pct": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
});
}
let n = timings.len();
let active = timings.iter().filter(|t| t.did_work).count();
let active_pct = (active as f64 / n as f64) * 100.0;
let mut phase_sums = [0u64; 6];
for t in timings {
for (i, &us) in t.phase_us.iter().enumerate() {
phase_sums[i] += us;
}
}
let total_us: u64 = phase_sums.iter().sum();
let avg_tick_us = total_us as f64 / n as f64;
let phases_pct: Vec<f64> = if total_us == 0 {
vec![0.0; 6]
} else {
phase_sums
.iter()
.map(|&s| (s as f64 / total_us as f64) * 100.0)
.collect()
};
serde_json::json!({
"ticks": n,
"active_pct": active_pct,
"avg_tick_us": avg_tick_us,
"phases_us": phase_sums,
"phases_pct": phases_pct,
})
}