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
19impl PreparedSave {
20 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 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 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 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 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 pub fn load_latest_for_project_root(project_root: &str) -> Option<Self> {
119 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 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 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 && let Ok(session) = serde_json::from_str::<SessionState>(&json)
192 {
193 summaries.push(SessionSummary {
194 id: session.id,
195 started_at: session.started_at,
196 updated_at: session.updated_at,
197 version: session.version,
198 task: session.task.as_ref().map(|t| t.description.clone()),
199 tool_calls: session.stats.total_tool_calls,
200 tokens_saved: session.stats.total_tokens_saved,
201 });
202 }
203 }
204 }
205
206 summaries.sort_by_key(|x| std::cmp::Reverse(x.updated_at));
207 summaries
208 }
209
210 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 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 && let Ok(session) = serde_json::from_str::<SessionState>(&json)
283 && session.updated_at < cutoff
284 && std::fs::remove_file(&path).is_ok()
285 {
286 removed += 1;
287 }
288 }
289 }
290
291 removed
292 }
293}