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/// Tool-context extension: the session filesystem the runtime itself uses to
183/// persist artifacts it owns (delegated-run records under `/.agent-runs`,
184/// structured task results under `/.tasks`), as opposed to the model-facing
185/// [`ToolContext::file_store`](crate::ToolContext::file_store).
186///
187/// A host that restricts the model-facing store with a workspace policy
188/// installs this so runtime-written records do not depend on the model's
189/// permissions. Read it through
190/// [`ToolContext::runtime_artifact_file_store`](crate::ToolContext::runtime_artifact_file_store),
191/// which falls back to `file_store` when no host installed one.
192pub struct RuntimeArtifactFileSystem(pub Arc<dyn SessionFileSystem>);
193
194// Kept beside the extension type so `tool_context.rs` stays under its
195// focused-contract size guard.
196impl crate::ToolContext {
197    /// Filesystem for artifacts the runtime writes on the session's behalf,
198    /// at paths the runtime chooses (`/.agent-runs/{run_id}`,
199    /// `/.tasks/{task_id}`).
200    ///
201    /// Uses the host's [`RuntimeArtifactFileSystem`](crate::session_files::RuntimeArtifactFileSystem)
202    /// when installed, re-keyed to the attached workspace and mount-resolved the
203    /// same way the engine prepares `file_store` for tool execution; otherwise
204    /// `file_store`. Never pass a model-chosen path to this store.
205    pub fn runtime_artifact_file_store(&self) -> Option<Arc<dyn SessionFileSystem>> {
206        match self
207            .extensions
208            .get::<crate::session_files::RuntimeArtifactFileSystem>()
209        {
210            Some(artifacts) => Some(crate::mount_fs::scoped_prompt_file_store(
211                artifacts.0.clone(),
212                self.workspace_id,
213            )),
214            None => self.file_store.clone(),
215        }
216    }
217}
218
219/// A [`SessionFileSystem`] decorator that pins every operation to a fixed
220/// workspace key, ignoring the per-call `session_id`.
221///
222/// Used to re-key file I/O for a session attached to a shared workspace (where
223/// `workspace.id != session.id`): wrap the session's file store once with the
224/// session's `workspace_id`, and all downstream capability/tool access then
225/// addresses the attached workspace rather than the session's own keyspace. For
226/// the default 1:1 session the key equals the session id, so the wrapper is a
227/// transparent pass-through. See `knowledge/runtime-resources/workspace.md`.
228pub struct WorkspaceScopedFileSystem {
229    inner: Arc<dyn SessionFileSystem>,
230    key: SessionId,
231}
232
233impl WorkspaceScopedFileSystem {
234    /// Wrap `inner`, pinning all operations to `workspace_id`'s key.
235    pub fn wrap(
236        inner: Arc<dyn SessionFileSystem>,
237        workspace_id: WorkspaceId,
238    ) -> Arc<dyn SessionFileSystem> {
239        Arc::new(Self {
240            inner,
241            key: SessionId::from_uuid(workspace_id.uuid()),
242        })
243    }
244}
245
246#[async_trait]
247impl SessionFileSystem for WorkspaceScopedFileSystem {
248    async fn read_file(&self, _session_id: SessionId, path: &str) -> Result<Option<SessionFile>> {
249        self.inner.read_file(self.key, path).await
250    }
251    async fn write_file(
252        &self,
253        _session_id: SessionId,
254        path: &str,
255        content: &str,
256        encoding: &str,
257    ) -> Result<SessionFile> {
258        self.inner
259            .write_file(self.key, path, content, encoding)
260            .await
261    }
262    async fn write_file_if_content_matches(
263        &self,
264        _session_id: SessionId,
265        path: &str,
266        expected_content: &str,
267        expected_encoding: &str,
268        content: &str,
269        encoding: &str,
270    ) -> Result<Option<SessionFile>> {
271        self.inner
272            .write_file_if_content_matches(
273                self.key,
274                path,
275                expected_content,
276                expected_encoding,
277                content,
278                encoding,
279            )
280            .await
281    }
282    async fn delete_file(
283        &self,
284        _session_id: SessionId,
285        path: &str,
286        recursive: bool,
287    ) -> Result<bool> {
288        self.inner.delete_file(self.key, path, recursive).await
289    }
290    async fn list_directory(&self, _session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
291        self.inner.list_directory(self.key, path).await
292    }
293    async fn stat_file(&self, _session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
294        self.inner.stat_file(self.key, path).await
295    }
296    async fn grep_files(
297        &self,
298        _session_id: SessionId,
299        pattern: &str,
300        path_pattern: Option<&str>,
301    ) -> Result<Vec<GrepMatch>> {
302        self.inner.grep_files(self.key, pattern, path_pattern).await
303    }
304    async fn grep_files_with_options(
305        &self,
306        _session_id: SessionId,
307        pattern: &str,
308        options: &GrepOptions,
309    ) -> Result<GrepSearchResult> {
310        self.inner
311            .grep_files_with_options(self.key, pattern, options)
312            .await
313    }
314    async fn create_directory(&self, _session_id: SessionId, path: &str) -> Result<FileInfo> {
315        self.inner.create_directory(self.key, path).await
316    }
317    async fn seed_initial_file(&self, _session_id: SessionId, file: &InitialFile) -> Result<()> {
318        self.inner.seed_initial_file(self.key, file).await
319    }
320
321    fn display_root(&self) -> String {
322        self.inner.display_root()
323    }
324
325    fn display_path(&self, path: &str) -> String {
326        self.inner.display_path(path)
327    }
328
329    fn resolve_path(&self, input: &str) -> String {
330        self.inner.resolve_path(input)
331    }
332
333    fn is_mount_resolver(&self) -> bool {
334        self.inner.is_mount_resolver()
335    }
336
337    fn host_path(&self, path: &str) -> Option<std::path::PathBuf> {
338        self.inner.host_path(path)
339    }
340}
341
342#[async_trait]
343impl<T: SessionFileSystem + ?Sized> SessionFileSystem for std::sync::Arc<T> {
344    fn display_root(&self) -> String {
345        (**self).display_root()
346    }
347
348    fn display_path(&self, path: &str) -> String {
349        (**self).display_path(path)
350    }
351
352    fn resolve_path(&self, input: &str) -> String {
353        (**self).resolve_path(input)
354    }
355
356    fn is_mount_resolver(&self) -> bool {
357        (**self).is_mount_resolver()
358    }
359
360    fn host_path(&self, path: &str) -> Option<std::path::PathBuf> {
361        (**self).host_path(path)
362    }
363
364    async fn read_file(&self, session_id: SessionId, path: &str) -> Result<Option<SessionFile>> {
365        (**self).read_file(session_id, path).await
366    }
367
368    async fn write_file(
369        &self,
370        session_id: SessionId,
371        path: &str,
372        content: &str,
373        encoding: &str,
374    ) -> Result<SessionFile> {
375        (**self)
376            .write_file(session_id, path, content, encoding)
377            .await
378    }
379
380    async fn write_file_if_content_matches(
381        &self,
382        session_id: SessionId,
383        path: &str,
384        expected_content: &str,
385        expected_encoding: &str,
386        content: &str,
387        encoding: &str,
388    ) -> Result<Option<SessionFile>> {
389        (**self)
390            .write_file_if_content_matches(
391                session_id,
392                path,
393                expected_content,
394                expected_encoding,
395                content,
396                encoding,
397            )
398            .await
399    }
400
401    async fn delete_file(
402        &self,
403        session_id: SessionId,
404        path: &str,
405        recursive: bool,
406    ) -> Result<bool> {
407        (**self).delete_file(session_id, path, recursive).await
408    }
409
410    async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
411        (**self).list_directory(session_id, path).await
412    }
413
414    async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
415        (**self).stat_file(session_id, path).await
416    }
417
418    async fn grep_files(
419        &self,
420        session_id: SessionId,
421        pattern: &str,
422        path_pattern: Option<&str>,
423    ) -> Result<Vec<GrepMatch>> {
424        (**self).grep_files(session_id, pattern, path_pattern).await
425    }
426
427    async fn grep_files_with_options(
428        &self,
429        session_id: SessionId,
430        pattern: &str,
431        options: &GrepOptions,
432    ) -> Result<GrepSearchResult> {
433        (**self)
434            .grep_files_with_options(session_id, pattern, options)
435            .await
436    }
437
438    async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
439        (**self).create_directory(session_id, path).await
440    }
441
442    async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
443        (**self).seed_initial_file(session_id, file).await
444    }
445}