Skip to main content

lean_ctx/core/knowledge/
persist.rs

1use chrono::Utc;
2use std::collections::HashMap;
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 save(&self) -> Result<(), String> {
88        let dir = knowledge_dir(&self.project_hash)?;
89        std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
90        #[cfg(unix)]
91        {
92            use std::os::unix::fs::PermissionsExt;
93            let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
94        }
95
96        let path = dir.join("knowledge.json");
97        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
98        write_json_atomic(&dir, &path, &json)?;
99        Ok(())
100    }
101
102    /// Runs `f` while holding this project's locks — the in-process per-hash
103    /// mutex *and* the cross-process advisory file lock — without loading or
104    /// saving the knowledge JSON itself. [`mutate_locked`](Self::mutate_locked)
105    /// is built on this, and side-car stores that must stay consistent with the
106    /// facts (today: the embedding index) call it directly so their
107    /// read-modify-write is serialized against parallel
108    /// `remember`/`remove`/`reindex`. That side-car write used to run lock-free,
109    /// so concurrent callers clobbered each other's embeddings and pruned
110    /// just-stored vectors, degrading semantic recall (issue #412, a #326
111    /// follow-up).
112    pub(crate) fn with_project_lock<T>(project_root: &str, f: impl FnOnce() -> T) -> T {
113        let hash = hash_project_root(project_root);
114        let lock = knowledge_lock(&hash);
115        let _guard = lock
116            .lock()
117            .unwrap_or_else(std::sync::PoisonError::into_inner);
118
119        // Cross-process lock: create the dir up front so the lock file has a
120        // home, then block until any other process releases it. Held for the
121        // whole critical section via `_file_lock`'s lifetime.
122        let _file_lock = match knowledge_dir(&hash) {
123            Ok(dir) => {
124                let _ = std::fs::create_dir_all(&dir);
125                acquire_file_lock(&dir)
126            }
127            Err(_) => None,
128        };
129
130        f()
131    }
132
133    /// Runs a read-modify-write cycle under `with_project_lock`, then saves
134    /// atomically. The knowledge is (re)loaded *inside* the lock so
135    /// the closure always operates on the latest on-disk state; this is what
136    /// prevents lost updates when several `remember` calls run in parallel —
137    /// whether as threads in one process (parallel MCP calls) or as separate
138    /// processes (parallel CLI invocations, CLI + daemon + MCP server) — see
139    /// issue #326. Returns the persisted knowledge plus the closure's return
140    /// value so the caller can build a response from the committed state.
141    pub fn mutate_locked<T>(
142        project_root: &str,
143        f: impl FnOnce(&mut Self) -> T,
144    ) -> Result<(Self, T), String> {
145        Self::with_project_lock(project_root, || {
146            let mut knowledge = Self::load_or_create(project_root);
147            let out = f(&mut knowledge);
148            knowledge.save()?;
149            Ok((knowledge, out))
150        })
151    }
152
153    pub fn load(project_root: &str) -> Option<Self> {
154        let hash = hash_project_root(project_root);
155        let dir = knowledge_dir(&hash).ok()?;
156        let path = dir.join("knowledge.json");
157
158        if let Ok(content) = std::fs::read_to_string(&path) {
159            let size = content.len();
160            if size > 1_000_000 {
161                tracing::warn!(
162                    "knowledge.json is large ({:.1} MB) — recall may be slow. \
163                     Consider running ctx_knowledge(action=\"consolidate\") to compact it.",
164                    size as f64 / 1_048_576.0,
165                );
166            }
167            if let Ok(k) = serde_json::from_str::<Self>(&content) {
168                return Some(k);
169            }
170        }
171
172        let old_hash = crate::core::project_hash::hash_path_only(project_root);
173        if old_hash != hash {
174            crate::core::project_hash::migrate_if_needed(&old_hash, &hash, project_root);
175            if let Ok(content) = std::fs::read_to_string(&path)
176                && let Ok(mut k) = serde_json::from_str::<Self>(&content)
177            {
178                k.project_hash = hash;
179                let _ = k.save();
180                return Some(k);
181            }
182        }
183
184        // Migrate stores created before path normalization (issue #325): on
185        // Windows the CLI keyed its store by a backslash path, splitting it from
186        // the forward-slash MCP store. Pull any such legacy store into the
187        // canonical (normalized) location so facts converge.
188        for legacy_hash in crate::core::project_hash::legacy_unnormalized_hashes(project_root) {
189            if legacy_hash == hash {
190                continue;
191            }
192            crate::core::project_hash::migrate_if_needed(&legacy_hash, &hash, project_root);
193            if let Ok(content) = std::fs::read_to_string(&path)
194                && let Ok(mut k) = serde_json::from_str::<Self>(&content)
195            {
196                k.project_hash = hash;
197                let _ = k.save();
198                return Some(k);
199            }
200        }
201
202        None
203    }
204
205    pub fn load_or_create(project_root: &str) -> Self {
206        Self::load(project_root).unwrap_or_else(|| Self::new(project_root))
207    }
208
209    /// Migrates legacy knowledge that was accidentally stored under an empty project_root ("")
210    /// into the given `target_root`. Keeps a timestamped backup of the legacy file.
211    pub fn migrate_legacy_empty_root(
212        target_root: &str,
213        policy: &MemoryPolicy,
214    ) -> Result<bool, String> {
215        if target_root.trim().is_empty() {
216            return Ok(false);
217        }
218
219        let Some(legacy) = Self::load("") else {
220            return Ok(false);
221        };
222
223        if !legacy.project_root.trim().is_empty() {
224            return Ok(false);
225        }
226        if legacy.facts.is_empty() && legacy.patterns.is_empty() && legacy.history.is_empty() {
227            return Ok(false);
228        }
229
230        let mut target = Self::load_or_create(target_root);
231
232        fn fact_key(f: &KnowledgeFact) -> String {
233            format!(
234                "{}|{}|{}|{}|{}",
235                f.category, f.key, f.value, f.source_session, f.created_at
236            )
237        }
238        fn pattern_key(p: &ProjectPattern) -> String {
239            format!(
240                "{}|{}|{}|{}",
241                p.pattern_type, p.description, p.source_session, p.created_at
242            )
243        }
244        fn history_key(h: &ConsolidatedInsight) -> String {
245            format!(
246                "{}|{}|{}",
247                h.summary,
248                h.from_sessions.join(","),
249                h.timestamp
250            )
251        }
252
253        let mut seen_facts: std::collections::HashSet<String> =
254            target.facts.iter().map(fact_key).collect();
255        for f in legacy.facts {
256            if seen_facts.insert(fact_key(&f)) {
257                target.facts.push(f);
258            }
259        }
260
261        let mut seen_patterns: std::collections::HashSet<String> =
262            target.patterns.iter().map(pattern_key).collect();
263        for p in legacy.patterns {
264            if seen_patterns.insert(pattern_key(&p)) {
265                target.patterns.push(p);
266            }
267        }
268
269        let mut seen_history: std::collections::HashSet<String> =
270            target.history.iter().map(history_key).collect();
271        for h in legacy.history {
272            if seen_history.insert(history_key(&h)) {
273                target.history.push(h);
274            }
275        }
276
277        target.facts.sort_by(|a, b| {
278            b.created_at
279                .cmp(&a.created_at)
280                .then_with(|| b.confidence.total_cmp(&a.confidence))
281        });
282        if target.facts.len() > policy.knowledge.max_facts {
283            target.facts.truncate(policy.knowledge.max_facts);
284        }
285        target
286            .patterns
287            .sort_by_key(|x| std::cmp::Reverse(x.created_at));
288        if target.patterns.len() > policy.knowledge.max_patterns {
289            target.patterns.truncate(policy.knowledge.max_patterns);
290        }
291        target
292            .history
293            .sort_by_key(|x| std::cmp::Reverse(x.timestamp));
294        if target.history.len() > policy.knowledge.max_history {
295            target.history.truncate(policy.knowledge.max_history);
296        }
297
298        target.updated_at = Utc::now();
299        target.save()?;
300
301        let legacy_hash = crate::core::project_hash::hash_path_only("");
302        let legacy_dir = knowledge_dir(&legacy_hash)?;
303        let legacy_path = legacy_dir.join("knowledge.json");
304        if legacy_path.exists() {
305            let ts = Utc::now().format("%Y%m%d-%H%M%S");
306            let backup = legacy_dir.join(format!("knowledge.legacy-empty-root.{ts}.json"));
307            std::fs::rename(&legacy_path, &backup).map_err(|e| e.to_string())?;
308        }
309
310        Ok(true)
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use fs2::FileExt;
318
319    #[test]
320    fn file_lock_is_exclusive_across_handles() {
321        // flock-style locks are tied to the open file description, so two
322        // independent `open()`s in the same process behave like two separate
323        // processes: while the first holds the exclusive lock, the second must
324        // fail to acquire it. This validates the cross-process guarantee that
325        // protects parallel CLI writes (issue #326).
326        let dir = tempfile::tempdir().unwrap();
327        let held = acquire_file_lock(dir.path()).expect("first lock must succeed");
328
329        let second = std::fs::OpenOptions::new()
330            .create(true)
331            .truncate(false)
332            .write(true)
333            .open(dir.path().join(".knowledge.lock"))
334            .unwrap();
335        assert!(
336            second.try_lock_exclusive().is_err(),
337            "a second handle must not acquire the lock while it is held"
338        );
339
340        drop(held);
341        // `close()` releases the flock synchronously, so the lock IS free here.
342        // Under heavy parallel test load, however, a single non-blocking
343        // `try_lock_exclusive()` can momentarily observe `EWOULDBLOCK` from
344        // scheduling jitter. A short bounded retry removes that flake without
345        // weakening the guarantee: the lock must become acquirable again.
346        let mut reacquired = false;
347        for _ in 0..50 {
348            if second.try_lock_exclusive().is_ok() {
349                reacquired = true;
350                break;
351            }
352            std::thread::sleep(std::time::Duration::from_millis(10));
353        }
354        assert!(
355            reacquired,
356            "lock must be acquirable within 500ms of release"
357        );
358    }
359
360    #[test]
361    fn write_json_atomic_leaves_valid_file_and_no_temp() {
362        let dir = tempfile::tempdir().unwrap();
363        let path = dir.path().join("knowledge.json");
364        write_json_atomic(dir.path(), &path, "{\"ok\":true}").unwrap();
365        assert_eq!(std::fs::read_to_string(&path).unwrap(), "{\"ok\":true}");
366        let leftover = std::fs::read_dir(dir.path())
367            .unwrap()
368            .filter_map(Result::ok)
369            .any(|e| e.file_name().to_string_lossy().contains(".tmp."));
370        assert!(!leftover, "no temp file should remain");
371    }
372}