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    /// The directory on this machine's disk that `path` names, when the store
62    /// is backed by one.
63    ///
64    /// Deliberately not [`display_root`](Self::display_root): that is a
65    /// presentation choice, and an embedder may show `/workspace` for a store
66    /// that is really a host directory, or the host directory for one that is
67    /// not. A capability that spawns real processes cannot work from a
68    /// presentation. It needs the path the kernel will use, and it has to be
69    /// able to tell that there is none rather than guess, which is what `None`
70    /// says here for every virtual store.
71    ///
72    /// The path is not promised to exist; it is promised to be where this
73    /// store's bytes live.
74    fn host_path(&self, _path: &str) -> Option<std::path::PathBuf> {
75        None
76    }
77
78    /// Read a file by path
79    async fn read_file(&self, session_id: SessionId, path: &str) -> Result<Option<SessionFile>>;
80
81    /// Write/create a file
82    async fn write_file(
83        &self,
84        session_id: SessionId,
85        path: &str,
86        content: &str,
87        encoding: &str,
88    ) -> Result<SessionFile>;
89
90    /// Write a file only if its current content snapshot still matches.
91    ///
92    /// Implementations backed by transactional storage should override this
93    /// with an atomic compare-and-set update.
94    async fn write_file_if_content_matches(
95        &self,
96        session_id: SessionId,
97        path: &str,
98        expected_content: &str,
99        expected_encoding: &str,
100        content: &str,
101        encoding: &str,
102    ) -> Result<Option<SessionFile>> {
103        let Some(existing) = self.read_file(session_id, path).await? else {
104            return Ok(None);
105        };
106
107        if existing.is_directory {
108            return Ok(None);
109        }
110
111        let current_content = existing.content.unwrap_or_default();
112        if current_content != expected_content || existing.encoding != expected_encoding {
113            return Ok(None);
114        }
115
116        self.write_file(session_id, path, content, encoding)
117            .await
118            .map(Some)
119    }
120
121    /// Delete a file or directory
122    async fn delete_file(&self, session_id: SessionId, path: &str, recursive: bool)
123    -> Result<bool>;
124
125    /// List files in a directory
126    async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>>;
127
128    /// Get file metadata
129    async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>>;
130
131    /// Search file contents with Rust regex syntax, optionally filtering canonical paths by glob.
132    ///
133    /// Implementations compile the content pattern once before scanning and
134    /// return an error for invalid regex. Basename-only globs match at any
135    /// depth. Non-glob path filters retain legacy substring matching; see
136    /// `knowledge/runtime-resources/file-store.md`.
137    async fn grep_files(
138        &self,
139        session_id: SessionId,
140        pattern: &str,
141        path_pattern: Option<&str>,
142    ) -> Result<Vec<GrepMatch>>;
143
144    /// Search with match pagination and bounded before/after context.
145    ///
146    /// Backends should override this to collect context during their content
147    /// scan. The default preserves compatibility for third-party stores that
148    /// only implement the original zero-context method.
149    async fn grep_files_with_options(
150        &self,
151        session_id: SessionId,
152        pattern: &str,
153        options: &GrepOptions,
154    ) -> Result<GrepSearchResult> {
155        if options.before_context != 0 || options.after_context != 0 {
156            return Err(crate::error::AgentLoopError::tool(
157                "this file store does not support grep context",
158            ));
159        }
160        let all = self
161            .grep_files(session_id, pattern, options.path_pattern.as_deref())
162            .await?;
163        Ok(crate::session_file::bound_grep_matches(all, options))
164    }
165
166    /// Create a directory
167    async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo>;
168
169    /// Seed a starter file into a session workspace.
170    async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
171        if file.is_readonly {
172            return Err(crate::error::AgentLoopError::store(
173                "read-only initial files require a SessionFileSystem-specific seed implementation",
174            ));
175        }
176        self.write_file(session_id, &file.path, &file.content, &file.encoding)
177            .await?;
178        Ok(())
179    }
180}
181
182/// A [`SessionFileSystem`] decorator that pins every operation to a fixed
183/// workspace key, ignoring the per-call `session_id`.
184///
185/// Used to re-key file I/O for a session attached to a shared workspace (where
186/// `workspace.id != session.id`): wrap the session's file store once with the
187/// session's `workspace_id`, and all downstream capability/tool access then
188/// addresses the attached workspace rather than the session's own keyspace. For
189/// the default 1:1 session the key equals the session id, so the wrapper is a
190/// transparent pass-through. See `knowledge/runtime-resources/workspace.md`.
191pub struct WorkspaceScopedFileSystem {
192    inner: Arc<dyn SessionFileSystem>,
193    key: SessionId,
194}
195
196impl WorkspaceScopedFileSystem {
197    /// Wrap `inner`, pinning all operations to `workspace_id`'s key.
198    pub fn wrap(
199        inner: Arc<dyn SessionFileSystem>,
200        workspace_id: WorkspaceId,
201    ) -> Arc<dyn SessionFileSystem> {
202        Arc::new(Self {
203            inner,
204            key: SessionId::from_uuid(workspace_id.uuid()),
205        })
206    }
207}
208
209#[async_trait]
210impl SessionFileSystem for WorkspaceScopedFileSystem {
211    async fn read_file(&self, _session_id: SessionId, path: &str) -> Result<Option<SessionFile>> {
212        self.inner.read_file(self.key, path).await
213    }
214    async fn write_file(
215        &self,
216        _session_id: SessionId,
217        path: &str,
218        content: &str,
219        encoding: &str,
220    ) -> Result<SessionFile> {
221        self.inner
222            .write_file(self.key, path, content, encoding)
223            .await
224    }
225    async fn write_file_if_content_matches(
226        &self,
227        _session_id: SessionId,
228        path: &str,
229        expected_content: &str,
230        expected_encoding: &str,
231        content: &str,
232        encoding: &str,
233    ) -> Result<Option<SessionFile>> {
234        self.inner
235            .write_file_if_content_matches(
236                self.key,
237                path,
238                expected_content,
239                expected_encoding,
240                content,
241                encoding,
242            )
243            .await
244    }
245    async fn delete_file(
246        &self,
247        _session_id: SessionId,
248        path: &str,
249        recursive: bool,
250    ) -> Result<bool> {
251        self.inner.delete_file(self.key, path, recursive).await
252    }
253    async fn list_directory(&self, _session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
254        self.inner.list_directory(self.key, path).await
255    }
256    async fn stat_file(&self, _session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
257        self.inner.stat_file(self.key, path).await
258    }
259    async fn grep_files(
260        &self,
261        _session_id: SessionId,
262        pattern: &str,
263        path_pattern: Option<&str>,
264    ) -> Result<Vec<GrepMatch>> {
265        self.inner.grep_files(self.key, pattern, path_pattern).await
266    }
267    async fn grep_files_with_options(
268        &self,
269        _session_id: SessionId,
270        pattern: &str,
271        options: &GrepOptions,
272    ) -> Result<GrepSearchResult> {
273        self.inner
274            .grep_files_with_options(self.key, pattern, options)
275            .await
276    }
277    async fn create_directory(&self, _session_id: SessionId, path: &str) -> Result<FileInfo> {
278        self.inner.create_directory(self.key, path).await
279    }
280    async fn seed_initial_file(&self, _session_id: SessionId, file: &InitialFile) -> Result<()> {
281        self.inner.seed_initial_file(self.key, file).await
282    }
283
284    fn display_root(&self) -> String {
285        self.inner.display_root()
286    }
287
288    fn display_path(&self, path: &str) -> String {
289        self.inner.display_path(path)
290    }
291
292    fn resolve_path(&self, input: &str) -> String {
293        self.inner.resolve_path(input)
294    }
295
296    fn is_mount_resolver(&self) -> bool {
297        self.inner.is_mount_resolver()
298    }
299
300    fn host_path(&self, path: &str) -> Option<std::path::PathBuf> {
301        self.inner.host_path(path)
302    }
303}
304
305#[async_trait]
306impl<T: SessionFileSystem + ?Sized> SessionFileSystem for std::sync::Arc<T> {
307    fn display_root(&self) -> String {
308        (**self).display_root()
309    }
310
311    fn display_path(&self, path: &str) -> String {
312        (**self).display_path(path)
313    }
314
315    fn resolve_path(&self, input: &str) -> String {
316        (**self).resolve_path(input)
317    }
318
319    fn is_mount_resolver(&self) -> bool {
320        (**self).is_mount_resolver()
321    }
322
323    fn host_path(&self, path: &str) -> Option<std::path::PathBuf> {
324        (**self).host_path(path)
325    }
326
327    async fn read_file(&self, session_id: SessionId, path: &str) -> Result<Option<SessionFile>> {
328        (**self).read_file(session_id, path).await
329    }
330
331    async fn write_file(
332        &self,
333        session_id: SessionId,
334        path: &str,
335        content: &str,
336        encoding: &str,
337    ) -> Result<SessionFile> {
338        (**self)
339            .write_file(session_id, path, content, encoding)
340            .await
341    }
342
343    async fn write_file_if_content_matches(
344        &self,
345        session_id: SessionId,
346        path: &str,
347        expected_content: &str,
348        expected_encoding: &str,
349        content: &str,
350        encoding: &str,
351    ) -> Result<Option<SessionFile>> {
352        (**self)
353            .write_file_if_content_matches(
354                session_id,
355                path,
356                expected_content,
357                expected_encoding,
358                content,
359                encoding,
360            )
361            .await
362    }
363
364    async fn delete_file(
365        &self,
366        session_id: SessionId,
367        path: &str,
368        recursive: bool,
369    ) -> Result<bool> {
370        (**self).delete_file(session_id, path, recursive).await
371    }
372
373    async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
374        (**self).list_directory(session_id, path).await
375    }
376
377    async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
378        (**self).stat_file(session_id, path).await
379    }
380
381    async fn grep_files(
382        &self,
383        session_id: SessionId,
384        pattern: &str,
385        path_pattern: Option<&str>,
386    ) -> Result<Vec<GrepMatch>> {
387        (**self).grep_files(session_id, pattern, path_pattern).await
388    }
389
390    async fn grep_files_with_options(
391        &self,
392        session_id: SessionId,
393        pattern: &str,
394        options: &GrepOptions,
395    ) -> Result<GrepSearchResult> {
396        (**self)
397            .grep_files_with_options(session_id, pattern, options)
398            .await
399    }
400
401    async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
402        (**self).create_directory(session_id, path).await
403    }
404
405    async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
406        (**self).seed_initial_file(session_id, file).await
407    }
408}