Skip to main content

neuron_runtime/
session.rs

1//! Session management: types and storage traits.
2
3use std::collections::HashMap;
4use std::future::Future;
5use std::path::PathBuf;
6use std::sync::Arc;
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use tokio::sync::RwLock;
11
12use neuron_types::{Message, StorageError, TokenUsage, WasmCompatSend, WasmCompatSync};
13
14/// A conversation session with its messages and metadata.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct Session {
17    /// Unique identifier for this session.
18    pub id: String,
19    /// The conversation messages.
20    pub messages: Vec<Message>,
21    /// Runtime state for this session.
22    pub state: SessionState,
23    /// When the session was created.
24    pub created_at: DateTime<Utc>,
25    /// When the session was last updated.
26    pub updated_at: DateTime<Utc>,
27}
28
29impl Session {
30    /// Create a new session with the given ID and working directory.
31    #[must_use]
32    pub fn new(id: impl Into<String>, cwd: PathBuf) -> Self {
33        let now = Utc::now();
34        Self {
35            id: id.into(),
36            messages: Vec::new(),
37            state: SessionState {
38                cwd,
39                token_usage: TokenUsage::default(),
40                event_count: 0,
41                custom: HashMap::new(),
42            },
43            created_at: now,
44            updated_at: now,
45        }
46    }
47
48    /// Create a summary of this session (without messages).
49    #[must_use]
50    pub fn summary(&self) -> SessionSummary {
51        SessionSummary {
52            id: self.id.clone(),
53            created_at: self.created_at,
54            updated_at: self.updated_at,
55            message_count: self.messages.len(),
56        }
57    }
58}
59
60/// Mutable runtime state within a session.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct SessionState {
63    /// Current working directory.
64    pub cwd: PathBuf,
65    /// Cumulative token usage across the session.
66    pub token_usage: TokenUsage,
67    /// Number of events processed.
68    pub event_count: u64,
69    /// Custom key-value metadata.
70    pub custom: HashMap<String, serde_json::Value>,
71}
72
73/// A lightweight summary of a session (without messages).
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct SessionSummary {
76    /// Unique session identifier.
77    pub id: String,
78    /// When the session was created.
79    pub created_at: DateTime<Utc>,
80    /// When the session was last updated.
81    pub updated_at: DateTime<Utc>,
82    /// Number of messages in the session.
83    pub message_count: usize,
84}
85
86/// Trait for persisting and loading sessions.
87///
88/// # Example
89///
90/// ```ignore
91/// use neuron_runtime::*;
92///
93/// let storage = InMemorySessionStorage::new();
94/// let session = Session::new("s-1", "/tmp".into());
95/// storage.save(&session).await?;
96/// let loaded = storage.load("s-1").await?;
97/// assert_eq!(loaded.id, "s-1");
98/// ```
99pub trait SessionStorage: WasmCompatSend + WasmCompatSync {
100    /// Save a session (create or update).
101    fn save(
102        &self,
103        session: &Session,
104    ) -> impl Future<Output = Result<(), StorageError>> + WasmCompatSend;
105
106    /// Load a session by ID.
107    fn load(
108        &self,
109        id: &str,
110    ) -> impl Future<Output = Result<Session, StorageError>> + WasmCompatSend;
111
112    /// List all session summaries.
113    fn list(
114        &self,
115    ) -> impl Future<Output = Result<Vec<SessionSummary>, StorageError>> + WasmCompatSend;
116
117    /// Delete a session by ID.
118    fn delete(&self, id: &str) -> impl Future<Output = Result<(), StorageError>> + WasmCompatSend;
119}
120
121/// In-memory session storage backed by a concurrent hash map.
122///
123/// Suitable for testing and short-lived processes.
124#[derive(Debug, Clone)]
125pub struct InMemorySessionStorage {
126    sessions: Arc<RwLock<HashMap<String, Session>>>,
127}
128
129impl InMemorySessionStorage {
130    /// Create a new empty in-memory storage.
131    #[must_use]
132    pub fn new() -> Self {
133        Self {
134            sessions: Arc::new(RwLock::new(HashMap::new())),
135        }
136    }
137}
138
139impl Default for InMemorySessionStorage {
140    fn default() -> Self {
141        Self::new()
142    }
143}
144
145impl SessionStorage for InMemorySessionStorage {
146    async fn save(&self, session: &Session) -> Result<(), StorageError> {
147        let mut map = self.sessions.write().await;
148        map.insert(session.id.clone(), session.clone());
149        Ok(())
150    }
151
152    async fn load(&self, id: &str) -> Result<Session, StorageError> {
153        let map = self.sessions.read().await;
154        map.get(id)
155            .cloned()
156            .ok_or_else(|| StorageError::NotFound(id.to_string()))
157    }
158
159    async fn list(&self) -> Result<Vec<SessionSummary>, StorageError> {
160        let map = self.sessions.read().await;
161        Ok(map.values().map(|s| s.summary()).collect())
162    }
163
164    async fn delete(&self, id: &str) -> Result<(), StorageError> {
165        let mut map = self.sessions.write().await;
166        map.remove(id)
167            .ok_or_else(|| StorageError::NotFound(id.to_string()))?;
168        Ok(())
169    }
170}
171
172/// File-based session storage storing one JSON file per session.
173///
174/// Each session is stored at `{directory}/{session_id}.json`.
175#[derive(Debug, Clone)]
176pub struct FileSessionStorage {
177    directory: PathBuf,
178}
179
180impl FileSessionStorage {
181    /// Create a new file-based storage at the given directory.
182    ///
183    /// The directory will be created if it does not exist on the first `save()`.
184    #[must_use]
185    pub fn new(directory: PathBuf) -> Self {
186        Self { directory }
187    }
188
189    /// Compute the file path for a session.
190    fn path_for(&self, id: &str) -> PathBuf {
191        self.directory.join(format!("{id}.json"))
192    }
193}
194
195impl SessionStorage for FileSessionStorage {
196    async fn save(&self, session: &Session) -> Result<(), StorageError> {
197        tokio::fs::create_dir_all(&self.directory).await?;
198        let json = serde_json::to_string_pretty(session)
199            .map_err(|e| StorageError::Serialization(e.to_string()))?;
200        tokio::fs::write(self.path_for(&session.id), json).await?;
201        Ok(())
202    }
203
204    async fn load(&self, id: &str) -> Result<Session, StorageError> {
205        let path = self.path_for(id);
206        let data = tokio::fs::read_to_string(&path).await.map_err(|e| {
207            if e.kind() == std::io::ErrorKind::NotFound {
208                StorageError::NotFound(id.to_string())
209            } else {
210                StorageError::Io(e)
211            }
212        })?;
213        let session: Session =
214            serde_json::from_str(&data).map_err(|e| StorageError::Serialization(e.to_string()))?;
215        Ok(session)
216    }
217
218    async fn list(&self) -> Result<Vec<SessionSummary>, StorageError> {
219        let mut summaries = Vec::new();
220        let mut entries = match tokio::fs::read_dir(&self.directory).await {
221            Ok(entries) => entries,
222            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(summaries),
223            Err(e) => return Err(StorageError::Io(e)),
224        };
225        while let Some(entry) = entries.next_entry().await? {
226            let path = entry.path();
227            if path.extension().is_some_and(|ext| ext == "json") {
228                let data = tokio::fs::read_to_string(&path).await?;
229                if let Ok(session) = serde_json::from_str::<Session>(&data) {
230                    summaries.push(session.summary());
231                }
232            }
233        }
234        Ok(summaries)
235    }
236
237    async fn delete(&self, id: &str) -> Result<(), StorageError> {
238        let path = self.path_for(id);
239        tokio::fs::remove_file(&path).await.map_err(|e| {
240            if e.kind() == std::io::ErrorKind::NotFound {
241                StorageError::NotFound(id.to_string())
242            } else {
243                StorageError::Io(e)
244            }
245        })
246    }
247}