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