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#[derive(Debug, Clone, Default)]
20pub struct SessionCleanupPolicy {
21 pub workspace_root: Option<String>,
23 pub max_sessions_per_workspace: Option<usize>,
25 pub max_age_days: Option<i64>,
27 pub protected_session_ids: Vec<Uuid>,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct SessionCleanupCandidate {
34 pub id: Uuid,
36 pub workspace_root: String,
38 pub file_path: PathBuf,
40 pub size_bytes: u64,
42 pub timestamp: DateTime<Utc>,
44 pub reason: String,
46}
47
48#[derive(Debug, Clone, Default, PartialEq, Eq)]
50pub struct SessionCleanupReport {
51 pub candidates: Vec<SessionCleanupCandidate>,
53 pub removed: usize,
55 pub bytes_removed: u64,
57}
58
59pub 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 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 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 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 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 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 pub fn session_exists(&self, id: &Uuid) -> bool {
164 self.get_session(id).is_ok()
165 }
166
167 pub fn read_session(&self, id: &Uuid) -> Result<Vec<crate::SessionEntry>, SessionError> {
169 self.get_session(id)?.read_entries()
170 }
171
172 pub fn session_size(&self, id: &Uuid) -> Result<u64, SessionError> {
174 Ok(fs::metadata(self.find_session_file(id)?)?.len())
175 }
176
177 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
891fn home_dir_from_env() -> Result<String, SessionError> {
898 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}