ta_changeset/
interactive_session_store.rs1use std::fs;
7use std::path::PathBuf;
8
9use uuid::Uuid;
10
11use crate::session_channel::{InteractiveSession, InteractiveSessionState};
12use crate::ChangeSetError;
13
14pub struct InteractiveSessionStore {
16 sessions_dir: PathBuf,
17}
18
19impl InteractiveSessionStore {
20 pub fn new(sessions_dir: PathBuf) -> Result<Self, ChangeSetError> {
22 fs::create_dir_all(&sessions_dir)?;
23 Ok(Self { sessions_dir })
24 }
25
26 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 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 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 pub fn list_alive(&self) -> Result<Vec<InteractiveSession>, ChangeSetError> {
73 Ok(self.list()?.into_iter().filter(|s| s.is_alive()).collect())
74 }
75
76 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 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 pub fn exists(&self, session_id: Uuid) -> bool {
98 self.session_path(session_id).exists()
99 }
100
101 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}