Skip to main content

ta_changeset/
interactive_session_store.rs

1// interactive_session_store.rs — Persistent storage for InteractiveSession instances.
2//
3// Stores interactive sessions as JSON files in .ta/interactive_sessions/<session-id>.json
4// Enables multi-invocation interactive workflows where humans can pause and resume sessions.
5
6use std::fs;
7use std::path::PathBuf;
8
9use uuid::Uuid;
10
11use crate::session_channel::{InteractiveSession, InteractiveSessionState};
12use crate::ChangeSetError;
13
14/// Storage backend for InteractiveSession instances.
15pub struct InteractiveSessionStore {
16    sessions_dir: PathBuf,
17}
18
19impl InteractiveSessionStore {
20    /// Create a new store with the given sessions directory.
21    pub fn new(sessions_dir: PathBuf) -> Result<Self, ChangeSetError> {
22        fs::create_dir_all(&sessions_dir)?;
23        Ok(Self { sessions_dir })
24    }
25
26    /// Save an interactive session to disk.
27    pub fn save(&self, session: &InteractiveSession) -> Result<(), ChangeSetError> {
28        let path = self.session_path(session.session_id);
29        let json = serde_json::to_string_pretty(session)?;
30        fs::write(&path, json)?;
31        Ok(())
32    }
33
34    /// Load an interactive session from disk by ID.
35    pub fn load(&self, session_id: Uuid) -> Result<InteractiveSession, ChangeSetError> {
36        let path = self.session_path(session_id);
37        if !path.exists() {
38            return Err(ChangeSetError::InvalidData(format!(
39                "Interactive session not found: {}",
40                session_id
41            )));
42        }
43        let json = fs::read_to_string(&path)?;
44        let session = serde_json::from_str(&json)?;
45        Ok(session)
46    }
47
48    /// List all interactive sessions, sorted by most recently updated.
49    pub fn list(&self) -> Result<Vec<InteractiveSession>, ChangeSetError> {
50        let mut sessions = Vec::new();
51        if !self.sessions_dir.exists() {
52            return Ok(sessions);
53        }
54
55        for entry in fs::read_dir(&self.sessions_dir)? {
56            let entry = entry?;
57            let path = entry.path();
58            if path.extension().is_some_and(|ext| ext == "json") {
59                if let Ok(json) = fs::read_to_string(&path) {
60                    if let Ok(session) = serde_json::from_str::<InteractiveSession>(&json) {
61                        sessions.push(session);
62                    }
63                }
64            }
65        }
66
67        sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
68        Ok(sessions)
69    }
70
71    /// List only active/paused (alive) sessions.
72    pub fn list_alive(&self) -> Result<Vec<InteractiveSession>, ChangeSetError> {
73        Ok(self.list()?.into_iter().filter(|s| s.is_alive()).collect())
74    }
75
76    /// Find the active interactive session for a goal (if any).
77    pub fn find_active_for_goal(
78        &self,
79        goal_id: Uuid,
80    ) -> Result<Option<InteractiveSession>, ChangeSetError> {
81        let sessions = self.list()?;
82        Ok(sessions
83            .into_iter()
84            .find(|s| s.goal_id == goal_id && s.state == InteractiveSessionState::Active))
85    }
86
87    /// Delete an interactive session from disk.
88    pub fn delete(&self, session_id: Uuid) -> Result<(), ChangeSetError> {
89        let path = self.session_path(session_id);
90        if path.exists() {
91            fs::remove_file(&path)?;
92        }
93        Ok(())
94    }
95
96    /// Check if a session exists.
97    pub fn exists(&self, session_id: Uuid) -> bool {
98        self.session_path(session_id).exists()
99    }
100
101    /// Get the file path for a session ID.
102    fn session_path(&self, session_id: Uuid) -> PathBuf {
103        self.sessions_dir.join(format!("{}.json", session_id))
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use tempfile::TempDir;
111
112    #[test]
113    fn save_and_load_session() {
114        let temp = TempDir::new().unwrap();
115        let store = InteractiveSessionStore::new(temp.path().to_path_buf()).unwrap();
116
117        let mut session = InteractiveSession::new(
118            Uuid::new_v4(),
119            "cli:tty0".to_string(),
120            "claude-code".to_string(),
121        );
122        session.log_message("human", "Test guidance");
123
124        store.save(&session).unwrap();
125
126        let loaded = store.load(session.session_id).unwrap();
127        assert_eq!(loaded.session_id, session.session_id);
128        assert_eq!(loaded.channel_id, "cli:tty0");
129        assert_eq!(loaded.messages.len(), 1);
130    }
131
132    #[test]
133    fn list_sessions_returns_all() {
134        let temp = TempDir::new().unwrap();
135        let store = InteractiveSessionStore::new(temp.path().to_path_buf()).unwrap();
136
137        let session1 = InteractiveSession::new(
138            Uuid::new_v4(),
139            "cli:tty0".to_string(),
140            "claude-code".to_string(),
141        );
142        let session2 =
143            InteractiveSession::new(Uuid::new_v4(), "cli:tty1".to_string(), "codex".to_string());
144
145        store.save(&session1).unwrap();
146        store.save(&session2).unwrap();
147
148        let sessions = store.list().unwrap();
149        assert_eq!(sessions.len(), 2);
150    }
151
152    #[test]
153    fn list_alive_filters_completed() {
154        let temp = TempDir::new().unwrap();
155        let store = InteractiveSessionStore::new(temp.path().to_path_buf()).unwrap();
156
157        let session1 = InteractiveSession::new(
158            Uuid::new_v4(),
159            "cli:tty0".to_string(),
160            "claude-code".to_string(),
161        );
162        let mut session2 =
163            InteractiveSession::new(Uuid::new_v4(), "cli:tty1".to_string(), "codex".to_string());
164        session2
165            .transition(InteractiveSessionState::Completed)
166            .unwrap();
167
168        store.save(&session1).unwrap();
169        store.save(&session2).unwrap();
170
171        let alive = store.list_alive().unwrap();
172        assert_eq!(alive.len(), 1);
173        assert_eq!(alive[0].session_id, session1.session_id);
174    }
175
176    #[test]
177    fn find_active_for_goal_returns_matching() {
178        let temp = TempDir::new().unwrap();
179        let store = InteractiveSessionStore::new(temp.path().to_path_buf()).unwrap();
180
181        let goal_id = Uuid::new_v4();
182        let session =
183            InteractiveSession::new(goal_id, "cli:tty0".to_string(), "claude-code".to_string());
184        store.save(&session).unwrap();
185
186        let found = store.find_active_for_goal(goal_id).unwrap();
187        assert!(found.is_some());
188        assert_eq!(found.unwrap().goal_id, goal_id);
189    }
190
191    #[test]
192    fn find_active_for_goal_returns_none_when_completed() {
193        let temp = TempDir::new().unwrap();
194        let store = InteractiveSessionStore::new(temp.path().to_path_buf()).unwrap();
195
196        let goal_id = Uuid::new_v4();
197        let mut session =
198            InteractiveSession::new(goal_id, "cli:tty0".to_string(), "claude-code".to_string());
199        session
200            .transition(InteractiveSessionState::Completed)
201            .unwrap();
202        store.save(&session).unwrap();
203
204        let found = store.find_active_for_goal(goal_id).unwrap();
205        assert!(found.is_none());
206    }
207
208    #[test]
209    fn delete_removes_session() {
210        let temp = TempDir::new().unwrap();
211        let store = InteractiveSessionStore::new(temp.path().to_path_buf()).unwrap();
212
213        let session = InteractiveSession::new(
214            Uuid::new_v4(),
215            "cli:tty0".to_string(),
216            "claude-code".to_string(),
217        );
218        let session_id = session.session_id;
219
220        store.save(&session).unwrap();
221        assert!(store.exists(session_id));
222
223        store.delete(session_id).unwrap();
224        assert!(!store.exists(session_id));
225    }
226
227    #[test]
228    fn exists_returns_false_for_nonexistent() {
229        let temp = TempDir::new().unwrap();
230        let store = InteractiveSessionStore::new(temp.path().to_path_buf()).unwrap();
231        assert!(!store.exists(Uuid::new_v4()));
232    }
233
234    #[test]
235    fn load_nonexistent_returns_error() {
236        let temp = TempDir::new().unwrap();
237        let store = InteractiveSessionStore::new(temp.path().to_path_buf()).unwrap();
238        let result = store.load(Uuid::new_v4());
239        assert!(result.is_err());
240    }
241}