Skip to main content

lean_ctx/core/session/
persistence.rs

1use chrono::Utc;
2
3use super::heuristics::{normalize_loaded_session, session_matches_project_root};
4use super::paths::sessions_dir;
5use super::state::BATCH_SAVE_INTERVAL;
6#[allow(clippy::wildcard_imports)]
7use super::types::*;
8
9#[cfg(unix)]
10fn restrict_file_permissions(path: &std::path::Path) {
11    use std::os::unix::fs::PermissionsExt;
12    let perms = std::fs::Permissions::from_mode(0o600);
13    let _ = std::fs::set_permissions(path, perms);
14}
15
16#[cfg(not(unix))]
17fn restrict_file_permissions(_path: &std::path::Path) {}
18
19impl PreparedSave {
20    /// Writes the pre-serialized session data, latest pointer, and compaction
21    /// snapshot to disk atomically.
22    pub fn write_to_disk(self) -> Result<(), String> {
23        if !self.dir.exists() {
24            std::fs::create_dir_all(&self.dir).map_err(|e| e.to_string())?;
25        }
26        let path = self.dir.join(format!("{}.json", self.id));
27        let tmp = self.dir.join(format!(".{}.json.tmp", self.id));
28        std::fs::write(&tmp, &self.json).map_err(|e| e.to_string())?;
29        restrict_file_permissions(&tmp);
30        std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?;
31
32        let latest_path = self.dir.join("latest.json");
33        let latest_tmp = self.dir.join(".latest.json.tmp");
34        std::fs::write(&latest_tmp, &self.pointer_json).map_err(|e| e.to_string())?;
35        restrict_file_permissions(&latest_tmp);
36        std::fs::rename(&latest_tmp, &latest_path).map_err(|e| e.to_string())?;
37
38        if let Some(snapshot) = self.compaction_snapshot {
39            let snap_path = self.dir.join(format!("{}_snapshot.txt", self.id));
40            let _ = std::fs::write(&snap_path, &snapshot);
41            restrict_file_permissions(&snap_path);
42        }
43        Ok(())
44    }
45}
46
47impl SessionState {
48    /// Serializes and writes the session state to disk synchronously.
49    pub fn save(&mut self) -> Result<(), String> {
50        let prepared = self.prepare_save()?;
51        match prepared.write_to_disk() {
52            Ok(()) => Ok(()),
53            Err(e) => {
54                self.stats.unsaved_changes = BATCH_SAVE_INTERVAL;
55                Err(e)
56            }
57        }
58    }
59
60    /// Serialize session state while holding the lock (CPU-only), reset the
61    /// unsaved counter, and return a `PreparedSave` whose I/O can be deferred
62    /// to a background thread via `write_to_disk()`.
63    pub fn prepare_save(&mut self) -> Result<PreparedSave, String> {
64        let dir = sessions_dir().ok_or("cannot determine home directory")?;
65        let compaction_snapshot = if self.stats.total_tool_calls > 0 {
66            Some(self.build_compaction_snapshot())
67        } else {
68            None
69        };
70        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
71        let pointer_json = serde_json::to_string(&LatestPointer {
72            id: self.id.clone(),
73        })
74        .map_err(|e| e.to_string())?;
75        self.stats.unsaved_changes = 0;
76        Ok(PreparedSave {
77            dir,
78            id: self.id.clone(),
79            json,
80            pointer_json,
81            compaction_snapshot,
82        })
83    }
84
85    /// Loads the most recent session matching the current working directory's
86    /// project root.
87    ///
88    /// Returns `None` (a fresh session) rather than falling back to the global
89    /// `latest.json` pointer: that unconditional fallback bypassed project-root
90    /// matching and was the root cause of cross-project session leakage — one
91    /// project's findings/decisions/knowledge bleeding into another project's
92    /// first session. The correct project session is loaded later from the MCP
93    /// `roots` handshake (`load_latest_for_project_root`).
94    ///
95    /// Also refuses to scope to a broad/unsafe cwd (e.g. the MCP daemon's HOME),
96    /// which would otherwise resurrect the contaminated "HOME mega-session".
97    pub fn load_latest() -> Option<Self> {
98        let cwd = std::env::current_dir().ok()?;
99        if crate::core::pathutil::is_broad_or_unsafe_root(&cwd) {
100            return None;
101        }
102        Self::load_latest_for_project_root(&cwd.to_string_lossy())
103    }
104
105    /// Loads the session referenced by the global `latest.json` pointer,
106    /// regardless of project. Intended only for explicit, cross-project UX
107    /// (e.g. `lean-ctx session` status from an arbitrary directory) — never for
108    /// injecting knowledge into a new project's context. Prefer `load_latest`.
109    pub fn load_global_latest_pointer() -> Option<Self> {
110        let dir = sessions_dir()?;
111        let latest_path = dir.join("latest.json");
112        let pointer_json = std::fs::read_to_string(&latest_path).ok()?;
113        let pointer: LatestPointer = serde_json::from_str(&pointer_json).ok()?;
114        Self::load_by_id(&pointer.id)
115    }
116
117    /// Loads the most recent session matching a specific project root.
118    pub fn load_latest_for_project_root(project_root: &str) -> Option<Self> {
119        // Broad roots ("/", HOME, agent sandboxes) never own a session. Bail out
120        // BEFORE scanning: the daemon boots with cwd "/" and previously walked
121        // every stored session here, stat-ing each session's project_root /
122        // shell_cwd. For roots under ~/Documents that probe popped the macOS
123        // TCC prompt in lean-ctx's name on every launchd (re)start (#356) —
124        // and `shell_cwd.starts_with("/")` could even leak an arbitrary
125        // project's session into the broad-root context.
126        if crate::core::pathutil::is_broad_or_unsafe_root(std::path::Path::new(project_root)) {
127            return None;
128        }
129        let dir = sessions_dir()?;
130        let target_root =
131            crate::core::pathutil::safe_canonicalize_or_self(std::path::Path::new(project_root));
132        let mut latest_match: Option<Self> = None;
133
134        for entry in std::fs::read_dir(&dir).ok()?.flatten() {
135            let path = entry.path();
136            if path.extension().and_then(|e| e.to_str()) != Some("json") {
137                continue;
138            }
139            if path.file_name().and_then(|n| n.to_str()) == Some("latest.json") {
140                continue;
141            }
142
143            let Some(id) = path.file_stem().and_then(|n| n.to_str()) else {
144                continue;
145            };
146            let Some(session) = Self::load_by_id(id) else {
147                continue;
148            };
149
150            if !session_matches_project_root(&session, &target_root) {
151                continue;
152            }
153
154            if latest_match
155                .as_ref()
156                .is_none_or(|existing| session.updated_at > existing.updated_at)
157            {
158                latest_match = Some(session);
159            }
160        }
161
162        latest_match
163    }
164
165    /// Loads a specific session from disk by its unique ID.
166    pub fn load_by_id(id: &str) -> Option<Self> {
167        let dir = sessions_dir()?;
168        let path = dir.join(format!("{id}.json"));
169        let json = std::fs::read_to_string(&path).ok()?;
170        let session: Self = serde_json::from_str(&json).ok()?;
171        Some(normalize_loaded_session(session))
172    }
173
174    /// Lists all saved sessions as summaries, sorted by most recently updated.
175    pub fn list_sessions() -> Vec<SessionSummary> {
176        let Some(dir) = sessions_dir() else {
177            return Vec::new();
178        };
179
180        let mut summaries = Vec::new();
181        if let Ok(entries) = std::fs::read_dir(&dir) {
182            for entry in entries.flatten() {
183                let path = entry.path();
184                if path.extension().and_then(|e| e.to_str()) != Some("json") {
185                    continue;
186                }
187                if path.file_name().and_then(|n| n.to_str()) == Some("latest.json") {
188                    continue;
189                }
190                if let Ok(json) = std::fs::read_to_string(&path) {
191                    if let Ok(session) = serde_json::from_str::<SessionState>(&json) {
192                        summaries.push(SessionSummary {
193                            id: session.id,
194                            started_at: session.started_at,
195                            updated_at: session.updated_at,
196                            version: session.version,
197                            task: session.task.as_ref().map(|t| t.description.clone()),
198                            tool_calls: session.stats.total_tool_calls,
199                            tokens_saved: session.stats.total_tokens_saved,
200                        });
201                    }
202                }
203            }
204        }
205
206        summaries.sort_by_key(|x| std::cmp::Reverse(x.updated_at));
207        summaries
208    }
209
210    /// Scans all saved sessions for contaminated ones — those rooted at a
211    /// broad/unsafe path (HOME, filesystem root, agent sandbox dir) without a
212    /// real project marker, i.e. the historic "HOME mega-session" artifact.
213    ///
214    /// Returns `(found, quarantined)` where `found` is `(id, root)` pairs. When
215    /// `apply` is true, each offending session file is moved to a
216    /// `sessions/quarantine/` subdirectory (non-destructive) instead of being
217    /// loaded into any project's context.
218    pub fn doctor_quarantine_unsafe_roots(apply: bool) -> (Vec<(String, String)>, usize) {
219        let mut found: Vec<(String, String)> = Vec::new();
220        let mut quarantined = 0usize;
221        let Some(dir) = sessions_dir() else {
222            return (found, quarantined);
223        };
224        let Ok(entries) = std::fs::read_dir(&dir) else {
225            return (found, quarantined);
226        };
227        for entry in entries.flatten() {
228            let path = entry.path();
229            if path.extension().and_then(|e| e.to_str()) != Some("json") {
230                continue;
231            }
232            let Some(id) = path.file_stem().and_then(|n| n.to_str()) else {
233                continue;
234            };
235            if id == "latest" || id.starts_with('.') {
236                continue;
237            }
238            let Some(session) = Self::load_by_id(id) else {
239                continue;
240            };
241            let Some(root) = session.project_root.as_deref() else {
242                continue;
243            };
244            let root_path = std::path::Path::new(root);
245            if crate::core::pathutil::is_broad_or_unsafe_root(root_path) {
246                found.push((id.to_string(), root.to_string()));
247                if apply {
248                    let q_dir = dir.join("quarantine");
249                    if std::fs::create_dir_all(&q_dir).is_ok()
250                        && std::fs::rename(&path, q_dir.join(format!("{id}.json"))).is_ok()
251                    {
252                        quarantined += 1;
253                    }
254                }
255            }
256        }
257        (found, quarantined)
258    }
259
260    /// Deletes sessions older than `max_age_days`, preserving the latest. Returns count removed.
261    pub fn cleanup_old_sessions(max_age_days: i64) -> u32 {
262        let Some(dir) = sessions_dir() else { return 0 };
263
264        let cutoff = Utc::now() - chrono::Duration::days(max_age_days);
265        let latest = Self::load_latest().map(|s| s.id);
266        let mut removed = 0u32;
267
268        if let Ok(entries) = std::fs::read_dir(&dir) {
269            for entry in entries.flatten() {
270                let path = entry.path();
271                if path.extension().and_then(|e| e.to_str()) != Some("json") {
272                    continue;
273                }
274                let filename = path.file_stem().and_then(|n| n.to_str()).unwrap_or("");
275                if filename == "latest" || filename.starts_with('.') {
276                    continue;
277                }
278                if latest.as_deref() == Some(filename) {
279                    continue;
280                }
281                if let Ok(json) = std::fs::read_to_string(&path) {
282                    if let Ok(session) = serde_json::from_str::<SessionState>(&json) {
283                        if session.updated_at < cutoff && std::fs::remove_file(&path).is_ok() {
284                            removed += 1;
285                        }
286                    }
287                }
288            }
289        }
290
291        removed
292    }
293}