Skip to main content

orcs_runtime/
board.rs

1//! SharedBoard - Shared event log for cross-component visibility.
2//!
3//! The Board provides a rolling buffer of recent events (Output and Extension)
4//! that any Component can query. Unlike the event system which is fire-and-forget,
5//! the Board retains a configurable number of recent entries for retrospective queries.
6//!
7//! # Architecture
8//!
9//! ```text
10//! EventEmitter (auto-write on emit_output/emit_event)
11//!     |
12//!     v
13//! SharedBoard (Arc<RwLock<Board>>)
14//!     |
15//!     v read (query)
16//! +-------------------------+
17//! | orcs.board_recent(n)    |  <- Lua API
18//! | emitter.board_recent(n) |  <- Native Component API
19//! +-------------------------+
20//! ```
21//!
22//! # Usage
23//!
24//! ```ignore
25//! use orcs_runtime::board::{shared_board, BoardEntry, BoardEntryKind};
26//!
27//! let board = shared_board();
28//!
29//! // Auto-written by EventEmitter; manual append for illustration:
30//! {
31//!     let mut b = board.write().expect("board write lock");
32//!     b.append(BoardEntry {
33//!         timestamp: chrono::Utc::now(),
34//!         source: ComponentId::builtin("tool"),
35//!         kind: BoardEntryKind::Output { level: "info".into() },
36//!         operation: "display".into(),
37//!         payload: serde_json::json!({"message": "hello"}),
38//!     });
39//! }
40//!
41//! // Query recent entries
42//! {
43//!     let b = board.read().expect("board read lock");
44//!     let recent = b.recent(10);
45//!     assert_eq!(recent.len(), 1);
46//! }
47//! ```
48
49use chrono::{DateTime, Utc};
50use orcs_types::ComponentId;
51use parking_lot::RwLock;
52use serde::{Deserialize, Serialize};
53use std::collections::VecDeque;
54use std::sync::Arc;
55
56/// Default maximum entries in the Board.
57const DEFAULT_MAX_ENTRIES: usize = 1000;
58
59/// A single entry in the Board.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct BoardEntry {
62    /// When the entry was recorded.
63    pub timestamp: DateTime<Utc>,
64    /// Which component produced this entry.
65    pub source: ComponentId,
66    /// What kind of entry (Output or Event).
67    pub kind: BoardEntryKind,
68    /// Operation name (e.g., "display", "complete").
69    pub operation: String,
70    /// Entry payload data.
71    pub payload: serde_json::Value,
72}
73
74/// Classification of a Board entry.
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
76#[serde(tag = "type")]
77pub enum BoardEntryKind {
78    /// From `emit_output` / `emit_output_with_level`.
79    Output {
80        /// Log level ("info", "warn", "error").
81        level: String,
82    },
83    /// From `emit_event` (Extension events).
84    Event {
85        /// Extension category (e.g., "tool:result").
86        category: String,
87    },
88}
89
90/// Rolling buffer of recent events for cross-component visibility.
91///
92/// Backed by a [`VecDeque`] with a configurable maximum size.
93/// When full, the oldest entry is evicted on each append.
94pub struct Board {
95    entries: VecDeque<BoardEntry>,
96    max_entries: usize,
97}
98
99impl Board {
100    /// Creates a new Board with default capacity (1000 entries).
101    #[must_use]
102    pub fn new() -> Self {
103        Self {
104            entries: VecDeque::with_capacity(DEFAULT_MAX_ENTRIES),
105            max_entries: DEFAULT_MAX_ENTRIES,
106        }
107    }
108
109    /// Creates a new Board with specified maximum entries.
110    ///
111    /// A `max_entries` of 0 is treated as 1 (at least one entry is always stored).
112    #[must_use]
113    pub fn with_capacity(max_entries: usize) -> Self {
114        let max_entries = max_entries.max(1);
115        Self {
116            entries: VecDeque::with_capacity(max_entries),
117            max_entries,
118        }
119    }
120
121    /// Appends an entry, evicting the oldest if at capacity.
122    pub fn append(&mut self, entry: BoardEntry) {
123        if self.entries.len() >= self.max_entries {
124            self.entries.pop_front();
125        }
126        self.entries.push_back(entry);
127    }
128
129    /// Returns the most recent `n` entries (oldest first, newest last).
130    #[must_use]
131    pub fn recent(&self, n: usize) -> Vec<&BoardEntry> {
132        let len = self.entries.len();
133        let skip = len.saturating_sub(n);
134        self.entries.iter().skip(skip).collect()
135    }
136
137    /// Returns the most recent `n` entries from a specific source (oldest first).
138    #[must_use]
139    pub fn query_by_source(&self, source: &ComponentId, n: usize) -> Vec<&BoardEntry> {
140        self.entries
141            .iter()
142            .rev()
143            .filter(|e| e.source.fqn_eq(source))
144            .take(n)
145            .collect::<Vec<_>>()
146            .into_iter()
147            .rev()
148            .collect()
149    }
150
151    /// Returns the most recent `n` entries as JSON values.
152    ///
153    /// Each entry is serialized via serde. Entries that fail
154    /// serialization are silently skipped.
155    #[must_use]
156    pub fn recent_as_json(&self, n: usize) -> Vec<serde_json::Value> {
157        self.recent(n)
158            .into_iter()
159            .filter_map(|e| serde_json::to_value(e).ok())
160            .collect()
161    }
162
163    /// Returns the total number of entries.
164    #[must_use]
165    pub fn len(&self) -> usize {
166        self.entries.len()
167    }
168
169    /// Returns true if the board has no entries.
170    #[must_use]
171    pub fn is_empty(&self) -> bool {
172        self.entries.is_empty()
173    }
174}
175
176impl Default for Board {
177    fn default() -> Self {
178        Self::new()
179    }
180}
181
182/// Thread-safe shared Board handle.
183pub type SharedBoard = Arc<RwLock<Board>>;
184
185/// Creates a new [`SharedBoard`] with default capacity.
186#[must_use]
187pub fn shared_board() -> SharedBoard {
188    Arc::new(RwLock::new(Board::new()))
189}
190
191/// Creates a new [`SharedBoard`] with specified capacity.
192#[must_use]
193pub fn shared_board_with_capacity(max_entries: usize) -> SharedBoard {
194    Arc::new(RwLock::new(Board::with_capacity(max_entries)))
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    fn sample_entry(source_name: &str, kind: BoardEntryKind) -> BoardEntry {
202        BoardEntry {
203            timestamp: Utc::now(),
204            source: ComponentId::builtin(source_name),
205            kind,
206            operation: "display".to_string(),
207            payload: serde_json::json!({"message": "hello"}),
208        }
209    }
210
211    fn output_kind(level: &str) -> BoardEntryKind {
212        BoardEntryKind::Output {
213            level: level.to_string(),
214        }
215    }
216
217    fn event_kind(category: &str) -> BoardEntryKind {
218        BoardEntryKind::Event {
219            category: category.to_string(),
220        }
221    }
222
223    #[test]
224    fn new_board_is_empty() {
225        let board = Board::new();
226        assert!(board.is_empty());
227        assert_eq!(board.len(), 0);
228    }
229
230    #[test]
231    fn append_and_recent() {
232        let mut board = Board::new();
233        board.append(sample_entry("tool", output_kind("info")));
234        board.append(sample_entry("agent", output_kind("warn")));
235        board.append(sample_entry("shell", event_kind("tool:result")));
236
237        assert_eq!(board.len(), 3);
238
239        let recent = board.recent(2);
240        assert_eq!(recent.len(), 2);
241        assert_eq!(recent[0].source.name, "agent");
242        assert_eq!(recent[1].source.name, "shell");
243    }
244
245    #[test]
246    fn recent_more_than_available() {
247        let mut board = Board::new();
248        board.append(sample_entry("tool", output_kind("info")));
249
250        let recent = board.recent(100);
251        assert_eq!(recent.len(), 1);
252    }
253
254    #[test]
255    fn eviction_at_capacity() {
256        let mut board = Board::with_capacity(3);
257        board.append(sample_entry("a", output_kind("info")));
258        board.append(sample_entry("b", output_kind("info")));
259        board.append(sample_entry("c", output_kind("info")));
260        assert_eq!(board.len(), 3);
261
262        // This should evict "a"
263        board.append(sample_entry("d", output_kind("info")));
264        assert_eq!(board.len(), 3);
265
266        let recent = board.recent(10);
267        assert_eq!(recent[0].source.name, "b");
268        assert_eq!(recent[1].source.name, "c");
269        assert_eq!(recent[2].source.name, "d");
270    }
271
272    #[test]
273    fn query_by_source() {
274        let mut board = Board::new();
275        board.append(sample_entry("tool", output_kind("info")));
276        board.append(sample_entry("agent", output_kind("info")));
277        board.append(sample_entry("tool", output_kind("warn")));
278        board.append(sample_entry("shell", output_kind("info")));
279        board.append(sample_entry("tool", event_kind("result")));
280
281        let tool_source = ComponentId::builtin("tool");
282        let results = board.query_by_source(&tool_source, 10);
283        assert_eq!(results.len(), 3);
284        // Oldest first
285        assert_eq!(
286            results[0].kind,
287            BoardEntryKind::Output {
288                level: "info".into()
289            }
290        );
291        assert_eq!(
292            results[2].kind,
293            BoardEntryKind::Event {
294                category: "result".into()
295            }
296        );
297    }
298
299    #[test]
300    fn query_by_source_limited() {
301        let mut board = Board::new();
302        board.append(sample_entry("tool", output_kind("info")));
303        board.append(sample_entry("tool", output_kind("warn")));
304        board.append(sample_entry("tool", output_kind("error")));
305
306        let tool_source = ComponentId::builtin("tool");
307        let results = board.query_by_source(&tool_source, 2);
308        assert_eq!(results.len(), 2);
309        // Most recent 2, oldest first
310        assert_eq!(
311            results[0].kind,
312            BoardEntryKind::Output {
313                level: "warn".into()
314            }
315        );
316        assert_eq!(
317            results[1].kind,
318            BoardEntryKind::Output {
319                level: "error".into()
320            }
321        );
322    }
323
324    #[test]
325    fn recent_as_json() {
326        let mut board = Board::new();
327        board.append(sample_entry("tool", output_kind("info")));
328        board.append(sample_entry("agent", event_kind("tool:result")));
329
330        let json = board.recent_as_json(10);
331        assert_eq!(json.len(), 2);
332        assert_eq!(json[0]["source"]["name"], "tool");
333        assert_eq!(json[1]["kind"]["type"], "Event");
334        assert_eq!(json[1]["kind"]["category"], "tool:result");
335    }
336
337    #[test]
338    fn shared_board_thread_safe() {
339        let board = shared_board();
340
341        {
342            let mut b = board.write();
343            b.append(sample_entry("tool", output_kind("info")));
344        }
345
346        {
347            let b = board.read();
348            assert_eq!(b.len(), 1);
349        }
350    }
351
352    #[test]
353    fn shared_board_with_custom_capacity() {
354        let board = shared_board_with_capacity(5);
355        let b = board.read();
356        assert_eq!(b.len(), 0);
357    }
358
359    #[test]
360    fn default_board() {
361        let board = Board::default();
362        assert!(board.is_empty());
363    }
364}