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(mut k) = serde_json::from_str::<Self>(&content) {
197                k.rebuild_index();
198                return Some(k);
199            }
200        }
201
202        let old_hash = crate::core::project_hash::hash_path_only(project_root);
203        if old_hash != hash {
204            crate::core::project_hash::migrate_if_needed(&old_hash, &hash, project_root);
205            if let Ok(content) = std::fs::read_to_string(&path)
206                && let Ok(mut k) = serde_json::from_str::<Self>(&content)
207            {
208                k.project_hash = hash;
209                k.rebuild_index();
210                let _ = k.save();
211                return Some(k);
212            }
213        }
214
215        // Migrate stores created before path normalization (issue #325): on
216        // Windows the CLI keyed its store by a backslash path, splitting it from
217        // the forward-slash MCP store. Pull any such legacy store into the
218        // canonical (normalized) location so facts converge.
219        for legacy_hash in crate::core::project_hash::legacy_unnormalized_hashes(project_root) {
220            if legacy_hash == hash {
221                continue;
222            }
223            crate::core::project_hash::migrate_if_needed(&legacy_hash, &hash, project_root);
224            if let Ok(content) = std::fs::read_to_string(&path)
225                && let Ok(mut k) = serde_json::from_str::<Self>(&content)
226            {
227                k.project_hash = hash;
228                k.rebuild_index();
229                let _ = k.save();
230                return Some(k);
231            }
232        }
233
234        None
235    }
236
237    pub fn load_or_create(project_root: &str) -> Self {
238        Self::load(project_root).unwrap_or_else(|| Self::new(project_root))
239    }
240
241    /// Migrates legacy knowledge that was accidentally stored under an empty project_root ("")
242    /// into the given `target_root`. Keeps a timestamped backup of the legacy file.
243    pub fn migrate_legacy_empty_root(
244        target_root: &str,
245        policy: &MemoryPolicy,
246    ) -> Result<bool, String> {
247        if target_root.trim().is_empty() {
248            return Ok(false);
249        }
250
251        let Some(legacy) = Self::load("") else {
252            return Ok(false);
253        };
254
255        if !legacy.project_root.trim().is_empty() {
256            return Ok(false);
257        }
258        if legacy.facts.is_empty() && legacy.patterns.is_empty() && legacy.history.is_empty() {
259            return Ok(false);
260        }
261
262        let mut target = Self::load_or_create(target_root);
263
264        fn fact_key(f: &KnowledgeFact) -> String {
265            format!(
266                "{}|{}|{}|{}|{}",
267                f.category, f.key, f.value, f.source_session, f.created_at
268            )
269        }
270        fn pattern_key(p: &ProjectPattern) -> String {
271            format!(
272                "{}|{}|{}|{}",
273                p.pattern_type, p.description, p.source_session, p.created_at
274            )
275        }
276        fn history_key(h: &ConsolidatedInsight) -> String {
277            format!(
278                "{}|{}|{}",
279                h.summary,
280                h.from_sessions.join(","),
281                h.timestamp
282            )
283        }
284
285        let mut seen_facts: std::collections::HashSet<String> =
286            target.facts.iter().map(fact_key).collect();
287        for f in legacy.facts {
288            if seen_facts.insert(fact_key(&f)) {
289                target.facts.push(f);
290            }
291        }
292
293        let mut seen_patterns: std::collections::HashSet<String> =
294            target.patterns.iter().map(pattern_key).collect();
295        for p in legacy.patterns {
296            if seen_patterns.insert(pattern_key(&p)) {
297                target.patterns.push(p);
298            }
299        }
300
301        let mut seen_history: std::collections::HashSet<String> =
302            target.history.iter().map(history_key).collect();
303        for h in legacy.history {
304            if seen_history.insert(history_key(&h)) {
305                target.history.push(h);
306            }
307        }
308
309        target.facts.sort_by(|a, b| {
310            b.created_at
311                .cmp(&a.created_at)
312                .then_with(|| b.confidence.total_cmp(&a.confidence))
313        });
314        if target.facts.len() > policy.knowledge.max_facts {
315            target.facts.truncate(policy.knowledge.max_facts);
316        }
317        target
318            .patterns
319            .sort_by_key(|x| std::cmp::Reverse(x.created_at));
320        if target.patterns.len() > policy.knowledge.max_patterns {
321            target.patterns.truncate(policy.knowledge.max_patterns);
322        }
323        target
324            .history
325            .sort_by_key(|x| std::cmp::Reverse(x.timestamp));
326        if target.history.len() > policy.knowledge.max_history {
327            target.history.truncate(policy.knowledge.max_history);
328        }
329
330        target.updated_at = Utc::now();
331        target.save()?;
332
333        let legacy_hash = crate::core::project_hash::hash_path_only("");
334        let legacy_dir = knowledge_dir(&legacy_hash)?;
335        let legacy_path = legacy_dir.join("knowledge.json");
336        if legacy_path.exists() {
337            let ts = Utc::now().format("%Y%m%d-%H%M%S");
338            let backup = legacy_dir.join(format!("knowledge.legacy-empty-root.{ts}.json"));
339            std::fs::rename(&legacy_path, &backup).map_err(|e| e.to_string())?;
340        }
341
342        Ok(true)
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349    use fs2::FileExt;
350
351    #[test]
352    fn file_lock_is_exclusive_across_handles() {
353        // flock-style locks are tied to the open file description, so two
354        // independent `open()`s in the same process behave like two separate
355        // processes: while the first holds the exclusive lock, the second must
356        // fail to acquire it. This validates the cross-process guarantee that
357        // protects parallel CLI writes (issue #326).
358        let dir = tempfile::tempdir().unwrap();
359        let held = acquire_file_lock(dir.path()).expect("first lock must succeed");
360
361        let second = std::fs::OpenOptions::new()
362            .create(true)
363            .truncate(false)
364            .write(true)
365            .open(dir.path().join(".knowledge.lock"))
366            .unwrap();
367        assert!(
368            second.try_lock_exclusive().is_err(),
369            "a second handle must not acquire the lock while it is held"
370        );
371
372        drop(held);
373        // `close()` releases the flock synchronously, so the lock IS free here.
374        // Under heavy parallel test load, however, a single non-blocking
375        // `try_lock_exclusive()` can momentarily observe `EWOULDBLOCK` from
376        // scheduling jitter. A short bounded retry removes that flake without
377        // weakening the guarantee: the lock must become acquirable again.
378        let mut reacquired = false;
379        for _ in 0..50 {
380            if second.try_lock_exclusive().is_ok() {
381                reacquired = true;
382                break;
383            }
384            std::thread::sleep(std::time::Duration::from_millis(10));
385        }
386        assert!(
387            reacquired,
388            "lock must be acquirable within 500ms of release"
389        );
390    }
391
392    #[test]
393    fn write_json_atomic_leaves_valid_file_and_no_temp() {
394        let dir = tempfile::tempdir().unwrap();
395        let path = dir.path().join("knowledge.json");
396        write_json_atomic(dir.path(), &path, "{\"ok\":true}").unwrap();
397        assert_eq!(std::fs::read_to_string(&path).unwrap(), "{\"ok\":true}");
398        let leftover = std::fs::read_dir(dir.path())
399            .unwrap()
400            .filter_map(Result::ok)
401            .any(|e| e.file_name().to_string_lossy().contains(".tmp."));
402        assert!(!leftover, "no temp file should remain");
403    }
404
405    #[test]
406    fn load_rebuilds_ephemeral_index_without_changing_json() {
407        let _isolated = crate::core::data_dir::isolated_data_dir();
408        let root = "/tmp/knowledge-index-load";
409        let policy = MemoryPolicy::default();
410        let mut knowledge = ProjectKnowledge::new(root);
411        knowledge.remember(
412            "architecture",
413            "database",
414            "PostgreSQL",
415            "test",
416            0.9,
417            &policy,
418        );
419        knowledge.save().unwrap();
420
421        let loaded = ProjectKnowledge::load(root).expect("saved knowledge should load");
422        assert!(loaded.index.token_positions.contains_key("postgresql"));
423        assert_eq!(loaded.recall("postgresql").len(), 1);
424
425        let json = serde_json::to_string(&loaded).unwrap();
426        assert!(!json.contains("\"index\""));
427    }
428}