Skip to main content

everruns_runtime/
in_memory.rs

1// In-memory runtime 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 async_trait::async_trait;
6use chrono::{DateTime, Utc};
7use everruns_core::error::{AgentLoopError, Result};
8use everruns_core::session::Session;
9use everruns_core::session_file::{
10    FileInfo, FileStat, GrepMatch, GrepOptions, GrepSearchResult, InitialFile, SessionFile,
11    build_grep_search_result,
12};
13use everruns_core::traits::{
14    KeyInfo, SecretInfo, SessionFileSystem, SessionFileSystemFactory,
15    SessionFileSystemFactoryContext, SessionMutator, SessionStorageStore, SessionStore,
16};
17use everruns_core::typed_id::SessionId;
18use regex::Regex;
19use std::collections::{BTreeSet, HashMap};
20use std::sync::Arc;
21use tokio::sync::RwLock;
22use uuid::Uuid;
23
24/// In-memory `SessionStore` + `SessionMutator` for embedded runtimes.
25///
26/// This is the default session backend used by [`crate::InProcessRuntime`].
27#[derive(Debug, Default, Clone)]
28pub struct InMemorySessionStore {
29    sessions: Arc<RwLock<HashMap<SessionId, Session>>>,
30}
31
32impl InMemorySessionStore {
33    /// Create an empty in-memory session store.
34    pub fn new() -> Self {
35        Self {
36            sessions: Arc::new(RwLock::new(HashMap::new())),
37        }
38    }
39
40    /// Insert or replace a session in the store.
41    pub async fn add_session(&self, session: Session) {
42        self.sessions.write().await.insert(session.id, session);
43    }
44}
45
46#[async_trait]
47impl SessionStore for InMemorySessionStore {
48    async fn get_session(&self, session_id: SessionId) -> Result<Option<Session>> {
49        Ok(self.sessions.read().await.get(&session_id).cloned())
50    }
51}
52
53#[async_trait]
54impl SessionMutator for InMemorySessionStore {
55    async fn update_session_title(&self, session_id: SessionId, title: String) -> Result<Session> {
56        let mut sessions = self.sessions.write().await;
57        let session = sessions
58            .get_mut(&session_id)
59            .ok_or_else(|| AgentLoopError::store(format!("session not found: {session_id}")))?;
60        session.title = Some(title);
61        session.updated_at = Utc::now();
62        Ok(session.clone())
63    }
64}
65
66#[derive(Debug, Clone)]
67struct FileEntry {
68    file: SessionFile,
69}
70
71/// In-memory implementation of the session virtual filesystem.
72///
73/// Paths accept either canonical session paths (`/notes.txt`) or `/workspace`
74/// prefixed paths (`/workspace/notes.txt`).
75#[derive(Debug, Default, Clone)]
76pub struct InMemorySessionFileStore {
77    files: Arc<RwLock<HashMap<(SessionId, String), FileEntry>>>,
78}
79
80/// Factory for the runtime's in-memory session filesystem.
81#[derive(Debug, Clone, Default)]
82pub struct InMemorySessionFileSystemFactory;
83
84#[async_trait]
85impl SessionFileSystemFactory for InMemorySessionFileSystemFactory {
86    fn name(&self) -> &'static str {
87        "InMemorySessionFileSystemFactory"
88    }
89
90    async fn create_session_file_system(
91        &self,
92        _context: SessionFileSystemFactoryContext,
93    ) -> Result<Arc<dyn SessionFileSystem>> {
94        Ok(Arc::new(InMemorySessionFileStore::new()))
95    }
96}
97
98impl InMemorySessionFileStore {
99    /// Create an empty in-memory file store.
100    pub fn new() -> Self {
101        Self {
102            files: Arc::new(RwLock::new(HashMap::new())),
103        }
104    }
105
106    /// Seed a file into a session workspace.
107    pub async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
108        let path = normalize_path(&file.path);
109        self.ensure_parent_directories(session_id, &path).await?;
110        self.upsert_file(
111            session_id,
112            &path,
113            &file.content,
114            &file.encoding,
115            file.is_readonly,
116        )
117        .await
118        .map(|_| ())
119    }
120
121    async fn ensure_parent_directories(&self, session_id: SessionId, path: &str) -> Result<()> {
122        let mut current = String::new();
123        for segment in path.trim_start_matches('/').split('/').collect::<Vec<_>>() {
124            if segment.is_empty() {
125                continue;
126            }
127            current.push('/');
128            current.push_str(segment);
129            let is_leaf = current == path;
130            if is_leaf {
131                break;
132            }
133            self.insert_directory_if_missing(session_id, &current)
134                .await?;
135        }
136        Ok(())
137    }
138
139    async fn insert_directory_if_missing(&self, session_id: SessionId, path: &str) -> Result<()> {
140        let path = normalize_path(path);
141        if path == "/" {
142            return Ok(());
143        }
144
145        let mut files = self.files.write().await;
146        files
147            .entry((session_id, path.clone()))
148            .or_insert_with(|| FileEntry {
149                file: SessionFile {
150                    id: Uuid::now_v7(),
151                    session_id: session_id.uuid(),
152                    path: path.clone(),
153                    name: FileInfo::name_from_path(&path),
154                    content: None,
155                    encoding: "text".to_string(),
156                    is_directory: true,
157                    is_readonly: false,
158                    size_bytes: 0,
159                    created_at: Utc::now(),
160                    updated_at: Utc::now(),
161                },
162            });
163        Ok(())
164    }
165
166    async fn upsert_file(
167        &self,
168        session_id: SessionId,
169        path: &str,
170        content: &str,
171        encoding: &str,
172        is_readonly: bool,
173    ) -> Result<SessionFile> {
174        let now = Utc::now();
175        let normalized = normalize_path(path);
176        let mut files = self.files.write().await;
177        let key = (session_id, normalized.clone());
178
179        let file = files
180            .entry(key)
181            .and_modify(|entry| {
182                entry.file.content = Some(content.to_string());
183                entry.file.encoding = encoding.to_string();
184                entry.file.is_directory = false;
185                entry.file.is_readonly = is_readonly;
186                entry.file.size_bytes = content.len() as i64;
187                entry.file.updated_at = now;
188            })
189            .or_insert_with(|| FileEntry {
190                file: SessionFile {
191                    id: Uuid::now_v7(),
192                    session_id: session_id.uuid(),
193                    path: normalized.clone(),
194                    name: FileInfo::name_from_path(&normalized),
195                    content: Some(content.to_string()),
196                    encoding: encoding.to_string(),
197                    is_directory: false,
198                    is_readonly,
199                    size_bytes: content.len() as i64,
200                    created_at: now,
201                    updated_at: now,
202                },
203            })
204            .file
205            .clone();
206
207        Ok(file)
208    }
209
210    /// Read a text file from the workspace, returning `None` when absent.
211    pub async fn read_text(&self, session_id: SessionId, path: &str) -> Option<String> {
212        self.read_file(session_id, path)
213            .await
214            .ok()
215            .flatten()
216            .and_then(|file| file.content)
217    }
218}
219
220#[async_trait]
221impl SessionFileSystem for InMemorySessionFileStore {
222    async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
223        InMemorySessionFileStore::seed_initial_file(self, session_id, file).await
224    }
225
226    async fn read_file(&self, session_id: SessionId, path: &str) -> Result<Option<SessionFile>> {
227        let normalized = normalize_path(path);
228        if normalized == "/" {
229            return Ok(Some(root_directory(session_id)));
230        }
231
232        Ok(self
233            .files
234            .read()
235            .await
236            .get(&(session_id, normalized))
237            .map(|entry| entry.file.clone()))
238    }
239
240    async fn write_file(
241        &self,
242        session_id: SessionId,
243        path: &str,
244        content: &str,
245        encoding: &str,
246    ) -> Result<SessionFile> {
247        let normalized = normalize_path(path);
248        self.ensure_parent_directories(session_id, &normalized)
249            .await?;
250
251        if let Some(existing) = self.read_file(session_id, &normalized).await?
252            && existing.is_readonly
253        {
254            return Err(AgentLoopError::tool(format!(
255                "file is read-only: {}",
256                normalized
257            )));
258        }
259
260        self.upsert_file(session_id, &normalized, content, encoding, false)
261            .await
262    }
263
264    async fn delete_file(
265        &self,
266        session_id: SessionId,
267        path: &str,
268        recursive: bool,
269    ) -> Result<bool> {
270        let normalized = normalize_path(path);
271        if normalized == "/" {
272            return Ok(false);
273        }
274
275        let mut files = self.files.write().await;
276        let key = (session_id, normalized.clone());
277        let Some(existing) = files.get(&key).cloned() else {
278            return Ok(false);
279        };
280
281        if existing.file.is_readonly {
282            return Ok(false);
283        }
284
285        if existing.file.is_directory {
286            let prefix = format!("{normalized}/");
287            let has_children = files
288                .keys()
289                .any(|(sid, candidate)| *sid == session_id && candidate.starts_with(&prefix));
290            if has_children && !recursive {
291                return Ok(false);
292            }
293            files.retain(|(sid, candidate), _| {
294                !(*sid == session_id
295                    && (candidate == &normalized || candidate.starts_with(&prefix)))
296            });
297            return Ok(true);
298        }
299
300        Ok(files.remove(&key).is_some())
301    }
302
303    async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
304        let normalized = normalize_path(path);
305        if normalized != "/" {
306            let Some(dir) = self.read_file(session_id, &normalized).await? else {
307                return Ok(vec![]);
308            };
309            if !dir.is_directory {
310                return Ok(vec![]);
311            }
312        }
313
314        let files = self.files.read().await;
315        let mut infos = Vec::new();
316        let mut seen = BTreeSet::new();
317
318        for ((sid, candidate), entry) in files.iter() {
319            if *sid != session_id {
320                continue;
321            }
322            if FileInfo::parent_path(candidate).as_deref() != Some(normalized.as_str()) {
323                continue;
324            }
325            if seen.insert(candidate.clone()) {
326                infos.push(file_info(&entry.file));
327            }
328        }
329
330        infos.sort_by(|a, b| a.path.cmp(&b.path));
331        Ok(infos)
332    }
333
334    async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
335        let normalized = normalize_path(path);
336        if normalized == "/" {
337            let root = root_directory(session_id);
338            return Ok(Some(FileStat {
339                path: root.path,
340                name: root.name,
341                is_directory: true,
342                is_readonly: false,
343                size_bytes: 0,
344                created_at: root.created_at,
345                updated_at: root.updated_at,
346            }));
347        }
348
349        Ok(self
350            .files
351            .read()
352            .await
353            .get(&(session_id, normalized))
354            .map(|entry| FileStat {
355                path: entry.file.path.clone(),
356                name: entry.file.name.clone(),
357                is_directory: entry.file.is_directory,
358                is_readonly: entry.file.is_readonly,
359                size_bytes: entry.file.size_bytes,
360                created_at: entry.file.created_at,
361                updated_at: entry.file.updated_at,
362            }))
363    }
364
365    async fn grep_files(
366        &self,
367        session_id: SessionId,
368        pattern: &str,
369        path_pattern: Option<&str>,
370    ) -> Result<Vec<GrepMatch>> {
371        let regex = Regex::new(pattern)
372            .map_err(|error| AgentLoopError::tool(format!("Invalid regex pattern: {error}")))?;
373        let path_pattern = path_pattern
374            .map(everruns_core::session_path::GrepPathPattern::new)
375            .transpose()?;
376        let files = self.files.read().await;
377        let mut matches = Vec::new();
378
379        for ((sid, path), entry) in files.iter() {
380            if *sid != session_id || entry.file.is_directory || entry.file.encoding != "text" {
381                continue;
382            }
383            if let Some(path_pattern) = &path_pattern
384                && !path_pattern.is_match(path)
385            {
386                continue;
387            }
388
389            let Some(content) = &entry.file.content else {
390                continue;
391            };
392
393            for (idx, line) in content.lines().enumerate() {
394                if regex.is_match(line) {
395                    matches.push(GrepMatch {
396                        path: path.clone(),
397                        line_number: idx + 1,
398                        line: line.to_string(),
399                    });
400                }
401            }
402        }
403
404        Ok(matches)
405    }
406
407    async fn grep_files_with_options(
408        &self,
409        session_id: SessionId,
410        pattern: &str,
411        options: &GrepOptions,
412    ) -> Result<GrepSearchResult> {
413        let regex = Regex::new(pattern)
414            .map_err(|error| AgentLoopError::tool(format!("Invalid regex pattern: {error}")))?;
415        let path_pattern = options
416            .path_pattern
417            .as_deref()
418            .map(everruns_core::session_path::GrepPathPattern::new)
419            .transpose()?;
420        let files = self.files.read().await;
421        let text_files = files
422            .iter()
423            .filter(|((sid, path), entry)| {
424                *sid == session_id
425                    && !entry.file.is_directory
426                    && entry.file.encoding == "text"
427                    && path_pattern
428                        .as_ref()
429                        .is_none_or(|matcher| matcher.is_match(path))
430            })
431            .filter_map(|((_, path), entry)| {
432                entry
433                    .file
434                    .content
435                    .as_ref()
436                    .map(|content| (path.clone(), content.clone()))
437            })
438            .collect();
439        Ok(build_grep_search_result(text_files, &regex, options))
440    }
441
442    async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
443        let normalized = normalize_path(path);
444        self.ensure_parent_directories(session_id, &normalized)
445            .await?;
446        self.insert_directory_if_missing(session_id, &normalized)
447            .await?;
448        let file = self
449            .read_file(session_id, &normalized)
450            .await?
451            .ok_or_else(|| AgentLoopError::store(format!("directory not found: {normalized}")))?;
452        Ok(file_info(&file))
453    }
454
455    fn is_mount_resolver(&self) -> bool {
456        false
457    }
458}
459
460/// In-memory implementation of session key/value and secret storage.
461#[derive(Debug, Default, Clone)]
462pub struct InMemorySessionStorageStore {
463    values: Arc<RwLock<HashMap<(SessionId, String), StorageValue>>>,
464    secrets: Arc<RwLock<HashMap<(SessionId, String), StorageValue>>>,
465}
466
467#[derive(Debug, Clone)]
468struct StorageValue {
469    value: String,
470    created_at: DateTime<Utc>,
471    updated_at: DateTime<Utc>,
472}
473
474impl InMemorySessionStorageStore {
475    /// Create an empty in-memory storage store.
476    pub fn new() -> Self {
477        Self {
478            values: Arc::new(RwLock::new(HashMap::new())),
479            secrets: Arc::new(RwLock::new(HashMap::new())),
480        }
481    }
482}
483
484#[async_trait]
485impl SessionStorageStore for InMemorySessionStorageStore {
486    async fn set_value(&self, session_id: SessionId, key: &str, value: &str) -> Result<()> {
487        upsert_storage(&self.values, session_id, key, value).await;
488        Ok(())
489    }
490
491    async fn get_value(&self, session_id: SessionId, key: &str) -> Result<Option<String>> {
492        Ok(self
493            .values
494            .read()
495            .await
496            .get(&(session_id, key.to_string()))
497            .map(|value| value.value.clone()))
498    }
499
500    async fn delete_value(&self, session_id: SessionId, key: &str) -> Result<bool> {
501        Ok(self
502            .values
503            .write()
504            .await
505            .remove(&(session_id, key.to_string()))
506            .is_some())
507    }
508
509    async fn list_keys(&self, session_id: SessionId) -> Result<Vec<KeyInfo>> {
510        Ok(list_storage(&self.values, session_id)
511            .await
512            .into_iter()
513            .map(|(key, value)| KeyInfo {
514                key,
515                created_at: value.created_at,
516                updated_at: value.updated_at,
517            })
518            .collect())
519    }
520
521    async fn set_secret(&self, session_id: SessionId, name: &str, value: &str) -> Result<()> {
522        upsert_storage(&self.secrets, session_id, name, value).await;
523        Ok(())
524    }
525
526    async fn get_secret(&self, session_id: SessionId, name: &str) -> Result<Option<String>> {
527        Ok(self
528            .secrets
529            .read()
530            .await
531            .get(&(session_id, name.to_string()))
532            .map(|value| value.value.clone()))
533    }
534
535    async fn delete_secret(&self, session_id: SessionId, name: &str) -> Result<bool> {
536        Ok(self
537            .secrets
538            .write()
539            .await
540            .remove(&(session_id, name.to_string()))
541            .is_some())
542    }
543
544    async fn list_secrets(&self, session_id: SessionId) -> Result<Vec<SecretInfo>> {
545        Ok(list_storage(&self.secrets, session_id)
546            .await
547            .into_iter()
548            .map(|(name, value)| SecretInfo {
549                name,
550                created_at: value.created_at,
551                updated_at: value.updated_at,
552            })
553            .collect())
554    }
555}
556
557async fn upsert_storage(
558    map: &Arc<RwLock<HashMap<(SessionId, String), StorageValue>>>,
559    session_id: SessionId,
560    key: &str,
561    value: &str,
562) {
563    let mut map = map.write().await;
564    let now = Utc::now();
565    map.entry((session_id, key.to_string()))
566        .and_modify(|stored| {
567            stored.value = value.to_string();
568            stored.updated_at = now;
569        })
570        .or_insert_with(|| StorageValue {
571            value: value.to_string(),
572            created_at: now,
573            updated_at: now,
574        });
575}
576
577async fn list_storage(
578    map: &Arc<RwLock<HashMap<(SessionId, String), StorageValue>>>,
579    session_id: SessionId,
580) -> Vec<(String, StorageValue)> {
581    let mut values: Vec<_> = map
582        .read()
583        .await
584        .iter()
585        .filter(|((sid, _), _)| *sid == session_id)
586        .map(|((_, key), value)| (key.clone(), value.clone()))
587        .collect();
588    values.sort_by(|a, b| a.0.cmp(&b.0));
589    values
590}
591
592fn normalize_path(path: &str) -> String {
593    // Single workspace path normalizer (EVE-660): the in-memory VFS shares the
594    // same `/workspace`-alias handling as every other backend.
595    everruns_core::session_path::to_session_path(path)
596}
597
598fn root_directory(session_id: SessionId) -> SessionFile {
599    let now = Utc::now();
600    SessionFile {
601        id: Uuid::nil(),
602        session_id: session_id.uuid(),
603        path: "/".to_string(),
604        name: "/".to_string(),
605        content: None,
606        encoding: "text".to_string(),
607        is_directory: true,
608        is_readonly: false,
609        size_bytes: 0,
610        created_at: now,
611        updated_at: now,
612    }
613}
614
615fn file_info(file: &SessionFile) -> FileInfo {
616    FileInfo {
617        id: file.id,
618        session_id: file.session_id,
619        path: file.path.clone(),
620        name: file.name.clone(),
621        is_directory: file.is_directory,
622        is_readonly: file.is_readonly,
623        size_bytes: file.size_bytes,
624        created_at: file.created_at,
625        updated_at: file.updated_at,
626    }
627}
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632
633    #[tokio::test]
634    async fn grep_path_pattern_supports_globs_and_substring_compatibility() {
635        let store = InMemorySessionFileStore::new();
636        let session = SessionId::from_seed(1);
637        for path in [
638            "/src/lib.rs",
639            "/src/nested/mod.rs",
640            "/docs/readme.md",
641            "/notes.txt",
642        ] {
643            store
644                .write_file(session, path, "needle", "text")
645                .await
646                .unwrap();
647        }
648
649        let mut glob_paths: Vec<_> = store
650            .grep_files(session, "needle", Some("src/**/*.rs"))
651            .await
652            .unwrap()
653            .into_iter()
654            .map(|hit| hit.path)
655            .collect();
656        glob_paths.sort();
657        assert_eq!(glob_paths, vec!["/src/lib.rs", "/src/nested/mod.rs"]);
658
659        let substring_paths: Vec<_> = store
660            .grep_files(session, "needle", Some("docs"))
661            .await
662            .unwrap()
663            .into_iter()
664            .map(|hit| hit.path)
665            .collect();
666        assert_eq!(substring_paths, vec!["/docs/readme.md"]);
667    }
668}