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