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";
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;
#[allow(clippy::if_same_then_else)] 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>,
}
#[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,
}
#[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
);
}
}
}