feat(story-93): expose server logs to agents via get_server_logs MCP tool
- 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>
This commit is contained in:
136
server/src/log_buffer.rs
Normal file
136
server/src/log_buffer.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user