Skip to main content

lds_session/
lib.rs

1//! Session lifecycle primitives shared across all lds modules.
2//!
3//! Every module (git, recipe, sandbox) receives an `Arc<Session>` that
4//! anchors operations to a single project root. Shared concerns — timeout,
5//! output truncation, global recipe dirs — live here so modules don't
6//! duplicate configuration.
7//!
8//! This crate was split out of `lds-core` to give downstream consumers
9//! (current: lds workspace modules; future: session-mcp / KV primitives)
10//! a self-contained session contract independent of the broader core
11//! utilities (binary probing, output truncation, config files).
12
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15use std::sync::{Arc, RwLock};
16use std::time::{Duration, SystemTime, UNIX_EPOCH};
17
18const DEFAULT_TIMEOUT_SECS: u64 = 60;
19const DEFAULT_MAX_OUTPUT: usize = 102_400; // 100KB
20
21/// Configuration passed to [`Session::new`]. Optional fields fall back
22/// to sensible defaults (60s timeout, 100KB output limit).
23#[derive(Debug, Default)]
24pub struct SessionConfig {
25    pub root: PathBuf,
26    pub timeout_secs: Option<u64>,
27    pub max_output: Option<usize>,
28    /// Optional human-readable alias for this session.
29    ///
30    /// Used by callers (MainAI / SubAgent) to dispatch by a stable label
31    /// instead of the opaque `session_id`. Aliases are case-sensitive and
32    /// must be unique within an [`LdsState`] ledger.
33    pub alias: Option<String>,
34    /// Additional global recipe directories, in precedence order (lowest first).
35    ///
36    /// The default `~/.config/lds` is always consulted by `build_resolve_chain`
37    /// regardless of this list. Entries here are pushed after the default and before
38    /// the project justfile. Populate via `LDS_RECIPE_GLOBAL_DIRS` (colon-separated)
39    /// and/or the `global_recipe_dir` MCP wire argument.
40    pub global_recipe_dirs: Vec<PathBuf>,
41    /// Directory where session-owned git worktrees are placed.
42    ///
43    /// Resolution precedence (highest first): this field → env
44    /// `LDS_WORKTREES_DIR` → `<root>/.worktrees` (default).
45    ///
46    /// **Setup expectation — this dir MUST be gitignored.**
47    /// `GitModule::worktree_add` creates worktrees at
48    /// `<worktrees_dir>/<name>` on session-owned branches. If the directory
49    /// is not covered by `.gitignore`, `git status` will surface the
50    /// worktree contents as untracked entries in the parent repo and a
51    /// `git add -A` / commit can silently absorb them (large accidental
52    /// commit). Callers overriding this path (env or explicit field) must
53    /// ensure the target is either outside the parent repo's tracking or
54    /// listed in the parent's `.gitignore`. For the default path,
55    /// a top-level `.worktrees` entry in the parent repo's `.gitignore`
56    /// is the recommended coverage.
57    ///
58    /// Absolute paths are taken verbatim. Relative paths are resolved
59    /// against `root`.
60    pub worktrees_dir: Option<PathBuf>,
61}
62
63/// Errors that can occur during a [`Session`]'s post-construction lifecycle.
64#[derive(Debug, thiserror::Error)]
65pub enum SessionError {
66    #[error(
67        "session root path no longer exists, please call session_start again: {}",
68        _0.display()
69    )]
70    RootGone(PathBuf),
71}
72
73/// Errors that can occur during session construction or access.
74#[derive(Debug, thiserror::Error)]
75pub enum CoreError {
76    #[error("session root does not exist: {}", _0.display())]
77    RootNotFound(PathBuf),
78    #[error("no active session — call session_start first")]
79    NoSession,
80    #[error("session not found: {0}")]
81    SessionNotFound(String),
82    #[error("alias already in use: {0}")]
83    AliasConflict(String),
84}
85
86/// Immutable session state created by `session_start` / `session_create`.
87///
88/// Cloned (via `Arc`) into each module. Holds the project root and
89/// cross-cutting concerns that every module may need.
90///
91/// `alias` and `last_used_at` are interior-mutable (RwLock) so the ledger
92/// can rename or touch sessions without invalidating outstanding `Arc<Session>`
93/// handles held by tool handlers.
94#[derive(Debug)]
95pub struct Session {
96    root: PathBuf,
97    session_id: String,
98    alias: RwLock<Option<String>>,
99    timeout: Duration,
100    max_output: usize,
101    global_recipe_dirs: Vec<PathBuf>,
102    worktrees_dir: PathBuf,
103    created_at: u64,
104    last_used_at: RwLock<u64>,
105}
106
107/// Canonical subdirectory name appended to the session root when no
108/// override (explicit config field or `LDS_WORKTREES_DIR` env) is set.
109///
110/// Single source of truth for the default worktrees dir literal — every
111/// doc comment, test, and downstream module referring to "the default"
112/// links to this constant instead of hardcoding the string.
113pub const DEFAULT_WORKTREES_SUBDIR: &str = ".worktrees";
114
115/// Resolve the effective worktrees directory from
116/// (config → env `LDS_WORKTREES_DIR` → default `<root>/[`DEFAULT_WORKTREES_SUBDIR`]`).
117/// Relative paths are anchored to `root`. Only invoked from
118/// [`Session::new`]; exposed as a free function for test coverage of
119/// the precedence rules.
120fn resolve_worktrees_dir(root: &Path, override_dir: Option<PathBuf>) -> PathBuf {
121    let candidate = override_dir
122        .or_else(|| std::env::var_os("LDS_WORKTREES_DIR").map(PathBuf::from))
123        .unwrap_or_else(|| PathBuf::from(DEFAULT_WORKTREES_SUBDIR));
124    if candidate.is_absolute() {
125        candidate
126    } else {
127        root.join(candidate)
128    }
129}
130
131impl Session {
132    pub fn new(config: SessionConfig) -> Result<Self, CoreError> {
133        let root = config.root;
134        if !root.is_dir() {
135            tracing::warn!(root = %root.display(), "session root does not exist");
136            return Err(CoreError::RootNotFound(root));
137        }
138        let session_id = session_id_new();
139        let timeout = Duration::from_secs(config.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS));
140        let max_output = config.max_output.unwrap_or(DEFAULT_MAX_OUTPUT);
141        let now = epoch_secs();
142        tracing::info!(
143            root = %root.display(),
144            session_id = %session_id,
145            alias = ?config.alias,
146            timeout_secs = timeout.as_secs(),
147            max_output,
148            "session started"
149        );
150        let global_recipe_dirs = config.global_recipe_dirs;
151        let worktrees_dir = resolve_worktrees_dir(&root, config.worktrees_dir);
152        Ok(Self {
153            root,
154            session_id,
155            alias: RwLock::new(config.alias),
156            timeout,
157            max_output,
158            global_recipe_dirs,
159            worktrees_dir,
160            created_at: now,
161            last_used_at: RwLock::new(now),
162        })
163    }
164
165    /// Directory where this session's git worktrees live.
166    ///
167    /// Absolute path. Resolved once at [`Session::new`] from
168    /// [`SessionConfig::worktrees_dir`] → env `LDS_WORKTREES_DIR` →
169    /// `<root>/[`DEFAULT_WORKTREES_SUBDIR`]` (default). See
170    /// [`SessionConfig::worktrees_dir`] for the setup expectation
171    /// (gitignore requirement).
172    pub fn worktrees_dir(&self) -> &Path {
173        &self.worktrees_dir
174    }
175
176    pub fn root(&self) -> &Path {
177        &self.root
178    }
179
180    pub fn id(&self) -> &str {
181        &self.session_id
182    }
183
184    pub fn alias(&self) -> Option<String> {
185        self.alias.read().expect("alias lock poisoned").clone()
186    }
187
188    pub fn created_at(&self) -> u64 {
189        self.created_at
190    }
191
192    pub fn last_used_at(&self) -> u64 {
193        *self.last_used_at.read().expect("last_used lock poisoned")
194    }
195
196    pub fn touch(&self) {
197        if let Ok(mut g) = self.last_used_at.write() {
198            *g = epoch_secs();
199        }
200    }
201
202    pub(crate) fn set_alias(&self, alias: Option<String>) {
203        if let Ok(mut g) = self.alias.write() {
204            *g = alias;
205        }
206    }
207
208    pub fn timeout(&self) -> Duration {
209        self.timeout
210    }
211
212    pub fn max_output(&self) -> usize {
213        self.max_output
214    }
215
216    pub fn global_recipe_dirs(&self) -> &[PathBuf] {
217        &self.global_recipe_dirs
218    }
219
220    /// Check that the session root directory still exists.
221    ///
222    /// Call this at each entry point (e.g. `list`, `run`) to detect a deleted
223    /// root before attempting I/O. Returns [`SessionError::RootGone`] if the
224    /// directory no longer exists.
225    pub fn ensure_alive(&self) -> Result<(), SessionError> {
226        if !self.root.is_dir() {
227            tracing::warn!(root = %self.root.display(), "session root no longer exists");
228            return Err(SessionError::RootGone(self.root.clone()));
229        }
230        Ok(())
231    }
232}
233
234/// Top-level mutable state for the MCP server.
235///
236/// Holds a ledger of all live [`Session`]s, indexed by `session_id` (opaque
237/// hash) and `alias` (human-readable label). One session is designated the
238/// **default session**, returned by [`Self::session`] for backward-compatible
239/// tool calls that pre-date per-call `session_id` addressing.
240///
241/// The MCP handler wraps this in `Arc<RwLock<LdsState>>` for concurrent tool
242/// access. Mutations (create / close / alias) take the write lock; reads
243/// (resolve / list / describe / doctor) take the read lock.
244#[derive(Debug, Clone)]
245pub struct LdsState {
246    /// id -> Arc<Session>
247    sessions: HashMap<String, Arc<Session>>,
248    /// alias -> id (alias resolution map)
249    aliases: HashMap<String, String>,
250    /// The implicit default session for backward-compat callers.
251    /// `None` until the first session_start / session_create.
252    default_id: Option<String>,
253}
254
255/// Snapshot of a single session entry, returned by ledger introspection APIs.
256#[derive(Debug, Clone)]
257pub struct SessionEntry {
258    pub session_id: String,
259    pub alias: Option<String>,
260    pub root: PathBuf,
261    pub created_at: u64,
262    pub last_used_at: u64,
263    pub is_default: bool,
264}
265
266impl SessionEntry {
267    fn from_session(s: &Session, is_default: bool) -> Self {
268        Self {
269            session_id: s.id().to_string(),
270            alias: s.alias(),
271            root: s.root().to_path_buf(),
272            created_at: s.created_at(),
273            last_used_at: s.last_used_at(),
274            is_default,
275        }
276    }
277}
278
279impl LdsState {
280    pub fn new() -> Self {
281        Self {
282            sessions: HashMap::new(),
283            aliases: HashMap::new(),
284            default_id: None,
285        }
286    }
287
288    /// Create a new session and register it in the ledger.
289    ///
290    /// If `make_default` is true (or no default exists yet), the new session
291    /// becomes the implicit default.
292    ///
293    /// Returns [`CoreError::AliasConflict`] if `config.alias` is already
294    /// taken by another session.
295    pub fn create_session(
296        &mut self,
297        config: SessionConfig,
298        make_default: bool,
299    ) -> Result<Arc<Session>, CoreError> {
300        if let Some(alias) = &config.alias
301            && self.aliases.contains_key(alias)
302        {
303            return Err(CoreError::AliasConflict(alias.clone()));
304        }
305        let alias_clone = config.alias.clone();
306        let session = Arc::new(Session::new(config)?);
307        let id = session.id().to_string();
308        self.sessions.insert(id.clone(), Arc::clone(&session));
309        if let Some(alias) = alias_clone {
310            self.aliases.insert(alias, id.clone());
311        }
312        if make_default || self.default_id.is_none() {
313            self.default_id = Some(id);
314        }
315        Ok(session)
316    }
317
318    /// Backward-compatible entry point used by the legacy `session_start`
319    /// MCP tool. Always replaces the default session.
320    pub fn start_session(&mut self, config: SessionConfig) -> Result<Arc<Session>, CoreError> {
321        self.create_session(config, true)
322    }
323
324    /// Look up a session by id or alias.
325    ///
326    /// `key` is tried as an alias first, then as a session_id. Returns
327    /// [`CoreError::SessionNotFound`] if neither matches.
328    pub fn resolve(&self, key: &str) -> Result<Arc<Session>, CoreError> {
329        if let Some(id) = self.aliases.get(key)
330            && let Some(s) = self.sessions.get(id)
331        {
332            return Ok(Arc::clone(s));
333        }
334        if let Some(s) = self.sessions.get(key) {
335            return Ok(Arc::clone(s));
336        }
337        Err(CoreError::SessionNotFound(key.to_string()))
338    }
339
340    /// Return the default session for backward-compatible tool calls.
341    pub fn session(&self) -> Result<Arc<Session>, CoreError> {
342        let id = self.default_id.as_ref().ok_or(CoreError::NoSession)?;
343        let s = self
344            .sessions
345            .get(id)
346            .ok_or_else(|| CoreError::SessionNotFound(id.clone()))?;
347        Ok(Arc::clone(s))
348    }
349
350    pub fn default_session_id(&self) -> Option<&str> {
351        self.default_id.as_deref()
352    }
353
354    /// Snapshot every session in the ledger.
355    pub fn list_sessions(&self) -> Vec<SessionEntry> {
356        let mut out: Vec<SessionEntry> = self
357            .sessions
358            .values()
359            .map(|s| {
360                let is_default = self.default_id.as_deref() == Some(s.id());
361                SessionEntry::from_session(s, is_default)
362            })
363            .collect();
364        out.sort_by_key(|e| e.created_at);
365        out
366    }
367
368    /// Describe a single session by id or alias.
369    pub fn describe(&self, key: &str) -> Result<SessionEntry, CoreError> {
370        let s = self.resolve(key)?;
371        let is_default = self.default_id.as_deref() == Some(s.id());
372        Ok(SessionEntry::from_session(&s, is_default))
373    }
374
375    /// Assign or change an alias on an existing session.
376    ///
377    /// Returns [`CoreError::AliasConflict`] if the new alias is already held
378    /// by another session.
379    pub fn set_alias(&mut self, key: &str, alias: String) -> Result<(), CoreError> {
380        let target = self.resolve(key)?;
381        if let Some(owner_id) = self.aliases.get(&alias) {
382            if owner_id != target.id() {
383                return Err(CoreError::AliasConflict(alias));
384            }
385            return Ok(());
386        }
387        // Drop the session's previous alias, if any.
388        if let Some(prev) = target.alias() {
389            self.aliases.remove(&prev);
390        }
391        target.set_alias(Some(alias.clone()));
392        self.aliases.insert(alias, target.id().to_string());
393        Ok(())
394    }
395
396    /// Remove an alias from the ledger. The session itself remains.
397    pub fn unset_alias(&mut self, alias: &str) -> Result<(), CoreError> {
398        let id = self
399            .aliases
400            .remove(alias)
401            .ok_or_else(|| CoreError::SessionNotFound(alias.to_string()))?;
402        if let Some(s) = self.sessions.get(&id) {
403            s.set_alias(None);
404        }
405        Ok(())
406    }
407
408    /// Close (drop) a session by id or alias.
409    ///
410    /// If the closed session was the default, the default is cleared and
411    /// must be re-set by a subsequent `session_start` / `session_create`
412    /// with `make_default=true`.
413    pub fn close(&mut self, key: &str) -> Result<(), CoreError> {
414        let target = self.resolve(key)?;
415        let id = target.id().to_string();
416        if let Some(alias) = target.alias() {
417            self.aliases.remove(&alias);
418        }
419        self.sessions.remove(&id);
420        if self.default_id.as_deref() == Some(&id) {
421            self.default_id = None;
422        }
423        Ok(())
424    }
425}
426
427impl Default for LdsState {
428    fn default() -> Self {
429        Self::new()
430    }
431}
432
433// ── Doctor ────────────────────────────────────────────────────────────────
434
435/// Per-check verdict returned by [`LdsState::doctor`].
436#[derive(Debug, Clone, PartialEq, Eq)]
437pub enum CheckStatus {
438    Ok,
439    Warn,
440    Fail,
441}
442
443impl CheckStatus {
444    pub fn as_str(&self) -> &'static str {
445        match self {
446            CheckStatus::Ok => "ok",
447            CheckStatus::Warn => "warn",
448            CheckStatus::Fail => "fail",
449        }
450    }
451}
452
453#[derive(Debug, Clone)]
454pub struct DoctorCheck {
455    pub name: &'static str,
456    pub status: CheckStatus,
457    pub evidence: String,
458}
459
460#[derive(Debug, Clone)]
461pub struct DoctorReport {
462    pub session_id: String,
463    pub alias: Option<String>,
464    pub verdict: CheckStatus,
465    pub checks: Vec<DoctorCheck>,
466}
467
468const IDLE_WARN_SECS: u64 = 60 * 60 * 6; // 6h idle → ledger-leak WARN
469
470impl LdsState {
471    /// Run health checks on a single session (by id / alias).
472    pub fn doctor(&self, key: &str) -> Result<DoctorReport, CoreError> {
473        let s = self.resolve(key)?;
474        let mut checks: Vec<DoctorCheck> = Vec::new();
475
476        // C1 root-exists
477        let root = s.root();
478        if root.is_dir() {
479            checks.push(DoctorCheck {
480                name: "root-exists",
481                status: CheckStatus::Ok,
482                evidence: format!("root={}", root.display()),
483            });
484        } else {
485            checks.push(DoctorCheck {
486                name: "root-exists",
487                status: CheckStatus::Fail,
488                evidence: format!("root missing: {}", root.display()),
489            });
490        }
491
492        // C2 git-bound (presence of .git, file or dir = git worktree)
493        let git_path = root.join(".git");
494        if git_path.exists() {
495            checks.push(DoctorCheck {
496                name: "git-bound",
497                status: CheckStatus::Ok,
498                evidence: format!("{}/.git present", root.display()),
499            });
500        } else {
501            checks.push(DoctorCheck {
502                name: "git-bound",
503                status: CheckStatus::Warn,
504                evidence: "no .git in root; git_* tools will fail".into(),
505            });
506        }
507
508        // C3 journal-db-writable
509        let journal_dir = root.join("workspace");
510        let journal_status = if !journal_dir.exists() {
511            CheckStatus::Warn
512        } else {
513            // Probe writability by attempting to create a temp marker.
514            match tempfile::NamedTempFile::new_in(&journal_dir) {
515                Ok(_) => CheckStatus::Ok,
516                Err(_) => CheckStatus::Fail,
517            }
518        };
519        checks.push(DoctorCheck {
520            name: "journal-db-writable",
521            status: journal_status,
522            evidence: format!("probe dir = {}", journal_dir.display()),
523        });
524
525        // C4 stale-lock (heuristic: look for .lock files older than 1h)
526        let mut stale = Vec::new();
527        for candidate in ["workspace/.journal.db.lock", ".journal.db.lock"] {
528            let p = root.join(candidate);
529            if let Ok(meta) = std::fs::metadata(&p)
530                && let Ok(modified) = meta.modified()
531                && let Ok(age) = SystemTime::now().duration_since(modified)
532                && age.as_secs() > 3600
533            {
534                stale.push(p.display().to_string());
535            }
536        }
537        checks.push(DoctorCheck {
538            name: "stale-lock",
539            status: if stale.is_empty() {
540                CheckStatus::Ok
541            } else {
542                CheckStatus::Warn
543            },
544            evidence: if stale.is_empty() {
545                "no stale lock found".into()
546            } else {
547                format!("stale: {}", stale.join(","))
548            },
549        });
550
551        // C5 ownership-drift: detect multiple sessions claiming the same root.
552        let conflict_count = self
553            .sessions
554            .values()
555            .filter(|other| other.root() == root && other.id() != s.id())
556            .count();
557        checks.push(DoctorCheck {
558            name: "ownership-drift",
559            status: if conflict_count == 0 {
560                CheckStatus::Ok
561            } else {
562                CheckStatus::Warn
563            },
564            evidence: if conflict_count == 0 {
565                "exclusive owner".into()
566            } else {
567                format!("{conflict_count} other session(s) share root")
568            },
569        });
570
571        // C6 root-conflict: same as C5 but raises to FAIL when ≥2 conflicts.
572        checks.push(DoctorCheck {
573            name: "root-conflict",
574            status: match conflict_count {
575                0 => CheckStatus::Ok,
576                1 => CheckStatus::Warn,
577                _ => CheckStatus::Fail,
578            },
579            evidence: format!("conflicts={conflict_count}"),
580        });
581
582        // C7 ledger-leak: idle for > IDLE_WARN_SECS.
583        let idle = epoch_secs().saturating_sub(s.last_used_at());
584        checks.push(DoctorCheck {
585            name: "ledger-leak",
586            status: if idle > IDLE_WARN_SECS {
587                CheckStatus::Warn
588            } else {
589                CheckStatus::Ok
590            },
591            evidence: format!("idle_secs={idle}"),
592        });
593
594        let verdict =
595            checks
596                .iter()
597                .map(|c| c.status.clone())
598                .fold(CheckStatus::Ok, |acc, s| match (&acc, &s) {
599                    (CheckStatus::Fail, _) | (_, CheckStatus::Fail) => CheckStatus::Fail,
600                    (CheckStatus::Warn, _) | (_, CheckStatus::Warn) => CheckStatus::Warn,
601                    _ => CheckStatus::Ok,
602                });
603
604        Ok(DoctorReport {
605            session_id: s.id().to_string(),
606            alias: s.alias(),
607            verdict,
608            checks,
609        })
610    }
611}
612
613fn epoch_secs() -> u64 {
614    SystemTime::now()
615        .duration_since(UNIX_EPOCH)
616        .map(|d| d.as_secs())
617        .unwrap_or(0)
618}
619
620/// Generate a session uniqueness identifier as `{nanos_hex}-{pid_hex}`.
621///
622/// This is NOT an RFC 4122 UUID — it is a lightweight identifier used
623/// for session ownership tracking and log correlation. It carries no
624/// cryptographic randomness guarantees. Use the `uuid` crate if a
625/// RFC 4122 v4 UUID is required.
626fn session_id_new() -> String {
627    use std::time::{SystemTime, UNIX_EPOCH};
628    let ts = SystemTime::now()
629        .duration_since(UNIX_EPOCH)
630        .unwrap_or_default()
631        .as_nanos();
632    let pid = std::process::id();
633    format!("{ts:x}-{pid:x}")
634}
635
636#[cfg(test)]
637mod tests {
638    use super::*;
639
640    // ── resolve_worktrees_dir precedence (env parsing intentionally
641    //    excluded — std::env is process-global and races other tests) ────────
642
643    #[test]
644    fn resolve_worktrees_dir_falls_back_to_default_subdir_when_no_override() {
645        let root = PathBuf::from("/tmp/some-session-root");
646        let got = resolve_worktrees_dir(&root, None);
647        assert_eq!(got, root.join(DEFAULT_WORKTREES_SUBDIR));
648    }
649
650    #[test]
651    fn resolve_worktrees_dir_explicit_absolute_config_wins_over_default() {
652        let root = PathBuf::from("/tmp/some-session-root");
653        let explicit = PathBuf::from("/var/pool/lds-wt");
654        let got = resolve_worktrees_dir(&root, Some(explicit.clone()));
655        assert_eq!(got, explicit);
656    }
657
658    #[test]
659    fn resolve_worktrees_dir_relative_config_is_anchored_to_root() {
660        let root = PathBuf::from("/tmp/some-session-root");
661        let got = resolve_worktrees_dir(&root, Some(PathBuf::from("wt-pool")));
662        assert_eq!(got, root.join("wt-pool"));
663    }
664
665    #[test]
666    fn session_worktrees_dir_matches_resolver_output() {
667        let tmp = tempfile::tempdir().unwrap();
668        let root = tmp.path().to_path_buf();
669        let session = Session::new(SessionConfig {
670            root: root.clone(),
671            ..Default::default()
672        })
673        .unwrap();
674        assert_eq!(session.worktrees_dir(), resolve_worktrees_dir(&root, None));
675    }
676
677    // ── CoreError Display invariants (I1 / I2) ───────────────────────────────
678
679    #[test]
680    fn core_error_root_not_found_display_contains_prefix_and_path() {
681        let path = PathBuf::from("/some/missing/root");
682        let err = CoreError::RootNotFound(path.clone());
683        let msg = err.to_string();
684        assert!(
685            msg.contains("session root does not exist: "),
686            "I1: message must start with invariant prefix, got: {msg}"
687        );
688        assert!(
689            msg.contains("/some/missing/root"),
690            "I1: message must contain the path, got: {msg}"
691        );
692    }
693
694    #[test]
695    fn core_error_no_session_display_matches_invariant() {
696        let err = CoreError::NoSession;
697        let msg = err.to_string();
698        assert_eq!(
699            msg, "no active session \u{2014} call session_start first",
700            "I2: message must exactly match invariant string"
701        );
702    }
703
704    // ── SessionError / Session::ensure_alive ─────────────────────────────────
705
706    #[test]
707    fn session_error_root_gone_message_contains_invariant_substring() {
708        use std::path::PathBuf;
709        let path = PathBuf::from("/tmp/gone");
710        let err = SessionError::RootGone(path.clone());
711        let msg = err.to_string();
712        assert!(
713            msg.contains("session root path no longer exists, please call session_start again"),
714            "error message must contain the K-239 recovery substring, got: {msg}"
715        );
716        assert!(
717            msg.contains("/tmp/gone"),
718            "error message must include the path, got: {msg}"
719        );
720    }
721
722    #[test]
723    fn ensure_alive_ok_when_root_exists() {
724        let tmp = tempfile::tempdir().unwrap();
725        let session = Session::new(SessionConfig {
726            root: tmp.path().to_path_buf(),
727            ..Default::default()
728        })
729        .unwrap();
730        assert!(session.ensure_alive().is_ok());
731    }
732
733    // ── Ledger (multi-session) ───────────────────────────────────────────────
734
735    fn mk_state_with_root(alias: Option<&str>) -> (LdsState, tempfile::TempDir, String) {
736        let tmp = tempfile::tempdir().unwrap();
737        let mut state = LdsState::new();
738        let session = state
739            .create_session(
740                SessionConfig {
741                    root: tmp.path().to_path_buf(),
742                    alias: alias.map(|s| s.to_string()),
743                    ..Default::default()
744                },
745                true,
746            )
747            .unwrap();
748        let id = session.id().to_string();
749        (state, tmp, id)
750    }
751
752    #[test]
753    fn ledger_create_sets_default_when_first_session() {
754        let (state, _tmp, id) = mk_state_with_root(None);
755        assert_eq!(state.default_session_id(), Some(id.as_str()));
756        assert_eq!(state.list_sessions().len(), 1);
757    }
758
759    #[test]
760    fn ledger_second_session_preserves_default_unless_requested() {
761        let (mut state, _tmp1, id1) = mk_state_with_root(None);
762        let tmp2 = tempfile::tempdir().unwrap();
763        let _ = state
764            .create_session(
765                SessionConfig {
766                    root: tmp2.path().to_path_buf(),
767                    ..Default::default()
768                },
769                false,
770            )
771            .unwrap();
772        assert_eq!(state.default_session_id(), Some(id1.as_str()));
773        assert_eq!(state.list_sessions().len(), 2);
774    }
775
776    #[test]
777    fn ledger_resolve_by_id_or_alias() {
778        let (state, _tmp, id) = mk_state_with_root(Some("worker-1"));
779        let by_id = state.resolve(&id).unwrap();
780        let by_alias = state.resolve("worker-1").unwrap();
781        assert_eq!(by_id.id(), by_alias.id());
782    }
783
784    #[test]
785    fn ledger_resolve_unknown_returns_not_found() {
786        let (state, _tmp, _id) = mk_state_with_root(None);
787        let err = state.resolve("does-not-exist").unwrap_err();
788        assert!(matches!(err, CoreError::SessionNotFound(_)), "got {err:?}");
789    }
790
791    #[test]
792    fn ledger_alias_conflict_rejected_on_create() {
793        let (mut state, _tmp, _id) = mk_state_with_root(Some("dup"));
794        let tmp2 = tempfile::tempdir().unwrap();
795        let err = state
796            .create_session(
797                SessionConfig {
798                    root: tmp2.path().to_path_buf(),
799                    alias: Some("dup".to_string()),
800                    ..Default::default()
801                },
802                false,
803            )
804            .unwrap_err();
805        assert!(matches!(err, CoreError::AliasConflict(_)), "got {err:?}");
806    }
807
808    #[test]
809    fn ledger_set_alias_assigns_and_replaces() {
810        let (mut state, _tmp, id) = mk_state_with_root(None);
811        state.set_alias(&id, "main".into()).unwrap();
812        assert_eq!(state.resolve("main").unwrap().id(), id);
813        // Reassign to a new alias — previous one is freed.
814        state.set_alias(&id, "renamed".into()).unwrap();
815        assert!(state.resolve("main").is_err());
816        assert_eq!(state.resolve("renamed").unwrap().id(), id);
817    }
818
819    #[test]
820    fn ledger_unset_alias_removes_mapping_but_keeps_session() {
821        let (mut state, _tmp, id) = mk_state_with_root(Some("tmp-alias"));
822        state.unset_alias("tmp-alias").unwrap();
823        assert!(state.resolve("tmp-alias").is_err());
824        assert!(state.resolve(&id).is_ok());
825    }
826
827    #[test]
828    fn ledger_close_drops_session_and_clears_default() {
829        let (mut state, _tmp, id) = mk_state_with_root(Some("main"));
830        state.close(&id).unwrap();
831        assert!(state.resolve(&id).is_err());
832        assert_eq!(state.default_session_id(), None);
833        assert_eq!(state.list_sessions().len(), 0);
834    }
835
836    #[test]
837    fn ledger_list_sorted_by_created_at() {
838        let (mut state, _tmp1, id1) = mk_state_with_root(None);
839        // Force a measurable timestamp gap (epoch_secs is second-resolution).
840        std::thread::sleep(std::time::Duration::from_millis(1100));
841        let tmp2 = tempfile::tempdir().unwrap();
842        let s2 = state
843            .create_session(
844                SessionConfig {
845                    root: tmp2.path().to_path_buf(),
846                    ..Default::default()
847                },
848                false,
849            )
850            .unwrap();
851        let entries = state.list_sessions();
852        assert_eq!(entries.len(), 2);
853        assert_eq!(entries[0].session_id, id1);
854        assert_eq!(entries[1].session_id, s2.id());
855    }
856
857    // ── Doctor checks ────────────────────────────────────────────────────────
858
859    #[test]
860    fn doctor_root_exists_passes_on_live_root() {
861        let (state, _tmp, id) = mk_state_with_root(None);
862        let report = state.doctor(&id).unwrap();
863        let root_check = report
864            .checks
865            .iter()
866            .find(|c| c.name == "root-exists")
867            .unwrap();
868        assert_eq!(root_check.status, CheckStatus::Ok);
869    }
870
871    #[test]
872    fn doctor_root_exists_fails_when_root_deleted() {
873        let tmp = tempfile::tempdir().unwrap();
874        let path = tmp.path().to_path_buf();
875        let mut state = LdsState::new();
876        let s = state
877            .create_session(
878                SessionConfig {
879                    root: path.clone(),
880                    ..Default::default()
881                },
882                true,
883            )
884            .unwrap();
885        std::fs::remove_dir_all(&path).unwrap();
886        let report = state.doctor(s.id()).unwrap();
887        let root_check = report
888            .checks
889            .iter()
890            .find(|c| c.name == "root-exists")
891            .unwrap();
892        assert_eq!(root_check.status, CheckStatus::Fail);
893        assert_eq!(report.verdict, CheckStatus::Fail);
894    }
895
896    #[test]
897    fn doctor_detects_root_conflict_between_sessions() {
898        let tmp = tempfile::tempdir().unwrap();
899        let mut state = LdsState::new();
900        let s1 = state
901            .create_session(
902                SessionConfig {
903                    root: tmp.path().to_path_buf(),
904                    ..Default::default()
905                },
906                true,
907            )
908            .unwrap();
909        let _s2 = state
910            .create_session(
911                SessionConfig {
912                    root: tmp.path().to_path_buf(),
913                    ..Default::default()
914                },
915                false,
916            )
917            .unwrap();
918        let report = state.doctor(s1.id()).unwrap();
919        let conflict = report
920            .checks
921            .iter()
922            .find(|c| c.name == "root-conflict")
923            .unwrap();
924        assert_eq!(conflict.status, CheckStatus::Warn);
925    }
926
927    #[test]
928    fn ensure_alive_err_when_root_deleted() {
929        let tmp = tempfile::tempdir().unwrap();
930        let path = tmp.path().to_path_buf();
931        let session = Session::new(SessionConfig {
932            root: path.clone(),
933            ..Default::default()
934        })
935        .unwrap();
936        std::fs::remove_dir_all(&path).unwrap();
937        let err = session.ensure_alive().unwrap_err();
938        let msg = err.to_string();
939        assert!(
940            msg.contains("session root path no longer exists, please call session_start again"),
941            "expected K-239 substring, got: {msg}"
942        );
943    }
944}