Skip to main content

talos_session/
manager.rs

1use crate::jsonl::scan_file;
2use crate::sqlite::{ForkInfo, IndexError, SearchResult, SessionIndex};
3use crate::topology::{workspace_dir_name, workspace_root_from_dir_name};
4use crate::{Session, SessionError, SessionInfo};
5use chrono::{DateTime, Duration, Utc};
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::sync::{Arc, Mutex};
9use uuid::Uuid;
10
11/// Policy for selecting session cleanup candidates.
12#[derive(Debug, Clone, Default)]
13pub struct SessionCleanupPolicy {
14    /// Workspace root to limit cleanup to. `None` scans all workspaces.
15    pub workspace_root: Option<String>,
16    /// Keep at most this many newest sessions after excluding protected IDs.
17    pub max_sessions_per_workspace: Option<usize>,
18    /// Delete sessions older than this many days after excluding protected IDs.
19    pub max_age_days: Option<i64>,
20    /// Session IDs that must never be selected for cleanup.
21    pub protected_session_ids: Vec<Uuid>,
22}
23
24/// A session selected by a cleanup policy.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct SessionCleanupCandidate {
27    /// Session identifier.
28    pub id: Uuid,
29    /// Workspace root associated with the session.
30    pub workspace_root: String,
31    /// JSONL file path to remove if cleanup is applied.
32    pub file_path: PathBuf,
33    /// File size in bytes at selection time.
34    pub size_bytes: u64,
35    /// Last modified timestamp used for retention decisions.
36    pub timestamp: DateTime<Utc>,
37    /// Human-readable reason this session was selected.
38    pub reason: String,
39}
40
41/// Result of applying a session cleanup policy.
42#[derive(Debug, Clone, Default, PartialEq, Eq)]
43pub struct SessionCleanupReport {
44    /// Candidates selected by the policy.
45    pub candidates: Vec<SessionCleanupCandidate>,
46    /// Number of candidates actually removed.
47    pub removed: usize,
48    /// Total bytes removed from JSONL files.
49    pub bytes_removed: u64,
50}
51
52/// Manages sessions on disk.
53#[derive(Debug, Clone)]
54pub struct SessionManager {
55    pub(crate) sessions_dir: PathBuf,
56    index: Arc<Mutex<Option<SessionIndex>>>,
57}
58
59impl SessionManager {
60    /// Create a new `SessionManager` with the default sessions directory (`~/.talos/sessions/`).
61    pub fn new() -> Result<Self, SessionError> {
62        let home =
63            std::env::var("HOME").map_err(|e| SessionError::IoError(std::io::Error::other(e)))?;
64        let dir = PathBuf::from(home).join(".talos").join("sessions");
65        let manager = Self {
66            sessions_dir: dir,
67            index: Arc::new(Mutex::new(None)),
68        };
69        // Repair any drift between on-disk JSONL and the SQLite index on
70        // startup. Errors are non-fatal: a future operation that needs the
71        // index will surface a precise cause.
72        let _ = manager.reconcile_index();
73        Ok(manager)
74    }
75
76    /// Create a new `SessionManager` with a custom sessions directory.
77    pub fn with_dir(sessions_dir: PathBuf) -> Self {
78        Self {
79            sessions_dir,
80            index: Arc::new(Mutex::new(None)),
81        }
82    }
83
84    /// Return the root directory used for session JSONL files and the colocated index.
85    #[must_use]
86    pub fn sessions_dir(&self) -> &Path {
87        &self.sessions_dir
88    }
89
90    /// Create a new session for the given project and workspace.
91    ///
92    /// The session file is created at `~/.talos/sessions/<workspace_dir>/<uuid>.jsonl`,
93    /// where `workspace_dir` is a hash of the workspace root path.
94    pub fn create_session(
95        &self,
96        project: &str,
97        workspace_root: &str,
98    ) -> Result<Session, SessionError> {
99        let id = Uuid::new_v4();
100        let project_dir = self.sessions_dir.join(workspace_dir_name(workspace_root));
101        fs::create_dir_all(&project_dir)?;
102
103        let file_path = project_dir.join(format!("{id}.jsonl"));
104        fs::File::create(&file_path)?;
105
106        Ok(Session::new(
107            id,
108            project.to_string(),
109            workspace_root.to_string(),
110            file_path,
111        ))
112    }
113
114    /// Prepare a new session without creating the file on disk.
115    ///
116    /// Returns a [`Session`] with `persisted = false`. The JSONL file is
117    /// not created until [`Session::ensure_persisted`] is called (triggered
118    /// automatically on the first message append).
119    pub fn defer_create_session(
120        &self,
121        project: &str,
122        workspace_root: &str,
123    ) -> Result<Session, SessionError> {
124        let id = Uuid::new_v4();
125        let project_dir = self.sessions_dir.join(workspace_dir_name(workspace_root));
126        let file_path = project_dir.join(format!("{id}.jsonl"));
127
128        Ok(Session::new_deferred(
129            id,
130            project.to_string(),
131            workspace_root.to_string(),
132            file_path,
133        ))
134    }
135
136    /// Load an existing session by ID.
137    ///
138    /// Scans all workspace directories (both old basename and new hashed layouts)
139    /// for a file matching `<id>.jsonl`.
140    pub fn get_session(&self, id: &Uuid) -> Result<Session, SessionError> {
141        if !self.sessions_dir.exists() {
142            return Err(SessionError::SessionNotFound(*id));
143        }
144
145        for entry in fs::read_dir(&self.sessions_dir)? {
146            let entry = entry?;
147            if !entry.file_type()?.is_dir() {
148                continue;
149            }
150            let project_dir = entry.path();
151            let dir_name = project_dir
152                .file_name()
153                .and_then(|n| n.to_str())
154                .unwrap_or("unknown")
155                .to_string();
156
157            let file_path = project_dir.join(format!("{id}.jsonl"));
158            if file_path.exists() {
159                let metadata = fs::metadata(&file_path)?;
160                let created_at = metadata
161                    .modified()
162                    .ok()
163                    .map(DateTime::<Utc>::from)
164                    .unwrap_or_else(Utc::now);
165
166                let mut session = Session::new(
167                    *id,
168                    dir_name.clone(),
169                    workspace_root_from_dir_name(&dir_name),
170                    file_path,
171                );
172                session.created_at = created_at;
173
174                let entries = session.read_entries()?;
175                if !entries.is_empty()
176                    && let Some(branch) = session.branches.get_mut(&session.current_branch)
177                {
178                    branch.entries = entries;
179                }
180
181                return Ok(session);
182            }
183        }
184
185        Err(SessionError::SessionNotFound(*id))
186    }
187
188    /// List all sessions across all workspace directories.
189    pub fn list_sessions(&self) -> Result<Vec<SessionInfo>, SessionError> {
190        let mut sessions = Vec::new();
191
192        if !self.sessions_dir.exists() {
193            return Ok(sessions);
194        }
195
196        for entry in fs::read_dir(&self.sessions_dir)? {
197            let entry = entry?;
198            if !entry.file_type()?.is_dir() {
199                continue;
200            }
201            let project_dir = entry.path();
202            let dir_name = project_dir
203                .file_name()
204                .and_then(|n| n.to_str())
205                .unwrap_or("unknown")
206                .to_string();
207
208            for file_entry in fs::read_dir(&project_dir)? {
209                let file_entry = file_entry?;
210                let path = file_entry.path();
211                if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
212                    continue;
213                }
214
215                let file_stem = path
216                    .file_stem()
217                    .and_then(|s| s.to_str())
218                    .and_then(|s| Uuid::parse_str(s).ok());
219
220                if let Some(id) = file_stem {
221                    let metadata = fs::metadata(&path)?;
222                    let timestamp = metadata
223                        .modified()
224                        .ok()
225                        .map(DateTime::<Utc>::from)
226                        .unwrap_or_else(Utc::now);
227
228                    let (message_count, last_preview) = scan_file(&path)?;
229
230                    sessions.push(SessionInfo {
231                        id,
232                        project: dir_name.clone(),
233                        workspace_root: String::new(),
234                        last_message_preview: last_preview,
235                        timestamp,
236                        message_count,
237                    });
238                }
239            }
240        }
241
242        Ok(sessions)
243    }
244
245    /// List sessions for one workspace directory.
246    pub fn list_workspace_sessions(
247        &self,
248        workspace_root: &str,
249    ) -> Result<Vec<SessionInfo>, SessionError> {
250        let workspace_dir = self.sessions_dir.join(workspace_dir_name(workspace_root));
251        if !workspace_dir.exists() {
252            return Ok(Vec::new());
253        }
254
255        Self::scan_workspace_sessions(workspace_root, &workspace_dir)
256    }
257
258    /// Return the most recently modified session for one workspace directory.
259    pub fn latest_workspace_session(
260        &self,
261        workspace_root: &str,
262    ) -> Result<Option<SessionInfo>, SessionError> {
263        let sessions = self.list_workspace_sessions(workspace_root)?;
264        Ok(sessions.into_iter().max_by_key(|s| s.timestamp))
265    }
266
267    fn scan_workspace_sessions(
268        workspace_root: &str,
269        workspace_dir: &Path,
270    ) -> Result<Vec<SessionInfo>, SessionError> {
271        let mut sessions = Vec::new();
272        for file_entry in fs::read_dir(workspace_dir)? {
273            let file_entry = file_entry?;
274            let path = file_entry.path();
275            if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
276                continue;
277            }
278
279            let file_stem = path
280                .file_stem()
281                .and_then(|s| s.to_str())
282                .and_then(|s| Uuid::parse_str(s).ok());
283
284            if let Some(id) = file_stem {
285                let metadata = fs::metadata(&path)?;
286                let timestamp = metadata
287                    .modified()
288                    .ok()
289                    .map(DateTime::<Utc>::from)
290                    .unwrap_or_else(Utc::now);
291
292                let (message_count, last_preview) = scan_file(&path)?;
293
294                sessions.push(SessionInfo {
295                    id,
296                    project: String::new(),
297                    workspace_root: workspace_root.to_string(),
298                    last_message_preview: last_preview,
299                    timestamp,
300                    message_count,
301                });
302            }
303        }
304
305        Ok(sessions)
306    }
307
308    /// Resume a session by ID, loading all entries from the JSONL file.
309    ///
310    /// This is equivalent to [`SessionManager::get_session`] but with a clearer
311    /// name for the "resume" use case.
312    pub fn resume_session(&self, session_id: &str) -> Result<Session, SessionError> {
313        let id = Uuid::parse_str(session_id)
314            .map_err(|_| SessionError::SessionNotFound(Uuid::new_v4()))?;
315        self.get_session(&id)
316    }
317
318    fn get_or_create_index(
319        &self,
320    ) -> Result<std::sync::MutexGuard<'_, Option<SessionIndex>>, IndexError> {
321        let mut guard = self.index.lock().expect("index lock poisoned");
322        if guard.is_none() {
323            let db_path = self.sessions_dir.join("index.db");
324
325            let index = SessionIndex::new(&db_path)?;
326            index.init_schema()?;
327            *guard = Some(index);
328        }
329        Ok(guard)
330    }
331
332    /// Perform a full-text search across all indexed session messages.
333    ///
334    /// Returns results ranked by relevance. The index is created lazily if it
335    /// does not exist.
336    pub fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchResult>, IndexError> {
337        let guard = self.get_or_create_index()?;
338        let index = guard.as_ref().expect("index just created");
339        index.search(query, limit)
340    }
341
342    /// List the most recently updated sessions from the index.
343    ///
344    /// Returns sessions ordered by last update time, descending.
345    pub fn list_recent(&self, limit: usize) -> Result<Vec<SessionInfo>, IndexError> {
346        let guard = self.get_or_create_index()?;
347        let index = guard.as_ref().expect("index just created");
348        index.list_recent(limit)
349    }
350
351    /// Update the search index for a session.
352    ///
353    /// If the index has not been initialized, this is a no-op.
354    pub fn update_index(&self, session: &Session) -> Result<(), IndexError> {
355        let mut guard = self.get_or_create_index()?;
356        let index = guard.as_mut().expect("index just created");
357        index.index_session(session)
358    }
359
360    /// Checkpoint the session index WAL and truncate it where possible.
361    pub fn checkpoint_index(&self) -> Result<(), IndexError> {
362        let guard = self.get_or_create_index()?;
363        let index = guard.as_ref().expect("index just created");
364        index.checkpoint_truncate()
365    }
366
367    /// Vacuum the session index database.
368    pub fn vacuum_index(&self) -> Result<(), IndexError> {
369        let guard = self.get_or_create_index()?;
370        let index = guard.as_ref().expect("index just created");
371        index.vacuum()
372    }
373
374    /// Return forks originating from the given session ID.
375    pub fn get_forks(&self, session_id: &str) -> Result<Vec<ForkInfo>, IndexError> {
376        let guard = self.get_or_create_index()?;
377        let index = guard.as_ref().expect("index just created");
378        index.get_forks(session_id)
379    }
380
381    #[allow(clippy::collapsible_if)]
382    pub fn reconcile_index(&self) -> Result<usize, IndexError> {
383        let mut guard = self.get_or_create_index()?;
384        let index = guard.as_mut().expect("index just created");
385
386        let mut fixed = 0usize;
387
388        let indexed_ids: std::collections::HashSet<String> =
389            index.list_all_session_ids()?.into_iter().collect();
390
391        let mut on_disk_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
392
393        if self.sessions_dir.exists() {
394            for ws_entry in fs::read_dir(&self.sessions_dir)? {
395                let ws_entry = ws_entry?;
396                if !ws_entry.file_type()?.is_dir() {
397                    continue;
398                }
399                let ws_dir = ws_entry.path();
400                let workspace_root = workspace_root_from_dir_name(
401                    &ws_dir.file_name().unwrap_or_default().to_string_lossy(),
402                );
403
404                for file_entry in fs::read_dir(&ws_dir)? {
405                    let file_entry = file_entry?;
406                    let path = file_entry.path();
407                    if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
408                        continue;
409                    }
410                    let stem = match path.file_stem().and_then(|s| s.to_str()) {
411                        Some(s) => s.to_string(),
412                        None => continue,
413                    };
414                    on_disk_ids.insert(stem.clone());
415
416                    let existing = index.get_session_info(&stem)?;
417                    let (msg_count, _) = scan_file(&path).unwrap_or((0, String::new()));
418                    let needs_reindex = match &existing {
419                        None => true,
420                        Some(info) => info.message_count != msg_count,
421                    };
422                    if needs_reindex && Uuid::parse_str(&stem).is_ok() {
423                        if let Ok(id) = Uuid::parse_str(&stem) {
424                            let project = ws_dir
425                                .file_name()
426                                .and_then(|n| n.to_str())
427                                .unwrap_or("unknown")
428                                .to_string();
429                            let mut session =
430                                Session::new(id, project, workspace_root.to_string(), path.clone());
431                            if let Ok(entries) = session.read_entries() {
432                                if let Some(branch) =
433                                    session.branches.get_mut(&session.current_branch)
434                                {
435                                    branch.entries = entries;
436                                }
437                            }
438                            index.index_session(&session)?;
439                            fixed += 1;
440                        }
441                    }
442                }
443            }
444        }
445
446        for orphan_id in indexed_ids.difference(&on_disk_ids) {
447            index.delete_session(orphan_id)?;
448            fixed += 1;
449        }
450
451        Ok(fixed)
452    }
453
454    #[allow(clippy::collapsible_if)]
455    pub fn delete_session(&self, id: &Uuid) -> Result<(), SessionError> {
456        let file_path = self.find_session_file(id)?;
457        if file_path.exists() {
458            fs::remove_file(&file_path)?;
459        }
460        if let Ok(mut guard) = self.get_or_create_index() {
461            if let Some(index) = guard.as_mut() {
462                let _ = index.delete_session(&id.to_string());
463            }
464        }
465        Ok(())
466    }
467
468    /// Return sessions that would be removed by `policy` without deleting files.
469    pub fn cleanup_candidates(
470        &self,
471        policy: &SessionCleanupPolicy,
472    ) -> Result<Vec<SessionCleanupCandidate>, SessionError> {
473        let mut by_workspace = self.collect_cleanup_sessions(policy)?;
474        let protected: std::collections::HashSet<Uuid> =
475            policy.protected_session_ids.iter().copied().collect();
476        let cutoff = policy
477            .max_age_days
478            .map(|days| Utc::now() - Duration::days(days.max(0)));
479
480        let mut candidates = Vec::new();
481        for (workspace_root, sessions) in by_workspace.iter_mut() {
482            sessions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp).then_with(|| a.id.cmp(&b.id)));
483
484            for session in sessions.iter() {
485                if protected.contains(&session.id) {
486                    continue;
487                }
488                if let Some(cutoff) = cutoff
489                    && session.timestamp < cutoff
490                {
491                    candidates.push(SessionCleanupCandidate {
492                        id: session.id,
493                        workspace_root: workspace_root.clone(),
494                        file_path: session.file_path.clone(),
495                        size_bytes: session.size_bytes,
496                        timestamp: session.timestamp,
497                        reason: format!(
498                            "older than {} day(s)",
499                            policy.max_age_days.unwrap_or_default().max(0)
500                        ),
501                    });
502                }
503            }
504
505            if let Some(max_sessions) = policy.max_sessions_per_workspace {
506                let mut unprotected: Vec<_> = sessions
507                    .iter()
508                    .filter(|session| !protected.contains(&session.id))
509                    .collect();
510                unprotected
511                    .sort_by(|a, b| b.timestamp.cmp(&a.timestamp).then_with(|| a.id.cmp(&b.id)));
512                for session in unprotected.into_iter().skip(max_sessions) {
513                    if candidates
514                        .iter()
515                        .any(|candidate| candidate.id == session.id)
516                    {
517                        continue;
518                    }
519                    candidates.push(SessionCleanupCandidate {
520                        id: session.id,
521                        workspace_root: workspace_root.clone(),
522                        file_path: session.file_path.clone(),
523                        size_bytes: session.size_bytes,
524                        timestamp: session.timestamp,
525                        reason: format!("exceeds max_sessions_per_workspace={max_sessions}"),
526                    });
527                }
528            }
529        }
530
531        candidates.sort_by(|a, b| a.timestamp.cmp(&b.timestamp).then_with(|| a.id.cmp(&b.id)));
532        Ok(candidates)
533    }
534
535    /// Apply a cleanup policy by deleting selected sessions and index rows.
536    ///
537    /// This is an explicit maintenance operation. It never removes IDs listed in
538    /// `protected_session_ids`, even if callers accidentally include an active
539    /// session in an otherwise matching policy.
540    pub fn apply_cleanup(
541        &self,
542        policy: &SessionCleanupPolicy,
543    ) -> Result<SessionCleanupReport, SessionError> {
544        let candidates = self.cleanup_candidates(policy)?;
545        let mut report = SessionCleanupReport {
546            candidates,
547            removed: 0,
548            bytes_removed: 0,
549        };
550
551        for candidate in &report.candidates {
552            if candidate.file_path.exists() {
553                fs::remove_file(&candidate.file_path)?;
554                report.removed += 1;
555                report.bytes_removed += candidate.size_bytes;
556            }
557
558            if let Ok(mut guard) = self.get_or_create_index()
559                && let Some(index) = guard.as_mut()
560            {
561                let _ = index.delete_session(&candidate.id.to_string());
562            }
563        }
564
565        Ok(report)
566    }
567
568    #[allow(clippy::collapsible_if)]
569    fn find_session_file(&self, id: &Uuid) -> Result<PathBuf, SessionError> {
570        if self.sessions_dir.exists() {
571            for ws_entry in fs::read_dir(&self.sessions_dir)? {
572                let ws_entry = ws_entry?;
573                if !ws_entry.file_type()?.is_dir() {
574                    continue;
575                }
576                let candidate = ws_entry.path().join(format!("{id}.jsonl"));
577                if candidate.exists() {
578                    return Ok(candidate);
579                }
580            }
581        }
582        Err(SessionError::SessionNotFound(*id))
583    }
584
585    fn collect_cleanup_sessions(
586        &self,
587        policy: &SessionCleanupPolicy,
588    ) -> Result<std::collections::HashMap<String, Vec<CleanupSession>>, SessionError> {
589        let mut by_workspace: std::collections::HashMap<String, Vec<CleanupSession>> =
590            std::collections::HashMap::new();
591
592        if !self.sessions_dir.exists() {
593            return Ok(by_workspace);
594        }
595
596        if let Some(target) = &policy.workspace_root {
597            let workspace_dir = self.sessions_dir.join(workspace_dir_name(target));
598            if workspace_dir.exists() {
599                Self::collect_cleanup_workspace(target, &workspace_dir, &mut by_workspace)?;
600            }
601            return Ok(by_workspace);
602        }
603
604        for ws_entry in fs::read_dir(&self.sessions_dir)? {
605            let ws_entry = ws_entry?;
606            if !ws_entry.file_type()?.is_dir() {
607                continue;
608            }
609            let ws_dir = ws_entry.path();
610            let workspace_root = workspace_root_from_dir_name(
611                &ws_dir.file_name().unwrap_or_default().to_string_lossy(),
612            );
613            Self::collect_cleanup_workspace(&workspace_root, &ws_dir, &mut by_workspace)?;
614        }
615
616        Ok(by_workspace)
617    }
618
619    fn collect_cleanup_workspace(
620        workspace_root: &str,
621        workspace_dir: &Path,
622        by_workspace: &mut std::collections::HashMap<String, Vec<CleanupSession>>,
623    ) -> Result<(), SessionError> {
624        for file_entry in fs::read_dir(workspace_dir)? {
625            let file_entry = file_entry?;
626            let path = file_entry.path();
627            if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
628                continue;
629            }
630            let Some(id) = path
631                .file_stem()
632                .and_then(|s| s.to_str())
633                .and_then(|s| Uuid::parse_str(s).ok())
634            else {
635                continue;
636            };
637            let metadata = fs::metadata(&path)?;
638            let timestamp = metadata
639                .modified()
640                .ok()
641                .map(DateTime::<Utc>::from)
642                .unwrap_or_else(Utc::now);
643            by_workspace
644                .entry(workspace_root.to_string())
645                .or_default()
646                .push(CleanupSession {
647                    id,
648                    file_path: path,
649                    size_bytes: metadata.len(),
650                    timestamp,
651                });
652        }
653
654        Ok(())
655    }
656}
657
658#[derive(Debug, Clone)]
659struct CleanupSession {
660    id: Uuid,
661    file_path: PathBuf,
662    size_bytes: u64,
663    timestamp: DateTime<Utc>,
664}
665
666impl Default for SessionManager {
667    fn default() -> Self {
668        let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
669        Self {
670            sessions_dir: PathBuf::from(home).join(".talos").join("sessions"),
671            index: Arc::new(Mutex::new(None)),
672        }
673    }
674}