workdir: project occupancy from links

This commit is contained in:
Keisuke Hirata 2026-07-24 09:29:25 +09:00
parent b1570aba63
commit e0b092dbc8
No known key found for this signature in database
9 changed files with 202 additions and 21 deletions

View File

@ -105,6 +105,15 @@ pub struct BackendWorkingDirectoryCleanupTarget {
pub repository_id: String, 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)] #[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct BackendWorkingDirectorySummary { pub struct BackendWorkingDirectorySummary {
pub working_directory_id: String, pub working_directory_id: String,
@ -122,7 +131,9 @@ pub struct BackendWorkingDirectorySummary {
#[serde(default)] #[serde(default)]
pub cleanliness: Option<String>, pub cleanliness: Option<String>,
#[serde(default)] #[serde(default)]
pub primary_worker_id: Option<String>, pub primary_worker_id: Option<u64>,
#[serde(default)]
pub occupied_by: Option<BackendWorkingDirectoryOccupancy>,
} }
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Deserialize, PartialEq, Eq)]

View File

@ -138,6 +138,15 @@ pub struct WorkingDirectoryCleanupTarget {
pub repository_id: String, 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)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkingDirectorySummary { pub struct WorkingDirectorySummary {
pub working_directory_id: String, pub working_directory_id: String,
@ -156,6 +165,8 @@ pub struct WorkingDirectorySummary {
pub cleanliness: Option<String>, pub cleanliness: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub primary_worker_id: Option<WorkerId>, pub primary_worker_id: Option<WorkerId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub occupied_by: Option<WorkingDirectoryOccupancy>,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]

View File

@ -48,6 +48,7 @@ impl WorkingDirectory {
status: self.status.clone(), status: self.status.clone(),
cleanliness: None, cleanliness: None,
primary_worker_id: None, primary_worker_id: None,
occupied_by: None,
} }
} }
} }
@ -221,6 +222,7 @@ impl LocalGitWorktreeMaterializer {
status: WorkingDirectoryStatusKind::Corrupted, status: WorkingDirectoryStatusKind::Corrupted,
cleanliness: Some("unknown".to_string()), cleanliness: Some("unknown".to_string()),
primary_worker_id: None, primary_worker_id: None,
occupied_by: None,
}, },
} }
} }

View File

@ -84,14 +84,15 @@ use crate::{Error, Result};
use worker_runtime::catalog::{ use worker_runtime::catalog::{
ConfigBundleRef, MaterializerKind, ProfileSelector, ConfigBundleRef, MaterializerKind, ProfileSelector,
RepositorySelector as RuntimeRepositorySelector, WorkingDirectoryClaim, RepositorySelector as RuntimeRepositorySelector, WorkingDirectoryClaim,
WorkingDirectoryRepository, WorkingDirectoryRequest, WorkingDirectoryStatusKind, WorkingDirectoryOccupancy, WorkingDirectoryRepository, WorkingDirectoryRequest,
WorkingDirectorySummary, WorkingDirectoryStatusKind, WorkingDirectorySummary,
}; };
use worker_runtime::config_bundle::ConfigBundle; use worker_runtime::config_bundle::ConfigBundle;
use worker_runtime::http_server::{ use worker_runtime::http_server::{
RuntimeHttpConfigBundleAvailabilityResponse, RuntimeHttpConfigBundlesResponse, RuntimeHttpConfigBundleAvailabilityResponse, RuntimeHttpConfigBundlesResponse,
RuntimeHttpSummaryResponse, RuntimeHttpWorkerResponse, RuntimeHttpWorkersResponse, RuntimeHttpSummaryResponse, RuntimeHttpWorkerResponse, RuntimeHttpWorkersResponse,
}; };
use worker_runtime::identity::WorkerId;
use worker_runtime::interaction::{ use worker_runtime::interaction::{
WorkerInput as EmbeddedWorkerInput, WorkerInputKind as EmbeddedWorkerInputKind, 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); let record = workdir_record_from_summary(&api, &runtime_id, &working_directory.summary);
api.store.upsert_workdir_registry(&record)?; api.store.upsert_workdir_registry(&record)?;
let mut summary = working_directory.summary;
apply_workdir_occupancy_projection(&api, &mut summary)?;
Ok(Json(BrowserWorkingDirectoryDetailResponse { Ok(Json(BrowserWorkingDirectoryDetailResponse {
workspace_id: api.config.workspace_id.clone(), workspace_id: api.config.workspace_id.clone(),
item: working_directory.summary, item: summary,
diagnostics: result.diagnostics, diagnostics: result.diagnostics,
})) }))
} }
@ -1779,9 +1782,11 @@ fn working_directory_detail_for_runtime(
if let Some(working_directory) = result.working_directory { if let Some(working_directory) = result.working_directory {
let record = workdir_record_from_summary(&api, runtime_id, &working_directory.summary); let record = workdir_record_from_summary(&api, runtime_id, &working_directory.summary);
api.store.upsert_workdir_registry(&record)?; api.store.upsert_workdir_registry(&record)?;
let mut summary = working_directory.summary;
apply_workdir_occupancy_projection(&api, &mut summary)?;
return Ok(Json(BrowserWorkingDirectoryDetailResponse { return Ok(Json(BrowserWorkingDirectoryDetailResponse {
workspace_id: api.config.workspace_id.clone(), workspace_id: api.config.workspace_id.clone(),
item: working_directory.summary, item: summary,
diagnostics: result.diagnostics, diagnostics: result.diagnostics,
})); }));
} }
@ -1791,7 +1796,7 @@ fn working_directory_detail_for_runtime(
{ {
return Ok(Json(BrowserWorkingDirectoryDetailResponse { return Ok(Json(BrowserWorkingDirectoryDetailResponse {
workspace_id: api.config.workspace_id.clone(), workspace_id: api.config.workspace_id.clone(),
item: workdir_summary_from_record(&record), item: projected_workdir_summary_from_record(&api, &record)?,
diagnostics: result.diagnostics, 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); let record = workdir_record_from_summary(&api, runtime_id, &working_directory.summary);
api.store.upsert_workdir_registry(&record)?; api.store.upsert_workdir_registry(&record)?;
let mut summary = working_directory.summary;
apply_workdir_occupancy_projection(&api, &mut summary)?;
Ok(Json(BrowserWorkingDirectoryDetailResponse { Ok(Json(BrowserWorkingDirectoryDetailResponse {
workspace_id: api.config.workspace_id.clone(), workspace_id: api.config.workspace_id.clone(),
item: working_directory.summary, item: summary,
diagnostics: result.diagnostics, diagnostics: result.diagnostics,
})) }))
} }
@ -5298,10 +5305,11 @@ fn working_directory_summaries(api: &WorkspaceApi) -> ApiResult<Vec<WorkingDirec
let records = api let records = api
.store .store
.list_workdir_registry(&api.config.workspace_id, 200)?; .list_workdir_registry(&api.config.workspace_id, 200)?;
Ok(records records
.iter() .iter()
.map(workdir_summary_from_record) .map(|record| projected_workdir_summary_from_record(api, record))
.collect::<Vec<_>>()) .collect::<Result<Vec<_>>>()
.map_err(ApiError::from)
} }
fn available_working_directory_summaries( fn available_working_directory_summaries(
@ -5319,11 +5327,7 @@ fn available_working_directory_summaries(
{ {
continue; continue;
} }
let links = api.store.list_workdir_worker_links( if summary.occupied_by.is_none() && summary.primary_worker_id.is_none() {
&api.config.workspace_id,
summary.working_directory_id.as_str(),
)?;
if links.is_empty() && summary.primary_worker_id.is_none() {
available.push(summary); available.push(summary);
} }
} }
@ -5341,8 +5345,9 @@ fn runtime_working_directory_summaries(
let items = records let items = records
.iter() .iter()
.filter(|record| record.runtime_id == runtime_id) .filter(|record| record.runtime_id == runtime_id)
.map(workdir_summary_from_record) .map(|record| projected_workdir_summary_from_record(api, record))
.collect::<Vec<_>>(); .collect::<Result<Vec<_>>>()
.map_err(ApiError::from)?;
Ok((items, diagnostics)) Ok((items, diagnostics))
} }
@ -5474,7 +5479,18 @@ fn merge_worker_registry_projection(
workdirs workdirs
.iter() .iter()
.find(|workdir| workdir.workdir_id == link.workdir_id) .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 summary
} }
@ -5732,9 +5748,52 @@ fn workdir_summary_from_record(record: &WorkdirRegistryRecord) -> WorkingDirecto
status, status,
cleanliness: Some(record.cleanliness.clone()), cleanliness: Some(record.cleanliness.clone()),
primary_worker_id: None, 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<WorkingDirectorySummary> {
let mut summary = workdir_summary_from_record(record);
apply_workdir_occupancy_projection(api, &mut summary)?;
Ok(summary)
}
fn link_worker_to_workdir( fn link_worker_to_workdir(
api: &WorkspaceApi, api: &WorkspaceApi,
worker_record: &WorkerRegistryRecord, worker_record: &WorkerRegistryRecord,
@ -6370,10 +6429,16 @@ mod tests {
let projected = merge_worker_registry_projection(None, &worker, vec![link], &[workdir]); let projected = merge_worker_registry_projection(None, &worker, vec![link], &[workdir]);
assert_eq!(projected.state, "missing"); assert_eq!(projected.state, "missing");
let working_directory = projected.working_directory.as_ref().unwrap();
assert_eq!( assert_eq!(
projected.working_directory.as_ref().unwrap().status, working_directory.status,
WorkingDirectoryStatusKind::NotFound 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(); let serialized = serde_json::to_string(&projected).unwrap();
assert!(!serialized.contains("/tmp/")); assert!(!serialized.contains("/tmp/"));
assert!(!serialized.contains("materialized_path")); assert!(!serialized.contains("materialized_path"));
@ -6411,6 +6476,33 @@ mod tests {
updated_at: "2".to_string(), updated_at: "2".to_string(),
}) })
.unwrap(); .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) let summaries = working_directory_summaries(&api)
.unwrap_or_else(|err| panic!("working_directory_summaries failed: {}", err.error)); .unwrap_or_else(|err| panic!("working_directory_summaries failed: {}", err.error));
@ -6420,6 +6512,15 @@ mod tests {
.collect::<Vec<_>>(); .collect::<Vec<_>>();
assert!(ids.contains(&"managed")); assert!(ids.contains(&"managed"));
assert!(ids.contains(&"runtime-direct")); 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, _) = let (runtime_projection, _) =
runtime_working_directory_summaries(&api, EMBEDDED_WORKER_RUNTIME_ID).unwrap_or_else( runtime_working_directory_summaries(&api, EMBEDDED_WORKER_RUNTIME_ID).unwrap_or_else(

View File

@ -115,6 +115,14 @@ export type WorkingDirectoryRepositoryOption = {
default_selector?: string | null; 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 = { export type WorkingDirectorySummary = {
working_directory_id: string; working_directory_id: string;
repository_id: string; repository_id: string;
@ -125,6 +133,7 @@ export type WorkingDirectorySummary = {
status: string; status: string;
cleanliness?: string | null; cleanliness?: string | null;
primary_worker_id?: number | null; primary_worker_id?: number | null;
occupied_by?: WorkingDirectoryOccupancy | null;
cleanup_target: { cleanup_target: {
kind: string; kind: string;
working_directory_id: string; working_directory_id: string;

View File

@ -86,6 +86,40 @@ Deno.test("defaultWorkerLaunchForm chooses active runtime, coder profile, reposi
assertEquals(form.working_directory_selector, "HEAD"); 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", () => { Deno.test("buildBrowserCreateWorkerRequest sends working_directory id and relative cwd only", () => {
const request = buildBrowserCreateWorkerRequest({ const request = buildBrowserCreateWorkerRequest({
runtime_id: "embedded", runtime_id: "embedded",

View File

@ -38,7 +38,8 @@ export function defaultWorkerLaunchForm(
const availableWorkingDirectories = options?.working_directories.filter((directory) => const availableWorkingDirectories = options?.working_directories.filter((directory) =>
directory.status === "active" && directory.status === "active" &&
directory.cleanliness === "clean" && directory.cleanliness === "clean" &&
directory.primary_worker_id == null directory.primary_worker_id == null &&
directory.occupied_by == null
) ?? []; ) ?? [];
const selectedRuntime = current.runtime_id const selectedRuntime = current.runtime_id
? options?.runtimes.find((runtime) => runtime.runtime_id === current.runtime_id) ? options?.runtimes.find((runtime) => runtime.runtime_id === current.runtime_id)

View File

@ -129,6 +129,7 @@
<th>Commit</th> <th>Commit</th>
<th>Status</th> <th>Status</th>
<th>Cleanliness</th> <th>Cleanliness</th>
<th>Occupied by</th>
<th>Action</th> <th>Action</th>
</tr> </tr>
</thead> </thead>
@ -142,6 +143,14 @@
<td><code>{commitLabel(workdir)}</code></td> <td><code>{commitLabel(workdir)}</code></td>
<td>{workdir.status}</td> <td>{workdir.status}</td>
<td>{workdir.cleanliness ?? 'unknown'}</td> <td>{workdir.cleanliness ?? 'unknown'}</td>
<td>
{#if workdir.occupied_by}
<span>{workdir.occupied_by.display_name}</span>
<small>{workdir.occupied_by.runtime_id}:{workdir.occupied_by.runtime_worker_id}</small>
{:else}
<span class="muted"></span>
{/if}
</td>
<td> <td>
{#if cleanup} {#if cleanup}
<button <button

View File

@ -50,7 +50,10 @@
selectedRuntimeAllowsNoWorkdir selectedRuntimeAllowsNoWorkdir
? [] ? []
: (options?.working_directories ?? []).filter((directory) => : (options?.working_directories ?? []).filter((directory) =>
directory.status === 'active' && directory.cleanliness === 'clean' && directory.primary_worker_id == null directory.status === 'active' &&
directory.cleanliness === 'clean' &&
directory.primary_worker_id == null &&
directory.occupied_by == null
), ),
); );
let canStartWorker = $derived(Boolean( let canStartWorker = $derived(Boolean(