Skip to main content

orcs_runtime/session/
store.rs

1//! Session storage abstraction.
2//!
3//! The [`SessionStore`] trait defines the interface for session persistence.
4//! This allows pluggable storage backends (local file, remote API, hybrid).
5
6use super::{SessionAsset, StorageError};
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use std::future::Future;
10use std::path::PathBuf;
11
12/// Session storage abstraction.
13///
14/// Implementations must be thread-safe (`Send + Sync`) for use across async tasks.
15///
16/// # Design Principles
17///
18/// - **Async**: All operations are async for I/O efficiency
19/// - **Error Handling**: Returns `StorageError` for consistent error handling
20/// - **Metadata Efficiency**: `list()` returns lightweight metadata, not full assets
21///
22/// # Example
23///
24/// ```no_run
25/// use orcs_runtime::session::{SessionStore, SessionAsset, StorageError};
26///
27/// async fn save_session(store: &impl SessionStore, asset: &SessionAsset) -> Result<(), StorageError> {
28///     store.save(asset).await?;
29///     println!("Saved session: {}", asset.id);
30///     Ok(())
31/// }
32/// ```
33pub trait SessionStore: Send + Sync {
34    /// Saves a session asset.
35    ///
36    /// If a session with the same ID exists, it will be overwritten.
37    fn save(&self, asset: &SessionAsset) -> impl Future<Output = Result<(), StorageError>> + Send;
38
39    /// Loads a session asset by ID.
40    ///
41    /// # Errors
42    ///
43    /// Returns `StorageError::NotFound` if the session does not exist.
44    fn load(&self, id: &str) -> impl Future<Output = Result<SessionAsset, StorageError>> + Send;
45
46    /// Lists all session metadata.
47    ///
48    /// Returns lightweight metadata for each session, sorted by `updated_at` descending.
49    fn list(&self) -> impl Future<Output = Result<Vec<SessionMeta>, StorageError>> + Send;
50
51    /// Deletes a session by ID.
52    ///
53    /// # Errors
54    ///
55    /// Returns `StorageError::NotFound` if the session does not exist.
56    fn delete(&self, id: &str) -> impl Future<Output = Result<(), StorageError>> + Send;
57
58    /// Checks if a session exists.
59    fn exists(&self, id: &str) -> impl Future<Output = Result<bool, StorageError>> + Send;
60
61    /// Returns the current sync status.
62    ///
63    /// For local-only stores, this returns `SyncStatus::offline()`.
64    fn sync_status(&self) -> SyncStatus {
65        SyncStatus::offline()
66    }
67}
68
69/// Session metadata for listing.
70///
71/// This is a lightweight representation of a session for display purposes.
72/// Use [`SessionStore::load`] to get the full [`SessionAsset`].
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct SessionMeta {
75    /// Session ID.
76    pub id: String,
77
78    /// Optional human-readable name.
79    pub name: Option<String>,
80
81    /// Creation timestamp.
82    pub created_at: DateTime<Utc>,
83
84    /// Last update timestamp.
85    pub updated_at: DateTime<Utc>,
86
87    /// Associated project path.
88    pub project_path: Option<PathBuf>,
89
90    /// Number of conversation turns.
91    pub turn_count: usize,
92
93    /// Sync state for this session.
94    pub sync_state: SyncState,
95}
96
97impl SessionMeta {
98    /// Creates metadata from a full session asset.
99    pub fn from_asset(asset: &SessionAsset) -> Self {
100        Self {
101            id: asset.id.clone(),
102            name: asset.project_context.name.clone(),
103            created_at: DateTime::from_timestamp_millis(asset.created_at_ms as i64)
104                .unwrap_or_else(Utc::now),
105            updated_at: DateTime::from_timestamp_millis(asset.updated_at_ms as i64)
106                .unwrap_or_else(Utc::now),
107            project_path: asset.project_context.root_path.clone(),
108            turn_count: asset.history.len(),
109            sync_state: SyncState::LocalOnly,
110        }
111    }
112}
113
114/// Sync state for a session.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
116pub enum SyncState {
117    /// Local only (not synced).
118    #[default]
119    LocalOnly,
120
121    /// Fully synced with remote.
122    Synced,
123
124    /// Local has unsynced changes.
125    LocalAhead,
126
127    /// Remote has unsynced changes.
128    RemoteAhead,
129
130    /// Conflict between local and remote.
131    Conflict,
132}
133
134impl SyncState {
135    /// Returns `true` if fully synced.
136    pub fn is_synced(&self) -> bool {
137        matches!(self, Self::Synced)
138    }
139
140    /// Returns `true` if there are pending changes.
141    pub fn has_pending_changes(&self) -> bool {
142        matches!(self, Self::LocalAhead | Self::RemoteAhead)
143    }
144
145    /// Returns `true` if there is a conflict.
146    pub fn has_conflict(&self) -> bool {
147        matches!(self, Self::Conflict)
148    }
149}
150
151/// Overall sync status for the store.
152#[derive(Debug, Clone, Default)]
153pub struct SyncStatus {
154    /// Current sync mode.
155    pub mode: SyncMode,
156
157    /// Last successful sync time.
158    pub last_sync: Option<DateTime<Utc>>,
159
160    /// Number of sessions pending upload.
161    pub pending_uploads: usize,
162
163    /// Number of sessions pending download.
164    pub pending_downloads: usize,
165
166    /// Sessions with conflicts.
167    pub conflicts: Vec<String>,
168}
169
170impl SyncStatus {
171    /// Creates an offline status (local-only mode).
172    pub fn offline() -> Self {
173        Self {
174            mode: SyncMode::Offline,
175            ..Default::default()
176        }
177    }
178
179    /// Creates an online status.
180    pub fn online() -> Self {
181        Self {
182            mode: SyncMode::Online,
183            last_sync: Some(Utc::now()),
184            ..Default::default()
185        }
186    }
187
188    /// Returns `true` if online and synced.
189    pub fn is_fully_synced(&self) -> bool {
190        self.mode == SyncMode::Online
191            && self.pending_uploads == 0
192            && self.pending_downloads == 0
193            && self.conflicts.is_empty()
194    }
195}
196
197/// Sync mode.
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
199pub enum SyncMode {
200    /// Offline mode (local only).
201    #[default]
202    Offline,
203
204    /// Online and connected.
205    Online,
206
207    /// Currently syncing.
208    Syncing,
209
210    /// Error state.
211    Error,
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn sync_state_predicates() {
220        assert!(SyncState::Synced.is_synced());
221        assert!(!SyncState::LocalOnly.is_synced());
222
223        assert!(SyncState::LocalAhead.has_pending_changes());
224        assert!(!SyncState::Synced.has_pending_changes());
225
226        assert!(SyncState::Conflict.has_conflict());
227        assert!(!SyncState::LocalOnly.has_conflict());
228    }
229
230    #[test]
231    fn sync_status_offline() {
232        let status = SyncStatus::offline();
233        assert_eq!(status.mode, SyncMode::Offline);
234        assert!(status.last_sync.is_none());
235    }
236
237    #[test]
238    fn sync_status_fully_synced() {
239        let status = SyncStatus::online();
240        assert!(status.is_fully_synced());
241
242        let status_with_pending = SyncStatus {
243            mode: SyncMode::Online,
244            pending_uploads: 1,
245            ..Default::default()
246        };
247        assert!(!status_with_pending.is_fully_synced());
248    }
249}