Skip to main content

lean_ctx/core/session/
persistence.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use 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
14/// Keep the startup warm set deliberately small: cache warming is an optional
15/// optimisation and must never turn process startup into a session-store scan.
16const PROJECT_HISTORY_LIMIT: usize = 8;
17
18/// How long a save waits for the per-session lock before reporting failure.
19/// Generous enough that an ordinary concurrent save wins it, short enough that
20/// a holder which has stopped making progress cannot hold a caller forever.
21#[cfg(not(test))]
22const SAVE_LOCK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
23/// The wedged-holder test waits this out under the global test env lock.
24#[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    /// Oldest to newest; duplicates are removed before appending on every save.
37    session_ids: Vec<String>,
38    /// Set only by a full-store scan that found no session for this root, written
39    /// under the index lock. It makes "no session" a cached answer: without it
40    /// every process (each hook call loads config, which resolves the project
41    /// root) re-parsed the whole session store for a project without sessions.
42    /// Any save for the root appends an id, which ends the empty state.
43    #[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
59/// Normalize a receiving project root without accepting an implicit CWD.
60///
61/// The legacy loader intentionally accepts relative roots for compatibility;
62/// session replacement must not use that heuristic as an authority because a
63/// relative value can resolve to a different process working directory.
64fn 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
112/// Resolve the canonical project head without changing the index. Callers hold
113/// the project lock so a save cannot move the index between selection and read.
114fn 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                // A safe but stale index entry follows the existing loader's
152                // repair semantics; the scan below remains read-only for CAS.
153            }
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
238/// Update one project's bounded warm-history index while its project lock is
239/// already held. The index remains an acceleration structure.
240fn 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    // Scan outside the short index lock. A save can proceed during a slow scan;
267    // its index entry is merged below before the repaired index is written.
268    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        // The scan ran outside the lock; a save may have indexed a session in the
293        // meantime. Merge instead of overwriting so that id is never lost, but drop
294        // ids whose file is gone (the reason this repair ran).
295        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    // A fact derived from a finding keeps that finding's source binding, and a
361    // finding that is not served now derives none: the save path must not
362    // turn a bound finding into an unbound fact.
363    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
368/// Run `operation` under the request project that was bound when the save was
369/// prepared. A background writer has no request scope of its own; without this
370/// the admission would silently fall back to a different project authority.
371fn 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/// The commit side: `AdmittedSave`, the authority check inside the commit's
380/// locks and the writes. Split out to keep this file under the LOC gate.
381#[path = "persistence_commit.rs"]
382mod commit;
383pub(crate) use commit::AdmittedSave;
384
385impl PreparedSave {
386    /// Admits the detached session for storage, then writes the session data,
387    /// latest pointer, compaction snapshot and project index atomically. A
388    /// per-session file lock and version check make deferred saves monotonic
389    /// even when background tasks finish out of order.
390    ///
391    /// This is the step that reads configuration and policy inputs and the
392    /// disk; run it after the live session lock has been released. Nothing is
393    /// written when admission refuses the session or when the admitting
394    /// authority no longer holds at the commit.
395    ///
396    /// The lock has a deadline. A save that cannot get it reports that instead
397    /// of waiting, because `SessionState::save` restores `unsaved_changes` on
398    /// `Err` and the next batch tries again — whereas a caller stuck in an
399    /// unbounded wait keeps whatever it holds until the other side lets go
400    /// (#1783).
401    pub fn write_to_disk(self) -> Result<(), String> {
402        self.publish().map_err(String::from)
403    }
404
405    /// [`write_to_disk`](Self::write_to_disk) with the refusal classified.
406    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    /// Storage admission of the detached copy: project-root checks, one
421    /// Gateway snapshot, one verified project policy snapshot, serialization.
422    /// Writes nothing. The returned save carries the admitting authority.
423    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    /// Return the exact persisted project head and its primary-file SHA-256.
483    ///
484    /// The project index is consulted first, with the existing repair scan
485    /// semantics used read-only when the index is absent or stale.
486    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    /// Return the current project root only when it is an absolute, non-broad
497    /// root normalized with the same path rules as scoped session loading.
498    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    /// Compare a loaded session against an already trusted normalized root.
505    ///
506    /// Re-normalizing the expected value keeps this helper safe for callers
507    /// that do not hold the receiving-root invariant themselves.
508    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    /// Counts locally recorded decisions from the trailing seven days.
517    #[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    /// Serializes and writes the session state to disk synchronously.
531    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    /// Import publication never replaces an existing session archive. A failure
539    /// after primary publication may retain a new recovery candidate, not erase
540    /// the previous live session or source archive.
541    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    /// Publish a new project session only when the persisted project head still
549    /// equals `expected`; a conflict leaves all session files and pointers alone.
550    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    /// Publish with the existing project-head CAS and return the exact primary
559    /// session file digest produced by this commit.
560    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    /// Take a detached copy of the session for a later save. Safe to call while
585    /// holding the live session lock: it validates the ID and copies memory —
586    /// no file, configuration or policy input is read and nothing here can
587    /// wait on a policy refresh (#1783). The unsaved counter is retained until
588    /// a write is acknowledged.
589    ///
590    /// The copy is not admitted for storage. Project-root checks, storage
591    /// admission, serialization and the commit happen in
592    /// `PreparedSave::write_to_disk()`, which belongs after the lock is
593    /// released (a background thread, or `save_shared`). A session whose
594    /// project root is unsafe, deleted or unreadable therefore prepares here
595    /// and is refused there, with nothing written.
596    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    /// Load the bounded warm-history set for one safe project root.
614    ///
615    /// There is intentionally no legacy full-store fallback: cache warming is
616    /// optional, while scanning every persisted session on each MCP launch is
617    /// not acceptable under concurrent agent load. New saves populate the
618    /// index; legacy sessions remain available through explicit session tools.
619    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    /// Loads the most recent session matching the current working directory's
648    /// project root.
649    ///
650    /// Returns `None` (a fresh session) rather than falling back to the global
651    /// `latest.json` pointer: that unconditional fallback bypassed project-root
652    /// matching and was the root cause of cross-project session leakage — one
653    /// project's findings/decisions/knowledge bleeding into another project's
654    /// first session. The correct project session is loaded later from the MCP
655    /// `roots` handshake (`load_latest_for_project_root`).
656    ///
657    /// Also refuses to scope to a broad/unsafe cwd (e.g. the MCP daemon's HOME),
658    /// which would otherwise resurrect the contaminated "HOME mega-session".
659    pub fn load_latest() -> Option<Self> {
660        // Inside a request its bound project is the only scope: the serving
661        // process's cwd may be another project, and a request without a
662        // project has no latest session (AP01 §3, AP04-G2).
663        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    /// Loads the session referenced by the global `latest.json` pointer,
676    /// regardless of project. Intended only for explicit, cross-project UX
677    /// (e.g. `lean-ctx session` status from an arbitrary directory) — never for
678    /// injecting knowledge into a new project's context. Prefer `load_latest`.
679    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    /// Loads the most recent session matching a specific project root.
688    ///
689    /// A valid per-project index is the only nominal path: one index read and
690    /// one session read, independent of the global session-store cardinality.
691    /// A missing, corrupt, or stale index is repaired by one exceptional scan.
692    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    /// Loads a specific session only when its persisted root already matches
720    /// the receiving root; legacy normalization runs after that check.
721    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        // Keep the persisted spelling that was checked above; legacy
734        // normalization may otherwise replace an admitted root from shell_cwd.
735        normalized.project_root = persisted_project_root;
736        Ok(Some(normalized))
737    }
738
739    /// Loads a specific session from disk by its unique ID.
740    pub fn load_by_id(id: &str) -> Option<Self> {
741        Self::load_by_id_raw(id).map(normalize_loaded_session)
742    }
743
744    /// Deletes a saved session and its compaction snapshot.
745    ///
746    /// If the deleted session is the global latest pointer, the pointer is
747    /// moved to the newest remaining session or removed when none remain.
748    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    /// Lists all saved sessions as summaries, sorted by most recently updated.
796    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    /// Scans all saved sessions for contaminated ones — those rooted at a
833    /// broad/unsafe path (HOME, filesystem root, agent sandbox dir) without a
834    /// real project marker, i.e. the historic "HOME mega-session" artifact.
835    ///
836    /// Returns `(found, quarantined)` where `found` is `(id, root)` pairs. When
837    /// `apply` is true, each offending session file is moved to a
838    /// `sessions/quarantine/` subdirectory (non-destructive) instead of being
839    /// loaded into any project's context.
840    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    /// Deletes sessions older than `max_age_days`, preserving the most recent
883    /// session for every project root. Returns the count removed.
884    ///
885    /// This is an explicit retention operation, so its single store scan stays
886    /// off the command hot path.
887    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        // A malformed legacy artifact proves the hot path consults only the
981        // per-project index, never `list_sessions()` as a hidden fallback.
982        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    /// #1783: a save must never wait on the per-session lock indefinitely. The
1000    /// callers that matter hold a `tokio` guard the rest of the server needs,
1001    /// so an unbounded wait here is a permanent server-wide wedge; a reported
1002    /// failure is recoverable, because `save` re-arms `unsaved_changes` and the
1003    /// next batch writes.
1004    #[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        // This valid but unindexed legacy file is newer. The nominal path must
1072        // trust the index and never deserialize unrelated root-level sessions.
1073        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    /// AP04-G2: inside a request the latest session is the request project's,
1093    /// never the one of the serving process's working directory; a request
1094    /// without a project has none.
1095    #[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    /// AP04-G8: a nested directory's session is not restored into its parent
1117    /// project because its working directory lies below the parent.
1118    #[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        // First lookup scans the store, finds nothing and records that.
1207        assert!(SessionState::load_latest_for_project_root(&root).is_none());
1208
1209        // A later unindexed file is not picked up: the verified-empty answer is
1210        // reused instead of re-parsing the whole store on every process start.
1211        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        // A real save indexes the session and ends the empty state.
1222        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(&current_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}