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 let mut session = Self::from_storage_json(&json).ok()?;
717 if let Some(taint) = super::taint::load_sidecar(&session.id) {
720 session.taint.merge(&taint);
721 }
722 Some(session)
723 }
724
725 pub fn load_by_id_for_project_root(
728 id: &str,
729 expected_root: &str,
730 ) -> Result<Option<Self>, SessionScopeError> {
731 let Some(session) = Self::load_by_id_raw(id) else {
732 return Ok(None);
733 };
734 if !session.has_exact_project_root(expected_root) {
735 return Err(SessionScopeError::ProjectRootMismatch);
736 }
737 let persisted_project_root = session.project_root.clone();
738 let mut normalized = normalize_loaded_session(session);
739 normalized.project_root = persisted_project_root;
742 Ok(Some(normalized))
743 }
744
745 pub fn load_by_id(id: &str) -> Option<Self> {
747 Self::load_by_id_raw(id).map(normalize_loaded_session)
748 }
749
750 pub fn delete_session(id: &str) -> Result<bool, String> {
755 validate_session_id(id)?;
756 let Some(dir) = sessions_dir() else {
757 return Ok(false);
758 };
759 let path = dir.join(format!("{id}.json"));
760 if !path.exists() {
761 return Ok(false);
762 }
763
764 if let Some(session) = Self::load_by_id(id) {
765 persist_session_facts(&session)?;
766 }
767 std::fs::remove_file(&path).map_err(|e| e.to_string())?;
768
769 let snapshot = dir.join(format!("{id}_snapshot.txt"));
770 match std::fs::remove_file(&snapshot) {
771 Ok(()) => {}
772 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
773 Err(e) => return Err(e.to_string()),
774 }
775
776 let latest_path = dir.join("latest.json");
777 let points_to_deleted = std::fs::read_to_string(&latest_path)
778 .ok()
779 .and_then(|json| serde_json::from_str::<LatestPointer>(&json).ok())
780 .is_some_and(|pointer| pointer.id == id);
781 if points_to_deleted {
782 if let Some(next) = Self::list_sessions().into_iter().next() {
783 let latest_tmp = dir.join(".latest.json.tmp");
784 let pointer_json = serde_json::to_string(&LatestPointer { id: next.id })
785 .map_err(|e| e.to_string())?;
786 std::fs::write(&latest_tmp, pointer_json).map_err(|e| e.to_string())?;
787 restrict_file_permissions(&latest_tmp);
788 std::fs::rename(&latest_tmp, &latest_path).map_err(|e| e.to_string())?;
789 } else {
790 match std::fs::remove_file(&latest_path) {
791 Ok(()) => {}
792 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
793 Err(e) => return Err(e.to_string()),
794 }
795 }
796 }
797
798 Ok(true)
799 }
800
801 pub fn list_sessions() -> Vec<SessionSummary> {
803 let Some(dir) = sessions_dir() else {
804 return Vec::new();
805 };
806
807 let mut summaries = Vec::new();
808 if let Ok(entries) = std::fs::read_dir(&dir) {
809 for entry in entries.flatten() {
810 let path = entry.path();
811 if path.extension().and_then(|e| e.to_str()) != Some("json") {
812 continue;
813 }
814 if path.file_name().and_then(|n| n.to_str()) == Some("latest.json") {
815 continue;
816 }
817 if let Ok(json) = std::fs::read_to_string(&path)
818 && let Ok(session) = SessionState::from_storage_json(&json)
819 {
820 summaries.push(SessionSummary {
821 id: session.id,
822 started_at: session.started_at,
823 updated_at: session.updated_at,
824 version: session.version,
825 task: session.task.as_ref().map(|t| t.description.clone()),
826 tool_calls: session.stats.total_tool_calls,
827 tokens_saved: session.stats.total_tokens_saved,
828 project_root: session.project_root,
829 });
830 }
831 }
832 }
833
834 summaries.sort_by_key(|x| std::cmp::Reverse(x.updated_at));
835 summaries
836 }
837
838 pub fn load_attributed(id: &str) -> Vec<Self> {
842 let Some(dir) = sessions_dir() else {
843 return Vec::new();
844 };
845 let Ok(entries) = std::fs::read_dir(&dir) else {
846 return Vec::new();
847 };
848 let mut out: Vec<Self> = entries
849 .flatten()
850 .map(|entry| entry.path())
851 .filter(|path| path.extension().and_then(|e| e.to_str()) == Some("json"))
852 .filter(|path| path.file_name().and_then(|n| n.to_str()) != Some("latest.json"))
853 .filter_map(|path| std::fs::read_to_string(path).ok())
854 .filter_map(|json| Self::from_storage_json(&json).ok())
855 .filter(|s| s.id == id || s.app_session_id.as_deref() == Some(id))
856 .collect();
857 out.sort_by(|a, b| {
858 a.started_at
859 .cmp(&b.started_at)
860 .then_with(|| a.id.cmp(&b.id))
861 });
862 out
863 }
864
865 pub fn doctor_quarantine_unsafe_roots(apply: bool) -> (Vec<(String, String)>, usize) {
874 let mut found: Vec<(String, String)> = Vec::new();
875 let mut quarantined = 0usize;
876 let Some(dir) = sessions_dir() else {
877 return (found, quarantined);
878 };
879 let Ok(entries) = std::fs::read_dir(&dir) else {
880 return (found, quarantined);
881 };
882 for entry in entries.flatten() {
883 let path = entry.path();
884 if path.extension().and_then(|e| e.to_str()) != Some("json") {
885 continue;
886 }
887 let Some(id) = path.file_stem().and_then(|n| n.to_str()) else {
888 continue;
889 };
890 if id == "latest" || id.starts_with('.') {
891 continue;
892 }
893 let Some(session) = Self::load_by_id(id) else {
894 continue;
895 };
896 let Some(root) = session.project_root.as_deref() else {
897 continue;
898 };
899 let root_path = std::path::Path::new(root);
900 if crate::core::pathutil::is_broad_or_unsafe_root(root_path) {
901 found.push((id.to_string(), root.to_string()));
902 if apply {
903 let q_dir = dir.join("quarantine");
904 if std::fs::create_dir_all(&q_dir).is_ok()
905 && std::fs::rename(&path, q_dir.join(format!("{id}.json"))).is_ok()
906 {
907 quarantined += 1;
908 }
909 }
910 }
911 }
912 (found, quarantined)
913 }
914
915 pub fn cleanup_old_sessions(max_age_days: i64) -> u32 {
921 let Some(dir) = sessions_dir() else { return 0 };
922 let cutoff = Utc::now() - chrono::Duration::days(max_age_days);
923 let global_latest = Self::load_global_latest_pointer().map(|session| session.id);
924 let mut sessions = Vec::new();
925
926 if let Ok(entries) = std::fs::read_dir(&dir) {
927 for entry in entries.flatten() {
928 let path = entry.path();
929 if path.extension().and_then(|extension| extension.to_str()) != Some("json") {
930 continue;
931 }
932 let Some(id) = path.file_stem().and_then(|stem| stem.to_str()) else {
933 continue;
934 };
935 if id == "latest" || id.starts_with('.') {
936 continue;
937 }
938 if let Some(session) = Self::load_by_id(id) {
939 sessions.push((path, session));
940 }
941 }
942 }
943
944 let mut newest_by_project = std::collections::HashMap::new();
945 for (_, session) in &sessions {
946 let Some(project_root) = session
947 .project_root
948 .as_deref()
949 .filter(|root| !root.trim().is_empty())
950 else {
951 continue;
952 };
953 newest_by_project
954 .entry(project_root)
955 .and_modify(|current: &mut &SessionState| {
956 if session.updated_at > current.updated_at {
957 *current = session;
958 }
959 })
960 .or_insert(session);
961 }
962
963 let mut retained_ids: std::collections::HashSet<String> = newest_by_project
964 .values()
965 .map(|session| session.id.clone())
966 .collect();
967 if let Some(id) = global_latest {
968 retained_ids.insert(id);
969 }
970
971 sessions
972 .into_iter()
973 .filter(|(_, session)| {
974 session.updated_at < cutoff && !retained_ids.contains(&session.id)
975 })
976 .filter(|(_, session)| persist_session_facts(session).is_ok())
977 .filter(|(path, _)| std::fs::remove_file(path).is_ok())
978 .map(|(path, session)| {
979 let snapshot = path.with_file_name(format!("{}_snapshot.txt", session.id));
980 let _ = std::fs::remove_file(snapshot);
981 })
982 .count() as u32
983 }
984}
985
986#[cfg(test)]
987#[path = "storage_boundary_tests.rs"]
988mod storage_boundary_tests;
989
990#[cfg(test)]
991mod tests {
992 use super::{
993 ProjectSessionIndex, SessionState, normalized_safe_project_root, project_index_path,
994 write_project_index,
995 };
996 use chrono::{Duration, Utc};
997 use sha2::{Digest as _, Sha256};
998
999 #[test]
1000 fn recent_project_sessions_use_bounded_index_without_scanning_legacy_store() {
1001 let _data = crate::core::data_dir::isolated_data_dir();
1002 let project = tempfile::tempdir().expect("project tempdir");
1003 let root = project.path().to_string_lossy().to_string();
1004 assert_eq!(SessionState::project_head(&root), Ok(None));
1005
1006 for id in ["first", "second", "third"] {
1007 let mut session = SessionState::new();
1008 session.id = id.to_string();
1009 session.project_root = Some(root.clone());
1010 session.save().expect("save indexed session");
1011 }
1012
1013 let sessions = crate::core::session::paths::sessions_dir().expect("sessions dir");
1016 std::fs::write(sessions.join("legacy-unreadable.json"), "not json")
1017 .expect("write legacy artifact");
1018
1019 let ids: Vec<_> = SessionState::load_recent_for_project_root(&root, 8)
1020 .into_iter()
1021 .map(|session| session.id)
1022 .collect();
1023 assert_eq!(ids, ["third", "second", "first"]);
1024 }
1025
1026 #[test]
1027 fn recent_project_sessions_refuse_broad_roots() {
1028 let _data = crate::core::data_dir::isolated_data_dir();
1029 assert!(SessionState::load_recent_for_project_root("/", 8).is_empty());
1030 }
1031
1032 #[test]
1038 fn save_gives_up_when_another_holder_keeps_the_session_lock() {
1039 use fs2::FileExt;
1040
1041 let _data = crate::core::data_dir::isolated_data_dir();
1042 let project = tempfile::tempdir().expect("project tempdir");
1043 let mut session = SessionState::new();
1044 session.id = "wedged".to_string();
1045 session.project_root = Some(project.path().to_string_lossy().to_string());
1046
1047 let sessions = crate::core::session::paths::sessions_dir().expect("sessions dir");
1048 std::fs::create_dir_all(&sessions).expect("create sessions dir");
1049 let holder = std::fs::File::create(sessions.join(".wedged.save.lock")).expect("lock file");
1050 holder.lock_exclusive().expect("hold the save lock");
1051
1052 let started = std::time::Instant::now();
1053 let error = session
1054 .save()
1055 .expect_err("a held lock must not be waited out");
1056 let waited = started.elapsed();
1057
1058 assert!(
1059 error.contains("lock session save"),
1060 "unexpected error: {error}"
1061 );
1062 assert!(
1063 waited < super::SAVE_LOCK_TIMEOUT * 4,
1064 "save must return on its own deadline, waited {waited:?}"
1065 );
1066 assert_ne!(
1067 session.stats.unsaved_changes, 0,
1068 "a failed save must stay pending so the next batch retries"
1069 );
1070 FileExt::unlock(&holder).expect("release");
1071 }
1072
1073 #[test]
1074 fn broad_root_sessions_are_never_persisted() {
1075 let _data = crate::core::data_dir::isolated_data_dir();
1076 let mut session = SessionState::new();
1077 session.project_root = Some("/".to_string());
1078
1079 let error = session
1080 .save()
1081 .expect_err("broad root save must be rejected");
1082
1083 assert!(error.contains("broad or unsafe"));
1084 assert!(
1085 crate::core::session::paths::sessions_dir()
1086 .expect("sessions dir")
1087 .read_dir()
1088 .map_or(true, |mut entries| entries.next().is_none())
1089 );
1090 }
1091
1092 #[test]
1093 fn latest_project_session_uses_valid_index_without_scanning_unindexed_sessions() {
1094 let _data = crate::core::data_dir::isolated_data_dir();
1095 let project = tempfile::tempdir().expect("project tempdir");
1096 let root = project.path().to_string_lossy().to_string();
1097
1098 let mut indexed = SessionState::new();
1099 indexed.id = "indexed".to_string();
1100 indexed.project_root = Some(root.clone());
1101 indexed.updated_at = Utc::now() - Duration::hours(1);
1102 indexed.save().expect("save indexed session");
1103
1104 let mut unindexed = SessionState::new();
1107 unindexed.id = "unindexed".to_string();
1108 unindexed.project_root = Some(root.clone());
1109 unindexed.updated_at = Utc::now();
1110 let sessions = crate::core::session::paths::sessions_dir().expect("sessions dir");
1111 std::fs::write(
1112 sessions.join("unindexed.json"),
1113 serde_json::to_string(&unindexed).expect("serialize legacy session"),
1114 )
1115 .expect("write unindexed session");
1116
1117 assert_eq!(
1118 SessionState::load_latest_for_project_root(&root)
1119 .expect("load indexed session")
1120 .id,
1121 "indexed"
1122 );
1123 }
1124
1125 #[test]
1129 fn latest_session_inside_a_request_follows_the_request_project() {
1130 let _data = crate::core::data_dir::isolated_data_dir();
1131 let project = tempfile::tempdir().expect("project tempdir");
1132 let mut session = SessionState::new();
1133 session.id = "request-project".to_string();
1134 session.project_root = Some(project.path().to_string_lossy().to_string());
1135 session.save().expect("save session");
1136 let in_request = |project: Option<std::path::PathBuf>| {
1137 crate::core::policy::runtime::REQUEST_PROJECT
1138 .sync_scope(std::cell::RefCell::new(project), SessionState::load_latest)
1139 .map(|session| session.id)
1140 };
1141
1142 assert_eq!(
1143 in_request(Some(project.path().to_path_buf())).as_deref(),
1144 Some("request-project")
1145 );
1146 assert_eq!(in_request(None), None);
1147 }
1148
1149 #[test]
1152 fn a_nested_directorys_session_is_not_the_parent_projects() {
1153 let _data = crate::core::data_dir::isolated_data_dir();
1154 let parent = tempfile::tempdir().expect("project tempdir");
1155 let nested = parent.path().join("tools");
1156 std::fs::create_dir(&nested).expect("nested dir");
1157 let nested = nested.to_string_lossy().to_string();
1158 let mut session = SessionState::new();
1159 session.project_root = Some(nested.clone());
1160 session.shell_cwd = Some(nested);
1161 session.save().expect("save nested session");
1162
1163 assert!(
1164 SessionState::load_latest_for_project_root(&parent.path().to_string_lossy()).is_none()
1165 );
1166 }
1167
1168 #[test]
1169 fn latest_project_session_repairs_a_missing_index() {
1170 let _data = crate::core::data_dir::isolated_data_dir();
1171 let project = tempfile::tempdir().expect("project tempdir");
1172 let root = project.path().to_string_lossy().to_string();
1173 let mut session = SessionState::new();
1174 session.id = "repair-missing".to_string();
1175 session.project_root = Some(root.clone());
1176 session.save().expect("save indexed session");
1177
1178 let sessions = crate::core::session::paths::sessions_dir().expect("sessions dir");
1179 let canonical_root = normalized_safe_project_root(&root).expect("safe root");
1180 let index_path = project_index_path(&sessions, &canonical_root);
1181 std::fs::remove_file(&index_path).expect("remove project index");
1182
1183 assert_eq!(
1184 SessionState::load_latest_for_project_root(&root)
1185 .expect("repair and load session")
1186 .id,
1187 "repair-missing"
1188 );
1189 let repaired: ProjectSessionIndex =
1190 serde_json::from_str(&std::fs::read_to_string(index_path).expect("repaired index"))
1191 .expect("valid repaired index");
1192 assert_eq!(repaired.session_ids, ["repair-missing"]);
1193 }
1194
1195 #[test]
1196 fn latest_project_session_repairs_an_empty_index() {
1197 let _data = crate::core::data_dir::isolated_data_dir();
1198 let project = tempfile::tempdir().expect("project tempdir");
1199 let root = project.path().to_string_lossy().to_string();
1200 let mut session = SessionState::new();
1201 session.id = "repair-empty".to_string();
1202 session.project_root = Some(root.clone());
1203 session.save().expect("save indexed session");
1204
1205 let sessions = crate::core::session::paths::sessions_dir().expect("sessions dir");
1206 let canonical_root = normalized_safe_project_root(&root).expect("safe root");
1207 let index_path = project_index_path(&sessions, &canonical_root);
1208 write_project_index(
1209 &index_path,
1210 &ProjectSessionIndex {
1211 version: 1,
1212 project_root: canonical_root,
1213 session_ids: Vec::new(),
1214 verified_empty: false,
1215 },
1216 )
1217 .expect("empty project index");
1218
1219 assert_eq!(
1220 SessionState::load_latest_for_project_root(&root)
1221 .expect("repair and load session")
1222 .id,
1223 "repair-empty"
1224 );
1225 let repaired: ProjectSessionIndex =
1226 serde_json::from_str(&std::fs::read_to_string(index_path).expect("repaired index"))
1227 .expect("valid repaired index");
1228 assert_eq!(repaired.session_ids, ["repair-empty"]);
1229 }
1230
1231 #[test]
1232 fn a_scan_that_finds_no_session_is_cached_until_the_next_save() {
1233 let _data = crate::core::data_dir::isolated_data_dir();
1234 let project = tempfile::tempdir().expect("project tempdir");
1235 let root = project.path().to_string_lossy().to_string();
1236 let sessions = crate::core::session::paths::sessions_dir().expect("sessions dir");
1237 std::fs::create_dir_all(&sessions).expect("session store exists");
1238
1239 assert!(SessionState::load_latest_for_project_root(&root).is_none());
1241
1242 let mut unindexed = SessionState::new();
1245 unindexed.id = "unindexed-after-scan".to_string();
1246 unindexed.project_root = Some(root.clone());
1247 std::fs::write(
1248 sessions.join("unindexed-after-scan.json"),
1249 serde_json::to_string(&unindexed).expect("serialize session"),
1250 )
1251 .expect("write unindexed session");
1252 assert!(SessionState::load_latest_for_project_root(&root).is_none());
1253
1254 let mut saved = SessionState::new();
1256 saved.id = "saved-after-scan".to_string();
1257 saved.project_root = Some(root.clone());
1258 saved.save().expect("save session");
1259 assert_eq!(
1260 SessionState::load_latest_for_project_root(&root)
1261 .expect("saved session")
1262 .id,
1263 "saved-after-scan"
1264 );
1265 }
1266
1267 #[test]
1268 fn latest_project_session_repairs_a_corrupt_index() {
1269 let _data = crate::core::data_dir::isolated_data_dir();
1270 let project = tempfile::tempdir().expect("project tempdir");
1271 let root = project.path().to_string_lossy().to_string();
1272 let mut session = SessionState::new();
1273 session.id = "repair-corrupt".to_string();
1274 session.project_root = Some(root.clone());
1275 session.save().expect("save indexed session");
1276
1277 let sessions = crate::core::session::paths::sessions_dir().expect("sessions dir");
1278 let canonical_root = normalized_safe_project_root(&root).expect("safe root");
1279 let index_path = project_index_path(&sessions, &canonical_root);
1280 std::fs::write(&index_path, "not json").expect("corrupt project index");
1281
1282 assert_eq!(
1283 SessionState::load_latest_for_project_root(&root)
1284 .expect("repair and load session")
1285 .id,
1286 "repair-corrupt"
1287 );
1288 let repaired: ProjectSessionIndex =
1289 serde_json::from_str(&std::fs::read_to_string(index_path).expect("repaired index"))
1290 .expect("valid repaired index");
1291 assert_eq!(repaired.session_ids, ["repair-corrupt"]);
1292 }
1293
1294 #[test]
1295 fn latest_project_session_repairs_an_index_with_a_missing_session() {
1296 let _data = crate::core::data_dir::isolated_data_dir();
1297 let project = tempfile::tempdir().expect("project tempdir");
1298 let root = project.path().to_string_lossy().to_string();
1299
1300 let mut older = SessionState::new();
1301 older.id = "repair-older".to_string();
1302 older.project_root = Some(root.clone());
1303 older.updated_at = Utc::now() - Duration::hours(1);
1304 older.save().expect("save older session");
1305
1306 let mut missing = SessionState::new();
1307 missing.id = "repair-missing-file".to_string();
1308 missing.project_root = Some(root.clone());
1309 missing.save().expect("save missing session");
1310 let sessions = crate::core::session::paths::sessions_dir().expect("sessions dir");
1311 std::fs::remove_file(sessions.join("repair-missing-file.json"))
1312 .expect("remove indexed session");
1313
1314 assert_eq!(
1315 SessionState::load_latest_for_project_root(&root)
1316 .expect("repair and load fallback")
1317 .id,
1318 "repair-older"
1319 );
1320 let repaired: ProjectSessionIndex = serde_json::from_str(
1321 &std::fs::read_to_string(project_index_path(
1322 &sessions,
1323 &normalized_safe_project_root(&root).expect("safe root"),
1324 ))
1325 .expect("repaired index"),
1326 )
1327 .expect("valid repaired index");
1328 assert_eq!(repaired.session_ids, ["repair-older"]);
1329 }
1330
1331 #[test]
1332 fn cleanup_old_sessions_preserves_the_latest_session_for_each_project() {
1333 let _data = crate::core::data_dir::isolated_data_dir();
1334 let project_a = tempfile::tempdir().expect("project A");
1335 let project_b = tempfile::tempdir().expect("project B");
1336 let root_a = project_a.path().to_string_lossy().to_string();
1337 let root_b = project_b.path().to_string_lossy().to_string();
1338
1339 for (id, root, age_days) in [
1340 ("a-old", root_a.as_str(), 10),
1341 ("a-latest", root_a.as_str(), 8),
1342 ("b-old", root_b.as_str(), 10),
1343 ("b-latest", root_b.as_str(), 8),
1344 ] {
1345 let mut session = SessionState::new();
1346 session.id = id.to_string();
1347 session.project_root = Some(root.to_string());
1348 session.updated_at = Utc::now() - Duration::days(age_days);
1349 session.save().expect("save session");
1350 }
1351
1352 assert_eq!(SessionState::cleanup_old_sessions(7), 2);
1353 assert!(SessionState::load_by_id("a-latest").is_some());
1354 assert!(SessionState::load_by_id("b-latest").is_some());
1355 assert!(SessionState::load_by_id("a-old").is_none());
1356 assert!(SessionState::load_by_id("b-old").is_none());
1357 }
1358
1359 #[test]
1360 fn deferred_save_cannot_replace_a_newer_session_version() {
1361 let _data = crate::core::data_dir::isolated_data_dir();
1362 let project = tempfile::tempdir().expect("project root");
1363 let project_root = project.path().to_string_lossy().into_owned();
1364 let mut session = SessionState::new();
1365 session.project_root = Some(project_root.clone());
1366 session.shell_cwd = Some(project_root);
1367 let id = session.id.clone();
1368 let older = session.prepare_save().expect("prepare older save");
1369 session.increment();
1370 let expected_version = session.version;
1371 let newer = session.prepare_save().expect("prepare newer save");
1372
1373 newer.write_to_disk().expect("write newer save");
1374 older.write_to_disk().expect("skip older save");
1375
1376 assert_eq!(
1377 SessionState::load_by_id(&id)
1378 .expect("load persisted session")
1379 .version,
1380 expected_version
1381 );
1382 }
1383
1384 #[test]
1385 fn project_head_cas_conflict_preserves_live_files_and_success_returns_exact_digest() {
1386 let _data = crate::core::data_dir::isolated_data_dir();
1387 let project = tempfile::tempdir().expect("project tempdir");
1388 let root = project.path().to_string_lossy().to_string();
1389
1390 let mut initial = SessionState::new();
1391 initial.id = "cas-initial".to_string();
1392 initial.project_root = Some(root.clone());
1393 initial.save().expect("save initial project session");
1394 let stale_head = SessionState::project_head(&root)
1395 .expect("read initial project head")
1396 .expect("initial head exists");
1397
1398 let mut local = SessionState::new();
1399 local.id = "cas-local".to_string();
1400 local.project_root = Some(root.clone());
1401 local.save().expect("save competing local session");
1402
1403 let sessions = crate::core::session::paths::sessions_dir().expect("sessions dir");
1404 let latest_path = sessions.join("latest.json");
1405 let index_path = project_index_path(
1406 &sessions,
1407 &normalized_safe_project_root(&root).expect("safe root"),
1408 );
1409 let latest_before_conflict = std::fs::read(&latest_path).expect("latest pointer");
1410 let index_before_conflict = std::fs::read(&index_path).expect("project index");
1411
1412 let mut candidate = SessionState::new();
1413 candidate.id = "cas-candidate".to_string();
1414 candidate.project_root = Some(root.clone());
1415 assert!(
1416 !candidate
1417 .save_new_if_project_head(Some(&stale_head))
1418 .expect("stale head is a conflict")
1419 );
1420 assert!(!sessions.join("cas-candidate.json").exists());
1421 assert_eq!(
1422 std::fs::read(&latest_path).expect("latest after conflict"),
1423 latest_before_conflict
1424 );
1425 assert_eq!(
1426 std::fs::read(&index_path).expect("index after conflict"),
1427 index_before_conflict
1428 );
1429
1430 let current_head = SessionState::project_head(&root)
1431 .expect("read current project head")
1432 .expect("current head exists");
1433 assert!(
1434 candidate
1435 .save_new_if_project_head(Some(¤t_head))
1436 .expect("publish against current head")
1437 );
1438 let persisted =
1439 std::fs::read(sessions.join("cas-candidate.json")).expect("published primary");
1440 assert_eq!(
1441 SessionState::project_head(&root).expect("read published project head"),
1442 Some((
1443 "cas-candidate".to_string(),
1444 format!("sha256:{}", hex::encode(Sha256::digest(&persisted))),
1445 ))
1446 );
1447 }
1448}