Skip to main content

everruns_host/
in_memory.rs

1// In-memory host stores.
2// Decision: runtime ships batteries-included in-memory stores for public
3// embedding so common capabilities work without depending on server internals.
4
5use crate::SessionMutator;
6use crate::{SessionFileSystemFactory, SessionFileSystemFactoryContext};
7use async_trait::async_trait;
8use chrono::{DateTime, Utc};
9use everruns_capability::CapabilityRef;
10use everruns_core::agent_definition::AgentDefinition;
11use everruns_core::harness_definition::HarnessDefinition;
12use everruns_core::session::ExecutionSession;
13use everruns_core::session_file::{
14    FileInfo, FileStat, GrepMatch, GrepOptions, GrepSearchResult, InitialFile, SessionFile,
15    build_grep_search_result,
16};
17use everruns_core::{
18    COMPACTION_CHECKPOINT_FORMAT_VERSION, CompactionCheckpoint, CompactionCheckpointStore,
19    ProactiveCompactionAttempt, ProactiveCompactionAttemptTracker, execution_loading::AgentStore,
20    execution_loading::HarnessStore, execution_loading::SessionStore,
21    provider_resolution::ProviderStore, session_files::SessionFileSystem,
22    session_services::KeyInfo, session_services::SecretInfo, session_services::SessionStorageStore,
23};
24use everruns_provider::credential_provider::CredentialProvider;
25use everruns_provider::error::{AgentLoopError, Result};
26use everruns_provider::model_spec::ModelSpec;
27use everruns_provider::provider::DriverId;
28use everruns_provider::runtime_provider::ProviderKey;
29use everruns_provider::typed_id::{AgentId, HarnessId, ModelId, SessionId};
30use std::collections::{BTreeSet, HashMap};
31use std::sync::Arc;
32use tokio::sync::RwLock;
33use uuid::Uuid;
34
35type CheckpointKey = (SessionId, String, String, u32);
36
37/// Application-grade in-memory compaction checkpoint store for embedded hosts.
38#[derive(Debug, Default)]
39pub struct InMemoryCompactionCheckpointStore {
40    checkpoints: RwLock<HashMap<CheckpointKey, CompactionCheckpoint>>,
41    proactive_attempts: ProactiveCompactionAttemptTracker,
42}
43
44#[async_trait]
45impl CompactionCheckpointStore for InMemoryCompactionCheckpointStore {
46    async fn get_latest(
47        &self,
48        session_id: SessionId,
49        provider_type: &str,
50        model: &str,
51    ) -> Result<Option<CompactionCheckpoint>> {
52        Ok(self
53            .checkpoints
54            .read()
55            .await
56            .get(&(
57                session_id,
58                provider_type.to_string(),
59                model.to_string(),
60                COMPACTION_CHECKPOINT_FORMAT_VERSION,
61            ))
62            .cloned())
63    }
64
65    async fn install(&self, checkpoint: CompactionCheckpoint) -> Result<bool> {
66        let key = (
67            checkpoint.session_id,
68            checkpoint.provider_type.clone(),
69            checkpoint.model.clone(),
70            checkpoint.format_version,
71        );
72        let mut checkpoints = self.checkpoints.write().await;
73        if checkpoints
74            .get(&key)
75            .is_some_and(|current| current.source_sequence >= checkpoint.source_sequence)
76        {
77            return Ok(false);
78        }
79        checkpoints.insert(key, checkpoint);
80        Ok(true)
81    }
82
83    async fn get_proactive_attempt(
84        &self,
85        session_id: SessionId,
86        provider_type: &str,
87        model: &str,
88    ) -> Result<Option<ProactiveCompactionAttempt>> {
89        Ok(self
90            .proactive_attempts
91            .get(session_id, provider_type, model)
92            .await)
93    }
94
95    async fn record_proactive_attempt(
96        &self,
97        session_id: SessionId,
98        provider_type: &str,
99        model: &str,
100        attempt: ProactiveCompactionAttempt,
101    ) -> Result<()> {
102        self.proactive_attempts
103            .record(session_id, provider_type, model, attempt)
104            .await;
105        Ok(())
106    }
107}
108
109/// Application-grade in-memory agent store for embedded hosts.
110#[derive(Debug, Default, Clone)]
111pub struct InMemoryAgentStore {
112    agents: Arc<RwLock<HashMap<AgentId, AgentDefinition>>>,
113}
114
115impl InMemoryAgentStore {
116    /// Create an empty agent store.
117    pub fn new() -> Self {
118        Self::default()
119    }
120
121    /// Insert or replace an agent definition.
122    pub async fn add_agent(&self, agent: AgentDefinition) {
123        self.agents.write().await.insert(agent.id, agent);
124    }
125
126    /// Return every configured agent id.
127    pub async fn agent_ids(&self) -> Vec<AgentId> {
128        self.agents.read().await.keys().copied().collect()
129    }
130
131    /// Remove every configured agent.
132    pub async fn clear(&self) {
133        self.agents.write().await.clear();
134    }
135}
136
137#[async_trait]
138impl AgentStore for InMemoryAgentStore {
139    async fn get_agent(&self, agent_id: AgentId) -> Result<Option<AgentDefinition>> {
140        Ok(self.agents.read().await.get(&agent_id).cloned())
141    }
142}
143
144/// Application-grade in-memory harness store for embedded hosts.
145#[derive(Debug, Default, Clone)]
146pub struct InMemoryHarnessStore {
147    harnesses: Arc<RwLock<HashMap<HarnessId, HarnessDefinition>>>,
148}
149
150impl InMemoryHarnessStore {
151    /// Create an empty harness store.
152    pub fn new() -> Self {
153        Self::default()
154    }
155
156    /// Insert or replace a harness definition.
157    pub async fn add_harness(&self, harness_id: HarnessId, harness: HarnessDefinition) {
158        self.harnesses.write().await.insert(harness_id, harness);
159    }
160}
161
162#[async_trait]
163impl HarnessStore for InMemoryHarnessStore {
164    async fn get_harness(&self, harness_id: HarnessId) -> Result<Option<HarnessDefinition>> {
165        Ok(self.harnesses.read().await.get(&harness_id).cloned())
166    }
167}
168
169/// Application-grade in-memory provider store for embedded hosts.
170#[derive(Debug, Default, Clone)]
171pub struct InMemoryProviderStore {
172    models: Arc<RwLock<HashMap<ModelId, ModelSpec>>>,
173    default_model: Arc<RwLock<Option<ModelSpec>>>,
174    provider_configs:
175        Arc<RwLock<HashMap<ProviderKey, everruns_provider::driver_registry::ProviderConfig>>>,
176}
177
178impl InMemoryProviderStore {
179    /// Create an empty provider store.
180    pub fn new() -> Self {
181        Self::default()
182    }
183
184    /// Configure a default model from explicitly injected credentials.
185    pub async fn from_credential_provider(provider: &dyn CredentialProvider) -> Self {
186        let store = Self::new();
187        if let Some(credentials) = provider
188            .resolve(&DriverId::OpenAI)
189            .filter(|credentials| credentials.api_key.is_some())
190        {
191            let config = everruns_provider::driver_registry::ProviderConfig::new(DriverId::OpenAI)
192                .with_api_key(credentials.api_key.expect("filtered above"));
193            let config = match credentials.base_url {
194                Some(base_url) => config.with_base_url(base_url),
195                None => config,
196            };
197            store.set_provider_config(config).await;
198            store
199                .set_default_model_spec(ModelSpec::on("openai", "gpt-5.4"))
200                .await;
201        } else if let Some(credentials) = provider
202            .resolve(&DriverId::Anthropic)
203            .filter(|credentials| credentials.api_key.is_some())
204        {
205            let config =
206                everruns_provider::driver_registry::ProviderConfig::new(DriverId::Anthropic)
207                    .with_api_key(credentials.api_key.expect("filtered above"));
208            let config = match credentials.base_url {
209                Some(base_url) => config.with_base_url(base_url),
210                None => config,
211            };
212            store.set_provider_config(config).await;
213            store
214                .set_default_model_spec(ModelSpec::on("anthropic", "claude-sonnet-4-20250514"))
215                .await;
216        }
217        store
218    }
219
220    /// Create a provider store with one default model.
221    pub async fn with_default(model: ModelSpec) -> Self {
222        let store = Self::new();
223        store.set_default_model_spec(model).await;
224        store
225    }
226
227    /// Insert or replace a model by id.
228    pub async fn add_model(&self, model_id: ModelId, model: ModelSpec) {
229        self.models.write().await.insert(model_id, model);
230    }
231
232    /// Set the default model used when a session has no explicit model id.
233    pub async fn set_default_model_spec(&self, model: ModelSpec) {
234        *self.default_model.write().await = Some(model);
235    }
236
237    /// Set credential-bearing construction material independently from model identity.
238    pub async fn set_provider_config(
239        &self,
240        config: everruns_provider::driver_registry::ProviderConfig,
241    ) {
242        self.provider_configs
243            .write()
244            .await
245            .insert(config.provider.clone(), config);
246    }
247
248    /// Remove all configured models and the default.
249    pub async fn clear(&self) {
250        self.models.write().await.clear();
251        *self.default_model.write().await = None;
252        self.provider_configs.write().await.clear();
253    }
254}
255
256#[async_trait]
257impl ProviderStore for InMemoryProviderStore {
258    async fn get_model_spec(&self, model_id: ModelId) -> Result<Option<ModelSpec>> {
259        Ok(self.models.read().await.get(&model_id).cloned())
260    }
261
262    async fn get_default_model_spec(&self) -> Result<Option<ModelSpec>> {
263        Ok(self.default_model.read().await.clone())
264    }
265
266    async fn get_provider_config(
267        &self,
268        provider: &ProviderKey,
269    ) -> Result<Option<everruns_provider::driver_registry::ProviderConfig>> {
270        Ok(self.provider_configs.read().await.get(provider).cloned())
271    }
272}
273
274/// In-memory `SessionStore` + `SessionMutator` for embedded runtimes.
275///
276/// This is the default session backend used by [`crate::InProcessRuntime`].
277/// Holds portable [`ExecutionSession`] values (EVE-882): no stored Session
278/// persistence records exist in the embedded host.
279#[derive(Debug, Default, Clone)]
280pub struct InMemorySessionStore {
281    sessions: Arc<RwLock<HashMap<SessionId, ExecutionSession>>>,
282}
283
284impl InMemorySessionStore {
285    /// Create an empty in-memory session store.
286    pub fn new() -> Self {
287        Self {
288            sessions: Arc::new(RwLock::new(HashMap::new())),
289        }
290    }
291
292    /// Insert or replace a session in the store.
293    pub async fn add_session(&self, session: ExecutionSession) {
294        self.sessions.write().await.insert(session.id, session);
295    }
296
297    /// Return every configured session id.
298    pub async fn session_ids(&self) -> Vec<SessionId> {
299        self.sessions.read().await.keys().copied().collect()
300    }
301
302    /// Remove every configured session.
303    pub async fn clear(&self) {
304        self.sessions.write().await.clear();
305    }
306}
307
308#[async_trait]
309impl SessionStore for InMemorySessionStore {
310    async fn get_session(&self, session_id: SessionId) -> Result<Option<ExecutionSession>> {
311        Ok(self.sessions.read().await.get(&session_id).cloned())
312    }
313}
314
315#[async_trait]
316impl SessionMutator for InMemorySessionStore {
317    async fn update_session_title(
318        &self,
319        session_id: SessionId,
320        title: String,
321    ) -> Result<ExecutionSession> {
322        let mut sessions = self.sessions.write().await;
323        let session = sessions
324            .get_mut(&session_id)
325            .ok_or_else(|| AgentLoopError::store(format!("session not found: {session_id}")))?;
326        session.title = Some(title);
327        Ok(session.clone())
328    }
329
330    async fn upsert_session_capability(
331        &self,
332        session_id: SessionId,
333        capability: CapabilityRef,
334    ) -> Result<ExecutionSession> {
335        let mut sessions = self.sessions.write().await;
336        let session = sessions
337            .get_mut(&session_id)
338            .ok_or_else(|| AgentLoopError::store(format!("session not found: {session_id}")))?;
339        if let Some(existing) = session
340            .capabilities
341            .iter_mut()
342            .find(|existing| existing.capability_id() == capability.capability_id())
343        {
344            *existing = capability;
345        } else {
346            session.capabilities.push(capability);
347        }
348        Ok(session.clone())
349    }
350
351    async fn remove_session_capability(
352        &self,
353        session_id: SessionId,
354        capability_id: &str,
355    ) -> Result<ExecutionSession> {
356        let mut sessions = self.sessions.write().await;
357        let session = sessions
358            .get_mut(&session_id)
359            .ok_or_else(|| AgentLoopError::store(format!("session not found: {session_id}")))?;
360        session
361            .capabilities
362            .retain(|capability| capability.capability_id() != capability_id);
363        Ok(session.clone())
364    }
365}
366
367#[derive(Debug, Clone)]
368struct FileEntry {
369    file: SessionFile,
370}
371
372/// In-memory implementation of the session virtual filesystem.
373///
374/// Paths accept either canonical session paths (`/notes.txt`) or `/workspace`
375/// prefixed paths (`/workspace/notes.txt`).
376#[derive(Debug, Default, Clone)]
377pub struct InMemorySessionFileStore {
378    files: Arc<RwLock<HashMap<(SessionId, String), FileEntry>>>,
379}
380
381/// Factory for the runtime's in-memory session filesystem.
382#[derive(Debug, Clone, Default)]
383pub struct InMemorySessionFileSystemFactory;
384
385#[async_trait]
386impl SessionFileSystemFactory for InMemorySessionFileSystemFactory {
387    fn name(&self) -> &'static str {
388        "InMemorySessionFileSystemFactory"
389    }
390
391    async fn create_session_file_system(
392        &self,
393        _context: SessionFileSystemFactoryContext,
394    ) -> Result<Arc<dyn SessionFileSystem>> {
395        Ok(Arc::new(InMemorySessionFileStore::new()))
396    }
397}
398
399impl InMemorySessionFileStore {
400    /// Create an empty in-memory file store.
401    pub fn new() -> Self {
402        Self {
403            files: Arc::new(RwLock::new(HashMap::new())),
404        }
405    }
406
407    /// Seed a file into a session workspace.
408    pub async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
409        let path = normalize_path(&file.path);
410        self.ensure_parent_directories(session_id, &path).await?;
411        self.upsert_file(
412            session_id,
413            &path,
414            &file.content,
415            &file.encoding,
416            file.is_readonly,
417        )
418        .await
419        .map(|_| ())
420    }
421
422    async fn ensure_parent_directories(&self, session_id: SessionId, path: &str) -> Result<()> {
423        let mut current = String::new();
424        for segment in path.trim_start_matches('/').split('/').collect::<Vec<_>>() {
425            if segment.is_empty() {
426                continue;
427            }
428            current.push('/');
429            current.push_str(segment);
430            let is_leaf = current == path;
431            if is_leaf {
432                break;
433            }
434            self.insert_directory_if_missing(session_id, &current)
435                .await?;
436        }
437        Ok(())
438    }
439
440    async fn insert_directory_if_missing(&self, session_id: SessionId, path: &str) -> Result<()> {
441        let path = normalize_path(path);
442        if path == "/" {
443            return Ok(());
444        }
445
446        let mut files = self.files.write().await;
447        files
448            .entry((session_id, path.clone()))
449            .or_insert_with(|| FileEntry {
450                file: SessionFile {
451                    id: Uuid::now_v7(),
452                    session_id: session_id.uuid(),
453                    path: path.clone(),
454                    name: FileInfo::name_from_path(&path),
455                    content: None,
456                    encoding: "text".to_string(),
457                    is_directory: true,
458                    is_readonly: false,
459                    size_bytes: 0,
460                    created_at: Utc::now(),
461                    updated_at: Utc::now(),
462                },
463            });
464        Ok(())
465    }
466
467    async fn upsert_file(
468        &self,
469        session_id: SessionId,
470        path: &str,
471        content: &str,
472        encoding: &str,
473        is_readonly: bool,
474    ) -> Result<SessionFile> {
475        let now = Utc::now();
476        let normalized = normalize_path(path);
477        let mut files = self.files.write().await;
478        let key = (session_id, normalized.clone());
479
480        let file = files
481            .entry(key)
482            .and_modify(|entry| {
483                entry.file.content = Some(content.to_string());
484                entry.file.encoding = encoding.to_string();
485                entry.file.is_directory = false;
486                entry.file.is_readonly = is_readonly;
487                entry.file.size_bytes = content.len() as i64;
488                entry.file.updated_at = now;
489            })
490            .or_insert_with(|| FileEntry {
491                file: SessionFile {
492                    id: Uuid::now_v7(),
493                    session_id: session_id.uuid(),
494                    path: normalized.clone(),
495                    name: FileInfo::name_from_path(&normalized),
496                    content: Some(content.to_string()),
497                    encoding: encoding.to_string(),
498                    is_directory: false,
499                    is_readonly,
500                    size_bytes: content.len() as i64,
501                    created_at: now,
502                    updated_at: now,
503                },
504            })
505            .file
506            .clone();
507
508        Ok(file)
509    }
510
511    /// Read a text file from the workspace, returning `None` when absent.
512    pub async fn read_text(&self, session_id: SessionId, path: &str) -> Option<String> {
513        self.read_file(session_id, path)
514            .await
515            .ok()
516            .flatten()
517            .and_then(|file| file.content)
518    }
519}
520
521#[async_trait]
522impl SessionFileSystem for InMemorySessionFileStore {
523    async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
524        InMemorySessionFileStore::seed_initial_file(self, session_id, file).await
525    }
526
527    async fn read_file(&self, session_id: SessionId, path: &str) -> Result<Option<SessionFile>> {
528        let normalized = normalize_path(path);
529        if normalized == "/" {
530            return Ok(Some(root_directory(session_id)));
531        }
532
533        Ok(self
534            .files
535            .read()
536            .await
537            .get(&(session_id, normalized))
538            .map(|entry| entry.file.clone()))
539    }
540
541    async fn write_file(
542        &self,
543        session_id: SessionId,
544        path: &str,
545        content: &str,
546        encoding: &str,
547    ) -> Result<SessionFile> {
548        let normalized = normalize_path(path);
549        self.ensure_parent_directories(session_id, &normalized)
550            .await?;
551
552        if let Some(existing) = self.read_file(session_id, &normalized).await?
553            && existing.is_readonly
554        {
555            return Err(AgentLoopError::tool(format!(
556                "file is read-only: {}",
557                normalized
558            )));
559        }
560
561        self.upsert_file(session_id, &normalized, content, encoding, false)
562            .await
563    }
564
565    async fn delete_file(
566        &self,
567        session_id: SessionId,
568        path: &str,
569        recursive: bool,
570    ) -> Result<bool> {
571        let normalized = normalize_path(path);
572        if normalized == "/" {
573            return Ok(false);
574        }
575
576        let mut files = self.files.write().await;
577        let key = (session_id, normalized.clone());
578        let Some(existing) = files.get(&key).cloned() else {
579            return Ok(false);
580        };
581
582        if existing.file.is_readonly {
583            return Ok(false);
584        }
585
586        if existing.file.is_directory {
587            let prefix = format!("{normalized}/");
588            let has_children = files
589                .keys()
590                .any(|(sid, candidate)| *sid == session_id && candidate.starts_with(&prefix));
591            if has_children && !recursive {
592                return Ok(false);
593            }
594            files.retain(|(sid, candidate), _| {
595                !(*sid == session_id
596                    && (candidate == &normalized || candidate.starts_with(&prefix)))
597            });
598            return Ok(true);
599        }
600
601        Ok(files.remove(&key).is_some())
602    }
603
604    async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
605        let normalized = normalize_path(path);
606        if normalized != "/" {
607            let Some(dir) = self.read_file(session_id, &normalized).await? else {
608                return Ok(vec![]);
609            };
610            if !dir.is_directory {
611                return Ok(vec![]);
612            }
613        }
614
615        let files = self.files.read().await;
616        let mut infos = Vec::new();
617        let mut seen = BTreeSet::new();
618
619        for ((sid, candidate), entry) in files.iter() {
620            if *sid != session_id {
621                continue;
622            }
623            if FileInfo::parent_path(candidate).as_deref() != Some(normalized.as_str()) {
624                continue;
625            }
626            if seen.insert(candidate.clone()) {
627                infos.push(file_info(&entry.file));
628            }
629        }
630
631        infos.sort_by(|a, b| a.path.cmp(&b.path));
632        Ok(infos)
633    }
634
635    async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
636        let normalized = normalize_path(path);
637        if normalized == "/" {
638            let root = root_directory(session_id);
639            return Ok(Some(FileStat {
640                path: root.path,
641                name: root.name,
642                is_directory: true,
643                is_readonly: false,
644                size_bytes: 0,
645                created_at: root.created_at,
646                updated_at: root.updated_at,
647            }));
648        }
649
650        Ok(self
651            .files
652            .read()
653            .await
654            .get(&(session_id, normalized))
655            .map(|entry| FileStat {
656                path: entry.file.path.clone(),
657                name: entry.file.name.clone(),
658                is_directory: entry.file.is_directory,
659                is_readonly: entry.file.is_readonly,
660                size_bytes: entry.file.size_bytes,
661                created_at: entry.file.created_at,
662                updated_at: entry.file.updated_at,
663            }))
664    }
665
666    async fn grep_files(
667        &self,
668        session_id: SessionId,
669        pattern: &str,
670        path_pattern: Option<&str>,
671    ) -> Result<Vec<GrepMatch>> {
672        let regex = crate::grep_limits::build_regex(pattern)?;
673        crate::grep_limits::validate_path_pattern(path_pattern)?;
674        let path_pattern = path_pattern
675            .map(everruns_core::session_path::GrepPathPattern::new)
676            .transpose()?;
677        let files = self.files.read().await;
678        let mut matches = Vec::new();
679        let mut total_scanned = 0;
680
681        for ((sid, path), entry) in files.iter() {
682            if *sid != session_id || entry.file.is_directory || entry.file.encoding != "text" {
683                continue;
684            }
685            if let Some(path_pattern) = &path_pattern
686                && !path_pattern.is_match(path)
687            {
688                continue;
689            }
690
691            let Some(content) = &entry.file.content else {
692                continue;
693            };
694            if !crate::grep_limits::account_scan(&mut total_scanned, content.len())? {
695                continue;
696            }
697
698            for (idx, line) in content.lines().enumerate() {
699                if regex.is_match(line) {
700                    matches.push(GrepMatch {
701                        path: path.clone(),
702                        line_number: idx + 1,
703                        line: line.to_string(),
704                    });
705                }
706            }
707        }
708
709        Ok(matches)
710    }
711
712    async fn grep_files_with_options(
713        &self,
714        session_id: SessionId,
715        pattern: &str,
716        options: &GrepOptions,
717    ) -> Result<GrepSearchResult> {
718        let regex = crate::grep_limits::build_regex(pattern)?;
719        crate::grep_limits::validate_path_pattern(options.path_pattern.as_deref())?;
720        let path_pattern = options
721            .path_pattern
722            .as_deref()
723            .map(everruns_core::session_path::GrepPathPattern::new)
724            .transpose()?;
725        let files = self.files.read().await;
726        let mut total_scanned = 0;
727        let mut text_files = Vec::new();
728        for ((_, path), entry) in files.iter().filter(|((sid, path), entry)| {
729            *sid == session_id
730                && !entry.file.is_directory
731                && entry.file.encoding == "text"
732                && path_pattern
733                    .as_ref()
734                    .is_none_or(|matcher| matcher.is_match(path))
735        }) {
736            let Some(content) = entry.file.content.as_ref() else {
737                continue;
738            };
739            if crate::grep_limits::account_scan(&mut total_scanned, content.len())? {
740                text_files.push((path.clone(), content.clone()));
741            }
742        }
743        Ok(build_grep_search_result(text_files, &regex, options))
744    }
745
746    async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
747        let normalized = normalize_path(path);
748        self.ensure_parent_directories(session_id, &normalized)
749            .await?;
750        self.insert_directory_if_missing(session_id, &normalized)
751            .await?;
752        let file = self
753            .read_file(session_id, &normalized)
754            .await?
755            .ok_or_else(|| AgentLoopError::store(format!("directory not found: {normalized}")))?;
756        Ok(file_info(&file))
757    }
758
759    fn is_mount_resolver(&self) -> bool {
760        false
761    }
762}
763
764/// In-memory implementation of session key/value and secret storage.
765#[derive(Debug, Default, Clone)]
766pub struct InMemorySessionStorageStore {
767    values: Arc<RwLock<HashMap<(SessionId, String), StorageValue>>>,
768    secrets: Arc<RwLock<HashMap<(SessionId, String), StorageValue>>>,
769}
770
771#[derive(Debug, Clone)]
772struct StorageValue {
773    value: String,
774    created_at: DateTime<Utc>,
775    updated_at: DateTime<Utc>,
776}
777
778impl InMemorySessionStorageStore {
779    /// Create an empty in-memory storage store.
780    pub fn new() -> Self {
781        Self {
782            values: Arc::new(RwLock::new(HashMap::new())),
783            secrets: Arc::new(RwLock::new(HashMap::new())),
784        }
785    }
786}
787
788#[async_trait]
789impl SessionStorageStore for InMemorySessionStorageStore {
790    async fn set_value(&self, session_id: SessionId, key: &str, value: &str) -> Result<()> {
791        upsert_storage(&self.values, session_id, key, value).await;
792        Ok(())
793    }
794
795    async fn get_value(&self, session_id: SessionId, key: &str) -> Result<Option<String>> {
796        Ok(self
797            .values
798            .read()
799            .await
800            .get(&(session_id, key.to_string()))
801            .map(|value| value.value.clone()))
802    }
803
804    async fn delete_value(&self, session_id: SessionId, key: &str) -> Result<bool> {
805        Ok(self
806            .values
807            .write()
808            .await
809            .remove(&(session_id, key.to_string()))
810            .is_some())
811    }
812
813    async fn list_keys(&self, session_id: SessionId) -> Result<Vec<KeyInfo>> {
814        Ok(list_storage(&self.values, session_id)
815            .await
816            .into_iter()
817            .map(|(key, value)| KeyInfo {
818                key,
819                created_at: value.created_at,
820                updated_at: value.updated_at,
821            })
822            .collect())
823    }
824
825    async fn set_secret(&self, session_id: SessionId, name: &str, value: &str) -> Result<()> {
826        upsert_storage(&self.secrets, session_id, name, value).await;
827        Ok(())
828    }
829
830    async fn get_secret(&self, session_id: SessionId, name: &str) -> Result<Option<String>> {
831        Ok(self
832            .secrets
833            .read()
834            .await
835            .get(&(session_id, name.to_string()))
836            .map(|value| value.value.clone()))
837    }
838
839    async fn delete_secret(&self, session_id: SessionId, name: &str) -> Result<bool> {
840        Ok(self
841            .secrets
842            .write()
843            .await
844            .remove(&(session_id, name.to_string()))
845            .is_some())
846    }
847
848    async fn list_secrets(&self, session_id: SessionId) -> Result<Vec<SecretInfo>> {
849        Ok(list_storage(&self.secrets, session_id)
850            .await
851            .into_iter()
852            .map(|(name, value)| SecretInfo {
853                name,
854                created_at: value.created_at,
855                updated_at: value.updated_at,
856            })
857            .collect())
858    }
859}
860
861async fn upsert_storage(
862    map: &Arc<RwLock<HashMap<(SessionId, String), StorageValue>>>,
863    session_id: SessionId,
864    key: &str,
865    value: &str,
866) {
867    let mut map = map.write().await;
868    let now = Utc::now();
869    map.entry((session_id, key.to_string()))
870        .and_modify(|stored| {
871            stored.value = value.to_string();
872            stored.updated_at = now;
873        })
874        .or_insert_with(|| StorageValue {
875            value: value.to_string(),
876            created_at: now,
877            updated_at: now,
878        });
879}
880
881async fn list_storage(
882    map: &Arc<RwLock<HashMap<(SessionId, String), StorageValue>>>,
883    session_id: SessionId,
884) -> Vec<(String, StorageValue)> {
885    let mut values: Vec<_> = map
886        .read()
887        .await
888        .iter()
889        .filter(|((sid, _), _)| *sid == session_id)
890        .map(|((_, key), value)| (key.clone(), value.clone()))
891        .collect();
892    values.sort_by(|a, b| a.0.cmp(&b.0));
893    values
894}
895
896fn normalize_path(path: &str) -> String {
897    // Single workspace path normalizer (EVE-660): the in-memory VFS shares the
898    // same `/workspace`-alias handling as every other backend.
899    everruns_core::session_path::to_session_path(path)
900}
901
902fn root_directory(session_id: SessionId) -> SessionFile {
903    let now = Utc::now();
904    SessionFile {
905        id: Uuid::nil(),
906        session_id: session_id.uuid(),
907        path: "/".to_string(),
908        name: "/".to_string(),
909        content: None,
910        encoding: "text".to_string(),
911        is_directory: true,
912        is_readonly: false,
913        size_bytes: 0,
914        created_at: now,
915        updated_at: now,
916    }
917}
918
919fn file_info(file: &SessionFile) -> FileInfo {
920    FileInfo {
921        id: file.id,
922        session_id: file.session_id,
923        path: file.path.clone(),
924        name: file.name.clone(),
925        is_directory: file.is_directory,
926        is_readonly: file.is_readonly,
927        size_bytes: file.size_bytes,
928        created_at: file.created_at,
929        updated_at: file.updated_at,
930    }
931}
932
933#[cfg(test)]
934mod tests {
935    use super::*;
936
937    #[tokio::test]
938    async fn grep_path_pattern_supports_globs_and_substring_compatibility() {
939        let store = InMemorySessionFileStore::new();
940        let session = SessionId::from_seed(1);
941        for path in [
942            "/src/lib.rs",
943            "/src/nested/mod.rs",
944            "/docs/readme.md",
945            "/notes.txt",
946        ] {
947            store
948                .write_file(session, path, "needle", "text")
949                .await
950                .unwrap();
951        }
952
953        let mut glob_paths: Vec<_> = store
954            .grep_files(session, "needle", Some("src/**/*.rs"))
955            .await
956            .unwrap()
957            .into_iter()
958            .map(|hit| hit.path)
959            .collect();
960        glob_paths.sort();
961        assert_eq!(glob_paths, vec!["/src/lib.rs", "/src/nested/mod.rs"]);
962
963        let substring_paths: Vec<_> = store
964            .grep_files(session, "needle", Some("docs"))
965            .await
966            .unwrap()
967            .into_iter()
968            .map(|hit| hit.path)
969            .collect();
970        assert_eq!(substring_paths, vec!["/docs/readme.md"]);
971    }
972}