Skip to main content

everruns_core/
session_files.rs

1//! Neutral session filesystem contract and execution-scoping adapter.
2
3use crate::error::Result;
4use crate::session_file::{
5    FileInfo, FileStat, GrepMatch, GrepOptions, GrepSearchResult, InitialFile, SessionFile,
6};
7use crate::typed_id::{SessionId, WorkspaceId};
8use async_trait::async_trait;
9use std::sync::Arc;
10
11/// Trait for session filesystem operations
12///
13/// This trait abstracts the session filesystem contract for tools and hosts.
14/// Implementations can:
15/// - Store files in a database (production)
16/// - Use an in-memory filesystem for testing
17/// - Project files onto real disk or object storage
18#[async_trait]
19pub trait SessionFileSystem: Send + Sync {
20    /// Human-facing root path for this filesystem.
21    ///
22    /// `/workspace` is the stable agent namespace and the default. Direct
23    /// host-backed stores may override this for host-side integrations, while
24    /// [`MountFs`](crate::mount_fs::MountFs) restores the agent-facing root.
25    fn display_root(&self) -> String {
26        crate::session_path::WORKSPACE_PREFIX.to_string()
27    }
28
29    /// Convert a canonical session path into a human-facing path.
30    ///
31    /// The default renders the `/workspace` alias. Direct host-backed stores may
32    /// override it, while [`MountFs`](crate::mount_fs::MountFs) presents primary
33    /// workspace paths through the stable agent-facing namespace.
34    fn display_path(&self, path: &str) -> String {
35        crate::session_path::to_display_path(path)
36    }
37
38    /// Resolve an input path (any accepted spelling, relative or absolute) to an
39    /// absolute path within this filesystem's namespace. Relative inputs resolve
40    /// against the filesystem's current directory.
41    ///
42    /// This is how a shell seeds its working directory: [`MountFs`] returns a
43    /// path in its stable agent-facing namespace, so the shell and file tools
44    /// share the same identity. The default is the flat VFS session form.
45    /// Security decorators may authorize the returned path, so providers that
46    /// accept additional aliases must use the same contained mapping here and
47    /// in their I/O methods. An alias must never resolve to one workspace path
48    /// here and a different storage object during the subsequent operation.
49    ///
50    /// [`MountFs`]: crate::mount_fs::MountFs
51    fn resolve_path(&self, input: &str) -> String {
52        crate::session_path::to_session_path(input)
53    }
54
55    /// Whether this store is already a mount-based resolver
56    /// ([`MountFs`](crate::mount_fs::MountFs)).
57    ///
58    /// Used to avoid re-wrapping nested mount tables when building tool context.
59    fn is_mount_resolver(&self) -> bool;
60
61    /// Read a file by path
62    async fn read_file(&self, session_id: SessionId, path: &str) -> Result<Option<SessionFile>>;
63
64    /// Write/create a file
65    async fn write_file(
66        &self,
67        session_id: SessionId,
68        path: &str,
69        content: &str,
70        encoding: &str,
71    ) -> Result<SessionFile>;
72
73    /// Write a file only if its current content snapshot still matches.
74    ///
75    /// Implementations backed by transactional storage should override this
76    /// with an atomic compare-and-set update.
77    async fn write_file_if_content_matches(
78        &self,
79        session_id: SessionId,
80        path: &str,
81        expected_content: &str,
82        expected_encoding: &str,
83        content: &str,
84        encoding: &str,
85    ) -> Result<Option<SessionFile>> {
86        let Some(existing) = self.read_file(session_id, path).await? else {
87            return Ok(None);
88        };
89
90        if existing.is_directory {
91            return Ok(None);
92        }
93
94        let current_content = existing.content.unwrap_or_default();
95        if current_content != expected_content || existing.encoding != expected_encoding {
96            return Ok(None);
97        }
98
99        self.write_file(session_id, path, content, encoding)
100            .await
101            .map(Some)
102    }
103
104    /// Delete a file or directory
105    async fn delete_file(&self, session_id: SessionId, path: &str, recursive: bool)
106    -> Result<bool>;
107
108    /// List files in a directory
109    async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>>;
110
111    /// Get file metadata
112    async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>>;
113
114    /// Search file contents with Rust regex syntax, optionally filtering canonical paths by glob.
115    ///
116    /// Implementations compile the content pattern once before scanning and
117    /// return an error for invalid regex. Basename-only globs match at any
118    /// depth. Non-glob path filters retain legacy substring matching; see
119    /// `knowledge/runtime-resources/file-store.md`.
120    async fn grep_files(
121        &self,
122        session_id: SessionId,
123        pattern: &str,
124        path_pattern: Option<&str>,
125    ) -> Result<Vec<GrepMatch>>;
126
127    /// Search with match pagination and bounded before/after context.
128    ///
129    /// Backends should override this to collect context during their content
130    /// scan. The default preserves compatibility for third-party stores that
131    /// only implement the original zero-context method.
132    async fn grep_files_with_options(
133        &self,
134        session_id: SessionId,
135        pattern: &str,
136        options: &GrepOptions,
137    ) -> Result<GrepSearchResult> {
138        if options.before_context != 0 || options.after_context != 0 {
139            return Err(crate::error::AgentLoopError::tool(
140                "this file store does not support grep context",
141            ));
142        }
143        let all = self
144            .grep_files(session_id, pattern, options.path_pattern.as_deref())
145            .await?;
146        Ok(crate::session_file::bound_grep_matches(all, options))
147    }
148
149    /// Create a directory
150    async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo>;
151
152    /// Seed a starter file into a session workspace.
153    async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
154        if file.is_readonly {
155            return Err(crate::error::AgentLoopError::store(
156                "read-only initial files require a SessionFileSystem-specific seed implementation",
157            ));
158        }
159        self.write_file(session_id, &file.path, &file.content, &file.encoding)
160            .await?;
161        Ok(())
162    }
163}
164
165/// A [`SessionFileSystem`] decorator that pins every operation to a fixed
166/// workspace key, ignoring the per-call `session_id`.
167///
168/// Used to re-key file I/O for a session attached to a shared workspace (where
169/// `workspace.id != session.id`): wrap the session's file store once with the
170/// session's `workspace_id`, and all downstream capability/tool access then
171/// addresses the attached workspace rather than the session's own keyspace. For
172/// the default 1:1 session the key equals the session id, so the wrapper is a
173/// transparent pass-through. See `knowledge/runtime-resources/workspace.md`.
174pub struct WorkspaceScopedFileSystem {
175    inner: Arc<dyn SessionFileSystem>,
176    key: SessionId,
177}
178
179impl WorkspaceScopedFileSystem {
180    /// Wrap `inner`, pinning all operations to `workspace_id`'s key.
181    pub fn wrap(
182        inner: Arc<dyn SessionFileSystem>,
183        workspace_id: WorkspaceId,
184    ) -> Arc<dyn SessionFileSystem> {
185        Arc::new(Self {
186            inner,
187            key: SessionId::from_uuid(workspace_id.uuid()),
188        })
189    }
190}
191
192#[async_trait]
193impl SessionFileSystem for WorkspaceScopedFileSystem {
194    async fn read_file(&self, _session_id: SessionId, path: &str) -> Result<Option<SessionFile>> {
195        self.inner.read_file(self.key, path).await
196    }
197    async fn write_file(
198        &self,
199        _session_id: SessionId,
200        path: &str,
201        content: &str,
202        encoding: &str,
203    ) -> Result<SessionFile> {
204        self.inner
205            .write_file(self.key, path, content, encoding)
206            .await
207    }
208    async fn write_file_if_content_matches(
209        &self,
210        _session_id: SessionId,
211        path: &str,
212        expected_content: &str,
213        expected_encoding: &str,
214        content: &str,
215        encoding: &str,
216    ) -> Result<Option<SessionFile>> {
217        self.inner
218            .write_file_if_content_matches(
219                self.key,
220                path,
221                expected_content,
222                expected_encoding,
223                content,
224                encoding,
225            )
226            .await
227    }
228    async fn delete_file(
229        &self,
230        _session_id: SessionId,
231        path: &str,
232        recursive: bool,
233    ) -> Result<bool> {
234        self.inner.delete_file(self.key, path, recursive).await
235    }
236    async fn list_directory(&self, _session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
237        self.inner.list_directory(self.key, path).await
238    }
239    async fn stat_file(&self, _session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
240        self.inner.stat_file(self.key, path).await
241    }
242    async fn grep_files(
243        &self,
244        _session_id: SessionId,
245        pattern: &str,
246        path_pattern: Option<&str>,
247    ) -> Result<Vec<GrepMatch>> {
248        self.inner.grep_files(self.key, pattern, path_pattern).await
249    }
250    async fn grep_files_with_options(
251        &self,
252        _session_id: SessionId,
253        pattern: &str,
254        options: &GrepOptions,
255    ) -> Result<GrepSearchResult> {
256        self.inner
257            .grep_files_with_options(self.key, pattern, options)
258            .await
259    }
260    async fn create_directory(&self, _session_id: SessionId, path: &str) -> Result<FileInfo> {
261        self.inner.create_directory(self.key, path).await
262    }
263    async fn seed_initial_file(&self, _session_id: SessionId, file: &InitialFile) -> Result<()> {
264        self.inner.seed_initial_file(self.key, file).await
265    }
266
267    fn display_root(&self) -> String {
268        self.inner.display_root()
269    }
270
271    fn display_path(&self, path: &str) -> String {
272        self.inner.display_path(path)
273    }
274
275    fn resolve_path(&self, input: &str) -> String {
276        self.inner.resolve_path(input)
277    }
278
279    fn is_mount_resolver(&self) -> bool {
280        self.inner.is_mount_resolver()
281    }
282}
283
284#[async_trait]
285impl<T: SessionFileSystem + ?Sized> SessionFileSystem for std::sync::Arc<T> {
286    fn display_root(&self) -> String {
287        (**self).display_root()
288    }
289
290    fn display_path(&self, path: &str) -> String {
291        (**self).display_path(path)
292    }
293
294    fn resolve_path(&self, input: &str) -> String {
295        (**self).resolve_path(input)
296    }
297
298    fn is_mount_resolver(&self) -> bool {
299        (**self).is_mount_resolver()
300    }
301
302    async fn read_file(&self, session_id: SessionId, path: &str) -> Result<Option<SessionFile>> {
303        (**self).read_file(session_id, path).await
304    }
305
306    async fn write_file(
307        &self,
308        session_id: SessionId,
309        path: &str,
310        content: &str,
311        encoding: &str,
312    ) -> Result<SessionFile> {
313        (**self)
314            .write_file(session_id, path, content, encoding)
315            .await
316    }
317
318    async fn write_file_if_content_matches(
319        &self,
320        session_id: SessionId,
321        path: &str,
322        expected_content: &str,
323        expected_encoding: &str,
324        content: &str,
325        encoding: &str,
326    ) -> Result<Option<SessionFile>> {
327        (**self)
328            .write_file_if_content_matches(
329                session_id,
330                path,
331                expected_content,
332                expected_encoding,
333                content,
334                encoding,
335            )
336            .await
337    }
338
339    async fn delete_file(
340        &self,
341        session_id: SessionId,
342        path: &str,
343        recursive: bool,
344    ) -> Result<bool> {
345        (**self).delete_file(session_id, path, recursive).await
346    }
347
348    async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
349        (**self).list_directory(session_id, path).await
350    }
351
352    async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
353        (**self).stat_file(session_id, path).await
354    }
355
356    async fn grep_files(
357        &self,
358        session_id: SessionId,
359        pattern: &str,
360        path_pattern: Option<&str>,
361    ) -> Result<Vec<GrepMatch>> {
362        (**self).grep_files(session_id, pattern, path_pattern).await
363    }
364
365    async fn grep_files_with_options(
366        &self,
367        session_id: SessionId,
368        pattern: &str,
369        options: &GrepOptions,
370    ) -> Result<GrepSearchResult> {
371        (**self)
372            .grep_files_with_options(session_id, pattern, options)
373            .await
374    }
375
376    async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
377        (**self).create_directory(session_id, path).await
378    }
379
380    async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
381        (**self).seed_initial_file(session_id, file).await
382    }
383}