agent-workspace-contract 0.5.0

Transport-neutral contracts for Agent Infra workspace APIs
Documentation
use super::*;

/// Reserved compare-and-swap revision representing an absent file.
pub const WORKSPACE_MISSING_FILE_REVISION: &str = "missing";

pub fn revision_matches(expected: &str, actual: Option<&str>) -> bool {
    if expected == WORKSPACE_MISSING_FILE_REVISION {
        actual.is_none()
    } else {
        actual == Some(expected)
    }
}

/// The core workspace trait. All filesystem and command execution flows through this.
#[async_trait]
pub trait Workspace: Send + Sync + Debug {
    fn description(&self) -> String;
    fn root(&self) -> PathBuf;
    /// Describe the data-plane features exposed by this workspace handle.
    ///
    /// The conservative default keeps custom adapters source-compatible;
    /// adapters should override it when they provide stronger guarantees or
    /// command/preview support.
    fn capabilities(&self) -> WorkspaceCapabilities {
        WorkspaceCapabilities::default()
    }
    fn resolved_backend_id(&self) -> Option<ResolvedBackendId> {
        None
    }
    /// Whether this handle is backed by Infra's durable, atomic ChangeSet API.
    fn supports_change_sets(&self) -> bool {
        false
    }

    async fn apply_change_set(
        &self,
        _request: WorkspaceChangeSetRequest,
    ) -> Result<WorkspaceChangeSet> {
        anyhow::bail!("workspace change sets are not supported by this workspace")
    }

    async fn get_change_set(&self, _id: &str) -> Result<WorkspaceChangeSet> {
        anyhow::bail!("workspace change sets are not supported by this workspace")
    }

    async fn revert_change_set(
        &self,
        _id: &str,
        _conversation_id: Option<String>,
    ) -> Result<WorkspaceChangeSet> {
        anyhow::bail!("workspace change sets are not supported by this workspace")
    }

    async fn read_file(&self, path: &str) -> Result<String>;
    /// Read at most the final `max_bytes` of a UTF-8 text file.
    ///
    /// Providers should override this to avoid transferring or buffering the
    /// entire file. The compatibility default preserves behavior for custom
    /// Workspace implementations while keeping callers on one capability.
    async fn read_file_tail(&self, path: &str, max_bytes: usize) -> Result<String> {
        if max_bytes == 0 {
            return Ok(String::new());
        }
        let mut value = self.read_file(path).await?;
        if value.len() <= max_bytes {
            return Ok(value);
        }
        let mut start = value.len() - max_bytes;
        while !value.is_char_boundary(start) {
            start += 1;
        }
        value.drain(..start);
        Ok(value)
    }
    async fn read_file_bytes(&self, path: &str) -> Result<Vec<u8>> {
        Ok(self.read_file(path).await?.into_bytes())
    }
    async fn write_file(&self, path: &str, content: &str) -> Result<()>;
    async fn write_file_bytes(&self, path: &str, content: &[u8]) -> Result<()> {
        let content = std::str::from_utf8(content)?;
        self.write_file(path, content).await
    }
    /// Return a content revision suitable for optimistic concurrency checks.
    async fn file_revision(&self, path: &str) -> Result<Option<String>> {
        if !self.exists(path).await? {
            return Ok(None);
        }
        Ok(Some(content_revision(&self.read_file_bytes(path).await?)))
    }
    async fn read_file_versioned(&self, path: &str) -> Result<VersionedFile> {
        let bytes = self.read_file_bytes(path).await?;
        let revision = content_revision(&bytes);
        Ok(VersionedFile { bytes, revision })
    }
    /// Compatibility CAS facade. Adapters should override this so compare and
    /// write share their strongest available atomicity boundary.
    async fn write_file_if_match(
        &self,
        path: &str,
        content: &[u8],
        if_match: Option<&str>,
    ) -> Result<String> {
        if let Some(expected) = if_match {
            let actual = self.file_revision(path).await?;
            if !revision_matches(expected, actual.as_deref()) {
                return Err(WorkspaceVersionConflict {
                    expected: expected.to_string(),
                    actual,
                }
                .into());
            }
        }
        self.write_file_bytes(path, content).await?;
        Ok(content_revision(content))
    }
    /// Write one resumable upload chunk at an exact byte offset.
    async fn write_file_chunk(
        &self,
        _path: &str,
        _offset: u64,
        _content: bytes::Bytes,
        _final_chunk: bool,
    ) -> Result<()> {
        anyhow::bail!("resumable uploads are not supported by this workspace")
    }
    /// Security-sensitive file operations have no shell-based compatibility
    /// fallback. Every adapter must implement them with its native file API.
    async fn create_dir_all(&self, path: &str) -> Result<()>;
    async fn remove_file(&self, path: &str) -> Result<()>;
    /// Remove a file only when its content revision still matches. Adapters
    /// should override this so the check and unlink share one atomic boundary.
    async fn remove_file_if_match(&self, path: &str, if_match: &str) -> Result<()> {
        let actual = self.file_revision(path).await?;
        if !revision_matches(if_match, actual.as_deref()) {
            return Err(WorkspaceVersionConflict {
                expected: if_match.to_string(),
                actual,
            }
            .into());
        }
        self.remove_file(path).await
    }
    async fn exists(&self, path: &str) -> Result<bool>;
    async fn is_dir(&self, path: &str) -> Result<bool>;
    async fn list_dir(&self, path: &str) -> Result<Vec<DirEntry>>;
    async fn file_size(&self, path: &str) -> Result<Option<u64>> {
        let path = std::path::Path::new(path);
        let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
            return Ok(None);
        };
        let parent = path.parent().unwrap_or_else(|| std::path::Path::new("."));
        Ok(self
            .list_dir(parent.to_string_lossy().as_ref())
            .await?
            .into_iter()
            .find(|entry| !entry.is_dir && entry.name == name)
            .and_then(|entry| entry.size))
    }
    async fn list_dir_with_options(
        &self,
        path: &str,
        _options: FileVisibilityOptions,
    ) -> Result<Vec<DirEntry>> {
        self.list_dir(path).await
    }
    async fn walk_tree(&self, path: &str, max_depth: usize) -> Result<Vec<String>>;
    async fn walk_tree_with_options(
        &self,
        path: &str,
        max_depth: usize,
        _options: FileVisibilityOptions,
    ) -> Result<Vec<String>> {
        self.walk_tree(path, max_depth).await
    }
    async fn find_files(&self, pattern: &str, path: &str) -> Result<Vec<String>>;
    async fn find_files_with_options(
        &self,
        pattern: &str,
        path: &str,
        _options: FileVisibilityOptions,
    ) -> Result<Vec<String>> {
        self.find_files(pattern, path).await
    }
    async fn grep(
        &self,
        pattern: &str,
        path: &str,
        include: Option<&str>,
    ) -> Result<Vec<GrepMatch>>;
    async fn grep_with_options(
        &self,
        pattern: &str,
        path: &str,
        include: Option<&str>,
        _options: FileVisibilityOptions,
    ) -> Result<Vec<GrepMatch>> {
        self.grep(pattern, path, include).await
    }

    async fn exec(&self, command: &str, cwd: Option<&str>) -> Result<CmdOutput>;

    /// Collect resource pressure from inside the workspace boundary.
    ///
    /// Filesystem and inode values come from the workspace mount itself.
    /// Provider quota remains absent until a provider exposes a trustworthy
    /// quota API; callers must not substitute host filesystem values.
    async fn resource_usage(&self) -> Result<WorkspaceResourceUsage> {
        Ok(WorkspaceResourceUsage::unavailable(
            self.description(),
            self.root(),
            self.resolved_backend_id().map(|value| value.0),
            "providerResourceUsageUnavailable",
        ))
    }

    /// Start a command whose lifetime is independent from the request that
    /// launched it. Remote backends should override this when their synchronous
    /// exec transport owns or reaps descendant processes after returning.
    async fn exec_detached(
        &self,
        command: &str,
        cwd: Option<&str>,
        _timeout_secs: u64,
    ) -> Result<CmdOutput> {
        let _ = (command, cwd);
        anyhow::bail!("detached command execution requires a provider-native executor")
    }

    async fn port_url(&self, port: u16) -> Result<PortUrl> {
        anyhow::bail!(
            "preview URL lookup is not supported for {} on port {}",
            self.description(),
            port
        )
    }

    /// Resolve a preview URL whose provider-side token remains valid for the
    /// requested lifetime. Backends without signed URLs can ignore the TTL.
    async fn port_url_with_ttl(&self, port: u16, _ttl_seconds: u64) -> Result<PortUrl> {
        self.port_url(port).await
    }

    /// Start a managed, loopback-only static server rooted at `directory`.
    ///
    /// The generated server rejects directory listings, dotfile access, and
    /// Provider adapters must implement preview lifecycle natively; the
    /// provider-neutral contract never spawns a host-side helper process.
    async fn start_static_preview(&self, directory: &str) -> Result<StaticPreviewServer> {
        let _ = directory;
        anyhow::bail!("static preview requires a provider-native preview adapter")
    }

    /// Check whether a previously started static Preview still owns its
    /// process and the port it published. Remote backends may override this
    /// when process identity is managed by the provider.
    async fn static_preview_ready(&self, server: &StaticPreviewServer) -> Result<bool> {
        let _ = server;
        Ok(false)
    }

    async fn stop_static_preview(&self, id: &str) -> Result<()> {
        let _ = id;
        anyhow::bail!("static preview requires a provider-native preview adapter")
    }

    async fn keep_alive(&self) -> Result<()> {
        Ok(())
    }

    async fn init(&self) -> Result<()> {
        Ok(())
    }

    async fn cleanup(&self) -> Result<()> {
        Ok(())
    }
}