Skip to main content

talos_session/
manager.rs

1use crate::sqlite::{ForkInfo, IndexError, SearchResult, SessionIndex};
2use crate::store::{CompactTextSessionStore, JsonlSessionStore, SessionStore};
3use crate::todo::{TodoError, TodoRepository};
4use crate::topology::{workspace_dir_name, workspace_root_from_dir_name};
5use crate::{
6    DurableSession, OrphanSidecarReconciliationPolicy, OrphanSidecarReconciliationReport, Session,
7    SessionArtifactCleanupReport, SessionError, SessionInfo,
8    remove_session_sidecars_for_transcript, remove_session_transcript,
9};
10use chrono::{DateTime, Duration, Utc};
11use std::fs;
12use std::path::{Path, PathBuf};
13use std::sync::{Arc, Mutex};
14use uuid::Uuid;
15
16const KNOWN_EXTENSIONS: &[&str] = &["jsonl", "tlog"];
17
18/// Policy for selecting session cleanup candidates.
19#[derive(Debug, Clone, Default)]
20pub struct SessionCleanupPolicy {
21    /// Workspace root to limit cleanup to. `None` scans all workspaces.
22    pub workspace_root: Option<String>,
23    /// Keep at most this many newest sessions after excluding protected IDs.
24    pub max_sessions_per_workspace: Option<usize>,
25    /// Delete sessions older than this many days after excluding protected IDs.
26    pub max_age_days: Option<i64>,
27    /// Session IDs that must never be selected for cleanup.
28    pub protected_session_ids: Vec<Uuid>,
29}
30
31/// A session selected by a cleanup policy.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct SessionCleanupCandidate {
34    /// Session identifier.
35    pub id: Uuid,
36    /// Workspace root associated with the session.
37    pub workspace_root: String,
38    /// JSONL file path to remove if cleanup is applied.
39    pub file_path: PathBuf,
40    /// File size in bytes at selection time.
41    pub size_bytes: u64,
42    /// Last modified timestamp used for retention decisions.
43    pub timestamp: DateTime<Utc>,
44    /// Human-readable reason this session was selected.
45    pub reason: String,
46}
47
48/// Result of applying a session cleanup policy.
49#[derive(Debug, Clone, Default, PartialEq, Eq)]
50pub struct SessionCleanupReport {
51    /// Candidates selected by the policy.
52    pub candidates: Vec<SessionCleanupCandidate>,
53    /// Number of candidates actually removed.
54    pub removed: usize,
55    /// Total bytes removed from complete Session-owned artifact sets.
56    pub bytes_removed: u64,
57}
58
59/// Manages sessions on disk.
60pub struct SessionManager {
61    pub(crate) sessions_dir: PathBuf,
62    index: Arc<Mutex<Option<SessionIndex>>>,
63    store: Arc<dyn SessionStore>,
64    jsonl_store: Arc<dyn SessionStore>,
65}
66
67impl std::fmt::Debug for SessionManager {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        f.debug_struct("SessionManager")
70            .field("sessions_dir", &self.sessions_dir)
71            .finish()
72    }
73}
74
75impl Clone for SessionManager {
76    fn clone(&self) -> Self {
77        Self {
78            sessions_dir: self.sessions_dir.clone(),
79            index: Arc::clone(&self.index),
80            store: Arc::clone(&self.store),
81            jsonl_store: Arc::clone(&self.jsonl_store),
82        }
83    }
84}
85
86impl SessionManager {
87    /// Create a new `SessionManager` with the default sessions directory (`~/.talos/sessions/`).
88    pub fn new() -> Result<Self, SessionError> {
89        let dir = Self::default_sessions_dir()?;
90        let manager = Self {
91            sessions_dir: dir,
92            index: Arc::new(Mutex::new(None)),
93            store: Arc::new(CompactTextSessionStore),
94            jsonl_store: Arc::new(JsonlSessionStore),
95        };
96        if let Err(error) = manager.reconcile_index() {
97            eprintln!("Session index reconciliation failed during startup: {error}");
98        }
99        match manager.reconcile_orphan_sidecars(&OrphanSidecarReconciliationPolicy::default()) {
100            Ok(report) => {
101                if report.bounded {
102                    eprintln!(
103                        "Session orphan-sidecar reconciliation reached its safety bound after scanning {} entries; continuation state was saved. Run `talos storage maintenance --reconcile` to continue.",
104                        report.scanned_entries,
105                    );
106                }
107                for failure in report.failures {
108                    eprintln!(
109                        "Session orphan-sidecar reconciliation failed for {} at {}: {}",
110                        failure.session_id,
111                        failure.path.display(),
112                        failure.error,
113                    );
114                }
115            }
116            Err(error) => {
117                eprintln!("Session orphan-sidecar reconciliation failed during startup: {error}");
118            }
119        }
120        Ok(manager)
121    }
122
123    /// Return the default sessions directory without opening indexes or reconciling state.
124    ///
125    /// # Errors
126    ///
127    /// Returns an error if no user home directory environment variable is available.
128    pub fn default_sessions_dir() -> Result<PathBuf, SessionError> {
129        let home = home_dir_from_env()?;
130        Ok(PathBuf::from(home).join(".talos").join("sessions"))
131    }
132
133    /// Create a new `SessionManager` with a custom sessions directory.
134    pub fn with_dir(sessions_dir: PathBuf) -> Self {
135        Self {
136            sessions_dir,
137            index: Arc::new(Mutex::new(None)),
138            store: Arc::new(CompactTextSessionStore),
139            jsonl_store: Arc::new(JsonlSessionStore),
140        }
141    }
142
143    /// Creates or opens a UUID-backed durable session for a host logical ID.
144    ///
145    /// The external ID is stored only in a colocated binding index; it is never
146    /// used as a path component or filename.
147    pub fn create_or_open_session(
148        &self,
149        external_id: &str,
150    ) -> Result<DurableSession, SessionError> {
151        crate::durable::create_or_open(&self.sessions_dir, external_id)
152    }
153
154    /// Looks up a durable session by its host logical ID without creating one.
155    pub fn get_session_by_external_id(
156        &self,
157        external_id: &str,
158    ) -> Result<Option<DurableSession>, SessionError> {
159        crate::durable::get_by_external_id(&self.sessions_dir, external_id)
160    }
161
162    /// Returns whether a UUID-backed session exists without accepting a path.
163    pub fn session_exists(&self, id: &Uuid) -> bool {
164        self.get_session(id).is_ok()
165    }
166
167    /// Reads all normalized entries for a UUID-backed session.
168    pub fn read_session(&self, id: &Uuid) -> Result<Vec<crate::SessionEntry>, SessionError> {
169        self.get_session(id)?.read_entries()
170    }
171
172    /// Returns the byte size of a UUID-backed session file.
173    pub fn session_size(&self, id: &Uuid) -> Result<u64, SessionError> {
174        Ok(fs::metadata(self.find_session_file(id)?)?.len())
175    }
176
177    /// Return the root directory used for session JSONL files and the colocated index.
178    #[must_use]
179    pub fn sessions_dir(&self) -> &Path {
180        &self.sessions_dir
181    }
182
183    fn is_session_file(&self, path: &Path) -> bool {
184        path.extension()
185            .and_then(|e| e.to_str())
186            .is_some_and(|ext| KNOWN_EXTENSIONS.contains(&ext))
187    }
188
189    fn store_for_path(&self, path: &Path) -> &dyn SessionStore {
190        match path.extension().and_then(|e| e.to_str()) {
191            Some("tlog") => self.store.as_ref(),
192            _ => self.jsonl_store.as_ref(),
193        }
194    }
195
196    /// Open the colocated session todo repository and initialize its schema.
197    ///
198    /// The repository is session-scoped by data, not by database file: all todo rows carry a
199    /// `session_id` and share one SQLite file under the sessions directory.
200    ///
201    /// # Errors
202    ///
203    /// Returns an error if the SQLite database cannot be opened or initialized.
204    pub fn todo_repository(&self) -> Result<TodoRepository, TodoError> {
205        let repo = TodoRepository::new(&self.sessions_dir.join("todos.sqlite"))?;
206        repo.init_schema()?;
207        Ok(repo)
208    }
209
210    /// Create a new session for the given project and workspace.
211    ///
212    /// The session file is created at `~/.talos/sessions/<workspace_dir>/<uuid>.tlog`,
213    /// where `workspace_dir` is a hash of the workspace root path.
214    pub fn create_session(
215        &self,
216        project: &str,
217        workspace_root: &str,
218    ) -> Result<Session, SessionError> {
219        let id = Uuid::new_v4();
220        let project_dir = self.sessions_dir.join(workspace_dir_name(workspace_root));
221        fs::create_dir_all(&project_dir)?;
222
223        let file_path = project_dir.join(format!("{id}.{}", self.store.file_extension()));
224        fs::File::create(&file_path)?;
225
226        Ok(Session::new(
227            id,
228            project.to_string(),
229            workspace_root.to_string(),
230            file_path,
231        ))
232    }
233
234    /// Prepare a new session without creating the file on disk.
235    ///
236    /// Returns a [`Session`] with `persisted = false`. The JSONL file is
237    /// not created until [`Session::ensure_persisted`] is called (triggered
238    /// automatically on the first message append).
239    pub fn defer_create_session(
240        &self,
241        project: &str,
242        workspace_root: &str,
243    ) -> Result<Session, SessionError> {
244        let id = Uuid::new_v4();
245        let project_dir = self.sessions_dir.join(workspace_dir_name(workspace_root));
246        let file_path = project_dir.join(format!("{id}.{}", self.store.file_extension()));
247
248        Ok(Session::new_deferred(
249            id,
250            project.to_string(),
251            workspace_root.to_string(),
252            file_path,
253        ))
254    }
255
256    /// Load an existing session by ID.
257    ///
258    /// Scans all workspace directories for a file matching `<id>.<ext>`
259    /// where `<ext>` is any known session file extension (`.tlog` or `.jsonl`).
260    /// If both exist for the same UUID, returns a duplicate-format error.
261    pub fn get_session(&self, id: &Uuid) -> Result<Session, SessionError> {
262        if !self.sessions_dir.exists() {
263            return Err(SessionError::SessionNotFound(*id));
264        }
265
266        for entry in fs::read_dir(&self.sessions_dir)? {
267            let entry = entry?;
268            if !entry.file_type()?.is_dir() {
269                continue;
270            }
271            let project_dir = entry.path();
272            let dir_name = project_dir
273                .file_name()
274                .and_then(|n| n.to_str())
275                .unwrap_or("unknown")
276                .to_string();
277
278            let mut found: Option<(PathBuf, Arc<dyn SessionStore>)> = None;
279            for ext in KNOWN_EXTENSIONS {
280                let candidate = project_dir.join(format!("{id}.{ext}"));
281                if candidate.exists() {
282                    if found.is_some() {
283                        return Err(SessionError::ParseError(format!(
284                            "duplicate session files for {id}: both .tlog and .jsonl exist"
285                        )));
286                    }
287                    let store = if ext == &"tlog" {
288                        Arc::clone(&self.store)
289                    } else {
290                        Arc::clone(&self.jsonl_store)
291                    };
292                    found = Some((candidate, store));
293                }
294            }
295
296            if let Some((file_path, store)) = found {
297                let metadata = fs::metadata(&file_path)?;
298                let created_at = metadata
299                    .modified()
300                    .ok()
301                    .map(DateTime::<Utc>::from)
302                    .unwrap_or_else(Utc::now);
303
304                let mut session = Session::with_store(
305                    *id,
306                    dir_name.clone(),
307                    workspace_root_from_dir_name(&dir_name),
308                    file_path,
309                    store,
310                );
311                session.created_at = created_at;
312
313                let entries = session.read_entries()?;
314                if !entries.is_empty()
315                    && let Some(branch) = session.branches.get_mut(&session.current_branch)
316                {
317                    branch.entries = entries;
318                }
319
320                return Ok(session);
321            }
322        }
323
324        Err(SessionError::SessionNotFound(*id))
325    }
326
327    /// List all sessions across all workspace directories.
328    pub fn list_sessions(&self) -> Result<Vec<SessionInfo>, SessionError> {
329        let mut sessions = Vec::new();
330
331        if !self.sessions_dir.exists() {
332            return Ok(sessions);
333        }
334
335        for entry in fs::read_dir(&self.sessions_dir)? {
336            let entry = entry?;
337            if !entry.file_type()?.is_dir() {
338                continue;
339            }
340            let project_dir = entry.path();
341            let dir_name = project_dir
342                .file_name()
343                .and_then(|n| n.to_str())
344                .unwrap_or("unknown")
345                .to_string();
346
347            for file_entry in fs::read_dir(&project_dir)? {
348                let file_entry = file_entry?;
349                let path = file_entry.path();
350                if !self.is_session_file(&path) {
351                    continue;
352                }
353
354                let file_stem = path
355                    .file_stem()
356                    .and_then(|s| s.to_str())
357                    .and_then(|s| Uuid::parse_str(s).ok());
358
359                if let Some(id) = file_stem {
360                    let metadata = fs::metadata(&path)?;
361                    let timestamp = metadata
362                        .modified()
363                        .ok()
364                        .map(DateTime::<Utc>::from)
365                        .unwrap_or_else(Utc::now);
366
367                    let store = self.store_for_path(&path);
368                    let info = store.scan_file(&path)?;
369
370                    sessions.push(SessionInfo {
371                        id,
372                        project: dir_name.clone(),
373                        workspace_root: String::new(),
374                        last_message_preview: info.last_message_preview,
375                        timestamp,
376                        message_count: info.message_count,
377                    });
378                }
379            }
380        }
381
382        Ok(sessions)
383    }
384
385    /// List sessions for one workspace directory.
386    pub fn list_workspace_sessions(
387        &self,
388        workspace_root: &str,
389    ) -> Result<Vec<SessionInfo>, SessionError> {
390        let workspace_dir = self.sessions_dir.join(workspace_dir_name(workspace_root));
391        if !workspace_dir.exists() {
392            return Ok(Vec::new());
393        }
394
395        let mut sessions = Vec::new();
396        for file_entry in fs::read_dir(&workspace_dir)? {
397            let file_entry = file_entry?;
398            let path = file_entry.path();
399            if !self.is_session_file(&path) {
400                continue;
401            }
402
403            let file_stem = path
404                .file_stem()
405                .and_then(|s| s.to_str())
406                .and_then(|s| Uuid::parse_str(s).ok());
407
408            if let Some(id) = file_stem {
409                let metadata = fs::metadata(&path)?;
410                let timestamp = metadata
411                    .modified()
412                    .ok()
413                    .map(DateTime::<Utc>::from)
414                    .unwrap_or_else(Utc::now);
415
416                let store = self.store_for_path(&path);
417                let info = store.scan_file(&path)?;
418
419                sessions.push(SessionInfo {
420                    id,
421                    project: String::new(),
422                    workspace_root: workspace_root.to_string(),
423                    last_message_preview: info.last_message_preview,
424                    timestamp,
425                    message_count: info.message_count,
426                });
427            }
428        }
429
430        Ok(sessions)
431    }
432
433    /// Return the most recently modified session for one workspace directory.
434    pub fn latest_workspace_session(
435        &self,
436        workspace_root: &str,
437    ) -> Result<Option<SessionInfo>, SessionError> {
438        let sessions = self.list_workspace_sessions(workspace_root)?;
439        Ok(sessions.into_iter().max_by_key(|s| s.timestamp))
440    }
441
442    /// Resume a session by ID, loading all entries from the session file.
443    ///
444    /// This is equivalent to [`SessionManager::get_session`] but with a clearer
445    /// name for the "resume" use case.
446    pub fn resume_session(&self, session_id: &str) -> Result<Session, SessionError> {
447        let id = Uuid::parse_str(session_id)
448            .map_err(|_| SessionError::SessionNotFound(Uuid::new_v4()))?;
449        self.get_session(&id)
450    }
451
452    fn get_or_create_index(
453        &self,
454    ) -> Result<std::sync::MutexGuard<'_, Option<SessionIndex>>, IndexError> {
455        let mut guard = self.index.lock().expect("index lock poisoned");
456        if guard.is_none() {
457            let db_path = self.sessions_dir.join("index.db");
458
459            let index = SessionIndex::new(&db_path)?;
460            index.init_schema()?;
461            *guard = Some(index);
462        }
463        Ok(guard)
464    }
465
466    /// Perform a full-text search across all indexed session messages.
467    ///
468    /// Returns results ranked by relevance. The index is created lazily if it
469    /// does not exist.
470    pub fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchResult>, IndexError> {
471        let guard = self.get_or_create_index()?;
472        let index = guard.as_ref().expect("index just created");
473        index.search(query, limit)
474    }
475
476    /// List the most recently updated sessions from the index.
477    ///
478    /// Returns sessions ordered by last update time, descending.
479    pub fn list_recent(&self, limit: usize) -> Result<Vec<SessionInfo>, IndexError> {
480        let guard = self.get_or_create_index()?;
481        let index = guard.as_ref().expect("index just created");
482        index.list_recent(limit)
483    }
484
485    /// Update the search index for a session.
486    ///
487    /// If the index has not been initialized, this is a no-op.
488    pub fn update_index(&self, session: &Session) -> Result<(), IndexError> {
489        let mut guard = self.get_or_create_index()?;
490        let index = guard.as_mut().expect("index just created");
491        index.index_session(session)
492    }
493
494    /// Checkpoint the session index WAL and truncate it where possible.
495    pub fn checkpoint_index(&self) -> Result<(), IndexError> {
496        let guard = self.get_or_create_index()?;
497        let index = guard.as_ref().expect("index just created");
498        index.checkpoint_truncate()
499    }
500
501    /// Vacuum the session index database.
502    pub fn vacuum_index(&self) -> Result<(), IndexError> {
503        let guard = self.get_or_create_index()?;
504        let index = guard.as_ref().expect("index just created");
505        index.vacuum()
506    }
507
508    /// Return forks originating from the given session ID.
509    pub fn get_forks(&self, session_id: &str) -> Result<Vec<ForkInfo>, IndexError> {
510        let guard = self.get_or_create_index()?;
511        let index = guard.as_ref().expect("index just created");
512        index.get_forks(session_id)
513    }
514
515    /// Record one source/child fork relationship through the manager-owned index.
516    pub fn record_fork(
517        &self,
518        source_session_id: &Uuid,
519        forked_session_id: &Uuid,
520        fork_entry_id: &str,
521    ) -> Result<(), IndexError> {
522        let mut guard = self.get_or_create_index()?;
523        let index = guard.as_mut().expect("index just created");
524        index.record_fork(
525            &source_session_id.to_string(),
526            &forked_session_id.to_string(),
527            fork_entry_id,
528        )
529    }
530
531    #[allow(clippy::collapsible_if)]
532    pub fn reconcile_index(&self) -> Result<usize, IndexError> {
533        let mut guard = self.get_or_create_index()?;
534        let index = guard.as_mut().expect("index just created");
535
536        let mut fixed = 0usize;
537
538        let indexed_ids: std::collections::HashSet<String> =
539            index.list_all_session_ids()?.into_iter().collect();
540
541        let mut on_disk_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
542
543        if self.sessions_dir.exists() {
544            for ws_entry in fs::read_dir(&self.sessions_dir)? {
545                let ws_entry = ws_entry?;
546                if !ws_entry.file_type()?.is_dir() {
547                    continue;
548                }
549                let ws_dir = ws_entry.path();
550                let workspace_root = workspace_root_from_dir_name(
551                    &ws_dir.file_name().unwrap_or_default().to_string_lossy(),
552                );
553
554                for file_entry in fs::read_dir(&ws_dir)? {
555                    let file_entry = file_entry?;
556                    let path = file_entry.path();
557                    if !self.is_session_file(&path) {
558                        continue;
559                    }
560                    let stem = match path.file_stem().and_then(|s| s.to_str()) {
561                        Some(s) => s.to_string(),
562                        None => continue,
563                    };
564                    on_disk_ids.insert(stem.clone());
565
566                    let existing = index.get_session_info(&stem)?;
567                    let store = self.store_for_path(&path);
568                    let info = store.scan_file(&path).unwrap_or(SessionInfo {
569                        id: Uuid::nil(),
570                        project: String::new(),
571                        workspace_root: String::new(),
572                        last_message_preview: String::new(),
573                        timestamp: Utc::now(),
574                        message_count: 0,
575                    });
576                    let msg_count = info.message_count;
577                    let needs_reindex = match &existing {
578                        None => true,
579                        Some(info) => info.message_count != msg_count,
580                    };
581                    if needs_reindex && Uuid::parse_str(&stem).is_ok() {
582                        if let Ok(id) = Uuid::parse_str(&stem) {
583                            let project = ws_dir
584                                .file_name()
585                                .and_then(|n| n.to_str())
586                                .unwrap_or("unknown")
587                                .to_string();
588                            let session_store =
589                                if path.extension().and_then(|e| e.to_str()) == Some("tlog") {
590                                    Arc::clone(&self.store)
591                                } else {
592                                    Arc::clone(&self.jsonl_store)
593                                };
594                            let mut session = Session::with_store(
595                                id,
596                                project,
597                                workspace_root.to_string(),
598                                path.clone(),
599                                session_store,
600                            );
601                            if let Ok(entries) = session.read_entries() {
602                                if let Some(branch) =
603                                    session.branches.get_mut(&session.current_branch)
604                                {
605                                    branch.entries = entries;
606                                }
607                            }
608                            index.index_session(&session)?;
609                            fixed += 1;
610                        }
611                    }
612                }
613            }
614        }
615
616        for orphan_id in indexed_ids.difference(&on_disk_ids) {
617            index.delete_session(orphan_id)?;
618            fixed += 1;
619        }
620
621        Ok(fixed)
622    }
623
624    /// Delete one discoverable Session through the retryable ownership boundary.
625    pub fn delete_session(&self, id: &Uuid) -> Result<(), SessionError> {
626        let file_path = self.find_session_file(id)?;
627        self.remove_owned_session_artifacts(id, &file_path)
628            .map(|_| ())
629    }
630
631    /// Roll back all artifacts owned by a prepared or partially created Session.
632    ///
633    /// Unlike `delete_session`, this does not require the transcript to exist, so
634    /// post-identity initialization failures remain recoverable.
635    pub fn rollback_session_artifacts(
636        &self,
637        session: &Session,
638    ) -> Result<SessionArtifactCleanupReport, SessionError> {
639        self.remove_owned_session_artifacts(&session.id, &session.file_path)
640    }
641
642    fn remove_owned_session_artifacts(
643        &self,
644        id: &Uuid,
645        transcript_path: &Path,
646    ) -> Result<SessionArtifactCleanupReport, SessionError> {
647        let mut report = remove_session_sidecars_for_transcript(transcript_path)?;
648        crate::durable::remove_binding_for_session(&self.sessions_dir, id)?;
649        let mut guard = self
650            .get_or_create_index()
651            .map_err(|error| SessionError::IndexCleanup {
652                session_id: *id,
653                message: error.to_string(),
654            })?;
655        if let Some(index) = guard.as_mut() {
656            index
657                .delete_session(&id.to_string())
658                .map_err(|error| SessionError::IndexCleanup {
659                    session_id: *id,
660                    message: error.to_string(),
661                })?;
662        }
663        report.merge(remove_session_transcript(transcript_path)?);
664        Ok(report)
665    }
666
667    /// Discover and remove safe transcript-less pending SQLite artifacts.
668    pub fn reconcile_orphan_sidecars(
669        &self,
670        policy: &OrphanSidecarReconciliationPolicy,
671    ) -> Result<OrphanSidecarReconciliationReport, SessionError> {
672        crate::artifacts::reconcile_orphan_sidecars_in_root(&self.sessions_dir, policy)
673    }
674
675    /// Return sessions that would be removed by `policy` without deleting files.
676    pub fn cleanup_candidates(
677        &self,
678        policy: &SessionCleanupPolicy,
679    ) -> Result<Vec<SessionCleanupCandidate>, SessionError> {
680        let mut by_workspace = self.collect_cleanup_sessions(policy)?;
681        let protected: std::collections::HashSet<Uuid> =
682            policy.protected_session_ids.iter().copied().collect();
683        let cutoff = policy
684            .max_age_days
685            .map(|days| Utc::now() - Duration::days(days.max(0)));
686
687        let mut candidates = Vec::new();
688        for (workspace_root, sessions) in by_workspace.iter_mut() {
689            sessions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp).then_with(|| a.id.cmp(&b.id)));
690
691            for session in sessions.iter() {
692                if protected.contains(&session.id) {
693                    continue;
694                }
695                if let Some(cutoff) = cutoff
696                    && session.timestamp < cutoff
697                {
698                    candidates.push(SessionCleanupCandidate {
699                        id: session.id,
700                        workspace_root: workspace_root.clone(),
701                        file_path: session.file_path.clone(),
702                        size_bytes: session.size_bytes,
703                        timestamp: session.timestamp,
704                        reason: format!(
705                            "older than {} day(s)",
706                            policy.max_age_days.unwrap_or_default().max(0)
707                        ),
708                    });
709                }
710            }
711
712            if let Some(max_sessions) = policy.max_sessions_per_workspace {
713                let mut unprotected: Vec<_> = sessions
714                    .iter()
715                    .filter(|session| !protected.contains(&session.id))
716                    .collect();
717                unprotected
718                    .sort_by(|a, b| b.timestamp.cmp(&a.timestamp).then_with(|| a.id.cmp(&b.id)));
719                for session in unprotected.into_iter().skip(max_sessions) {
720                    if candidates
721                        .iter()
722                        .any(|candidate| candidate.id == session.id)
723                    {
724                        continue;
725                    }
726                    candidates.push(SessionCleanupCandidate {
727                        id: session.id,
728                        workspace_root: workspace_root.clone(),
729                        file_path: session.file_path.clone(),
730                        size_bytes: session.size_bytes,
731                        timestamp: session.timestamp,
732                        reason: format!("exceeds max_sessions_per_workspace={max_sessions}"),
733                    });
734                }
735            }
736        }
737
738        candidates.sort_by(|a, b| a.timestamp.cmp(&b.timestamp).then_with(|| a.id.cmp(&b.id)));
739        Ok(candidates)
740    }
741
742    /// Apply a cleanup policy by deleting selected sessions and index rows.
743    ///
744    /// This is an explicit maintenance operation. It never removes IDs listed in
745    /// `protected_session_ids`, even if callers accidentally include an active
746    /// session in an otherwise matching policy.
747    pub fn apply_cleanup(
748        &self,
749        policy: &SessionCleanupPolicy,
750    ) -> Result<SessionCleanupReport, SessionError> {
751        let candidates = self.cleanup_candidates(policy)?;
752        let mut report = SessionCleanupReport {
753            candidates,
754            removed: 0,
755            bytes_removed: 0,
756        };
757
758        for candidate in &report.candidates {
759            let cleanup =
760                self.remove_owned_session_artifacts(&candidate.id, &candidate.file_path)?;
761            report.removed = report.removed.saturating_add(1);
762            report.bytes_removed = report.bytes_removed.saturating_add(cleanup.bytes_removed);
763        }
764
765        Ok(report)
766    }
767
768    #[allow(clippy::collapsible_if)]
769    fn find_session_file(&self, id: &Uuid) -> Result<PathBuf, SessionError> {
770        if self.sessions_dir.exists() {
771            for ws_entry in fs::read_dir(&self.sessions_dir)? {
772                let ws_entry = ws_entry?;
773                if !ws_entry.file_type()?.is_dir() {
774                    continue;
775                }
776                let mut found: Option<PathBuf> = None;
777                for ext in KNOWN_EXTENSIONS {
778                    let candidate = ws_entry.path().join(format!("{id}.{ext}"));
779                    if candidate.exists() {
780                        if found.is_some() {
781                            return Err(SessionError::ParseError(format!(
782                                "duplicate session files for {id}: both .tlog and .jsonl exist"
783                            )));
784                        }
785                        found = Some(candidate);
786                    }
787                }
788                if let Some(path) = found {
789                    return Ok(path);
790                }
791            }
792        }
793        Err(SessionError::SessionNotFound(*id))
794    }
795
796    fn collect_cleanup_sessions(
797        &self,
798        policy: &SessionCleanupPolicy,
799    ) -> Result<std::collections::HashMap<String, Vec<CleanupSession>>, SessionError> {
800        let mut by_workspace: std::collections::HashMap<String, Vec<CleanupSession>> =
801            std::collections::HashMap::new();
802
803        if !self.sessions_dir.exists() {
804            return Ok(by_workspace);
805        }
806
807        if let Some(target) = &policy.workspace_root {
808            let workspace_dir = self.sessions_dir.join(workspace_dir_name(target));
809            if workspace_dir.exists() {
810                self.collect_cleanup_workspace(target, &workspace_dir, &mut by_workspace)?;
811            }
812            return Ok(by_workspace);
813        }
814
815        for ws_entry in fs::read_dir(&self.sessions_dir)? {
816            let ws_entry = ws_entry?;
817            if !ws_entry.file_type()?.is_dir() {
818                continue;
819            }
820            let ws_dir = ws_entry.path();
821            let workspace_root = workspace_root_from_dir_name(
822                &ws_dir.file_name().unwrap_or_default().to_string_lossy(),
823            );
824            self.collect_cleanup_workspace(&workspace_root, &ws_dir, &mut by_workspace)?;
825        }
826
827        Ok(by_workspace)
828    }
829
830    fn collect_cleanup_workspace(
831        &self,
832        workspace_root: &str,
833        workspace_dir: &Path,
834        by_workspace: &mut std::collections::HashMap<String, Vec<CleanupSession>>,
835    ) -> Result<(), SessionError> {
836        for file_entry in fs::read_dir(workspace_dir)? {
837            let file_entry = file_entry?;
838            let path = file_entry.path();
839            if !self.is_session_file(&path) {
840                continue;
841            }
842            let Some(id) = path
843                .file_stem()
844                .and_then(|s| s.to_str())
845                .and_then(|s| Uuid::parse_str(s).ok())
846            else {
847                continue;
848            };
849            let metadata = fs::metadata(&path)?;
850            let timestamp = metadata
851                .modified()
852                .ok()
853                .map(DateTime::<Utc>::from)
854                .unwrap_or_else(Utc::now);
855            by_workspace
856                .entry(workspace_root.to_string())
857                .or_default()
858                .push(CleanupSession {
859                    id,
860                    file_path: path,
861                    size_bytes: metadata.len(),
862                    timestamp,
863                });
864        }
865
866        Ok(())
867    }
868}
869
870#[derive(Debug, Clone)]
871struct CleanupSession {
872    id: Uuid,
873    file_path: PathBuf,
874    size_bytes: u64,
875    timestamp: DateTime<Utc>,
876}
877
878impl Default for SessionManager {
879    fn default() -> Self {
880        let home = home_dir_from_env()
881            .unwrap_or_else(|_| std::env::temp_dir().to_string_lossy().into_owned());
882        Self {
883            sessions_dir: PathBuf::from(home).join(".talos").join("sessions"),
884            index: Arc::new(Mutex::new(None)),
885            store: Arc::new(CompactTextSessionStore),
886            jsonl_store: Arc::new(JsonlSessionStore),
887        }
888    }
889}
890
891/// Resolve home directory env vars in cross-platform precedence.
892///
893/// Order:
894/// 1. `HOME`
895/// 2. `USERPROFILE`
896/// 3. `HOMEDRIVE` + `HOMEPATH`
897fn home_dir_from_env() -> Result<String, SessionError> {
898    // Empty env vars should behave as unset vars so Windows fallback order still applies.
899    home_dir_from_getter(|key| std::env::var(key).ok().filter(|value| !value.is_empty()))
900}
901
902fn home_dir_from_getter<F>(mut get_var: F) -> Result<String, SessionError>
903where
904    F: FnMut(&str) -> Option<String>,
905{
906    if let Some(home) = get_var("HOME") {
907        return Ok(home);
908    }
909    if let Some(profile) = get_var("USERPROFILE") {
910        return Ok(profile);
911    }
912    let drive = get_var("HOMEDRIVE").unwrap_or_default();
913    let path = get_var("HOMEPATH").unwrap_or_default();
914    if !drive.is_empty() && !path.is_empty() {
915        return Ok(format!("{drive}{path}"));
916    }
917    Err(SessionError::IoError(std::io::Error::new(
918        std::io::ErrorKind::NotFound,
919        "home directory environment variable not found",
920    )))
921}
922
923#[cfg(test)]
924mod manager_env_tests {
925    use super::home_dir_from_getter;
926
927    #[test]
928    fn home_dir_prefers_home() {
929        let value = home_dir_from_getter(|key| match key {
930            "HOME" => Some("/home/test".to_string()),
931            "USERPROFILE" => Some("C:\\Users\\test".to_string()),
932            _ => None,
933        })
934        .expect("HOME should be used");
935        assert_eq!(value, "/home/test");
936    }
937
938    #[test]
939    fn home_dir_falls_back_to_userprofile() {
940        let value = home_dir_from_getter(|key| match key {
941            "HOME" => None,
942            "USERPROFILE" => Some("C:\\Users\\test".to_string()),
943            _ => None,
944        })
945        .expect("USERPROFILE should be used");
946        assert_eq!(value, "C:\\Users\\test");
947    }
948
949    #[test]
950    fn home_dir_falls_back_to_drive_and_path() {
951        let value = home_dir_from_getter(|key| match key {
952            "HOME" => None,
953            "USERPROFILE" => None,
954            "HOMEDRIVE" => Some("C:".to_string()),
955            "HOMEPATH" => Some("\\Users\\test".to_string()),
956            _ => None,
957        })
958        .expect("HOMEDRIVE/HOMEPATH should be used");
959        assert_eq!(value, "C:\\Users\\test");
960    }
961
962    #[test]
963    fn home_dir_errors_when_all_missing() {
964        let err = home_dir_from_getter(|_| None).expect_err("missing vars should error");
965        assert!(matches!(err, crate::SessionError::IoError(_)));
966    }
967}