Skip to main content

a3s_code_core/
store.rs

1//! Session persistence layer
2//!
3//! Provides pluggable session storage via the `SessionStore` trait.
4//!
5//! ## Default Implementation
6//!
7//! `FileSessionStore` stores each session as a JSON file:
8//! - Session metadata (id, name, timestamps)
9//! - Configuration (system prompt, policies)
10//! - Conversation history (messages)
11//! - Context usage statistics
12//!
13//! ## Custom Backends
14//!
15//! Implement `SessionStore` trait for custom backends (Redis, PostgreSQL, etc.):
16//!
17//! ```ignore
18//! use a3s_code::store::{SessionStore, SessionData};
19//!
20//! struct RedisStore { /* ... */ }
21//!
22//! #[async_trait::async_trait]
23//! impl SessionStore for RedisStore {
24//!     async fn save(&self, session: &SessionData) -> Result<()> { /* ... */ }
25//!     async fn load(&self, id: &str) -> Result<Option<SessionData>> { /* ... */ }
26//!     async fn delete(&self, id: &str) -> Result<()> { /* ... */ }
27//!     async fn list(&self) -> Result<Vec<String>> { /* ... */ }
28//!     async fn exists(&self, id: &str) -> Result<bool> { /* ... */ }
29//! }
30//! ```
31
32use crate::llm::{Message, TokenUsage, ToolDefinition};
33use crate::planning::Task;
34use crate::prompts::PlanningMode;
35use crate::queue::SessionQueueConfig;
36use crate::run::RunRecord;
37use crate::tools::ArtifactStore;
38use crate::trace::TraceEvent;
39use crate::verification::VerificationReport;
40use anyhow::{Context, Result};
41use serde::{Deserialize, Serialize};
42use std::collections::HashMap;
43use std::path::{Path, PathBuf};
44use tokio::fs;
45use tokio::io::AsyncWriteExt;
46
47// ============================================================================
48// Serializable Session Data
49// ============================================================================
50
51/// Session state persisted with saved sessions.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
53pub enum SessionState {
54    #[default]
55    Unknown = 0,
56    Active = 1,
57    Paused = 2,
58    Completed = 3,
59    Error = 4,
60}
61
62/// Context usage statistics persisted with saved sessions.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct ContextUsage {
65    pub used_tokens: usize,
66    pub max_tokens: usize,
67    pub percent: f32,
68    pub turns: usize,
69}
70
71impl Default for ContextUsage {
72    fn default() -> Self {
73        Self {
74            used_tokens: 0,
75            max_tokens: 200_000,
76            percent: 0.0,
77            turns: 0,
78        }
79    }
80}
81
82/// Default auto-compact threshold (80% of context window).
83pub const DEFAULT_AUTO_COMPACT_THRESHOLD: f32 = 0.80;
84
85fn default_auto_compact_threshold() -> f32 {
86    DEFAULT_AUTO_COMPACT_THRESHOLD
87}
88
89/// Serializable session configuration.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct SessionConfig {
92    pub name: String,
93    pub workspace: String,
94    pub system_prompt: Option<String>,
95    pub max_context_length: u32,
96    pub auto_compact: bool,
97    /// Context usage percentage threshold to trigger auto-compaction (0.0 - 1.0).
98    /// Only used when `auto_compact` is true. Default: 0.80 (80%).
99    #[serde(default = "default_auto_compact_threshold")]
100    pub auto_compact_threshold: f32,
101    /// Storage type for this session.
102    #[serde(default)]
103    pub storage_type: crate::config::StorageBackend,
104    /// Optional advanced queue configuration.
105    ///
106    /// Queue infrastructure is initialized only when this is set. Ordinary
107    /// sessions stay queue-free.
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub queue_config: Option<SessionQueueConfig>,
110    /// Confirmation policy (optional, uses defaults if None).
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub confirmation_policy: Option<crate::hitl::ConfirmationPolicy>,
113    /// Permission policy (optional, uses defaults if None).
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub permission_policy: Option<crate::permissions::PermissionPolicy>,
116    /// Parent session ID (for delegated child sessions).
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub parent_id: Option<String>,
119    /// Security configuration (optional, enables security features).
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub security_config: Option<crate::security::SecurityConfig>,
122    /// Shared hook engine for lifecycle events.
123    #[serde(skip)]
124    pub hook_engine: Option<std::sync::Arc<dyn crate::hooks::HookExecutor>>,
125    /// Enable planning phase before execution.
126    #[serde(default)]
127    pub planning_mode: PlanningMode,
128    /// Enable goal tracking.
129    #[serde(default)]
130    pub goal_tracking: bool,
131}
132
133impl Default for SessionConfig {
134    fn default() -> Self {
135        Self {
136            name: String::new(),
137            workspace: String::new(),
138            system_prompt: None,
139            max_context_length: 0,
140            auto_compact: false,
141            auto_compact_threshold: DEFAULT_AUTO_COMPACT_THRESHOLD,
142            storage_type: crate::config::StorageBackend::default(),
143            queue_config: None,
144            confirmation_policy: None,
145            permission_policy: None,
146            parent_id: None,
147            security_config: None,
148            hook_engine: None,
149            planning_mode: PlanningMode::default(),
150            goal_tracking: false,
151        }
152    }
153}
154
155/// Serializable session data for persistence
156///
157/// Contains only the fields that can be serialized.
158/// Non-serializable fields (event_tx, command_queue, etc.) are rebuilt on load.
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct SessionData {
161    /// Session ID
162    pub id: String,
163
164    /// Session configuration
165    pub config: SessionConfig,
166
167    /// Current state
168    pub state: SessionState,
169
170    /// Conversation history
171    pub messages: Vec<Message>,
172
173    /// Context usage statistics
174    pub context_usage: ContextUsage,
175
176    /// Total token usage
177    pub total_usage: TokenUsage,
178
179    /// Cumulative dollar cost for this session
180    #[serde(default)]
181    pub total_cost: f64,
182
183    /// Model name for cost calculation
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub model_name: Option<String>,
186
187    /// LLM cost records for this session
188    #[serde(default)]
189    pub cost_records: Vec<crate::telemetry::LlmCostRecord>,
190
191    /// Tool definitions (names only, rebuilt from executor on load)
192    pub tool_names: Vec<String>,
193
194    /// Whether thinking mode is enabled
195    pub thinking_enabled: bool,
196
197    /// Thinking budget if set
198    pub thinking_budget: Option<usize>,
199
200    /// Creation timestamp (Unix epoch seconds)
201    pub created_at: i64,
202
203    /// Last update timestamp (Unix epoch seconds)
204    pub updated_at: i64,
205
206    /// LLM configuration for per-session client (if set)
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub llm_config: Option<LlmConfigData>,
209
210    /// Task list for tracking
211    #[serde(default, alias = "todos")]
212    pub tasks: Vec<Task>,
213
214    /// Parent session ID (for delegated child sessions)
215    #[serde(skip_serializing_if = "Option::is_none")]
216    pub parent_id: Option<String>,
217}
218
219/// Serializable LLM configuration
220#[derive(Debug, Clone, Serialize, Deserialize)]
221pub struct LlmConfigData {
222    pub provider: String,
223    pub model: String,
224    /// API key is NOT stored - must be provided on session resume
225    #[serde(skip_serializing, default)]
226    pub api_key: Option<String>,
227    pub base_url: Option<String>,
228}
229
230impl SessionData {
231    /// Extract tool names from definitions
232    pub fn tool_names_from_definitions(tools: &[ToolDefinition]) -> Vec<String> {
233        tools.iter().map(|t| t.name.clone()).collect()
234    }
235}
236
237// ============================================================================
238// Session Store Trait
239// ============================================================================
240
241/// Session storage trait
242#[async_trait::async_trait]
243pub trait SessionStore: Send + Sync {
244    /// Save session data
245    async fn save(&self, session: &SessionData) -> Result<()>;
246
247    /// Load session data by ID
248    async fn load(&self, id: &str) -> Result<Option<SessionData>>;
249
250    /// Delete session data
251    async fn delete(&self, id: &str) -> Result<()>;
252
253    /// List all session IDs
254    async fn list(&self) -> Result<Vec<String>>;
255
256    /// Check if session exists
257    async fn exists(&self, id: &str) -> Result<bool>;
258
259    /// Save artifacts associated with a session.
260    async fn save_artifacts(&self, _id: &str, _artifacts: &ArtifactStore) -> Result<()> {
261        Ok(())
262    }
263
264    /// Load artifacts associated with a session.
265    async fn load_artifacts(&self, _id: &str) -> Result<Option<ArtifactStore>> {
266        Ok(None)
267    }
268
269    /// Save compact trace events associated with a session.
270    async fn save_trace_events(&self, _id: &str, _events: &[TraceEvent]) -> Result<()> {
271        Ok(())
272    }
273
274    /// Load compact trace events associated with a session.
275    async fn load_trace_events(&self, _id: &str) -> Result<Option<Vec<TraceEvent>>> {
276        Ok(None)
277    }
278
279    /// Save run snapshots and replayable runtime events associated with a session.
280    async fn save_run_records(&self, _id: &str, _records: &[RunRecord]) -> Result<()> {
281        Ok(())
282    }
283
284    /// Load run snapshots and replayable runtime events associated with a session.
285    async fn load_run_records(&self, _id: &str) -> Result<Option<Vec<RunRecord>>> {
286        Ok(None)
287    }
288
289    /// Save structured verification reports associated with a session.
290    async fn save_verification_reports(
291        &self,
292        _id: &str,
293        _reports: &[VerificationReport],
294    ) -> Result<()> {
295        Ok(())
296    }
297
298    /// Load structured verification reports associated with a session.
299    async fn load_verification_reports(
300        &self,
301        _id: &str,
302    ) -> Result<Option<Vec<VerificationReport>>> {
303        Ok(None)
304    }
305
306    /// Health check — verify the store backend is reachable and operational
307    async fn health_check(&self) -> Result<()> {
308        Ok(())
309    }
310
311    /// Backend name for diagnostics
312    fn backend_name(&self) -> &str {
313        "unknown"
314    }
315}
316
317// ============================================================================
318// File-based Session Store
319// ============================================================================
320
321/// File-based session store
322///
323/// Stores each session as a JSON file in a directory:
324/// ```text
325/// sessions/
326///   session-1.json
327///   session-2.json
328/// ```
329pub struct FileSessionStore {
330    /// Directory to store session files
331    dir: PathBuf,
332}
333
334impl FileSessionStore {
335    /// Create a new file session store
336    ///
337    /// Creates the directory if it doesn't exist.
338    pub async fn new<P: AsRef<Path>>(dir: P) -> Result<Self> {
339        let dir = dir.as_ref().to_path_buf();
340
341        // Create directory if it doesn't exist
342        fs::create_dir_all(&dir)
343            .await
344            .with_context(|| format!("Failed to create session directory: {}", dir.display()))?;
345
346        Ok(Self { dir })
347    }
348
349    /// Get the file path for a session
350    fn session_path(&self, id: &str) -> PathBuf {
351        // Sanitize ID to prevent path traversal
352        self.dir.join(format!("{}.json", safe_session_id(id)))
353    }
354
355    fn artifact_dir(&self, id: &str) -> PathBuf {
356        self.dir.join("artifacts").join(safe_session_id(id))
357    }
358
359    fn trace_path(&self, id: &str) -> PathBuf {
360        self.dir
361            .join("traces")
362            .join(format!("{}.json", safe_session_id(id)))
363    }
364
365    fn verification_path(&self, id: &str) -> PathBuf {
366        self.dir
367            .join("verification")
368            .join(format!("{}.json", safe_session_id(id)))
369    }
370
371    fn runs_path(&self, id: &str) -> PathBuf {
372        self.dir
373            .join("runs")
374            .join(format!("{}.json", safe_session_id(id)))
375    }
376}
377
378fn safe_session_id(id: &str) -> String {
379    id.replace(['/', '\\'], "_").replace("..", "_")
380}
381
382#[async_trait::async_trait]
383impl SessionStore for FileSessionStore {
384    async fn save(&self, session: &SessionData) -> Result<()> {
385        let path = self.session_path(&session.id);
386
387        // Serialize to JSON with pretty printing for readability
388        let json = serde_json::to_string_pretty(session)
389            .with_context(|| format!("Failed to serialize session: {}", session.id))?;
390
391        // Write atomically: write to temp file with unique name, then rename
392        // Use timestamp + process ID to ensure uniqueness for concurrent saves
393        let unique_suffix = format!(
394            "{}.{}",
395            std::time::SystemTime::now()
396                .duration_since(std::time::UNIX_EPOCH)
397                .unwrap()
398                .as_nanos(),
399            std::process::id()
400        );
401        let temp_path = path.with_extension(format!("json.{}.tmp", unique_suffix));
402
403        let mut file = fs::File::create(&temp_path)
404            .await
405            .with_context(|| format!("Failed to create temp file: {}", temp_path.display()))?;
406
407        file.write_all(json.as_bytes())
408            .await
409            .with_context(|| format!("Failed to write session data: {}", session.id))?;
410
411        file.sync_all()
412            .await
413            .with_context(|| format!("Failed to sync session file: {}", session.id))?;
414
415        // Rename temp file to final path (atomic on most filesystems)
416        fs::rename(&temp_path, &path)
417            .await
418            .with_context(|| format!("Failed to rename session file: {}", session.id))?;
419
420        tracing::debug!("Saved session {} to {}", session.id, path.display());
421        Ok(())
422    }
423
424    async fn load(&self, id: &str) -> Result<Option<SessionData>> {
425        let path = self.session_path(id);
426
427        if !path.exists() {
428            return Ok(None);
429        }
430
431        let json = fs::read_to_string(&path)
432            .await
433            .with_context(|| format!("Failed to read session file: {}", path.display()))?;
434
435        let session: SessionData = serde_json::from_str(&json)
436            .with_context(|| format!("Failed to parse session file: {}", path.display()))?;
437
438        tracing::debug!("Loaded session {} from {}", id, path.display());
439        Ok(Some(session))
440    }
441
442    async fn delete(&self, id: &str) -> Result<()> {
443        let path = self.session_path(id);
444
445        if path.exists() {
446            fs::remove_file(&path)
447                .await
448                .with_context(|| format!("Failed to delete session file: {}", path.display()))?;
449
450            tracing::debug!("Deleted session {} from {}", id, path.display());
451        }
452
453        let artifact_dir = self.artifact_dir(id);
454        if artifact_dir.exists() {
455            fs::remove_dir_all(&artifact_dir).await.with_context(|| {
456                format!(
457                    "Failed to delete artifact directory for session {}: {}",
458                    id,
459                    artifact_dir.display()
460                )
461            })?;
462        }
463
464        let trace_path = self.trace_path(id);
465        if trace_path.exists() {
466            fs::remove_file(&trace_path).await.with_context(|| {
467                format!(
468                    "Failed to delete trace file for session {}: {}",
469                    id,
470                    trace_path.display()
471                )
472            })?;
473        }
474
475        let verification_path = self.verification_path(id);
476        if verification_path.exists() {
477            fs::remove_file(&verification_path).await.with_context(|| {
478                format!(
479                    "Failed to delete verification report file for session {}: {}",
480                    id,
481                    verification_path.display()
482                )
483            })?;
484        }
485
486        let runs_path = self.runs_path(id);
487        if runs_path.exists() {
488            fs::remove_file(&runs_path).await.with_context(|| {
489                format!(
490                    "Failed to delete run record file for session {}: {}",
491                    id,
492                    runs_path.display()
493                )
494            })?;
495        }
496
497        Ok(())
498    }
499
500    async fn list(&self) -> Result<Vec<String>> {
501        let mut session_ids = Vec::new();
502
503        let mut entries = fs::read_dir(&self.dir)
504            .await
505            .with_context(|| format!("Failed to read session directory: {}", self.dir.display()))?;
506
507        while let Some(entry) = entries.next_entry().await? {
508            let path = entry.path();
509
510            if path.extension().is_some_and(|ext| ext == "json") {
511                if let Some(stem) = path.file_stem() {
512                    if let Some(id) = stem.to_str() {
513                        session_ids.push(id.to_string());
514                    }
515                }
516            }
517        }
518
519        Ok(session_ids)
520    }
521
522    async fn exists(&self, id: &str) -> Result<bool> {
523        let path = self.session_path(id);
524        Ok(path.exists())
525    }
526
527    async fn save_artifacts(&self, id: &str, artifacts: &ArtifactStore) -> Result<()> {
528        let artifact_dir = self.artifact_dir(id);
529        artifacts.save_to_dir(&artifact_dir).with_context(|| {
530            format!(
531                "Failed to save artifacts for session {} to {}",
532                id,
533                artifact_dir.display()
534            )
535        })
536    }
537
538    async fn load_artifacts(&self, id: &str) -> Result<Option<ArtifactStore>> {
539        let artifact_dir = self.artifact_dir(id);
540        if !artifact_dir.exists() {
541            return Ok(None);
542        }
543
544        let artifacts = ArtifactStore::load_from_dir(&artifact_dir).with_context(|| {
545            format!(
546                "Failed to load artifacts for session {} from {}",
547                id,
548                artifact_dir.display()
549            )
550        })?;
551        Ok(Some(artifacts))
552    }
553
554    async fn save_trace_events(&self, id: &str, events: &[TraceEvent]) -> Result<()> {
555        let path = self.trace_path(id);
556        if let Some(parent) = path.parent() {
557            fs::create_dir_all(parent).await.with_context(|| {
558                format!("Failed to create trace directory: {}", parent.display())
559            })?;
560        }
561
562        let json = serde_json::to_string_pretty(events)
563            .with_context(|| format!("Failed to serialize trace events for session {id}"))?;
564        fs::write(&path, json)
565            .await
566            .with_context(|| format!("Failed to write trace events to {}", path.display()))?;
567        Ok(())
568    }
569
570    async fn load_trace_events(&self, id: &str) -> Result<Option<Vec<TraceEvent>>> {
571        let path = self.trace_path(id);
572        if !path.exists() {
573            return Ok(None);
574        }
575
576        let json = fs::read_to_string(&path)
577            .await
578            .with_context(|| format!("Failed to read trace events from {}", path.display()))?;
579        let events = serde_json::from_str(&json)
580            .with_context(|| format!("Failed to parse trace events from {}", path.display()))?;
581        Ok(Some(events))
582    }
583
584    async fn save_run_records(&self, id: &str, records: &[RunRecord]) -> Result<()> {
585        let path = self.runs_path(id);
586        if let Some(parent) = path.parent() {
587            fs::create_dir_all(parent)
588                .await
589                .with_context(|| format!("Failed to create run directory: {}", parent.display()))?;
590        }
591
592        let json = serde_json::to_string_pretty(records)
593            .with_context(|| format!("Failed to serialize run records for session {id}"))?;
594        fs::write(&path, json)
595            .await
596            .with_context(|| format!("Failed to write run records to {}", path.display()))?;
597        Ok(())
598    }
599
600    async fn load_run_records(&self, id: &str) -> Result<Option<Vec<RunRecord>>> {
601        let path = self.runs_path(id);
602        if !path.exists() {
603            return Ok(None);
604        }
605
606        let json = fs::read_to_string(&path)
607            .await
608            .with_context(|| format!("Failed to read run records from {}", path.display()))?;
609        let records = serde_json::from_str(&json)
610            .with_context(|| format!("Failed to parse run records from {}", path.display()))?;
611        Ok(Some(records))
612    }
613
614    async fn save_verification_reports(
615        &self,
616        id: &str,
617        reports: &[VerificationReport],
618    ) -> Result<()> {
619        let path = self.verification_path(id);
620        if let Some(parent) = path.parent() {
621            fs::create_dir_all(parent).await.with_context(|| {
622                format!(
623                    "Failed to create verification report directory: {}",
624                    parent.display()
625                )
626            })?;
627        }
628
629        let json = serde_json::to_string_pretty(reports).with_context(|| {
630            format!("Failed to serialize verification reports for session {id}")
631        })?;
632        fs::write(&path, json).await.with_context(|| {
633            format!("Failed to write verification reports to {}", path.display())
634        })?;
635        Ok(())
636    }
637
638    async fn load_verification_reports(&self, id: &str) -> Result<Option<Vec<VerificationReport>>> {
639        let path = self.verification_path(id);
640        if !path.exists() {
641            return Ok(None);
642        }
643
644        let json = fs::read_to_string(&path).await.with_context(|| {
645            format!(
646                "Failed to read verification reports from {}",
647                path.display()
648            )
649        })?;
650        let reports = serde_json::from_str(&json).with_context(|| {
651            format!(
652                "Failed to parse verification reports from {}",
653                path.display()
654            )
655        })?;
656        Ok(Some(reports))
657    }
658
659    async fn health_check(&self) -> Result<()> {
660        // Verify directory exists and is writable
661        let probe = self.dir.join(".health_check");
662        fs::write(&probe, b"ok")
663            .await
664            .with_context(|| format!("Store directory not writable: {}", self.dir.display()))?;
665        let _ = fs::remove_file(&probe).await;
666        Ok(())
667    }
668
669    fn backend_name(&self) -> &str {
670        "file"
671    }
672}
673
674// ============================================================================
675// In-Memory Session Store (for testing)
676// ============================================================================
677
678/// In-memory session store for testing
679pub struct MemorySessionStore {
680    sessions: tokio::sync::RwLock<HashMap<String, SessionData>>,
681    artifacts: tokio::sync::RwLock<HashMap<String, ArtifactStore>>,
682    trace_events: tokio::sync::RwLock<HashMap<String, Vec<TraceEvent>>>,
683    run_records: tokio::sync::RwLock<HashMap<String, Vec<RunRecord>>>,
684    verification_reports: tokio::sync::RwLock<HashMap<String, Vec<VerificationReport>>>,
685}
686
687impl MemorySessionStore {
688    pub fn new() -> Self {
689        Self {
690            sessions: tokio::sync::RwLock::new(HashMap::new()),
691            artifacts: tokio::sync::RwLock::new(HashMap::new()),
692            trace_events: tokio::sync::RwLock::new(HashMap::new()),
693            run_records: tokio::sync::RwLock::new(HashMap::new()),
694            verification_reports: tokio::sync::RwLock::new(HashMap::new()),
695        }
696    }
697}
698
699impl Default for MemorySessionStore {
700    fn default() -> Self {
701        Self::new()
702    }
703}
704
705#[async_trait::async_trait]
706impl SessionStore for MemorySessionStore {
707    async fn save(&self, session: &SessionData) -> Result<()> {
708        let mut sessions = self.sessions.write().await;
709        sessions.insert(session.id.clone(), session.clone());
710        Ok(())
711    }
712
713    async fn load(&self, id: &str) -> Result<Option<SessionData>> {
714        let sessions = self.sessions.read().await;
715        Ok(sessions.get(id).cloned())
716    }
717
718    async fn delete(&self, id: &str) -> Result<()> {
719        let mut sessions = self.sessions.write().await;
720        sessions.remove(id);
721        self.artifacts.write().await.remove(id);
722        self.trace_events.write().await.remove(id);
723        self.run_records.write().await.remove(id);
724        self.verification_reports.write().await.remove(id);
725        Ok(())
726    }
727
728    async fn list(&self) -> Result<Vec<String>> {
729        let sessions = self.sessions.read().await;
730        Ok(sessions.keys().cloned().collect())
731    }
732
733    async fn exists(&self, id: &str) -> Result<bool> {
734        let sessions = self.sessions.read().await;
735        Ok(sessions.contains_key(id))
736    }
737
738    async fn save_artifacts(&self, id: &str, artifacts: &ArtifactStore) -> Result<()> {
739        self.artifacts
740            .write()
741            .await
742            .insert(id.to_string(), artifacts.clone());
743        Ok(())
744    }
745
746    async fn load_artifacts(&self, id: &str) -> Result<Option<ArtifactStore>> {
747        Ok(self.artifacts.read().await.get(id).cloned())
748    }
749
750    async fn save_trace_events(&self, id: &str, events: &[TraceEvent]) -> Result<()> {
751        self.trace_events
752            .write()
753            .await
754            .insert(id.to_string(), events.to_vec());
755        Ok(())
756    }
757
758    async fn load_trace_events(&self, id: &str) -> Result<Option<Vec<TraceEvent>>> {
759        Ok(self.trace_events.read().await.get(id).cloned())
760    }
761
762    async fn save_run_records(&self, id: &str, records: &[RunRecord]) -> Result<()> {
763        self.run_records
764            .write()
765            .await
766            .insert(id.to_string(), records.to_vec());
767        Ok(())
768    }
769
770    async fn load_run_records(&self, id: &str) -> Result<Option<Vec<RunRecord>>> {
771        Ok(self.run_records.read().await.get(id).cloned())
772    }
773
774    async fn save_verification_reports(
775        &self,
776        id: &str,
777        reports: &[VerificationReport],
778    ) -> Result<()> {
779        self.verification_reports
780            .write()
781            .await
782            .insert(id.to_string(), reports.to_vec());
783        Ok(())
784    }
785
786    async fn load_verification_reports(&self, id: &str) -> Result<Option<Vec<VerificationReport>>> {
787        Ok(self.verification_reports.read().await.get(id).cloned())
788    }
789
790    fn backend_name(&self) -> &str {
791        "memory"
792    }
793}
794
795// ============================================================================
796// Tests
797// ============================================================================
798
799#[cfg(test)]
800mod tests {
801    use super::*;
802    use crate::hitl::ConfirmationPolicy;
803    use crate::permissions::PermissionPolicy;
804    use crate::prompts::PlanningMode;
805    use crate::queue::SessionQueueConfig;
806    use tempfile::tempdir;
807
808    fn create_test_session_data() -> SessionData {
809        SessionData {
810            id: "test-session-1".to_string(),
811            config: SessionConfig {
812                name: "Test Session".to_string(),
813                workspace: "/tmp/workspace".to_string(),
814                system_prompt: Some("You are helpful.".to_string()),
815                max_context_length: 200000,
816                auto_compact: false,
817                auto_compact_threshold: DEFAULT_AUTO_COMPACT_THRESHOLD,
818                storage_type: crate::config::StorageBackend::File,
819                queue_config: None,
820                confirmation_policy: None,
821                permission_policy: None,
822                parent_id: None,
823                security_config: None,
824                hook_engine: None,
825                planning_mode: PlanningMode::default(),
826                goal_tracking: false,
827            },
828            state: SessionState::Active,
829            messages: vec![
830                Message::user("Hello"),
831                Message {
832                    role: "assistant".to_string(),
833                    content: vec![crate::llm::ContentBlock::Text {
834                        text: "Hi there!".to_string(),
835                    }],
836                    reasoning_content: None,
837                },
838            ],
839            context_usage: ContextUsage {
840                used_tokens: 100,
841                max_tokens: 200000,
842                percent: 0.0005,
843                turns: 2,
844            },
845            total_usage: TokenUsage {
846                prompt_tokens: 50,
847                completion_tokens: 50,
848                total_tokens: 100,
849                cache_read_tokens: None,
850                cache_write_tokens: None,
851            },
852            tool_names: vec!["bash".to_string(), "read".to_string()],
853            thinking_enabled: false,
854            thinking_budget: None,
855            created_at: 1700000000,
856            updated_at: 1700000100,
857            llm_config: None,
858            tasks: vec![],
859            parent_id: None,
860            total_cost: 0.0,
861            model_name: None,
862            cost_records: Vec::new(),
863        }
864    }
865
866    fn create_test_verification_report() -> VerificationReport {
867        VerificationReport::new(
868            "program:test",
869            vec![crate::verification::VerificationCheck::required(
870                "check:test",
871                "test",
872                "Run tests",
873            )
874            .with_status(crate::verification::VerificationStatus::Passed)],
875        )
876    }
877
878    async fn create_test_run_records() -> Vec<RunRecord> {
879        let runs = crate::run::InMemoryRunStore::new();
880        let run = runs.create_run("session/a", "persist run").await;
881        runs.record_event(
882            &run.id,
883            crate::agent::AgentEvent::Start {
884                prompt: "persist run".to_string(),
885            },
886        )
887        .await;
888        runs.records().await
889    }
890
891    // ========================================================================
892    // FileSessionStore Tests
893    // ========================================================================
894
895    #[tokio::test]
896    async fn test_file_store_save_and_load() {
897        let dir = tempdir().unwrap();
898        let store = FileSessionStore::new(dir.path()).await.unwrap();
899
900        let session = create_test_session_data();
901
902        // Save
903        store.save(&session).await.unwrap();
904
905        // Load
906        let loaded = store.load(&session.id).await.unwrap();
907        assert!(loaded.is_some());
908
909        let loaded = loaded.unwrap();
910        assert_eq!(loaded.id, session.id);
911        assert_eq!(loaded.config.name, session.config.name);
912        assert_eq!(loaded.messages.len(), 2);
913        assert_eq!(loaded.state, SessionState::Active);
914    }
915
916    #[tokio::test]
917    async fn test_file_store_load_nonexistent() {
918        let dir = tempdir().unwrap();
919        let store = FileSessionStore::new(dir.path()).await.unwrap();
920
921        let loaded = store.load("nonexistent").await.unwrap();
922        assert!(loaded.is_none());
923    }
924
925    #[tokio::test]
926    async fn test_file_store_delete() {
927        let dir = tempdir().unwrap();
928        let store = FileSessionStore::new(dir.path()).await.unwrap();
929
930        let session = create_test_session_data();
931        store.save(&session).await.unwrap();
932
933        // Verify exists
934        assert!(store.exists(&session.id).await.unwrap());
935
936        // Delete
937        store.delete(&session.id).await.unwrap();
938
939        // Verify gone
940        assert!(!store.exists(&session.id).await.unwrap());
941        assert!(store.load(&session.id).await.unwrap().is_none());
942    }
943
944    #[tokio::test]
945    async fn test_file_store_save_and_load_artifacts() {
946        let dir = tempdir().unwrap();
947        let store = FileSessionStore::new(dir.path()).await.unwrap();
948        let artifacts = ArtifactStore::new();
949        artifacts.put(crate::tools::ToolArtifact {
950            artifact_id: "tool-output:test:a".to_string(),
951            artifact_uri: "a3s://tool-output/test/a".to_string(),
952            tool_name: "test".to_string(),
953            content: "artifact content".to_string(),
954            original_bytes: 16,
955            shown_bytes: 4,
956        });
957
958        store.save_artifacts("session/a", &artifacts).await.unwrap();
959        let loaded = store
960            .load_artifacts("session/a")
961            .await
962            .unwrap()
963            .expect("artifacts");
964
965        assert_eq!(loaded.len(), 1);
966        assert_eq!(
967            loaded
968                .get("a3s://tool-output/test/a")
969                .expect("artifact")
970                .content,
971            "artifact content"
972        );
973    }
974
975    #[tokio::test]
976    async fn test_file_store_save_and_load_trace_events() {
977        let dir = tempdir().unwrap();
978        let store = FileSessionStore::new(dir.path()).await.unwrap();
979        let event = TraceEvent::tool_execution(
980            "read",
981            true,
982            0,
983            std::time::Duration::from_millis(9),
984            12,
985            Some(&serde_json::json!({
986                "artifact": {
987                    "artifact_uri": "a3s://tool-output/read/abc"
988                }
989            })),
990        );
991
992        store
993            .save_trace_events("session/a", std::slice::from_ref(&event))
994            .await
995            .unwrap();
996        let loaded = store
997            .load_trace_events("session/a")
998            .await
999            .unwrap()
1000            .expect("trace events");
1001
1002        assert_eq!(loaded, vec![event]);
1003    }
1004
1005    #[tokio::test]
1006    async fn test_file_store_save_and_load_run_records() {
1007        let dir = tempdir().unwrap();
1008        let store = FileSessionStore::new(dir.path()).await.unwrap();
1009        let records = create_test_run_records().await;
1010
1011        store.save_run_records("session/a", &records).await.unwrap();
1012        let loaded = store
1013            .load_run_records("session/a")
1014            .await
1015            .unwrap()
1016            .expect("run records");
1017
1018        assert_eq!(loaded.len(), 1);
1019        assert_eq!(loaded[0].snapshot.prompt, "persist run");
1020        assert_eq!(loaded[0].events.len(), 1);
1021    }
1022
1023    #[tokio::test]
1024    async fn test_file_store_save_and_load_verification_reports() {
1025        let dir = tempdir().unwrap();
1026        let store = FileSessionStore::new(dir.path()).await.unwrap();
1027        let report = create_test_verification_report();
1028
1029        store
1030            .save_verification_reports("session/a", std::slice::from_ref(&report))
1031            .await
1032            .unwrap();
1033        let loaded = store
1034            .load_verification_reports("session/a")
1035            .await
1036            .unwrap()
1037            .expect("verification reports");
1038
1039        assert_eq!(loaded, vec![report]);
1040    }
1041
1042    #[tokio::test]
1043    async fn test_memory_store_save_load_and_delete_artifacts() {
1044        let store = MemorySessionStore::new();
1045        let session = create_test_session_data();
1046        store.save(&session).await.unwrap();
1047        let artifacts = ArtifactStore::new();
1048        artifacts.put(crate::tools::ToolArtifact {
1049            artifact_id: "tool-output:test:a".to_string(),
1050            artifact_uri: "a3s://tool-output/test/a".to_string(),
1051            tool_name: "test".to_string(),
1052            content: "artifact content".to_string(),
1053            original_bytes: 16,
1054            shown_bytes: 4,
1055        });
1056
1057        store.save_artifacts(&session.id, &artifacts).await.unwrap();
1058        assert!(store
1059            .load_artifacts(&session.id)
1060            .await
1061            .unwrap()
1062            .expect("artifacts")
1063            .get("a3s://tool-output/test/a")
1064            .is_some());
1065
1066        store.delete(&session.id).await.unwrap();
1067        assert!(store.load_artifacts(&session.id).await.unwrap().is_none());
1068    }
1069
1070    #[tokio::test]
1071    async fn test_memory_store_save_load_and_delete_trace_events() {
1072        let store = MemorySessionStore::new();
1073        let session = create_test_session_data();
1074        let event = TraceEvent::tool_execution(
1075            "grep",
1076            false,
1077            1,
1078            std::time::Duration::from_millis(2),
1079            24,
1080            None,
1081        );
1082
1083        store.save(&session).await.unwrap();
1084        store
1085            .save_trace_events(&session.id, std::slice::from_ref(&event))
1086            .await
1087            .unwrap();
1088        let loaded = store
1089            .load_trace_events(&session.id)
1090            .await
1091            .unwrap()
1092            .expect("trace events");
1093        assert_eq!(loaded, vec![event]);
1094
1095        store.delete(&session.id).await.unwrap();
1096        assert!(store
1097            .load_trace_events(&session.id)
1098            .await
1099            .unwrap()
1100            .is_none());
1101    }
1102
1103    #[tokio::test]
1104    async fn test_memory_store_save_load_and_delete_run_records() {
1105        let store = MemorySessionStore::new();
1106        let session = create_test_session_data();
1107        let records = create_test_run_records().await;
1108
1109        store.save(&session).await.unwrap();
1110        store.save_run_records(&session.id, &records).await.unwrap();
1111        let loaded = store
1112            .load_run_records(&session.id)
1113            .await
1114            .unwrap()
1115            .expect("run records");
1116        assert_eq!(loaded.len(), 1);
1117        assert_eq!(loaded[0].events.len(), 1);
1118
1119        store.delete(&session.id).await.unwrap();
1120        assert!(store.load_run_records(&session.id).await.unwrap().is_none());
1121    }
1122
1123    #[tokio::test]
1124    async fn test_memory_store_save_load_and_delete_verification_reports() {
1125        let store = MemorySessionStore::new();
1126        let session = create_test_session_data();
1127        let report = create_test_verification_report();
1128
1129        store.save(&session).await.unwrap();
1130        store
1131            .save_verification_reports(&session.id, std::slice::from_ref(&report))
1132            .await
1133            .unwrap();
1134        let loaded = store
1135            .load_verification_reports(&session.id)
1136            .await
1137            .unwrap()
1138            .expect("verification reports");
1139        assert_eq!(loaded, vec![report]);
1140
1141        store.delete(&session.id).await.unwrap();
1142        assert!(store
1143            .load_verification_reports(&session.id)
1144            .await
1145            .unwrap()
1146            .is_none());
1147    }
1148
1149    #[tokio::test]
1150    async fn test_file_store_list() {
1151        let dir = tempdir().unwrap();
1152        let store = FileSessionStore::new(dir.path()).await.unwrap();
1153
1154        // Initially empty
1155        let list = store.list().await.unwrap();
1156        assert!(list.is_empty());
1157
1158        // Add sessions
1159        for i in 1..=3 {
1160            let mut session = create_test_session_data();
1161            session.id = format!("session-{}", i);
1162            store.save(&session).await.unwrap();
1163        }
1164
1165        // List should have 3 sessions
1166        let list = store.list().await.unwrap();
1167        assert_eq!(list.len(), 3);
1168        assert!(list.contains(&"session-1".to_string()));
1169        assert!(list.contains(&"session-2".to_string()));
1170        assert!(list.contains(&"session-3".to_string()));
1171    }
1172
1173    #[tokio::test]
1174    async fn test_file_store_overwrite() {
1175        let dir = tempdir().unwrap();
1176        let store = FileSessionStore::new(dir.path()).await.unwrap();
1177
1178        let mut session = create_test_session_data();
1179        store.save(&session).await.unwrap();
1180
1181        // Modify and save again
1182        session.messages.push(Message::user("Another message"));
1183        session.updated_at = 1700000200;
1184        store.save(&session).await.unwrap();
1185
1186        // Load and verify
1187        let loaded = store.load(&session.id).await.unwrap().unwrap();
1188        assert_eq!(loaded.messages.len(), 3);
1189        assert_eq!(loaded.updated_at, 1700000200);
1190    }
1191
1192    #[tokio::test]
1193    async fn test_file_store_path_traversal_prevention() {
1194        let dir = tempdir().unwrap();
1195        let store = FileSessionStore::new(dir.path()).await.unwrap();
1196
1197        // Attempt path traversal - should be sanitized
1198        let mut session = create_test_session_data();
1199        session.id = "../../../etc/passwd".to_string();
1200        store.save(&session).await.unwrap();
1201
1202        // File should be in the store directory, not /etc/passwd
1203        let files: Vec<_> = std::fs::read_dir(dir.path())
1204            .unwrap()
1205            .filter_map(|e| e.ok())
1206            .collect();
1207        assert_eq!(files.len(), 1);
1208
1209        // Should still be loadable with sanitized ID
1210        let loaded = store.load(&session.id).await.unwrap();
1211        assert!(loaded.is_some());
1212    }
1213
1214    #[tokio::test]
1215    async fn test_file_store_with_policies() {
1216        let dir = tempdir().unwrap();
1217        let store = FileSessionStore::new(dir.path()).await.unwrap();
1218
1219        let mut session = create_test_session_data();
1220        session.config.confirmation_policy = Some(ConfirmationPolicy::enabled());
1221        session.config.permission_policy = Some(PermissionPolicy::new().allow("Bash(cargo:*)"));
1222        session.config.queue_config = Some(SessionQueueConfig::default());
1223
1224        store.save(&session).await.unwrap();
1225
1226        let loaded = store.load(&session.id).await.unwrap().unwrap();
1227        assert!(loaded.config.confirmation_policy.is_some());
1228        assert!(loaded.config.permission_policy.is_some());
1229        assert!(loaded.config.queue_config.is_some());
1230    }
1231
1232    #[tokio::test]
1233    async fn test_file_store_with_llm_config() {
1234        let dir = tempdir().unwrap();
1235        let store = FileSessionStore::new(dir.path()).await.unwrap();
1236
1237        let mut session = create_test_session_data();
1238        session.llm_config = Some(LlmConfigData {
1239            provider: "anthropic".to_string(),
1240            model: "claude-3-5-sonnet-20241022".to_string(),
1241            api_key: Some("secret".to_string()), // Should NOT be saved
1242            base_url: None,
1243        });
1244
1245        store.save(&session).await.unwrap();
1246
1247        let loaded = store.load(&session.id).await.unwrap().unwrap();
1248        let llm_config = loaded.llm_config.unwrap();
1249        assert_eq!(llm_config.provider, "anthropic");
1250        assert_eq!(llm_config.model, "claude-3-5-sonnet-20241022");
1251        // API key should not be persisted
1252        assert!(llm_config.api_key.is_none());
1253    }
1254
1255    // ========================================================================
1256    // MemorySessionStore Tests
1257    // ========================================================================
1258
1259    #[tokio::test]
1260    async fn test_memory_store_save_and_load() {
1261        let store = MemorySessionStore::new();
1262        let session = create_test_session_data();
1263
1264        store.save(&session).await.unwrap();
1265
1266        let loaded = store.load(&session.id).await.unwrap();
1267        assert!(loaded.is_some());
1268        assert_eq!(loaded.unwrap().id, session.id);
1269    }
1270
1271    #[tokio::test]
1272    async fn test_memory_store_delete() {
1273        let store = MemorySessionStore::new();
1274        let session = create_test_session_data();
1275
1276        store.save(&session).await.unwrap();
1277        assert!(store.exists(&session.id).await.unwrap());
1278
1279        store.delete(&session.id).await.unwrap();
1280        assert!(!store.exists(&session.id).await.unwrap());
1281    }
1282
1283    #[tokio::test]
1284    async fn test_memory_store_list() {
1285        let store = MemorySessionStore::new();
1286
1287        for i in 1..=3 {
1288            let mut session = create_test_session_data();
1289            session.id = format!("session-{}", i);
1290            store.save(&session).await.unwrap();
1291        }
1292
1293        let list = store.list().await.unwrap();
1294        assert_eq!(list.len(), 3);
1295    }
1296
1297    // ========================================================================
1298    // SessionData Tests
1299    // ========================================================================
1300
1301    #[test]
1302    fn test_session_data_serialization() {
1303        let session = create_test_session_data();
1304        let json = serde_json::to_string(&session).unwrap();
1305        let parsed: SessionData = serde_json::from_str(&json).unwrap();
1306
1307        assert_eq!(parsed.id, session.id);
1308        assert_eq!(parsed.messages.len(), session.messages.len());
1309    }
1310
1311    #[test]
1312    fn test_tool_names_from_definitions() {
1313        let tools = vec![
1314            crate::llm::ToolDefinition {
1315                name: "bash".to_string(),
1316                description: "Execute bash".to_string(),
1317                parameters: serde_json::json!({}),
1318            },
1319            crate::llm::ToolDefinition {
1320                name: "read".to_string(),
1321                description: "Read file".to_string(),
1322                parameters: serde_json::json!({}),
1323            },
1324        ];
1325
1326        let names = SessionData::tool_names_from_definitions(&tools);
1327        assert_eq!(names, vec!["bash", "read"]);
1328    }
1329
1330    // ========================================================================
1331    // Sanitization Tests
1332    // ========================================================================
1333
1334    #[tokio::test]
1335    async fn test_file_store_backslash_sanitization() {
1336        let dir = tempdir().unwrap();
1337        let store = FileSessionStore::new(dir.path()).await.unwrap();
1338
1339        let mut session = create_test_session_data();
1340        session.id = r"foo\bar\baz".to_string();
1341        store.save(&session).await.unwrap();
1342
1343        let loaded = store.load(&session.id).await.unwrap();
1344        assert!(loaded.is_some());
1345
1346        let loaded = loaded.unwrap();
1347        assert_eq!(loaded.id, session.id);
1348
1349        // Verify the file on disk uses sanitized name
1350        let expected_path = dir.path().join("foo_bar_baz.json");
1351        assert!(expected_path.exists());
1352    }
1353
1354    #[tokio::test]
1355    async fn test_file_store_mixed_separator_sanitization() {
1356        let dir = tempdir().unwrap();
1357        let store = FileSessionStore::new(dir.path()).await.unwrap();
1358
1359        let mut session = create_test_session_data();
1360        session.id = r"foo/bar\baz..qux".to_string();
1361        store.save(&session).await.unwrap();
1362
1363        let loaded = store.load(&session.id).await.unwrap();
1364        assert!(loaded.is_some());
1365
1366        let loaded = loaded.unwrap();
1367        assert_eq!(loaded.id, session.id);
1368
1369        // / -> _, \ -> _, .. -> _
1370        let expected_path = dir.path().join("foo_bar_baz_qux.json");
1371        assert!(expected_path.exists());
1372    }
1373
1374    // ========================================================================
1375    // Error Recovery Tests
1376    // ========================================================================
1377
1378    #[tokio::test]
1379    async fn test_file_store_corrupted_json_recovery() {
1380        let dir = tempdir().unwrap();
1381        let store = FileSessionStore::new(dir.path()).await.unwrap();
1382
1383        // Manually write invalid JSON to a session file
1384        let corrupted_path = dir.path().join("test-id.json");
1385        tokio::fs::write(&corrupted_path, b"not valid json {{{")
1386            .await
1387            .unwrap();
1388
1389        // Loading should return an error, not panic
1390        let result = store.load("test-id").await;
1391        assert!(result.is_err());
1392    }
1393
1394    // ========================================================================
1395    // Exists Tests
1396    // ========================================================================
1397
1398    #[tokio::test]
1399    async fn test_file_store_exists() {
1400        let dir = tempdir().unwrap();
1401        let store = FileSessionStore::new(dir.path()).await.unwrap();
1402
1403        let session = create_test_session_data();
1404
1405        // Not yet saved
1406        assert!(!store.exists(&session.id).await.unwrap());
1407
1408        // Save and verify exists
1409        store.save(&session).await.unwrap();
1410        assert!(store.exists(&session.id).await.unwrap());
1411
1412        // Delete and verify gone
1413        store.delete(&session.id).await.unwrap();
1414        assert!(!store.exists(&session.id).await.unwrap());
1415    }
1416
1417    #[tokio::test]
1418    async fn test_memory_store_exists() {
1419        let store = MemorySessionStore::new();
1420
1421        // Unknown id
1422        assert!(!store.exists("unknown-id").await.unwrap());
1423
1424        // Save and verify exists
1425        let session = create_test_session_data();
1426        store.save(&session).await.unwrap();
1427        assert!(store.exists(&session.id).await.unwrap());
1428    }
1429
1430    #[tokio::test]
1431    async fn test_file_store_health_check() {
1432        let dir = tempfile::tempdir().unwrap();
1433        let store = FileSessionStore::new(dir.path()).await.unwrap();
1434        assert!(store.health_check().await.is_ok());
1435        assert_eq!(store.backend_name(), "file");
1436    }
1437
1438    #[tokio::test]
1439    async fn test_file_store_health_check_bad_dir() {
1440        let store = FileSessionStore {
1441            dir: std::path::PathBuf::from("/nonexistent/path/that/does/not/exist"),
1442        };
1443        assert!(store.health_check().await.is_err());
1444    }
1445
1446    #[tokio::test]
1447    async fn test_memory_store_health_check() {
1448        let store = MemorySessionStore::new();
1449        assert!(store.health_check().await.is_ok());
1450        assert_eq!(store.backend_name(), "memory");
1451    }
1452
1453    // ========================================================================
1454    // Session Resume Boundary Tests
1455    // ========================================================================
1456
1457    #[tokio::test]
1458    async fn test_file_store_load_empty_file() {
1459        let dir = tempdir().unwrap();
1460        let store = FileSessionStore::new(dir.path()).await.unwrap();
1461
1462        // Write an empty file — JSON parse must fail gracefully, not panic
1463        let empty_path = dir.path().join("empty-session.json");
1464        tokio::fs::write(&empty_path, b"").await.unwrap();
1465
1466        let result = store.load("empty-session").await;
1467        assert!(
1468            result.is_err(),
1469            "Empty file must return error, not Ok(None)"
1470        );
1471    }
1472
1473    #[tokio::test]
1474    async fn test_file_store_load_partial_json() {
1475        let dir = tempdir().unwrap();
1476        let store = FileSessionStore::new(dir.path()).await.unwrap();
1477
1478        // Truncated JSON — simulates a crash mid-write
1479        let partial_path = dir.path().join("partial-session.json");
1480        tokio::fs::write(&partial_path, b"{\"id\":\"partial-session\",\"message")
1481            .await
1482            .unwrap();
1483
1484        let result = store.load("partial-session").await;
1485        assert!(result.is_err(), "Partial JSON must return error");
1486    }
1487
1488    #[tokio::test]
1489    async fn test_file_store_concurrent_save() {
1490        let dir = tempdir().unwrap();
1491        let store = std::sync::Arc::new(FileSessionStore::new(dir.path()).await.unwrap());
1492
1493        let session = create_test_session_data();
1494        let id = session.id.clone();
1495
1496        // First save to create the file
1497        store.save(&session).await.unwrap();
1498
1499        // Spawn multiple concurrent saves — last write wins, no corruption
1500        let mut handles = Vec::new();
1501        for _ in 0..5 {
1502            let s = store.clone();
1503            let sess = session.clone();
1504            handles.push(tokio::spawn(async move { s.save(&sess).await }));
1505        }
1506        for h in handles {
1507            h.await.unwrap().unwrap();
1508        }
1509
1510        // File must be loadable after concurrent writes
1511        let loaded = store.load(&id).await.unwrap();
1512        assert!(loaded.is_some());
1513        assert_eq!(loaded.unwrap().id, id);
1514    }
1515
1516    #[tokio::test]
1517    async fn test_file_store_load_nonexistent_returns_none() {
1518        let dir = tempdir().unwrap();
1519        let store = FileSessionStore::new(dir.path()).await.unwrap();
1520
1521        let result = store.load("does-not-exist-at-all").await.unwrap();
1522        assert!(result.is_none(), "Missing session must return Ok(None)");
1523    }
1524}