Skip to main content

agent_workspace_contract/
workspace.rs

1use super::*;
2
3/// Reserved compare-and-swap revision representing an absent file.
4pub const WORKSPACE_MISSING_FILE_REVISION: &str = "missing";
5
6pub fn revision_matches(expected: &str, actual: Option<&str>) -> bool {
7    if expected == WORKSPACE_MISSING_FILE_REVISION {
8        actual.is_none()
9    } else {
10        actual == Some(expected)
11    }
12}
13
14/// The core workspace trait. All filesystem and command execution flows through this.
15#[async_trait]
16pub trait Workspace: Send + Sync + Debug {
17    fn description(&self) -> String;
18    fn root(&self) -> PathBuf;
19    fn resolved_backend_id(&self) -> Option<ResolvedBackendId> {
20        None
21    }
22    /// Whether this handle is backed by Infra's durable, atomic ChangeSet API.
23    fn supports_change_sets(&self) -> bool {
24        false
25    }
26
27    async fn apply_change_set(
28        &self,
29        _request: WorkspaceChangeSetRequest,
30    ) -> Result<WorkspaceChangeSet> {
31        anyhow::bail!("workspace change sets are not supported by this workspace")
32    }
33
34    async fn get_change_set(&self, _id: &str) -> Result<WorkspaceChangeSet> {
35        anyhow::bail!("workspace change sets are not supported by this workspace")
36    }
37
38    async fn revert_change_set(
39        &self,
40        _id: &str,
41        _conversation_id: Option<String>,
42    ) -> Result<WorkspaceChangeSet> {
43        anyhow::bail!("workspace change sets are not supported by this workspace")
44    }
45
46    async fn read_file(&self, path: &str) -> Result<String>;
47    /// Read at most the final `max_bytes` of a UTF-8 text file.
48    ///
49    /// Providers should override this to avoid transferring or buffering the
50    /// entire file. The compatibility default preserves behavior for custom
51    /// Workspace implementations while keeping callers on one capability.
52    async fn read_file_tail(&self, path: &str, max_bytes: usize) -> Result<String> {
53        if max_bytes == 0 {
54            return Ok(String::new());
55        }
56        let mut value = self.read_file(path).await?;
57        if value.len() <= max_bytes {
58            return Ok(value);
59        }
60        let mut start = value.len() - max_bytes;
61        while !value.is_char_boundary(start) {
62            start += 1;
63        }
64        value.drain(..start);
65        Ok(value)
66    }
67    async fn read_file_bytes(&self, path: &str) -> Result<Vec<u8>> {
68        Ok(self.read_file(path).await?.into_bytes())
69    }
70    async fn write_file(&self, path: &str, content: &str) -> Result<()>;
71    async fn write_file_bytes(&self, path: &str, content: &[u8]) -> Result<()> {
72        let content = std::str::from_utf8(content)?;
73        self.write_file(path, content).await
74    }
75    /// Return a content revision suitable for optimistic concurrency checks.
76    async fn file_revision(&self, path: &str) -> Result<Option<String>> {
77        if !self.exists(path).await? {
78            return Ok(None);
79        }
80        Ok(Some(content_revision(&self.read_file_bytes(path).await?)))
81    }
82    async fn read_file_versioned(&self, path: &str) -> Result<VersionedFile> {
83        let bytes = self.read_file_bytes(path).await?;
84        let revision = content_revision(&bytes);
85        Ok(VersionedFile { bytes, revision })
86    }
87    /// Compatibility CAS facade. Adapters should override this so compare and
88    /// write share their strongest available atomicity boundary.
89    async fn write_file_if_match(
90        &self,
91        path: &str,
92        content: &[u8],
93        if_match: Option<&str>,
94    ) -> Result<String> {
95        if let Some(expected) = if_match {
96            let actual = self.file_revision(path).await?;
97            if !revision_matches(expected, actual.as_deref()) {
98                return Err(WorkspaceVersionConflict {
99                    expected: expected.to_string(),
100                    actual,
101                }
102                .into());
103            }
104        }
105        self.write_file_bytes(path, content).await?;
106        Ok(content_revision(content))
107    }
108    /// Write one resumable upload chunk at an exact byte offset.
109    async fn write_file_chunk(
110        &self,
111        _path: &str,
112        _offset: u64,
113        _content: bytes::Bytes,
114        _final_chunk: bool,
115    ) -> Result<()> {
116        anyhow::bail!("resumable uploads are not supported by this workspace")
117    }
118    /// Security-sensitive file operations have no shell-based compatibility
119    /// fallback. Every adapter must implement them with its native file API.
120    async fn create_dir_all(&self, path: &str) -> Result<()>;
121    async fn remove_file(&self, path: &str) -> Result<()>;
122    /// Remove a file only when its content revision still matches. Adapters
123    /// should override this so the check and unlink share one atomic boundary.
124    async fn remove_file_if_match(&self, path: &str, if_match: &str) -> Result<()> {
125        let actual = self.file_revision(path).await?;
126        if !revision_matches(if_match, actual.as_deref()) {
127            return Err(WorkspaceVersionConflict {
128                expected: if_match.to_string(),
129                actual,
130            }
131            .into());
132        }
133        self.remove_file(path).await
134    }
135    async fn exists(&self, path: &str) -> Result<bool>;
136    async fn is_dir(&self, path: &str) -> Result<bool>;
137    async fn list_dir(&self, path: &str) -> Result<Vec<DirEntry>>;
138    async fn file_size(&self, path: &str) -> Result<Option<u64>> {
139        let path = std::path::Path::new(path);
140        let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
141            return Ok(None);
142        };
143        let parent = path.parent().unwrap_or_else(|| std::path::Path::new("."));
144        Ok(self
145            .list_dir(parent.to_string_lossy().as_ref())
146            .await?
147            .into_iter()
148            .find(|entry| !entry.is_dir && entry.name == name)
149            .and_then(|entry| entry.size))
150    }
151    async fn list_dir_with_options(
152        &self,
153        path: &str,
154        _options: FileVisibilityOptions,
155    ) -> Result<Vec<DirEntry>> {
156        self.list_dir(path).await
157    }
158    async fn walk_tree(&self, path: &str, max_depth: usize) -> Result<Vec<String>>;
159    async fn walk_tree_with_options(
160        &self,
161        path: &str,
162        max_depth: usize,
163        _options: FileVisibilityOptions,
164    ) -> Result<Vec<String>> {
165        self.walk_tree(path, max_depth).await
166    }
167    async fn find_files(&self, pattern: &str, path: &str) -> Result<Vec<String>>;
168    async fn find_files_with_options(
169        &self,
170        pattern: &str,
171        path: &str,
172        _options: FileVisibilityOptions,
173    ) -> Result<Vec<String>> {
174        self.find_files(pattern, path).await
175    }
176    async fn grep(
177        &self,
178        pattern: &str,
179        path: &str,
180        include: Option<&str>,
181    ) -> Result<Vec<GrepMatch>>;
182    async fn grep_with_options(
183        &self,
184        pattern: &str,
185        path: &str,
186        include: Option<&str>,
187        _options: FileVisibilityOptions,
188    ) -> Result<Vec<GrepMatch>> {
189        self.grep(pattern, path, include).await
190    }
191
192    async fn exec(&self, command: &str, cwd: Option<&str>) -> Result<CmdOutput>;
193
194    /// Collect resource pressure from inside the workspace boundary.
195    ///
196    /// Filesystem and inode values come from the workspace mount itself.
197    /// Provider quota remains absent until a provider exposes a trustworthy
198    /// quota API; callers must not substitute host filesystem values.
199    async fn resource_usage(&self) -> Result<WorkspaceResourceUsage> {
200        Ok(WorkspaceResourceUsage::unavailable(
201            self.description(),
202            self.root(),
203            self.resolved_backend_id().map(|value| value.0),
204            "providerResourceUsageUnavailable",
205        ))
206    }
207
208    /// Start a command whose lifetime is independent from the request that
209    /// launched it. Remote backends should override this when their synchronous
210    /// exec transport owns or reaps descendant processes after returning.
211    async fn exec_detached(
212        &self,
213        command: &str,
214        cwd: Option<&str>,
215        _timeout_secs: u64,
216    ) -> Result<CmdOutput> {
217        let _ = (command, cwd);
218        anyhow::bail!("detached command execution requires a provider-native executor")
219    }
220
221    async fn port_url(&self, port: u16) -> Result<PortUrl> {
222        anyhow::bail!(
223            "preview URL lookup is not supported for {} on port {}",
224            self.description(),
225            port
226        )
227    }
228
229    /// Resolve a preview URL whose provider-side token remains valid for the
230    /// requested lifetime. Backends without signed URLs can ignore the TTL.
231    async fn port_url_with_ttl(&self, port: u16, _ttl_seconds: u64) -> Result<PortUrl> {
232        self.port_url(port).await
233    }
234
235    /// Start a managed, loopback-only static server rooted at `directory`.
236    ///
237    /// The generated server rejects directory listings, dotfile access, and
238    /// Provider adapters must implement preview lifecycle natively; the
239    /// provider-neutral contract never spawns a host-side helper process.
240    async fn start_static_preview(&self, directory: &str) -> Result<StaticPreviewServer> {
241        let _ = directory;
242        anyhow::bail!("static preview requires a provider-native preview adapter")
243    }
244
245    /// Check whether a previously started static Preview still owns its
246    /// process and the port it published. Remote backends may override this
247    /// when process identity is managed by the provider.
248    async fn static_preview_ready(&self, server: &StaticPreviewServer) -> Result<bool> {
249        let _ = server;
250        Ok(false)
251    }
252
253    async fn stop_static_preview(&self, id: &str) -> Result<()> {
254        let _ = id;
255        anyhow::bail!("static preview requires a provider-native preview adapter")
256    }
257
258    async fn keep_alive(&self) -> Result<()> {
259        Ok(())
260    }
261
262    async fn init(&self) -> Result<()> {
263        Ok(())
264    }
265
266    async fn cleanup(&self) -> Result<()> {
267        Ok(())
268    }
269}