orcs_component/snapshot.rs
1//! Component snapshot support for persistence and resume.
2//!
3//! The [`Snapshottable`] trait enables components to save and restore their state,
4//! supporting session persistence and resume functionality.
5//!
6//! # Design
7//!
8//! Snapshottable is an **optional** trait. Components that don't implement it
9//! will not have their state persisted across sessions.
10//!
11//! # Example
12//!
13//! ```
14//! use orcs_component::{Snapshottable, ComponentSnapshot, SnapshotError};
15//! use serde::{Serialize, Deserialize};
16//!
17//! #[derive(Serialize, Deserialize)]
18//! struct CounterState {
19//! count: u64,
20//! }
21//!
22//! struct CounterComponent {
23//! count: u64,
24//! }
25//!
26//! impl Snapshottable for CounterComponent {
27//! fn snapshot(&self) -> ComponentSnapshot {
28//! let state = CounterState { count: self.count };
29//! ComponentSnapshot::from_state("counter", &state).expect("state should serialize")
30//! }
31//!
32//! fn restore(&mut self, snapshot: &ComponentSnapshot) -> Result<(), SnapshotError> {
33//! let state: CounterState = snapshot.to_state()?;
34//! self.count = state.count;
35//! Ok(())
36//! }
37//! }
38//! ```
39
40use serde::{Deserialize, Serialize};
41use std::collections::HashMap;
42use thiserror::Error;
43
44/// Errors that can occur during snapshot operations.
45#[derive(Debug, Error)]
46pub enum SnapshotError {
47 /// Serialization failed.
48 #[error("serialization error: {0}")]
49 Serialization(#[from] serde_json::Error),
50
51 /// Snapshot version mismatch.
52 #[error("version mismatch: expected {expected}, got {actual}")]
53 VersionMismatch { expected: u32, actual: u32 },
54
55 /// Component ID mismatch.
56 #[error("component mismatch: expected {expected}, got {actual}")]
57 ComponentMismatch { expected: String, actual: String },
58
59 /// Invalid snapshot data.
60 #[error("invalid snapshot data: {0}")]
61 InvalidData(String),
62
63 /// Component does not support snapshots.
64 #[error("component {0} does not support snapshots")]
65 NotSupported(String),
66
67 /// Restore failed for a required component.
68 #[error("restore failed for component {component}: {reason}")]
69 RestoreFailed { component: String, reason: String },
70}
71
72/// Declares whether a component supports snapshot persistence.
73///
74/// Components use this to declare their snapshot capability.
75/// The engine uses this to determine how to handle snapshot operations.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
77pub enum SnapshotSupport {
78 /// Component supports and requires snapshot persistence.
79 ///
80 /// - `snapshot()` will be called during session save
81 /// - `restore()` will be called during session load
82 /// - **Restore failure is an error** (not a warning)
83 Enabled,
84
85 /// Component does not support snapshots (default).
86 ///
87 /// - `snapshot()` and `restore()` will NOT be called
88 /// - If called anyway, returns `SnapshotError::NotSupported`
89 #[default]
90 Disabled,
91}
92
93/// Current snapshot format version.
94pub const SNAPSHOT_VERSION: u32 = 1;
95
96/// Component state snapshot.
97///
98/// Stores a component's serialized state along with metadata
99/// for safe restoration.
100///
101/// # Fields
102///
103/// - `component_fqn`: Fully qualified name (`namespace::name`) for cross-session matching
104/// - `version`: Format version for compatibility checking
105/// - `state`: Serialized component state
106/// - `metadata`: Optional additional data (timestamps, checksums, etc.)
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct ComponentSnapshot {
109 /// Fully qualified name (`namespace::name`) for validation.
110 ///
111 /// Uses FQN instead of full `ComponentId` because UUIDs differ between sessions.
112 pub component_fqn: String,
113
114 /// Snapshot format version.
115 pub version: u32,
116
117 /// Serialized component state.
118 pub state: serde_json::Value,
119
120 /// Optional metadata.
121 pub metadata: HashMap<String, serde_json::Value>,
122}
123
124impl ComponentSnapshot {
125 /// Creates a new snapshot from serializable state.
126 ///
127 /// # Arguments
128 ///
129 /// * `fqn` - The component's fully qualified name (`namespace::name`)
130 /// * `state` - The state to serialize
131 ///
132 /// # Errors
133 ///
134 /// Returns `SnapshotError::Serialization` if the state cannot be serialized.
135 pub fn from_state<T: Serialize>(
136 fqn: impl Into<String>,
137 state: &T,
138 ) -> Result<Self, SnapshotError> {
139 Ok(Self {
140 component_fqn: fqn.into(),
141 version: SNAPSHOT_VERSION,
142 state: serde_json::to_value(state)?,
143 metadata: HashMap::new(),
144 })
145 }
146
147 /// Creates an empty snapshot (for components with no state).
148 #[must_use]
149 pub fn empty(fqn: impl Into<String>) -> Self {
150 Self {
151 component_fqn: fqn.into(),
152 version: SNAPSHOT_VERSION,
153 state: serde_json::Value::Null,
154 metadata: HashMap::new(),
155 }
156 }
157
158 /// Deserializes the state.
159 ///
160 /// # Errors
161 ///
162 /// Returns `SnapshotError::Serialization` if deserialization fails.
163 pub fn to_state<T: for<'de> Deserialize<'de>>(&self) -> Result<T, SnapshotError> {
164 Ok(serde_json::from_value(self.state.clone())?)
165 }
166
167 /// Adds metadata.
168 #[must_use]
169 pub fn with_metadata(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
170 self.metadata.insert(key.into(), value);
171 self
172 }
173
174 /// Validates that this snapshot matches the expected component.
175 ///
176 /// # Errors
177 ///
178 /// Returns an error if the FQN or version doesn't match.
179 pub fn validate(&self, expected_fqn: &str) -> Result<(), SnapshotError> {
180 if self.component_fqn != expected_fqn {
181 return Err(SnapshotError::ComponentMismatch {
182 expected: expected_fqn.to_string(),
183 actual: self.component_fqn.clone(),
184 });
185 }
186
187 if self.version != SNAPSHOT_VERSION {
188 return Err(SnapshotError::VersionMismatch {
189 expected: SNAPSHOT_VERSION,
190 actual: self.version,
191 });
192 }
193
194 Ok(())
195 }
196
197 /// Returns true if the state is empty/null.
198 #[must_use]
199 pub fn is_empty(&self) -> bool {
200 self.state.is_null()
201 }
202}
203
204/// Trait for components that support state persistence.
205///
206/// Implementing this trait allows a component's state to be saved
207/// when a session is paused and restored when resumed.
208///
209/// # Contract
210///
211/// - `snapshot()` must produce a complete representation of the component's state
212/// - `restore()` must return the component to the exact state represented by the snapshot
213/// - Restored components must behave identically to their pre-snapshot state
214///
215/// # Thread Safety
216///
217/// Both methods take `&self` / `&mut self` as appropriate and don't require
218/// additional synchronization.
219pub trait Snapshottable {
220 /// Creates a snapshot of the current state.
221 ///
222 /// The snapshot should contain all information needed to restore
223 /// the component to its current state.
224 fn snapshot(&self) -> ComponentSnapshot;
225
226 /// Restores state from a snapshot.
227 ///
228 /// # Errors
229 ///
230 /// Returns `SnapshotError` if restoration fails (version mismatch,
231 /// invalid data, etc.).
232 fn restore(&mut self, snapshot: &ComponentSnapshot) -> Result<(), SnapshotError>;
233
234 /// Returns true if this component has meaningful state to persist.
235 ///
236 /// Components that always start fresh can return `false` to skip
237 /// snapshot/restore operations.
238 fn has_state(&self) -> bool {
239 true
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
248 struct TestState {
249 value: i32,
250 name: String,
251 }
252
253 #[test]
254 fn snapshot_roundtrip() {
255 let state = TestState {
256 value: 42,
257 name: "test".into(),
258 };
259
260 let snapshot =
261 ComponentSnapshot::from_state("test-component", &state).expect("create snapshot");
262 let restored: TestState = snapshot.to_state().expect("deserialize state");
263
264 assert_eq!(state, restored);
265 }
266
267 #[test]
268 fn snapshot_validation() {
269 let snapshot = ComponentSnapshot::empty("my-component");
270
271 assert!(snapshot.validate("my-component").is_ok());
272 assert!(snapshot.validate("other-component").is_err());
273 }
274
275 #[test]
276 fn snapshot_with_metadata() {
277 let snapshot = ComponentSnapshot::empty("test")
278 .with_metadata("timestamp", serde_json::json!(12345))
279 .with_metadata("version", serde_json::json!("1.0.0"));
280
281 assert_eq!(snapshot.metadata.len(), 2);
282 assert_eq!(snapshot.metadata["timestamp"], serde_json::json!(12345));
283 }
284
285 #[test]
286 fn empty_snapshot() {
287 let snapshot = ComponentSnapshot::empty("empty-component");
288 assert!(snapshot.is_empty());
289 assert_eq!(snapshot.component_fqn, "empty-component");
290 }
291
292 #[test]
293 fn version_mismatch_error() {
294 let mut snapshot = ComponentSnapshot::empty("test");
295 snapshot.version = 999;
296
297 let result = snapshot.validate("test");
298 assert!(matches!(result, Err(SnapshotError::VersionMismatch { .. })));
299 }
300
301 struct TestComponent {
302 count: u64,
303 }
304
305 impl Snapshottable for TestComponent {
306 fn snapshot(&self) -> ComponentSnapshot {
307 ComponentSnapshot::from_state("test", &self.count).expect("create snapshot")
308 }
309
310 fn restore(&mut self, snapshot: &ComponentSnapshot) -> Result<(), SnapshotError> {
311 self.count = snapshot.to_state()?;
312 Ok(())
313 }
314 }
315
316 #[test]
317 fn snapshottable_trait() {
318 let mut comp = TestComponent { count: 100 };
319 let snapshot = comp.snapshot();
320
321 comp.count = 0;
322 comp.restore(&snapshot).expect("restore snapshot");
323
324 assert_eq!(comp.count, 100);
325 }
326}