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 Ok(PreparedSave {
90 dir,
91 id: self.id.clone(),
92 json,
93 pointer_json,
94 compaction_snapshot,
95 })
96 }
97
98 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 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 pub fn load_latest_for_project_root(project_root: &str) -> Option<Self> {
132 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 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 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 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 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 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}