agent_tui/daemon/
session.rs

1use std::collections::HashMap;
2use std::collections::VecDeque;
3use std::fs::{self, File, OpenOptions};
4use std::io::BufReader;
5use std::io::BufWriter;
6use std::os::unix::io::AsRawFd;
7use std::path::PathBuf;
8use std::sync::Arc;
9use std::sync::Mutex;
10use std::sync::RwLock;
11use std::time::Duration;
12use std::time::Instant;
13
14use tracing::warn;
15
16use chrono::DateTime;
17use chrono::Utc;
18use serde::Deserialize;
19use serde::Serialize;
20use uuid::Uuid;
21
22use crate::common::mutex_lock_or_recover;
23use crate::common::rwlock_read_or_recover;
24use crate::common::rwlock_write_or_recover;
25use crate::core::Element;
26use crate::terminal::CursorPosition;
27use crate::terminal::PtyHandle;
28use crate::terminal::key_to_escape_sequence;
29
30use super::pty_session::PtySession;
31use crate::daemon::terminal_state::TerminalState;
32
33pub use crate::daemon::domain::session_types::ErrorEntry;
34pub use crate::daemon::domain::session_types::RecordingFrame;
35pub use crate::daemon::domain::session_types::RecordingStatus;
36pub use crate::daemon::domain::session_types::SessionId;
37pub use crate::daemon::domain::session_types::SessionInfo;
38pub use crate::daemon::domain::session_types::TraceEntry;
39pub use crate::daemon::error::SessionError;
40
41const MAX_RECORDING_FRAMES: usize = 1000;
42const MAX_TRACE_ENTRIES: usize = 500;
43const MAX_ERROR_ENTRIES: usize = 500;
44
45/// Generate a new unique session ID.
46///
47/// This is the infrastructure-level ID generation that uses UUID.
48/// Domain layer SessionId only holds the string value.
49pub fn generate_session_id() -> SessionId {
50    SessionId::new(Uuid::new_v4().to_string()[..8].to_string())
51}
52
53fn get_last_n<T: Clone>(queue: &VecDeque<T>, count: usize) -> Vec<T> {
54    let start = queue.len().saturating_sub(count);
55    queue.iter().skip(start).cloned().collect()
56}
57
58fn push_bounded<T>(queue: &mut VecDeque<T>, item: T, max_size: usize) {
59    if queue.len() >= max_size {
60        queue.pop_front();
61    }
62    queue.push_back(item);
63}
64
65struct RecordingState {
66    is_recording: bool,
67    start_time: Instant,
68    frames: VecDeque<RecordingFrame>,
69}
70
71impl RecordingState {
72    fn new() -> Self {
73        Self {
74            is_recording: false,
75            start_time: Instant::now(),
76            frames: VecDeque::new(),
77        }
78    }
79}
80
81struct TraceState {
82    is_tracing: bool,
83    start_time: Instant,
84    entries: VecDeque<TraceEntry>,
85}
86
87impl TraceState {
88    fn new() -> Self {
89        Self {
90            is_tracing: false,
91            start_time: Instant::now(),
92            entries: VecDeque::new(),
93        }
94    }
95}
96
97struct ErrorState {
98    entries: VecDeque<ErrorEntry>,
99}
100
101impl ErrorState {
102    fn new() -> Self {
103        Self {
104            entries: VecDeque::new(),
105        }
106    }
107}
108
109#[derive(Clone, Copy)]
110enum ModifierKey {
111    Ctrl,
112    Alt,
113    Shift,
114    Meta,
115}
116
117impl ModifierKey {
118    fn from_str(key: &str) -> Option<Self> {
119        match key.to_lowercase().as_str() {
120            "ctrl" | "control" => Some(Self::Ctrl),
121            "alt" => Some(Self::Alt),
122            "shift" => Some(Self::Shift),
123            "meta" | "cmd" | "command" | "win" | "super" => Some(Self::Meta),
124            _ => None,
125        }
126    }
127}
128
129#[derive(Default)]
130struct ModifierState {
131    ctrl: bool,
132    alt: bool,
133    shift: bool,
134    meta: bool,
135}
136
137impl ModifierState {
138    fn set(&mut self, key: ModifierKey, value: bool) {
139        match key {
140            ModifierKey::Ctrl => self.ctrl = value,
141            ModifierKey::Alt => self.alt = value,
142            ModifierKey::Shift => self.shift = value,
143            ModifierKey::Meta => self.meta = value,
144        }
145    }
146}
147
148pub struct Session {
149    pub id: SessionId,
150    pub command: String,
151    pub created_at: DateTime<Utc>,
152    pty: PtySession,
153    terminal: TerminalState,
154    recording: RecordingState,
155    trace: TraceState,
156    held_modifiers: ModifierState,
157    errors: ErrorState,
158}
159
160impl Session {
161    fn new(id: SessionId, command: String, pty: PtyHandle, cols: u16, rows: u16) -> Self {
162        Self {
163            id,
164            command,
165            created_at: Utc::now(),
166            pty: PtySession::new(pty),
167            terminal: TerminalState::new(cols, rows),
168            recording: RecordingState::new(),
169            trace: TraceState::new(),
170            held_modifiers: ModifierState::default(),
171            errors: ErrorState::new(),
172        }
173    }
174
175    pub fn pid(&self) -> Option<u32> {
176        self.pty.pid()
177    }
178
179    pub fn is_running(&mut self) -> bool {
180        self.pty.is_running()
181    }
182
183    pub fn size(&self) -> (u16, u16) {
184        self.terminal.size()
185    }
186
187    pub fn update(&mut self) -> Result<(), SessionError> {
188        let mut buf = [0u8; 4096];
189
190        loop {
191            match self.pty.try_read(&mut buf, 10) {
192                Ok(0) => break,
193                Ok(n) => {
194                    self.terminal.process(&buf[..n]);
195                }
196                Err(e) => {
197                    let err_str = e.to_string();
198                    if err_str.contains("Resource temporarily unavailable")
199                        || err_str.contains("EAGAIN")
200                        || err_str.contains("EWOULDBLOCK")
201                    {
202                        break;
203                    }
204
205                    return Err(e);
206                }
207            }
208        }
209
210        Ok(())
211    }
212
213    pub fn screen_text(&self) -> String {
214        self.terminal.screen_text()
215    }
216
217    pub fn cursor(&self) -> CursorPosition {
218        self.terminal.cursor()
219    }
220
221    pub fn detect_elements(&mut self) -> &[Element] {
222        let cursor = self.terminal.cursor();
223        self.terminal.detect_elements(&cursor)
224    }
225
226    pub fn cached_elements(&self) -> &[Element] {
227        self.terminal.cached_elements()
228    }
229
230    pub fn find_element(&self, element_ref: &str) -> Option<&Element> {
231        self.terminal.find_element(element_ref)
232    }
233
234    pub fn keystroke(&self, key: &str) -> Result<(), SessionError> {
235        let seq =
236            key_to_escape_sequence(key).ok_or_else(|| SessionError::InvalidKey(key.to_string()))?;
237        self.pty.write(&seq)?;
238        Ok(())
239    }
240
241    pub fn keydown(&mut self, key: &str) -> Result<(), SessionError> {
242        let modifier = ModifierKey::from_str(key).ok_or_else(|| {
243            SessionError::InvalidKey(format!(
244                "{}. Only modifier keys (Ctrl, Alt, Shift, Meta) can be held",
245                key
246            ))
247        })?;
248        self.held_modifiers.set(modifier, true);
249        Ok(())
250    }
251
252    pub fn keyup(&mut self, key: &str) -> Result<(), SessionError> {
253        let modifier = ModifierKey::from_str(key).ok_or_else(|| {
254            SessionError::InvalidKey(format!(
255                "{}. Only modifier keys (Ctrl, Alt, Shift, Meta) can be released",
256                key
257            ))
258        })?;
259        self.held_modifiers.set(modifier, false);
260        Ok(())
261    }
262
263    pub fn type_text(&self, text: &str) -> Result<(), SessionError> {
264        self.pty.write_str(text)?;
265        Ok(())
266    }
267
268    pub fn click(&mut self, element_ref: &str) -> Result<(), SessionError> {
269        self.update()?;
270        self.detect_elements();
271
272        let element = self
273            .find_element(element_ref)
274            .ok_or_else(|| SessionError::ElementNotFound(element_ref.to_string()))?;
275
276        match element.element_type.as_str() {
277            "checkbox" | "radio" => {
278                self.pty.write(b" ")?;
279            }
280            _ => {
281                self.pty.write(b"\r")?;
282            }
283        }
284
285        Ok(())
286    }
287
288    pub fn resize(&mut self, cols: u16, rows: u16) -> Result<(), SessionError> {
289        self.pty.resize(cols, rows)?;
290        self.terminal.resize(cols, rows);
291        Ok(())
292    }
293
294    pub fn kill(&mut self) -> Result<(), SessionError> {
295        self.pty.kill()?;
296        Ok(())
297    }
298
299    pub fn pty_write(&self, data: &[u8]) -> Result<(), SessionError> {
300        self.pty.write(data)?;
301        Ok(())
302    }
303
304    pub fn pty_try_read(&self, buf: &mut [u8], timeout_ms: i32) -> Result<usize, SessionError> {
305        self.pty.try_read(buf, timeout_ms)
306    }
307
308    pub fn start_recording(&mut self) {
309        self.recording.is_recording = true;
310        self.recording.start_time = Instant::now();
311        self.recording.frames.clear();
312
313        let screen = self.terminal.screen_text();
314        push_bounded(
315            &mut self.recording.frames,
316            RecordingFrame {
317                timestamp_ms: 0,
318                screen,
319            },
320            MAX_RECORDING_FRAMES,
321        );
322    }
323
324    pub fn stop_recording(&mut self) -> Vec<RecordingFrame> {
325        self.recording.is_recording = false;
326        std::mem::take(&mut self.recording.frames)
327            .into_iter()
328            .collect()
329    }
330
331    pub fn add_recording_frame(&mut self, screen: String) {
332        if !self.recording.is_recording {
333            return;
334        }
335        let timestamp_ms = self.recording.start_time.elapsed().as_millis() as u64;
336        push_bounded(
337            &mut self.recording.frames,
338            RecordingFrame {
339                timestamp_ms,
340                screen,
341            },
342            MAX_RECORDING_FRAMES,
343        );
344    }
345
346    pub fn recording_status(&self) -> RecordingStatus {
347        RecordingStatus {
348            is_recording: self.recording.is_recording,
349            frame_count: self.recording.frames.len(),
350            duration_ms: if self.recording.is_recording {
351                self.recording.start_time.elapsed().as_millis() as u64
352            } else {
353                0
354            },
355        }
356    }
357
358    pub fn start_trace(&mut self) {
359        self.trace.is_tracing = true;
360        self.trace.start_time = Instant::now();
361        self.trace.entries.clear();
362    }
363
364    pub fn stop_trace(&mut self) {
365        self.trace.is_tracing = false;
366    }
367
368    pub fn is_tracing(&self) -> bool {
369        self.trace.is_tracing
370    }
371
372    pub fn get_trace_entries(&self, count: usize) -> Vec<TraceEntry> {
373        get_last_n(&self.trace.entries, count)
374    }
375
376    pub fn add_trace_entry(&mut self, action: String, details: Option<String>) {
377        if !self.trace.is_tracing {
378            return;
379        }
380        let timestamp_ms = self.trace.start_time.elapsed().as_millis() as u64;
381        push_bounded(
382            &mut self.trace.entries,
383            TraceEntry {
384                timestamp_ms,
385                action,
386                details,
387            },
388            MAX_TRACE_ENTRIES,
389        );
390    }
391
392    pub fn get_errors(&self, count: usize) -> Vec<ErrorEntry> {
393        get_last_n(&self.errors.entries, count)
394    }
395
396    pub fn add_error(&mut self, message: String, source: String) {
397        let timestamp = Utc::now().to_rfc3339();
398        push_bounded(
399            &mut self.errors.entries,
400            ErrorEntry {
401                timestamp,
402                message,
403                source,
404            },
405            MAX_ERROR_ENTRIES,
406        );
407    }
408
409    pub fn error_count(&self) -> usize {
410        self.errors.entries.len()
411    }
412
413    pub fn clear_errors(&mut self) {
414        self.errors.entries.clear();
415    }
416
417    pub fn clear_console(&mut self) {
418        self.terminal.clear();
419    }
420
421    pub fn analyze_screen(&self) -> Vec<crate::core::Component> {
422        let cursor = self.terminal.cursor();
423        self.terminal.analyze_screen(&cursor)
424    }
425}
426
427impl crate::daemon::repository::SessionOps for Session {
428    fn update(&mut self) -> Result<(), SessionError> {
429        Session::update(self)
430    }
431
432    fn screen_text(&self) -> String {
433        Session::screen_text(self)
434    }
435
436    fn detect_elements(&mut self) -> &[Element] {
437        Session::detect_elements(self)
438    }
439
440    fn find_element(&self, element_ref: &str) -> Option<&Element> {
441        Session::find_element(self, element_ref)
442    }
443
444    fn pty_write(&mut self, data: &[u8]) -> Result<(), SessionError> {
445        Session::pty_write(self, data)
446    }
447
448    fn analyze_screen(&self) -> Vec<crate::core::Component> {
449        Session::analyze_screen(self)
450    }
451}
452
453/// Lock ordering: sessions → active_session → Session mutex
454///
455/// When acquiring multiple locks, always follow this order to prevent deadlocks.
456pub struct SessionManager {
457    sessions: RwLock<HashMap<SessionId, Arc<Mutex<Session>>>>,
458    active_session: RwLock<Option<SessionId>>,
459    persistence: SessionPersistence,
460    max_sessions: usize,
461}
462
463pub const DEFAULT_MAX_SESSIONS: usize = 16;
464
465impl Default for SessionManager {
466    fn default() -> Self {
467        Self::new()
468    }
469}
470
471impl SessionManager {
472    pub fn new() -> Self {
473        Self::with_max_sessions(DEFAULT_MAX_SESSIONS)
474    }
475
476    pub fn with_max_sessions(max_sessions: usize) -> Self {
477        let persistence = SessionPersistence::new();
478        if let Err(e) = persistence.cleanup_stale_sessions() {
479            warn!(error = %e, "Failed to cleanup stale sessions");
480        }
481
482        Self {
483            sessions: RwLock::new(HashMap::new()),
484            active_session: RwLock::new(None),
485            persistence,
486            max_sessions,
487        }
488    }
489
490    #[allow(clippy::too_many_arguments)]
491    pub fn spawn(
492        &self,
493        command: &str,
494        args: &[String],
495        cwd: Option<&str>,
496        env: Option<&HashMap<String, String>>,
497        session_id: Option<String>,
498        cols: u16,
499        rows: u16,
500    ) -> Result<(SessionId, u32), SessionError> {
501        {
502            let sessions = rwlock_read_or_recover(&self.sessions);
503            if sessions.len() >= self.max_sessions {
504                return Err(SessionError::LimitReached(self.max_sessions));
505            }
506        }
507
508        let id = session_id
509            .map(SessionId::new)
510            .unwrap_or_else(generate_session_id);
511
512        let pty = PtyHandle::spawn(command, args, cwd, env, cols, rows)?;
513        let pid = pty.pid().unwrap_or(0);
514
515        let session = Session::new(id.clone(), command.to_string(), pty, cols, rows);
516        let session = Arc::new(Mutex::new(session));
517
518        let created_at = Utc::now().to_rfc3339();
519        let persisted = PersistedSession {
520            id: id.to_string(),
521            command: command.to_string(),
522            pid,
523            created_at,
524            cols,
525            rows,
526        };
527
528        {
529            let mut sessions = rwlock_write_or_recover(&self.sessions);
530            sessions.insert(id.clone(), session);
531        }
532
533        {
534            let mut active = rwlock_write_or_recover(&self.active_session);
535            *active = Some(id.clone());
536        }
537
538        if let Err(e) = self.persistence.add_session(persisted) {
539            warn!(error = %e, "Failed to persist session metadata");
540        }
541
542        Ok((id, pid))
543    }
544
545    pub fn get(&self, session_id: &str) -> Result<Arc<Mutex<Session>>, SessionError> {
546        let sessions = rwlock_read_or_recover(&self.sessions);
547        let id = SessionId::new(session_id);
548        sessions
549            .get(&id)
550            .cloned()
551            .ok_or_else(|| SessionError::NotFound(session_id.to_string()))
552    }
553
554    pub fn active(&self) -> Result<Arc<Mutex<Session>>, SessionError> {
555        let active_id = {
556            let active = rwlock_read_or_recover(&self.active_session);
557            active.clone()
558        };
559
560        match active_id {
561            Some(id) => self.get(id.as_str()),
562            None => Err(SessionError::NoActiveSession),
563        }
564    }
565
566    pub fn resolve(&self, session_id: Option<&str>) -> Result<Arc<Mutex<Session>>, SessionError> {
567        match session_id {
568            Some(id) => self.get(id),
569            None => self.active(),
570        }
571    }
572
573    pub fn set_active(&self, session_id: &str) -> Result<(), SessionError> {
574        let id = SessionId::new(session_id);
575        let sessions = rwlock_read_or_recover(&self.sessions);
576        if !sessions.contains_key(&id) {
577            return Err(SessionError::NotFound(session_id.to_string()));
578        }
579        let mut active = rwlock_write_or_recover(&self.active_session);
580        *active = Some(id);
581        Ok(())
582    }
583
584    pub fn list(&self) -> Vec<SessionInfo> {
585        use super::lock_helpers::acquire_session_lock;
586
587        let session_refs: Vec<(SessionId, Arc<Mutex<Session>>)> = {
588            let sessions = rwlock_read_or_recover(&self.sessions);
589            sessions
590                .iter()
591                .map(|(id, session)| (id.clone(), Arc::clone(session)))
592                .collect()
593        };
594
595        session_refs
596            .into_iter()
597            .map(
598                |(id, session)| match acquire_session_lock(&session, Duration::from_millis(100)) {
599                    Some(mut sess) => SessionInfo {
600                        id: id.clone(),
601                        command: sess.command.clone(),
602                        pid: sess.pid().unwrap_or(0),
603                        running: sess.is_running(),
604                        created_at: sess.created_at.to_rfc3339(),
605                        size: sess.size(),
606                    },
607                    None => SessionInfo {
608                        id: id.clone(),
609                        command: "(locked)".to_string(),
610                        pid: 0,
611                        running: true,
612                        created_at: "".to_string(),
613                        size: (80, 24),
614                    },
615                },
616            )
617            .collect()
618    }
619
620    pub fn kill(&self, session_id: &str) -> Result<(), SessionError> {
621        let id = SessionId::new(session_id);
622
623        let session = {
624            let mut sessions = rwlock_write_or_recover(&self.sessions);
625            let mut active = rwlock_write_or_recover(&self.active_session);
626
627            let session = sessions
628                .remove(&id)
629                .ok_or_else(|| SessionError::NotFound(session_id.to_string()))?;
630
631            if active.as_ref() == Some(&id) {
632                *active = None;
633            }
634
635            session
636        };
637
638        {
639            let mut sess = mutex_lock_or_recover(&session);
640            sess.kill()?;
641        }
642
643        if let Err(e) = self.persistence.remove_session(session_id) {
644            warn!(session_id = session_id, error = %e, "Failed to remove session from persistence");
645        }
646
647        Ok(())
648    }
649
650    pub fn session_count(&self) -> usize {
651        rwlock_read_or_recover(&self.sessions).len()
652    }
653
654    pub fn active_session_id(&self) -> Option<SessionId> {
655        rwlock_read_or_recover(&self.active_session).clone()
656    }
657}
658
659/// Serializable session data for persistence.
660///
661/// Uses String for session ID to avoid framework dependencies
662/// in domain types. Converted to/from SessionId at boundaries.
663#[derive(Debug, Clone, Serialize, Deserialize)]
664pub struct PersistedSession {
665    pub id: String,
666    pub command: String,
667    pub pid: u32,
668    pub created_at: String,
669    pub cols: u16,
670    pub rows: u16,
671}
672
673pub struct SessionPersistence {
674    path: PathBuf,
675    lock_path: PathBuf,
676}
677
678impl SessionPersistence {
679    pub fn new() -> Self {
680        let path = Self::sessions_file_path();
681        let lock_path = path.with_extension("json.lock");
682        Self { path, lock_path }
683    }
684
685    fn sessions_file_path() -> PathBuf {
686        let home = std::env::var("HOME")
687            .map(PathBuf::from)
688            .unwrap_or_else(|_| PathBuf::from("/tmp"));
689        let dir = home.join(".agent-tui");
690        dir.join("sessions.json")
691    }
692
693    fn io_to_persistence(operation: &str, e: std::io::Error) -> SessionError {
694        SessionError::Persistence {
695            operation: operation.to_string(),
696            reason: e.to_string(),
697        }
698    }
699
700    fn ensure_dir(&self) -> Result<(), SessionError> {
701        if let Some(parent) = self.path.parent() {
702            fs::create_dir_all(parent).map_err(|e| {
703                Self::io_to_persistence(
704                    "create_dir",
705                    std::io::Error::new(
706                        e.kind(),
707                        format!("Failed to create directory '{}': {}", parent.display(), e),
708                    ),
709                )
710            })?;
711        }
712        Ok(())
713    }
714
715    fn acquire_lock(&self) -> Result<File, SessionError> {
716        const PERSISTENCE_LOCK_TIMEOUT: Duration = Duration::from_secs(5);
717
718        self.ensure_dir()?;
719        let lock_file = OpenOptions::new()
720            .write(true)
721            .create(true)
722            .truncate(false)
723            .open(&self.lock_path)
724            .map_err(|e| Self::io_to_persistence("open_lock", e))?;
725
726        let fd = lock_file.as_raw_fd();
727        let start = Instant::now();
728        let mut backoff = Duration::from_millis(1);
729
730        loop {
731            let result = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
732            if result == 0 {
733                return Ok(lock_file);
734            }
735
736            let err = std::io::Error::last_os_error();
737            if err.raw_os_error() != Some(libc::EWOULDBLOCK)
738                && err.raw_os_error() != Some(libc::EAGAIN)
739            {
740                return Err(Self::io_to_persistence("flock", err));
741            }
742
743            if start.elapsed() > PERSISTENCE_LOCK_TIMEOUT {
744                return Err(SessionError::Persistence {
745                    operation: "acquire_lock".to_string(),
746                    reason: "lock acquisition timed out after 5 seconds".to_string(),
747                });
748            }
749
750            std::thread::sleep(backoff);
751            backoff = (backoff * 2).min(Duration::from_millis(100));
752        }
753    }
754
755    fn load_unlocked(&self) -> Vec<PersistedSession> {
756        if !self.path.exists() {
757            return Vec::new();
758        }
759
760        match File::open(&self.path) {
761            Ok(file) => {
762                let reader = BufReader::new(file);
763                match serde_json::from_reader(reader) {
764                    Ok(sessions) => sessions,
765                    Err(e) => {
766                        warn!(
767                            path = %self.path.display(),
768                            error = %e,
769                            "Sessions file corrupted, starting with empty session list"
770                        );
771                        Vec::new()
772                    }
773                }
774            }
775            Err(e) => {
776                warn!(
777                    path = %self.path.display(),
778                    error = %e,
779                    "Failed to open sessions file"
780                );
781                Vec::new()
782            }
783        }
784    }
785
786    fn save_unlocked(&self, sessions: &[PersistedSession]) -> Result<(), SessionError> {
787        let temp_path = self.path.with_extension("json.tmp");
788
789        let file = File::create(&temp_path).map_err(|e| SessionError::Persistence {
790            operation: "create_temp".to_string(),
791            reason: format!(
792                "Failed to create temp file '{}': {}",
793                temp_path.display(),
794                e
795            ),
796        })?;
797        let writer = BufWriter::new(file);
798        serde_json::to_writer_pretty(writer, sessions).map_err(|e| SessionError::Persistence {
799            operation: "write_json".to_string(),
800            reason: format!(
801                "Failed to write sessions to '{}': {}",
802                temp_path.display(),
803                e
804            ),
805        })?;
806
807        fs::rename(&temp_path, &self.path).map_err(|e| SessionError::Persistence {
808            operation: "rename".to_string(),
809            reason: format!(
810                "Failed to rename '{}' to '{}': {}",
811                temp_path.display(),
812                self.path.display(),
813                e
814            ),
815        })?;
816
817        Ok(())
818    }
819
820    pub fn load(&self) -> Vec<PersistedSession> {
821        match self.acquire_lock() {
822            Ok(_lock) => self.load_unlocked(),
823            Err(e) => {
824                warn!(error = %e, "Failed to acquire lock for loading sessions");
825                self.load_unlocked()
826            }
827        }
828    }
829
830    pub fn save(&self, sessions: &[PersistedSession]) -> Result<(), SessionError> {
831        let _lock = self.acquire_lock()?;
832        self.save_unlocked(sessions)
833    }
834
835    pub fn add_session(&self, session: PersistedSession) -> Result<(), SessionError> {
836        let _lock = self.acquire_lock()?;
837        let mut sessions = self.load_unlocked();
838
839        sessions.retain(|s| s.id != session.id);
840        sessions.push(session);
841
842        self.save_unlocked(&sessions)
843    }
844
845    pub fn remove_session(&self, session_id: &str) -> Result<(), SessionError> {
846        let _lock = self.acquire_lock()?;
847        let mut sessions = self.load_unlocked();
848        sessions.retain(|s| s.id.as_str() != session_id);
849        self.save_unlocked(&sessions)
850    }
851
852    pub fn cleanup_stale_sessions(&self) -> Result<usize, SessionError> {
853        let _lock = self.acquire_lock()?;
854        let sessions = self.load_unlocked();
855        let mut cleaned = 0;
856
857        let active_sessions: Vec<PersistedSession> = sessions
858            .into_iter()
859            .filter(|s| {
860                let running = is_process_running(s.pid);
861                if !running {
862                    cleaned += 1;
863                }
864                running
865            })
866            .collect();
867
868        self.save_unlocked(&active_sessions)?;
869        Ok(cleaned)
870    }
871}
872
873impl Default for SessionPersistence {
874    fn default() -> Self {
875        Self::new()
876    }
877}
878
879fn is_process_running(pid: u32) -> bool {
880    unsafe { libc::kill(pid as i32, 0) == 0 }
881}
882
883impl From<&SessionInfo> for PersistedSession {
884    fn from(info: &SessionInfo) -> Self {
885        PersistedSession {
886            id: info.id.to_string(),
887            command: info.command.clone(),
888            pid: info.pid,
889            created_at: info.created_at.clone(),
890            cols: info.size.0,
891            rows: info.size.1,
892        }
893    }
894}
895
896#[cfg(test)]
897mod tests {
898    use super::*;
899
900    #[test]
901    fn test_persisted_session_serialization() {
902        let session = PersistedSession {
903            id: "test123".to_string(),
904            command: "bash".to_string(),
905            pid: 12345,
906            created_at: "2024-01-01T00:00:00Z".to_string(),
907            cols: 80,
908            rows: 24,
909        };
910
911        let json = serde_json::to_string(&session).unwrap();
912        let parsed: PersistedSession = serde_json::from_str(&json).unwrap();
913
914        assert_eq!(parsed.id, session.id);
915        assert_eq!(parsed.command, session.command);
916        assert_eq!(parsed.pid, session.pid);
917    }
918
919    #[test]
920    fn test_is_process_running() {
921        let current_pid = std::process::id();
922        assert!(is_process_running(current_pid));
923
924        assert!(!is_process_running(999999999));
925    }
926}