Skip to main content

lean_ctx/core/knowledge/
persist.rs

1use chrono::Utc;
2use std::collections::{HashMap, HashSet};
3use std::path::{Path, PathBuf};
4use std::sync::{Arc, Mutex, OnceLock};
5
6use super::ranking::hash_project_root;
7use super::types::{ConsolidatedInsight, KnowledgeFact, ProjectKnowledge, ProjectPattern};
8use crate::core::memory_policy::MemoryPolicy;
9
10fn knowledge_dir(project_hash: &str) -> Result<PathBuf, String> {
11    Ok(crate::core::data_dir::lean_ctx_data_dir()?
12        .join("knowledge")
13        .join(project_hash))
14}
15
16/// Per-project-hash mutex registry. Serializes the read-modify-write cycle of
17/// `mutate_locked` so concurrent `remember` calls within a single process (e.g.
18/// parallel MCP tool calls) cannot clobber each other (issue #326). The outer
19/// map lock is held only briefly to clone the inner `Arc`; the inner lock is
20/// held across the load → mutate → save cycle.
21fn knowledge_lock(project_hash: &str) -> Arc<Mutex<()>> {
22    static KNOWLEDGE_LOCKS: OnceLock<Mutex<HashMap<String, Arc<Mutex<()>>>>> = OnceLock::new();
23    let map = KNOWLEDGE_LOCKS.get_or_init(|| Mutex::new(HashMap::new()));
24    let mut guard = map
25        .lock()
26        .unwrap_or_else(std::sync::PoisonError::into_inner);
27    guard
28        .entry(project_hash.to_string())
29        .or_insert_with(|| Arc::new(Mutex::new(())))
30        .clone()
31}
32
33/// Acquires an exclusive, cross-process advisory lock for a project's
34/// knowledge store. The returned file handle holds the lock until it is
35/// dropped; the OS releases it automatically if the process exits (even on
36/// crash), so there are no stale locks. This serializes the read-modify-write
37/// cycle across *separate processes* (parallel CLI invocations, CLI + daemon +
38/// MCP server), complementing the in-process mutex (issue #326).
39fn acquire_file_lock(dir: &Path) -> Option<std::fs::File> {
40    use fs2::FileExt;
41    let lock_path = dir.join(".knowledge.lock");
42    let file = std::fs::OpenOptions::new()
43        .create(true)
44        .truncate(false)
45        .write(true)
46        .open(&lock_path)
47        .ok()?;
48    #[cfg(unix)]
49    {
50        use std::os::unix::fs::PermissionsExt;
51        let _ = std::fs::set_permissions(&lock_path, std::fs::Permissions::from_mode(0o600));
52    }
53    // Blocks until every other process holding the lock releases it. A failure
54    // here (unsupported FS, etc.) degrades to the in-process lock only.
55    file.lock_exclusive().ok()?;
56    Some(file)
57}
58
59/// Atomically writes `json` to `path` by writing to a unique temp file in the
60/// same directory and renaming it into place. `rename` is atomic on every
61/// supported platform (and replaces the target on Windows), so readers and
62/// concurrent writers never observe a half-written file — preventing the
63/// trailing-garbage JSON corruption reported in issue #326.
64fn write_json_atomic(dir: &Path, path: &Path, json: &str) -> Result<(), String> {
65    let unique = format!(
66        "knowledge.json.tmp.{}.{}",
67        std::process::id(),
68        std::time::SystemTime::now()
69            .duration_since(std::time::UNIX_EPOCH)
70            .map_or(0, |d| d.as_nanos())
71    );
72    let tmp = dir.join(unique);
73    std::fs::write(&tmp, json).map_err(|e| e.to_string())?;
74    #[cfg(unix)]
75    {
76        use std::os::unix::fs::PermissionsExt;
77        let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600));
78    }
79    if let Err(e) = std::fs::rename(&tmp, path) {
80        let _ = std::fs::remove_file(&tmp);
81        return Err(e.to_string());
82    }
83    Ok(())
84}
85
86impl ProjectKnowledge {
87    pub fn list_project_roots() -> Result<Vec<String>, String> {
88        let base = crate::core::data_dir::lean_ctx_data_dir()?.join("knowledge");
89        if !base.exists() {
90            return Ok(Vec::new());
91        }
92
93        let mut roots = Vec::new();
94        let mut seen = HashSet::new();
95        let entries = std::fs::read_dir(&base).map_err(|e| e.to_string())?;
96        for entry in entries.flatten() {
97            let path = entry.path().join("knowledge.json");
98            if !path.is_file() {
99                continue;
100            }
101            let Ok(content) = std::fs::read_to_string(&path) else {
102                continue;
103            };
104            let Ok(knowledge) = serde_json::from_str::<Self>(&content) else {
105                continue;
106            };
107            if seen.insert(knowledge.project_root.clone()) {
108                roots.push(knowledge.project_root);
109            }
110        }
111
112        roots.sort();
113        Ok(roots)
114    }
115
116    pub fn save(&self) -> Result<(), String> {
117        let dir = knowledge_dir(&self.project_hash)?;
118        std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
119        #[cfg(unix)]
120        {
121            use std::os::unix::fs::PermissionsExt;
122            let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
123        }
124
125        let path = dir.join("knowledge.json");
126        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
127        write_json_atomic(&dir, &path, &json)?;
128        Ok(())
129    }
130
131    /// Runs `f` while holding this project's locks — the in-process per-hash
132    /// mutex *and* the cross-process advisory file lock — without loading or
133    /// saving the knowledge JSON itself. [`mutate_locked`](Self::mutate_locked)
134    /// is built on this, and side-car stores that must stay consistent with the
135    /// facts (today: the embedding index) call it directly so their
136    /// read-modify-write is serialized against parallel
137    /// `remember`/`remove`/`reindex`. That side-car write used to run lock-free,
138    /// so concurrent callers clobbered each other's embeddings and pruned
139    /// just-stored vectors, degrading semantic recall (issue #412, a #326
140    /// follow-up).
141    pub(crate) fn with_project_lock<T>(project_root: &str, f: impl FnOnce() -> T) -> T {
142        let hash = hash_project_root(project_root);
143        let lock = knowledge_lock(&hash);
144        let _guard = lock
145            .lock()
146            .unwrap_or_else(std::sync::PoisonError::into_inner);
147
148        // Cross-process lock: create the dir up front so the lock file has a
149        // home, then block until any other process releases it. Held for the
150        // whole critical section via `_file_lock`'s lifetime.
151        let _file_lock = match knowledge_dir(&hash) {
152            Ok(dir) => {
153                let _ = std::fs::create_dir_all(&dir);
154                acquire_file_lock(&dir)
155            }
156            Err(_) => None,
157        };
158
159        f()
160    }
161
162    /// Runs a read-modify-write cycle under `with_project_lock`, then saves
163    /// atomically. The knowledge is (re)loaded *inside* the lock so
164    /// the closure always operates on the latest on-disk state; this is what
165    /// prevents lost updates when several `remember` calls run in parallel —
166    /// whether as threads in one process (parallel MCP calls) or as separate
167    /// processes (parallel CLI invocations, CLI + daemon + MCP server) — see
168    /// issue #326. Returns the persisted knowledge plus the closure's return
169    /// value so the caller can build a response from the committed state.
170    pub fn mutate_locked<T>(
171        project_root: &str,
172        f: impl FnOnce(&mut Self) -> T,
173    ) -> Result<(Self, T), String> {
174        Self::with_project_lock(project_root, || {
175            let mut knowledge = Self::load_or_create(project_root);
176            let out = f(&mut knowledge);
177            knowledge.save()?;
178            Ok((knowledge, out))
179        })
180    }
181
182    pub fn load(project_root: &str) -> Option<Self> {
183        let hash = hash_project_root(project_root);
184        let dir = knowledge_dir(&hash).ok()?;
185        let path = dir.join("knowledge.json");
186
187        if let Ok(content) = std::fs::read_to_string(&path) {
188            let size = content.len();
189            if size > 1_000_000 {
190                tracing::warn!(
191                    "knowledge.json is large ({:.1} MB) — recall may be slow. \
192                     Consider running ctx_knowledge(action=\"consolidate\") to compact it.",
193                    size as f64 / 1_048_576.0,
194                );
195            }
196            if let Ok(k) = serde_json::from_str::<Self>(&content) {
197                return Some(k);
198            }
199        }
200
201        let old_hash = crate::core::project_hash::hash_path_only(project_root);
202        if old_hash != hash {
203            crate::core::project_hash::migrate_if_needed(&old_hash, &hash, project_root);
204            if let Ok(content) = std::fs::read_to_string(&path)
205                && let Ok(mut k) = serde_json::from_str::<Self>(&content)
206            {
207                k.project_hash = hash;
208                let _ = k.save();
209                return Some(k);
210            }
211        }
212
213        // Migrate stores created before path normalization (issue #325): on
214        // Windows the CLI keyed its store by a backslash path, splitting it from
215        // the forward-slash MCP store. Pull any such legacy store into the
216        // canonical (normalized) location so facts converge.
217        for legacy_hash in crate::core::project_hash::legacy_unnormalized_hashes(project_root) {
218            if legacy_hash == hash {
219                continue;
220            }
221            crate::core::project_hash::migrate_if_needed(&legacy_hash, &hash, project_root);
222            if let Ok(content) = std::fs::read_to_string(&path)
223                && let Ok(mut k) = serde_json::from_str::<Self>(&content)
224            {
225                k.project_hash = hash;
226                let _ = k.save();
227                return Some(k);
228            }
229        }
230
231        None
232    }
233
234    pub fn load_or_create(project_root: &str) -> Self {
235        Self::load(project_root).unwrap_or_else(|| Self::new(project_root))
236    }
237
238    /// Migrates legacy knowledge that was accidentally stored under an empty project_root ("")
239    /// into the given `target_root`. Keeps a timestamped backup of the legacy file.
240    pub fn migrate_legacy_empty_root(
241        target_root: &str,
242        policy: &MemoryPolicy,
243    ) -> Result<bool, String> {
244        if target_root.trim().is_empty() {
245            return Ok(false);
246        }
247
248        let Some(legacy) = Self::load("") else {
249            return Ok(false);
250        };
251
252        if !legacy.project_root.trim().is_empty() {
253            return Ok(false);
254        }
255        if legacy.facts.is_empty() && legacy.patterns.is_empty() && legacy.history.is_empty() {
256            return Ok(false);
257        }
258
259        let mut target = Self::load_or_create(target_root);
260
261        fn fact_key(f: &KnowledgeFact) -> String {
262            format!(
263                "{}|{}|{}|{}|{}",
264                f.category, f.key, f.value, f.source_session, f.created_at
265            )
266        }
267        fn pattern_key(p: &ProjectPattern) -> String {
268            format!(
269                "{}|{}|{}|{}",
270                p.pattern_type, p.description, p.source_session, p.created_at
271            )
272        }
273        fn history_key(h: &ConsolidatedInsight) -> String {
274            format!(
275                "{}|{}|{}",
276                h.summary,
277                h.from_sessions.join(","),
278                h.timestamp
279            )
280        }
281
282        let mut seen_facts: std::collections::HashSet<String> =
283            target.facts.iter().map(fact_key).collect();
284        for f in legacy.facts {
285            if seen_facts.insert(fact_key(&f)) {
286                target.facts.push(f);
287            }
288        }
289
290        let mut seen_patterns: std::collections::HashSet<String> =
291            target.patterns.iter().map(pattern_key).collect();
292        for p in legacy.patterns {
293            if seen_patterns.insert(pattern_key(&p)) {
294                target.patterns.push(p);
295            }
296        }
297
298        let mut seen_history: std::collections::HashSet<String> =
299            target.history.iter().map(history_key).collect();
300        for h in legacy.history {
301            if seen_history.insert(history_key(&h)) {
302                target.history.push(h);
303            }
304        }
305
306        target.facts.sort_by(|a, b| {
307            b.created_at
308                .cmp(&a.created_at)
309                .then_with(|| b.confidence.total_cmp(&a.confidence))
310        });
311        if target.facts.len() > policy.knowledge.max_facts {
312            target.facts.truncate(policy.knowledge.max_facts);
313        }
314        target
315            .patterns
316            .sort_by_key(|x| std::cmp::Reverse(x.created_at));
317        if target.patterns.len() > policy.knowledge.max_patterns {
318            target.patterns.truncate(policy.knowledge.max_patterns);
319        }
320        target
321            .history
322            .sort_by_key(|x| std::cmp::Reverse(x.timestamp));
323        if target.history.len() > policy.knowledge.max_history {
324            target.history.truncate(policy.knowledge.max_history);
325        }
326
327        target.updated_at = Utc::now();
328        target.save()?;
329
330        let legacy_hash = crate::core::project_hash::hash_path_only("");
331        let legacy_dir = knowledge_dir(&legacy_hash)?;
332        let legacy_path = legacy_dir.join("knowledge.json");
333        if legacy_path.exists() {
334            let ts = Utc::now().format("%Y%m%d-%H%M%S");
335            let backup = legacy_dir.join(format!("knowledge.legacy-empty-root.{ts}.json"));
336            std::fs::rename(&legacy_path, &backup).map_err(|e| e.to_string())?;
337        }
338
339        Ok(true)
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346    use fs2::FileExt;
347
348    #[test]
349    fn file_lock_is_exclusive_across_handles() {
350        // flock-style locks are tied to the open file description, so two
351        // independent `open()`s in the same process behave like two separate
352        // processes: while the first holds the exclusive lock, the second must
353        // fail to acquire it. This validates the cross-process guarantee that
354        // protects parallel CLI writes (issue #326).
355        let dir = tempfile::tempdir().unwrap();
356        let held = acquire_file_lock(dir.path()).expect("first lock must succeed");
357
358        let second = std::fs::OpenOptions::new()
359            .create(true)
360            .truncate(false)
361            .write(true)
362            .open(dir.path().join(".knowledge.lock"))
363            .unwrap();
364        assert!(
365            second.try_lock_exclusive().is_err(),
366            "a second handle must not acquire the lock while it is held"
367        );
368
369        drop(held);
370        // `close()` releases the flock synchronously, so the lock IS free here.
371        // Under heavy parallel test load, however, a single non-blocking
372        // `try_lock_exclusive()` can momentarily observe `EWOULDBLOCK` from
373        // scheduling jitter. A short bounded retry removes that flake without
374        // weakening the guarantee: the lock must become acquirable again.
375        let mut reacquired = false;
376        for _ in 0..50 {
377            if second.try_lock_exclusive().is_ok() {
378                reacquired = true;
379                break;
380            }
381            std::thread::sleep(std::time::Duration::from_millis(10));
382        }
383        assert!(
384            reacquired,
385            "lock must be acquirable within 500ms of release"
386        );
387    }
388
389    #[test]
390    fn write_json_atomic_leaves_valid_file_and_no_temp() {
391        let dir = tempfile::tempdir().unwrap();
392        let path = dir.path().join("knowledge.json");
393        write_json_atomic(dir.path(), &path, "{\"ok\":true}").unwrap();
394        assert_eq!(std::fs::read_to_string(&path).unwrap(), "{\"ok\":true}");
395        let leftover = std::fs::read_dir(dir.path())
396            .unwrap()
397            .filter_map(Result::ok)
398            .any(|e| e.file_name().to_string_lossy().contains(".tmp."));
399        assert!(!leftover, "no temp file should remain");
400    }
401}