lean_ctx/core/context_kernel/
recovery.rs1use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::time::{SystemTime, UNIX_EPOCH};
6
7const SNAPSHOT_VERSION: u32 = 1;
8
9#[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 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#[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
80pub 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
99pub 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
112pub 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}