From e0b092dbc85c7b9fa6f090907a8f83f4e4dd2b65 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 24 Jul 2026 09:29:25 +0900 Subject: [PATCH] workdir: project occupancy from links --- crates/client/src/backend_runtime.rs | 13 +- crates/worker-runtime/src/catalog.rs | 11 ++ .../worker-runtime/src/working_directory.rs | 2 + crates/workspace-server/src/server.rs | 137 +++++++++++++++--- .../src/lib/workspace/sidebar/types.ts | 9 ++ .../workspace/sidebar/worker-launch.test.ts | 34 +++++ .../lib/workspace/sidebar/worker-launch.ts | 3 +- .../[runtimeId]/workdirs/+page.svelte | 9 ++ .../w/[workspaceId]/workers/new/+page.svelte | 5 +- 9 files changed, 202 insertions(+), 21 deletions(-) diff --git a/crates/client/src/backend_runtime.rs b/crates/client/src/backend_runtime.rs index b75faadd..3928e05f 100644 --- a/crates/client/src/backend_runtime.rs +++ b/crates/client/src/backend_runtime.rs @@ -105,6 +105,15 @@ pub struct BackendWorkingDirectoryCleanupTarget { pub repository_id: String, } +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct BackendWorkingDirectoryOccupancy { + pub runtime_id: String, + pub runtime_worker_id: u64, + pub worker_id: String, + pub display_name: String, + pub linked_at: String, +} + #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] pub struct BackendWorkingDirectorySummary { pub working_directory_id: String, @@ -122,7 +131,9 @@ pub struct BackendWorkingDirectorySummary { #[serde(default)] pub cleanliness: Option, #[serde(default)] - pub primary_worker_id: Option, + pub primary_worker_id: Option, + #[serde(default)] + pub occupied_by: Option, } #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] diff --git a/crates/worker-runtime/src/catalog.rs b/crates/worker-runtime/src/catalog.rs index 7d67edc8..c611f587 100644 --- a/crates/worker-runtime/src/catalog.rs +++ b/crates/worker-runtime/src/catalog.rs @@ -138,6 +138,15 @@ pub struct WorkingDirectoryCleanupTarget { pub repository_id: String, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkingDirectoryOccupancy { + pub runtime_id: String, + pub runtime_worker_id: u64, + pub worker_id: String, + pub display_name: String, + pub linked_at: String, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct WorkingDirectorySummary { pub working_directory_id: String, @@ -156,6 +165,8 @@ pub struct WorkingDirectorySummary { pub cleanliness: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub primary_worker_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub occupied_by: Option, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/worker-runtime/src/working_directory.rs b/crates/worker-runtime/src/working_directory.rs index 1579a094..ebbec7fc 100644 --- a/crates/worker-runtime/src/working_directory.rs +++ b/crates/worker-runtime/src/working_directory.rs @@ -48,6 +48,7 @@ impl WorkingDirectory { status: self.status.clone(), cleanliness: None, primary_worker_id: None, + occupied_by: None, } } } @@ -221,6 +222,7 @@ impl LocalGitWorktreeMaterializer { status: WorkingDirectoryStatusKind::Corrupted, cleanliness: Some("unknown".to_string()), primary_worker_id: None, + occupied_by: None, }, } } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 21eacad2..9a1e5732 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -84,14 +84,15 @@ use crate::{Error, Result}; use worker_runtime::catalog::{ ConfigBundleRef, MaterializerKind, ProfileSelector, RepositorySelector as RuntimeRepositorySelector, WorkingDirectoryClaim, - WorkingDirectoryRepository, WorkingDirectoryRequest, WorkingDirectoryStatusKind, - WorkingDirectorySummary, + WorkingDirectoryOccupancy, WorkingDirectoryRepository, WorkingDirectoryRequest, + WorkingDirectoryStatusKind, WorkingDirectorySummary, }; use worker_runtime::config_bundle::ConfigBundle; use worker_runtime::http_server::{ RuntimeHttpConfigBundleAvailabilityResponse, RuntimeHttpConfigBundlesResponse, RuntimeHttpSummaryResponse, RuntimeHttpWorkerResponse, RuntimeHttpWorkersResponse, }; +use worker_runtime::identity::WorkerId; use worker_runtime::interaction::{ WorkerInput as EmbeddedWorkerInput, WorkerInputKind as EmbeddedWorkerInputKind, }; @@ -1760,9 +1761,11 @@ fn create_working_directory_for_runtime( }; let record = workdir_record_from_summary(&api, &runtime_id, &working_directory.summary); api.store.upsert_workdir_registry(&record)?; + let mut summary = working_directory.summary; + apply_workdir_occupancy_projection(&api, &mut summary)?; Ok(Json(BrowserWorkingDirectoryDetailResponse { workspace_id: api.config.workspace_id.clone(), - item: working_directory.summary, + item: summary, diagnostics: result.diagnostics, })) } @@ -1779,9 +1782,11 @@ fn working_directory_detail_for_runtime( if let Some(working_directory) = result.working_directory { let record = workdir_record_from_summary(&api, runtime_id, &working_directory.summary); api.store.upsert_workdir_registry(&record)?; + let mut summary = working_directory.summary; + apply_workdir_occupancy_projection(&api, &mut summary)?; return Ok(Json(BrowserWorkingDirectoryDetailResponse { workspace_id: api.config.workspace_id.clone(), - item: working_directory.summary, + item: summary, diagnostics: result.diagnostics, })); } @@ -1791,7 +1796,7 @@ fn working_directory_detail_for_runtime( { return Ok(Json(BrowserWorkingDirectoryDetailResponse { workspace_id: api.config.workspace_id.clone(), - item: workdir_summary_from_record(&record), + item: projected_workdir_summary_from_record(&api, &record)?, diagnostics: result.diagnostics, })); } @@ -1846,9 +1851,11 @@ fn cleanup_working_directory_for_runtime( }; let record = workdir_record_from_summary(&api, runtime_id, &working_directory.summary); api.store.upsert_workdir_registry(&record)?; + let mut summary = working_directory.summary; + apply_workdir_occupancy_projection(&api, &mut summary)?; Ok(Json(BrowserWorkingDirectoryDetailResponse { workspace_id: api.config.workspace_id.clone(), - item: working_directory.summary, + item: summary, diagnostics: result.diagnostics, })) } @@ -5298,10 +5305,11 @@ fn working_directory_summaries(api: &WorkspaceApi) -> ApiResult>()) + .map(|record| projected_workdir_summary_from_record(api, record)) + .collect::>>() + .map_err(ApiError::from) } fn available_working_directory_summaries( @@ -5319,11 +5327,7 @@ fn available_working_directory_summaries( { 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() { + if summary.occupied_by.is_none() && summary.primary_worker_id.is_none() { available.push(summary); } } @@ -5341,8 +5345,9 @@ fn runtime_working_directory_summaries( let items = records .iter() .filter(|record| record.runtime_id == runtime_id) - .map(workdir_summary_from_record) - .collect::>(); + .map(|record| projected_workdir_summary_from_record(api, record)) + .collect::>>() + .map_err(ApiError::from)?; Ok((items, diagnostics)) } @@ -5474,7 +5479,18 @@ fn merge_worker_registry_projection( workdirs .iter() .find(|workdir| workdir.workdir_id == link.workdir_id) - .map(|workdir| workdir_summary_from_record(workdir)) + .map(|workdir| { + let mut workdir_summary = workdir_summary_from_record(workdir); + workdir_summary.primary_worker_id = Some(WorkerId::new(record.runtime_worker_id)); + workdir_summary.occupied_by = Some(WorkingDirectoryOccupancy { + runtime_id: record.runtime_id.clone(), + runtime_worker_id: record.runtime_worker_id, + worker_id: format!("{}:{}", record.runtime_id, record.runtime_worker_id), + display_name: record.display_name.clone(), + linked_at: link.linked_at.clone(), + }); + workdir_summary + }) }); summary } @@ -5732,9 +5748,52 @@ fn workdir_summary_from_record(record: &WorkdirRegistryRecord) -> WorkingDirecto status, cleanliness: Some(record.cleanliness.clone()), primary_worker_id: None, + occupied_by: None, } } +fn apply_workdir_occupancy_projection( + api: &WorkspaceApi, + summary: &mut WorkingDirectorySummary, +) -> Result<()> { + let links = api + .store + .list_workdir_worker_links(&api.config.workspace_id, &summary.working_directory_id)?; + let Some(link) = links.first() else { + summary.primary_worker_id = None; + summary.occupied_by = None; + return Ok(()); + }; + + let worker = api.store.get_worker_registry( + &api.config.workspace_id, + &link.runtime_id, + link.runtime_worker_id, + )?; + let display_name = worker + .as_ref() + .map(|worker| worker.display_name.clone()) + .unwrap_or_else(|| format!("{}:{}", link.runtime_id, link.runtime_worker_id)); + summary.primary_worker_id = Some(WorkerId::new(link.runtime_worker_id)); + summary.occupied_by = Some(WorkingDirectoryOccupancy { + runtime_id: link.runtime_id.clone(), + runtime_worker_id: link.runtime_worker_id, + worker_id: format!("{}:{}", link.runtime_id, link.runtime_worker_id), + display_name, + linked_at: link.linked_at.clone(), + }); + Ok(()) +} + +fn projected_workdir_summary_from_record( + api: &WorkspaceApi, + record: &WorkdirRegistryRecord, +) -> Result { + let mut summary = workdir_summary_from_record(record); + apply_workdir_occupancy_projection(api, &mut summary)?; + Ok(summary) +} + fn link_worker_to_workdir( api: &WorkspaceApi, worker_record: &WorkerRegistryRecord, @@ -6370,10 +6429,16 @@ mod tests { let projected = merge_worker_registry_projection(None, &worker, vec![link], &[workdir]); assert_eq!(projected.state, "missing"); + let working_directory = projected.working_directory.as_ref().unwrap(); assert_eq!( - projected.working_directory.as_ref().unwrap().status, + working_directory.status, WorkingDirectoryStatusKind::NotFound ); + let occupied_by = working_directory.occupied_by.as_ref().unwrap(); + assert_eq!(occupied_by.runtime_id, "embedded"); + assert_eq!(occupied_by.runtime_worker_id, 1); + assert_eq!(occupied_by.display_name, "Missing Worker"); + assert_eq!(occupied_by.linked_at, "4"); let serialized = serde_json::to_string(&projected).unwrap(); assert!(!serialized.contains("/tmp/")); assert!(!serialized.contains("materialized_path")); @@ -6411,6 +6476,33 @@ mod tests { updated_at: "2".to_string(), }) .unwrap(); + api.store + .upsert_worker_registry(&WorkerRegistryRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), + runtime_worker_id: 7, + display_name: "Worker Seven".to_string(), + profile: Some("builtin:coder".to_string()), + retention_state: "normal".to_string(), + transcript_ref: None, + session_ref: None, + summary_ref: None, + diagnostics_ref: None, + created_at: "1".to_string(), + updated_at: "2".to_string(), + }) + .unwrap(); + api.store + .upsert_worker_workdir_link(&WorkerWorkdirLinkRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), + runtime_worker_id: 7, + workdir_id: "managed".to_string(), + role: "primary_cwd".to_string(), + linked_at: "3".to_string(), + unlinked_at: None, + }) + .unwrap(); let summaries = working_directory_summaries(&api) .unwrap_or_else(|err| panic!("working_directory_summaries failed: {}", err.error)); @@ -6420,6 +6512,15 @@ mod tests { .collect::>(); assert!(ids.contains(&"managed")); assert!(ids.contains(&"runtime-direct")); + let managed = summaries + .iter() + .find(|summary| summary.working_directory_id == "managed") + .unwrap(); + let occupied_by = managed.occupied_by.as_ref().unwrap(); + assert_eq!(occupied_by.runtime_id, EMBEDDED_WORKER_RUNTIME_ID); + assert_eq!(occupied_by.runtime_worker_id, 7); + assert_eq!(occupied_by.display_name, "Worker Seven"); + assert_eq!(occupied_by.linked_at, "3"); let (runtime_projection, _) = runtime_working_directory_summaries(&api, EMBEDDED_WORKER_RUNTIME_ID).unwrap_or_else( diff --git a/web/workspace/src/lib/workspace/sidebar/types.ts b/web/workspace/src/lib/workspace/sidebar/types.ts index dc48b11d..d038825d 100644 --- a/web/workspace/src/lib/workspace/sidebar/types.ts +++ b/web/workspace/src/lib/workspace/sidebar/types.ts @@ -115,6 +115,14 @@ export type WorkingDirectoryRepositoryOption = { default_selector?: string | null; }; +export type WorkingDirectoryOccupancy = { + runtime_id: string; + runtime_worker_id: number; + worker_id: string; + display_name: string; + linked_at: string; +}; + export type WorkingDirectorySummary = { working_directory_id: string; repository_id: string; @@ -125,6 +133,7 @@ export type WorkingDirectorySummary = { status: string; cleanliness?: string | null; primary_worker_id?: number | null; + occupied_by?: WorkingDirectoryOccupancy | null; cleanup_target: { kind: string; working_directory_id: 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 5bb4b7e2..93750e96 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts @@ -86,6 +86,40 @@ Deno.test("defaultWorkerLaunchForm chooses active runtime, coder profile, reposi assertEquals(form.working_directory_selector, "HEAD"); }); +Deno.test("defaultWorkerLaunchForm skips occupied working directories", () => { + const form = defaultWorkerLaunchForm( + { + ...options, + working_directories: [ + { + ...options.working_directories[0], + occupied_by: { + runtime_id: "embedded", + runtime_worker_id: 12, + worker_id: "embedded:12", + display_name: "Worker 12", + linked_at: "2026-07-24T00:00:00Z", + }, + }, + ], + }, + { + runtime_id: "", + display_name: "", + profile: "", + initial_text: "hello", + working_directory_id: "", + working_directory_repository_id: "", + working_directory_selector: "", + relative_cwd: "", + }, + ); + + assertEquals(form.working_directory_id, ""); + assertEquals(form.working_directory_repository_id, "repo"); + assertEquals(form.working_directory_selector, "HEAD"); +}); + Deno.test("buildBrowserCreateWorkerRequest sends working_directory id and relative cwd only", () => { const request = buildBrowserCreateWorkerRequest({ runtime_id: "embedded", diff --git a/web/workspace/src/lib/workspace/sidebar/worker-launch.ts b/web/workspace/src/lib/workspace/sidebar/worker-launch.ts index 15adb4ca..6742dc43 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-launch.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-launch.ts @@ -38,7 +38,8 @@ export function defaultWorkerLaunchForm( const availableWorkingDirectories = options?.working_directories.filter((directory) => directory.status === "active" && directory.cleanliness === "clean" && - directory.primary_worker_id == null + directory.primary_worker_id == null && + directory.occupied_by == null ) ?? []; const selectedRuntime = current.runtime_id ? options?.runtimes.find((runtime) => runtime.runtime_id === current.runtime_id) diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.svelte index 884de2af..e1830297 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.svelte @@ -129,6 +129,7 @@ Commit Status Cleanliness + Occupied by Action @@ -142,6 +143,14 @@ {commitLabel(workdir)} {workdir.status} {workdir.cleanliness ?? 'unknown'} + + {#if workdir.occupied_by} + {workdir.occupied_by.display_name} + {workdir.occupied_by.runtime_id}:{workdir.occupied_by.runtime_worker_id} + {:else} + + {/if} + {#if cleanup}