Skip to main content

lean_ctx/core/
memory_archive.rs

1//! Multi-store, lossless memory archive (#995 Phase 1).
2//!
3//! Every memory store — facts, history, procedures, patterns — archives the
4//! items it evicts here *before* dropping them, so capacity management is never
5//! lossy and anything reclaimed can be restored. This is the single archive
6//! subsystem behind [`crate::core::memory_capacity`] and the recall-miss
7//! rehydrate path.
8//!
9//! ## On-disk layout
10//! - Facts keep their legacy global location `memory/archive/archive-*.json`
11//!   for backward compatibility (pre-#995 archives stay readable).
12//! - Every other store lives under `memory/archive/<store>/<scope>/archive-*.json`,
13//!   where `<scope>` is the per-project hash so a restore lands in the right
14//!   project.
15//!
16//! ## Format
17//! A single envelope ([`ArchiveEnvelope`]) is used for all stores. The item
18//! collection serializes as `items`; the `facts` alias keeps legacy facts
19//! archives (which used that key) deserializable.
20
21use chrono::{DateTime, Utc};
22use serde::{Deserialize, Serialize, de::DeserializeOwned};
23use std::path::{Path, PathBuf};
24
25/// Retention bound on archive files, kept well above the rehydrate reach so a
26/// recall miss can still find recently-evicted items. Overridable via
27/// `LEAN_CTX_ARCHIVE_MAX_FILES`.
28const DEFAULT_MAX_ARCHIVE_FILES: usize = 16;
29
30/// Which memory store an archive belongs to. Drives the on-disk path only — the
31/// archive is generic over the item type, so this stays decoupled from the
32/// concrete fact/insight/procedure/pattern structs.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum MemoryStore {
35    Facts,
36    History,
37    Procedures,
38    Patterns,
39}
40
41impl MemoryStore {
42    pub fn as_str(self) -> &'static str {
43        match self {
44            MemoryStore::Facts => "facts",
45            MemoryStore::History => "history",
46            MemoryStore::Procedures => "procedures",
47            MemoryStore::Patterns => "patterns",
48        }
49    }
50
51    pub fn parse(s: &str) -> Option<Self> {
52        match s.trim().to_lowercase().as_str() {
53            "facts" | "fact" => Some(MemoryStore::Facts),
54            "history" | "insights" => Some(MemoryStore::History),
55            "procedures" | "procedure" | "procs" => Some(MemoryStore::Procedures),
56            "patterns" | "pattern" => Some(MemoryStore::Patterns),
57            _ => None,
58        }
59    }
60
61    /// All stores, for cross-store iteration (restore, reporting).
62    pub fn all() -> [MemoryStore; 4] {
63        [
64            MemoryStore::Facts,
65            MemoryStore::History,
66            MemoryStore::Procedures,
67            MemoryStore::Patterns,
68        ]
69    }
70
71    /// Subdirectory under `memory/archive`. Facts return `None` (legacy root).
72    fn subdir(self) -> Option<&'static str> {
73        match self {
74            MemoryStore::Facts => None,
75            other => Some(other.as_str()),
76        }
77    }
78}
79
80/// Tunable archive bounds. `rehydrate_reach` is how many of the newest archives
81/// the recall-miss path scans; it defaults to `max_files` so every retained
82/// archive is actually reachable (closing the pre-#995 16-retained / 4-reachable
83/// gap). Both are overridable via env for ops tuning.
84#[derive(Debug, Clone, Copy)]
85pub struct ArchiveConfig {
86    pub max_files: usize,
87    pub rehydrate_reach: usize,
88}
89
90impl Default for ArchiveConfig {
91    fn default() -> Self {
92        Self {
93            max_files: DEFAULT_MAX_ARCHIVE_FILES,
94            rehydrate_reach: DEFAULT_MAX_ARCHIVE_FILES,
95        }
96    }
97}
98
99impl ArchiveConfig {
100    /// Read overrides from the environment. `rehydrate_reach` defaults to
101    /// `max_files` and is clamped to it (cannot reach more files than retained).
102    pub fn from_env() -> Self {
103        let mut cfg = Self::default();
104        if let Ok(v) = std::env::var("LEAN_CTX_ARCHIVE_MAX_FILES")
105            && let Ok(n) = v.parse::<usize>()
106            && n > 0
107        {
108            cfg.max_files = n;
109            cfg.rehydrate_reach = n;
110        }
111        if let Ok(v) = std::env::var("LEAN_CTX_ARCHIVE_REHYDRATE_REACH")
112            && let Ok(n) = v.parse::<usize>()
113            && n > 0
114        {
115            cfg.rehydrate_reach = n;
116        }
117        cfg.rehydrate_reach = cfg.rehydrate_reach.min(cfg.max_files);
118        cfg
119    }
120}
121
122/// Unified archive envelope for reading every store. The collection is keyed
123/// `items`; the `facts` alias keeps legacy facts archives (which used that key)
124/// deserializable.
125#[derive(Debug, Deserialize)]
126pub struct ArchiveEnvelope<T> {
127    pub archived_at: DateTime<Utc>,
128    #[serde(default)]
129    pub store: String,
130    #[serde(default)]
131    pub scope: Option<String>,
132    #[serde(rename = "items", alias = "facts")]
133    pub items: Vec<T>,
134}
135
136/// Borrowed write-side envelope, so archiving never has to clone the evicted
137/// slice. Mirrors [`ArchiveEnvelope`]'s on-disk shape exactly.
138#[derive(Serialize)]
139struct ArchiveEnvelopeRef<'a, T: Serialize> {
140    archived_at: DateTime<Utc>,
141    store: &'a str,
142    #[serde(skip_serializing_if = "Option::is_none")]
143    scope: Option<&'a str>,
144    items: &'a [T],
145}
146
147fn archive_dir(store: MemoryStore, scope: Option<&str>) -> Result<PathBuf, String> {
148    let base = crate::core::data_dir::lean_ctx_data_dir()?
149        .join("memory")
150        .join("archive");
151    let dir = match (store.subdir(), scope) {
152        (None, _) => base,                   // facts: legacy global root
153        (Some(sub), None) => base.join(sub), // store-global
154        (Some(sub), Some(s)) => base.join(sub).join(sanitize_scope(s)),
155    };
156    Ok(dir)
157}
158
159/// Scopes are project hashes (hex). Guard against path traversal regardless,
160/// so a malformed scope can never escape the archive root.
161fn sanitize_scope(scope: &str) -> String {
162    scope
163        .chars()
164        .map(|c| {
165            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
166                c
167            } else {
168                '_'
169            }
170        })
171        .collect()
172}
173
174/// Archive `items` for `store`/`scope`, then prune the directory to
175/// `cfg.max_files` newest. Returns the written path, or `None` when there was
176/// nothing to archive. Best-effort prune: a prune failure never fails the write.
177pub fn archive_items<T: Serialize>(
178    store: MemoryStore,
179    scope: Option<&str>,
180    items: &[T],
181    cfg: &ArchiveConfig,
182) -> Result<Option<PathBuf>, String> {
183    if items.is_empty() {
184        return Ok(None);
185    }
186    let dir = archive_dir(store, scope)?;
187    std::fs::create_dir_all(&dir).map_err(|e| format!("{e}"))?;
188
189    // Sub-second suffix avoids same-second filename collisions that would
190    // otherwise silently overwrite a prior archive in the same wall-clock second.
191    let now = Utc::now();
192    let suffix = now.timestamp_subsec_nanos() % 1_000_000;
193    let filename = format!("archive-{}-{suffix:06}.json", now.format("%Y%m%d-%H%M%S"));
194    let path = dir.join(filename);
195
196    let envelope = ArchiveEnvelopeRef {
197        archived_at: now,
198        store: store.as_str(),
199        scope,
200        items,
201    };
202    let json = serde_json::to_string_pretty(&envelope).map_err(|e| format!("{e}"))?;
203    std::fs::write(&path, json).map_err(|e| format!("{e}"))?;
204
205    let archives = list_archives(store, scope);
206    if archives.len() > cfg.max_files {
207        for old in &archives[..archives.len() - cfg.max_files] {
208            let _ = std::fs::remove_file(old);
209        }
210    }
211    Ok(Some(path))
212}
213
214/// All archive files for `store`/`scope`, sorted ascending (lexical ==
215/// chronological for the zero-padded timestamp filename prefix).
216pub fn list_archives(store: MemoryStore, scope: Option<&str>) -> Vec<PathBuf> {
217    let Ok(dir) = archive_dir(store, scope) else {
218        return Vec::new();
219    };
220    if !dir.exists() {
221        return Vec::new();
222    }
223    let mut archives: Vec<PathBuf> = std::fs::read_dir(&dir)
224        .into_iter()
225        .flatten()
226        .flatten()
227        .map(|e| e.path())
228        .filter(|p| p.is_file() && p.extension().is_some_and(|ext| ext == "json"))
229        .collect();
230    archives.sort();
231    archives
232}
233
234/// The newest `cfg.rehydrate_reach` archives for `store`/`scope` — the set a
235/// recall miss should scan. Aligned with retention so nothing retained is
236/// unreachable.
237pub fn reachable_archives(
238    store: MemoryStore,
239    scope: Option<&str>,
240    cfg: &ArchiveConfig,
241) -> Vec<PathBuf> {
242    let mut archives = list_archives(store, scope);
243    if archives.len() > cfg.rehydrate_reach {
244        archives = archives[archives.len() - cfg.rehydrate_reach..].to_vec();
245    }
246    archives
247}
248
249/// Restore the items from a single archive file.
250pub fn restore_items<T: DeserializeOwned>(path: &Path) -> Result<Vec<T>, String> {
251    let data = std::fs::read_to_string(path).map_err(|e| format!("{e}"))?;
252    let envelope: ArchiveEnvelope<T> = serde_json::from_str(&data).map_err(|e| format!("{e}"))?;
253    Ok(envelope.items)
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    fn with_temp_data_dir<T>(f: impl FnOnce() -> T) -> T {
261        let _lock = crate::core::data_dir::test_env_lock();
262        let dir = std::env::temp_dir().join(format!(
263            "lctx-archive-{}-{}",
264            std::process::id(),
265            Utc::now().timestamp_nanos_opt().unwrap_or(0)
266        ));
267        let _ = std::fs::create_dir_all(&dir);
268        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
269        let out = f();
270        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
271        let _ = std::fs::remove_dir_all(&dir);
272        out
273    }
274
275    #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
276    struct Item {
277        id: u32,
278        label: String,
279    }
280
281    fn items(n: u32) -> Vec<Item> {
282        (0..n)
283            .map(|i| Item {
284                id: i,
285                label: format!("item-{i}"),
286            })
287            .collect()
288    }
289
290    #[test]
291    fn round_trip_each_store() {
292        with_temp_data_dir(|| {
293            let cfg = ArchiveConfig::default();
294            for store in MemoryStore::all() {
295                let scope = if store == MemoryStore::Facts {
296                    None
297                } else {
298                    Some("projhash")
299                };
300                let path = archive_items(store, scope, &items(3), &cfg)
301                    .unwrap()
302                    .expect("wrote an archive");
303                let restored: Vec<Item> = restore_items(&path).unwrap();
304                assert_eq!(restored, items(3), "round-trip for {}", store.as_str());
305            }
306        });
307    }
308
309    #[test]
310    fn empty_archive_is_noop() {
311        with_temp_data_dir(|| {
312            let cfg = ArchiveConfig::default();
313            let res =
314                archive_items(MemoryStore::History, Some("p"), &Vec::<Item>::new(), &cfg).unwrap();
315            assert!(res.is_none());
316            assert!(list_archives(MemoryStore::History, Some("p")).is_empty());
317        });
318    }
319
320    #[test]
321    fn facts_use_legacy_root_other_stores_are_scoped() {
322        with_temp_data_dir(|| {
323            let base = crate::core::data_dir::lean_ctx_data_dir()
324                .unwrap()
325                .join("memory")
326                .join("archive");
327            assert_eq!(archive_dir(MemoryStore::Facts, None).unwrap(), base);
328            assert_eq!(
329                archive_dir(MemoryStore::History, Some("h")).unwrap(),
330                base.join("history").join("h")
331            );
332        });
333    }
334
335    #[test]
336    fn legacy_facts_field_alias_still_deserializes() {
337        with_temp_data_dir(|| {
338            let dir = archive_dir(MemoryStore::Facts, None).unwrap();
339            std::fs::create_dir_all(&dir).unwrap();
340            // A pre-#995 facts archive used the `facts` key, no store/scope.
341            let legacy =
342                r#"{"archived_at":"2026-01-01T00:00:00Z","facts":[{"id":7,"label":"old"}]}"#;
343            let path = dir.join("archive-20260101-000000-000000.json");
344            std::fs::write(&path, legacy).unwrap();
345            let restored: Vec<Item> = restore_items(&path).unwrap();
346            assert_eq!(
347                restored,
348                vec![Item {
349                    id: 7,
350                    label: "old".into()
351                }]
352            );
353        });
354    }
355
356    #[test]
357    fn prune_keeps_newest_max_files() {
358        with_temp_data_dir(|| {
359            let cfg = ArchiveConfig {
360                max_files: 3,
361                rehydrate_reach: 3,
362            };
363            for _ in 0..6 {
364                // Distinct filenames require distinct sub-second suffixes; a tiny
365                // sleep guarantees monotonic timestamps on fast machines.
366                archive_items(MemoryStore::Patterns, Some("p"), &items(1), &cfg).unwrap();
367                std::thread::sleep(std::time::Duration::from_millis(2));
368            }
369            let archives = list_archives(MemoryStore::Patterns, Some("p"));
370            assert!(
371                archives.len() <= 3,
372                "prune should bound to max_files, got {}",
373                archives.len()
374            );
375        });
376    }
377
378    #[test]
379    fn reachable_is_bounded_and_aligns_with_retention_by_default() {
380        let cfg = ArchiveConfig::default();
381        assert_eq!(cfg.rehydrate_reach, cfg.max_files);
382    }
383
384    #[test]
385    fn from_env_reach_clamped_to_max() {
386        let _lock = crate::core::data_dir::test_env_lock();
387        crate::test_env::set_var("LEAN_CTX_ARCHIVE_MAX_FILES", "5");
388        crate::test_env::set_var("LEAN_CTX_ARCHIVE_REHYDRATE_REACH", "99");
389        let cfg = ArchiveConfig::from_env();
390        assert_eq!(cfg.max_files, 5);
391        assert_eq!(cfg.rehydrate_reach, 5, "reach clamped to max_files");
392        crate::test_env::remove_var("LEAN_CTX_ARCHIVE_MAX_FILES");
393        crate::test_env::remove_var("LEAN_CTX_ARCHIVE_REHYDRATE_REACH");
394    }
395
396    #[test]
397    fn store_parse_round_trips() {
398        for store in MemoryStore::all() {
399            assert_eq!(MemoryStore::parse(store.as_str()), Some(store));
400        }
401        assert_eq!(MemoryStore::parse("nope"), None);
402    }
403}