mcp_execution_server/state.rs
1//! State management for pending generation sessions.
2//!
3//! The `StateManager` stores temporary session data between `introspect_server`
4//! and `save_categorized_tools` calls. Sessions expire after 30 minutes and
5//! are cleaned up lazily on each operation.
6
7use crate::clock::{Clock, SystemClock};
8use crate::types::PendingGeneration;
9use std::collections::HashMap;
10use std::sync::Arc;
11use tokio::sync::RwLock;
12use uuid::Uuid;
13
14/// State manager for pending generation sessions.
15///
16/// Uses an in-memory `HashMap` protected by `RwLock` for thread-safe access.
17/// Sessions expire after 30 minutes and are cleaned up lazily.
18///
19/// # Examples
20///
21/// ```
22/// use mcp_execution_server::state::StateManager;
23/// use mcp_execution_server::types::PendingGeneration;
24/// use mcp_execution_server::clock::SystemClock;
25/// use mcp_execution_core::{ServerId, ServerConfig};
26/// use mcp_execution_introspector::ServerInfo;
27/// use std::path::PathBuf;
28///
29/// # async fn example() {
30/// let state = StateManager::new();
31///
32/// # let server_info = ServerInfo {
33/// # id: ServerId::new("test"),
34/// # name: "Test".to_string(),
35/// # version: "1.0.0".to_string(),
36/// # capabilities: mcp_execution_introspector::ServerCapabilities {
37/// # supports_tools: true,
38/// # supports_resources: false,
39/// # supports_prompts: false,
40/// # },
41/// # tools: vec![],
42/// # };
43/// let pending = PendingGeneration::new(
44/// ServerId::new("github"),
45/// server_info,
46/// ServerConfig::builder().command("npx".to_string()).build(),
47/// PathBuf::from("/tmp/output"),
48/// &SystemClock,
49/// );
50///
51/// // Store and get session ID
52/// let session_id = state.store(pending).await;
53///
54/// // Retrieve session data
55/// let retrieved = state.take(session_id).await;
56/// assert!(retrieved.is_some());
57/// # }
58/// ```
59#[derive(Debug)]
60pub struct StateManager {
61 pending: Arc<RwLock<HashMap<Uuid, PendingGeneration>>>,
62 clock: Arc<dyn Clock>,
63}
64
65impl Default for StateManager {
66 fn default() -> Self {
67 Self::new()
68 }
69}
70
71impl StateManager {
72 /// Creates a new state manager using the real system clock.
73 #[must_use]
74 pub fn new() -> Self {
75 Self::with_clock(Arc::new(SystemClock))
76 }
77
78 /// Creates a new state manager backed by a custom clock.
79 ///
80 /// Used in tests to inject a fake clock so session expiry can be
81 /// exercised deterministically.
82 #[must_use]
83 pub(crate) fn with_clock(clock: Arc<dyn Clock>) -> Self {
84 Self {
85 pending: Arc::new(RwLock::new(HashMap::new())),
86 clock,
87 }
88 }
89
90 /// Stores a pending generation and returns a session ID.
91 ///
92 /// This operation also performs lazy cleanup of expired sessions.
93 ///
94 /// # Examples
95 ///
96 /// ```
97 /// use mcp_execution_server::state::StateManager;
98 /// # use mcp_execution_server::types::PendingGeneration;
99 /// # use mcp_execution_core::{ServerId, ServerConfig};
100 /// # use mcp_execution_introspector::ServerInfo;
101 /// # use std::path::PathBuf;
102 ///
103 /// # async fn example(pending: PendingGeneration) {
104 /// let state = StateManager::new();
105 /// let session_id = state.store(pending).await;
106 /// # }
107 /// ```
108 pub async fn store(&self, generation: PendingGeneration) -> Uuid {
109 let session_id = Uuid::new_v4();
110 let mut pending = self.pending.write().await;
111
112 // Clean up expired sessions
113 let clock = self.clock.as_ref();
114 pending.retain(|_, g| !g.is_expired(clock));
115
116 pending.insert(session_id, generation);
117 session_id
118 }
119
120 /// Retrieves and removes a pending generation.
121 ///
122 /// Returns `None` if the session is not found or has expired.
123 /// This operation also performs lazy cleanup of expired sessions.
124 ///
125 /// # Examples
126 ///
127 /// ```
128 /// use mcp_execution_server::state::StateManager;
129 /// # use mcp_execution_server::types::PendingGeneration;
130 /// # use mcp_execution_core::{ServerId, ServerConfig};
131 /// # use mcp_execution_introspector::ServerInfo;
132 /// # use std::path::PathBuf;
133 ///
134 /// # async fn example(pending: PendingGeneration) {
135 /// let state = StateManager::new();
136 /// let session_id = state.store(pending).await;
137 ///
138 /// let retrieved = state.take(session_id).await;
139 /// assert!(retrieved.is_some());
140 ///
141 /// // Second take returns None (already removed)
142 /// let second = state.take(session_id).await;
143 /// assert!(second.is_none());
144 /// # }
145 /// ```
146 pub async fn take(&self, session_id: Uuid) -> Option<PendingGeneration> {
147 let generation = {
148 let mut pending = self.pending.write().await;
149
150 // Clean up expired sessions
151 let clock = self.clock.as_ref();
152 pending.retain(|_, g| !g.is_expired(clock));
153
154 pending.remove(&session_id)?
155 };
156
157 // Verify not expired (lock already released)
158 if generation.is_expired(self.clock.as_ref()) {
159 return None;
160 }
161
162 Some(generation)
163 }
164
165 /// Gets a pending generation without removing it.
166 ///
167 /// Returns `None` if the session is not found or has expired.
168 ///
169 /// # Examples
170 ///
171 /// ```
172 /// use mcp_execution_server::state::StateManager;
173 /// # use mcp_execution_server::types::PendingGeneration;
174 /// # use mcp_execution_core::{ServerId, ServerConfig};
175 /// # use mcp_execution_introspector::ServerInfo;
176 /// # use std::path::PathBuf;
177 ///
178 /// # async fn example(pending: PendingGeneration) {
179 /// let state = StateManager::new();
180 /// let session_id = state.store(pending).await;
181 ///
182 /// // Get without removing
183 /// let peeked = state.get(session_id).await;
184 /// assert!(peeked.is_some());
185 ///
186 /// // Still available
187 /// let peeked_again = state.get(session_id).await;
188 /// assert!(peeked_again.is_some());
189 /// # }
190 /// ```
191 pub async fn get(&self, session_id: Uuid) -> Option<PendingGeneration> {
192 let pending = self.pending.read().await;
193 let clock = self.clock.as_ref();
194 pending
195 .get(&session_id)
196 .filter(|g| !g.is_expired(clock))
197 .cloned()
198 }
199
200 /// Returns the current pending session count (excluding expired).
201 ///
202 /// # Examples
203 ///
204 /// ```
205 /// use mcp_execution_server::state::StateManager;
206 ///
207 /// # async fn example() {
208 /// let state = StateManager::new();
209 /// assert_eq!(state.pending_count().await, 0);
210 /// # }
211 /// ```
212 pub async fn pending_count(&self) -> usize {
213 let pending = self.pending.read().await;
214 let clock = self.clock.as_ref();
215 pending.values().filter(|g| !g.is_expired(clock)).count()
216 }
217
218 /// Cleans up all expired sessions.
219 ///
220 /// Returns the number of sessions that were removed.
221 ///
222 /// # Examples
223 ///
224 /// ```
225 /// use mcp_execution_server::state::StateManager;
226 ///
227 /// # async fn example() {
228 /// let state = StateManager::new();
229 /// let removed = state.cleanup_expired().await;
230 /// assert_eq!(removed, 0);
231 /// # }
232 /// ```
233 pub async fn cleanup_expired(&self) -> usize {
234 let mut pending = self.pending.write().await;
235 let before = pending.len();
236 let clock = self.clock.as_ref();
237 pending.retain(|_, g| !g.is_expired(clock));
238 before - pending.len()
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use crate::clock::{SystemClock, TestClock};
246 use crate::types::PendingGeneration;
247 use mcp_execution_core::{ServerConfig, ServerId, ToolName};
248 use mcp_execution_introspector::ServerInfo;
249 use std::path::PathBuf;
250
251 fn create_test_pending() -> PendingGeneration {
252 create_test_pending_with_clock(&SystemClock)
253 }
254
255 fn create_test_pending_with_clock(clock: &dyn Clock) -> PendingGeneration {
256 use mcp_execution_introspector::{ServerCapabilities, ToolInfo};
257
258 let server_id = ServerId::new("test");
259 let server_info = ServerInfo {
260 id: server_id.clone(),
261 name: "Test Server".to_string(),
262 version: "1.0.0".to_string(),
263 capabilities: ServerCapabilities {
264 supports_tools: true,
265 supports_resources: false,
266 supports_prompts: false,
267 },
268 tools: vec![ToolInfo {
269 name: ToolName::new("test_tool"),
270 description: "Test tool".to_string(),
271 input_schema: serde_json::json!({}),
272 output_schema: None,
273 }],
274 };
275 let config = ServerConfig::builder().command("echo".to_string()).build();
276 let output_dir = PathBuf::from("/tmp/test");
277
278 PendingGeneration::new(server_id, server_info, config, output_dir, clock)
279 }
280
281 /// Builds an already-expired pending generation by constructing it with a
282 /// clock fixed an hour in the past, instead of rewinding `expires_at`
283 /// after construction.
284 fn create_expired_pending() -> PendingGeneration {
285 let past_clock = TestClock::new(chrono::Utc::now() - chrono::Duration::hours(1));
286 create_test_pending_with_clock(&past_clock)
287 }
288
289 #[tokio::test]
290 async fn test_store_and_retrieve() {
291 let state = StateManager::new();
292 let pending = create_test_pending();
293
294 let session_id = state.store(pending.clone()).await;
295 let retrieved = state.take(session_id).await;
296
297 assert!(retrieved.is_some());
298 let retrieved = retrieved.unwrap();
299 assert_eq!(retrieved.server_id, pending.server_id);
300 }
301
302 #[tokio::test]
303 async fn test_take_removes_session() {
304 let state = StateManager::new();
305 let pending = create_test_pending();
306
307 let session_id = state.store(pending).await;
308
309 // First take succeeds
310 let first = state.take(session_id).await;
311 assert!(first.is_some());
312
313 // Second take returns None
314 let second = state.take(session_id).await;
315 assert!(second.is_none());
316 }
317
318 #[tokio::test]
319 async fn test_get_does_not_remove() {
320 let state = StateManager::new();
321 let pending = create_test_pending();
322
323 let session_id = state.store(pending).await;
324
325 // Get multiple times
326 let first = state.get(session_id).await;
327 assert!(first.is_some());
328
329 let second = state.get(session_id).await;
330 assert!(second.is_some());
331
332 // Still available for take
333 let taken = state.take(session_id).await;
334 assert!(taken.is_some());
335 }
336
337 #[tokio::test]
338 async fn test_expired_session() {
339 let state = StateManager::new();
340 let pending = create_expired_pending();
341
342 let session_id = state.store(pending).await;
343
344 // Should return None because expired
345 let retrieved = state.take(session_id).await;
346 assert!(retrieved.is_none());
347 }
348
349 #[tokio::test]
350 async fn test_pending_count() {
351 let state = StateManager::new();
352
353 assert_eq!(state.pending_count().await, 0);
354
355 let session_id = state.store(create_test_pending()).await;
356 assert_eq!(state.pending_count().await, 1);
357
358 state.take(session_id).await;
359 assert_eq!(state.pending_count().await, 0);
360 }
361
362 #[tokio::test]
363 async fn test_cleanup_expired() {
364 let state = StateManager::new();
365
366 // Add valid session
367 state.store(create_test_pending()).await;
368
369 // Add expired session
370 state.store(create_expired_pending()).await;
371
372 assert_eq!(state.pending_count().await, 1); // Only valid session counts
373
374 let removed = state.cleanup_expired().await;
375 assert_eq!(removed, 1); // One expired session removed
376 }
377
378 /// Proves `StateManager` consults the clock it was constructed with (not a
379 /// hardcoded `SystemClock`): a session created and stored while the shared
380 /// clock is fresh must flip to expired across `get`/`pending_count`/
381 /// `cleanup_expired`/`take` once that same clock is moved past the TTL —
382 /// real wall-clock time barely advances during the test, so this would fail
383 /// if any of those call sites silently used `SystemClock` instead of
384 /// `self.clock`.
385 #[tokio::test]
386 async fn test_shared_clock_drives_expiry() {
387 let start = chrono::Utc::now();
388 let clock = Arc::new(TestClock::new(start));
389 let state = StateManager::with_clock(Arc::clone(&clock) as Arc<dyn Clock>);
390
391 let pending = create_test_pending_with_clock(clock.as_ref());
392 let session_id = state.store(pending).await;
393
394 // Fresh session is visible while the clock is still within the TTL window.
395 assert!(state.get(session_id).await.is_some());
396 assert_eq!(state.pending_count().await, 1);
397
398 // Jump the shared clock straight past the 30-minute boundary.
399 clock.set(
400 start
401 + chrono::Duration::minutes(PendingGeneration::DEFAULT_TIMEOUT_MINUTES)
402 + chrono::Duration::seconds(1),
403 );
404
405 assert!(
406 state.get(session_id).await.is_none(),
407 "expiry should track the injected clock, not Utc::now()"
408 );
409 assert_eq!(state.pending_count().await, 0);
410 assert_eq!(state.cleanup_expired().await, 1);
411 assert!(state.take(session_id).await.is_none());
412 }
413
414 #[tokio::test]
415 async fn test_concurrent_access() {
416 let state = Arc::new(StateManager::new());
417 let mut handles = vec![];
418
419 // Spawn 10 concurrent store operations
420 for i in 0..10 {
421 let state_clone = Arc::clone(&state);
422 handles.push(tokio::spawn(async move {
423 let mut pending = create_test_pending();
424 pending.server_id = ServerId::new(&format!("server-{i}"));
425 state_clone.store(pending).await
426 }));
427 }
428
429 // Wait for all operations to complete
430 for handle in handles {
431 handle.await.unwrap();
432 }
433
434 assert_eq!(state.pending_count().await, 10);
435 }
436
437 #[tokio::test]
438 async fn test_lazy_cleanup_on_store() {
439 let state = StateManager::new();
440
441 // Store expired session directly
442 {
443 let mut pending = state.pending.write().await;
444 pending.insert(Uuid::new_v4(), create_expired_pending());
445 }
446
447 // Store new session triggers cleanup
448 state.store(create_test_pending()).await;
449
450 // Only the new session should remain
451 assert_eq!(state.pending_count().await, 1);
452 }
453}