1use super::*;
2
3pub const WORKSPACE_SERVICE_NAME: &str = "agent-workspace";
4pub const WORKSPACE_API_PREFIX: &str = "/internal/v1/workspace";
5pub const WORKSPACE_CAPABILITIES_PATH: &str = "/internal/v1/workspace/capabilities";
6pub const WORKSPACE_READ_FILE_PATH: &str = "/internal/v1/workspace/read-file";
7pub const WORKSPACE_READ_FILE_BYTES_PATH: &str = "/internal/v1/workspace/read-file-bytes";
8pub const WORKSPACE_READ_FILE_TAIL_PATH: &str = "/internal/v1/workspace/read-file-tail";
9pub const WORKSPACE_WRITE_FILE_PATH: &str = "/internal/v1/workspace/write-file";
10pub const WORKSPACE_WRITE_FILE_BYTES_PATH: &str = "/internal/v1/workspace/write-file-bytes";
11pub const WORKSPACE_CREATE_DIR_ALL_PATH: &str = "/internal/v1/workspace/create-dir-all";
12pub const WORKSPACE_REMOVE_FILE_PATH: &str = "/internal/v1/workspace/remove-file";
13pub const WORKSPACE_EXISTS_PATH: &str = "/internal/v1/workspace/exists";
14pub const WORKSPACE_IS_DIR_PATH: &str = "/internal/v1/workspace/is-dir";
15pub const WORKSPACE_LIST_DIR_PATH: &str = "/internal/v1/workspace/list-dir";
16pub const WORKSPACE_WALK_TREE_PATH: &str = "/internal/v1/workspace/walk-tree";
17pub const WORKSPACE_FIND_FILES_PATH: &str = "/internal/v1/workspace/find-files";
18pub const WORKSPACE_GREP_PATH: &str = "/internal/v1/workspace/grep";
19pub const WORKSPACE_EXEC_PATH: &str = "/internal/v1/workspace/exec";
20pub const WORKSPACE_PORT_URL_PATH: &str = "/internal/v1/workspace/port-url";
21pub const WORKSPACE_RESOURCE_FILE_PATH: &str =
24 "/internal/v1/workspaces/{workspaceId}/files/{*filePath}";
25pub const WORKSPACE_RESOURCE_SEARCH_PATH: &str =
26 "/internal/v1/workspaces/{workspaceId}/files:search";
27
28pub const WORKSPACE_MAX_FILE_BYTES: usize = 16 * 1024 * 1024;
29pub const WORKSPACE_MAX_RESULTS: usize = 10_000;
30pub const WORKSPACE_MAX_SEARCH_OUTPUT_BYTES: usize = 4 * 1024 * 1024;
31pub const WORKSPACE_MAX_COMMAND_OUTPUT_BYTES: usize = 1024 * 1024;
32pub const WORKSPACE_MAX_COMMAND_BYTES: usize = 64 * 1024;
33pub const WORKSPACE_MAX_PATH_BYTES: usize = 4096;
34
35#[allow(clippy::if_same_then_else)] pub fn required_workspace_capability(path: &str, method: &str) -> &'static str {
41 let path = path
42 .strip_prefix("/internal/v1")
43 .or_else(|| path.strip_prefix("/v1"))
44 .unwrap_or(path);
45 if method == "GET"
46 && (path == "/delegations"
47 || path.ends_with("/binding")
48 || path.ends_with("/grants")
49 || path.ends_with("/sessions")
50 || path.ends_with("/leases")
51 || path.ends_with("/change-sets"))
52 {
53 "workspace:manage"
54 } else if path.starts_with("/spaces/") && path.contains("/files/") && method == "PUT" {
55 "files:write"
56 } else if path.starts_with("/spaces/") && path.contains("/files/") {
57 "files:read"
58 } else if path.contains("/snapshots") || path.starts_with("/templates") {
59 "snapshot:manage"
60 } else if method != "GET"
61 && (path.starts_with("/computers")
62 || path.starts_with("/spaces")
63 || path.starts_with("/space-")
64 || path.starts_with("/sandboxes")
65 || path.starts_with("/delegations")
66 || path.starts_with("/templates"))
67 {
68 "workspace:manage"
69 } else if (path.contains("/workspace-operations/") && method != "GET")
70 || path == "/workspaces" && method == "POST"
71 || path.starts_with("/workspaces/")
72 && (method == "DELETE"
73 || path.ends_with("/suspend")
74 || path.ends_with("/resume")
75 || path.ends_with("/reconcile")
76 || path.ends_with("/lease"))
77 {
78 "workspace:manage"
79 } else if path.contains("/commands") || path.ends_with("/exec") {
80 "commands:exec"
81 } else if path.contains("/uploads")
82 || (path.contains("/files/") && matches!(method, "PUT" | "DELETE"))
83 {
84 "files:write"
85 } else if path.ends_with("/clone") || path.ends_with("/migrate") {
86 "snapshot:manage"
87 } else if path.ends_with("/lease") || path.ends_with("/suspend") || path.ends_with("/resume") {
88 "workspace:manage"
89 } else if path.ends_with("/port-url") || path.contains("/previews") {
90 "preview:manage"
91 } else if (path.contains("change-sets") && method != "GET")
92 || path.contains("write-file")
93 || path.contains("create-dir")
94 || path.contains("remove-file")
95 {
96 "files:write"
97 } else {
98 "files:read"
99 }
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "camelCase")]
104pub struct WorkspaceCapabilities {
105 pub schema_version: String,
106 pub files: bool,
107 pub search: bool,
108 pub commands: bool,
109 pub previews: bool,
110 pub atomic_writes: bool,
111 pub compare_and_swap: bool,
112 pub max_file_bytes: usize,
113 pub max_results: usize,
114 pub max_search_output_bytes: usize,
115 pub max_command_output_bytes: usize,
116}
117
118impl Default for WorkspaceCapabilities {
119 fn default() -> Self {
120 Self {
121 schema_version: "v1".into(),
122 files: true,
123 search: true,
124 commands: false,
125 previews: false,
126 atomic_writes: false,
127 compare_and_swap: false,
128 max_file_bytes: WORKSPACE_MAX_FILE_BYTES,
129 max_results: WORKSPACE_MAX_RESULTS,
130 max_search_output_bytes: WORKSPACE_MAX_SEARCH_OUTPUT_BYTES,
131 max_command_output_bytes: WORKSPACE_MAX_COMMAND_OUTPUT_BYTES,
132 }
133 }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(rename_all = "camelCase")]
138pub struct WorkspaceApiError {
139 pub status: u16,
140 pub code: String,
141 #[serde(default = "default_error_category")]
142 pub category: String,
143 pub message: String,
144 pub retryable: bool,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub request_id: Option<String>,
147}
148
149fn default_error_category() -> String {
150 "internal".into()
151}
152
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
154pub struct PathRequest {
155 pub path: String,
156}
157
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
159#[serde(rename_all = "camelCase")]
160pub struct RemoveFileRequest {
161 pub path: String,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub if_match: Option<String>,
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(rename_all = "camelCase")]
168pub struct ReadFileTailRequest {
169 pub path: String,
170 #[serde(default = "default_read_tail_bytes")]
171 pub max_bytes: usize,
172}
173
174fn default_read_tail_bytes() -> usize {
175 4096
176}
177
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
179#[serde(rename_all = "camelCase")]
180pub struct WriteFileRequest {
181 pub path: String,
182 pub content: String,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub if_match: Option<String>,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(rename_all = "camelCase")]
189pub struct WriteFileBytesRequest {
190 pub path: String,
191 pub content_base64: String,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub if_match: Option<String>,
194}
195
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
199#[serde(deny_unknown_fields, rename_all = "camelCase")]
200pub struct PutWorkspaceFileRequest {
201 pub content_base64: String,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub if_match: Option<String>,
204}
205
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(rename_all = "snake_case")]
208pub enum WorkspaceSearchKind {
209 Walk,
210 Find,
211 Grep,
212}
213
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217#[serde(deny_unknown_fields, rename_all = "camelCase")]
218pub struct WorkspaceSearchRequest {
219 pub kind: WorkspaceSearchKind,
220 #[serde(default)]
221 pub path: String,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub pattern: Option<String>,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub include: Option<String>,
226 #[serde(default = "default_search_depth")]
227 pub max_depth: usize,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub cursor: Option<String>,
230 #[serde(default = "default_search_page_limit")]
231 pub limit: usize,
232}
233
234fn default_search_depth() -> usize {
235 32
236}
237
238fn default_search_page_limit() -> usize {
239 100
240}
241
242#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
243#[serde(rename_all = "camelCase")]
244pub struct WorkspaceSearchResponse {
245 #[serde(default, skip_serializing_if = "Vec::is_empty")]
246 pub paths: Vec<String>,
247 #[serde(default, skip_serializing_if = "Vec::is_empty")]
248 pub matches: Vec<GrepMatch>,
249 pub truncated: bool,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub next_cursor: Option<String>,
252}
253
254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
255#[serde(rename_all = "camelCase")]
256pub struct ListDirRequest {
257 pub path: String,
258 #[serde(default)]
259 pub include_hidden: bool,
260 #[serde(default)]
261 pub include_ignored: bool,
262}
263
264#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
265#[serde(rename_all = "camelCase")]
266pub struct WalkTreeRequest {
267 pub path: String,
268 pub max_depth: usize,
269 #[serde(default)]
270 pub include_hidden: bool,
271 #[serde(default)]
272 pub include_ignored: bool,
273}
274
275#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
276pub struct FindFilesRequest {
277 pub pattern: String,
278 pub path: String,
279}
280
281#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
282pub struct GrepRequest {
283 pub pattern: String,
284 pub path: String,
285 #[serde(default)]
286 pub include: Option<String>,
287}
288
289#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
290pub struct ExecRequest {
291 pub command: String,
292 #[serde(default)]
293 pub cwd: Option<String>,
294}
295
296#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
297pub struct PortUrlRequest {
298 pub port: u16,
299}
300
301#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
302pub struct ContentResponse {
303 pub content: String,
304 #[serde(default, skip_serializing_if = "Option::is_none")]
305 pub revision: Option<String>,
306}
307
308#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
309#[serde(rename_all = "camelCase")]
310pub struct BytesResponse {
311 pub content_base64: String,
312 #[serde(default, skip_serializing_if = "Option::is_none")]
313 pub revision: Option<String>,
314}
315
316#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
317pub struct AckResponse {
318 pub ok: bool,
319 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub revision: Option<String>,
321}
322
323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
324pub struct ExistsResponse {
325 pub exists: bool,
326}
327
328#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
329#[serde(rename_all = "camelCase")]
330pub struct IsDirResponse {
331 pub is_dir: bool,
332}
333
334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
335pub struct EntriesResponse {
336 pub entries: Vec<DirEntry>,
337}
338
339#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
340pub struct PathsResponse {
341 pub paths: Vec<String>,
342}
343
344#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
345pub struct GrepResponse {
346 pub matches: Vec<GrepMatch>,
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 #[test]
354 fn wire_contract_uses_camel_case_and_keeps_legacy_optional_fields() {
355 let request = WriteFileRequest {
356 path: "src/lib.rs".into(),
357 content: "fn main() {}".into(),
358 if_match: Some("sha256:abc".into()),
359 };
360 assert_eq!(
361 serde_json::to_string(&request).unwrap(),
362 r#"{"path":"src/lib.rs","content":"fn main() {}","ifMatch":"sha256:abc"}"#
363 );
364 let legacy: AckResponse = serde_json::from_str(r#"{"ok":true}"#).unwrap();
365 assert_eq!(legacy.revision, None);
366 }
367
368 #[test]
369 fn public_and_internal_workspace_routes_share_one_capability_contract() {
370 let cases = [
371 ("POST", "/computers", "workspace:manage"),
372 ("GET", "/computers", "files:read"),
373 ("POST", "/spaces/s/files/read", "files:read"),
374 ("PUT", "/spaces/s/files/a", "files:write"),
375 ("POST", "/spaces/s/change-sets", "workspace:manage"),
376 ("POST", "/snapshots", "snapshot:manage"),
377 ("GET", "/templates", "snapshot:manage"),
378 ("POST", "/sandboxes", "workspace:manage"),
379 ("GET", "/delegations", "workspace:manage"),
380 ("POST", "/workspaces/w/commands", "commands:exec"),
381 ];
382 for (method, suffix, expected) in cases {
383 assert_eq!(
384 required_workspace_capability(&format!("/v1{suffix}"), method),
385 expected
386 );
387 assert_eq!(
388 required_workspace_capability(&format!("/internal/v1{suffix}"), method),
389 expected
390 );
391 }
392 }
393}