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