From 59b1b3df793c843e974faae805109bfd04b5cdaf Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 12 Jul 2026 20:07:32 +0900 Subject: [PATCH] workspace: gate workdir assignment --- crates/worker-runtime/src/catalog.rs | 2 + crates/worker-runtime/src/runtime.rs | 71 ++++++++++++++++++- crates/worker-runtime/src/worker_backend.rs | 4 +- .../worker-runtime/src/working_directory.rs | 6 +- crates/workspace-server/src/server.rs | 54 +++++++++++--- .../src/lib/workspace-sidebar/types.ts | 1 + .../workspace-sidebar/worker-launch.test.ts | 2 + .../lib/workspace-sidebar/worker-launch.ts | 17 +++-- .../w/[workspaceId]/workers/new/+page.svelte | 37 +++++++--- 9 files changed, 161 insertions(+), 33 deletions(-) diff --git a/crates/worker-runtime/src/catalog.rs b/crates/worker-runtime/src/catalog.rs index 87331edf..3ce27380 100644 --- a/crates/worker-runtime/src/catalog.rs +++ b/crates/worker-runtime/src/catalog.rs @@ -154,6 +154,8 @@ pub struct WorkingDirectorySummary { pub status: WorkingDirectoryStatusKind, #[serde(default, skip_serializing_if = "Option::is_none")] pub cleanliness: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub primary_worker_id: Option, /// Backend projection metadata. Runtimes leave this absent; Workspace Browser /// APIs fill it with `backend_managed` or `runtime_unmanaged`. #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index 5278dbf1..65dbaf59 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -270,7 +270,8 @@ impl Runtime { } })? }; - Ok(backend.list_working_directories()) + let statuses = backend.list_working_directories(); + self.annotate_working_directory_statuses(statuses) } /// Get a Runtime-owned working directory status. @@ -287,9 +288,10 @@ impl Runtime { } })? }; - backend + let status = backend .working_directory(working_directory_id) - .map_err(|diagnostic| RuntimeError::InvalidRequest(diagnostic.to_string())) + .map_err(|diagnostic| RuntimeError::InvalidRequest(diagnostic.to_string()))?; + self.annotate_working_directory_status(status) } /// Cleanup a Runtime-owned working directory. @@ -300,6 +302,11 @@ impl Runtime { let backend = { let state = self.lock()?; state.ensure_running()?; + if let Some(worker_id) = state.primary_worker_id_for_workdir(working_directory_id) { + return Err(RuntimeError::InvalidRequest(format!( + "working directory {working_directory_id} is assigned to worker {worker_id}" + ))); + } state.execution_backend.clone().ok_or_else(|| { RuntimeError::ExecutionBackendUnavailable { message: "working directory cleanup requires an execution backend".to_string(), @@ -311,6 +318,26 @@ impl Runtime { .map_err(|diagnostic| RuntimeError::InvalidRequest(diagnostic.to_string())) } + fn annotate_working_directory_statuses( + &self, + statuses: Vec, + ) -> Result, RuntimeError> { + statuses + .into_iter() + .map(|status| self.annotate_working_directory_status(status)) + .collect() + } + + fn annotate_working_directory_status( + &self, + mut status: CatalogWorkingDirectoryStatus, + ) -> Result { + let state = self.lock()?; + status.summary.primary_worker_id = + state.primary_worker_id_for_workdir(status.summary.working_directory_id.as_str()); + Ok(status) + } + /// Create a Worker through the canonical profile-source + execution backend path. pub fn create_worker( &self, @@ -321,6 +348,15 @@ impl Runtime { state.ensure_running()?; validate_create_worker_request(&request)?; state.validate_worker_config_boundary(&request)?; + if let Some(working_directory_id) = requested_primary_workdir_id(&request) { + if let Some(owner_worker_id) = + state.primary_worker_id_for_workdir(working_directory_id) + { + return Err(RuntimeError::InvalidRequest(format!( + "working directory {working_directory_id} is already assigned to worker {owner_worker_id}" + ))); + } + } let backend = state.execution_backend.clone().ok_or_else(|| { RuntimeError::ExecutionBackendUnavailable { message: "worker creation requires an execution backend".to_string(), @@ -1342,6 +1378,22 @@ impl RuntimeState { }) } + fn primary_worker_id_for_workdir(&self, working_directory_id: &str) -> Option { + self.workers.values().find_map(|worker| { + if worker + .execution + .working_directory + .as_ref() + .is_some_and(|binding| binding.summary.working_directory_id == working_directory_id) + || requested_primary_workdir_id(&worker.request) == Some(working_directory_id) + { + Some(worker.worker_id) + } else { + None + } + }) + } + fn push_event( &mut self, worker_ref: Option, @@ -1510,6 +1562,19 @@ impl WorkerRecord { } } +fn requested_primary_workdir_id(request: &CreateWorkerRequest) -> Option<&str> { + request + .working_directory + .as_ref() + .map(|claim| claim.working_directory_id.as_str()) + .or_else(|| { + request + .working_directory_request + .as_ref() + .and_then(|request| request.backend_workdir_id.as_deref()) + }) +} + fn validate_create_worker_request(request: &CreateWorkerRequest) -> Result<(), RuntimeError> { match &request.profile_source { crate::catalog::ProfileSourceArchiveSource::Embedded { archive } => { diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index bd914eaa..3b24ac3a 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -866,8 +866,8 @@ mod tests { use crate::Runtime as EmbeddedRuntime; use crate::catalog::{ - ConfigBundleRef, CreateWorkerRequest, MaterializerKind, ProfileSelector, RepositorySelector, - WorkingDirectoryRepository, WorkingDirectoryRequest, + ConfigBundleRef, CreateWorkerRequest, MaterializerKind, ProfileSelector, + RepositorySelector, WorkingDirectoryRepository, WorkingDirectoryRequest, }; use crate::execution::WorkerExecutionContext; use crate::identity::RuntimeId; diff --git a/crates/worker-runtime/src/working_directory.rs b/crates/worker-runtime/src/working_directory.rs index 51ba421b..ea0789d9 100644 --- a/crates/worker-runtime/src/working_directory.rs +++ b/crates/worker-runtime/src/working_directory.rs @@ -1,6 +1,6 @@ use crate::catalog::{ - MaterializerKind, WorkingDirectoryCleanupTarget, WorkingDirectoryRequest, WorkingDirectoryStatus, - WorkingDirectoryStatusKind, WorkingDirectorySummary, + MaterializerKind, WorkingDirectoryCleanupTarget, WorkingDirectoryRequest, + WorkingDirectoryStatus, WorkingDirectoryStatusKind, WorkingDirectorySummary, }; use crate::identity::WorkerRef; use serde::{Deserialize, Serialize}; @@ -47,6 +47,7 @@ impl WorkingDirectory { cleanup_target: Some(self.cleanup_target.clone()), status: self.status.clone(), cleanliness: None, + primary_worker_id: None, management_kind: None, } } @@ -220,6 +221,7 @@ impl LocalGitWorktreeMaterializer { }), status: WorkingDirectoryStatusKind::Corrupted, cleanliness: Some("unknown".to_string()), + primary_worker_id: None, management_kind: None, }, } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index f76c66db..43d98d51 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -1709,8 +1709,7 @@ fn build_runtime_cleanup_plan( reason: if blocking_reason.is_some() { "Workdir cleanup is blocked until linked Worker state is safe".to_string() } else if matches!(file_status.as_str(), "missing" | "not_found") { - "Not-found Workdir record can be deleted from the Backend registry" - .to_string() + "Not-found Workdir record can be deleted from the Backend registry".to_string() } else if file_status == "corrupted" { "Corrupted Workdir can be deleted from Runtime storage and Backend registry" .to_string() @@ -1848,9 +1847,10 @@ fn execute_runtime_cleanup( )); } cleanup_runtime_workdir_for_execution(api, runtime_id, candidate)?; - let deleted = api - .store - .delete_workdir_registry(&api.config.workspace_id, candidate.workdir_id.as_str())?; + let deleted = api.store.delete_workdir_registry( + &api.config.workspace_id, + candidate.workdir_id.as_str(), + )?; if !deleted { return Err(cleanup_api_error( runtime_id, @@ -1869,9 +1869,10 @@ fn execute_runtime_cleanup( } CleanupTargetKind::WorkdirCleanCleanup => { cleanup_runtime_workdir_for_execution(api, runtime_id, candidate)?; - let deleted = api - .store - .delete_workdir_registry(&api.config.workspace_id, candidate.workdir_id.as_str())?; + let deleted = api.store.delete_workdir_registry( + &api.config.workspace_id, + candidate.workdir_id.as_str(), + )?; if !deleted { return Err(cleanup_api_error( runtime_id, @@ -1883,7 +1884,8 @@ fn execute_runtime_cleanup( target_id: candidate.target_id.clone(), action: candidate.action.clone(), status: "deleted".to_string(), - message: "Workdir deleted from Runtime storage and Backend registry".to_string(), + message: "Workdir deleted from Runtime storage and Backend registry" + .to_string(), }); } CleanupTargetKind::WorkdirRecordDelete => { @@ -3933,7 +3935,7 @@ fn worker_launch_options_response(api: &WorkspaceApi) -> WorkerLaunchOptionsResp runtimes, profiles: worker_profile_candidates_for_root(&api.config.workspace_root), repositories: working_directory_repository_options(api), - working_directories: working_directory_summaries(api).unwrap_or_default(), + working_directories: available_working_directory_summaries(api).unwrap_or_default(), diagnostics: Vec::new(), } } @@ -3966,6 +3968,32 @@ fn working_directory_summaries(api: &WorkspaceApi) -> ApiResult>()) } +fn available_working_directory_summaries( + api: &WorkspaceApi, +) -> ApiResult> { + let limit = api.config.max_records.min(200); + for worker in api.runtime.list_workers(limit).items { + let _ = sync_worker_observation(api, &worker); + } + let records = working_directory_summaries(api)?; + let mut available = Vec::new(); + for summary in records { + if summary.status != WorkingDirectoryStatusKind::Active + || summary.cleanliness.as_deref() != Some("clean") + { + continue; + } + let links = api.store.list_workdir_worker_links( + &api.config.workspace_id, + summary.working_directory_id.as_str(), + )?; + if links.is_empty() && summary.primary_worker_id.is_none() { + available.push(summary); + } + } + Ok(available) +} + fn runtime_working_directory_summaries( api: &WorkspaceApi, runtime_id: &str, @@ -4235,7 +4263,10 @@ fn sync_runtime_workdir_observations( &status.summary, management_kind.as_str(), ); - preserve_workdir_identity_for_corrupted_summary(&mut updated, Some(&record)); + preserve_workdir_identity_for_corrupted_summary( + &mut updated, + Some(&record), + ); api.store.upsert_workdir_registry(&updated)?; } } else { @@ -4400,6 +4431,7 @@ fn workdir_summary_from_record(record: &WorkdirRegistryRecord) -> WorkingDirecto }), status, cleanliness: Some(record.cleanliness.clone()), + primary_worker_id: None, management_kind: Some(record.management_kind.clone()), } } diff --git a/web/workspace/src/lib/workspace-sidebar/types.ts b/web/workspace/src/lib/workspace-sidebar/types.ts index 330ab497..8240d29c 100644 --- a/web/workspace/src/lib/workspace-sidebar/types.ts +++ b/web/workspace/src/lib/workspace-sidebar/types.ts @@ -126,6 +126,7 @@ export type WorkingDirectorySummary = { resolved_tree?: string | null; status: string; cleanliness?: string | null; + primary_worker_id?: number | null; management_kind?: "backend_managed" | "runtime_unmanaged" | string | null; cleanup_target: { kind: string; diff --git a/web/workspace/src/lib/workspace-sidebar/worker-launch.test.ts b/web/workspace/src/lib/workspace-sidebar/worker-launch.test.ts index 3e334d9f..5bb4b7e2 100644 --- a/web/workspace/src/lib/workspace-sidebar/worker-launch.test.ts +++ b/web/workspace/src/lib/workspace-sidebar/worker-launch.test.ts @@ -53,6 +53,8 @@ const options: WorkerLaunchOptionsResponse = { materializer_kind: "local_git_worktree", resolved_commit: "0123456789abcdef", status: "active", + cleanliness: "clean", + primary_worker_id: null, cleanup_target: { kind: "git_worktree", working_directory_id: "wd-1-repo", diff --git a/web/workspace/src/lib/workspace-sidebar/worker-launch.ts b/web/workspace/src/lib/workspace-sidebar/worker-launch.ts index c09075c1..15adb4ca 100644 --- a/web/workspace/src/lib/workspace-sidebar/worker-launch.ts +++ b/web/workspace/src/lib/workspace-sidebar/worker-launch.ts @@ -35,11 +35,16 @@ export function defaultWorkerLaunchForm( const preferredProfile = options?.profiles.find((candidate) => candidate.id === "builtin:coder") ?? options?.profiles[0]; - const preferredWorkingDirectory = - options?.working_directories.find((directory) => - directory.status === "active" - ) ?? - options?.working_directories[0]; + const availableWorkingDirectories = options?.working_directories.filter((directory) => + directory.status === "active" && + directory.cleanliness === "clean" && + directory.primary_worker_id == null + ) ?? []; + const selectedRuntime = current.runtime_id + ? options?.runtimes.find((runtime) => runtime.runtime_id === current.runtime_id) + : preferredRuntime; + const workdirlessRuntime = selectedRuntime?.working_directory_required === false; + const preferredWorkingDirectory = workdirlessRuntime ? undefined : availableWorkingDirectories[0]; const preferredRepository = options?.repositories.find((repository) => repository.id === current.working_directory_repository_id @@ -54,7 +59,7 @@ export function defaultWorkerLaunchForm( ? current.profile : preferredProfile?.id || "", initial_text: current.initial_text, - working_directory_id: options?.working_directories.some( + working_directory_id: !workdirlessRuntime && availableWorkingDirectories.some( (directory) => directory.working_directory_id === current.working_directory_id, ) diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte index 438e4edd..f2fa9a74 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte @@ -43,7 +43,16 @@ let isNewWorkingDirectorySelected = $derived(workingDirectoryId === NEW_WORKING_DIRECTORY_VALUE); let selectedRuntime = $derived(options?.runtimes.find((runtime) => runtime.runtime_id === runtimeId)); let selectedRuntimeAllowsNoWorkdir = $derived(selectedRuntime?.working_directory_required === false); - let hasSelectedExistingWorkdir = $derived(Boolean(workingDirectoryId && !isNewWorkingDirectorySelected)); + let hasSelectedExistingWorkdir = $derived(Boolean( + workingDirectoryId && !isNewWorkingDirectorySelected && !selectedRuntimeAllowsNoWorkdir, + )); + let availableWorkingDirectories = $derived( + selectedRuntimeAllowsNoWorkdir + ? [] + : (options?.working_directories ?? []).filter((directory) => + directory.status === 'active' && directory.cleanliness === 'clean' && directory.primary_worker_id == null + ), + ); let canStartWorker = $derived(Boolean( runtimeId && profile && @@ -65,6 +74,13 @@ return () => controller.abort(); }); + $effect(() => { + if (selectedRuntimeAllowsNoWorkdir && workingDirectoryId) { + workingDirectoryId = ''; + relativeCwd = ''; + } + }); + async function loadLaunchOptions(signal?: AbortSignal) { loading = true; optionsError = null; @@ -109,6 +125,10 @@ submitError = { message: 'select a runtime before creating a workdir', diagnostics: [] }; return; } + if (selectedRuntimeAllowsNoWorkdir) { + submitError = { message: 'embedded Runtime does not create workdirs', diagnostics: [] }; + return; + } if (!workingDirectoryRepositoryId) { submitError = { message: 'select a repository before creating a workdir', diagnostics: [] }; return; @@ -124,7 +144,6 @@ runtime_id: runtimeId, repository_id: workingDirectoryRepositoryId, selector: workingDirectorySelector || null, - policy: { dirty_state: 'clean_point_only', cleanup: 'manual_or_worker_stop' }, }), }, ); @@ -245,16 +264,16 @@ Run at in