agent-workspace-contract 0.1.2

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

pub const WORKSPACE_SERVICE_NAME: &str = "agent-workspace";
pub const WORKSPACE_API_PREFIX: &str = "/internal/v1/workspace";
pub const WORKSPACE_CAPABILITIES_PATH: &str = "/internal/v1/workspace/capabilities";
pub const WORKSPACE_READ_FILE_PATH: &str = "/internal/v1/workspace/read-file";
pub const WORKSPACE_READ_FILE_BYTES_PATH: &str = "/internal/v1/workspace/read-file-bytes";
pub const WORKSPACE_READ_FILE_TAIL_PATH: &str = "/internal/v1/workspace/read-file-tail";
pub const WORKSPACE_WRITE_FILE_PATH: &str = "/internal/v1/workspace/write-file";
pub const WORKSPACE_WRITE_FILE_BYTES_PATH: &str = "/internal/v1/workspace/write-file-bytes";
pub const WORKSPACE_CREATE_DIR_ALL_PATH: &str = "/internal/v1/workspace/create-dir-all";
pub const WORKSPACE_REMOVE_FILE_PATH: &str = "/internal/v1/workspace/remove-file";
pub const WORKSPACE_EXISTS_PATH: &str = "/internal/v1/workspace/exists";
pub const WORKSPACE_IS_DIR_PATH: &str = "/internal/v1/workspace/is-dir";
pub const WORKSPACE_LIST_DIR_PATH: &str = "/internal/v1/workspace/list-dir";
pub const WORKSPACE_WALK_TREE_PATH: &str = "/internal/v1/workspace/walk-tree";
pub const WORKSPACE_FIND_FILES_PATH: &str = "/internal/v1/workspace/find-files";
pub const WORKSPACE_GREP_PATH: &str = "/internal/v1/workspace/grep";
pub const WORKSPACE_EXEC_PATH: &str = "/internal/v1/workspace/exec";
pub const WORKSPACE_PORT_URL_PATH: &str = "/internal/v1/workspace/port-url";
/// Resource-scoped file API used by new consumers. `filePath` is an axum
/// catch-all and therefore may contain workspace-relative `/` separators.
pub const WORKSPACE_RESOURCE_FILE_PATH: &str =
    "/internal/v1/workspaces/{workspaceId}/files/{*filePath}";
pub const WORKSPACE_RESOURCE_SEARCH_PATH: &str =
    "/internal/v1/workspaces/{workspaceId}/files:search";

pub const WORKSPACE_MAX_FILE_BYTES: usize = 16 * 1024 * 1024;
pub const WORKSPACE_MAX_RESULTS: usize = 10_000;
pub const WORKSPACE_MAX_SEARCH_OUTPUT_BYTES: usize = 4 * 1024 * 1024;
pub const WORKSPACE_MAX_COMMAND_OUTPUT_BYTES: usize = 1024 * 1024;
pub const WORKSPACE_MAX_COMMAND_BYTES: usize = 64 * 1024;
pub const WORKSPACE_MAX_PATH_BYTES: usize = 4096;

/// Return the capability required by both the public Gateway alias and the
/// internal Workspace service route. Keeping this decision in the contract
/// prevents the Gateway-issued downstream credential from drifting away from
/// the capability independently enforced by the Workspace server.
#[allow(clippy::if_same_then_else)] // Branch order preserves more-specific file/snapshot capabilities.
pub fn required_workspace_capability(path: &str, method: &str) -> &'static str {
    let path = path
        .strip_prefix("/internal/v1")
        .or_else(|| path.strip_prefix("/v1"))
        .unwrap_or(path);
    if method == "GET"
        && (path == "/delegations"
            || path.ends_with("/binding")
            || path.ends_with("/grants")
            || path.ends_with("/sessions")
            || path.ends_with("/leases")
            || path.ends_with("/change-sets"))
    {
        "workspace:manage"
    } else if path.starts_with("/spaces/") && path.contains("/files/") && method == "PUT" {
        "files:write"
    } else if path.starts_with("/spaces/") && path.contains("/files/") {
        "files:read"
    } else if path.contains("/snapshots") || path.starts_with("/templates") {
        "snapshot:manage"
    } else if method != "GET"
        && (path.starts_with("/computers")
            || path.starts_with("/spaces")
            || path.starts_with("/space-")
            || path.starts_with("/sandboxes")
            || path.starts_with("/delegations")
            || path.starts_with("/templates"))
    {
        "workspace:manage"
    } else if (path.contains("/workspace-operations/") && method != "GET")
        || path == "/workspaces" && method == "POST"
        || path.starts_with("/workspaces/")
            && (method == "DELETE"
                || path.ends_with("/suspend")
                || path.ends_with("/resume")
                || path.ends_with("/reconcile")
                || path.ends_with("/lease"))
    {
        "workspace:manage"
    } else if path.contains("/commands") || path.ends_with("/exec") {
        "commands:exec"
    } else if path.contains("/uploads")
        || (path.contains("/files/") && matches!(method, "PUT" | "DELETE"))
    {
        "files:write"
    } else if path.ends_with("/clone") || path.ends_with("/migrate") {
        "snapshot:manage"
    } else if path.ends_with("/lease") || path.ends_with("/suspend") || path.ends_with("/resume") {
        "workspace:manage"
    } else if path.ends_with("/port-url") || path.contains("/previews") {
        "preview:manage"
    } else if (path.contains("change-sets") && method != "GET")
        || path.contains("write-file")
        || path.contains("create-dir")
        || path.contains("remove-file")
    {
        "files:write"
    } else {
        "files:read"
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceCapabilities {
    pub schema_version: String,
    pub files: bool,
    pub search: bool,
    pub commands: bool,
    pub previews: bool,
    pub atomic_writes: bool,
    pub compare_and_swap: bool,
    pub max_file_bytes: usize,
    pub max_results: usize,
    pub max_search_output_bytes: usize,
    pub max_command_output_bytes: usize,
}

impl Default for WorkspaceCapabilities {
    fn default() -> Self {
        Self {
            schema_version: "v1".into(),
            files: true,
            search: true,
            commands: false,
            previews: false,
            atomic_writes: false,
            compare_and_swap: false,
            max_file_bytes: WORKSPACE_MAX_FILE_BYTES,
            max_results: WORKSPACE_MAX_RESULTS,
            max_search_output_bytes: WORKSPACE_MAX_SEARCH_OUTPUT_BYTES,
            max_command_output_bytes: WORKSPACE_MAX_COMMAND_OUTPUT_BYTES,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceApiError {
    pub status: u16,
    pub code: String,
    #[serde(default = "default_error_category")]
    pub category: String,
    pub message: String,
    pub retryable: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub request_id: Option<String>,
}

fn default_error_category() -> String {
    "internal".into()
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PathRequest {
    pub path: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RemoveFileRequest {
    pub path: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub if_match: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReadFileTailRequest {
    pub path: String,
    #[serde(default = "default_read_tail_bytes")]
    pub max_bytes: usize,
}

fn default_read_tail_bytes() -> usize {
    4096
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WriteFileRequest {
    pub path: String,
    pub content: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub if_match: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WriteFileBytesRequest {
    pub path: String,
    pub content_base64: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub if_match: Option<String>,
}

/// Body for the resource-scoped `PUT .../files/{path}` API. The path belongs
/// to the URL so it cannot disagree with a second body field.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct PutWorkspaceFileRequest {
    pub content_base64: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub if_match: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceSearchKind {
    Walk,
    Find,
    Grep,
}

/// One bounded typed search entry point. Fields that do not apply to the
/// selected kind are rejected by the service rather than guessed.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct WorkspaceSearchRequest {
    pub kind: WorkspaceSearchKind,
    #[serde(default)]
    pub path: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pattern: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub include: Option<String>,
    #[serde(default = "default_search_depth")]
    pub max_depth: usize,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cursor: Option<String>,
    #[serde(default = "default_search_page_limit")]
    pub limit: usize,
}

fn default_search_depth() -> usize {
    32
}

fn default_search_page_limit() -> usize {
    100
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceSearchResponse {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub paths: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub matches: Vec<GrepMatch>,
    pub truncated: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub next_cursor: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ListDirRequest {
    pub path: String,
    #[serde(default)]
    pub include_hidden: bool,
    #[serde(default)]
    pub include_ignored: bool,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WalkTreeRequest {
    pub path: String,
    pub max_depth: usize,
    #[serde(default)]
    pub include_hidden: bool,
    #[serde(default)]
    pub include_ignored: bool,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FindFilesRequest {
    pub pattern: String,
    pub path: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GrepRequest {
    pub pattern: String,
    pub path: String,
    #[serde(default)]
    pub include: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExecRequest {
    pub command: String,
    #[serde(default)]
    pub cwd: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortUrlRequest {
    pub port: u16,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContentResponse {
    pub content: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub revision: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BytesResponse {
    pub content_base64: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub revision: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AckResponse {
    pub ok: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub revision: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExistsResponse {
    pub exists: bool,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IsDirResponse {
    pub is_dir: bool,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EntriesResponse {
    pub entries: Vec<DirEntry>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PathsResponse {
    pub paths: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GrepResponse {
    pub matches: Vec<GrepMatch>,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn wire_contract_uses_camel_case_and_keeps_legacy_optional_fields() {
        let request = WriteFileRequest {
            path: "src/lib.rs".into(),
            content: "fn main() {}".into(),
            if_match: Some("sha256:abc".into()),
        };
        assert_eq!(
            serde_json::to_string(&request).unwrap(),
            r#"{"path":"src/lib.rs","content":"fn main() {}","ifMatch":"sha256:abc"}"#
        );
        let legacy: AckResponse = serde_json::from_str(r#"{"ok":true}"#).unwrap();
        assert_eq!(legacy.revision, None);
    }

    #[test]
    fn public_and_internal_workspace_routes_share_one_capability_contract() {
        let cases = [
            ("POST", "/computers", "workspace:manage"),
            ("GET", "/computers", "files:read"),
            ("POST", "/spaces/s/files/read", "files:read"),
            ("PUT", "/spaces/s/files/a", "files:write"),
            ("POST", "/spaces/s/change-sets", "workspace:manage"),
            ("POST", "/snapshots", "snapshot:manage"),
            ("GET", "/templates", "snapshot:manage"),
            ("POST", "/sandboxes", "workspace:manage"),
            ("GET", "/delegations", "workspace:manage"),
            ("POST", "/workspaces/w/commands", "commands:exec"),
        ];
        for (method, suffix, expected) in cases {
            assert_eq!(
                required_workspace_capability(&format!("/v1{suffix}"), method),
                expected
            );
            assert_eq!(
                required_workspace_capability(&format!("/internal/v1{suffix}"), method),
                expected
            );
        }
    }
}