- Add log_buffer module: bounded 1000-line ring buffer with push/get_recent API - Add slog! macro: drop-in for eprintln! that also captures to ring buffer - Replace all eprintln! calls across agents, watcher, search, chat, worktree, claude_code with slog! - Add get_server_logs MCP tool: accepts count (1-500) and optional filter params - 5 unit tests for log_buffer covering push/retrieve, eviction, filtering, count limits, empty buffer - 262 tests passing, clippy clean Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
137 lines
3.9 KiB
Rust
137 lines
3.9 KiB
Rust
//! Bounded in-memory ring buffer for server log output.
|
|
//!
|
|
//! Use the [`slog!`] macro as a drop-in replacement for `eprintln!`. It writes
|
|
//! to stderr (same as before) and simultaneously appends the line to the global
|
|
//! ring buffer, making it retrievable via the `get_server_logs` MCP tool.
|
|
|
|
use std::collections::VecDeque;
|
|
use std::sync::{Mutex, OnceLock};
|
|
|
|
const CAPACITY: usize = 1000;
|
|
|
|
pub struct LogBuffer {
|
|
lines: Mutex<VecDeque<String>>,
|
|
}
|
|
|
|
impl LogBuffer {
|
|
fn new() -> Self {
|
|
Self {
|
|
lines: Mutex::new(VecDeque::with_capacity(CAPACITY)),
|
|
}
|
|
}
|
|
|
|
/// Append a log line, evicting the oldest entry when at capacity.
|
|
pub fn push(&self, line: String) {
|
|
if let Ok(mut buf) = self.lines.lock() {
|
|
if buf.len() >= CAPACITY {
|
|
buf.pop_front();
|
|
}
|
|
buf.push_back(line);
|
|
}
|
|
}
|
|
|
|
/// Return up to `count` recent lines, optionally filtered by a substring.
|
|
/// Lines are returned in chronological order (oldest first).
|
|
pub fn get_recent(&self, count: usize, filter: Option<&str>) -> Vec<String> {
|
|
let buf = match self.lines.lock() {
|
|
Ok(b) => b,
|
|
Err(_) => return vec![],
|
|
};
|
|
let filtered: Vec<&String> = buf
|
|
.iter()
|
|
.filter(|line| filter.is_none_or(|f| line.contains(f)))
|
|
.collect();
|
|
let start = filtered.len().saturating_sub(count);
|
|
filtered[start..].iter().map(|s| (*s).clone()).collect()
|
|
}
|
|
}
|
|
|
|
static GLOBAL: OnceLock<LogBuffer> = OnceLock::new();
|
|
|
|
/// Access the process-wide log ring buffer.
|
|
pub fn global() -> &'static LogBuffer {
|
|
GLOBAL.get_or_init(LogBuffer::new)
|
|
}
|
|
|
|
/// Write a formatted message to stderr **and** capture it in the ring buffer.
|
|
///
|
|
/// Usage is identical to `eprintln!`:
|
|
/// ```ignore
|
|
/// slog!("agent {} started", name);
|
|
/// ```
|
|
#[macro_export]
|
|
macro_rules! slog {
|
|
($($arg:tt)*) => {{
|
|
let _line = format!($($arg)*);
|
|
eprintln!("{}", _line);
|
|
$crate::log_buffer::global().push(_line);
|
|
}};
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn fresh_buffer() -> LogBuffer {
|
|
LogBuffer::new()
|
|
}
|
|
|
|
#[test]
|
|
fn push_and_retrieve() {
|
|
let buf = fresh_buffer();
|
|
buf.push("line one".into());
|
|
buf.push("line two".into());
|
|
let recent = buf.get_recent(10, None);
|
|
assert_eq!(recent, vec!["line one", "line two"]);
|
|
}
|
|
|
|
#[test]
|
|
fn evicts_oldest_at_capacity() {
|
|
let buf = LogBuffer {
|
|
lines: Mutex::new(VecDeque::with_capacity(CAPACITY)),
|
|
};
|
|
// Fill past capacity
|
|
for i in 0..=CAPACITY {
|
|
buf.push(format!("line {i}"));
|
|
}
|
|
let recent = buf.get_recent(CAPACITY + 1, None);
|
|
// Should have exactly CAPACITY lines
|
|
assert_eq!(recent.len(), CAPACITY);
|
|
// The oldest (line 0) should have been evicted
|
|
assert!(!recent.iter().any(|l| l == "line 0"));
|
|
// The newest should be present
|
|
assert!(recent.iter().any(|l| l == &format!("line {CAPACITY}")));
|
|
}
|
|
|
|
#[test]
|
|
fn filter_by_substring() {
|
|
let buf = fresh_buffer();
|
|
buf.push("watcher started".into());
|
|
buf.push("mcp call received".into());
|
|
buf.push("watcher event".into());
|
|
|
|
let filtered = buf.get_recent(100, Some("watcher"));
|
|
assert_eq!(filtered.len(), 2);
|
|
assert_eq!(filtered[0], "watcher started");
|
|
assert_eq!(filtered[1], "watcher event");
|
|
}
|
|
|
|
#[test]
|
|
fn count_limits_results() {
|
|
let buf = fresh_buffer();
|
|
for i in 0..10 {
|
|
buf.push(format!("line {i}"));
|
|
}
|
|
let recent = buf.get_recent(3, None);
|
|
assert_eq!(recent.len(), 3);
|
|
// Most recent 3
|
|
assert_eq!(recent, vec!["line 7", "line 8", "line 9"]);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_buffer_returns_empty() {
|
|
let buf = fresh_buffer();
|
|
assert!(buf.get_recent(10, None).is_empty());
|
|
}
|
|
}
|