//! Workspace-authority Workdir lifecycle tools for embedded Orchestrators. //! //! The model-visible contract contains only stable Workspace, Runtime, //! repository, selector, and Workdir identities. Repository paths, Runtime //! endpoints, credentials, materializer handles, and operation sessions stay //! behind [`WorkspaceClient`]. use std::sync::Arc; use async_trait::async_trait; use llm_engine::tool::{ Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput, }; use serde::{Deserialize, Serialize}; use serde_json::json; use workdir::http::{WorkdirSessionOperation, WorkdirSessionOperationResult}; use workdir::{ CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest, EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, ReadRequest, ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirSession, WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult, }; use crate::feature::{ FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution, ToolDeclaration, }; use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse}; const FEATURE_ID: &str = "manage-workdir"; const FEATURE_NAME: &str = "Manage Workdir"; const FEATURE_DESCRIPTION: &str = "Workspace-authority tools for listing, materializing, and deleting persistent Workdirs."; const LIST_TOOL: &str = "WorkdirList"; const CREATE_TOOL: &str = "WorkdirCreate"; const ATTACH_TOOL: &str = "WorkdirAttach"; const DETACH_TOOL: &str = "WorkdirDetach"; const DELETE_TOOL: &str = "WorkdirDelete"; const LIST_DESCRIPTION: &str = "List persistent Workdirs in the current Workspace through Backend Workspace API authority. The result contains safe summaries and diagnostics, never host paths or Runtime connection details."; const CREATE_DESCRIPTION: &str = "Materialize a persistent Workdir on a selected Runtime from a Workspace repository and optional selector. This does not change this Worker's attachment; use WorkdirAttach explicitly after creation."; const ATTACH_DESCRIPTION: &str = "Attach this Worker to one existing Workdir. The Backend enforces one active Workdir per Worker and one active Worker per Workdir, then opens an ephemeral operation session."; const DETACH_DESCRIPTION: &str = "Detach this Worker from its active Workdir and release Workdir occupancy. Any ephemeral operation session is closed."; const DELETE_DESCRIPTION: &str = "Delete one persistent Workdir by id through Backend Workspace API authority. Occupied, blocked, or dirty Workdirs requiring confirmation are rejected."; #[derive(Clone, Debug)] pub struct ManageWorkdirFeature { client: Arc, } impl ManageWorkdirFeature { pub fn new(client: Arc) -> Self { Self { client } } } pub fn manage_workdir_feature(client: Arc) -> ManageWorkdirFeature { ManageWorkdirFeature::new(client) } impl FeatureModule for ManageWorkdirFeature { fn descriptor(&self) -> FeatureDescriptor { FeatureDescriptor::builtin(FEATURE_ID, FEATURE_NAME) .with_description(FEATURE_DESCRIPTION) .with_tool(ToolDeclaration::new(LIST_TOOL, LIST_DESCRIPTION)) .with_tool(ToolDeclaration::new(CREATE_TOOL, CREATE_DESCRIPTION)) .with_tool(ToolDeclaration::new(ATTACH_TOOL, ATTACH_DESCRIPTION)) .with_tool(ToolDeclaration::new(DETACH_TOOL, DETACH_DESCRIPTION)) .with_tool(ToolDeclaration::new(DELETE_TOOL, DELETE_DESCRIPTION)) } fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { let backend = WorkspaceHttpWorkdirBackend::new(self.client.clone()); for (name, definition) in [ ( LIST_TOOL, workdir_tool( LIST_TOOL, LIST_DESCRIPTION, list_schema(), backend.clone(), WorkdirOperation::List, ), ), ( CREATE_TOOL, workdir_tool( CREATE_TOOL, CREATE_DESCRIPTION, create_schema(), backend.clone(), WorkdirOperation::Create, ), ), ( ATTACH_TOOL, workdir_tool( ATTACH_TOOL, ATTACH_DESCRIPTION, attach_schema(), backend.clone(), WorkdirOperation::Attach, ), ), ( DETACH_TOOL, workdir_tool( DETACH_TOOL, DETACH_DESCRIPTION, detach_schema(), backend.clone(), WorkdirOperation::Detach, ), ), ( DELETE_TOOL, workdir_tool( DELETE_TOOL, DELETE_DESCRIPTION, delete_schema(), backend.clone(), WorkdirOperation::Delete, ), ), ] { context .tools() .register(ToolContribution::new(name, definition))?; } Ok(()) } } #[derive(Clone, Debug)] struct WorkspaceHttpWorkdirBackend { client: Arc, } /// Worker-local Workdir handle whose operation authority remains in the Workspace Backend. /// /// The Backend resolves the caller from the identity-bound [`WorkspaceClient`] and routes each /// operation to that Worker's active attachment. Runtime endpoints, credentials, and ephemeral /// session ids remain outside model-visible tool contracts. #[derive(Debug)] pub struct WorkspaceAttachedWorkdirSession { client: Arc, workdir: Workdir, } impl WorkspaceAttachedWorkdirSession { pub fn handle(client: Arc) -> WorkdirSessionHandle { Arc::new(Self { client, workdir: Workdir::new("workspace-attachment"), }) } fn operate( &self, operation: WorkdirSessionOperation, ) -> Result { let workspace_id = self.client.workspace_id().ok_or_else(|| { WorkdirError::Unavailable("Workspace identity is unavailable".to_string()) })?; let request = WorkspaceRequest::json( WorkspaceRequestMethod::Post, format!( "/api/w/{}/workers/self/workdir-session/operations", encode_path_segment(workspace_id) ), serde_json::to_string(&operation).map_err(|error| { WorkdirError::Unavailable(format!( "failed to encode Workspace Workdir operation: {error}" )) })?, ); let response = self .client .execute(request) .map_err(|error| WorkdirError::Unavailable(error.to_string()))?; if !response.is_success() { return Err(WorkdirError::Unavailable(format!( "Workspace Workdir API returned HTTP {}: {}", response.status, bounded_error_body(&response.body) ))); } serde_json::from_str(&response.body).map_err(|error| { WorkdirError::Unavailable(format!( "failed to decode Workspace Workdir operation result: {error}" )) }) } fn mismatch(expected: &str) -> WorkdirError { WorkdirError::Unavailable(format!( "Workspace Backend returned a mismatched Workdir operation result; expected {expected}" )) } } #[async_trait] impl WorkdirSession for WorkspaceAttachedWorkdirSession { fn workdir(&self) -> &Workdir { &self.workdir } fn capabilities(&self) -> WorkdirSessionCapabilities { WorkdirSessionCapabilities::ALL } async fn stat(&self, request: StatRequest) -> Result { match self.operate(WorkdirSessionOperation::Stat(request))? { WorkdirSessionOperationResult::Stat(result) => Ok(result), _ => Err(Self::mismatch("stat")), } } async fn read(&self, request: ReadRequest) -> Result { match self.operate(WorkdirSessionOperation::Read(request))? { WorkdirSessionOperationResult::Read(result) => Ok(result), _ => Err(Self::mismatch("read")), } } async fn write(&self, request: WriteRequest) -> Result { match self.operate(WorkdirSessionOperation::Write(request))? { WorkdirSessionOperationResult::Write(result) => Ok(result), _ => Err(Self::mismatch("write")), } } async fn edit(&self, request: EditRequest) -> Result { match self.operate(WorkdirSessionOperation::Edit(request))? { WorkdirSessionOperationResult::Edit(result) => Ok(result), _ => Err(Self::mismatch("edit")), } } async fn list(&self, request: ListRequest) -> Result { match self.operate(WorkdirSessionOperation::List(request))? { WorkdirSessionOperationResult::List(result) => Ok(result), _ => Err(Self::mismatch("list")), } } async fn glob(&self, request: GlobRequest) -> Result { match self.operate(WorkdirSessionOperation::Glob(request))? { WorkdirSessionOperationResult::Glob(result) => Ok(result), _ => Err(Self::mismatch("glob")), } } async fn grep(&self, request: GrepRequest) -> Result { match self.operate(WorkdirSessionOperation::Grep(request))? { WorkdirSessionOperationResult::Grep(result) => Ok(result), _ => Err(Self::mismatch("grep")), } } async fn start_command(&self, request: CommandRequest) -> Result { match self.operate(WorkdirSessionOperation::CommandStart(request))? { WorkdirSessionOperationResult::CommandStart(result) => Ok(result), _ => Err(Self::mismatch("command_start")), } } async fn command_status(&self, handle: CommandHandle) -> Result { match self.operate(WorkdirSessionOperation::CommandStatus(handle))? { WorkdirSessionOperationResult::CommandStatus(result) => Ok(result), _ => Err(Self::mismatch("command_status")), } } async fn command_output( &self, request: CommandOutputRequest, ) -> Result { match self.operate(WorkdirSessionOperation::CommandOutput(request))? { WorkdirSessionOperationResult::CommandOutput(result) => Ok(result), _ => Err(Self::mismatch("command_output")), } } async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError> { match self.operate(WorkdirSessionOperation::CommandCancel(handle))? { WorkdirSessionOperationResult::CommandCancel => Ok(()), _ => Err(Self::mismatch("command_cancel")), } } async fn close(&self) -> Result<(), WorkdirError> { Ok(()) } } impl WorkspaceHttpWorkdirBackend { fn new(client: Arc) -> Self { Self { client } } fn workspace_id(&self) -> Result<&str, ToolError> { let Some(workspace_id) = self.client.workspace_id() else { return Err(ToolError::ExecutionFailed( "manage Workdir tools require a Workspace identity".to_string(), )); }; if workspace_id.is_empty() || workspace_id.chars().any(char::is_control) { return Err(ToolError::ExecutionFailed( "manage Workdir tools require a valid Workspace identity".to_string(), )); } Ok(workspace_id) } fn list(&self) -> Result { let workspace_id = encode_path_segment(self.workspace_id()?); let response = self.execute_json::(WorkspaceRequest::get(format!( "/api/w/{workspace_id}/working-directories" )))?; let count = response.items.len(); workdir_output(format!("Listed {count} Workdir(s)"), &response) } fn create(&self, input: WorkdirCreateInput) -> Result { let runtime_id = validate_identity(&input.runtime_id, CREATE_TOOL, "runtime_id")?; let repository_id = validate_identity(&input.repository_id, CREATE_TOOL, "repository_id")?; let selector = validate_optional_selector(input.selector)?; let workspace_id = encode_path_segment(self.workspace_id()?); let runtime_path = encode_path_segment(runtime_id); let request = WorkdirCreateRequest { runtime_id: runtime_id.to_string(), repository_id: repository_id.to_string(), selector, }; let response = self.execute_json::(WorkspaceRequest::json( WorkspaceRequestMethod::Post, format!("/api/w/{workspace_id}/runtimes/{runtime_path}/working-directories"), serde_json::to_string(&request).map_err(decode_error)?, ))?; workdir_output( format!("Created Workdir {}", response.item.working_directory_id), &response, ) } fn attach(&self, input: WorkdirAttachInput) -> Result { let workdir_id = validate_identity(&input.workdir_id, ATTACH_TOOL, "workdir_id")?; let response = self.attach_response(workdir_id)?; workdir_output(format!("Attached to Workdir {workdir_id}"), &response) } fn attach_response(&self, workdir_id: &str) -> Result { let workspace_id = encode_path_segment(self.workspace_id()?); self.execute_json::(WorkspaceRequest::json( WorkspaceRequestMethod::Post, format!("/api/w/{workspace_id}/workers/self/workdir-attachment"), serde_json::to_string(&WorkdirAttachRequest { workdir_id: workdir_id.to_string(), }) .map_err(decode_error)?, )) } fn detach(&self) -> Result { let workspace_id = encode_path_segment(self.workspace_id()?); let response = self.execute_json::(WorkspaceRequest { method: WorkspaceRequestMethod::Delete, path: format!("/api/w/{workspace_id}/workers/self/workdir-attachment"), body: None, })?; workdir_output( format!("Detached from Workdir {}", response.workdir_id), &response, ) } fn delete(&self, input: WorkdirDeleteInput) -> Result { let workdir_id = validate_identity( &input.working_directory_id, DELETE_TOOL, "working_directory_id", )?; let workspace_id = encode_path_segment(self.workspace_id()?); let workdir_path = encode_path_segment(workdir_id); let response = self.execute_json::(WorkspaceRequest { method: WorkspaceRequestMethod::Delete, path: format!("/api/w/{workspace_id}/working-directories/{workdir_path}"), body: None, })?; workdir_output(format!("Deleted Workdir {workdir_id}"), &response) } fn execute_json Deserialize<'de>>( &self, request: WorkspaceRequest, ) -> Result { let response = self.client.execute(request).map_err(|error| { ToolError::ExecutionFailed(format!("Workspace Workdir request failed: {error}")) })?; decode_response(response) } } #[derive(Clone, Copy)] enum WorkdirOperation { List, Create, Attach, Detach, Delete, } fn workdir_tool( name: &'static str, description: &'static str, schema: serde_json::Value, backend: WorkspaceHttpWorkdirBackend, operation: WorkdirOperation, ) -> ToolDefinition { Arc::new(move || { ( ToolMeta::new(name) .description(description) .input_schema(schema.clone()), Arc::new(WorkspaceHttpWorkdirTool { backend: backend.clone(), operation, }) as Arc, ) }) } #[derive(Clone)] struct WorkspaceHttpWorkdirTool { backend: WorkspaceHttpWorkdirBackend, operation: WorkdirOperation, } #[async_trait] impl Tool for WorkspaceHttpWorkdirTool { async fn execute( &self, input_json: &str, _ctx: ToolExecutionContext, ) -> Result { match self.operation { WorkdirOperation::List => { let _input = parse_input::(input_json)?; self.backend.list() } WorkdirOperation::Create => self .backend .create(parse_input::(input_json)?), WorkdirOperation::Attach => self .backend .attach(parse_input::(input_json)?), WorkdirOperation::Detach => { let _input = parse_input::(input_json)?; self.backend.detach() } WorkdirOperation::Delete => self .backend .delete(parse_input::(input_json)?), } } } fn parse_input Deserialize<'de>>(input: &str) -> Result { serde_json::from_str(input).map_err(|error| ToolError::InvalidArgument(error.to_string())) } fn decode_response Deserialize<'de>>( response: WorkspaceResponse, ) -> Result { if !response.is_success() { return Err(ToolError::ExecutionFailed(format!( "Workspace Workdir API returned HTTP {}: {}", response.status, bounded_error_body(&response.body) ))); } serde_json::from_str(&response.body).map_err(decode_error) } fn decode_error(error: serde_json::Error) -> ToolError { ToolError::ExecutionFailed(format!("decode Workspace Workdir response: {error}")) } fn workdir_output(summary: String, value: &T) -> Result { Ok(ToolOutput { summary, content: Some(serde_json::to_string_pretty(value).map_err(decode_error)?), }) } fn validate_identity<'a>( value: &'a str, tool_name: &str, field: &str, ) -> Result<&'a str, ToolError> { let value = value.trim(); if value.is_empty() || value.chars().any(char::is_control) { return Err(ToolError::InvalidArgument(format!( "{tool_name} requires non-empty {field} without control characters" ))); } Ok(value) } fn validate_optional_selector(selector: Option) -> Result, ToolError> { let Some(selector) = selector else { return Ok(None); }; let selector = selector.trim(); if selector.is_empty() || selector.chars().any(char::is_control) { return Err(ToolError::InvalidArgument( "WorkdirCreate selector must be non-empty and contain no control characters" .to_string(), )); } Ok(Some(selector.to_string())) } fn encode_path_segment(value: &str) -> String { let mut encoded = String::with_capacity(value.len()); for byte in value.bytes() { if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { encoded.push(byte as char); } else { use std::fmt::Write as _; let _ = write!(encoded, "%{byte:02X}"); } } encoded } fn bounded_error_body(body: &str) -> String { const MAX_CHARS: usize = 4096; let mut chars = body.chars(); let bounded: String = chars.by_ref().take(MAX_CHARS).collect(); if chars.next().is_some() { format!("{bounded}…") } else { bounded } } fn list_schema() -> serde_json::Value { json!({ "type": "object", "additionalProperties": false, "properties": {} }) } fn create_schema() -> serde_json::Value { json!({ "type": "object", "additionalProperties": false, "required": ["runtime_id", "repository_id"], "properties": { "runtime_id": {"type": "string", "minLength": 1}, "repository_id": {"type": "string", "minLength": 1}, "selector": {"type": ["string", "null"], "minLength": 1} } }) } fn attach_schema() -> serde_json::Value { json!({ "type": "object", "additionalProperties": false, "required": ["workdir_id"], "properties": { "workdir_id": {"type": "string", "minLength": 1} } }) } fn detach_schema() -> serde_json::Value { list_schema() } fn delete_schema() -> serde_json::Value { json!({ "type": "object", "additionalProperties": false, "required": ["working_directory_id"], "properties": { "working_directory_id": {"type": "string", "minLength": 1} } }) } #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct WorkdirListInput {} #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct WorkdirCreateInput { runtime_id: String, repository_id: String, #[serde(default)] selector: Option, } #[derive(Debug, Serialize)] struct WorkdirCreateRequest { runtime_id: String, repository_id: String, #[serde(skip_serializing_if = "Option::is_none")] selector: Option, } #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct WorkdirAttachInput { workdir_id: String, } #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct WorkdirDetachInput {} #[derive(Debug, Serialize)] struct WorkdirAttachRequest { workdir_id: String, } #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] struct WorkdirAttachmentResponse { workspace_id: String, workdir_id: String, attached: bool, } #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct WorkdirDeleteInput { working_directory_id: String, } #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] struct WorkdirListResponse { workspace_id: String, items: Vec, diagnostics: Vec, } #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] struct WorkdirDetailResponse { workspace_id: String, item: WorkdirSummary, diagnostics: Vec, } #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] struct WorkdirSummary { working_directory_id: String, repository_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] creation_selector: Option, #[serde(default, skip_serializing_if = "Option::is_none")] creation_ref: Option, #[serde(default, skip_serializing_if = "Option::is_none")] current_selector: Option, #[serde(default, skip_serializing_if = "Option::is_none")] current_ref: Option, materializer_kind: String, #[serde(default, skip_serializing_if = "Option::is_none")] cleanup_target: Option, status: String, #[serde(default, skip_serializing_if = "Option::is_none")] cleanliness: Option, #[serde( default, alias = "primary_worker_id", skip_serializing_if = "Option::is_none" )] attached_worker_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] occupied_by: Option, } #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] struct WorkdirCleanupTarget { kind: String, working_directory_id: String, repository_id: String, } #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] struct WorkdirOccupancy { runtime_id: String, runtime_worker_id: u64, worker_id: String, display_name: String, linked_at: String, } #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] struct WorkdirDiagnostic { code: String, severity: String, message: String, } #[cfg(test)] mod tests { use std::sync::Mutex; use super::*; use crate::feature::{FeatureModule, FeatureRegistryBuilder}; use crate::hook::HookRegistryBuilder; use crate::worker::{WorkspaceClientError, WorkspaceResponse}; #[derive(Debug)] struct RecordingWorkspaceClient { requests: Mutex>, responses: Mutex>, } impl RecordingWorkspaceClient { fn new(responses: Vec) -> Self { Self { requests: Mutex::new(Vec::new()), responses: Mutex::new(responses.into_iter().rev().collect()), } } fn requests(&self) -> Vec { self.requests.lock().unwrap().clone() } } impl WorkspaceClient for RecordingWorkspaceClient { fn workspace_id(&self) -> Option<&str> { Some("workspace/test") } fn kind(&self) -> &str { "recording" } fn is_available(&self) -> bool { true } fn execute( &self, request: WorkspaceRequest, ) -> Result { self.requests.lock().unwrap().push(request); self.responses .lock() .unwrap() .pop() .ok_or_else(|| WorkspaceClientError::Request("missing response".to_string())) } } fn response(body: serde_json::Value) -> WorkspaceResponse { WorkspaceResponse { status: 200, body: serde_json::to_string(&body).unwrap(), } } fn workdir_json(id: &str) -> serde_json::Value { json!({ "working_directory_id": id, "repository_id": "main", "creation_selector": "refs/heads/main", "creation_ref": "0123456789abcdef", "materializer_kind": "local_git_worktree", "cleanup_target": { "kind": "git_worktree", "working_directory_id": id, "repository_id": "main" }, "status": "active", "cleanliness": "clean" }) } #[test] fn descriptor_declares_only_workdir_lifecycle_tools() { let feature = manage_workdir_feature(Arc::new(RecordingWorkspaceClient::new(Vec::new()))); let descriptor = feature.descriptor(); let names = descriptor .tools .into_iter() .map(|tool| tool.name) .collect::>(); assert_eq!(descriptor.id.as_str(), "builtin:manage-workdir"); assert_eq!( names, [ LIST_TOOL, CREATE_TOOL, ATTACH_TOOL, DETACH_TOOL, DELETE_TOOL ] ); } #[test] fn feature_installs_all_declared_tools_through_registry() { let feature = manage_workdir_feature(Arc::new(RecordingWorkspaceClient::new(Vec::new()))); let mut pending_tools = Vec::new(); let mut hook_builder = HookRegistryBuilder::default(); let report = FeatureRegistryBuilder::new() .with_module(feature) .install_into_pending(&mut pending_tools, &mut hook_builder); let names = pending_tools .iter() .map(|definition| definition().0.name) .collect::>(); assert!(report.reports[0].installed); assert_eq!( report.installed_tool_names(), [ LIST_TOOL, CREATE_TOOL, ATTACH_TOOL, DETACH_TOOL, DELETE_TOOL ] ); assert_eq!( names, [ LIST_TOOL, CREATE_TOOL, ATTACH_TOOL, DETACH_TOOL, DELETE_TOOL ] ); } #[test] fn missing_workspace_identity_fails_closed() { #[derive(Debug)] struct MissingWorkspaceClient; impl WorkspaceClient for MissingWorkspaceClient { fn workspace_id(&self) -> Option<&str> { None } fn kind(&self) -> &str { "missing" } fn is_available(&self) -> bool { false } fn execute( &self, _request: WorkspaceRequest, ) -> Result { panic!("missing Workspace client must not execute a request") } } let backend = WorkspaceHttpWorkdirBackend::new(Arc::new(MissingWorkspaceClient)); let error = backend.list().unwrap_err(); assert!(matches!(error, ToolError::ExecutionFailed(_))); assert!(error.to_string().contains("Workspace identity")); } #[test] fn schemas_expose_identities_without_paths_or_session_handles() { let create = create_schema(); assert_eq!(create["required"], json!(["runtime_id", "repository_id"])); assert!(create["properties"].get("path").is_none()); assert!(create["properties"].get("session_id").is_none()); assert_eq!(attach_schema()["required"], json!(["workdir_id"])); assert!(attach_schema()["properties"].get("session_id").is_none()); assert_eq!(delete_schema()["required"], json!(["working_directory_id"])); } #[test] fn list_create_explicit_attach_detach_and_delete_use_scoped_workspace_authority_paths() { let client = Arc::new(RecordingWorkspaceClient::new(vec![ response(json!({ "workspace_id": "workspace/test", "items": [workdir_json("wd-list")], "diagnostics": [] })), response(json!({ "workspace_id": "workspace/test", "item": workdir_json("wd-created"), "diagnostics": [] })), response(json!({ "workspace_id": "workspace/test", "workdir_id": "wd-created", "attached": true })), response(json!({ "workspace_id": "workspace/test", "workdir_id": "wd-created", "attached": false })), response(json!({ "workspace_id": "workspace/test", "item": { "working_directory_id": "wd-created", "repository_id": "main", "materializer_kind": "local_git_worktree", "status": "not_found" }, "diagnostics": [] })), ])); let backend = WorkspaceHttpWorkdirBackend::new(client.clone()); backend.list().unwrap(); let created = backend .create(WorkdirCreateInput { runtime_id: "runtime/one".to_string(), repository_id: "main".to_string(), selector: Some("refs/heads/topic".to_string()), }) .unwrap(); assert_eq!(created.summary, "Created Workdir wd-created"); let created: serde_json::Value = serde_json::from_str(created.content.as_deref().unwrap()).unwrap(); assert_eq!(created["item"]["working_directory_id"], "wd-created"); assert!(created.get("attachment").is_none()); assert_eq!(client.requests().len(), 2); backend .attach(WorkdirAttachInput { workdir_id: "wd-created".to_string(), }) .unwrap(); backend.detach().unwrap(); backend .delete(WorkdirDeleteInput { working_directory_id: "wd-created".to_string(), }) .unwrap(); let requests = client.requests(); assert_eq!( requests[0].path, "/api/w/workspace%2Ftest/working-directories" ); assert_eq!(requests[0].method, WorkspaceRequestMethod::Get); assert_eq!( requests[1].path, "/api/w/workspace%2Ftest/runtimes/runtime%2Fone/working-directories" ); assert_eq!(requests[1].method, WorkspaceRequestMethod::Post); let body: serde_json::Value = serde_json::from_str(requests[1].body.as_deref().unwrap()).unwrap(); assert_eq!(body["repository_id"], "main"); assert_eq!(body["selector"], "refs/heads/topic"); assert_eq!( requests[2].path, "/api/w/workspace%2Ftest/workers/self/workdir-attachment" ); assert_eq!(requests[2].method, WorkspaceRequestMethod::Post); assert_eq!( requests[3].path, "/api/w/workspace%2Ftest/workers/self/workdir-attachment" ); assert_eq!(requests[3].method, WorkspaceRequestMethod::Delete); assert_eq!( requests[4].path, "/api/w/workspace%2Ftest/working-directories/wd-created" ); assert_eq!(requests[4].method, WorkspaceRequestMethod::Delete); } #[tokio::test] async fn attached_session_proxies_operations_without_runtime_or_session_arguments() { let client = Arc::new(RecordingWorkspaceClient::new(vec![response(json!({ "operation": "stat", "result": {"path": "visible.txt", "kind": "file", "size": 8} }))])); let session = WorkspaceAttachedWorkdirSession::handle(client.clone()); let result = session .stat(StatRequest { path: workdir::WorkdirPath::new("visible.txt").unwrap(), }) .await .unwrap(); assert_eq!(result.size, 8); let requests = client.requests(); assert_eq!(requests.len(), 1); assert_eq!( requests[0].path, "/api/w/workspace%2Ftest/workers/self/workdir-session/operations" ); let body: serde_json::Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap(); assert_eq!(body["operation"], "stat"); assert!(body.get("runtime_id").is_none()); assert!(body.get("session_id").is_none()); } #[test] fn invalid_or_extra_inputs_are_rejected_before_workspace_request() { let client = Arc::new(RecordingWorkspaceClient::new(Vec::new())); let backend = WorkspaceHttpWorkdirBackend::new(client.clone()); let error = backend .create(WorkdirCreateInput { runtime_id: " ".to_string(), repository_id: "main".to_string(), selector: None, }) .unwrap_err(); assert!(matches!(error, ToolError::InvalidArgument(_))); assert!(client.requests().is_empty()); assert!(parse_input::(r#"{"path":"/tmp"}"#).is_err()); } }