Skip to main content

github_copilot_sdk/
session_fs.rs

1//! Session filesystem provider — virtualizable filesystem layer over JSON-RPC.
2//!
3//! When [`ClientOptions::session_fs`] is set, the SDK tells the CLI to delegate
4//! all per-session filesystem operations (`readFile`, `writeFile`, `stat`, ...)
5//! to a [`SessionFsProvider`] registered on each session. This lets host
6//! applications sandbox sessions, project files into in-memory or remote
7//! storage, and apply permission policies before bytes move.
8//!
9//! # Concurrency
10//!
11//! Each inbound `sessionFs.*` request is dispatched on its own spawned task,
12//! so provider implementations MUST be safe for concurrent invocation across
13//! distinct paths. Use internal synchronization (e.g. [`tokio::sync::Mutex`]
14//! keyed by path) if your backing store needs ordering.
15//!
16//! # Errors
17//!
18//! Provider methods return [`Result<T, FsError>`]. The SDK adapts these into
19//! the schema's `{ ..., error: Option<SessionFsError> }` payload, mapping
20//! [`FsErrorKind::NotFound`](crate::session_fs::FsErrorKind::NotFound) to
21//! the wire's `ENOENT` and everything else to `UNKNOWN`.
22//! A [`From<std::io::Error>`] conversion is provided so handlers
23//! backed by [`tokio::fs`](https://docs.rs/tokio/latest/tokio/fs/index.html)
24//! can propagate `io::Error` with `?`.
25//!
26//! # Example
27//!
28//! ```no_run
29//! use std::sync::Arc;
30//! use async_trait::async_trait;
31//! use github_copilot_sdk::types::{SessionFsProvider, FsError, FileInfo, DirEntry};
32//!
33//! struct MyProvider;
34//!
35//! #[async_trait]
36//! impl SessionFsProvider for MyProvider {
37//!     async fn read_file(&self, path: &str) -> Result<String, FsError> {
38//!         std::fs::read_to_string(path)
39//!             .map_err(FsError::from)
40//!     }
41//! }
42//! ```
43
44use std::borrow::{Borrow, Cow};
45use std::collections::HashMap;
46use std::fmt;
47
48use async_trait::async_trait;
49
50use crate::generated::api_types::{
51    SessionFsError, SessionFsErrorCode, SessionFsReaddirWithTypesEntry,
52    SessionFsReaddirWithTypesEntryType, SessionFsSetProviderConventions, SessionFsStatResult,
53};
54pub use crate::generated::api_types::{
55    SessionFsSqliteQueryType, SessionFsSqliteTransactionErrorClass,
56    SessionFsSqliteTransactionStatement,
57};
58use crate::{Custom, Repr};
59
60/// Optional capabilities declared by a session filesystem provider.
61#[non_exhaustive]
62#[derive(Debug, Clone, Default)]
63pub struct SessionFsCapabilities {
64    /// Whether the provider supports SQLite query/exists operations.
65    pub sqlite: bool,
66}
67
68impl SessionFsCapabilities {
69    /// Create a new capabilities struct with default values.
70    pub fn new() -> Self {
71        Self::default()
72    }
73
74    /// Enable SQLite support.
75    pub fn with_sqlite(mut self, sqlite: bool) -> Self {
76        self.sqlite = sqlite;
77        self
78    }
79}
80
81/// Configuration for a custom session filesystem provider.
82///
83/// When set on [`ClientOptions::session_fs`](crate::ClientOptions::session_fs),
84/// the SDK calls `sessionFs.setProvider` during [`Client::start`](crate::Client::start)
85/// to tell the CLI to route per-session filesystem operations to the SDK.
86#[non_exhaustive]
87#[derive(Debug, Clone)]
88pub struct SessionFsConfig {
89    /// Initial working directory for sessions (the user's project directory).
90    pub initial_cwd: String,
91    /// Path within each session's SessionFs where the runtime stores
92    /// session-scoped files (events, workspace, checkpoints, etc.).
93    pub session_state_path: String,
94    /// Path conventions used by this filesystem provider.
95    pub conventions: SessionFsConventions,
96    /// Optional capabilities such as SQLite support.
97    pub capabilities: Option<SessionFsCapabilities>,
98}
99
100impl SessionFsConfig {
101    /// Build a new config with the required fields.
102    pub fn new(
103        initial_cwd: impl Into<String>,
104        session_state_path: impl Into<String>,
105        conventions: SessionFsConventions,
106    ) -> Self {
107        Self {
108            initial_cwd: initial_cwd.into(),
109            session_state_path: session_state_path.into(),
110            conventions,
111            capabilities: None,
112        }
113    }
114
115    /// Set the capabilities on this config and return it (builder pattern).
116    pub fn with_capabilities(mut self, capabilities: SessionFsCapabilities) -> Self {
117        self.capabilities = Some(capabilities);
118        self
119    }
120}
121
122/// Path conventions used by a session filesystem provider.
123///
124/// Hand-authored consumer-facing enum (rather than reusing
125/// [`SessionFsSetProviderConventions`]) to avoid exposing the generated
126/// catch-all `Unknown` variant on the input side. The SDK rejects unknown
127/// conventions at validation time with a typed error.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum SessionFsConventions {
130    /// POSIX-style paths (`/foo/bar`).
131    Posix,
132    /// Windows-style paths (`C:\foo\bar`).
133    Windows,
134}
135
136impl SessionFsConventions {
137    pub(crate) fn into_wire(self) -> SessionFsSetProviderConventions {
138        match self {
139            Self::Posix => SessionFsSetProviderConventions::Posix,
140            Self::Windows => SessionFsSetProviderConventions::Windows,
141        }
142    }
143}
144
145/// Error kind returned by a [`SessionFsProvider`] method.
146///
147/// The SDK maps this onto the wire schema's `SessionFsError`:
148/// [`FsErrorKind::NotFound`] becomes `ENOENT`, everything else becomes `UNKNOWN`.
149#[derive(Clone, Debug, PartialEq, Eq)]
150#[non_exhaustive]
151pub enum FsErrorKind {
152    /// File or directory does not exist.
153    NotFound(String),
154
155    /// Any other filesystem error (permission denied, I/O error, etc.).
156    Other,
157}
158
159impl fmt::Display for FsErrorKind {
160    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161        match self {
162            FsErrorKind::NotFound(path) => write!(f, "not found: {path}"),
163            FsErrorKind::Other => write!(f, "filesystem error"),
164        }
165    }
166}
167
168/// Error returned by a [`crate::session_fs::SessionFsProvider`] method.
169///
170/// The SDK maps this onto the wire schema's `SessionFsError`:
171/// [`FsErrorKind::NotFound`] becomes `ENOENT`, everything else becomes `UNKNOWN`.
172#[derive(Debug)]
173pub struct FsError {
174    repr: Repr<FsErrorKind>,
175}
176
177impl FsError {
178    /// Construct a `FsError` wrapping a source error.
179    pub fn new<E>(kind: FsErrorKind, error: E) -> Self
180    where
181        E: Into<Box<dyn std::error::Error + Send + Sync>>,
182    {
183        Self {
184            repr: Repr::Custom(Custom {
185                kind,
186                error: error.into(),
187            }),
188        }
189    }
190
191    /// The [`FsErrorKind`] of this error.
192    pub fn kind(&self) -> &FsErrorKind {
193        match &self.repr {
194            Repr::Simple(k) | Repr::SimpleMessage(k, ..) | Repr::Custom(Custom { kind: k, .. }) => {
195                k
196            }
197        }
198    }
199
200    /// The message provided when this error was constructed, or `None`.
201    pub fn message(&self) -> Option<&str> {
202        match &self.repr {
203            Repr::SimpleMessage(_, m) => Some(m.borrow()),
204            _ => None,
205        }
206    }
207
208    /// Create a `FsError` with a custom message.
209    #[must_use]
210    pub fn with_message<C>(kind: FsErrorKind, message: C) -> Self
211    where
212        C: Into<Cow<'static, str>>,
213    {
214        Self {
215            repr: Repr::SimpleMessage(kind, message.into()),
216        }
217    }
218
219    pub(crate) fn into_wire(self) -> SessionFsError {
220        match self.kind() {
221            FsErrorKind::NotFound(message) => SessionFsError {
222                code: SessionFsErrorCode::ENOENT,
223                message: Some(message.clone()),
224            },
225            FsErrorKind::Other => SessionFsError {
226                code: SessionFsErrorCode::UNKNOWN,
227                message: Some(self.to_string()),
228            },
229        }
230    }
231}
232
233impl fmt::Display for FsError {
234    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
235        match &self.repr {
236            Repr::Simple(k) => write!(f, "{k}"),
237            Repr::SimpleMessage(_, m) => write!(f, "{m}"),
238            Repr::Custom(Custom { error, .. }) => write!(f, "{error}"),
239        }
240    }
241}
242
243impl std::error::Error for FsError {
244    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
245        match &self.repr {
246            Repr::Custom(Custom { error, .. }) => Some(&**error),
247            _ => None,
248        }
249    }
250}
251
252impl From<FsErrorKind> for FsError {
253    fn from(kind: FsErrorKind) -> Self {
254        Self {
255            repr: Repr::Simple(kind),
256        }
257    }
258}
259
260impl From<std::io::Error> for FsError {
261    fn from(err: std::io::Error) -> Self {
262        match err.kind() {
263            std::io::ErrorKind::NotFound => Self::new(FsErrorKind::NotFound(err.to_string()), err),
264            _ => Self::new(FsErrorKind::Other, err),
265        }
266    }
267}
268
269/// File or directory metadata returned by [`SessionFsProvider::stat`].
270///
271/// The SDK adapts this into the wire's [`SessionFsStatResult`].
272#[non_exhaustive]
273#[derive(Debug, Clone)]
274pub struct FileInfo {
275    /// Whether the path is a regular file.
276    pub is_file: bool,
277    /// Whether the path is a directory.
278    pub is_directory: bool,
279    /// File size in bytes.
280    pub size: i64,
281    /// ISO 8601 timestamp of last modification.
282    pub mtime: String,
283    /// ISO 8601 timestamp of creation.
284    pub birthtime: String,
285}
286
287impl FileInfo {
288    /// Build a metadata record. The mtime/birthtime arguments are caller-
289    /// supplied ISO 8601 strings — the SDK does not format timestamps for
290    /// you.
291    pub fn new(
292        is_file: bool,
293        is_directory: bool,
294        size: i64,
295        mtime: impl Into<String>,
296        birthtime: impl Into<String>,
297    ) -> Self {
298        Self {
299            is_file,
300            is_directory,
301            size,
302            mtime: mtime.into(),
303            birthtime: birthtime.into(),
304        }
305    }
306
307    pub(crate) fn into_wire(self) -> SessionFsStatResult {
308        SessionFsStatResult {
309            is_file: self.is_file,
310            is_directory: self.is_directory,
311            size: self.size,
312            mtime: self.mtime,
313            birthtime: self.birthtime,
314            error: None,
315        }
316    }
317}
318
319/// Kind of entry returned by [`SessionFsProvider::readdir_with_types`].
320///
321/// The wire schema's `Unknown` forward-compat variant is intentionally absent
322/// from this consumer-facing enum — providers must classify each entry as
323/// either a file or a directory.
324#[derive(Debug, Clone, Copy, PartialEq, Eq)]
325pub enum DirEntryKind {
326    /// Regular file.
327    File,
328    /// Directory.
329    Directory,
330}
331
332impl DirEntryKind {
333    fn into_wire(self) -> SessionFsReaddirWithTypesEntryType {
334        match self {
335            Self::File => SessionFsReaddirWithTypesEntryType::File,
336            Self::Directory => SessionFsReaddirWithTypesEntryType::Directory,
337        }
338    }
339}
340
341/// Single entry in a directory listing returned by
342/// [`SessionFsProvider::readdir_with_types`].
343#[non_exhaustive]
344#[derive(Debug, Clone)]
345pub struct DirEntry {
346    /// Entry name (basename, not full path).
347    pub name: String,
348    /// Whether the entry is a file or a directory.
349    pub kind: DirEntryKind,
350}
351
352impl DirEntry {
353    /// Build a new directory entry.
354    pub fn new(name: impl Into<String>, kind: DirEntryKind) -> Self {
355        Self {
356            name: name.into(),
357            kind,
358        }
359    }
360
361    pub(crate) fn into_wire(self) -> SessionFsReaddirWithTypesEntry {
362        SessionFsReaddirWithTypesEntry {
363            name: self.name,
364            r#type: self.kind.into_wire(),
365        }
366    }
367}
368
369/// Implementor-supplied filesystem backing for a session.
370///
371/// Each method takes a path using the conventions declared in
372/// [`SessionFsConfig::conventions`] and returns the operation's result. The
373/// SDK adapts every `Result<_, FsError>` into the JSON-RPC response shape
374/// expected by the GitHub Copilot CLI.
375///
376/// # Concurrency
377///
378/// Implementations MUST be `Send + Sync` and safe for concurrent invocation
379/// across distinct paths. The SDK dispatches each inbound `sessionFs.*`
380/// request on its own spawned task. Use internal synchronization (e.g.
381/// [`tokio::sync::Mutex`] keyed by path) if your backing store requires
382/// ordering.
383///
384/// # Forward compatibility
385///
386/// Methods on this trait have default implementations that return
387/// `Err(FsError::with_message(FsErrorKind::Other, "operation not supported"))`. When the CLI
388/// schema grows new `sessionFs.*` methods, the SDK adds them to this trait
389/// with default impls so existing implementations continue to compile.
390/// Override only the methods relevant to your backing store.
391#[async_trait]
392pub trait SessionFsProvider: Send + Sync + 'static {
393    /// Read the full contents of a file as UTF-8.
394    async fn read_file(&self, path: &str) -> Result<String, FsError> {
395        let _ = path;
396        Err(FsError::with_message(
397            FsErrorKind::Other,
398            "read_file not supported",
399        ))
400    }
401
402    /// Write content to a file, creating parent directories if needed.
403    async fn write_file(
404        &self,
405        path: &str,
406        content: &str,
407        mode: Option<i64>,
408    ) -> Result<(), FsError> {
409        let _ = (path, content, mode);
410        Err(FsError::with_message(
411            FsErrorKind::Other,
412            "write_file not supported",
413        ))
414    }
415
416    /// Append content to a file, creating parent directories if needed.
417    async fn append_file(
418        &self,
419        path: &str,
420        content: &str,
421        mode: Option<i64>,
422    ) -> Result<(), FsError> {
423        let _ = (path, content, mode);
424        Err(FsError::with_message(
425            FsErrorKind::Other,
426            "append_file not supported",
427        ))
428    }
429
430    /// Check whether a path exists.
431    ///
432    /// Returns `Ok(false)` for non-existent paths, not [`FsErrorKind::NotFound`].
433    async fn exists(&self, path: &str) -> Result<bool, FsError> {
434        let _ = path;
435        Err(FsError::with_message(
436            FsErrorKind::Other,
437            "exists not supported",
438        ))
439    }
440
441    /// Get metadata about a file or directory.
442    async fn stat(&self, path: &str) -> Result<FileInfo, FsError> {
443        let _ = path;
444        Err(FsError::with_message(
445            FsErrorKind::Other,
446            "stat not supported",
447        ))
448    }
449
450    /// Create a directory. When `recursive`, missing parents are also created.
451    async fn mkdir(&self, path: &str, recursive: bool, mode: Option<i64>) -> Result<(), FsError> {
452        let _ = (path, recursive, mode);
453        Err(FsError::with_message(
454            FsErrorKind::Other,
455            "mkdir not supported",
456        ))
457    }
458
459    /// List entry names in a directory.
460    async fn readdir(&self, path: &str) -> Result<Vec<String>, FsError> {
461        let _ = path;
462        Err(FsError::with_message(
463            FsErrorKind::Other,
464            "readdir not supported",
465        ))
466    }
467
468    /// List directory entries with type information.
469    async fn readdir_with_types(&self, path: &str) -> Result<Vec<DirEntry>, FsError> {
470        let _ = path;
471        Err(FsError::with_message(
472            FsErrorKind::Other,
473            "readdir_with_types not supported",
474        ))
475    }
476
477    /// Remove a file or directory. When `force`, missing paths are not an
478    /// error. When `recursive`, directory contents are removed as well.
479    async fn rm(&self, path: &str, recursive: bool, force: bool) -> Result<(), FsError> {
480        let _ = (path, recursive, force);
481        Err(FsError::with_message(
482            FsErrorKind::Other,
483            "rm not supported",
484        ))
485    }
486
487    /// Rename or move a file or directory.
488    async fn rename(&self, src: &str, dest: &str) -> Result<(), FsError> {
489        let _ = (src, dest);
490        Err(FsError::with_message(
491            FsErrorKind::Other,
492            "rename not supported",
493        ))
494    }
495
496    /// Return a reference to the SQLite provider, if this provider supports
497    /// SQLite operations. The default returns `None`. Providers that support
498    /// SQLite should also implement [`SessionFsSqliteProvider`] and override
499    /// this to return `Some(self)`.
500    fn sqlite(&self) -> Option<&dyn SessionFsSqliteProvider> {
501        None
502    }
503}
504
505/// Optional trait for providers that support SQLite operations.
506///
507/// Providers are already session-scoped (created per session by the factory),
508/// so these methods do not take a `session_id` parameter.
509///
510/// To opt in, implement this trait on your provider and override
511/// [`SessionFsProvider::sqlite`] to return `Some(self)`:
512///
513/// ```ignore
514/// impl SessionFsSqliteProvider for MyProvider { /* ... */ }
515///
516/// #[async_trait]
517/// impl SessionFsProvider for MyProvider {
518///     fn sqlite(&self) -> Option<&dyn SessionFsSqliteProvider> {
519///         Some(self)
520///     }
521///     // ... other methods ...
522/// }
523/// ```
524#[async_trait]
525pub trait SessionFsSqliteProvider: Send + Sync {
526    /// Execute a SQLite query against the provider's per-session database.
527    async fn sqlite_query(
528        &self,
529        query_type: SessionFsSqliteQueryType,
530        query: &str,
531        params: Option<&HashMap<String, serde_json::Value>>,
532    ) -> Result<Option<SessionFsSqliteQueryResult>, FsError>;
533
534    /// Execute `statements` atomically against the provider's per-session
535    /// database, returning one result per statement, in order.
536    ///
537    /// Return `Err` with a [`SessionFsSqliteTransactionError`] describing how
538    /// the failure should be classified. `BusyOrLocked` guarantees the
539    /// transaction rolled back and is safe to retry; `PostCommitAmbiguous`
540    /// must never be retried.
541    async fn sqlite_transaction(
542        &self,
543        _statements: &[SessionFsSqliteTransactionStatement],
544    ) -> Result<Vec<SessionFsSqliteQueryResult>, SessionFsSqliteTransactionError> {
545        Err(SessionFsSqliteTransactionError::fatal(
546            "SQLite transactions are not supported by this SessionFs provider",
547        ))
548    }
549
550    /// Check whether the provider has a SQLite database for this session.
551    async fn sqlite_exists(&self) -> Result<bool, FsError>;
552}
553
554/// Classified SQLite transaction failure returned by
555/// [`SessionFsSqliteProvider::sqlite_transaction`].
556#[derive(Debug, Clone)]
557pub struct SessionFsSqliteTransactionError {
558    /// How the runtime should classify the failure.
559    pub error_class: SessionFsSqliteTransactionErrorClass,
560    /// Human-readable failure description.
561    pub message: String,
562}
563
564impl SessionFsSqliteTransactionError {
565    /// Create a `Fatal` transaction error with the given message.
566    pub fn fatal(message: impl Into<String>) -> Self {
567        Self {
568            error_class: SessionFsSqliteTransactionErrorClass::Fatal,
569            message: message.into(),
570        }
571    }
572
573    /// Create a `BusyOrLocked` transaction error with the given message.
574    pub fn busy_or_locked(message: impl Into<String>) -> Self {
575        Self {
576            error_class: SessionFsSqliteTransactionErrorClass::BusyOrLocked,
577            message: message.into(),
578        }
579    }
580
581    /// Create a `PostCommitAmbiguous` transaction error with the given message.
582    pub fn post_commit_ambiguous(message: impl Into<String>) -> Self {
583        Self {
584            error_class: SessionFsSqliteTransactionErrorClass::PostCommitAmbiguous,
585            message: message.into(),
586        }
587    }
588}
589
590impl std::fmt::Display for SessionFsSqliteTransactionError {
591    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
592        f.write_str(&self.message)
593    }
594}
595
596impl std::error::Error for SessionFsSqliteTransactionError {}
597
598impl From<FsError> for SessionFsSqliteTransactionError {
599    fn from(error: FsError) -> Self {
600        Self::fatal(error.to_string())
601    }
602}
603
604/// Result of a SQLite query execution via [`SessionFsSqliteProvider::sqlite_query`].
605///
606/// Same shape as the generated RPC type but without the `error` field,
607/// since providers signal errors by returning `Err`.
608#[derive(Debug, Clone, Default)]
609pub struct SessionFsSqliteQueryResult {
610    /// Column names from the result set.
611    pub columns: Vec<String>,
612    /// For SELECT: array of row objects. For others: empty array.
613    pub rows: Vec<HashMap<String, serde_json::Value>>,
614    /// Number of rows affected (for INSERT/UPDATE/DELETE).
615    pub rows_affected: i64,
616    /// Last inserted row ID (for INSERT).
617    pub last_insert_rowid: Option<i64>,
618}
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623
624    #[test]
625    fn fs_error_maps_io_not_found_to_enoent() {
626        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "missing.txt");
627        let fs_err: FsError = io_err.into();
628        assert!(
629            matches!(fs_err.kind(), FsErrorKind::NotFound(message) if message == "missing.txt")
630        );
631        let wire = fs_err.into_wire();
632        assert_eq!(wire.code, SessionFsErrorCode::ENOENT);
633    }
634
635    #[test]
636    fn fs_error_maps_other_io_to_unknown() {
637        let io_err = std::io::Error::other("disk full");
638        let fs_err: FsError = io_err.into();
639        assert!(matches!(fs_err.kind(), FsErrorKind::Other));
640        let wire = fs_err.into_wire();
641        assert_eq!(wire.code, SessionFsErrorCode::UNKNOWN);
642        assert!(wire.message.unwrap().contains("disk full"));
643    }
644
645    #[test]
646    fn conventions_maps_to_wire() {
647        assert_eq!(
648            SessionFsConventions::Posix.into_wire(),
649            SessionFsSetProviderConventions::Posix
650        );
651        assert_eq!(
652            SessionFsConventions::Windows.into_wire(),
653            SessionFsSetProviderConventions::Windows
654        );
655    }
656
657    struct DefaultProvider;
658    #[async_trait]
659    impl SessionFsProvider for DefaultProvider {}
660
661    #[tokio::test]
662    async fn default_impls_return_unsupported() {
663        let p = DefaultProvider;
664        let err = p.read_file("/x").await.unwrap_err();
665        assert!(
666            matches!(err.kind(), FsErrorKind::Other) && err.to_string().contains("not supported")
667        );
668    }
669}