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#[derive(Debug, Clone, Default)]
13pub struct SessionCleanupPolicy {
14 pub workspace_root: Option<String>,
16 pub max_sessions_per_workspace: Option<usize>,
18 pub max_age_days: Option<i64>,
20 pub protected_session_ids: Vec<Uuid>,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct SessionCleanupCandidate {
27 pub id: Uuid,
29 pub workspace_root: String,
31 pub file_path: PathBuf,
33 pub size_bytes: u64,
35 pub timestamp: DateTime<Utc>,
37 pub reason: String,
39}
40
41#[derive(Debug, Clone, Default, PartialEq, Eq)]
43pub struct SessionCleanupReport {
44 pub candidates: Vec<SessionCleanupCandidate>,
46 pub removed: usize,
48 pub bytes_removed: u64,
50}
51
52#[derive(Debug, Clone)]
54pub struct SessionManager {
55 pub(crate) sessions_dir: PathBuf,
56 index: Arc<Mutex<Option<SessionIndex>>>,
57}
58
59impl SessionManager {
60 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 let _ = manager.reconcile_index();
73 Ok(manager)
74 }
75
76 pub fn with_dir(sessions_dir: PathBuf) -> Self {
78 Self {
79 sessions_dir,
80 index: Arc::new(Mutex::new(None)),
81 }
82 }
83
84 #[must_use]
86 pub fn sessions_dir(&self) -> &Path {
87 &self.sessions_dir
88 }
89
90 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}