Skip to main content

lean_ctx/core/knowledge/
maintenance.rs

1//! Maintenance for the on-disk knowledge stores.
2//!
3//! A store at `<data_dir>/knowledge/<hash>/` is keyed to a `project_root`. When
4//! that root is deleted (a removed git worktree, a thrown-away temp project) the
5//! store can never be written again — its per-store eviction cap can therefore
6//! never self-heal (the memory lifecycle only runs on write) and the directory
7//! is pure accumulated bloat. This module finds and prunes those orphaned
8//! stores.
9//!
10//! Pruning is only ever invoked from the explicit `lean-ctx doctor --fix` path;
11//! the background lifecycle must never delete a store, since a missing root can
12//! also mean a temporarily-unmounted drive rather than a deleted project.
13
14use std::path::{Path, PathBuf};
15
16use super::ProjectKnowledge;
17
18/// A knowledge store whose recorded `project_root` no longer exists on disk.
19#[derive(Debug, Clone)]
20pub struct OrphanedStore {
21    pub hash: String,
22    pub project_root: String,
23    pub dir: PathBuf,
24    pub size_bytes: u64,
25}
26
27/// Outcome of a prune pass.
28#[derive(Debug, Default, Clone, Copy)]
29pub struct PruneReport {
30    pub removed: usize,
31    pub reclaimed_bytes: u64,
32}
33
34/// Scan every knowledge store under the real data dir and return the orphaned
35/// ones. See [`find_orphaned_stores_in`] for the detection rules.
36#[must_use]
37pub fn find_orphaned_stores() -> Vec<OrphanedStore> {
38    match crate::core::data_dir::lean_ctx_data_dir() {
39        Ok(data_dir) => find_orphaned_stores_in(&data_dir),
40        Err(_) => Vec::new(),
41    }
42}
43
44/// Remove every orphaned store under the real data dir. Best-effort.
45#[must_use]
46pub fn prune_orphaned_stores() -> PruneReport {
47    match crate::core::data_dir::lean_ctx_data_dir() {
48        Ok(data_dir) => prune_orphaned_stores_in(&data_dir),
49        Err(_) => PruneReport::default(),
50    }
51}
52
53/// Detect orphaned stores under an explicit `data_dir` (the testable core).
54///
55/// A store is orphaned when its `project_root` is **non-empty** and does **not**
56/// exist on disk. Stores with an empty root (the legacy/global store) and stores
57/// whose root still exists are always kept.
58#[must_use]
59pub fn find_orphaned_stores_in(data_dir: &Path) -> Vec<OrphanedStore> {
60    let knowledge_dir = data_dir.join("knowledge");
61    let Ok(entries) = std::fs::read_dir(&knowledge_dir) else {
62        return Vec::new();
63    };
64
65    let mut orphans = Vec::new();
66    for entry in entries.flatten() {
67        let dir = entry.path();
68        if !dir.is_dir() {
69            continue;
70        }
71        let Ok(content) = std::fs::read_to_string(dir.join("knowledge.json")) else {
72            continue;
73        };
74        let Ok(store) = serde_json::from_str::<ProjectKnowledge>(&content) else {
75            continue;
76        };
77
78        let root = store.project_root.trim();
79        // Empty root = legacy/global store: never an orphan.
80        if root.is_empty() {
81            continue;
82        }
83        // Live project: keep.
84        if Path::new(root).exists() {
85            continue;
86        }
87
88        // The directory name is the canonical store key (it also names the
89        // sibling `memory/episodes/<hash>.json` file). It equals `project_hash`
90        // for stores written by lean-ctx; trust the on-disk name regardless.
91        let hash = dir
92            .file_name()
93            .unwrap_or_default()
94            .to_string_lossy()
95            .to_string();
96        let size_bytes = dir_size(&dir);
97        orphans.push(OrphanedStore {
98            hash,
99            project_root: store.project_root,
100            dir,
101            size_bytes,
102        });
103    }
104    orphans
105}
106
107/// Prune orphaned stores under an explicit `data_dir` (the testable core).
108/// Removes each orphaned `knowledge/<hash>/` directory plus the matching
109/// episodic-memory file (`memory/episodes/<hash>.json`). A failure on one store
110/// never aborts the rest.
111#[must_use]
112pub fn prune_orphaned_stores_in(data_dir: &Path) -> PruneReport {
113    let mut report = PruneReport::default();
114    for orphan in find_orphaned_stores_in(data_dir) {
115        if std::fs::remove_dir_all(&orphan.dir).is_err() {
116            continue;
117        }
118        let mut freed = orphan.size_bytes;
119
120        // Episodic memory lives outside the hash dir, keyed by the same hash.
121        let episodes = data_dir
122            .join("memory")
123            .join("episodes")
124            .join(format!("{}.json", orphan.hash));
125        if let Ok(meta) = std::fs::metadata(&episodes)
126            && std::fs::remove_file(&episodes).is_ok()
127        {
128            freed = freed.saturating_add(meta.len());
129        }
130
131        report.removed += 1;
132        report.reclaimed_bytes = report.reclaimed_bytes.saturating_add(freed);
133    }
134    report
135}
136
137/// What `lean-ctx knowledge gc` found under the knowledge directory.
138///
139/// Only [`GcPlan::removable`] is ever removed, and only by an explicit
140/// `--apply`: directories that hold no data at all. A store that holds
141/// anything (facts, patterns, history, judged pairs, any field this build
142/// does not know) is never removed here; when its project is gone it is
143/// listed in [`GcPlan::kept_orphans`].
144#[derive(Debug, Default)]
145pub struct GcPlan {
146    /// Store directories that hold no data: an empty store whose project is
147    /// gone, or a directory with no store file at all (only the lock file,
148    /// or nothing) untouched for a day. Sorted.
149    pub removable: Vec<PathBuf>,
150    pub removable_bytes: u64,
151    /// Orphaned stores that still hold data: `(project root, facts)`.
152    pub kept_orphans: Vec<(String, usize)>,
153    /// Entries of the knowledge directory that are symbolic links: never
154    /// followed, never removed, reported.
155    pub symlinks: Vec<PathBuf>,
156    /// Store directories looked at.
157    pub scanned: usize,
158}
159
160/// Files of a store directory that carry no knowledge of their own.
161const BOOKKEEPING: [&str; 2] = ["knowledge.json", ".knowledge.lock"];
162
163/// Fields of a store that say whose it is and when it was written, not
164/// what it holds.
165const IDENTITY_FIELDS: [&str; 3] = ["project_root", "project_hash", "updated_at"];
166
167/// A lock-only directory younger than this may belong to a first save that
168/// is still running: it is left alone.
169const LOCK_ONLY_GRACE: std::time::Duration = std::time::Duration::from_hours(24);
170
171/// What one store directory is to a gc pass.
172enum GcClass {
173    /// Holds no data: may go with `--apply`.
174    Removable,
175    /// Its project is gone but it holds data: `(root, facts)`.
176    KeptOrphan(String, usize),
177    /// A live project, the legacy global store, or anything not understood.
178    Keep,
179}
180
181/// A field value that holds nothing.
182fn is_empty_value(value: &serde_json::Value) -> bool {
183    match value {
184        serde_json::Value::Null => true,
185        serde_json::Value::Array(items) => items.is_empty(),
186        serde_json::Value::Object(fields) => fields.is_empty(),
187        serde_json::Value::String(text) => text.is_empty(),
188        serde_json::Value::Bool(_) | serde_json::Value::Number(_) => false,
189    }
190}
191
192/// Classify one `knowledge/<hash>` directory under `data_dir`; reads only.
193/// `grace`: a directory without a store file must be a day old (off for the
194/// re-check under the lock, whose own lock file just touched the directory).
195fn classify(data_dir: &Path, dir: &Path, grace: bool) -> GcClass {
196    if std::fs::symlink_metadata(dir).map_or(true, |meta| !meta.is_dir()) {
197        return GcClass::Keep;
198    }
199    let Ok(entries) = std::fs::read_dir(dir).map(|entries| entries.flatten().collect::<Vec<_>>())
200    else {
201        return GcClass::Keep;
202    };
203    // Anything beside the store and its lock (a journal, embeddings,
204    // gotchas, …) is data of its own, and a link or a directory inside is
205    // not ours to judge: the directory is kept.
206    let names: Vec<String> = entries
207        .iter()
208        .map(|entry| entry.file_name().to_string_lossy().into_owned())
209        .collect();
210    if names
211        .iter()
212        .any(|name| !BOOKKEEPING.contains(&name.as_str()))
213        || entries
214            .iter()
215            .any(|entry| !entry.file_type().is_ok_and(|kind| kind.is_file()))
216    {
217        return GcClass::Keep;
218    }
219    let hash = dir.file_name().unwrap_or_default().to_string_lossy();
220    if data_dir
221        .join("memory")
222        .join("episodes")
223        .join(format!("{hash}.json"))
224        .exists()
225    {
226        return GcClass::Keep;
227    }
228    if !names.iter().any(|name| name == "knowledge.json") {
229        let settled = !grace
230            || std::fs::metadata(dir)
231                .and_then(|meta| meta.modified())
232                .ok()
233                .and_then(|at| at.elapsed().ok())
234                .is_some_and(|age| age >= LOCK_ONLY_GRACE);
235        return if settled {
236            GcClass::Removable
237        } else {
238            GcClass::Keep
239        };
240    }
241    let Some(serde_json::Value::Object(store)) =
242        std::fs::read_to_string(dir.join("knowledge.json"))
243            .ok()
244            .and_then(|content| serde_json::from_str::<serde_json::Value>(&content).ok())
245    else {
246        return GcClass::Keep;
247    };
248    let root = store
249        .get("project_root")
250        .and_then(serde_json::Value::as_str)
251        .unwrap_or_default()
252        .trim()
253        .to_owned();
254    // The empty root is the legacy global store; a live root is a project.
255    if root.is_empty() || Path::new(&root).exists() {
256        return GcClass::Keep;
257    }
258    let holds_data = store
259        .iter()
260        .any(|(name, value)| !IDENTITY_FIELDS.contains(&name.as_str()) && !is_empty_value(value));
261    if holds_data {
262        let facts = store
263            .get("facts")
264            .and_then(serde_json::Value::as_array)
265            .map_or(0, Vec::len);
266        GcClass::KeptOrphan(root, facts)
267    } else {
268        GcClass::Removable
269    }
270}
271
272/// Plan a gc pass under an explicit `data_dir`; reads only.
273#[must_use]
274pub fn plan_gc_in(data_dir: &Path) -> GcPlan {
275    let mut plan = GcPlan::default();
276    let Ok(entries) = std::fs::read_dir(data_dir.join("knowledge")) else {
277        return plan;
278    };
279    for entry in entries.flatten() {
280        let dir = entry.path();
281        let Ok(kind) = entry.file_type() else {
282            continue;
283        };
284        if kind.is_symlink() {
285            plan.symlinks.push(dir);
286            continue;
287        }
288        if !kind.is_dir() {
289            continue;
290        }
291        plan.scanned += 1;
292        match classify(data_dir, &dir, true) {
293            GcClass::Removable => {
294                plan.removable_bytes += dir_size(&dir);
295                plan.removable.push(dir);
296            }
297            GcClass::KeptOrphan(root, facts) => plan.kept_orphans.push((root, facts)),
298            GcClass::Keep => {}
299        }
300    }
301    plan.removable.sort();
302    plan.kept_orphans.sort();
303    plan.symlinks.sort();
304    plan
305}
306
307/// Remove the directories `plan` names as removable under `data_dir`. Each
308/// one is removed under its store lock (the lock every knowledge write
309/// takes), after it was classified again under that lock: one that gained
310/// data or a file since the plan, or whose lock is held, is kept. Returns
311/// how many were removed.
312#[must_use]
313pub fn apply_gc_in(data_dir: &Path, plan: &GcPlan) -> usize {
314    use fs2::FileExt;
315    plan.removable
316        .iter()
317        .filter(|dir| {
318            if std::fs::symlink_metadata(dir).map_or(true, |meta| !meta.is_dir()) {
319                return false;
320            }
321            let Ok(lock) =
322                crate::core::policy::diagnostics::open_append(&dir.join(".knowledge.lock"))
323            else {
324                return false;
325            };
326            if lock.try_lock_exclusive().is_err() {
327                return false;
328            }
329            let removed = matches!(classify(data_dir, dir, false), GcClass::Removable)
330                && std::fs::remove_dir_all(dir).is_ok();
331            let _ = FileExt::unlock(&lock);
332            removed
333        })
334        .count()
335}
336
337/// Recursively sum the size of every regular file under `dir`. Best-effort:
338/// unreadable entries contribute zero.
339fn dir_size(dir: &Path) -> u64 {
340    let Ok(entries) = std::fs::read_dir(dir) else {
341        return 0;
342    };
343    let mut total = 0u64;
344    for entry in entries.flatten() {
345        let path = entry.path();
346        if path.is_dir() {
347            total = total.saturating_add(dir_size(&path));
348        } else if let Ok(meta) = path.metadata() {
349            total = total.saturating_add(meta.len());
350        }
351    }
352    total
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    fn write_store(data_dir: &Path, hash: &str, project_root: &str) {
360        let dir = data_dir.join("knowledge").join(hash);
361        std::fs::create_dir_all(&dir).unwrap();
362        let store = ProjectKnowledge::new(project_root);
363        let json = serde_json::to_string(&store).unwrap();
364        std::fs::write(dir.join("knowledge.json"), json).unwrap();
365    }
366
367    #[test]
368    fn detects_only_missing_root_stores() {
369        let tmp = tempfile::tempdir().unwrap();
370        let data_dir = tmp.path();
371
372        // Live root: the temp dir itself exists.
373        let live_root = data_dir.to_string_lossy().to_string();
374        write_store(data_dir, "live0000000000000", &live_root);
375        // Empty root: legacy/global store — must never be flagged.
376        write_store(data_dir, "empty000000000000", "");
377        // Missing root: a path that does not exist — the orphan.
378        let missing_root = data_dir
379            .join("deleted-worktree")
380            .to_string_lossy()
381            .to_string();
382        write_store(data_dir, "dead00000000000000", &missing_root);
383
384        let orphans = find_orphaned_stores_in(data_dir);
385        assert_eq!(orphans.len(), 1, "only the missing-root store is an orphan");
386        assert_eq!(orphans[0].hash, "dead00000000000000");
387        assert!(orphans[0].size_bytes > 0, "orphan size should be measured");
388    }
389
390    #[test]
391    fn prune_removes_orphans_and_keeps_the_rest() {
392        let tmp = tempfile::tempdir().unwrap();
393        let data_dir = tmp.path();
394
395        let live_root = data_dir.to_string_lossy().to_string();
396        write_store(data_dir, "live0000000000000", &live_root);
397        write_store(data_dir, "empty000000000000", "");
398        let missing_root = data_dir.join("gone").to_string_lossy().to_string();
399        write_store(data_dir, "dead00000000000000", &missing_root);
400
401        // An episodic-memory file for the orphan must be cleaned up too.
402        let episodes_dir = data_dir.join("memory").join("episodes");
403        std::fs::create_dir_all(&episodes_dir).unwrap();
404        std::fs::write(episodes_dir.join("dead00000000000000.json"), "[]").unwrap();
405
406        let report = prune_orphaned_stores_in(data_dir);
407        assert_eq!(report.removed, 1, "exactly one orphan pruned");
408        assert!(
409            report.reclaimed_bytes > 0,
410            "reclaimed bytes should be reported"
411        );
412
413        assert!(
414            !data_dir
415                .join("knowledge")
416                .join("dead00000000000000")
417                .exists(),
418            "orphan store dir must be gone"
419        );
420        assert!(
421            !episodes_dir.join("dead00000000000000.json").exists(),
422            "orphan episodic file must be gone"
423        );
424        assert!(
425            data_dir
426                .join("knowledge")
427                .join("live0000000000000")
428                .exists(),
429            "live store must be kept"
430        );
431        assert!(
432            data_dir
433                .join("knowledge")
434                .join("empty000000000000")
435                .exists(),
436            "empty-root (legacy/global) store must be kept"
437        );
438    }
439
440    #[test]
441    fn prune_is_a_noop_when_nothing_is_orphaned() {
442        let tmp = tempfile::tempdir().unwrap();
443        let data_dir = tmp.path();
444        let live_root = data_dir.to_string_lossy().to_string();
445        write_store(data_dir, "live0000000000000", &live_root);
446
447        let report = prune_orphaned_stores_in(data_dir);
448        assert_eq!(report.removed, 0);
449        assert_eq!(report.reclaimed_bytes, 0);
450    }
451
452    /// `knowledge gc` removes only stores that hold no knowledge: an orphan
453    /// with a fact, a store with a file of its own and a live project stay.
454    #[test]
455    fn gc_removes_only_stores_without_knowledge() {
456        let tmp = tempfile::tempdir().unwrap();
457        let data_dir = tmp.path();
458        let gone = |name: &str| data_dir.join(name).to_string_lossy().to_string();
459        write_store(data_dir, "live0000000000000", &data_dir.to_string_lossy());
460        write_store(data_dir, "empty000000000000", "");
461        write_store(data_dir, "emptyorphan000000", &gone("tmp-a"));
462        write_store(data_dir, "journaled00000000", &gone("tmp-b"));
463        let journaled = data_dir.join("knowledge").join("journaled00000000");
464        std::fs::write(journaled.join("journal.jsonl"), "{}\n").unwrap();
465        let mut kept = ProjectKnowledge::new(&data_dir.to_string_lossy());
466        kept.remember(
467            "decision",
468            "db",
469            "Postgres",
470            "s1",
471            0.9,
472            &crate::core::memory_policy::MemoryPolicy::default(),
473        );
474        assert_eq!(kept.facts.len(), 1, "fixture fact");
475        // The worktree it was remembered in has been removed since.
476        kept.project_root = gone("removed-worktree");
477        let kept_dir = data_dir.join("knowledge").join("withfacts00000000");
478        std::fs::create_dir_all(&kept_dir).unwrap();
479        std::fs::write(
480            kept_dir.join("knowledge.json"),
481            serde_json::to_string(&kept).unwrap(),
482        )
483        .unwrap();
484
485        // A store with nothing but judged pairs still holds data.
486        let judged = data_dir.join("knowledge").join("judged00000000000");
487        std::fs::create_dir_all(&judged).unwrap();
488        std::fs::write(
489            judged.join("knowledge.json"),
490            serde_json::json!({
491                "project_root": gone("tmp-c"),
492                "project_hash": "judged00000000000",
493                "facts": [], "patterns": [], "history": [],
494                "judged_pairs": [["a", "b"]],
495            })
496            .to_string(),
497        )
498        .unwrap();
499        // A linked store directory is reported, never followed or removed.
500        #[cfg(unix)]
501        std::os::unix::fs::symlink(
502            data_dir.join("knowledge").join("emptyorphan000000"),
503            data_dir.join("knowledge").join("linked00000000000"),
504        )
505        .unwrap();
506
507        let plan = plan_gc_in(data_dir);
508        assert_eq!(plan.scanned, 6);
509        assert_eq!(
510            plan.removable,
511            vec![data_dir.join("knowledge").join("emptyorphan000000")]
512        );
513        assert_eq!(
514            plan.kept_orphans,
515            vec![(gone("removed-worktree"), 1), (gone("tmp-c"), 0)]
516        );
517        #[cfg(unix)]
518        assert_eq!(
519            plan.symlinks,
520            vec![data_dir.join("knowledge").join("linked00000000000")]
521        );
522
523        // The plan is not trusted blindly: a store that gained a file after
524        // the dry run is kept.
525        std::fs::write(
526            data_dir
527                .join("knowledge")
528                .join("emptyorphan000000")
529                .join("gotchas.json"),
530            "[]",
531        )
532        .unwrap();
533        assert_eq!(apply_gc_in(data_dir, &plan), 0);
534        std::fs::remove_file(
535            data_dir
536                .join("knowledge")
537                .join("emptyorphan000000")
538                .join("gotchas.json"),
539        )
540        .unwrap();
541        assert_eq!(apply_gc_in(data_dir, &plan), 1);
542        for kept in [
543            "live0000000000000",
544            "empty000000000000",
545            "journaled00000000",
546            "withfacts00000000",
547            "judged00000000000",
548        ] {
549            assert!(data_dir.join("knowledge").join(kept).exists(), "{kept}");
550        }
551    }
552}