1use astrid_core::SessionId;
13use astrid_core::dirs::AstridHome;
14use std::path::{Path, PathBuf};
15use tracing::{debug, info};
16
17use crate::error::{RuntimeError, RuntimeResult};
18use crate::session::{AgentSession, SerializableSession};
19
20pub struct SessionStore {
25 sessions_dir: PathBuf,
27 dir_ensured: std::sync::atomic::AtomicBool,
29}
30
31impl SessionStore {
32 #[must_use]
37 pub fn new(sessions_dir: impl AsRef<Path>) -> Self {
38 let sessions_dir = sessions_dir.as_ref().to_path_buf();
39 let dir_exists = sessions_dir.is_dir();
40 Self {
41 sessions_dir,
42 dir_ensured: std::sync::atomic::AtomicBool::new(dir_exists),
43 }
44 }
45
46 #[must_use]
51 pub fn from_home(home: &AstridHome) -> Self {
52 Self::new(home.sessions_dir())
53 }
54
55 fn ensure_dir(&self) -> RuntimeResult<()> {
57 if self.dir_ensured.load(std::sync::atomic::Ordering::Relaxed) {
58 return Ok(());
59 }
60 std::fs::create_dir_all(&self.sessions_dir)?;
61 #[cfg(unix)]
62 {
63 use std::os::unix::fs::PermissionsExt;
64 let perms = std::fs::Permissions::from_mode(0o700);
66 if let Some(parent) = self.sessions_dir.parent() {
67 let _ = std::fs::set_permissions(parent, perms.clone());
68 }
69 let _ = std::fs::set_permissions(&self.sessions_dir, perms);
70 }
71 self.dir_ensured
72 .store(true, std::sync::atomic::Ordering::Relaxed);
73 Ok(())
74 }
75
76 fn session_path(&self, id: &SessionId) -> PathBuf {
78 self.sessions_dir.join(format!("{}.json", id.0))
79 }
80
81 pub fn save(&self, session: &AgentSession) -> RuntimeResult<()> {
90 self.ensure_dir()?;
91
92 let path = self.session_path(&session.id);
93 let serializable = SerializableSession::from(session);
94
95 let json = serde_json::to_string_pretty(&serializable)
96 .map_err(|e| RuntimeError::SerializationError(e.to_string()))?;
97
98 let temp_path = path.with_extension("json.tmp");
100 std::fs::write(&temp_path, &json)?;
101 std::fs::rename(&temp_path, &path).inspect_err(|_| {
102 let _ = std::fs::remove_file(&temp_path);
104 })?;
105
106 debug!(session_id = %session.id, path = ?path, "Session saved");
107
108 Ok(())
109 }
110
111 pub fn load(&self, id: &SessionId) -> RuntimeResult<Option<AgentSession>> {
117 let path = self.session_path(id);
118
119 if !path.exists() {
120 return Ok(None);
121 }
122
123 let json = std::fs::read_to_string(&path)?;
124 let serializable: SerializableSession = serde_json::from_str(&json)
125 .map_err(|e| RuntimeError::SerializationError(e.to_string()))?;
126
127 let session = serializable.to_session();
128
129 debug!(session_id = %id, "Session loaded");
130
131 Ok(Some(session))
132 }
133
134 pub fn load_by_str(&self, id: &str) -> RuntimeResult<Option<AgentSession>> {
140 let uuid =
141 uuid::Uuid::parse_str(id).map_err(|e| RuntimeError::StorageError(e.to_string()))?;
142 self.load(&SessionId::from_uuid(uuid))
143 }
144
145 pub fn delete(&self, id: &SessionId) -> RuntimeResult<()> {
151 let path = self.session_path(id);
152
153 if path.exists() {
154 std::fs::remove_file(&path)?;
155 info!(session_id = %id, "Session deleted");
156 }
157
158 Ok(())
159 }
160
161 pub fn list(&self) -> RuntimeResult<Vec<SessionId>> {
169 if !self.sessions_dir.is_dir() {
170 return Ok(Vec::new());
171 }
172
173 let mut sessions = Vec::new();
174
175 for entry in std::fs::read_dir(&self.sessions_dir)? {
176 let entry = entry?;
177 let path = entry.path();
178
179 if path.extension().is_some_and(|e| e == "json")
180 && let Some(stem) = path.file_stem()
181 && let Some(stem_str) = stem.to_str()
182 && let Ok(uuid) = uuid::Uuid::parse_str(stem_str)
183 {
184 sessions.push(SessionId::from_uuid(uuid));
185 }
186 }
187
188 sessions.sort_by(|a, b| {
190 let path_a = self.session_path(a);
191 let path_b = self.session_path(b);
192
193 let time_a = std::fs::metadata(&path_a)
194 .and_then(|m| m.modified())
195 .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
196 let time_b = std::fs::metadata(&path_b)
197 .and_then(|m| m.modified())
198 .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
199
200 time_b.cmp(&time_a)
201 });
202
203 Ok(sessions)
204 }
205
206 pub fn list_with_metadata(&self) -> RuntimeResult<Vec<SessionSummary>> {
212 let ids = self.list()?;
213 let mut summaries = Vec::new();
214
215 for id in ids {
216 if let Ok(Some(session)) = self.load(&id) {
217 summaries.push(SessionSummary {
218 id: id.0.to_string(),
219 title: session.metadata.title.clone(),
220 created_at: session.created_at,
221 message_count: session.messages.len(),
222 token_count: session.token_count,
223 workspace_path: session.workspace_path.clone(),
224 });
225 }
226 }
227
228 Ok(summaries)
229 }
230
231 pub fn most_recent(&self) -> RuntimeResult<Option<AgentSession>> {
237 let ids = self.list()?;
238 if let Some(id) = ids.first() {
239 self.load(id)
240 } else {
241 Ok(None)
242 }
243 }
244
245 pub fn list_for_workspace(&self, workspace: &Path) -> RuntimeResult<Vec<SessionSummary>> {
253 let all = self.list_with_metadata()?;
254 Ok(all
255 .into_iter()
256 .filter(|s| s.workspace_path.as_deref().is_some_and(|p| p == workspace))
257 .collect())
258 }
259
260 pub fn cleanup_old(&self, max_age_days: i64) -> RuntimeResult<usize> {
266 #[allow(clippy::arithmetic_side_effects)]
268 let cutoff = chrono::Utc::now() - chrono::Duration::days(max_age_days);
269 let mut removed = 0usize;
270
271 for id in self.list()? {
272 if let Ok(Some(session)) = self.load(&id)
273 && session.created_at < cutoff
274 && self.delete(&id).is_ok()
275 {
276 removed = removed.saturating_add(1);
277 }
278 }
279
280 Ok(removed)
281 }
282}
283
284#[derive(Debug, Clone)]
286pub struct SessionSummary {
287 pub id: String,
289 pub title: Option<String>,
291 pub created_at: chrono::DateTime<chrono::Utc>,
293 pub message_count: usize,
295 pub token_count: usize,
297 pub workspace_path: Option<PathBuf>,
299}
300
301impl SessionSummary {
302 #[must_use]
304 pub fn display_title(&self) -> String {
305 self.title.clone().unwrap_or_else(|| {
306 let short_id = &self.id[..8];
307 format!("Session {short_id}")
308 })
309 }
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315
316 #[test]
317 fn test_session_store() {
318 let temp_dir = tempfile::tempdir().unwrap();
319 let store = SessionStore::new(temp_dir.path());
320
321 let session = AgentSession::new([0u8; 8], "Test");
322
323 store.save(&session).unwrap();
325
326 let loaded = store.load(&session.id).unwrap().unwrap();
328 assert_eq!(loaded.system_prompt, session.system_prompt);
329
330 let ids = store.list().unwrap();
332 assert_eq!(ids.len(), 1);
333
334 store.delete(&session.id).unwrap();
336 assert!(store.load(&session.id).unwrap().is_none());
337 }
338
339 #[test]
340 fn test_session_store_lazy_dir_creation() {
341 let temp_dir = tempfile::tempdir().unwrap();
342 let sessions_path = temp_dir.path().join("lazy_sessions");
343
344 let store = SessionStore::new(&sessions_path);
345
346 assert!(!sessions_path.exists());
348
349 let ids = store.list().unwrap();
351 assert!(ids.is_empty());
352
353 let session = AgentSession::new([0u8; 8], "Test");
355 store.save(&session).unwrap();
356 assert!(sessions_path.exists());
357 }
358
359 #[test]
360 fn test_session_store_atomic_write() {
361 let temp_dir = tempfile::tempdir().unwrap();
362 let store = SessionStore::new(temp_dir.path());
363
364 let session = AgentSession::new([0u8; 8], "Test");
365 store.save(&session).unwrap();
366
367 let temp_path = temp_dir.path().join(format!("{}.json.tmp", session.id.0));
369 assert!(!temp_path.exists());
370
371 let real_path = temp_dir.path().join(format!("{}.json", session.id.0));
373 assert!(real_path.exists());
374 }
375
376 #[test]
377 fn test_session_store_from_home() {
378 let temp_dir = tempfile::tempdir().unwrap();
379 let home = AstridHome::from_path(temp_dir.path());
380 let store = SessionStore::from_home(&home);
381
382 let session = AgentSession::new([0u8; 8], "Test");
383 store.save(&session).unwrap();
384
385 let expected = temp_dir
387 .path()
388 .join("sessions")
389 .join(format!("{}.json", session.id.0));
390 assert!(expected.exists());
391 }
392}