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