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(
119        &self,
120        id: &str,
121    ) -> impl Future<Output = Result<(), StorageError>> + WasmCompatSend;
122}
123
124/// In-memory session storage backed by a concurrent hash map.
125///
126/// Suitable for testing and short-lived processes.
127#[derive(Debug, Clone)]
128pub struct InMemorySessionStorage {
129    sessions: Arc<RwLock<HashMap<String, Session>>>,
130}
131
132impl InMemorySessionStorage {
133    /// Create a new empty in-memory storage.
134    #[must_use]
135    pub fn new() -> Self {
136        Self {
137            sessions: Arc::new(RwLock::new(HashMap::new())),
138        }
139    }
140}
141
142impl Default for InMemorySessionStorage {
143    fn default() -> Self {
144        Self::new()
145    }
146}
147
148impl SessionStorage for InMemorySessionStorage {
149    async fn save(&self, session: &Session) -> Result<(), StorageError> {
150        let mut map = self.sessions.write().await;
151        map.insert(session.id.clone(), session.clone());
152        Ok(())
153    }
154
155    async fn load(&self, id: &str) -> Result<Session, StorageError> {
156        let map = self.sessions.read().await;
157        map.get(id)
158            .cloned()
159            .ok_or_else(|| StorageError::NotFound(id.to_string()))
160    }
161
162    async fn list(&self) -> Result<Vec<SessionSummary>, StorageError> {
163        let map = self.sessions.read().await;
164        Ok(map.values().map(|s| s.summary()).collect())
165    }
166
167    async fn delete(&self, id: &str) -> Result<(), StorageError> {
168        let mut map = self.sessions.write().await;
169        map.remove(id)
170            .ok_or_else(|| StorageError::NotFound(id.to_string()))?;
171        Ok(())
172    }
173}
174
175/// File-based session storage storing one JSON file per session.
176///
177/// Each session is stored at `{directory}/{session_id}.json`.
178#[derive(Debug, Clone)]
179pub struct FileSessionStorage {
180    directory: PathBuf,
181}
182
183impl FileSessionStorage {
184    /// Create a new file-based storage at the given directory.
185    ///
186    /// The directory will be created if it does not exist on the first `save()`.
187    #[must_use]
188    pub fn new(directory: PathBuf) -> Self {
189        Self { directory }
190    }
191
192    /// Compute the file path for a session.
193    fn path_for(&self, id: &str) -> PathBuf {
194        self.directory.join(format!("{id}.json"))
195    }
196}
197
198impl SessionStorage for FileSessionStorage {
199    async fn save(&self, session: &Session) -> Result<(), StorageError> {
200        tokio::fs::create_dir_all(&self.directory).await?;
201        let json = serde_json::to_string_pretty(session)
202            .map_err(|e| StorageError::Serialization(e.to_string()))?;
203        tokio::fs::write(self.path_for(&session.id), json).await?;
204        Ok(())
205    }
206
207    async fn load(&self, id: &str) -> Result<Session, StorageError> {
208        let path = self.path_for(id);
209        let data = tokio::fs::read_to_string(&path).await.map_err(|e| {
210            if e.kind() == std::io::ErrorKind::NotFound {
211                StorageError::NotFound(id.to_string())
212            } else {
213                StorageError::Io(e)
214            }
215        })?;
216        let session: Session = serde_json::from_str(&data)
217            .map_err(|e| StorageError::Serialization(e.to_string()))?;
218        Ok(session)
219    }
220
221    async fn list(&self) -> Result<Vec<SessionSummary>, StorageError> {
222        let mut summaries = Vec::new();
223        let mut entries = match tokio::fs::read_dir(&self.directory).await {
224            Ok(entries) => entries,
225            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(summaries),
226            Err(e) => return Err(StorageError::Io(e)),
227        };
228        while let Some(entry) = entries.next_entry().await? {
229            let path = entry.path();
230            if path.extension().is_some_and(|ext| ext == "json") {
231                let data = tokio::fs::read_to_string(&path).await?;
232                if let Ok(session) = serde_json::from_str::<Session>(&data) {
233                    summaries.push(session.summary());
234                }
235            }
236        }
237        Ok(summaries)
238    }
239
240    async fn delete(&self, id: &str) -> Result<(), StorageError> {
241        let path = self.path_for(id);
242        tokio::fs::remove_file(&path).await.map_err(|e| {
243            if e.kind() == std::io::ErrorKind::NotFound {
244                StorageError::NotFound(id.to_string())
245            } else {
246                StorageError::Io(e)
247            }
248        })
249    }
250}