Skip to main content

clawft_kernel/
persistence.rs

1//! Unified persistence coordinator for kernel state.
2//!
3//! Provides a single entry point to save and restore all kernel
4//! subsystems (CausalGraph, HNSW index, ExoChain) to a data directory.
5//! Uses file-based JSON persistence — no external database required.
6
7use std::path::PathBuf;
8
9use serde::{Deserialize, Serialize};
10
11use crate::causal::CausalGraph;
12use crate::hnsw_service::{HnswService, HnswServiceConfig};
13
14/// Configuration for the persistence coordinator.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct PersistenceConfig {
17    /// Root directory for all persisted state.
18    pub data_dir: PathBuf,
19    /// If set, auto-save interval in seconds (for future use with a
20    /// background timer).
21    pub auto_save_interval_secs: Option<u64>,
22}
23
24impl Default for PersistenceConfig {
25    fn default() -> Self {
26        Self {
27            data_dir: PathBuf::from(".weftos/state"),
28            auto_save_interval_secs: None,
29        }
30    }
31}
32
33impl PersistenceConfig {
34    /// Path for the causal graph snapshot.
35    pub fn causal_graph_path(&self) -> PathBuf {
36        self.data_dir.join("causal_graph.json")
37    }
38
39    /// Path for the HNSW index snapshot.
40    pub fn hnsw_index_path(&self) -> PathBuf {
41        self.data_dir.join("hnsw_index.json")
42    }
43
44    /// Path for the ExoChain snapshot.
45    pub fn chain_path(&self) -> PathBuf {
46        self.data_dir.join("exochain.jsonl")
47    }
48}
49
50/// Save the causal graph to the configured data directory.
51pub fn save_causal_graph(
52    config: &PersistenceConfig,
53    graph: &CausalGraph,
54) -> Result<(), std::io::Error> {
55    graph.save_to_file(&config.causal_graph_path())
56}
57
58/// Load a causal graph from the configured data directory.
59///
60/// Returns a new empty graph if the file does not exist.
61pub fn load_causal_graph(config: &PersistenceConfig) -> Result<CausalGraph, std::io::Error> {
62    let path = config.causal_graph_path();
63    if !path.exists() {
64        return Ok(CausalGraph::new());
65    }
66    CausalGraph::load_from_file(&path)
67}
68
69/// Save the HNSW service state to the configured data directory.
70pub fn save_hnsw(config: &PersistenceConfig, service: &HnswService) -> Result<(), std::io::Error> {
71    service.save_to_file(&config.hnsw_index_path())
72}
73
74/// Load an HNSW service from the configured data directory.
75///
76/// Returns a new empty service if the file does not exist.
77pub fn load_hnsw(config: &PersistenceConfig) -> Result<HnswService, std::io::Error> {
78    let path = config.hnsw_index_path();
79    if !path.exists() {
80        return Ok(HnswService::new(HnswServiceConfig::default()));
81    }
82    HnswService::load_from_file(&path)
83}
84
85/// Save all kernel state to the configured data directory.
86pub fn save_all(
87    config: &PersistenceConfig,
88    graph: &CausalGraph,
89    hnsw: &HnswService,
90) -> Result<(), std::io::Error> {
91    std::fs::create_dir_all(&config.data_dir)?;
92    save_causal_graph(config, graph)?;
93    save_hnsw(config, hnsw)?;
94    Ok(())
95}
96
97/// Save all kernel state, logging the event to the exochain.
98#[cfg(feature = "exochain")]
99pub fn save_all_with_chain(
100    config: &PersistenceConfig,
101    graph: &CausalGraph,
102    hnsw: &HnswService,
103    cm: &crate::chain::ChainManager,
104) -> Result<(), std::io::Error> {
105    cm.append(
106        "persistence",
107        crate::chain::EVENT_KIND_KERNEL_SAVE,
108        Some(serde_json::json!({
109            "data_dir": config.data_dir.display().to_string(),
110            "node_count": graph.node_count(),
111            "hnsw_count": hnsw.len(),
112        })),
113    );
114    save_all(config, graph, hnsw)
115}
116
117/// Restore all kernel state from the configured data directory.
118///
119/// Components that have no saved state are returned as fresh instances.
120pub fn load_all(config: &PersistenceConfig) -> Result<(CausalGraph, HnswService), std::io::Error> {
121    let graph = load_causal_graph(config)?;
122    let hnsw = load_hnsw(config)?;
123    Ok((graph, hnsw))
124}
125
126/// Restore all kernel state, logging the event to the exochain.
127#[cfg(feature = "exochain")]
128pub fn load_all_with_chain(
129    config: &PersistenceConfig,
130    cm: &crate::chain::ChainManager,
131) -> Result<(CausalGraph, HnswService), std::io::Error> {
132    let result = load_all(config)?;
133    cm.append(
134        "persistence",
135        crate::chain::EVENT_KIND_KERNEL_LOAD,
136        Some(serde_json::json!({
137            "data_dir": config.data_dir.display().to_string(),
138            "node_count": result.0.node_count(),
139            "hnsw_count": result.1.len(),
140        })),
141    );
142    Ok(result)
143}
144
145// ── Tests ────────────────────────────────────────────────────────────────
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    fn tmp_config() -> PersistenceConfig {
152        // A unique, not-yet-existing data dir inside a fresh temp dir. A
153        // timestamp name collided under parallel nextest on macOS, whose
154        // clock is microsecond-grained.
155        let dir = tempfile::tempdir()
156            .expect("temp dir")
157            .keep()
158            .join("weftos_persist_test");
159        PersistenceConfig {
160            data_dir: dir,
161            auto_save_interval_secs: None,
162        }
163    }
164
165    #[test]
166    fn config_paths() {
167        let cfg = PersistenceConfig {
168            data_dir: PathBuf::from("/tmp/test"),
169            auto_save_interval_secs: None,
170        };
171        assert_eq!(
172            cfg.causal_graph_path(),
173            PathBuf::from("/tmp/test/causal_graph.json")
174        );
175        assert_eq!(
176            cfg.hnsw_index_path(),
177            PathBuf::from("/tmp/test/hnsw_index.json")
178        );
179        assert_eq!(cfg.chain_path(), PathBuf::from("/tmp/test/exochain.jsonl"));
180    }
181
182    #[test]
183    fn load_missing_returns_defaults() {
184        let cfg = tmp_config();
185        let graph = load_causal_graph(&cfg).unwrap();
186        assert_eq!(graph.node_count(), 0);
187        let hnsw = load_hnsw(&cfg).unwrap();
188        assert!(hnsw.is_empty());
189    }
190
191    #[test]
192    fn save_and_load_all_roundtrip() {
193        let cfg = tmp_config();
194
195        let graph = CausalGraph::new();
196        let a = graph.add_node("A".into(), serde_json::json!({"x": 1}));
197        let b = graph.add_node("B".into(), serde_json::json!({}));
198        graph.link(a, b, crate::causal::CausalEdgeType::Causes, 0.9, 100, 1);
199
200        let hnsw = HnswService::new(HnswServiceConfig::default());
201        hnsw.insert(
202            "v1".into(),
203            vec![1.0, 0.0, 0.0],
204            serde_json::json!({"tag": "first"}),
205        );
206
207        save_all(&cfg, &graph, &hnsw).unwrap();
208
209        let (loaded_graph, loaded_hnsw) = load_all(&cfg).unwrap();
210        assert_eq!(loaded_graph.node_count(), 2);
211        assert_eq!(loaded_graph.edge_count(), 1);
212        assert_eq!(loaded_hnsw.len(), 1);
213
214        // Cleanup.
215        let _ = std::fs::remove_dir_all(&cfg.data_dir);
216    }
217
218    // ── Corrupt file recovery ───────────────────────────────────────
219
220    #[test]
221    fn corrupt_causal_graph_file_returns_error() {
222        let cfg = tmp_config();
223        std::fs::create_dir_all(&cfg.data_dir).unwrap();
224
225        // Write garbage bytes to the causal graph file.
226        std::fs::write(cfg.causal_graph_path(), b"{{{{not json at all!").unwrap();
227
228        let result = load_causal_graph(&cfg);
229        assert!(result.is_err(), "loading corrupt causal graph should fail");
230        let err = result.unwrap_err();
231        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
232
233        let _ = std::fs::remove_dir_all(&cfg.data_dir);
234    }
235
236    #[test]
237    fn corrupt_hnsw_file_returns_error() {
238        let cfg = tmp_config();
239        std::fs::create_dir_all(&cfg.data_dir).unwrap();
240
241        // Write garbage bytes to the HNSW file.
242        std::fs::write(cfg.hnsw_index_path(), b"\x00\x01\x02binary garbage").unwrap();
243
244        let result = load_hnsw(&cfg);
245        assert!(result.is_err(), "loading corrupt HNSW index should fail");
246        let err = result.unwrap_err();
247        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
248
249        let _ = std::fs::remove_dir_all(&cfg.data_dir);
250    }
251
252    #[test]
253    fn truncated_json_returns_error() {
254        let cfg = tmp_config();
255        std::fs::create_dir_all(&cfg.data_dir).unwrap();
256
257        // Write truncated but plausible JSON.
258        std::fs::write(cfg.causal_graph_path(), b"{\"next_node_id\":5,\"nodes\":").unwrap();
259
260        let result = load_causal_graph(&cfg);
261        assert!(result.is_err(), "loading truncated JSON should fail");
262
263        let _ = std::fs::remove_dir_all(&cfg.data_dir);
264    }
265
266    #[test]
267    fn empty_file_returns_error() {
268        let cfg = tmp_config();
269        std::fs::create_dir_all(&cfg.data_dir).unwrap();
270
271        // Write zero-length file.
272        std::fs::write(cfg.causal_graph_path(), b"").unwrap();
273
274        let result = load_causal_graph(&cfg);
275        assert!(result.is_err(), "loading empty file should fail");
276
277        let _ = std::fs::remove_dir_all(&cfg.data_dir);
278    }
279
280    // ── Concurrent write handling ───────────────────────────────────
281
282    #[test]
283    fn concurrent_saves_do_not_corrupt() {
284        let cfg = tmp_config();
285        std::fs::create_dir_all(&cfg.data_dir).unwrap();
286
287        let graph = CausalGraph::new();
288        for i in 0..100 {
289            graph.add_node(format!("node-{i}"), serde_json::json!({"i": i}));
290        }
291        let hnsw = HnswService::new(HnswServiceConfig::default());
292        for i in 0..50 {
293            hnsw.insert(
294                format!("v{i}"),
295                vec![i as f32, 0.0, 0.0],
296                serde_json::json!({}),
297            );
298        }
299
300        // Save from two threads simultaneously.
301        let cfg1 = cfg.clone();
302        let cfg2 = cfg.clone();
303        let graph_ref = &graph;
304        let hnsw_ref = &hnsw;
305
306        std::thread::scope(|s| {
307            let h1 = s.spawn(|| save_all(&cfg1, graph_ref, hnsw_ref));
308            let h2 = s.spawn(|| save_all(&cfg2, graph_ref, hnsw_ref));
309
310            // Both saves should succeed (last-writer-wins on file I/O).
311            h1.join().unwrap().unwrap();
312            h2.join().unwrap().unwrap();
313        });
314
315        // The resulting files should be loadable.
316        let (loaded_graph, loaded_hnsw) = load_all(&cfg).unwrap();
317        assert_eq!(loaded_graph.node_count(), 100);
318        assert_eq!(loaded_hnsw.len(), 50);
319
320        let _ = std::fs::remove_dir_all(&cfg.data_dir);
321    }
322
323    // ── Disk-full / read-only simulation ────────────────────────────
324
325    #[test]
326    fn save_to_nonexistent_deep_path_creates_dirs() {
327        let cfg = PersistenceConfig {
328            data_dir: std::env::temp_dir()
329                .join(format!(
330                    "weftos_deep_{}",
331                    std::time::SystemTime::now()
332                        .duration_since(std::time::UNIX_EPOCH)
333                        .unwrap()
334                        .as_nanos()
335                ))
336                .join("a")
337                .join("b")
338                .join("c"),
339            auto_save_interval_secs: None,
340        };
341
342        let graph = CausalGraph::new();
343        let hnsw = HnswService::new(HnswServiceConfig::default());
344
345        // save_all creates intermediate directories.
346        save_all(&cfg, &graph, &hnsw).unwrap();
347        assert!(cfg.causal_graph_path().exists());
348
349        let _ = std::fs::remove_dir_all(
350            cfg.data_dir
351                .parent()
352                .unwrap()
353                .parent()
354                .unwrap()
355                .parent()
356                .unwrap(),
357        );
358    }
359
360    #[test]
361    fn save_to_readonly_dir_fails() {
362        // Create a directory, then make it read-only.
363        let base = std::env::temp_dir().join(format!(
364            "weftos_ro_{}",
365            std::time::SystemTime::now()
366                .duration_since(std::time::UNIX_EPOCH)
367                .unwrap()
368                .as_nanos()
369        ));
370        std::fs::create_dir_all(&base).unwrap();
371
372        // Set directory permissions to read-only.
373        let mut perms = std::fs::metadata(&base).unwrap().permissions();
374        #[allow(clippy::permissions_set_readonly_false)]
375        perms.set_readonly(true);
376        std::fs::set_permissions(&base, perms.clone()).unwrap();
377
378        let cfg = PersistenceConfig {
379            data_dir: base.join("state"),
380            auto_save_interval_secs: None,
381        };
382
383        let graph = CausalGraph::new();
384        let hnsw = HnswService::new(HnswServiceConfig::default());
385
386        let result = save_all(&cfg, &graph, &hnsw);
387        assert!(result.is_err(), "saving to read-only dir should fail");
388
389        // Restore permissions for cleanup (test teardown only — we must make the
390        // temp dir writable again to delete it).
391        #[allow(clippy::permissions_set_readonly_false)]
392        perms.set_readonly(false);
393        let _ = std::fs::set_permissions(&base, perms);
394        let _ = std::fs::remove_dir_all(&base);
395    }
396
397    // ── Large data roundtrip ────────────────────────────────────────
398
399    #[test]
400    fn save_load_roundtrip_large_graph() {
401        let cfg = tmp_config();
402
403        let graph = CausalGraph::new();
404        let mut node_ids = Vec::with_capacity(1000);
405        for i in 0..1000 {
406            let nid = graph.add_node(
407                format!("node-{i}"),
408                serde_json::json!({"index": i, "data": "x".repeat(50)}),
409            );
410            node_ids.push(nid);
411        }
412
413        // Create edges between consecutive nodes.
414        for window in node_ids.windows(2) {
415            graph.link(
416                window[0],
417                window[1],
418                crate::causal::CausalEdgeType::Follows,
419                0.8,
420                window[0],
421                0,
422            );
423        }
424
425        let hnsw = HnswService::new(HnswServiceConfig::default());
426        for i in 0..1000 {
427            hnsw.insert(
428                format!("vec-{i}"),
429                vec![i as f32, (i * 2) as f32, (i * 3) as f32],
430                serde_json::json!({"i": i}),
431            );
432        }
433
434        save_all(&cfg, &graph, &hnsw).unwrap();
435
436        let (loaded_graph, loaded_hnsw) = load_all(&cfg).unwrap();
437        assert_eq!(loaded_graph.node_count(), 1000);
438        assert_eq!(loaded_graph.edge_count(), 999);
439        assert_eq!(loaded_hnsw.len(), 1000);
440
441        let _ = std::fs::remove_dir_all(&cfg.data_dir);
442    }
443
444    #[test]
445    fn save_load_roundtrip_preserves_node_data() {
446        let cfg = tmp_config();
447
448        let graph = CausalGraph::new();
449        let nid = graph.add_node(
450            "important-node".into(),
451            serde_json::json!({"key": "value", "nested": {"a": [1,2,3]}}),
452        );
453
454        let hnsw = HnswService::new(HnswServiceConfig::default());
455
456        save_all(&cfg, &graph, &hnsw).unwrap();
457
458        let (loaded_graph, _) = load_all(&cfg).unwrap();
459        assert_eq!(loaded_graph.node_count(), 1);
460        // Verify the node data is intact by checking we can retrieve by ID.
461        let nodes = loaded_graph.get_node(nid);
462        assert!(
463            nodes.is_some(),
464            "loaded graph should contain the saved node"
465        );
466        assert_eq!(nodes.unwrap().label, "important-node");
467
468        let _ = std::fs::remove_dir_all(&cfg.data_dir);
469    }
470
471    #[test]
472    fn double_save_load_is_idempotent() {
473        let cfg = tmp_config();
474
475        let graph = CausalGraph::new();
476        graph.add_node("A".into(), serde_json::json!({}));
477        let hnsw = HnswService::new(HnswServiceConfig::default());
478
479        save_all(&cfg, &graph, &hnsw).unwrap();
480        save_all(&cfg, &graph, &hnsw).unwrap();
481
482        let (loaded, _) = load_all(&cfg).unwrap();
483        assert_eq!(loaded.node_count(), 1);
484
485        let _ = std::fs::remove_dir_all(&cfg.data_dir);
486    }
487}