Skip to main content

agent_tui/
session.rs

1//! Session management for agent-tui
2//!
3//! This module manages PTY sessions, providing a unified interface
4//! for spawning processes, taking snapshots, and interacting with
5//! terminal applications.
6
7use crate::detection::{Element, ElementDetector};
8use crate::pty::{key_to_escape_sequence, PtyError, PtyHandle};
9use crate::sync_utils::{mutex_lock_or_recover, rwlock_read_or_recover, rwlock_write_or_recover};
10use crate::terminal::{CursorPosition, ScreenBuffer, VirtualTerminal};
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use std::collections::{HashMap, VecDeque};
14use std::fs;
15use std::io::{BufReader, BufWriter};
16use std::path::PathBuf;
17use std::sync::{Arc, Mutex, RwLock};
18use std::time::Instant;
19use thiserror::Error;
20use uuid::Uuid;
21
22// ============================================================================
23// SessionId Newtype
24// ============================================================================
25
26/// A unique identifier for a session.
27///
28/// This newtype wrapper provides type safety by distinguishing session IDs
29/// from other strings in the codebase, preventing accidental misuse.
30#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
31#[serde(transparent)]
32pub struct SessionId(String);
33
34impl SessionId {
35    /// Create a new SessionId from a string
36    pub fn new(id: impl Into<String>) -> Self {
37        Self(id.into())
38    }
39
40    /// Generate a new random session ID (first 8 chars of UUID)
41    pub fn generate() -> Self {
42        Self(Uuid::new_v4().to_string()[..8].to_string())
43    }
44
45    /// Get the underlying string
46    pub fn as_str(&self) -> &str {
47        &self.0
48    }
49}
50
51impl std::fmt::Display for SessionId {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        write!(f, "{}", self.0)
54    }
55}
56
57impl AsRef<str> for SessionId {
58    fn as_ref(&self) -> &str {
59        &self.0
60    }
61}
62
63impl From<String> for SessionId {
64    fn from(s: String) -> Self {
65        Self(s)
66    }
67}
68
69impl From<&str> for SessionId {
70    fn from(s: &str) -> Self {
71        Self(s.to_string())
72    }
73}
74
75// ============================================================================
76// Recording Types
77// ============================================================================
78
79/// A single frame captured during recording
80#[derive(Clone, Debug)]
81pub struct RecordingFrame {
82    pub timestamp_ms: u64,
83    pub screen: String,
84}
85
86/// Recording state for a session
87struct RecordingState {
88    is_recording: bool,
89    start_time: Instant,
90    frames: Vec<RecordingFrame>,
91}
92
93impl RecordingState {
94    fn new() -> Self {
95        Self {
96            is_recording: false,
97            start_time: Instant::now(),
98            frames: Vec::new(),
99        }
100    }
101}
102
103/// A trace entry recording an action
104#[derive(Clone, Debug)]
105pub struct TraceEntry {
106    pub timestamp_ms: u64,
107    pub action: String,
108    pub details: Option<String>,
109}
110
111/// Trace state for a session
112struct TraceState {
113    is_tracing: bool,
114    start_time: Instant,
115    entries: VecDeque<TraceEntry>,
116    max_entries: usize,
117}
118
119impl TraceState {
120    fn new() -> Self {
121        Self {
122            is_tracing: false,
123            start_time: Instant::now(),
124            entries: VecDeque::new(),
125            max_entries: 1000,
126        }
127    }
128}
129
130/// Recording status summary
131pub struct RecordingStatus {
132    pub is_recording: bool,
133    pub frame_count: usize,
134    pub duration_ms: u64,
135}
136
137/// An error entry captured from the session
138#[derive(Clone, Debug)]
139pub struct ErrorEntry {
140    pub timestamp: String,
141    pub message: String,
142    pub source: String,
143}
144
145/// Error state for a session
146struct ErrorState {
147    entries: VecDeque<ErrorEntry>,
148    max_entries: usize,
149}
150
151impl ErrorState {
152    fn new() -> Self {
153        Self {
154            entries: VecDeque::new(),
155            max_entries: 500,
156        }
157    }
158}
159
160#[derive(Error, Debug)]
161pub enum SessionError {
162    #[error("Session not found: {0}")]
163    NotFound(String),
164    #[error("No active session")]
165    NoActiveSession,
166    #[error("PTY error: {0}")]
167    Pty(#[from] PtyError),
168    #[error("Element not found: {0}")]
169    ElementNotFound(String),
170    #[error("Invalid key: {0}")]
171    InvalidKey(String),
172    #[error("Timeout waiting for condition")]
173    Timeout,
174}
175
176/// Held modifier keys state for keydown/keyup sequences
177#[derive(Default)]
178struct ModifierState {
179    ctrl: bool,
180    alt: bool,
181    shift: bool,
182    meta: bool,
183}
184
185/// Session state
186pub struct Session {
187    pub id: SessionId,
188    pub command: String,
189    pub created_at: DateTime<Utc>,
190    pty: PtyHandle,
191    terminal: VirtualTerminal,
192    detector: ElementDetector,
193    /// Cached elements from last detection
194    cached_elements: Vec<Element>,
195    /// Recording state
196    recording: RecordingState,
197    /// Trace state
198    trace: TraceState,
199    /// Held modifier keys for keydown/keyup sequences
200    held_modifiers: ModifierState,
201    /// Error state
202    errors: ErrorState,
203}
204
205impl Session {
206    fn new(id: SessionId, command: String, pty: PtyHandle, cols: u16, rows: u16) -> Self {
207        Self {
208            id,
209            command,
210            created_at: Utc::now(),
211            pty,
212            terminal: VirtualTerminal::new(cols, rows),
213            detector: ElementDetector::new(),
214            cached_elements: Vec::new(),
215            recording: RecordingState::new(),
216            trace: TraceState::new(),
217            held_modifiers: ModifierState::default(),
218            errors: ErrorState::new(),
219        }
220    }
221
222    /// Get process ID
223    pub fn pid(&self) -> Option<u32> {
224        self.pty.pid()
225    }
226
227    /// Check if process is running
228    pub fn is_running(&mut self) -> bool {
229        self.pty.is_running()
230    }
231
232    /// Get terminal size
233    pub fn size(&self) -> (u16, u16) {
234        self.terminal.size()
235    }
236
237    /// Process any pending PTY output and update the terminal
238    pub fn update(&mut self) -> Result<(), SessionError> {
239        let mut buf = [0u8; 4096];
240
241        // Use non-blocking poll-based read to avoid holding locks during blocking I/O
242        loop {
243            match self.pty.try_read(&mut buf, 10) {
244                Ok(0) => break, // No data available or EOF
245                Ok(n) => {
246                    self.terminal.process(&buf[..n]);
247                }
248                Err(_) => break,
249            }
250        }
251
252        Ok(())
253    }
254
255    /// Get screen text
256    pub fn screen_text(&self) -> String {
257        self.terminal.screen_text()
258    }
259
260    /// Get screen buffer with style data
261    pub fn screen_buffer(&self) -> ScreenBuffer {
262        self.terminal.screen_buffer()
263    }
264
265    /// Get cursor position
266    pub fn cursor(&self) -> CursorPosition {
267        self.terminal.cursor()
268    }
269
270    /// Detect elements and cache them
271    pub fn detect_elements(&mut self) -> &[Element] {
272        let screen_text = self.terminal.screen_text();
273        let screen_buffer = self.terminal.screen_buffer();
274        self.cached_elements = self.detector.detect(&screen_text, Some(&screen_buffer));
275        &self.cached_elements
276    }
277
278    /// Get cached elements
279    pub fn elements(&self) -> &[Element] {
280        &self.cached_elements
281    }
282
283    /// Find element by ref
284    pub fn find_element(&self, element_ref: &str) -> Option<&Element> {
285        self.detector
286            .find_by_ref(&self.cached_elements, element_ref)
287    }
288
289    /// Send a keystroke
290    pub fn keystroke(&self, key: &str) -> Result<(), SessionError> {
291        let seq =
292            key_to_escape_sequence(key).ok_or_else(|| SessionError::InvalidKey(key.to_string()))?;
293        self.pty.write(&seq)?;
294        Ok(())
295    }
296
297    /// Hold a modifier key down
298    ///
299    /// This tracks the modifier state for subsequent keystrokes. In PTY terminals,
300    /// modifier keys don't have separate "down" events - they're combined with
301    /// other keys. This method allows sequences like:
302    ///   keydown("Shift") -> click(@item1) -> click(@item2) -> keyup("Shift")
303    pub fn keydown(&mut self, key: &str) -> Result<(), SessionError> {
304        match key.to_lowercase().as_str() {
305            "ctrl" | "control" => self.held_modifiers.ctrl = true,
306            "alt" => self.held_modifiers.alt = true,
307            "shift" => self.held_modifiers.shift = true,
308            "meta" | "cmd" | "command" | "win" | "super" => self.held_modifiers.meta = true,
309            _ => {
310                return Err(SessionError::InvalidKey(format!(
311                    "{}. Only modifier keys (Ctrl, Alt, Shift, Meta) can be held",
312                    key
313                )))
314            }
315        }
316        Ok(())
317    }
318
319    /// Release a held modifier key
320    pub fn keyup(&mut self, key: &str) -> Result<(), SessionError> {
321        match key.to_lowercase().as_str() {
322            "ctrl" | "control" => self.held_modifiers.ctrl = false,
323            "alt" => self.held_modifiers.alt = false,
324            "shift" => self.held_modifiers.shift = false,
325            "meta" | "cmd" | "command" | "win" | "super" => self.held_modifiers.meta = false,
326            _ => {
327                return Err(SessionError::InvalidKey(format!(
328                    "{}. Only modifier keys (Ctrl, Alt, Shift, Meta) can be released",
329                    key
330                )))
331            }
332        }
333        Ok(())
334    }
335
336    /// Type text
337    pub fn type_text(&self, text: &str) -> Result<(), SessionError> {
338        self.pty.write_str(text)?;
339        Ok(())
340    }
341
342    /// Click an element (move cursor and press Enter/Space)
343    pub fn click(&mut self, element_ref: &str) -> Result<(), SessionError> {
344        self.update()?;
345        self.detect_elements();
346
347        let element = self
348            .find_element(element_ref)
349            .ok_or_else(|| SessionError::ElementNotFound(element_ref.to_string()))?;
350
351        match element.element_type.as_str() {
352            "checkbox" | "radio" => {
353                self.pty.write(b" ")?; // Space to toggle
354            }
355            _ => {
356                self.pty.write(b"\r")?; // Enter to activate
357            }
358        }
359
360        Ok(())
361    }
362
363    /// Fill an input with a value
364    pub fn fill(&mut self, element_ref: &str, value: &str) -> Result<(), SessionError> {
365        self.update()?;
366        self.detect_elements();
367
368        let _element = self
369            .find_element(element_ref)
370            .ok_or_else(|| SessionError::ElementNotFound(element_ref.to_string()))?;
371
372        self.pty.write_str(value)?;
373
374        Ok(())
375    }
376
377    /// Resize the terminal
378    pub fn resize(&mut self, cols: u16, rows: u16) -> Result<(), SessionError> {
379        self.pty.resize(cols, rows)?;
380        self.terminal.resize(cols, rows);
381        Ok(())
382    }
383
384    /// Kill the session
385    pub fn kill(&mut self) -> Result<(), SessionError> {
386        self.pty.kill()?;
387        Ok(())
388    }
389
390    /// Write raw bytes to the PTY
391    pub fn pty_write(&self, data: &[u8]) -> Result<(), SessionError> {
392        self.pty.write(data)?;
393        Ok(())
394    }
395
396    /// Read from PTY with timeout (for attach mode)
397    pub fn pty_try_read(&self, buf: &mut [u8], timeout_ms: i32) -> Result<usize, SessionError> {
398        self.pty
399            .try_read(buf, timeout_ms)
400            .map_err(SessionError::Pty)
401    }
402
403    /// Get the PTY reader file descriptor (for polling in attach mode)
404    pub fn pty_reader_fd(&self) -> std::os::fd::RawFd {
405        self.pty.reader_fd()
406    }
407
408    /// Start recording
409    pub fn start_recording(&mut self) {
410        self.recording.is_recording = true;
411        self.recording.start_time = Instant::now();
412        self.recording.frames.clear();
413
414        let screen = self.terminal.screen_text();
415        self.recording.frames.push(RecordingFrame {
416            timestamp_ms: 0,
417            screen,
418        });
419    }
420
421    /// Stop recording and return captured frames
422    pub fn stop_recording(&mut self) -> Vec<RecordingFrame> {
423        self.recording.is_recording = false;
424        std::mem::take(&mut self.recording.frames)
425    }
426
427    /// Get recording status
428    pub fn recording_status(&self) -> RecordingStatus {
429        RecordingStatus {
430            is_recording: self.recording.is_recording,
431            frame_count: self.recording.frames.len(),
432            duration_ms: if self.recording.is_recording {
433                self.recording.start_time.elapsed().as_millis() as u64
434            } else {
435                0
436            },
437        }
438    }
439
440    /// Capture a recording frame if recording is active
441    pub fn capture_frame(&mut self) {
442        if self.recording.is_recording {
443            let screen = self.terminal.screen_text();
444            let timestamp_ms = self.recording.start_time.elapsed().as_millis() as u64;
445            self.recording.frames.push(RecordingFrame {
446                timestamp_ms,
447                screen,
448            });
449        }
450    }
451
452    /// Start tracing
453    pub fn start_trace(&mut self) {
454        self.trace.is_tracing = true;
455        self.trace.start_time = Instant::now();
456        self.trace.entries.clear();
457    }
458
459    /// Stop tracing
460    pub fn stop_trace(&mut self) {
461        self.trace.is_tracing = false;
462    }
463
464    /// Check if tracing is active
465    pub fn is_tracing(&self) -> bool {
466        self.trace.is_tracing
467    }
468
469    /// Add a trace entry
470    pub fn add_trace_entry(&mut self, action: &str, details: Option<&str>) {
471        if self.trace.is_tracing {
472            let timestamp_ms = self.trace.start_time.elapsed().as_millis() as u64;
473            self.trace.entries.push_back(TraceEntry {
474                timestamp_ms,
475                action: action.to_string(),
476                details: details.map(String::from),
477            });
478
479            while self.trace.entries.len() > self.trace.max_entries {
480                self.trace.entries.pop_front();
481            }
482        }
483    }
484
485    /// Get recent trace entries
486    pub fn get_trace_entries(&self, count: usize) -> Vec<TraceEntry> {
487        let len = self.trace.entries.len();
488        let start = len.saturating_sub(count);
489        self.trace.entries.iter().skip(start).cloned().collect()
490    }
491
492    /// Add an error entry
493    pub fn add_error(&mut self, message: &str, source: &str) {
494        let timestamp = Utc::now().to_rfc3339();
495        self.errors.entries.push_back(ErrorEntry {
496            timestamp,
497            message: message.to_string(),
498            source: source.to_string(),
499        });
500
501        while self.errors.entries.len() > self.errors.max_entries {
502            self.errors.entries.pop_front();
503        }
504    }
505
506    /// Get recent errors
507    pub fn get_errors(&self, count: usize) -> Vec<ErrorEntry> {
508        let len = self.errors.entries.len();
509        let start = len.saturating_sub(count);
510        self.errors.entries.iter().skip(start).cloned().collect()
511    }
512
513    /// Get total error count
514    pub fn error_count(&self) -> usize {
515        self.errors.entries.len()
516    }
517
518    /// Clear all errors
519    pub fn clear_errors(&mut self) {
520        self.errors.entries.clear();
521    }
522
523    /// Clear console (resets terminal screen buffer)
524    pub fn clear_console(&mut self) {
525        self.terminal.clear();
526    }
527}
528
529/// Session manager
530pub struct SessionManager {
531    sessions: RwLock<HashMap<SessionId, Arc<Mutex<Session>>>>,
532    active_session: RwLock<Option<SessionId>>,
533    start_time: Instant,
534    persistence: SessionPersistence,
535}
536
537impl Default for SessionManager {
538    fn default() -> Self {
539        Self::new()
540    }
541}
542
543impl SessionManager {
544    pub fn new() -> Self {
545        let persistence = SessionPersistence::new();
546        let _ = persistence.cleanup_stale_sessions();
547
548        Self {
549            sessions: RwLock::new(HashMap::new()),
550            active_session: RwLock::new(None),
551            start_time: Instant::now(),
552            persistence,
553        }
554    }
555
556    /// Spawn a new session
557    #[allow(clippy::too_many_arguments)]
558    pub fn spawn(
559        &self,
560        command: &str,
561        args: &[String],
562        cwd: Option<&str>,
563        env: Option<&HashMap<String, String>>,
564        session_id: Option<String>,
565        cols: u16,
566        rows: u16,
567    ) -> Result<(SessionId, u32), SessionError> {
568        let id = session_id
569            .map(SessionId::new)
570            .unwrap_or_else(SessionId::generate);
571
572        let pty = PtyHandle::spawn(command, args, cwd, env, cols, rows)?;
573        let pid = pty.pid().unwrap_or(0);
574
575        let session = Session::new(id.clone(), command.to_string(), pty, cols, rows);
576        let session = Arc::new(Mutex::new(session));
577
578        // Note: No background thread - PTY updates happen on-demand when client
579        // requests a snapshot or screen. This avoids lock contention.
580
581        let created_at = Utc::now().to_rfc3339();
582        let persisted = PersistedSession {
583            id: id.clone(),
584            command: command.to_string(),
585            pid,
586            created_at,
587            cols,
588            rows,
589        };
590
591        {
592            let mut sessions = rwlock_write_or_recover(&self.sessions);
593            sessions.insert(id.clone(), session);
594        }
595
596        {
597            let mut active = rwlock_write_or_recover(&self.active_session);
598            *active = Some(id.clone());
599        }
600
601        // Persist session metadata (warn on failure, don't fail spawn)
602        if let Err(e) = self.persistence.add_session(persisted) {
603            eprintln!("Warning: Failed to persist session metadata: {}", e);
604        }
605
606        Ok((id, pid))
607    }
608
609    /// Get a session by ID
610    pub fn get(&self, session_id: &str) -> Result<Arc<Mutex<Session>>, SessionError> {
611        let sessions = rwlock_read_or_recover(&self.sessions);
612        let id = SessionId::new(session_id);
613        sessions
614            .get(&id)
615            .cloned()
616            .ok_or_else(|| SessionError::NotFound(session_id.to_string()))
617    }
618
619    /// Get the active session
620    pub fn active(&self) -> Result<Arc<Mutex<Session>>, SessionError> {
621        let active_id = {
622            let active = rwlock_read_or_recover(&self.active_session);
623            active.clone()
624        };
625
626        match active_id {
627            Some(id) => self.get(id.as_str()),
628            None => Err(SessionError::NoActiveSession),
629        }
630    }
631
632    /// Get session, using provided ID or falling back to active session
633    pub fn resolve(&self, session_id: Option<&str>) -> Result<Arc<Mutex<Session>>, SessionError> {
634        match session_id {
635            Some(id) => self.get(id),
636            None => self.active(),
637        }
638    }
639
640    /// Set active session
641    pub fn set_active(&self, session_id: &str) -> Result<(), SessionError> {
642        let _ = self.get(session_id)?;
643
644        let mut active = rwlock_write_or_recover(&self.active_session);
645        *active = Some(SessionId::new(session_id));
646        Ok(())
647    }
648
649    /// List all sessions
650    /// Note: Uses try_lock to avoid blocking; sessions that can't be locked
651    /// are reported with status "busy" via the running field.
652    pub fn list(&self) -> Vec<SessionInfo> {
653        // Collect session refs under the read lock, then release it before
654        // attempting to lock individual sessions to prevent lock ordering issues
655        let session_refs: Vec<(SessionId, Arc<Mutex<Session>>)> = {
656            let sessions = rwlock_read_or_recover(&self.sessions);
657            sessions
658                .iter()
659                .map(|(id, session)| (id.clone(), Arc::clone(session)))
660                .collect()
661        };
662
663        let active_id = rwlock_read_or_recover(&self.active_session).clone();
664
665        session_refs
666            .into_iter()
667            .map(|(id, session)| {
668                // Use try_lock to avoid blocking if the session is busy
669                match session.try_lock() {
670                    Ok(mut sess) => SessionInfo {
671                        id: id.clone(),
672                        command: sess.command.clone(),
673                        pid: sess.pid().unwrap_or(0),
674                        running: sess.is_running(),
675                        created_at: sess.created_at.to_rfc3339(),
676                        size: sess.size(),
677                        is_active: active_id.as_ref() == Some(&id),
678                    },
679                    Err(_) => {
680                        // Session is busy - report with "busy" indicator
681                        // Note: running=true with command="busy" indicates lock contention
682                        SessionInfo {
683                            id: id.clone(),
684                            command: "(busy)".to_string(),
685                            pid: 0,
686                            running: true,
687                            created_at: "".to_string(),
688                            size: (80, 24),
689                            is_active: active_id.as_ref() == Some(&id),
690                        }
691                    }
692                }
693            })
694            .collect()
695    }
696
697    /// Kill a session
698    ///
699    /// Lock ordering: sessions.write() -> active_session.write() -> session.lock()
700    /// This order prevents deadlock with list() which acquires sessions.read() first.
701    pub fn kill(&self, session_id: &str) -> Result<(), SessionError> {
702        let id = SessionId::new(session_id);
703
704        // Step 1: Acquire sessions write lock and remove the session atomically
705        // This prevents deadlock by ensuring consistent lock ordering
706        let session = {
707            let mut sessions = rwlock_write_or_recover(&self.sessions);
708            let mut active = rwlock_write_or_recover(&self.active_session);
709
710            let session = sessions
711                .remove(&id)
712                .ok_or_else(|| SessionError::NotFound(session_id.to_string()))?;
713
714            if active.as_ref() == Some(&id) {
715                *active = None;
716            }
717
718            session
719            // Both locks released here
720        };
721
722        // Step 2: Now safe to kill - session is already removed from the map
723        // so no other thread can find it via list() or get()
724        {
725            let mut sess = mutex_lock_or_recover(&session);
726            sess.kill()?;
727        }
728
729        // Step 3: Remove from persistence (warn on failure, non-critical)
730        if let Err(e) = self.persistence.remove_session(session_id) {
731            eprintln!("Warning: Failed to remove session from persistence: {}", e);
732        }
733
734        Ok(())
735    }
736
737    /// Get persisted sessions from previous daemon runs
738    pub fn get_persisted_sessions(&self) -> Vec<PersistedSession> {
739        self.persistence.load()
740    }
741
742    /// Clean up stale persisted sessions
743    pub fn cleanup_persisted(&self) -> std::io::Result<usize> {
744        self.persistence.cleanup_stale_sessions()
745    }
746
747    /// Get uptime in milliseconds
748    pub fn uptime_ms(&self) -> u64 {
749        self.start_time.elapsed().as_millis() as u64
750    }
751
752    /// Get session count
753    pub fn session_count(&self) -> usize {
754        rwlock_read_or_recover(&self.sessions).len()
755    }
756
757    /// Get active session ID
758    pub fn active_session_id(&self) -> Option<SessionId> {
759        rwlock_read_or_recover(&self.active_session).clone()
760    }
761}
762
763/// Session info for listing
764#[derive(Debug, Clone)]
765pub struct SessionInfo {
766    pub id: SessionId,
767    pub command: String,
768    pub pid: u32,
769    pub running: bool,
770    pub created_at: String,
771    pub size: (u16, u16),
772    pub is_active: bool,
773}
774
775lazy_static::lazy_static! {
776    pub static ref SESSION_MANAGER: SessionManager = SessionManager::new();
777}
778
779/// Persisted session metadata
780#[derive(Debug, Clone, Serialize, Deserialize)]
781pub struct PersistedSession {
782    pub id: SessionId,
783    pub command: String,
784    pub pid: u32,
785    pub created_at: String,
786    pub cols: u16,
787    pub rows: u16,
788}
789
790/// Session persistence manager
791pub struct SessionPersistence {
792    path: PathBuf,
793}
794
795impl SessionPersistence {
796    /// Create a new persistence manager
797    pub fn new() -> Self {
798        let path = Self::sessions_file_path();
799        Self { path }
800    }
801
802    /// Get the path to the sessions file
803    fn sessions_file_path() -> PathBuf {
804        let home = std::env::var("HOME")
805            .map(PathBuf::from)
806            .unwrap_or_else(|_| PathBuf::from("/tmp"));
807        let dir = home.join(".agent-tui");
808        dir.join("sessions.json")
809    }
810
811    /// Ensure the directory exists
812    fn ensure_dir(&self) -> std::io::Result<()> {
813        if let Some(parent) = self.path.parent() {
814            fs::create_dir_all(parent).map_err(|e| {
815                std::io::Error::new(
816                    e.kind(),
817                    format!("Failed to create directory '{}': {}", parent.display(), e),
818                )
819            })?;
820        }
821        Ok(())
822    }
823
824    /// Load persisted sessions
825    pub fn load(&self) -> Vec<PersistedSession> {
826        if !self.path.exists() {
827            return Vec::new();
828        }
829
830        match fs::File::open(&self.path) {
831            Ok(file) => {
832                let reader = BufReader::new(file);
833                serde_json::from_reader(reader).unwrap_or_default()
834            }
835            Err(e) => {
836                eprintln!(
837                    "Warning: Failed to open sessions file '{}': {}",
838                    self.path.display(),
839                    e
840                );
841                Vec::new()
842            }
843        }
844    }
845
846    /// Save sessions to disk
847    pub fn save(&self, sessions: &[PersistedSession]) -> std::io::Result<()> {
848        self.ensure_dir()?;
849
850        let file = fs::File::create(&self.path).map_err(|e| {
851            std::io::Error::new(
852                e.kind(),
853                format!("Failed to create file '{}': {}", self.path.display(), e),
854            )
855        })?;
856        let writer = BufWriter::new(file);
857        serde_json::to_writer_pretty(writer, sessions).map_err(|e| {
858            std::io::Error::other(format!(
859                "Failed to write sessions to '{}': {}",
860                self.path.display(),
861                e
862            ))
863        })?;
864        Ok(())
865    }
866
867    /// Add a session to persistence
868    pub fn add_session(&self, session: PersistedSession) -> std::io::Result<()> {
869        let mut sessions = self.load();
870
871        sessions.retain(|s| s.id != session.id);
872        sessions.push(session);
873
874        self.save(&sessions)
875    }
876
877    /// Remove a session from persistence
878    pub fn remove_session(&self, session_id: &str) -> std::io::Result<()> {
879        let mut sessions = self.load();
880        sessions.retain(|s| s.id.as_str() != session_id);
881        self.save(&sessions)
882    }
883
884    /// Update a session in persistence
885    pub fn update_session(&self, session: PersistedSession) -> std::io::Result<()> {
886        let mut sessions = self.load();
887
888        if let Some(existing) = sessions.iter_mut().find(|s| s.id == session.id) {
889            *existing = session;
890        } else {
891            sessions.push(session);
892        }
893
894        self.save(&sessions)
895    }
896
897    /// Get a persisted session by ID
898    pub fn get_session(&self, session_id: &str) -> Option<PersistedSession> {
899        self.load()
900            .into_iter()
901            .find(|s| s.id.as_str() == session_id)
902    }
903
904    /// Clean up stale sessions (processes that are no longer running)
905    pub fn cleanup_stale_sessions(&self) -> std::io::Result<usize> {
906        let sessions = self.load();
907        let mut cleaned = 0;
908
909        let active_sessions: Vec<PersistedSession> = sessions
910            .into_iter()
911            .filter(|s| {
912                let running = is_process_running(s.pid);
913                if !running {
914                    cleaned += 1;
915                }
916                running
917            })
918            .collect();
919
920        self.save(&active_sessions)?;
921        Ok(cleaned)
922    }
923
924    /// Clear all persisted sessions
925    pub fn clear(&self) -> std::io::Result<()> {
926        if self.path.exists() {
927            fs::remove_file(&self.path).map_err(|e| {
928                std::io::Error::new(
929                    e.kind(),
930                    format!("Failed to remove file '{}': {}", self.path.display(), e),
931                )
932            })?;
933        }
934        Ok(())
935    }
936}
937
938impl Default for SessionPersistence {
939    fn default() -> Self {
940        Self::new()
941    }
942}
943
944/// Check if a process is still running
945fn is_process_running(pid: u32) -> bool {
946    // Use kill with signal 0 to check if process exists
947    unsafe { libc::kill(pid as i32, 0) == 0 }
948}
949
950/// Convert a SessionInfo to PersistedSession
951impl From<&SessionInfo> for PersistedSession {
952    fn from(info: &SessionInfo) -> Self {
953        PersistedSession {
954            id: info.id.clone(),
955            command: info.command.clone(),
956            pid: info.pid,
957            created_at: info.created_at.clone(),
958            cols: info.size.0,
959            rows: info.size.1,
960        }
961    }
962}
963
964#[cfg(test)]
965mod persistence_tests {
966    use super::*;
967
968    #[test]
969    fn test_persisted_session_serialization() {
970        let session = PersistedSession {
971            id: SessionId::new("test123"),
972            command: "bash".to_string(),
973            pid: 12345,
974            created_at: "2024-01-01T00:00:00Z".to_string(),
975            cols: 80,
976            rows: 24,
977        };
978
979        let json = serde_json::to_string(&session).unwrap();
980        let parsed: PersistedSession = serde_json::from_str(&json).unwrap();
981
982        assert_eq!(parsed.id, session.id);
983        assert_eq!(parsed.command, session.command);
984        assert_eq!(parsed.pid, session.pid);
985    }
986
987    #[test]
988    fn test_is_process_running() {
989        // Current process should be running
990        let current_pid = std::process::id();
991        assert!(is_process_running(current_pid));
992
993        // A very high PID unlikely to exist
994        assert!(!is_process_running(999999999));
995    }
996}