Skip to main content

astrid_runtime/
store.rs

1//! Session persistence.
2//!
3//! Stores and retrieves sessions from disk. Sessions live in
4//! `~/.astrid/sessions/` (the global home directory) and are linked to
5//! workspaces via workspace IDs stored in each session's JSON.
6//!
7//! # Crash Safety
8//!
9//! Writes use atomic write-to-tempfile + rename to prevent corruption if the
10//! process crashes mid-write.
11
12use 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
20/// Session store for persistence.
21///
22/// Directory creation is lazy — the sessions directory is only created on
23/// the first call to [`save`](Self::save), not at construction time.
24pub struct SessionStore {
25    /// Directory for session files.
26    sessions_dir: PathBuf,
27    /// Whether the directory has been ensured to exist.
28    dir_ensured: std::sync::atomic::AtomicBool,
29}
30
31impl SessionStore {
32    /// Create a new session store pointing at an explicit directory.
33    ///
34    /// The directory is **not** created immediately — it will be created
35    /// lazily on the first save.
36    #[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    /// Create a session store from an [`AstridHome`].
47    ///
48    /// Sessions will be stored in `~/.astrid/sessions/`.
49    /// The directory is created lazily on first save.
50    #[must_use]
51    pub fn from_home(home: &AstridHome) -> Self {
52        Self::new(home.sessions_dir())
53    }
54
55    /// Ensure the sessions directory exists (called lazily on first write).
56    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            // Ensure the sessions dir and its parent (.astrid/) are owner-only
65            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    /// Get the path for a session file.
77    fn session_path(&self, id: &SessionId) -> PathBuf {
78        self.sessions_dir.join(format!("{}.json", id.0))
79    }
80
81    /// Save a session atomically.
82    ///
83    /// Writes to a temporary file first, then renames. This prevents corruption
84    /// if the process crashes mid-write (session auto-saves after every turn).
85    ///
86    /// # Errors
87    ///
88    /// Returns an error if the session cannot be serialized or written to disk.
89    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        // Atomic write: write to temp file, then rename
99        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            // Clean up temp file on rename failure
103            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    /// Load a session by ID.
112    ///
113    /// # Errors
114    ///
115    /// Returns an error if the session file cannot be read or deserialized.
116    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    /// Load a session by ID string.
135    ///
136    /// # Errors
137    ///
138    /// Returns an error if the ID is not a valid UUID or the session cannot be loaded.
139    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    /// Delete a session.
146    ///
147    /// # Errors
148    ///
149    /// Returns an error if the session file cannot be deleted.
150    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    /// List all session IDs, sorted by modification time (most recent first).
162    ///
163    /// Returns an empty list if the sessions directory does not exist yet.
164    ///
165    /// # Errors
166    ///
167    /// Returns an error if the sessions directory cannot be read.
168    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        // Sort by modification time (most recent first)
189        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    /// List sessions with metadata.
207    ///
208    /// # Errors
209    ///
210    /// Returns an error if the sessions directory cannot be read.
211    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    /// Get the most recent session.
232    ///
233    /// # Errors
234    ///
235    /// Returns an error if the sessions cannot be listed or loaded.
236    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    /// List sessions filtered by workspace path.
246    ///
247    /// Only returns sessions whose `workspace_path` matches the given path.
248    ///
249    /// # Errors
250    ///
251    /// Returns an error if sessions cannot be listed or loaded.
252    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    /// Clean up old sessions (older than N days).
261    ///
262    /// # Errors
263    ///
264    /// Returns an error if the sessions cannot be listed.
265    pub fn cleanup_old(&self, max_age_days: i64) -> RuntimeResult<usize> {
266        // Safety: subtracting a known-positive duration from current time
267        #[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/// Summary of a session for listing.
285#[derive(Debug, Clone)]
286pub struct SessionSummary {
287    /// Session ID.
288    pub id: String,
289    /// Session title.
290    pub title: Option<String>,
291    /// Created timestamp.
292    pub created_at: chrono::DateTime<chrono::Utc>,
293    /// Number of messages.
294    pub message_count: usize,
295    /// Token count.
296    pub token_count: usize,
297    /// Workspace path (for workspace-scoped listing).
298    pub workspace_path: Option<PathBuf>,
299}
300
301impl SessionSummary {
302    /// Get a display title.
303    #[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        // Save (lazily creates dir)
324        store.save(&session).unwrap();
325
326        // Load
327        let loaded = store.load(&session.id).unwrap().unwrap();
328        assert_eq!(loaded.system_prompt, session.system_prompt);
329
330        // List
331        let ids = store.list().unwrap();
332        assert_eq!(ids.len(), 1);
333
334        // Delete
335        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        // Directory should not exist yet
347        assert!(!sessions_path.exists());
348
349        // List on non-existent dir returns empty
350        let ids = store.list().unwrap();
351        assert!(ids.is_empty());
352
353        // Save creates the directory
354        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        // No temp file should remain
368        let temp_path = temp_dir.path().join(format!("{}.json.tmp", session.id.0));
369        assert!(!temp_path.exists());
370
371        // The real file should exist
372        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        // Should be saved under sessions/
386        let expected = temp_dir
387            .path()
388            .join("sessions")
389            .join(format!("{}.json", session.id.0));
390        assert!(expected.exists());
391    }
392}