1use chrono::Utc;
4use serde::{Deserialize, Serialize};
5use sha2::{Digest as _, Sha256};
6
7use super::heuristics::{normalize_loaded_session, session_matches_project_root};
8use super::paths::sessions_dir;
9use super::save_outcome::SaveOutcome;
10use super::state::extract_session_facts;
11#[allow(clippy::wildcard_imports)]
12use super::types::*;
13
14const PROJECT_HISTORY_LIMIT: usize = 8;
17
18#[cfg(not(test))]
22const SAVE_LOCK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
23#[cfg(test)]
25const SAVE_LOCK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1);
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum SessionScopeError {
29 ProjectRootMismatch,
30}
31
32#[derive(Debug, Default, Deserialize, Serialize)]
33struct ProjectSessionIndex {
34 version: u8,
35 project_root: String,
36 session_ids: Vec<String>,
38 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
44 verified_empty: bool,
45}
46
47fn normalized_safe_project_root(project_root: &str) -> Option<String> {
48 let path = std::path::Path::new(project_root);
49 if project_root.trim().is_empty() || crate::core::pathutil::is_broad_or_unsafe_root(path) {
50 return None;
51 }
52 Some(
53 crate::core::pathutil::safe_canonicalize_or_self(path)
54 .to_string_lossy()
55 .to_string(),
56 )
57}
58
59fn normalized_strict_project_root(project_root: &str) -> Option<String> {
65 let path = std::path::Path::new(project_root);
66 if project_root.trim().is_empty()
67 || !path.is_absolute()
68 || crate::core::pathutil::is_broad_or_unsafe_root(path)
69 {
70 return None;
71 }
72
73 let normalized = crate::core::pathutil::safe_canonicalize_or_self(path);
74 if !normalized.is_absolute() || crate::core::pathutil::is_broad_or_unsafe_root(&normalized) {
75 return None;
76 }
77 Some(normalized.to_string_lossy().to_string())
78}
79
80fn project_index_path(dir: &std::path::Path, project_root: &str) -> std::path::PathBuf {
81 let key = blake3::hash(project_root.as_bytes()).to_hex();
82 dir.join("project-index").join(format!("{key}.json"))
83}
84
85fn read_project_index(dir: &std::path::Path, project_root: &str) -> Option<ProjectSessionIndex> {
86 std::fs::read_to_string(project_index_path(dir, project_root))
87 .ok()
88 .and_then(|json| serde_json::from_str::<ProjectSessionIndex>(&json).ok())
89 .filter(|index| index.version == 1 && index.project_root == project_root)
90}
91
92fn project_head_from_bytes(
93 id: &str,
94 project_root: &str,
95 bytes: &[u8],
96) -> Result<(String, String), String> {
97 let json =
98 std::str::from_utf8(bytes).map_err(|_| "corrupt project session head".to_string())?;
99 let session = SessionState::from_storage_json(json)
100 .map(normalize_loaded_session)
101 .map_err(|_| "corrupt project session head".to_string())?;
102 if session.id != id {
103 return Err("corrupt project session head id".to_string());
104 }
105 if !session_matches_project_root(&session, std::path::Path::new(project_root)) {
106 return Err("project session head root mismatch".to_string());
107 }
108 let digest = Sha256::digest(bytes);
109 Ok((session.id, format!("sha256:{}", hex::encode(digest))))
110}
111
112fn read_project_head_locked(
115 dir: &std::path::Path,
116 project_root: &str,
117) -> Result<Option<(String, String)>, String> {
118 if project_index_path(dir, project_root).exists()
119 && read_project_index(dir, project_root).is_none()
120 {
121 return Err("corrupt project session index".to_string());
122 }
123 if let Some(index) = read_project_index(dir, project_root)
124 && let Some(id) = index.session_ids.last()
125 {
126 validate_session_id(id).map_err(|_| "invalid project session index head".to_string())?;
127 let path = dir.join(format!("{id}.json"));
128 match std::fs::read(path) {
129 Ok(bytes) => {
130 let json = std::str::from_utf8(&bytes)
131 .map_err(|_| "corrupt project session head".to_string())?;
132 let session = SessionState::from_storage_json(json)
133 .map(normalize_loaded_session)
134 .map_err(|_| "corrupt project session head".to_string())?;
135 if session.id.as_str() != id.as_str() {
136 return Err("corrupt project session head id".to_string());
137 }
138 let Some(session_root) = session.project_root.as_deref() else {
139 return Err("unsafe project session head root".to_string());
140 };
141 if normalized_safe_project_root(session_root).is_none() {
142 return Err("unsafe project session head root".to_string());
143 }
144 if session_matches_project_root(&session, std::path::Path::new(project_root)) {
145 let digest = Sha256::digest(&bytes);
146 return Ok(Some((
147 session.id,
148 format!("sha256:{}", hex::encode(digest)),
149 )));
150 }
151 }
154 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
155 Err(error) => return Err(format!("read project session head: {error}")),
156 }
157 }
158
159 let entries = match std::fs::read_dir(dir) {
160 Ok(entries) => entries,
161 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
162 Err(error) => return Err(format!("scan project sessions: {error}")),
163 };
164 let mut matches = Vec::new();
165 for entry in entries {
166 let entry = entry.map_err(|error| format!("scan project sessions: {error}"))?;
167 let path = entry.path();
168 if path.extension().and_then(|extension| extension.to_str()) != Some("json") {
169 continue;
170 }
171 let Some(id) = path.file_stem().and_then(|stem| stem.to_str()) else {
172 continue;
173 };
174 if id == "latest" || id.starts_with('.') {
175 continue;
176 }
177 let bytes = match std::fs::read(&path) {
178 Ok(bytes) => bytes,
179 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
180 Err(error) => return Err(format!("read project session: {error}")),
181 };
182 let json = std::str::from_utf8(&bytes)
183 .map_err(|_| "unreadable session during project recovery".to_string())?;
184 let session = SessionState::from_storage_json(json)
185 .map(normalize_loaded_session)
186 .map_err(|_| "unreadable session during project recovery".to_string())?;
187 if session.id != id {
188 continue;
189 }
190 if session_matches_project_root(&session, std::path::Path::new(project_root)) {
191 matches.push((session.updated_at, session.id, bytes));
192 }
193 }
194 matches.sort_by_key(|(updated_at, _, _)| *updated_at);
195 matches
196 .pop()
197 .map(|(_, id, bytes)| project_head_from_bytes(&id, project_root, &bytes))
198 .transpose()
199}
200
201fn write_project_index(
202 index_path: &std::path::Path,
203 index: &ProjectSessionIndex,
204) -> Result<(), String> {
205 let json = serde_json::to_string(index).map_err(|e| format!("serialize project index: {e}"))?;
206 let tmp = index_path.with_extension(format!("{}.tmp", std::process::id()));
207 std::fs::write(&tmp, json).map_err(|e| format!("write project index: {e}"))?;
208 restrict_file_permissions(&tmp);
209 std::fs::rename(tmp, index_path).map_err(|e| format!("commit project index: {e}"))
210}
211
212fn with_project_index_lock<T>(
213 dir: &std::path::Path,
214 project_root: &str,
215 operation: impl FnOnce(&std::path::Path) -> Result<T, String>,
216) -> Result<T, String> {
217 use fs2::FileExt;
218 use std::time::Duration;
219
220 const LOCK_TIMEOUT: Duration = Duration::from_millis(200);
221
222 let index_path = project_index_path(dir, project_root);
223 let index_dir = index_path.parent().ok_or("project index has no parent")?;
224 std::fs::create_dir_all(index_dir).map_err(|e| format!("create project index: {e}"))?;
225 let lock = std::fs::OpenOptions::new()
226 .create(true)
227 .truncate(false)
228 .write(true)
229 .open(index_path.with_extension("lock"))
230 .map_err(|e| format!("project index lock: {e}"))?;
231 crate::core::file_lock::acquire_exclusive_timeout(&lock, LOCK_TIMEOUT)
232 .map_err(|e| format!("project index lock: {e}"))?;
233 let result = operation(&index_path);
234 let _ = FileExt::unlock(&lock);
235 result
236}
237
238fn update_project_index_locked(
241 dir: &std::path::Path,
242 project_root: &str,
243 index_path: &std::path::Path,
244 id: &str,
245) -> Result<(), String> {
246 let mut index = read_project_index(dir, project_root).unwrap_or_else(|| ProjectSessionIndex {
247 version: 1,
248 project_root: project_root.to_string(),
249 session_ids: Vec::new(),
250 verified_empty: false,
251 });
252 index.verified_empty = false;
253 index.session_ids.retain(|existing| existing != id);
254 index.session_ids.push(id.to_string());
255 let excess = index
256 .session_ids
257 .len()
258 .saturating_sub(PROJECT_HISTORY_LIMIT);
259 if excess > 0 {
260 index.session_ids.drain(..excess);
261 }
262 write_project_index(index_path, &index)
263}
264
265fn repair_project_index(dir: &std::path::Path, project_root: &str) -> Option<SessionState> {
266 let entries = std::fs::read_dir(dir).ok()?;
269 let mut matches = Vec::new();
270 for entry in entries.flatten() {
271 let path = entry.path();
272 if path.extension().and_then(|extension| extension.to_str()) != Some("json") {
273 continue;
274 }
275 let Some(id) = path.file_stem().and_then(|stem| stem.to_str()) else {
276 continue;
277 };
278 if id == "latest" || id.starts_with('.') {
279 continue;
280 }
281 let Some(session) = SessionState::load_by_id(id) else {
282 continue;
283 };
284 if session_matches_project_root(&session, std::path::Path::new(project_root)) {
285 matches.push(session);
286 }
287 }
288 matches.sort_by_key(|session| session.updated_at);
289 let latest = matches.last().cloned();
290 let scanned_ids: Vec<String> = matches.iter().map(|session| session.id.clone()).collect();
291 let result = with_project_index_lock(dir, project_root, |index_path| {
292 let mut session_ids = scanned_ids;
296 if let Some(current) = read_project_index(dir, project_root) {
297 for id in current.session_ids {
298 if validate_session_id(&id).is_err() || !dir.join(format!("{id}.json")).is_file() {
299 continue;
300 }
301 session_ids.retain(|existing| existing != &id);
302 session_ids.push(id);
303 }
304 }
305 let first_retained = session_ids.len().saturating_sub(PROJECT_HISTORY_LIMIT);
306 session_ids.drain(..first_retained);
307 write_project_index(
308 index_path,
309 &ProjectSessionIndex {
310 version: 1,
311 project_root: project_root.to_string(),
312 verified_empty: session_ids.is_empty(),
313 session_ids,
314 },
315 )?;
316 Ok(latest)
317 });
318 if let Err(error) = &result {
319 tracing::debug!("lean-ctx: session project index repair skipped: {error}");
320 }
321 result.ok().flatten()
322}
323
324#[cfg(unix)]
325fn restrict_file_permissions(path: &std::path::Path) {
326 use std::os::unix::fs::PermissionsExt;
327 let perms = std::fs::Permissions::from_mode(0o600);
328 let _ = std::fs::set_permissions(path, perms);
329}
330
331#[cfg(not(unix))]
332fn restrict_file_permissions(_path: &std::path::Path) {}
333
334fn validate_session_id(id: &str) -> Result<(), String> {
335 if id.is_empty()
336 || id == "latest"
337 || id.starts_with('.')
338 || id.contains('/')
339 || id.contains('\\')
340 || id.contains(std::path::MAIN_SEPARATOR)
341 {
342 return Err("invalid session id".to_string());
343 }
344 Ok(())
345}
346
347fn persist_session_facts(session: &SessionState) -> Result<(), String> {
348 let Some(project_root) = session
349 .project_root
350 .as_deref()
351 .filter(|project_root| !project_root.trim().is_empty())
352 else {
353 return Ok(());
354 };
355
356 if extract_session_facts(session).is_empty() {
357 return Ok(());
358 }
359
360 let mut knowledge = crate::core::knowledge::ProjectKnowledge::load_or_create(project_root);
364 crate::core::consolidation_engine::store_session_facts(&mut knowledge, session, false);
365 knowledge.save()
366}
367
368fn in_request_scope<T>(
372 request_project: Option<std::path::PathBuf>,
373 operation: impl FnOnce() -> T,
374) -> T {
375 crate::core::policy::runtime::REQUEST_PROJECT
376 .sync_scope(std::cell::RefCell::new(request_project), operation)
377}
378
379#[path = "persistence_commit.rs"]
382mod commit;
383pub(crate) use commit::AdmittedSave;
384
385impl PreparedSave {
386 pub fn write_to_disk(self) -> Result<(), String> {
402 self.publish().map_err(String::from)
403 }
404
405 pub fn publish(self) -> Result<(), SaveError> {
407 self.write_to_disk_with_policy(false).result
408 }
409
410 pub(super) fn write_to_disk_with_policy(self, require_new: bool) -> SaveOutcome {
411 let mut outcome =
412 SaveOutcome::new(&self.id, self.version, self.expected_storage_digest.clone());
413 let result = self
414 .admit()
415 .and_then(|admitted| admitted.commit(require_new, &mut outcome));
416 outcome.result = result;
417 outcome
418 }
419
420 pub(crate) fn admit(self) -> Result<AdmittedSave, SaveError> {
424 let request_project = self.request_project.clone();
425 in_request_scope(request_project, || self.admit_in_scope())
426 }
427
428 fn admit_in_scope(self) -> Result<AdmittedSave, SaveError> {
429 let session = &*self.snapshot;
430 if session
431 .project_root
432 .as_deref()
433 .is_some_and(|root| normalized_safe_project_root(root).is_none())
434 {
435 return Err(SaveError::AdmissionRefused(
436 "refusing to persist a session for a broad or unsafe project root".to_string(),
437 ));
438 }
439 let dir = sessions_dir()
440 .ok_or_else(|| SaveError::Storage("cannot determine home directory".to_string()))?;
441 let (admitted, authority) = super::admission::for_storage_with_authority(session)
442 .map_err(SaveError::AdmissionRefused)?;
443 let compaction_snapshot = if admitted.stats.total_tool_calls > 0 {
444 Some(admitted.build_compaction_snapshot())
445 } else {
446 None
447 };
448 let (json, _) = admitted.storage_json().map_err(SaveError::Storage)?;
449 let pointer_json = serde_json::to_string(&LatestPointer {
450 id: self.id.clone(),
451 })
452 .map_err(|error| SaveError::Storage(error.to_string()))?;
453 Ok(AdmittedSave {
454 canonical: session.canonical_checkpoint.is_some(),
455 expected_storage_digest: self.expected_storage_digest.clone(),
456 dir,
457 id: self.id.clone(),
458 version: self.version,
459 json,
460 pointer_json,
461 compaction_snapshot,
462 project_index_root: session
463 .project_root
464 .as_deref()
465 .and_then(normalized_safe_project_root),
466 request_project: self.request_project.clone(),
467 authority,
468 })
469 }
470
471 fn write_new_if_project_head(
472 self,
473 expected: Option<&(String, String)>,
474 ) -> Result<Option<SaveOutcome>, String> {
475 self.admit()
476 .map_err(String::from)?
477 .write_new_if_project_head(expected)
478 }
479}
480
481impl SessionState {
482 pub(crate) fn project_head(project_root: &str) -> Result<Option<(String, String)>, String> {
487 let Some(project_root) = normalized_safe_project_root(project_root) else {
488 return Err("unsafe project root".to_string());
489 };
490 let dir = sessions_dir().ok_or("cannot determine home directory")?;
491 with_project_index_lock(&dir, &project_root, |_| {
492 read_project_head_locked(&dir, &project_root)
493 })
494 }
495
496 pub fn strict_project_root(&self) -> Option<String> {
499 self.project_root
500 .as_deref()
501 .and_then(normalized_strict_project_root)
502 }
503
504 pub(crate) fn has_exact_project_root(&self, expected_root: &str) -> bool {
509 let Some(expected_root) = normalized_strict_project_root(expected_root) else {
510 return false;
511 };
512 self.strict_project_root()
513 .is_some_and(|actual_root| actual_root == expected_root)
514 }
515
516 #[must_use]
518 pub fn decision_count_this_week() -> u64 {
519 let cutoff = Utc::now() - chrono::Duration::days(7);
520 Self::list_sessions()
521 .into_iter()
522 .filter_map(|summary| Self::load_by_id(&summary.id))
523 .flat_map(|session| session.decisions)
524 .filter(|decision| decision.timestamp >= cutoff)
525 .count()
526 .try_into()
527 .unwrap_or(u64::MAX)
528 }
529
530 pub fn save(&mut self) -> Result<(), String> {
532 let prepared = self
533 .prepare_save()
534 .inspect_err(|_| self.note_save_failure())?;
535 self.acknowledge_save(prepared.write_to_disk_with_policy(false))
536 }
537
538 pub fn save_new(&mut self) -> Result<(), String> {
542 let prepared = self
543 .prepare_save()
544 .inspect_err(|_| self.note_save_failure())?;
545 self.acknowledge_save(prepared.write_to_disk_with_policy(true))
546 }
547
548 pub(crate) fn save_new_if_project_head(
551 &mut self,
552 expected: Option<&(String, String)>,
553 ) -> Result<bool, String> {
554 self.save_new_if_project_head_with_digest(expected)
555 .map(|committed| committed.is_some())
556 }
557
558 pub(crate) fn save_new_if_project_head_with_digest(
561 &mut self,
562 expected: Option<&(String, String)>,
563 ) -> Result<Option<String>, String> {
564 let prepared = self
565 .prepare_save()
566 .inspect_err(|_| self.note_save_failure())?;
567 match prepared.write_new_if_project_head(expected) {
568 Ok(Some(outcome)) => {
569 let digest = outcome
570 .primary_sha256
571 .clone()
572 .ok_or_else(|| "committed session digest unavailable".to_string())?;
573 self.acknowledge_save(outcome)?;
574 Ok(Some(digest))
575 }
576 Ok(None) => Ok(None),
577 Err(error) => {
578 self.note_save_failure();
579 Err(error)
580 }
581 }
582 }
583
584 pub fn prepare_save(&mut self) -> Result<PreparedSave, String> {
597 validate_session_id(&self.id)?;
598 Ok(PreparedSave {
599 expected_storage_digest: self
600 .canonical_checkpoint
601 .as_ref()
602 .and_then(|binding| binding.storage_digest.clone()),
603 id: self.id.clone(),
604 version: self.version,
605 request_project: crate::core::policy::runtime::REQUEST_PROJECT
606 .try_with(|slot| slot.borrow().clone())
607 .ok()
608 .flatten(),
609 snapshot: Box::new(self.clone()),
610 })
611 }
612
613 pub fn load_recent_for_project_root(project_root: &str, limit: usize) -> Vec<Self> {
620 let Some(project_root) = normalized_strict_project_root(project_root) else {
621 return Vec::new();
622 };
623 let Some(dir) = sessions_dir() else {
624 return Vec::new();
625 };
626 let Some(index) = std::fs::read_to_string(project_index_path(&dir, &project_root))
627 .ok()
628 .and_then(|json| serde_json::from_str::<ProjectSessionIndex>(&json).ok())
629 .filter(|index| index.version == 1 && index.project_root == project_root)
630 else {
631 return Vec::new();
632 };
633
634 index
635 .session_ids
636 .iter()
637 .rev()
638 .take(limit.min(PROJECT_HISTORY_LIMIT))
639 .filter_map(|id| {
640 Self::load_by_id_for_project_root(id, &project_root)
641 .ok()
642 .flatten()
643 })
644 .collect()
645 }
646
647 pub fn load_latest() -> Option<Self> {
660 if let Ok(project) =
664 crate::core::policy::runtime::REQUEST_PROJECT.try_with(|slot| slot.borrow().clone())
665 {
666 return Self::load_latest_for_project_root(&project?.to_string_lossy());
667 }
668 let cwd = std::env::current_dir().ok()?;
669 if crate::core::pathutil::is_broad_or_unsafe_root(&cwd) {
670 return None;
671 }
672 Self::load_latest_for_project_root(&cwd.to_string_lossy())
673 }
674
675 pub fn load_global_latest_pointer() -> Option<Self> {
680 let dir = sessions_dir()?;
681 let latest_path = dir.join("latest.json");
682 let pointer_json = std::fs::read_to_string(&latest_path).ok()?;
683 let pointer: LatestPointer = serde_json::from_str(&pointer_json).ok()?;
684 Self::load_by_id(&pointer.id)
685 }
686
687 pub fn load_latest_for_project_root(project_root: &str) -> Option<Self> {
693 let target_root = normalized_safe_project_root(project_root)?;
694 let dir = sessions_dir()?;
695
696 if let Some(index) = read_project_index(&dir, &target_root) {
697 if let Some(id) = index.session_ids.last()
698 && let Some(session) = Self::load_by_id(id)
699 && session_matches_project_root(&session, std::path::Path::new(&target_root))
700 {
701 return Some(session);
702 }
703 if index.session_ids.is_empty() && index.verified_empty {
704 return None;
705 }
706 }
707
708 repair_project_index(&dir, &target_root)
709 }
710
711 fn load_by_id_raw(id: &str) -> Option<Self> {
712 validate_session_id(id).ok()?;
713 let dir = sessions_dir()?;
714 let path = dir.join(format!("{id}.json"));
715 let json = std::fs::read_to_string(&path).ok()?;
716 Self::from_storage_json(&json).ok()
717 }
718
719 pub fn load_by_id_for_project_root(
722 id: &str,
723 expected_root: &str,
724 ) -> Result<Option<Self>, SessionScopeError> {
725 let Some(session) = Self::load_by_id_raw(id) else {
726 return Ok(None);
727 };
728 if !session.has_exact_project_root(expected_root) {
729 return Err(SessionScopeError::ProjectRootMismatch);
730 }
731 let persisted_project_root = session.project_root.clone();
732 let mut normalized = normalize_loaded_session(session);
733 normalized.project_root = persisted_project_root;
736 Ok(Some(normalized))
737 }
738
739 pub fn load_by_id(id: &str) -> Option<Self> {
741 Self::load_by_id_raw(id).map(normalize_loaded_session)
742 }
743
744 pub fn delete_session(id: &str) -> Result<bool, String> {
749 validate_session_id(id)?;
750 let Some(dir) = sessions_dir() else {
751 return Ok(false);
752 };
753 let path = dir.join(format!("{id}.json"));
754 if !path.exists() {
755 return Ok(false);
756 }
757
758 if let Some(session) = Self::load_by_id(id) {
759 persist_session_facts(&session)?;
760 }
761 std::fs::remove_file(&path).map_err(|e| e.to_string())?;
762
763 let snapshot = dir.join(format!("{id}_snapshot.txt"));
764 match std::fs::remove_file(&snapshot) {
765 Ok(()) => {}
766 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
767 Err(e) => return Err(e.to_string()),
768 }
769
770 let latest_path = dir.join("latest.json");
771 let points_to_deleted = std::fs::read_to_string(&latest_path)
772 .ok()
773 .and_then(|json| serde_json::from_str::<LatestPointer>(&json).ok())
774 .is_some_and(|pointer| pointer.id == id);
775 if points_to_deleted {
776 if let Some(next) = Self::list_sessions().into_iter().next() {
777 let latest_tmp = dir.join(".latest.json.tmp");
778 let pointer_json = serde_json::to_string(&LatestPointer { id: next.id })
779 .map_err(|e| e.to_string())?;
780 std::fs::write(&latest_tmp, pointer_json).map_err(|e| e.to_string())?;
781 restrict_file_permissions(&latest_tmp);
782 std::fs::rename(&latest_tmp, &latest_path).map_err(|e| e.to_string())?;
783 } else {
784 match std::fs::remove_file(&latest_path) {
785 Ok(()) => {}
786 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
787 Err(e) => return Err(e.to_string()),
788 }
789 }
790 }
791
792 Ok(true)
793 }
794
795 pub fn list_sessions() -> Vec<SessionSummary> {
797 let Some(dir) = sessions_dir() else {
798 return Vec::new();
799 };
800
801 let mut summaries = Vec::new();
802 if let Ok(entries) = std::fs::read_dir(&dir) {
803 for entry in entries.flatten() {
804 let path = entry.path();
805 if path.extension().and_then(|e| e.to_str()) != Some("json") {
806 continue;
807 }
808 if path.file_name().and_then(|n| n.to_str()) == Some("latest.json") {
809 continue;
810 }
811 if let Ok(json) = std::fs::read_to_string(&path)
812 && let Ok(session) = SessionState::from_storage_json(&json)
813 {
814 summaries.push(SessionSummary {
815 id: session.id,
816 started_at: session.started_at,
817 updated_at: session.updated_at,
818 version: session.version,
819 task: session.task.as_ref().map(|t| t.description.clone()),
820 tool_calls: session.stats.total_tool_calls,
821 tokens_saved: session.stats.total_tokens_saved,
822 project_root: session.project_root,
823 });
824 }
825 }
826 }
827
828 summaries.sort_by_key(|x| std::cmp::Reverse(x.updated_at));
829 summaries
830 }
831
832 pub fn doctor_quarantine_unsafe_roots(apply: bool) -> (Vec<(String, String)>, usize) {
841 let mut found: Vec<(String, String)> = Vec::new();
842 let mut quarantined = 0usize;
843 let Some(dir) = sessions_dir() else {
844 return (found, quarantined);
845 };
846 let Ok(entries) = std::fs::read_dir(&dir) else {
847 return (found, quarantined);
848 };
849 for entry in entries.flatten() {
850 let path = entry.path();
851 if path.extension().and_then(|e| e.to_str()) != Some("json") {
852 continue;
853 }
854 let Some(id) = path.file_stem().and_then(|n| n.to_str()) else {
855 continue;
856 };
857 if id == "latest" || id.starts_with('.') {
858 continue;
859 }
860 let Some(session) = Self::load_by_id(id) else {
861 continue;
862 };
863 let Some(root) = session.project_root.as_deref() else {
864 continue;
865 };
866 let root_path = std::path::Path::new(root);
867 if crate::core::pathutil::is_broad_or_unsafe_root(root_path) {
868 found.push((id.to_string(), root.to_string()));
869 if apply {
870 let q_dir = dir.join("quarantine");
871 if std::fs::create_dir_all(&q_dir).is_ok()
872 && std::fs::rename(&path, q_dir.join(format!("{id}.json"))).is_ok()
873 {
874 quarantined += 1;
875 }
876 }
877 }
878 }
879 (found, quarantined)
880 }
881
882 pub fn cleanup_old_sessions(max_age_days: i64) -> u32 {
888 let Some(dir) = sessions_dir() else { return 0 };
889 let cutoff = Utc::now() - chrono::Duration::days(max_age_days);
890 let global_latest = Self::load_global_latest_pointer().map(|session| session.id);
891 let mut sessions = Vec::new();
892
893 if let Ok(entries) = std::fs::read_dir(&dir) {
894 for entry in entries.flatten() {
895 let path = entry.path();
896 if path.extension().and_then(|extension| extension.to_str()) != Some("json") {
897 continue;
898 }
899 let Some(id) = path.file_stem().and_then(|stem| stem.to_str()) else {
900 continue;
901 };
902 if id == "latest" || id.starts_with('.') {
903 continue;
904 }
905 if let Some(session) = Self::load_by_id(id) {
906 sessions.push((path, session));
907 }
908 }
909 }
910
911 let mut newest_by_project = std::collections::HashMap::new();
912 for (_, session) in &sessions {
913 let Some(project_root) = session
914 .project_root
915 .as_deref()
916 .filter(|root| !root.trim().is_empty())
917 else {
918 continue;
919 };
920 newest_by_project
921 .entry(project_root)
922 .and_modify(|current: &mut &SessionState| {
923 if session.updated_at > current.updated_at {
924 *current = session;
925 }
926 })
927 .or_insert(session);
928 }
929
930 let mut retained_ids: std::collections::HashSet<String> = newest_by_project
931 .values()
932 .map(|session| session.id.clone())
933 .collect();
934 if let Some(id) = global_latest {
935 retained_ids.insert(id);
936 }
937
938 sessions
939 .into_iter()
940 .filter(|(_, session)| {
941 session.updated_at < cutoff && !retained_ids.contains(&session.id)
942 })
943 .filter(|(_, session)| persist_session_facts(session).is_ok())
944 .filter(|(path, _)| std::fs::remove_file(path).is_ok())
945 .map(|(path, session)| {
946 let snapshot = path.with_file_name(format!("{}_snapshot.txt", session.id));
947 let _ = std::fs::remove_file(snapshot);
948 })
949 .count() as u32
950 }
951}
952
953#[cfg(test)]
954#[path = "storage_boundary_tests.rs"]
955mod storage_boundary_tests;
956
957#[cfg(test)]
958mod tests {
959 use super::{
960 ProjectSessionIndex, SessionState, normalized_safe_project_root, project_index_path,
961 write_project_index,
962 };
963 use chrono::{Duration, Utc};
964 use sha2::{Digest as _, Sha256};
965
966 #[test]
967 fn recent_project_sessions_use_bounded_index_without_scanning_legacy_store() {
968 let _data = crate::core::data_dir::isolated_data_dir();
969 let project = tempfile::tempdir().expect("project tempdir");
970 let root = project.path().to_string_lossy().to_string();
971 assert_eq!(SessionState::project_head(&root), Ok(None));
972
973 for id in ["first", "second", "third"] {
974 let mut session = SessionState::new();
975 session.id = id.to_string();
976 session.project_root = Some(root.clone());
977 session.save().expect("save indexed session");
978 }
979
980 let sessions = crate::core::session::paths::sessions_dir().expect("sessions dir");
983 std::fs::write(sessions.join("legacy-unreadable.json"), "not json")
984 .expect("write legacy artifact");
985
986 let ids: Vec<_> = SessionState::load_recent_for_project_root(&root, 8)
987 .into_iter()
988 .map(|session| session.id)
989 .collect();
990 assert_eq!(ids, ["third", "second", "first"]);
991 }
992
993 #[test]
994 fn recent_project_sessions_refuse_broad_roots() {
995 let _data = crate::core::data_dir::isolated_data_dir();
996 assert!(SessionState::load_recent_for_project_root("/", 8).is_empty());
997 }
998
999 #[test]
1005 fn save_gives_up_when_another_holder_keeps_the_session_lock() {
1006 use fs2::FileExt;
1007
1008 let _data = crate::core::data_dir::isolated_data_dir();
1009 let project = tempfile::tempdir().expect("project tempdir");
1010 let mut session = SessionState::new();
1011 session.id = "wedged".to_string();
1012 session.project_root = Some(project.path().to_string_lossy().to_string());
1013
1014 let sessions = crate::core::session::paths::sessions_dir().expect("sessions dir");
1015 std::fs::create_dir_all(&sessions).expect("create sessions dir");
1016 let holder = std::fs::File::create(sessions.join(".wedged.save.lock")).expect("lock file");
1017 holder.lock_exclusive().expect("hold the save lock");
1018
1019 let started = std::time::Instant::now();
1020 let error = session
1021 .save()
1022 .expect_err("a held lock must not be waited out");
1023 let waited = started.elapsed();
1024
1025 assert!(
1026 error.contains("lock session save"),
1027 "unexpected error: {error}"
1028 );
1029 assert!(
1030 waited < super::SAVE_LOCK_TIMEOUT * 4,
1031 "save must return on its own deadline, waited {waited:?}"
1032 );
1033 assert_ne!(
1034 session.stats.unsaved_changes, 0,
1035 "a failed save must stay pending so the next batch retries"
1036 );
1037 FileExt::unlock(&holder).expect("release");
1038 }
1039
1040 #[test]
1041 fn broad_root_sessions_are_never_persisted() {
1042 let _data = crate::core::data_dir::isolated_data_dir();
1043 let mut session = SessionState::new();
1044 session.project_root = Some("/".to_string());
1045
1046 let error = session
1047 .save()
1048 .expect_err("broad root save must be rejected");
1049
1050 assert!(error.contains("broad or unsafe"));
1051 assert!(
1052 crate::core::session::paths::sessions_dir()
1053 .expect("sessions dir")
1054 .read_dir()
1055 .map_or(true, |mut entries| entries.next().is_none())
1056 );
1057 }
1058
1059 #[test]
1060 fn latest_project_session_uses_valid_index_without_scanning_unindexed_sessions() {
1061 let _data = crate::core::data_dir::isolated_data_dir();
1062 let project = tempfile::tempdir().expect("project tempdir");
1063 let root = project.path().to_string_lossy().to_string();
1064
1065 let mut indexed = SessionState::new();
1066 indexed.id = "indexed".to_string();
1067 indexed.project_root = Some(root.clone());
1068 indexed.updated_at = Utc::now() - Duration::hours(1);
1069 indexed.save().expect("save indexed session");
1070
1071 let mut unindexed = SessionState::new();
1074 unindexed.id = "unindexed".to_string();
1075 unindexed.project_root = Some(root.clone());
1076 unindexed.updated_at = Utc::now();
1077 let sessions = crate::core::session::paths::sessions_dir().expect("sessions dir");
1078 std::fs::write(
1079 sessions.join("unindexed.json"),
1080 serde_json::to_string(&unindexed).expect("serialize legacy session"),
1081 )
1082 .expect("write unindexed session");
1083
1084 assert_eq!(
1085 SessionState::load_latest_for_project_root(&root)
1086 .expect("load indexed session")
1087 .id,
1088 "indexed"
1089 );
1090 }
1091
1092 #[test]
1096 fn latest_session_inside_a_request_follows_the_request_project() {
1097 let _data = crate::core::data_dir::isolated_data_dir();
1098 let project = tempfile::tempdir().expect("project tempdir");
1099 let mut session = SessionState::new();
1100 session.id = "request-project".to_string();
1101 session.project_root = Some(project.path().to_string_lossy().to_string());
1102 session.save().expect("save session");
1103 let in_request = |project: Option<std::path::PathBuf>| {
1104 crate::core::policy::runtime::REQUEST_PROJECT
1105 .sync_scope(std::cell::RefCell::new(project), SessionState::load_latest)
1106 .map(|session| session.id)
1107 };
1108
1109 assert_eq!(
1110 in_request(Some(project.path().to_path_buf())).as_deref(),
1111 Some("request-project")
1112 );
1113 assert_eq!(in_request(None), None);
1114 }
1115
1116 #[test]
1119 fn a_nested_directorys_session_is_not_the_parent_projects() {
1120 let _data = crate::core::data_dir::isolated_data_dir();
1121 let parent = tempfile::tempdir().expect("project tempdir");
1122 let nested = parent.path().join("tools");
1123 std::fs::create_dir(&nested).expect("nested dir");
1124 let nested = nested.to_string_lossy().to_string();
1125 let mut session = SessionState::new();
1126 session.project_root = Some(nested.clone());
1127 session.shell_cwd = Some(nested);
1128 session.save().expect("save nested session");
1129
1130 assert!(
1131 SessionState::load_latest_for_project_root(&parent.path().to_string_lossy()).is_none()
1132 );
1133 }
1134
1135 #[test]
1136 fn latest_project_session_repairs_a_missing_index() {
1137 let _data = crate::core::data_dir::isolated_data_dir();
1138 let project = tempfile::tempdir().expect("project tempdir");
1139 let root = project.path().to_string_lossy().to_string();
1140 let mut session = SessionState::new();
1141 session.id = "repair-missing".to_string();
1142 session.project_root = Some(root.clone());
1143 session.save().expect("save indexed session");
1144
1145 let sessions = crate::core::session::paths::sessions_dir().expect("sessions dir");
1146 let canonical_root = normalized_safe_project_root(&root).expect("safe root");
1147 let index_path = project_index_path(&sessions, &canonical_root);
1148 std::fs::remove_file(&index_path).expect("remove project index");
1149
1150 assert_eq!(
1151 SessionState::load_latest_for_project_root(&root)
1152 .expect("repair and load session")
1153 .id,
1154 "repair-missing"
1155 );
1156 let repaired: ProjectSessionIndex =
1157 serde_json::from_str(&std::fs::read_to_string(index_path).expect("repaired index"))
1158 .expect("valid repaired index");
1159 assert_eq!(repaired.session_ids, ["repair-missing"]);
1160 }
1161
1162 #[test]
1163 fn latest_project_session_repairs_an_empty_index() {
1164 let _data = crate::core::data_dir::isolated_data_dir();
1165 let project = tempfile::tempdir().expect("project tempdir");
1166 let root = project.path().to_string_lossy().to_string();
1167 let mut session = SessionState::new();
1168 session.id = "repair-empty".to_string();
1169 session.project_root = Some(root.clone());
1170 session.save().expect("save indexed session");
1171
1172 let sessions = crate::core::session::paths::sessions_dir().expect("sessions dir");
1173 let canonical_root = normalized_safe_project_root(&root).expect("safe root");
1174 let index_path = project_index_path(&sessions, &canonical_root);
1175 write_project_index(
1176 &index_path,
1177 &ProjectSessionIndex {
1178 version: 1,
1179 project_root: canonical_root,
1180 session_ids: Vec::new(),
1181 verified_empty: false,
1182 },
1183 )
1184 .expect("empty project index");
1185
1186 assert_eq!(
1187 SessionState::load_latest_for_project_root(&root)
1188 .expect("repair and load session")
1189 .id,
1190 "repair-empty"
1191 );
1192 let repaired: ProjectSessionIndex =
1193 serde_json::from_str(&std::fs::read_to_string(index_path).expect("repaired index"))
1194 .expect("valid repaired index");
1195 assert_eq!(repaired.session_ids, ["repair-empty"]);
1196 }
1197
1198 #[test]
1199 fn a_scan_that_finds_no_session_is_cached_until_the_next_save() {
1200 let _data = crate::core::data_dir::isolated_data_dir();
1201 let project = tempfile::tempdir().expect("project tempdir");
1202 let root = project.path().to_string_lossy().to_string();
1203 let sessions = crate::core::session::paths::sessions_dir().expect("sessions dir");
1204 std::fs::create_dir_all(&sessions).expect("session store exists");
1205
1206 assert!(SessionState::load_latest_for_project_root(&root).is_none());
1208
1209 let mut unindexed = SessionState::new();
1212 unindexed.id = "unindexed-after-scan".to_string();
1213 unindexed.project_root = Some(root.clone());
1214 std::fs::write(
1215 sessions.join("unindexed-after-scan.json"),
1216 serde_json::to_string(&unindexed).expect("serialize session"),
1217 )
1218 .expect("write unindexed session");
1219 assert!(SessionState::load_latest_for_project_root(&root).is_none());
1220
1221 let mut saved = SessionState::new();
1223 saved.id = "saved-after-scan".to_string();
1224 saved.project_root = Some(root.clone());
1225 saved.save().expect("save session");
1226 assert_eq!(
1227 SessionState::load_latest_for_project_root(&root)
1228 .expect("saved session")
1229 .id,
1230 "saved-after-scan"
1231 );
1232 }
1233
1234 #[test]
1235 fn latest_project_session_repairs_a_corrupt_index() {
1236 let _data = crate::core::data_dir::isolated_data_dir();
1237 let project = tempfile::tempdir().expect("project tempdir");
1238 let root = project.path().to_string_lossy().to_string();
1239 let mut session = SessionState::new();
1240 session.id = "repair-corrupt".to_string();
1241 session.project_root = Some(root.clone());
1242 session.save().expect("save indexed session");
1243
1244 let sessions = crate::core::session::paths::sessions_dir().expect("sessions dir");
1245 let canonical_root = normalized_safe_project_root(&root).expect("safe root");
1246 let index_path = project_index_path(&sessions, &canonical_root);
1247 std::fs::write(&index_path, "not json").expect("corrupt project index");
1248
1249 assert_eq!(
1250 SessionState::load_latest_for_project_root(&root)
1251 .expect("repair and load session")
1252 .id,
1253 "repair-corrupt"
1254 );
1255 let repaired: ProjectSessionIndex =
1256 serde_json::from_str(&std::fs::read_to_string(index_path).expect("repaired index"))
1257 .expect("valid repaired index");
1258 assert_eq!(repaired.session_ids, ["repair-corrupt"]);
1259 }
1260
1261 #[test]
1262 fn latest_project_session_repairs_an_index_with_a_missing_session() {
1263 let _data = crate::core::data_dir::isolated_data_dir();
1264 let project = tempfile::tempdir().expect("project tempdir");
1265 let root = project.path().to_string_lossy().to_string();
1266
1267 let mut older = SessionState::new();
1268 older.id = "repair-older".to_string();
1269 older.project_root = Some(root.clone());
1270 older.updated_at = Utc::now() - Duration::hours(1);
1271 older.save().expect("save older session");
1272
1273 let mut missing = SessionState::new();
1274 missing.id = "repair-missing-file".to_string();
1275 missing.project_root = Some(root.clone());
1276 missing.save().expect("save missing session");
1277 let sessions = crate::core::session::paths::sessions_dir().expect("sessions dir");
1278 std::fs::remove_file(sessions.join("repair-missing-file.json"))
1279 .expect("remove indexed session");
1280
1281 assert_eq!(
1282 SessionState::load_latest_for_project_root(&root)
1283 .expect("repair and load fallback")
1284 .id,
1285 "repair-older"
1286 );
1287 let repaired: ProjectSessionIndex = serde_json::from_str(
1288 &std::fs::read_to_string(project_index_path(
1289 &sessions,
1290 &normalized_safe_project_root(&root).expect("safe root"),
1291 ))
1292 .expect("repaired index"),
1293 )
1294 .expect("valid repaired index");
1295 assert_eq!(repaired.session_ids, ["repair-older"]);
1296 }
1297
1298 #[test]
1299 fn cleanup_old_sessions_preserves_the_latest_session_for_each_project() {
1300 let _data = crate::core::data_dir::isolated_data_dir();
1301 let project_a = tempfile::tempdir().expect("project A");
1302 let project_b = tempfile::tempdir().expect("project B");
1303 let root_a = project_a.path().to_string_lossy().to_string();
1304 let root_b = project_b.path().to_string_lossy().to_string();
1305
1306 for (id, root, age_days) in [
1307 ("a-old", root_a.as_str(), 10),
1308 ("a-latest", root_a.as_str(), 8),
1309 ("b-old", root_b.as_str(), 10),
1310 ("b-latest", root_b.as_str(), 8),
1311 ] {
1312 let mut session = SessionState::new();
1313 session.id = id.to_string();
1314 session.project_root = Some(root.to_string());
1315 session.updated_at = Utc::now() - Duration::days(age_days);
1316 session.save().expect("save session");
1317 }
1318
1319 assert_eq!(SessionState::cleanup_old_sessions(7), 2);
1320 assert!(SessionState::load_by_id("a-latest").is_some());
1321 assert!(SessionState::load_by_id("b-latest").is_some());
1322 assert!(SessionState::load_by_id("a-old").is_none());
1323 assert!(SessionState::load_by_id("b-old").is_none());
1324 }
1325
1326 #[test]
1327 fn deferred_save_cannot_replace_a_newer_session_version() {
1328 let _data = crate::core::data_dir::isolated_data_dir();
1329 let project = tempfile::tempdir().expect("project root");
1330 let project_root = project.path().to_string_lossy().into_owned();
1331 let mut session = SessionState::new();
1332 session.project_root = Some(project_root.clone());
1333 session.shell_cwd = Some(project_root);
1334 let id = session.id.clone();
1335 let older = session.prepare_save().expect("prepare older save");
1336 session.increment();
1337 let expected_version = session.version;
1338 let newer = session.prepare_save().expect("prepare newer save");
1339
1340 newer.write_to_disk().expect("write newer save");
1341 older.write_to_disk().expect("skip older save");
1342
1343 assert_eq!(
1344 SessionState::load_by_id(&id)
1345 .expect("load persisted session")
1346 .version,
1347 expected_version
1348 );
1349 }
1350
1351 #[test]
1352 fn project_head_cas_conflict_preserves_live_files_and_success_returns_exact_digest() {
1353 let _data = crate::core::data_dir::isolated_data_dir();
1354 let project = tempfile::tempdir().expect("project tempdir");
1355 let root = project.path().to_string_lossy().to_string();
1356
1357 let mut initial = SessionState::new();
1358 initial.id = "cas-initial".to_string();
1359 initial.project_root = Some(root.clone());
1360 initial.save().expect("save initial project session");
1361 let stale_head = SessionState::project_head(&root)
1362 .expect("read initial project head")
1363 .expect("initial head exists");
1364
1365 let mut local = SessionState::new();
1366 local.id = "cas-local".to_string();
1367 local.project_root = Some(root.clone());
1368 local.save().expect("save competing local session");
1369
1370 let sessions = crate::core::session::paths::sessions_dir().expect("sessions dir");
1371 let latest_path = sessions.join("latest.json");
1372 let index_path = project_index_path(
1373 &sessions,
1374 &normalized_safe_project_root(&root).expect("safe root"),
1375 );
1376 let latest_before_conflict = std::fs::read(&latest_path).expect("latest pointer");
1377 let index_before_conflict = std::fs::read(&index_path).expect("project index");
1378
1379 let mut candidate = SessionState::new();
1380 candidate.id = "cas-candidate".to_string();
1381 candidate.project_root = Some(root.clone());
1382 assert!(
1383 !candidate
1384 .save_new_if_project_head(Some(&stale_head))
1385 .expect("stale head is a conflict")
1386 );
1387 assert!(!sessions.join("cas-candidate.json").exists());
1388 assert_eq!(
1389 std::fs::read(&latest_path).expect("latest after conflict"),
1390 latest_before_conflict
1391 );
1392 assert_eq!(
1393 std::fs::read(&index_path).expect("index after conflict"),
1394 index_before_conflict
1395 );
1396
1397 let current_head = SessionState::project_head(&root)
1398 .expect("read current project head")
1399 .expect("current head exists");
1400 assert!(
1401 candidate
1402 .save_new_if_project_head(Some(¤t_head))
1403 .expect("publish against current head")
1404 );
1405 let persisted =
1406 std::fs::read(sessions.join("cas-candidate.json")).expect("published primary");
1407 assert_eq!(
1408 SessionState::project_head(&root).expect("read published project head"),
1409 Some((
1410 "cas-candidate".to_string(),
1411 format!("sha256:{}", hex::encode(Sha256::digest(&persisted))),
1412 ))
1413 );
1414 }
1415}