Skip to main content

victauri_core/
recording.rs

1//! Time-travel recording: captures event streams and state checkpoints
2//! for replay and debugging.
3
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use std::collections::VecDeque;
7use std::sync::{Arc, Mutex};
8
9use crate::error::VictauriError;
10use crate::event::{AppEvent, IpcCall};
11
12const DEFAULT_MAX_CHECKPOINTS: usize = 1000;
13const DEFAULT_MAX_EVENTS: usize = 50_000;
14
15/// A snapshot of application state taken at a specific point during recording.
16#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
17pub struct StateCheckpoint {
18    /// Unique identifier for this checkpoint.
19    pub id: String,
20    /// Optional human-readable label for the checkpoint.
21    pub label: Option<String>,
22    /// When the checkpoint was created.
23    pub timestamp: DateTime<Utc>,
24    /// Serialized application state at the checkpoint.
25    pub state: serde_json::Value,
26    /// Index into the event stream at the time of this checkpoint.
27    pub event_index: usize,
28}
29
30/// A complete recorded session with events and state checkpoints. Serializable for export/import.
31#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
32pub struct RecordedSession {
33    /// Unique session identifier (UUID).
34    pub id: String,
35    /// When the recording session began.
36    pub started_at: DateTime<Utc>,
37    /// All events captured during the session, in order.
38    pub events: Vec<RecordedEvent>,
39    /// State checkpoints created during the session.
40    pub checkpoints: Vec<StateCheckpoint>,
41}
42
43/// A single event captured during a recording session, with its sequence index.
44#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
45pub struct RecordedEvent {
46    /// Monotonically increasing sequence number within the recording session.
47    pub index: usize,
48    /// When the event occurred.
49    pub timestamp: DateTime<Utc>,
50    /// The captured application event.
51    pub event: AppEvent,
52}
53
54/// Thread-safe session recorder for time-travel debugging. Records events and
55/// state checkpoints during a recording session. Only one session can be active at a time.
56#[derive(Debug, Clone)]
57pub struct EventRecorder {
58    recording: Arc<Mutex<Option<ActiveRecording>>>,
59    max_events: usize,
60}
61
62#[derive(Debug, Clone)]
63struct ActiveRecording {
64    session_id: String,
65    started_at: DateTime<Utc>,
66    events: VecDeque<RecordedEvent>,
67    checkpoints: VecDeque<StateCheckpoint>,
68    event_counter: usize,
69    max_events: usize,
70    max_checkpoints: usize,
71}
72
73impl EventRecorder {
74    /// Creates a new recorder with the given maximum event capacity.
75    ///
76    /// ```
77    /// use victauri_core::EventRecorder;
78    ///
79    /// let recorder = EventRecorder::new(1000);
80    /// assert!(!recorder.is_recording());
81    /// assert_eq!(recorder.event_count(), 0);
82    /// ```
83    #[must_use]
84    pub fn new(max_events: usize) -> Self {
85        Self {
86            recording: Arc::new(Mutex::new(None)),
87            max_events,
88        }
89    }
90
91    /// Starts a new recording session; returns `Err` if one is already active.
92    ///
93    /// # Errors
94    ///
95    /// Returns [`VictauriError::RecordingAlreadyActive`] if a session is already in progress.
96    ///
97    /// # Examples
98    ///
99    /// ```
100    /// use victauri_core::EventRecorder;
101    ///
102    /// let recorder = EventRecorder::new(1000);
103    /// recorder.start("session-1".to_string()).unwrap();
104    /// assert!(recorder.is_recording());
105    /// ```
106    pub fn start(&self, session_id: String) -> crate::error::Result<()> {
107        let mut rec = self
108            .recording
109            .lock()
110            .unwrap_or_else(std::sync::PoisonError::into_inner);
111        if rec.is_some() {
112            return Err(VictauriError::RecordingAlreadyActive);
113        }
114        *rec = Some(ActiveRecording {
115            session_id,
116            started_at: Utc::now(),
117            events: VecDeque::new(),
118            checkpoints: VecDeque::new(),
119            event_counter: 0,
120            max_events: self.max_events,
121            max_checkpoints: DEFAULT_MAX_CHECKPOINTS,
122        });
123        Ok(())
124    }
125
126    /// Stops the active recording and returns the completed session, or None if not recording.
127    ///
128    /// # Examples
129    ///
130    /// ```
131    /// use victauri_core::EventRecorder;
132    ///
133    /// let recorder = EventRecorder::new(1000);
134    /// recorder.start("session-1".to_string()).unwrap();
135    /// let session = recorder.stop().expect("should return session");
136    /// assert_eq!(session.id, "session-1");
137    /// assert!(!recorder.is_recording());
138    /// ```
139    #[must_use]
140    pub fn stop(&self) -> Option<RecordedSession> {
141        let mut rec = self
142            .recording
143            .lock()
144            .unwrap_or_else(std::sync::PoisonError::into_inner);
145        rec.take().map(|r| RecordedSession {
146            id: r.session_id,
147            started_at: r.started_at,
148            events: r.events.into_iter().collect(),
149            checkpoints: r.checkpoints.into_iter().collect(),
150        })
151    }
152
153    /// Returns true if a recording session is currently active.
154    #[must_use]
155    pub fn is_recording(&self) -> bool {
156        self.recording
157            .lock()
158            .unwrap_or_else(std::sync::PoisonError::into_inner)
159            .is_some()
160    }
161
162    /// Appends an event to the active recording, evicting the oldest if at capacity.
163    pub fn record_event(&self, event: AppEvent) {
164        let mut rec = self
165            .recording
166            .lock()
167            .unwrap_or_else(std::sync::PoisonError::into_inner);
168        if let Some(ref mut active) = *rec {
169            let timestamp = event.timestamp();
170            let index = active.event_counter;
171            active.event_counter += 1;
172
173            if active.events.len() >= active.max_events {
174                active.events.pop_front();
175            }
176
177            active.events.push_back(RecordedEvent {
178                index,
179                timestamp,
180                event,
181            });
182        }
183    }
184
185    /// Creates a named state checkpoint at the current event index; returns `Err` if not recording.
186    ///
187    /// # Errors
188    ///
189    /// Returns [`VictauriError::NoActiveRecording`] if no session is in progress.
190    pub fn checkpoint(
191        &self,
192        id: String,
193        label: Option<String>,
194        state: serde_json::Value,
195    ) -> crate::error::Result<()> {
196        let mut rec = self
197            .recording
198            .lock()
199            .unwrap_or_else(std::sync::PoisonError::into_inner);
200        if let Some(ref mut active) = *rec {
201            let event_index = active.event_counter;
202            if active.checkpoints.len() >= active.max_checkpoints {
203                active.checkpoints.pop_front();
204            }
205            active.checkpoints.push_back(StateCheckpoint {
206                id,
207                label,
208                timestamp: Utc::now(),
209                state,
210                event_index,
211            });
212            Ok(())
213        } else {
214            Err(VictauriError::NoActiveRecording)
215        }
216    }
217
218    /// Returns the number of events recorded so far, or 0 if not recording.
219    #[must_use]
220    pub fn event_count(&self) -> usize {
221        self.recording
222            .lock()
223            .unwrap_or_else(std::sync::PoisonError::into_inner)
224            .as_ref()
225            .map_or(0, |r| r.events.len())
226    }
227
228    /// Returns the number of checkpoints created so far, or 0 if not recording.
229    #[must_use]
230    pub fn checkpoint_count(&self) -> usize {
231        self.recording
232            .lock()
233            .unwrap_or_else(std::sync::PoisonError::into_inner)
234            .as_ref()
235            .map_or(0, |r| r.checkpoints.len())
236    }
237
238    /// Returns all events with an index >= the given value.
239    #[must_use]
240    pub fn events_since(&self, index: usize) -> Vec<RecordedEvent> {
241        let rec = self
242            .recording
243            .lock()
244            .unwrap_or_else(std::sync::PoisonError::into_inner);
245        match rec.as_ref() {
246            Some(active) => active
247                .events
248                .iter()
249                .filter(|e| e.index >= index)
250                .cloned()
251                .collect(),
252            None => Vec::new(),
253        }
254    }
255
256    /// Returns events whose timestamps fall within the given inclusive range.
257    #[must_use]
258    pub fn events_between(&self, from: DateTime<Utc>, to: DateTime<Utc>) -> Vec<RecordedEvent> {
259        let rec = self
260            .recording
261            .lock()
262            .unwrap_or_else(std::sync::PoisonError::into_inner);
263        match rec.as_ref() {
264            Some(active) => active
265                .events
266                .iter()
267                .filter(|e| e.timestamp >= from && e.timestamp <= to)
268                .cloned()
269                .collect(),
270            None => Vec::new(),
271        }
272    }
273
274    /// Returns all checkpoints from the active recording session.
275    #[must_use]
276    pub fn get_checkpoints(&self) -> Vec<StateCheckpoint> {
277        let rec = self
278            .recording
279            .lock()
280            .unwrap_or_else(std::sync::PoisonError::into_inner);
281        match rec.as_ref() {
282            Some(active) => active.checkpoints.iter().cloned().collect(),
283            None => Vec::new(),
284        }
285    }
286
287    /// Returns events recorded between two named checkpoints.
288    ///
289    /// # Errors
290    ///
291    /// - [`VictauriError::NoActiveRecording`] if no session is active.
292    /// - [`VictauriError::CheckpointNotFound`] if either checkpoint ID does not exist.
293    pub fn events_between_checkpoints(
294        &self,
295        from_checkpoint_id: &str,
296        to_checkpoint_id: &str,
297    ) -> crate::error::Result<Vec<RecordedEvent>> {
298        let rec = self
299            .recording
300            .lock()
301            .unwrap_or_else(std::sync::PoisonError::into_inner);
302        let active = rec.as_ref().ok_or(VictauriError::NoActiveRecording)?;
303
304        let from_idx = active
305            .checkpoints
306            .iter()
307            .find(|c| c.id == from_checkpoint_id)
308            .ok_or_else(|| VictauriError::CheckpointNotFound {
309                id: from_checkpoint_id.to_string(),
310            })?
311            .event_index;
312        let to_idx = active
313            .checkpoints
314            .iter()
315            .find(|c| c.id == to_checkpoint_id)
316            .ok_or_else(|| VictauriError::CheckpointNotFound {
317                id: to_checkpoint_id.to_string(),
318            })?
319            .event_index;
320
321        let (start, end) = if from_idx <= to_idx {
322            (from_idx, to_idx)
323        } else {
324            (to_idx, from_idx)
325        };
326
327        Ok(active
328            .events
329            .iter()
330            .filter(|e| e.index >= start && e.index < end)
331            .cloned()
332            .collect())
333    }
334
335    /// Snapshot the current recording as a session WITHOUT stopping it.
336    #[must_use]
337    pub fn export(&self) -> Option<RecordedSession> {
338        let rec = self
339            .recording
340            .lock()
341            .unwrap_or_else(std::sync::PoisonError::into_inner);
342        rec.as_ref().map(|r| RecordedSession {
343            id: r.session_id.clone(),
344            started_at: r.started_at,
345            events: r.events.iter().cloned().collect(),
346            checkpoints: r.checkpoints.iter().cloned().collect(),
347        })
348    }
349
350    /// Import a previously exported session, replacing any active recording.
351    pub fn import(&self, session: RecordedSession) {
352        let event_counter = session.events.last().map_or(0, |e| e.index + 1);
353        let max_events = self.max_events;
354        let mut rec = self
355            .recording
356            .lock()
357            .unwrap_or_else(std::sync::PoisonError::into_inner);
358        *rec = Some(ActiveRecording {
359            session_id: session.id,
360            started_at: session.started_at,
361            events: session.events.into_iter().collect(),
362            checkpoints: session.checkpoints.into_iter().collect(),
363            event_counter,
364            max_events,
365            max_checkpoints: DEFAULT_MAX_CHECKPOINTS,
366        });
367    }
368
369    /// Extracts IPC calls in order from the recording for replay.
370    #[must_use]
371    pub fn ipc_replay_sequence(&self) -> Vec<IpcCall> {
372        let rec = self
373            .recording
374            .lock()
375            .unwrap_or_else(std::sync::PoisonError::into_inner);
376        match rec.as_ref() {
377            Some(active) => active
378                .events
379                .iter()
380                .filter_map(|re| match &re.event {
381                    AppEvent::Ipc(call) => Some(call.clone()),
382                    _ => None,
383                })
384                .collect(),
385            None => Vec::new(),
386        }
387    }
388}
389
390impl Default for EventRecorder {
391    fn default() -> Self {
392        Self::new(DEFAULT_MAX_EVENTS)
393    }
394}