algocline_core/recent_log.rs
1//! Per-session recent-log ring buffer.
2//!
3//! [`LogEntry`] captures events from Lua `print()`, `alc.log()`, and
4//! engine-internal callsites. [`LogSink`] accumulates entries with a
5//! fixed cap (=20) for retrieval via `alc_log_view` and MCP resource
6//! endpoints, providing bounded per-session observability without
7//! unbounded memory growth.
8
9use std::collections::VecDeque;
10use std::sync::{Arc, Mutex};
11use std::time::{SystemTime, UNIX_EPOCH};
12
13// ─── LogEntry ────────────────────────────────────────────────
14
15/// A single log entry captured from a running session.
16///
17/// Entries are produced by Lua `print()`, `alc.log()`, and engine-internal
18/// events, then accumulated in a per-session ring buffer (cap=20) for
19/// lightweight observability via `alc_status`.
20///
21/// # Fields
22///
23/// - `ts` — Unix milliseconds (i64) when the entry was recorded.
24/// - `level` — Severity string: `"info"`, `"warn"`, `"error"`, `"debug"`, etc.
25/// - `source` — Originator: `"alc.lua.print"`, `"alc.log"`, `"engine"`, etc.
26/// - `message` — Human-readable log text.
27#[derive(Debug, Clone, serde::Serialize)]
28pub struct LogEntry {
29 /// Unix milliseconds timestamp of when the entry was recorded.
30 pub ts: i64,
31 /// Severity level string (e.g. "info", "warn", "error", "debug").
32 pub level: String,
33 /// Originator identifier (e.g. "alc.lua.print", "alc.log", "engine").
34 pub source: String,
35 /// Human-readable log message.
36 pub message: String,
37}
38
39impl LogEntry {
40 /// Create a new `LogEntry` with the current wall-clock timestamp.
41 ///
42 /// # Arguments
43 ///
44 /// - `level` — Severity label string.
45 /// - `source` — Originator identifier string.
46 /// - `message` — Log message text.
47 ///
48 /// # Returns
49 ///
50 /// A new `LogEntry` with `ts` set to the current Unix millisecond
51 /// timestamp. If `SystemTime` is before the Unix epoch (broken wall
52 /// clock), `ts` is saturated to `0`.
53 pub fn new(
54 level: impl Into<String>,
55 source: impl Into<String>,
56 message: impl Into<String>,
57 ) -> Self {
58 // Note: duration_since can only fail if the wall clock predates
59 // UNIX_EPOCH (1970-01-01), which indicates a broken system clock.
60 // Saturating to zero is harmless for observability purposes.
61 let ts = SystemTime::now()
62 .duration_since(UNIX_EPOCH)
63 .unwrap_or_default()
64 .as_millis() as i64;
65 Self {
66 ts,
67 level: level.into(),
68 source: source.into(),
69 message: message.into(),
70 }
71 }
72}
73
74// ─── LogSink ─────────────────────────────────────────────────
75
76/// A shared, bounded ring-buffer sink for [`LogEntry`] items.
77///
78/// Wraps `Arc<Mutex<VecDeque<LogEntry>>>` and enforces a maximum capacity
79/// of 20 entries. Oldest entries are evicted when the cap is exceeded.
80///
81/// `LogSink` can be cloned cheaply (clones the `Arc`, not the buffer).
82/// It is intended to be passed to the Lua bridge so that log output from
83/// both `print()` and `alc.log()` is routed into the session's ring buffer.
84#[derive(Clone, Debug)]
85pub struct LogSink(Arc<Mutex<VecDeque<LogEntry>>>);
86
87/// Maximum number of entries retained in a [`LogSink`].
88pub const LOG_SINK_CAP: usize = 20;
89
90impl LogSink {
91 /// Create a new, empty `LogSink`.
92 pub fn new() -> Self {
93 Self(Arc::new(Mutex::new(VecDeque::with_capacity(
94 LOG_SINK_CAP + 1,
95 ))))
96 }
97
98 /// Push a new entry into the ring buffer, evicting the oldest if necessary.
99 ///
100 /// # Arguments
101 ///
102 /// - `entry` — The [`LogEntry`] to append.
103 ///
104 /// # Errors
105 ///
106 /// If the internal mutex is poisoned (only possible on OOM-induced panic),
107 /// the entry is silently dropped. This is the approved "observation/recording"
108 /// policy — log capture failure must not interrupt execution.
109 pub fn push(&self, entry: LogEntry) {
110 if let Ok(mut buf) = self.0.lock() {
111 buf.push_back(entry);
112 if buf.len() > LOG_SINK_CAP {
113 buf.pop_front();
114 }
115 }
116 }
117
118 /// Snapshot the current ring-buffer contents as a JSON array.
119 ///
120 /// # Returns
121 ///
122 /// A `serde_json::Value::Array` of serialized [`LogEntry`] objects,
123 /// in chronological order (oldest first). Returns an empty array if
124 /// the mutex is poisoned.
125 pub fn to_json(&self) -> serde_json::Value {
126 if let Ok(buf) = self.0.lock() {
127 let entries: Vec<serde_json::Value> = buf
128 .iter()
129 .map(|e| {
130 // LogEntry has only i64 / String fields; serde_json::to_value
131 // cannot fail in practice (only on OOM, which would already
132 // panic elsewhere). Falling back to Null preserves array
133 // length without introducing a silent drop.
134 serde_json::to_value(e).unwrap_or(serde_json::Value::Null)
135 })
136 .collect();
137 serde_json::Value::Array(entries)
138 } else {
139 serde_json::Value::Array(vec![])
140 }
141 }
142
143 /// Snapshot the current ring-buffer contents as a `Vec<LogEntry>`.
144 ///
145 /// Useful for programmatic access (e.g. in `SessionStatus::snapshot`).
146 /// Returns an empty vec if the mutex is poisoned.
147 pub fn entries(&self) -> Vec<LogEntry> {
148 if let Ok(buf) = self.0.lock() {
149 buf.iter().cloned().collect()
150 } else {
151 vec![]
152 }
153 }
154}
155
156impl Default for LogSink {
157 fn default() -> Self {
158 Self::new()
159 }
160}
161
162// ─── Tests ───────────────────────────────────────────────────
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 // T1: happy path — push entries and read them back
169 #[test]
170 fn log_sink_push_and_read() {
171 let sink = LogSink::new();
172 sink.push(LogEntry::new("info", "engine", "hello"));
173 sink.push(LogEntry::new("warn", "alc.log", "world"));
174
175 let entries = sink.entries();
176 assert_eq!(entries.len(), 2);
177 assert_eq!(entries[0].level, "info");
178 assert_eq!(entries[0].source, "engine");
179 assert_eq!(entries[0].message, "hello");
180 assert_eq!(entries[1].level, "warn");
181 assert_eq!(entries[1].message, "world");
182 }
183
184 // T2: boundary — cap=20 enforcement: 21st entry evicts the oldest
185 #[test]
186 fn log_sink_cap_evicts_oldest() {
187 let sink = LogSink::new();
188 for i in 0..=20u32 {
189 sink.push(LogEntry::new("info", "engine", format!("msg-{i}")));
190 }
191
192 let entries = sink.entries();
193 assert_eq!(entries.len(), LOG_SINK_CAP);
194 // The first entry should be msg-1 (msg-0 was evicted)
195 assert_eq!(entries[0].message, "msg-1");
196 // The last entry should be msg-20
197 assert_eq!(entries[LOG_SINK_CAP - 1].message, "msg-20");
198 }
199
200 // T2: boundary — empty sink
201 #[test]
202 fn log_sink_empty() {
203 let sink = LogSink::new();
204 assert!(sink.entries().is_empty());
205 let json = sink.to_json();
206 assert_eq!(json, serde_json::Value::Array(vec![]));
207 }
208
209 // T1: to_json serializes correctly
210 #[test]
211 fn log_sink_to_json_shape() {
212 let sink = LogSink::new();
213 sink.push(LogEntry::new("debug", "alc.lua.print", "test-msg"));
214
215 let json = sink.to_json();
216 let arr = json.as_array().unwrap();
217 assert_eq!(arr.len(), 1);
218 assert_eq!(arr[0]["level"], "debug");
219 assert_eq!(arr[0]["source"], "alc.lua.print");
220 assert_eq!(arr[0]["message"], "test-msg");
221 assert!(arr[0].get("ts").is_some());
222 }
223
224 // T1: clone shares the same underlying buffer
225 #[test]
226 fn log_sink_clone_shares_buffer() {
227 let sink = LogSink::new();
228 let sink2 = sink.clone();
229 sink.push(LogEntry::new("info", "engine", "shared"));
230 assert_eq!(sink2.entries().len(), 1);
231 }
232
233 // T3: exactly at cap boundary (20 entries) — no eviction yet
234 #[test]
235 fn log_sink_exactly_at_cap() {
236 let sink = LogSink::new();
237 for i in 0..20u32 {
238 sink.push(LogEntry::new("info", "engine", format!("msg-{i}")));
239 }
240 let entries = sink.entries();
241 assert_eq!(entries.len(), 20);
242 assert_eq!(entries[0].message, "msg-0");
243 assert_eq!(entries[19].message, "msg-19");
244 }
245}