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