Skip to main content

lean_ctx/core/context_kernel/
recovery.rs

1//! Kernel state serialization, snapshots, and recovery.
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::time::{SystemTime, UNIX_EPOCH};
6
7const SNAPSHOT_VERSION: u32 = 1;
8
9/// Durable state needed to resume kernel operation after a restart.
10#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
11pub struct KernelSnapshot {
12    pub version: u32,
13    pub timestamp_epoch: u64,
14    pub provider_weights: HashMap<String, f64>,
15    pub recent_plan_ids: Vec<String>,
16    pub recent_receipt_ids: Vec<String>,
17    pub degradation_level: String,
18    pub circuit_states: HashMap<String, String>,
19}
20
21impl KernelSnapshot {
22    /// Capture the current recoverable kernel state.
23    pub fn capture(
24        provider_weights: &HashMap<String, f64>,
25        recent_plans: &[String],
26        recent_receipts: &[String],
27        degradation: &str,
28        circuits: &HashMap<String, String>,
29    ) -> Self {
30        let timestamp_epoch = SystemTime::now()
31            .duration_since(UNIX_EPOCH)
32            .map_or(0, |duration| duration.as_secs());
33
34        Self {
35            version: SNAPSHOT_VERSION,
36            timestamp_epoch,
37            provider_weights: provider_weights.clone(),
38            recent_plan_ids: recent_plans.to_vec(),
39            recent_receipt_ids: recent_receipts.to_vec(),
40            degradation_level: degradation.to_owned(),
41            circuit_states: circuits.clone(),
42        }
43    }
44}
45
46/// Errors encountered while persisting or recovering a kernel snapshot.
47#[derive(Debug)]
48pub enum SnapshotError {
49    Io(std::io::Error),
50    Serialize(String),
51    Deserialize(String),
52    InvalidVersion(u32),
53}
54
55impl std::fmt::Display for SnapshotError {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        match self {
58            Self::Io(error) => write!(f, "snapshot I/O error: {error}"),
59            Self::Serialize(message) => {
60                write!(f, "snapshot serialization error: {message}")
61            }
62            Self::Deserialize(message) => {
63                write!(f, "snapshot deserialization error: {message}")
64            }
65            Self::InvalidVersion(version) => {
66                write!(f, "unsupported snapshot version: {version}")
67            }
68        }
69    }
70}
71
72impl std::error::Error for SnapshotError {}
73
74impl From<std::io::Error> for SnapshotError {
75    fn from(error: std::io::Error) -> Self {
76        Self::Io(error)
77    }
78}
79
80/// Persist a snapshot using a temporary sibling file and atomic rename.
81pub fn save_snapshot(snapshot: &KernelSnapshot, path: &Path) -> Result<(), SnapshotError> {
82    let serialized = serde_json::to_vec_pretty(snapshot)
83        .map_err(|error| SnapshotError::Serialize(error.to_string()))?;
84    let temporary_path = temporary_path(path);
85
86    if let Err(error) = std::fs::write(&temporary_path, serialized) {
87        let _ = std::fs::remove_file(&temporary_path);
88        return Err(SnapshotError::Io(error));
89    }
90
91    if let Err(error) = std::fs::rename(&temporary_path, path) {
92        let _ = std::fs::remove_file(&temporary_path);
93        return Err(SnapshotError::Io(error));
94    }
95
96    Ok(())
97}
98
99/// Load and validate a persisted kernel snapshot.
100pub fn load_snapshot(path: &Path) -> Result<KernelSnapshot, SnapshotError> {
101    let serialized = std::fs::read(path)?;
102    let snapshot: KernelSnapshot = serde_json::from_slice(&serialized)
103        .map_err(|error| SnapshotError::Deserialize(error.to_string()))?;
104
105    if snapshot.version != SNAPSHOT_VERSION {
106        return Err(SnapshotError::InvalidVersion(snapshot.version));
107    }
108
109    Ok(snapshot)
110}
111
112/// Return the standard per-user location for kernel recovery state.
113pub fn default_snapshot_path() -> PathBuf {
114    dirs::cache_dir()
115        .unwrap_or_else(|| PathBuf::from("/tmp"))
116        .join("lean-ctx")
117        .join("kernel")
118        .join("snapshot.json")
119}
120
121fn temporary_path(path: &Path) -> PathBuf {
122    let mut temporary = path.as_os_str().to_owned();
123    temporary.push(format!(".tmp.{}", std::process::id()));
124    PathBuf::from(temporary)
125}
126
127#[cfg(test)]
128mod tests {
129    use std::collections::HashMap;
130    use std::path::Path;
131
132    use super::{KernelSnapshot, SnapshotError, load_snapshot, save_snapshot, temporary_path};
133
134    fn sample_snapshot() -> KernelSnapshot {
135        let provider_weights = HashMap::from([
136            ("filesystem".to_owned(), 0.75),
137            ("knowledge".to_owned(), 1.25),
138        ]);
139        let circuit_states = HashMap::from([
140            ("filesystem".to_owned(), "closed".to_owned()),
141            ("knowledge".to_owned(), "half_open".to_owned()),
142        ]);
143
144        KernelSnapshot {
145            version: 1,
146            timestamp_epoch: 1_700_000_000,
147            provider_weights,
148            recent_plan_ids: vec!["plan-1".to_owned(), "plan-2".to_owned()],
149            recent_receipt_ids: vec!["receipt-1".to_owned()],
150            degradation_level: "normal".to_owned(),
151            circuit_states,
152        }
153    }
154
155    #[test]
156    fn save_load_roundtrip() {
157        let directory = tempfile::tempdir().unwrap();
158        let path = directory.path().join("snapshot.json");
159        let snapshot = sample_snapshot();
160
161        save_snapshot(&snapshot, &path).unwrap();
162        let loaded = load_snapshot(&path).unwrap();
163
164        assert_eq!(loaded, snapshot);
165        assert!(!temporary_path(&path).exists());
166    }
167
168    #[test]
169    fn invalid_version_rejected() {
170        let directory = tempfile::tempdir().unwrap();
171        let path = directory.path().join("snapshot.json");
172        let mut snapshot = sample_snapshot();
173        snapshot.version = 99;
174        save_snapshot(&snapshot, &path).unwrap();
175
176        let error = load_snapshot(&path).unwrap_err();
177
178        assert!(matches!(error, SnapshotError::InvalidVersion(99)));
179    }
180
181    #[test]
182    fn missing_file_returns_error() {
183        let directory = tempfile::tempdir().unwrap();
184        let path = directory.path().join("missing.json");
185
186        let error = load_snapshot(&path).unwrap_err();
187
188        assert!(matches!(error, SnapshotError::Io(_)));
189    }
190
191    #[test]
192    fn atomic_write_no_partial() {
193        let directory = tempfile::tempdir().unwrap();
194        let missing_directory = directory.path().join("missing");
195        let path = missing_directory.join("snapshot.json");
196
197        let error = save_snapshot(&sample_snapshot(), &path).unwrap_err();
198
199        assert!(matches!(error, SnapshotError::Io(_)));
200        assert!(!Path::new(&path).exists());
201        assert!(!temporary_path(&path).exists());
202    }
203}