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        let mut session = Self::from_storage_json(&json).ok()?;
717        // The taint is written through on its own (pz21 §1.7): a crash
718        // between the taint and the next session save must not untaint.
719        if let Some(taint) = super::taint::load_sidecar(&session.id) {
720            session.taint.merge(&taint);
721        }
722        Some(session)
723    }
724
725    /// Loads a specific session only when its persisted root already matches
726    /// the receiving root; legacy normalization runs after that check.
727    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        // Keep the persisted spelling that was checked above; legacy
740        // normalization may otherwise replace an admitted root from shell_cwd.
741        normalized.project_root = persisted_project_root;
742        Ok(Some(normalized))
743    }
744
745    /// Loads a specific session from disk by its unique ID.
746    pub fn load_by_id(id: &str) -> Option<Self> {
747        Self::load_by_id_raw(id).map(normalize_loaded_session)
748    }
749
750    /// Deletes a saved session and its compaction snapshot.
751    ///
752    /// If the deleted session is the global latest pointer, the pointer is
753    /// moved to the newest remaining session or removed when none remain.
754    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    /// Lists all saved sessions as summaries, sorted by most recently updated.
802    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    /// The saved sessions `id` names: the session with that id and every
839    /// session opened under the app session id `id` (`app_session_id`),
840    /// oldest first (then by id).
841    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    /// Scans all saved sessions for contaminated ones — those rooted at a
866    /// broad/unsafe path (HOME, filesystem root, agent sandbox dir) without a
867    /// real project marker, i.e. the historic "HOME mega-session" artifact.
868    ///
869    /// Returns `(found, quarantined)` where `found` is `(id, root)` pairs. When
870    /// `apply` is true, each offending session file is moved to a
871    /// `sessions/quarantine/` subdirectory (non-destructive) instead of being
872    /// loaded into any project's context.
873    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    /// Deletes sessions older than `max_age_days`, preserving the most recent
916    /// session for every project root. Returns the count removed.
917    ///
918    /// This is an explicit retention operation, so its single store scan stays
919    /// off the command hot path.
920    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        // A malformed legacy artifact proves the hot path consults only the
1014        // per-project index, never `list_sessions()` as a hidden fallback.
1015        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    /// #1783: a save must never wait on the per-session lock indefinitely. The
1033    /// callers that matter hold a `tokio` guard the rest of the server needs,
1034    /// so an unbounded wait here is a permanent server-wide wedge; a reported
1035    /// failure is recoverable, because `save` re-arms `unsaved_changes` and the
1036    /// next batch writes.
1037    #[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        // This valid but unindexed legacy file is newer. The nominal path must
1105        // trust the index and never deserialize unrelated root-level sessions.
1106        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    /// AP04-G2: inside a request the latest session is the request project's,
1126    /// never the one of the serving process's working directory; a request
1127    /// without a project has none.
1128    #[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    /// AP04-G8: a nested directory's session is not restored into its parent
1150    /// project because its working directory lies below the parent.
1151    #[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        // First lookup scans the store, finds nothing and records that.
1240        assert!(SessionState::load_latest_for_project_root(&root).is_none());
1241
1242        // A later unindexed file is not picked up: the verified-empty answer is
1243        // reused instead of re-parsing the whole store on every process start.
1244        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        // A real save indexes the session and ends the empty state.
1255        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(&current_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}