runtime: own working directories

This commit is contained in:
Keisuke Hirata 2026-07-09 19:04:37 +09:00
parent afc43780a7
commit fa233ba025
No known key found for this signature in database
9 changed files with 798 additions and 117 deletions

View File

@ -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<WorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
Err(WorkingDirectoryDiagnostic::rejected(
"working_directory_unsupported",
"Worker execution backend does not support working directory materialization",
))
}
fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
Vec::new()
}
fn working_directory(
&self,
working_directory_id: &str,
) -> Result<WorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
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<WorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
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<WorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
self.backend.create_working_directory(request)
}
pub(crate) fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
self.backend.list_working_directories()
}
pub(crate) fn working_directory(
&self,
working_directory_id: &str,
) -> Result<WorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
self.backend.working_directory(working_directory_id)
}
pub(crate) fn cleanup_working_directory(
&self,
working_directory_id: &str,
) -> Result<WorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
self.backend.cleanup_working_directory(working_directory_id)
}
pub(crate) fn dispatch_input(
&self,
handle: &WorkerExecutionHandle,

View File

@ -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<String>) -> 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<WorkerSummary>,
}
/// `GET /v1/working-directories` response.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeHttpWorkingDirectoriesResponse {
pub working_directories: Vec<WorkingDirectoryStatus>,
}
/// 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<RuntimeHttpState>,
) -> RestResult<RuntimeHttpWorkingDirectoriesResponse> {
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<RuntimeHttpState>,
body: Result<Json<WorkingDirectoryRequest>, JsonRejection>,
) -> RestResult<RuntimeHttpWorkingDirectoryResponse> {
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<RuntimeHttpState>,
Path(working_directory_id): Path<String>,
) -> RestResult<RuntimeHttpWorkingDirectoryResponse> {
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<RuntimeHttpState>,
Path(working_directory_id): Path<String>,
) -> RestResult<RuntimeHttpWorkingDirectoryResponse> {
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<RuntimeHttpState>,
Path(worker_id): Path<String>,

View File

@ -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<CatalogWorkingDirectoryStatus, RuntimeError> {
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<Vec<CatalogWorkingDirectoryStatus>, 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<CatalogWorkingDirectoryStatus, RuntimeError> {
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<CatalogWorkingDirectoryStatus, RuntimeError> {
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,

View File

@ -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<WorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
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<WorkingDirectoryStatus> {
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<WorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
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<WorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
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

View File

@ -95,6 +95,10 @@ pub struct WorkingDirectoryDiagnostic {
}
impl WorkingDirectoryDiagnostic {
pub fn rejected(code: impl Into<String>, message: impl Into<String>) -> Self {
Self::new(code, message)
}
fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),

View File

@ -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<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RuntimeWorkingDirectoryResult {
pub state: WorkerOperationState,
#[serde(skip_serializing_if = "Option::is_none")]
pub working_directory: Option<WorkingDirectoryStatus>,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
/// 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<WorkingDirectoryStatus> {
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<RuntimeWorkingDirectoryResult, RuntimeRegistryError> {
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<RuntimeList<WorkingDirectoryStatus>, 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<RuntimeWorkingDirectoryResult, RuntimeRegistryError> {
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<RuntimeWorkingDirectoryResult, RuntimeRegistryError> {
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<WorkingDirectoryStatus> {
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<T>(&self, path: &str) -> Result<T, RuntimeDiagnostic>
where
T: DeserializeOwned + Send + 'static,
{
self.send_json(self.http.delete(self.endpoint(path)))
}
fn send_json<T>(&self, request: RequestBuilder) -> Result<T, RuntimeDiagnostic>
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<WorkingDirectoryStatus> {
match self.get_json::<RuntimeHttpWorkingDirectoriesResponse>("/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::<RuntimeHttpWorkingDirectoryResponse>(&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::<RuntimeHttpWorkingDirectoryResponse>(&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}"),
)
}

View File

@ -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<CompanionConsole>,
observation_proxy: BackendObservationProxy,
resource_broker: BackendResourceBroker,
working_directory_materializer: Arc<LocalGitWorktreeMaterializer>,
}
impl WorkspaceApi {
pub async fn new(config: ServerConfig, store: Arc<dyn ControlPlaneStore>) -> Result<Self> {
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<String>,
@ -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<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedRuntimePath>,
) -> ApiResult<Json<BrowserWorkingDirectoryListResponse>> {
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<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedRuntimePath>,
Json(mut request): Json<BrowserWorkingDirectoryCreateRequest>,
) -> ApiResult<Json<BrowserWorkingDirectoryDetailResponse>> {
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<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedRuntimeWorkingDirectoryPath>,
) -> ApiResult<Json<BrowserWorkingDirectoryDetailResponse>> {
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<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedRuntimeWorkingDirectoryPath>,
) -> ApiResult<Json<BrowserWorkingDirectoryDetailResponse>> {
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<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
@ -1073,22 +1132,7 @@ async fn scoped_create_working_directory(
Json(request): Json<BrowserWorkingDirectoryCreateRequest>,
) -> ApiResult<Json<BrowserWorkingDirectoryDetailResponse>> {
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<ScopedWorkingDirectoryPath>,
) -> ApiResult<Json<BrowserWorkingDirectoryDetailResponse>> {
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<ScopedWorkingDirectoryPath>,
) -> ApiResult<Json<BrowserWorkingDirectoryDetailResponse>> {
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<Json<BrowserWorkingDirectoryDetailResponse>> {
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<Json<BrowserWorkingDirectoryDetailResponse>> {
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<Json<BrowserWorkingDirectoryDetailResponse>> {
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<Vec<WorkingDirectorySummary>> {
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<worker_runtime::catalog::WorkingDirectoryStatus> {
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,
}
}

View File

@ -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' },

View File

@ -150,6 +150,7 @@ export type BrowserWorkerWorkingDirectorySelection = {
};
export type BrowserWorkingDirectoryCreateRequest = {
runtime_id: string;
repository_id: string;
selector?: string | null;
policy?: {