1use chrono::{DateTime, Utc};
50use orcs_types::ComponentId;
51use parking_lot::RwLock;
52use serde::{Deserialize, Serialize};
53use std::collections::VecDeque;
54use std::sync::Arc;
55
56const DEFAULT_MAX_ENTRIES: usize = 1000;
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct BoardEntry {
62 pub timestamp: DateTime<Utc>,
64 pub source: ComponentId,
66 pub kind: BoardEntryKind,
68 pub operation: String,
70 pub payload: serde_json::Value,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
76#[serde(tag = "type")]
77pub enum BoardEntryKind {
78 Output {
80 level: String,
82 },
83 Event {
85 category: String,
87 },
88}
89
90pub struct Board {
95 entries: VecDeque<BoardEntry>,
96 max_entries: usize,
97}
98
99impl Board {
100 #[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 #[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 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 #[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 #[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 #[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 #[must_use]
165 pub fn len(&self) -> usize {
166 self.entries.len()
167 }
168
169 #[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
182pub type SharedBoard = Arc<RwLock<Board>>;
184
185#[must_use]
187pub fn shared_board() -> SharedBoard {
188 Arc::new(RwLock::new(Board::new()))
189}
190
191#[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 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 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 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}