diff --git a/crates/worker-runtime/src/execution.rs b/crates/worker-runtime/src/execution.rs index 04e45226..e0fd79a6 100644 --- a/crates/worker-runtime/src/execution.rs +++ b/crates/worker-runtime/src/execution.rs @@ -1,11 +1,11 @@ -use crate::catalog::{CreateWorkerRequest, WorkingDirectoryStatus}; +use crate::catalog::{CreateWorkerRequest, WorkingDirectoryRequest, WorkingDirectoryStatus}; use crate::config_bundle::ConfigBundle; use crate::error::RuntimeError; use crate::identity::WorkerRef; use crate::interaction::WorkerInput; #[cfg(feature = "ws-server")] use crate::observation::WorkerObservationEvent; -use crate::working_directory::WorkingDirectoryBinding; +use crate::working_directory::{WorkingDirectoryBinding, WorkingDirectoryDiagnostic}; use serde::{Deserialize, Serialize}; use std::fmt; use std::sync::Arc; @@ -313,6 +313,42 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static { fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult; + fn create_working_directory( + &self, + _request: &WorkingDirectoryRequest, + ) -> Result { + Err(WorkingDirectoryDiagnostic::rejected( + "working_directory_unsupported", + "Worker execution backend does not support working directory materialization", + )) + } + + fn list_working_directories(&self) -> Vec { + Vec::new() + } + + fn working_directory( + &self, + working_directory_id: &str, + ) -> Result { + Err(WorkingDirectoryDiagnostic::rejected( + "working_directory_unknown", + format!("working directory `{working_directory_id}` is not known to this Runtime"), + )) + } + + fn cleanup_working_directory( + &self, + working_directory_id: &str, + ) -> Result { + Err(WorkingDirectoryDiagnostic::rejected( + "working_directory_cleanup_unsupported", + format!( + "working directory `{working_directory_id}` cannot be cleaned up by this backend" + ), + )) + } + fn dispatch_input( &self, handle: &WorkerExecutionHandle, @@ -358,6 +394,31 @@ impl WorkerExecutionBackendRef { self.backend.spawn_worker(request) } + pub(crate) fn create_working_directory( + &self, + request: &WorkingDirectoryRequest, + ) -> Result { + self.backend.create_working_directory(request) + } + + pub(crate) fn list_working_directories(&self) -> Vec { + self.backend.list_working_directories() + } + + pub(crate) fn working_directory( + &self, + working_directory_id: &str, + ) -> Result { + self.backend.working_directory(working_directory_id) + } + + pub(crate) fn cleanup_working_directory( + &self, + working_directory_id: &str, + ) -> Result { + self.backend.cleanup_working_directory(working_directory_id) + } + pub(crate) fn dispatch_input( &self, handle: &WorkerExecutionHandle, diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index f73f2b16..ad746596 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -9,6 +9,7 @@ use crate::Runtime; use crate::catalog::{ ConfigBundleRef, CreateWorkerRequest, WorkerDetail, WorkerLifecycleAck, WorkerSummary, + WorkingDirectoryRequest, WorkingDirectoryStatus, }; use crate::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary}; use crate::error::RuntimeError; @@ -138,6 +139,14 @@ pub fn runtime_http_router(runtime: Runtime, local_token: Option) -> Rou "/v1/config-bundles/{bundle_id}/availability", get(check_config_bundle), ) + .route( + "/v1/working-directories", + get(list_working_directories).post(create_working_directory), + ) + .route( + "/v1/working-directories/{working_directory_id}", + get(get_working_directory).delete(cleanup_working_directory), + ) .route("/v1/workers", get(list_workers).post(create_worker)) .route("/v1/workers/{worker_id}", get(get_worker)) .route("/v1/workers/{worker_id}/input", post(send_worker_input)) @@ -197,6 +206,18 @@ pub struct RuntimeHttpWorkersResponse { pub workers: Vec, } +/// `GET /v1/working-directories` response. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeHttpWorkingDirectoriesResponse { + pub working_directories: Vec, +} + +/// Working directory response used by create/detail/delete endpoints. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeHttpWorkingDirectoryResponse { + pub working_directory: WorkingDirectoryStatus, +} + /// Worker detail response used by create/detail endpoints. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RuntimeHttpWorkerResponse { @@ -399,6 +420,58 @@ async fn list_workers( Ok(Json(RuntimeHttpWorkersResponse { workers })) } +async fn list_working_directories( + State(state): State, +) -> RestResult { + let working_directories = state + .runtime + .list_working_directories() + .map_err(RuntimeHttpRestError::runtime)?; + Ok(Json(RuntimeHttpWorkingDirectoriesResponse { + working_directories, + })) +} + +async fn create_working_directory( + State(state): State, + body: Result, JsonRejection>, +) -> RestResult { + let Json(request) = body.map_err(RuntimeHttpRestError::json_rejection)?; + let working_directory = state + .runtime + .create_working_directory(request) + .map_err(RuntimeHttpRestError::runtime)?; + Ok(Json(RuntimeHttpWorkingDirectoryResponse { + working_directory, + })) +} + +async fn get_working_directory( + State(state): State, + Path(working_directory_id): Path, +) -> RestResult { + let working_directory = state + .runtime + .working_directory(&working_directory_id) + .map_err(RuntimeHttpRestError::runtime)?; + Ok(Json(RuntimeHttpWorkingDirectoryResponse { + working_directory, + })) +} + +async fn cleanup_working_directory( + State(state): State, + Path(working_directory_id): Path, +) -> RestResult { + let working_directory = state + .runtime + .cleanup_working_directory(&working_directory_id) + .map_err(RuntimeHttpRestError::runtime)?; + Ok(Json(RuntimeHttpWorkingDirectoryResponse { + working_directory, + })) +} + async fn get_worker( State(state): State, Path(worker_id): Path, diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index e9d7b87c..5875ba9e 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -1,6 +1,7 @@ use crate::catalog::{ ConfigBundleRef, CreateWorkerRequest, ProfileSelector, WorkerDetail, WorkerLifecycleAck, - WorkerStatus, WorkerSummary, WorkingDirectoryStatus as CatalogWorkingDirectoryStatus, + WorkerStatus, WorkerSummary, WorkingDirectoryRequest, + WorkingDirectoryStatus as CatalogWorkingDirectoryStatus, }; use crate::config_bundle::{ ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary, validate_config_bundle, @@ -234,6 +235,79 @@ impl Runtime { Ok(event_id) } + /// Create a Runtime-owned working directory through the attached execution backend. + pub fn create_working_directory( + &self, + request: WorkingDirectoryRequest, + ) -> Result { + let backend = { + let state = self.lock()?; + state.ensure_running()?; + state.execution_backend.clone().ok_or_else(|| { + RuntimeError::ExecutionBackendUnavailable { + message: "working directory creation requires an execution backend".to_string(), + } + })? + }; + backend + .create_working_directory(&request) + .map_err(|diagnostic| RuntimeError::InvalidRequest(diagnostic.to_string())) + } + + /// List Runtime-owned working directories through the attached execution backend. + pub fn list_working_directories( + &self, + ) -> Result, RuntimeError> { + let backend = { + let state = self.lock()?; + state.ensure_running()?; + state.execution_backend.clone().ok_or_else(|| { + RuntimeError::ExecutionBackendUnavailable { + message: "working directory listing requires an execution backend".to_string(), + } + })? + }; + Ok(backend.list_working_directories()) + } + + /// Get a Runtime-owned working directory status. + pub fn working_directory( + &self, + working_directory_id: &str, + ) -> Result { + let backend = { + let state = self.lock()?; + state.ensure_running()?; + state.execution_backend.clone().ok_or_else(|| { + RuntimeError::ExecutionBackendUnavailable { + message: "working directory lookup requires an execution backend".to_string(), + } + })? + }; + backend + .working_directory(working_directory_id) + .map_err(|diagnostic| RuntimeError::InvalidRequest(diagnostic.to_string())) + } + + /// Cleanup a Runtime-owned working directory. + pub fn cleanup_working_directory( + &self, + working_directory_id: &str, + ) -> Result { + let backend = { + let state = self.lock()?; + state.ensure_running()?; + state.execution_backend.clone().ok_or_else(|| { + RuntimeError::ExecutionBackendUnavailable { + message: "working directory cleanup requires an execution backend".to_string(), + } + })? + }; + backend + .cleanup_working_directory(working_directory_id) + .map_err(|diagnostic| RuntimeError::InvalidRequest(diagnostic.to_string())) + } + /// Create a Worker through the canonical ConfigBundle + execution backend path. pub fn create_worker( &self, diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index b55fa011..62f693ef 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -14,6 +14,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, mpsc}; use std::time::Duration; +use crate::catalog::{WorkingDirectoryRequest, WorkingDirectoryStatus}; use crate::config_bundle::verified_profile_source_archive; use crate::execution::{ WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation, WorkerExecutionResult, @@ -24,7 +25,9 @@ use crate::resource::{ BackendResourceClient, BackendResourceError, ProfileSourceArchiveCache, build_profile_source_archive_fetch_request, profile_source_archive_from_response, }; -use crate::working_directory::{WorkingDirectoryBinding, WorkingDirectoryMaterializer}; +use crate::working_directory::{ + WorkingDirectoryBinding, WorkingDirectoryDiagnostic, WorkingDirectoryMaterializer, +}; use async_trait::async_trait; use manifest::paths; use protocol::{Method, Segment, WorkerStatus}; @@ -441,6 +444,52 @@ where &self.backend_id } + fn create_working_directory( + &self, + request: &WorkingDirectoryRequest, + ) -> Result { + let Some(materializer) = self.working_directory_materializer.as_ref() else { + return Err(WorkingDirectoryDiagnostic::rejected( + "working_directory_materializer_unavailable", + "working directory materialization requested, but no materializer is configured for this runtime backend", + )); + }; + Ok(materializer.create(request)?.status()) + } + + fn list_working_directories(&self) -> Vec { + self.working_directory_materializer + .as_ref() + .and_then(|materializer| materializer.list_working_directories().ok()) + .unwrap_or_default() + } + + fn working_directory( + &self, + working_directory_id: &str, + ) -> Result { + let Some(materializer) = self.working_directory_materializer.as_ref() else { + return Err(WorkingDirectoryDiagnostic::rejected( + "working_directory_materializer_unavailable", + "working directory lookup requested, but no materializer is configured for this runtime backend", + )); + }; + materializer.working_directory_status(working_directory_id) + } + + fn cleanup_working_directory( + &self, + working_directory_id: &str, + ) -> Result { + let Some(materializer) = self.working_directory_materializer.as_ref() else { + return Err(WorkingDirectoryDiagnostic::rejected( + "working_directory_materializer_unavailable", + "working directory cleanup requested, but no materializer is configured for this runtime backend", + )); + }; + materializer.cleanup_working_directory(working_directory_id) + } + fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult { if self .workers diff --git a/crates/worker-runtime/src/working_directory.rs b/crates/worker-runtime/src/working_directory.rs index 00b2c06d..fb4593e8 100644 --- a/crates/worker-runtime/src/working_directory.rs +++ b/crates/worker-runtime/src/working_directory.rs @@ -95,6 +95,10 @@ pub struct WorkingDirectoryDiagnostic { } impl WorkingDirectoryDiagnostic { + pub fn rejected(code: impl Into, message: impl Into) -> Self { + Self::new(code, message) + } + fn new(code: impl Into, message: impl Into) -> Self { Self { code: code.into(), diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index 5334be9b..56d491d7 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -16,7 +16,7 @@ use std::{ use worker_runtime::catalog::{ ConfigBundleRef, CreateWorkerRequest, ProfileSelector, WorkerDetail as EmbeddedWorkerDetail, WorkerStatus as EmbeddedWorkerStatus, WorkingDirectoryClaim, WorkingDirectoryRequest, - WorkingDirectorySummary, + WorkingDirectoryStatus, WorkingDirectorySummary, }; use worker_runtime::config_bundle::{ ConfigBundle, ConfigBundleAvailability, ConfigBundleMetadata, ConfigBundleProvenance, @@ -32,6 +32,7 @@ use worker_runtime::http_server::{ RuntimeHttpErrorResponse, RuntimeHttpSummaryResponse, RuntimeHttpTranscriptResponse, RuntimeHttpWorkerInputResponse, RuntimeHttpWorkerLifecycleRequest, RuntimeHttpWorkerLifecycleResponse, RuntimeHttpWorkerResponse, RuntimeHttpWorkersResponse, + RuntimeHttpWorkingDirectoriesResponse, RuntimeHttpWorkingDirectoryResponse, }; use worker_runtime::identity::{ RuntimeId as EmbeddedRuntimeId, WorkerId as EmbeddedWorkerId, WorkerRef as EmbeddedWorkerRef, @@ -266,6 +267,14 @@ pub struct WorkerLookupResult { pub diagnostics: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RuntimeWorkingDirectoryResult { + pub state: WorkerOperationState, + #[serde(skip_serializing_if = "Option::is_none")] + pub working_directory: Option, + pub diagnostics: Vec, +} + /// Browser-safe worker spawn request shape. /// /// The request carries Browser-facing launch semantics only: workspace intent, @@ -537,6 +546,56 @@ pub trait WorkspaceWorkerRuntime: Send + Sync { fn worker(&self, worker_id: &str) -> WorkerLookupResult; + fn create_working_directory( + &self, + _request: WorkingDirectoryRequest, + ) -> RuntimeWorkingDirectoryResult { + RuntimeWorkingDirectoryResult { + state: WorkerOperationState::Unsupported, + working_directory: None, + diagnostics: vec![diagnostic( + "runtime_working_directory_create_unsupported", + DiagnosticSeverity::Info, + "runtime does not implement working directory creation".to_string(), + )], + } + } + + fn list_working_directories(&self) -> RuntimeList { + RuntimeList::new(Vec::new(), Vec::new()) + } + + fn working_directory(&self, working_directory_id: &str) -> RuntimeWorkingDirectoryResult { + RuntimeWorkingDirectoryResult { + state: WorkerOperationState::Unsupported, + working_directory: None, + diagnostics: vec![diagnostic( + "runtime_working_directory_lookup_unsupported", + DiagnosticSeverity::Info, + format!( + "runtime does not implement working directory lookup for `{working_directory_id}`" + ), + )], + } + } + + fn cleanup_working_directory( + &self, + working_directory_id: &str, + ) -> RuntimeWorkingDirectoryResult { + RuntimeWorkingDirectoryResult { + state: WorkerOperationState::Unsupported, + working_directory: None, + diagnostics: vec![diagnostic( + "runtime_working_directory_cleanup_unsupported", + DiagnosticSeverity::Info, + format!( + "runtime does not implement working directory cleanup for `{working_directory_id}`" + ), + )], + } + } + fn spawn_worker(&self, request: WorkerSpawnRequest) -> WorkerSpawnResult { WorkerSpawnResult { state: WorkerOperationState::Unsupported, @@ -882,6 +941,47 @@ impl RuntimeRegistry { Ok(runtime.spawn_worker(request)) } + pub fn create_working_directory( + &self, + runtime_id: &str, + request: WorkingDirectoryRequest, + ) -> Result { + validate_backend_identifier("runtime_id", runtime_id)?; + let runtime = self.runtime(runtime_id)?; + Ok(runtime.create_working_directory(request)) + } + + pub fn list_working_directories( + &self, + runtime_id: &str, + ) -> Result, RuntimeRegistryError> { + validate_backend_identifier("runtime_id", runtime_id)?; + let runtime = self.runtime(runtime_id)?; + Ok(runtime.list_working_directories()) + } + + pub fn working_directory( + &self, + runtime_id: &str, + working_directory_id: &str, + ) -> Result { + validate_backend_identifier("runtime_id", runtime_id)?; + validate_backend_identifier("working_directory_id", working_directory_id)?; + let runtime = self.runtime(runtime_id)?; + Ok(runtime.working_directory(working_directory_id)) + } + + pub fn cleanup_working_directory( + &self, + runtime_id: &str, + working_directory_id: &str, + ) -> Result { + validate_backend_identifier("runtime_id", runtime_id)?; + validate_backend_identifier("working_directory_id", working_directory_id)?; + let runtime = self.runtime(runtime_id)?; + Ok(runtime.cleanup_working_directory(working_directory_id)) + } + pub fn sync_config_bundle( &self, runtime_id: &str, @@ -1311,6 +1411,64 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { } } + fn create_working_directory( + &self, + request: WorkingDirectoryRequest, + ) -> RuntimeWorkingDirectoryResult { + match self.runtime.create_working_directory(request) { + Ok(working_directory) => RuntimeWorkingDirectoryResult { + state: WorkerOperationState::Accepted, + working_directory: Some(working_directory), + diagnostics: Vec::new(), + }, + Err(err) => RuntimeWorkingDirectoryResult { + state: WorkerOperationState::Rejected, + working_directory: None, + diagnostics: vec![embedded_runtime_diagnostic(&err)], + }, + } + } + + fn list_working_directories(&self) -> RuntimeList { + match self.runtime.list_working_directories() { + Ok(items) => RuntimeList::new(items, Vec::new()), + Err(err) => RuntimeList::new(Vec::new(), vec![embedded_runtime_diagnostic(&err)]), + } + } + + fn working_directory(&self, working_directory_id: &str) -> RuntimeWorkingDirectoryResult { + match self.runtime.working_directory(working_directory_id) { + Ok(working_directory) => RuntimeWorkingDirectoryResult { + state: WorkerOperationState::Accepted, + working_directory: Some(working_directory), + diagnostics: Vec::new(), + }, + Err(err) => RuntimeWorkingDirectoryResult { + state: WorkerOperationState::Rejected, + working_directory: None, + diagnostics: vec![embedded_runtime_diagnostic(&err)], + }, + } + } + + fn cleanup_working_directory( + &self, + working_directory_id: &str, + ) -> RuntimeWorkingDirectoryResult { + match self.runtime.cleanup_working_directory(working_directory_id) { + Ok(working_directory) => RuntimeWorkingDirectoryResult { + state: WorkerOperationState::Accepted, + working_directory: Some(working_directory), + diagnostics: Vec::new(), + }, + Err(err) => RuntimeWorkingDirectoryResult { + state: WorkerOperationState::Rejected, + working_directory: None, + diagnostics: vec![embedded_runtime_diagnostic(&err)], + }, + } + } + fn spawn_worker(&self, request: WorkerSpawnRequest) -> WorkerSpawnResult { let mut diagnostics = Vec::new(); if matches!( @@ -1811,6 +1969,13 @@ impl RemoteWorkerRuntime { self.send_json(self.http.post(self.endpoint(path)).json(body)) } + fn delete_json(&self, path: &str) -> Result + where + T: DeserializeOwned + Send + 'static, + { + self.send_json(self.http.delete(self.endpoint(path))) + } + fn send_json(&self, request: RequestBuilder) -> Result where T: DeserializeOwned + Send + 'static, @@ -2035,6 +2200,71 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { } } + fn create_working_directory( + &self, + request: WorkingDirectoryRequest, + ) -> RuntimeWorkingDirectoryResult { + match self.post_json::<_, RuntimeHttpWorkingDirectoryResponse>( + "/v1/working-directories", + &request, + ) { + Ok(response) => RuntimeWorkingDirectoryResult { + state: WorkerOperationState::Accepted, + working_directory: Some(response.working_directory), + diagnostics: Vec::new(), + }, + Err(diagnostic) => RuntimeWorkingDirectoryResult { + state: WorkerOperationState::Rejected, + working_directory: None, + diagnostics: vec![diagnostic], + }, + } + } + + fn list_working_directories(&self) -> RuntimeList { + match self.get_json::("/v1/working-directories") { + Ok(response) => RuntimeList::new(response.working_directories, Vec::new()), + Err(diagnostic) => RuntimeList::new(Vec::new(), vec![diagnostic]), + } + } + + fn working_directory(&self, working_directory_id: &str) -> RuntimeWorkingDirectoryResult { + match self.get_json::(&format!( + "/v1/working-directories/{working_directory_id}" + )) { + Ok(response) => RuntimeWorkingDirectoryResult { + state: WorkerOperationState::Accepted, + working_directory: Some(response.working_directory), + diagnostics: Vec::new(), + }, + Err(diagnostic) => RuntimeWorkingDirectoryResult { + state: WorkerOperationState::Rejected, + working_directory: None, + diagnostics: vec![diagnostic], + }, + } + } + + fn cleanup_working_directory( + &self, + working_directory_id: &str, + ) -> RuntimeWorkingDirectoryResult { + match self.delete_json::(&format!( + "/v1/working-directories/{working_directory_id}" + )) { + Ok(response) => RuntimeWorkingDirectoryResult { + state: WorkerOperationState::Accepted, + working_directory: Some(response.working_directory), + diagnostics: Vec::new(), + }, + Err(diagnostic) => RuntimeWorkingDirectoryResult { + state: WorkerOperationState::Rejected, + working_directory: None, + diagnostics: vec![diagnostic], + }, + } + } + fn spawn_worker(&self, request: WorkerSpawnRequest) -> WorkerSpawnResult { if matches!( request.acceptance, @@ -2801,6 +3031,14 @@ fn remote_reqwest_diagnostic(runtime_id: &str, err: reqwest::Error) -> RuntimeDi } } +fn sanitize_remote_runtime_message(code: &str, message: &str) -> String { + if message.contains('/') || message.contains('\\') { + format!("remote Runtime returned {code}; backend-private details were omitted") + } else { + message.to_string() + } +} + fn remote_http_status_diagnostic( runtime_id: &str, status: StatusCode, @@ -2811,6 +3049,10 @@ fn remote_http_status_diagnostic( .as_ref() .map(|error| error.error.code.as_str()) .unwrap_or("remote_http_error"); + let remote_message = error + .as_ref() + .map(|error| sanitize_remote_runtime_message(remote_code, &error.error.message)) + .unwrap_or_else(|| "remote Runtime did not return a typed error body".to_string()); let (code, severity) = match status { StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { ("remote_runtime_auth_failed", DiagnosticSeverity::Error) @@ -2820,14 +3062,12 @@ fn remote_http_status_diagnostic( ("remote_runtime_unsupported", DiagnosticSeverity::Warning) } _ if status.is_server_error() => ("remote_runtime_http_error", DiagnosticSeverity::Error), - _ => ("remote_runtime_http_rejected", DiagnosticSeverity::Warning), + _ => (remote_code, DiagnosticSeverity::Warning), }; diagnostic( code, severity, - format!( - "Remote Runtime '{runtime_id}' rejected request ({remote_code}, HTTP {status}); internal details were sanitized" - ), + format!("Remote Runtime '{runtime_id}' rejected request (HTTP {status}): {remote_message}"), ) } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index ffa67b8a..978b4eb0 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -14,9 +14,7 @@ use serde::{Deserialize, Serialize}; use tokio::net::TcpListener; use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest}; use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend}; -use worker_runtime::working_directory::{ - LocalGitWorktreeMaterializer, WorkingDirectoryDiagnostic, WorkingDirectoryMaterializer, -}; +use worker_runtime::working_directory::LocalGitWorktreeMaterializer; use crate::companion::{ CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse, @@ -67,6 +65,10 @@ use worker_runtime::interaction::{ const EMBEDDED_WORKER_RUNTIME_ID: &str = "embedded-worker-runtime"; +fn embedded_runtime_id() -> String { + EMBEDDED_WORKER_RUNTIME_ID.to_string() +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum AuthConfig { /// Local/dev-only mode. If a token is configured by a future entrypoint, it @@ -162,14 +164,10 @@ pub struct WorkspaceApi { companion: Arc, observation_proxy: BackendObservationProxy, resource_broker: BackendResourceBroker, - working_directory_materializer: Arc, } impl WorkspaceApi { pub async fn new(config: ServerConfig, store: Arc) -> Result { - let materializer = Arc::new(LocalGitWorktreeMaterializer::new( - config.embedded_runtime_store_root.clone(), - )); let resource_broker = BackendResourceBroker::default(); let execution_backend = WorkerRuntimeExecutionBackend::new( ProfileRuntimeWorkerFactory::new(config.workspace_root.clone()) @@ -180,7 +178,9 @@ impl WorkspaceApi { "failed to initialize embedded Worker backend: {err}" )) })? - .with_working_directory_materializer((*materializer).clone()); + .with_working_directory_materializer(LocalGitWorktreeMaterializer::new( + config.embedded_runtime_store_root.clone(), + )); Self::new_with_execution_backend_and_broker( config, store, @@ -241,9 +241,6 @@ impl WorkspaceApi { let runtime = Arc::new(runtime); let companion = Arc::new(CompanionConsole::new(runtime.clone())); let observation_proxy = BackendObservationProxy::new(config.runtime_event_sources.clone()); - let working_directory_materializer = Arc::new(LocalGitWorktreeMaterializer::new( - config.embedded_runtime_store_root.clone(), - )); Ok(Self { records: LocalProjectRecordReader::new(config.workspace_root.clone()), config, @@ -252,7 +249,6 @@ impl WorkspaceApi { companion, observation_proxy, resource_broker, - working_directory_materializer, }) } @@ -326,6 +322,14 @@ pub fn build_router(api: WorkspaceApi) -> Router { ) .route("/api/hosts", get(list_hosts)) .route("/api/w/{workspace_id}/hosts", get(scoped_list_hosts)) + .route( + "/api/w/{workspace_id}/runtimes/{runtime_id}/working-directories", + get(scoped_list_runtime_working_directories).post(scoped_create_runtime_working_directory), + ) + .route( + "/api/w/{workspace_id}/runtimes/{runtime_id}/working-directories/{working_directory_id}", + get(scoped_runtime_working_directory_detail).delete(scoped_cleanup_runtime_working_directory), + ) .route( "/api/w/{workspace_id}/working-directories", get(scoped_list_working_directories).post(scoped_create_working_directory), @@ -662,6 +666,8 @@ pub enum BrowserWorkingDirectoryCleanupPolicy { #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub struct BrowserWorkingDirectoryCreateRequest { + #[serde(default = "embedded_runtime_id")] + pub runtime_id: String, pub repository_id: String, #[serde(default)] pub selector: Option, @@ -807,6 +813,13 @@ struct ScopedWorkingDirectoryPath { working_directory_id: String, } +#[derive(Debug, Deserialize)] +struct ScopedRuntimeWorkingDirectoryPath { + workspace_id: String, + runtime_id: String, + working_directory_id: String, +} + #[derive(Debug, Deserialize)] struct ScopedConfigBundlePath { workspace_id: String, @@ -1054,6 +1067,52 @@ async fn scoped_get_worker_launch_options( get_worker_launch_options(State(api)).await } +async fn scoped_list_runtime_working_directories( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let list = api + .runtime + .list_working_directories(&path.runtime_id) + .map_err(|err| err.into_error())?; + Ok(Json(BrowserWorkingDirectoryListResponse { + workspace_id: api.config.workspace_id.clone(), + items: list + .items + .into_iter() + .map(|status| status.summary) + .collect(), + diagnostics: list.diagnostics, + })) +} + +async fn scoped_create_runtime_working_directory( + State(api): State, + AxumPath(path): AxumPath, + Json(mut request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + request.runtime_id = path.runtime_id; + create_working_directory_for_runtime(api, request) +} + +async fn scoped_runtime_working_directory_detail( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + working_directory_detail_for_runtime(api, &path.runtime_id, &path.working_directory_id) +} + +async fn scoped_cleanup_runtime_working_directory( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + cleanup_working_directory_for_runtime(api, &path.runtime_id, &path.working_directory_id) +} + async fn scoped_list_working_directories( State(api): State, AxumPath(path): AxumPath, @@ -1073,22 +1132,7 @@ async fn scoped_create_working_directory( Json(request): Json, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - let working_directory_request = working_directory_request_for_browser(&api, request)?; - let binding = api - .working_directory_materializer - .create(&working_directory_request) - .map_err(|diagnostic| { - ApiError::from(Error::RuntimeOperationFailed { - runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), - code: diagnostic.code, - message: diagnostic.message, - }) - })?; - Ok(Json(BrowserWorkingDirectoryDetailResponse { - workspace_id: api.config.workspace_id.clone(), - item: binding.status().summary, - diagnostics: Vec::new(), - })) + create_working_directory_for_runtime(api, request) } async fn scoped_working_directory_detail( @@ -1096,21 +1140,11 @@ async fn scoped_working_directory_detail( AxumPath(path): AxumPath, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - let status = api - .working_directory_materializer - .working_directory_status(&path.working_directory_id) - .map_err(|diagnostic| { - ApiError::from(Error::RuntimeOperationFailed { - runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), - code: diagnostic.code, - message: diagnostic.message, - }) - })?; - Ok(Json(BrowserWorkingDirectoryDetailResponse { - workspace_id: api.config.workspace_id.clone(), - item: status.summary, - diagnostics: Vec::new(), - })) + working_directory_detail_for_runtime( + api, + EMBEDDED_WORKER_RUNTIME_ID, + &path.working_directory_id, + ) } async fn scoped_cleanup_working_directory( @@ -1118,20 +1152,89 @@ async fn scoped_cleanup_working_directory( AxumPath(path): AxumPath, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - let status = api - .working_directory_materializer - .cleanup_working_directory(&path.working_directory_id) - .map_err(|diagnostic| { - ApiError::from(Error::RuntimeOperationFailed { - runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), - code: diagnostic.code, - message: diagnostic.message, - }) - })?; + cleanup_working_directory_for_runtime( + api, + EMBEDDED_WORKER_RUNTIME_ID, + &path.working_directory_id, + ) +} + +fn create_working_directory_for_runtime( + api: WorkspaceApi, + request: BrowserWorkingDirectoryCreateRequest, +) -> ApiResult> { + let runtime_id = request.runtime_id.clone(); + let working_directory_request = working_directory_request_for_browser(&api, request)?; + let result = api + .runtime + .create_working_directory(&runtime_id, working_directory_request) + .map_err(|err| err.into_error())?; + let Some(working_directory) = result.working_directory else { + return Err(ApiError::with_diagnostics( + Error::RuntimeOperationFailed { + runtime_id, + code: "workspace_working_directory_create_failed".to_string(), + message: "Runtime did not create working directory".to_string(), + }, + result.diagnostics, + )); + }; Ok(Json(BrowserWorkingDirectoryDetailResponse { workspace_id: api.config.workspace_id.clone(), - item: status.summary, - diagnostics: Vec::new(), + item: working_directory.summary, + diagnostics: result.diagnostics, + })) +} + +fn working_directory_detail_for_runtime( + api: WorkspaceApi, + runtime_id: &str, + working_directory_id: &str, +) -> ApiResult> { + let result = api + .runtime + .working_directory(runtime_id, working_directory_id) + .map_err(|err| err.into_error())?; + let Some(working_directory) = result.working_directory else { + return Err(ApiError::with_diagnostics( + Error::RuntimeOperationFailed { + runtime_id: runtime_id.to_string(), + code: "workspace_working_directory_lookup_failed".to_string(), + message: "Runtime did not return working directory".to_string(), + }, + result.diagnostics, + )); + }; + Ok(Json(BrowserWorkingDirectoryDetailResponse { + workspace_id: api.config.workspace_id.clone(), + item: working_directory.summary, + diagnostics: result.diagnostics, + })) +} + +fn cleanup_working_directory_for_runtime( + api: WorkspaceApi, + runtime_id: &str, + working_directory_id: &str, +) -> ApiResult> { + let result = api + .runtime + .cleanup_working_directory(runtime_id, working_directory_id) + .map_err(|err| err.into_error())?; + let Some(working_directory) = result.working_directory else { + return Err(ApiError::with_diagnostics( + Error::RuntimeOperationFailed { + runtime_id: runtime_id.to_string(), + code: "workspace_working_directory_cleanup_failed".to_string(), + message: "Runtime did not cleanup working directory".to_string(), + }, + result.diagnostics, + )); + }; + Ok(Json(BrowserWorkingDirectoryDetailResponse { + workspace_id: api.config.workspace_id.clone(), + item: working_directory.summary, + diagnostics: result.diagnostics, })) } @@ -1812,26 +1915,7 @@ async fn create_workspace_worker( working_directory_id: selection.working_directory_id, relative_cwd: selection.relative_cwd, }); - if resolved_working_directory.is_some() && request.runtime_id != EMBEDDED_WORKER_RUNTIME_ID { - return Err(Error::RuntimeOperationFailed { - runtime_id: request.runtime_id.clone(), - code: "working_directory_runtime_unsupported".to_string(), - message: - "created working directories are only supported by the embedded local runtime in v0" - .to_string(), - } - .into()); - } - if let Some(working_directory) = resolved_working_directory.as_ref() { - api.working_directory_materializer - .bind_working_directory( - &working_directory.working_directory_id, - working_directory.relative_cwd.as_deref(), - ) - .map_err(|diagnostic| { - working_directory_api_error(request.runtime_id.clone(), diagnostic) - })?; - } + validate_working_directory_claim_for_browser(resolved_working_directory.as_ref())?; let result = api .runtime .spawn_worker( @@ -2924,16 +3008,55 @@ fn working_directory_repository_options( } fn working_directory_summaries(api: &WorkspaceApi) -> ApiResult> { - api.working_directory_materializer - .list_working_directories() - .map(|items| items.into_iter().map(|status| status.summary).collect()) - .map_err(|diagnostic| { - ApiError::from(Error::RuntimeOperationFailed { + let list = api + .runtime + .list_working_directories(EMBEDDED_WORKER_RUNTIME_ID) + .map_err(|err| err.into_error())?; + if !list.diagnostics.is_empty() { + return Err(ApiError::with_diagnostics( + Error::RuntimeOperationFailed { runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), - code: diagnostic.code, - message: diagnostic.message, - }) - }) + code: "workspace_working_directory_list_failed".to_string(), + message: "Runtime did not list working directories".to_string(), + }, + list.diagnostics, + )); + } + Ok(list + .items + .into_iter() + .map(|status| status.summary) + .collect()) +} + +fn validate_working_directory_claim_for_browser( + claim: Option<&WorkingDirectoryClaim>, +) -> ApiResult<()> { + let Some(claim) = claim else { + return Ok(()); + }; + if let Some(relative_cwd) = claim.relative_cwd.as_deref() { + let path = Path::new(relative_cwd); + if path.is_absolute() + || path + .components() + .any(|component| !matches!(component, Component::CurDir | Component::Normal(_))) + { + return Err(ApiError::with_diagnostics( + Error::RuntimeOperationFailed { + runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), + code: "working_directory_relative_cwd_invalid".to_string(), + message: "working directory relative_cwd must stay inside the Runtime-owned working directory".to_string(), + }, + vec![RuntimeDiagnostic { + code: "working_directory_relative_cwd_invalid".to_string(), + severity: DiagnosticSeverity::Error, + message: "relative_cwd must be a relative path without parent traversal".to_string(), + }], + )); + } + } + Ok(()) } fn working_directory_request_for_browser( @@ -3065,24 +3188,6 @@ fn worker_create_not_accepted_error( ) } -fn working_directory_api_error( - runtime_id: String, - diagnostic: WorkingDirectoryDiagnostic, -) -> ApiError { - ApiError::with_diagnostics( - Error::RuntimeOperationFailed { - runtime_id, - code: diagnostic.code.clone(), - message: diagnostic.message.clone(), - }, - vec![RuntimeDiagnostic { - code: diagnostic.code, - severity: DiagnosticSeverity::Error, - message: diagnostic.message, - }], - ) -} - fn settings_bad_request(code: &'static str, message: &'static str) -> ApiError { Error::RuntimeOperationFailed { runtime_id: "workspace-backend".to_string(), @@ -3475,6 +3580,7 @@ mod tests { use tokio_tungstenite::tungstenite::Message; use tower::ServiceExt; use worker_runtime::resource::BackendResourceClient; + use worker_runtime::working_directory::WorkingDirectoryMaterializer; use crate::hosts::{ TicketWorkerRole, WorkerInputKind, WorkerOperationState, WorkerSpawnAcceptanceRequirement, @@ -3731,7 +3837,6 @@ mod tests { ); } - #[derive(Default)] struct DeterministicExecutionBackend { contexts: std::sync::Mutex< std::collections::HashMap< @@ -3739,6 +3844,26 @@ mod tests { worker_runtime::execution::WorkerExecutionContext, >, >, + materializer: worker_runtime::working_directory::LocalGitWorktreeMaterializer, + } + + impl Default for DeterministicExecutionBackend { + fn default() -> Self { + let unique = format!( + "yoi-deterministic-wd-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + Self { + contexts: std::sync::Mutex::new(std::collections::HashMap::new()), + materializer: worker_runtime::working_directory::LocalGitWorktreeMaterializer::new( + std::env::temp_dir().join(unique), + ), + } + } } impl worker_runtime::execution::WorkerExecutionBackend for DeterministicExecutionBackend { @@ -3746,10 +3871,65 @@ mod tests { "deterministic-workspace-server-test" } + fn create_working_directory( + &self, + request: &worker_runtime::catalog::WorkingDirectoryRequest, + ) -> std::result::Result< + worker_runtime::catalog::WorkingDirectoryStatus, + worker_runtime::working_directory::WorkingDirectoryDiagnostic, + > { + Ok(self.materializer.create(request)?.status()) + } + + fn list_working_directories(&self) -> Vec { + self.materializer + .list_working_directories() + .unwrap_or_default() + } + + fn working_directory( + &self, + working_directory_id: &str, + ) -> std::result::Result< + worker_runtime::catalog::WorkingDirectoryStatus, + worker_runtime::working_directory::WorkingDirectoryDiagnostic, + > { + self.materializer + .working_directory_status(working_directory_id) + } + + fn cleanup_working_directory( + &self, + working_directory_id: &str, + ) -> std::result::Result< + worker_runtime::catalog::WorkingDirectoryStatus, + worker_runtime::working_directory::WorkingDirectoryDiagnostic, + > { + self.materializer + .cleanup_working_directory(working_directory_id) + } + fn spawn_worker( &self, request: worker_runtime::execution::WorkerExecutionSpawnRequest, ) -> worker_runtime::execution::WorkerExecutionSpawnResult { + let working_directory = match request.request.working_directory.as_ref() { + Some(claim) => match self.materializer.bind_working_directory( + &claim.working_directory_id, + claim.relative_cwd.as_deref(), + ) { + Ok(binding) => Some(binding.status()), + Err(diagnostic) => { + return worker_runtime::execution::WorkerExecutionSpawnResult::Rejected( + worker_runtime::execution::WorkerExecutionResult::rejected( + worker_runtime::execution::WorkerExecutionOperation::Spawn, + diagnostic.to_string(), + ), + ); + } + }, + None => None, + }; self.contexts .lock() .unwrap() @@ -3760,10 +3940,7 @@ mod tests { self.backend_id(), ), run_state: worker_runtime::execution::WorkerExecutionRunState::Idle, - working_directory: request - .working_directory - .as_ref() - .map(|binding| binding.status()), + working_directory, } } diff --git a/web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte b/web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte index a51b61ba..e6385d8c 100644 --- a/web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte +++ b/web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte @@ -142,10 +142,12 @@ creatingWorkingDirectory = true; submitError = null; try { - const response = await fetch(workerApiPath('/working-directories'), { + const response = await fetch( + workerApiPath(`/runtimes/${encodeURIComponent(runtimeId)}/working-directories`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ + runtime_id: runtimeId, repository_id: workingDirectoryRepositoryId, selector: workingDirectorySelector || null, policy: { dirty_state: 'clean_point_only', cleanup: 'manual_or_worker_stop' }, diff --git a/web/workspace/src/lib/workspace-sidebar/types.ts b/web/workspace/src/lib/workspace-sidebar/types.ts index 975b75bc..e8379c87 100644 --- a/web/workspace/src/lib/workspace-sidebar/types.ts +++ b/web/workspace/src/lib/workspace-sidebar/types.ts @@ -150,6 +150,7 @@ export type BrowserWorkerWorkingDirectorySelection = { }; export type BrowserWorkingDirectoryCreateRequest = { + runtime_id: string; repository_id: string; selector?: string | null; policy?: {