lean_ctx/core/session/
persistence.rs1use 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 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 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 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 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 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 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 pub fn load_latest_for_project_root(project_root: &str) -> Option<Self> {
134 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 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 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 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 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 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}