diff --git a/crates/worker-runtime/src/catalog.rs b/crates/worker-runtime/src/catalog.rs index 178bdccd..87331edf 100644 --- a/crates/worker-runtime/src/catalog.rs +++ b/crates/worker-runtime/src/catalog.rs @@ -125,7 +125,6 @@ pub struct WorkingDirectoryClaim { #[serde(rename_all = "snake_case")] pub enum WorkingDirectoryStatusKind { Active, - Removed, CleanupPending, Corrupted, NotFound, diff --git a/crates/worker-runtime/src/working_directory.rs b/crates/worker-runtime/src/working_directory.rs index fc6c77e9..51ba421b 100644 --- a/crates/worker-runtime/src/working_directory.rs +++ b/crates/worker-runtime/src/working_directory.rs @@ -524,12 +524,23 @@ impl WorkingDirectoryMaterializer for LocalGitWorktreeMaterializer { })?; } let mut summary = status.summary; - summary.status = WorkingDirectoryStatusKind::Removed; + summary.status = WorkingDirectoryStatusKind::NotFound; return Ok(WorkingDirectoryStatus { summary }); } let binding = self.read_binding(working_directory_id)?; self.cleanup(&binding)?; - self.working_directory_status(working_directory_id) + let mut summary = binding.working_directory.status_summary(); + summary.status = WorkingDirectoryStatusKind::NotFound; + summary.cleanliness = Some("unknown".to_string()); + if binding.working_directory_root.exists() { + fs::remove_dir_all(&binding.working_directory_root).map_err(|_| { + WorkingDirectoryDiagnostic::new( + "working_directory_record_cleanup_failed", + "failed to remove working directory record; backend-private path details were omitted", + ) + })?; + } + Ok(WorkingDirectoryStatus { summary }) } fn cleanup(&self, binding: &WorkingDirectoryBinding) -> Result<(), WorkingDirectoryDiagnostic> { @@ -569,19 +580,17 @@ impl WorkingDirectoryMaterializer for LocalGitWorktreeMaterializer { Ok(()) } }); - working_directory.status = if remove_result.is_ok() { - WorkingDirectoryStatusKind::Removed - } else { - WorkingDirectoryStatusKind::CleanupPending - }; - let updated = WorkingDirectoryBinding { - working_directory, - root: binding.root.clone(), - cwd: binding.cwd.clone(), - working_directory_root: binding.working_directory_root.clone(), - source_repository_path: binding.source_repository_path.clone(), - }; - let _ = self.write_record(&updated); + if remove_result.is_err() { + working_directory.status = WorkingDirectoryStatusKind::CleanupPending; + let updated = WorkingDirectoryBinding { + working_directory, + root: binding.root.clone(), + cwd: binding.cwd.clone(), + working_directory_root: binding.working_directory_root.clone(), + source_repository_path: binding.source_repository_path.clone(), + }; + let _ = self.write_record(&updated); + } remove_result } } @@ -954,7 +963,7 @@ mod tests { } #[test] - fn cleanup_removes_worktree_and_updates_record() { + fn cleanup_working_directory_removes_worktree_and_record() { let repo = create_clean_repo(); let runtime_root = tempfile::tempdir().unwrap(); let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path()); @@ -962,16 +971,14 @@ mod tests { .materialize(&worker_ref(1), &request(repo.path())) .unwrap(); let root = binding.root.clone(); + let record_root = binding.working_directory_root().to_path_buf(); - materializer.cleanup(&binding).unwrap(); + let status = materializer + .cleanup_working_directory(&binding.working_directory.id) + .unwrap(); + assert_eq!(status.summary.status, WorkingDirectoryStatusKind::NotFound); assert!(!root.exists()); - let raw = fs::read_to_string( - binding - .working_directory_root() - .join(MATERIALIZATION_RECORD), - ) - .unwrap(); - assert!(raw.contains("removed")); + assert!(!record_root.exists()); } } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 81ab6380..f76c66db 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -1686,9 +1686,9 @@ fn build_runtime_cleanup_plan( .map(|summary| format!("{:?}", summary.status).to_lowercase()); let file_status = observed_status.unwrap_or_else(|| record.materialization_status.clone()); let cleanliness = record.cleanliness.clone(); - let action = if matches!(file_status.as_str(), "removed" | "missing" | "not_found") { + let action = if matches!(file_status.as_str(), "missing" | "not_found") { CleanupTargetKind::WorkdirRecordDelete - } else if cleanliness == "clean" { + } else if file_status == "corrupted" || cleanliness == "clean" { CleanupTargetKind::WorkdirCleanCleanup } else { CleanupTargetKind::WorkdirDirtyDiscard @@ -1708,8 +1708,11 @@ fn build_runtime_cleanup_plan( repository_id: record.repository_id.clone(), 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(), "removed" | "missing" | "not_found") { - "Removed or not-found Workdir record can be deleted from the Backend registry" + } else if matches!(file_status.as_str(), "missing" | "not_found") { + "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() } else if cleanliness == "dirty" { "Dirty Workdir requires explicit discard confirmation before cleanup".to_string() @@ -1845,34 +1848,61 @@ 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())?; + if !deleted { + return Err(cleanup_api_error( + runtime_id, + "workspace_cleanup_workdir_registry_not_found", + "Backend Workdir registry row was not found after Runtime cleanup", + )); + } results.push(RuntimeCleanupExecutionResult { target_id: candidate.target_id.clone(), action: candidate.action.clone(), - status: "discarded".to_string(), + status: "deleted".to_string(), message: - "Dirty/unknown Workdir cleanup/discard was executed after explicit confirmation" + "Dirty/unknown Workdir was deleted from Runtime storage and Backend registry after explicit confirmation" .to_string(), }); } CleanupTargetKind::WorkdirCleanCleanup => { cleanup_runtime_workdir_for_execution(api, runtime_id, candidate)?; - results.push(RuntimeCleanupExecutionResult { - target_id: candidate.target_id.clone(), - action: candidate.action.clone(), - status: "cleaned".to_string(), - message: "Clean Workdir cleanup was executed".to_string(), - }); - } - CleanupTargetKind::WorkdirRecordDelete => { - 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, + "workspace_cleanup_workdir_registry_not_found", + "Backend Workdir registry row was not found after Runtime cleanup", + )); + } results.push(RuntimeCleanupExecutionResult { target_id: candidate.target_id.clone(), action: candidate.action.clone(), status: "deleted".to_string(), - message: "Removed/missing Workdir registry row deleted".to_string(), + message: "Workdir deleted from Runtime storage and Backend registry".to_string(), + }); + } + CleanupTargetKind::WorkdirRecordDelete => { + 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, + "workspace_cleanup_workdir_registry_not_found", + "Backend Workdir registry row was not found", + )); + } + results.push(RuntimeCleanupExecutionResult { + target_id: candidate.target_id.clone(), + action: candidate.action.clone(), + status: "deleted".to_string(), + message: "Not-found Workdir registry row deleted".to_string(), }); } CleanupTargetKind::WorkerDelete => { @@ -1885,6 +1915,15 @@ fn execute_runtime_cleanup( } } + let requested_target_count = worker_targets.len() + workdir_targets.len(); + if requested_target_count > 0 && results.len() != requested_target_count { + return Err(cleanup_api_error( + runtime_id, + "workspace_cleanup_target_not_executed", + "one or more selected cleanup targets were not present in the current cleanup plan", + )); + } + let plan_after = build_runtime_cleanup_plan(api, runtime_id)?; let executed_at = now_registry_timestamp(); Ok(RuntimeCleanupExecutionResponse { @@ -1929,7 +1968,7 @@ fn cleanup_runtime_workdir_for_execution( .runtime .cleanup_working_directory(runtime_id, candidate.workdir_id.as_str()) .map_err(|err| err.into_error())?; - let Some(working_directory) = result.working_directory else { + if result.working_directory.is_none() { return Err(ApiError::with_diagnostics( Error::RuntimeOperationFailed { runtime_id: runtime_id.to_string(), @@ -1939,13 +1978,6 @@ fn cleanup_runtime_workdir_for_execution( result.diagnostics, )); }; - let record = workdir_record_from_summary( - api, - runtime_id, - &working_directory.summary, - "backend_managed", - ); - api.store.upsert_workdir_registry(&record)?; Ok(()) } @@ -4158,6 +4190,13 @@ fn sync_runtime_workdir_observations( let mut observed = std::collections::BTreeSet::new(); for status in &response.items { observed.insert(status.summary.working_directory_id.clone()); + if status.summary.status == WorkingDirectoryStatusKind::NotFound { + api.store.delete_workdir_registry( + &api.config.workspace_id, + &status.summary.working_directory_id, + )?; + continue; + } let existing = api.store.get_workdir_registry( &api.config.workspace_id, &status.summary.working_directory_id, @@ -4183,15 +4222,22 @@ fn sync_runtime_workdir_observations( { Ok(result) => { if let Some(status) = result.working_directory { - let management_kind = record.management_kind.clone(); - let mut updated = workdir_record_from_summary( - api, - runtime_id, - &status.summary, - management_kind.as_str(), - ); - preserve_workdir_identity_for_corrupted_summary(&mut updated, Some(&record)); - api.store.upsert_workdir_registry(&updated)?; + if status.summary.status == WorkingDirectoryStatusKind::NotFound { + api.store.delete_workdir_registry( + &api.config.workspace_id, + record.workdir_id.as_str(), + )?; + } else { + let management_kind = record.management_kind.clone(); + let mut updated = workdir_record_from_summary( + api, + runtime_id, + &status.summary, + management_kind.as_str(), + ); + preserve_workdir_identity_for_corrupted_summary(&mut updated, Some(&record)); + api.store.upsert_workdir_registry(&updated)?; + } } else { record.materialization_status = workdir_status_from_runtime_miss(result.diagnostics.as_slice()).to_string(); @@ -4294,7 +4340,6 @@ fn workdir_record_from_summary( resolved_commit: summary.resolved_commit.clone(), materialization_status: match summary.status { WorkingDirectoryStatusKind::Active => "present", - WorkingDirectoryStatusKind::Removed => "removed", WorkingDirectoryStatusKind::CleanupPending => "pending", WorkingDirectoryStatusKind::Corrupted => "corrupted", WorkingDirectoryStatusKind::NotFound => "not_found", @@ -4339,7 +4384,7 @@ fn workdir_summary_from_record(record: &WorkdirRegistryRecord) -> WorkingDirecto "corrupted" => WorkingDirectoryStatusKind::Corrupted, "not_found" | "missing" => WorkingDirectoryStatusKind::NotFound, "unknown" => WorkingDirectoryStatusKind::Unknown, - _ => WorkingDirectoryStatusKind::Removed, + _ => WorkingDirectoryStatusKind::Unknown, }; WorkingDirectorySummary { working_directory_id: record.workdir_id.clone(), @@ -5652,7 +5697,7 @@ mod tests { response.results[0].action, CleanupTargetKind::WorkdirCleanCleanup ); - assert_eq!(response.results[0].status, "cleaned"); + assert_eq!(response.results[0].status, "deleted"); } #[tokio::test] @@ -5699,16 +5744,16 @@ mod tests { } #[tokio::test] - async fn cleanup_plan_reports_pinned_running_dirty_removed_and_redacts_paths() { + async fn cleanup_plan_reports_pinned_running_dirty_not_found_and_redacts_paths() { let workspace = tempfile::tempdir().unwrap(); init_clean_git_workspace(workspace.path()); let api = test_api(workspace.path()).await; let pinned = seed_cleanup_worker(&api, 1, "pinned"); let unobserved = seed_cleanup_worker(&api, 2, "normal"); seed_cleanup_workdir(&api, "workdir-dirty", "present", "dirty"); - seed_cleanup_workdir(&api, "workdir-removed", "removed", "clean"); + seed_cleanup_workdir(&api, "workdir-not-found", "not_found", "clean"); seed_cleanup_link(&api, pinned.as_str(), "workdir-dirty"); - seed_cleanup_link(&api, unobserved.as_str(), "workdir-removed"); + seed_cleanup_link(&api, unobserved.as_str(), "workdir-not-found"); let plan = build_runtime_cleanup_plan(&api, "runtime-test") .unwrap_or_else(|err| panic!("cleanup plan: {}", err.error)); @@ -5725,7 +5770,7 @@ mod tests { let running_linked_workdir = plan .workdirs .iter() - .find(|candidate| candidate.workdir_id == "workdir-removed") + .find(|candidate| candidate.workdir_id == "workdir-not-found") .unwrap(); assert_eq!( running_linked_workdir.action, @@ -5783,7 +5828,7 @@ mod tests { init_clean_git_workspace(workspace.path()); let api = test_api(workspace.path()).await; seed_cleanup_workdir(&api, "workdir-dirty", "present", "dirty"); - seed_cleanup_workdir(&api, "workdir-removed", "removed", "clean"); + seed_cleanup_workdir(&api, "workdir-not-found", "not_found", "clean"); let plan = build_runtime_cleanup_plan(&api, "runtime-test") .unwrap_or_else(|err| panic!("cleanup plan: {}", err.error)); let dirty_target = plan @@ -5796,7 +5841,7 @@ mod tests { let removed_target = plan .workdirs .iter() - .find(|candidate| candidate.workdir_id == "workdir-removed") + .find(|candidate| candidate.workdir_id == "workdir-not-found") .unwrap() .target_id .clone(); @@ -5820,7 +5865,7 @@ mod tests { assert_eq!(response.results[0].status, "deleted"); assert!( api.store - .get_workdir_registry(&api.config.workspace_id, "workdir-removed") + .get_workdir_registry(&api.config.workspace_id, "workdir-not-found") .unwrap() .is_none() ); @@ -6103,7 +6148,7 @@ mod tests { StatusCode::OK, ) .await; - assert_eq!(removed["results"][0]["status"], "cleaned"); + assert_eq!(removed["results"][0]["status"], "deleted"); } #[tokio::test] diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 9a16190a..1e6cdc54 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -627,7 +627,7 @@ CREATE TABLE IF NOT EXISTS workdir_registry ( repository_id TEXT NOT NULL, selector TEXT, resolved_commit TEXT, - materialization_status TEXT NOT NULL CHECK (materialization_status IN ('pending', 'present', 'not_found', 'corrupted', 'unknown', 'removed', 'failed')), + materialization_status TEXT NOT NULL CHECK (materialization_status IN ('pending', 'present', 'not_found', 'corrupted', 'unknown', 'failed')), cleanliness TEXT NOT NULL CHECK (cleanliness IN ('clean', 'dirty', 'unknown')), management_kind TEXT NOT NULL CHECK (management_kind IN ('backend_managed', 'runtime_unmanaged')), created_at TEXT NOT NULL, @@ -946,7 +946,7 @@ fn add_workdir_runtime_observation_states(conn: &Connection) -> Result<()> { repository_id TEXT NOT NULL, selector TEXT, resolved_commit TEXT, - materialization_status TEXT NOT NULL CHECK (materialization_status IN ('pending', 'present', 'not_found', 'corrupted', 'unknown', 'removed', 'failed')), + materialization_status TEXT NOT NULL CHECK (materialization_status IN ('pending', 'present', 'not_found', 'corrupted', 'unknown', 'failed')), cleanliness TEXT NOT NULL CHECK (cleanliness IN ('clean', 'dirty', 'unknown')), management_kind TEXT NOT NULL CHECK (management_kind IN ('backend_managed', 'runtime_unmanaged')), created_at TEXT NOT NULL, @@ -962,6 +962,7 @@ fn add_workdir_runtime_observation_states(conn: &Connection) -> Result<()> { workspace_id, workdir_id, runtime_id, repository_id, selector, resolved_commit, CASE materialization_status WHEN 'missing' THEN 'not_found' + WHEN 'removed' THEN 'not_found' ELSE materialization_status END, cleanliness, management_kind, created_at, updated_at @@ -1547,7 +1548,7 @@ mod tests { repository_id: "repo".to_string(), selector: Some("develop".to_string()), resolved_commit: Some("abcdef".to_string()), - materialization_status: "removed".to_string(), + materialization_status: "not_found".to_string(), cleanliness: "clean".to_string(), management_kind: "backend_managed".to_string(), created_at: "2".to_string(), diff --git a/web/workspace/src/lib/workspace-alerts/WorkspaceAlerts.svelte b/web/workspace/src/lib/workspace-alerts/WorkspaceAlerts.svelte new file mode 100644 index 00000000..54bc0dfe --- /dev/null +++ b/web/workspace/src/lib/workspace-alerts/WorkspaceAlerts.svelte @@ -0,0 +1,130 @@ + + +
+ {#each $workspaceAlerts as alert (alert.id)} +
+ +
+ {alert.title ?? label(alert.level)} +

{alert.message}

+
+ +
+ {/each} +
+ + diff --git a/web/workspace/src/lib/workspace-alerts/store.ts b/web/workspace/src/lib/workspace-alerts/store.ts new file mode 100644 index 00000000..5e7916f8 --- /dev/null +++ b/web/workspace/src/lib/workspace-alerts/store.ts @@ -0,0 +1,61 @@ +export type WorkspaceAlertLevel = "success" | "info" | "warning" | "error" | "system" | "debug"; + +export type WorkspaceAlert = { + id: string; + level: WorkspaceAlertLevel; + title?: string; + message: string; + createdAt: number; +}; + +type AlertSubscriber = (alerts: WorkspaceAlert[]) => void; + +let sequence = 0; +let alerts: WorkspaceAlert[] = []; +const subscribers = new Set(); + +function emit(): void { + const snapshot = alerts.slice(); + for (const subscriber of subscribers) subscriber(snapshot); +} + +export const workspaceAlerts = { + subscribe(subscriber: AlertSubscriber): () => void { + subscribers.add(subscriber); + subscriber(alerts.slice()); + return () => subscribers.delete(subscriber); + }, +}; + +export function pushWorkspaceAlert( + level: WorkspaceAlertLevel, + message: string, + options: { title?: string; id?: string } = {}, +): string { + const id = options.id ?? `${Date.now().toString(36)}-${(sequence++).toString(36)}`; + alerts = [ + ...alerts.filter((alert) => alert.id !== id), + { + id, + level, + title: options.title, + message, + createdAt: Date.now(), + }, + ].slice(-8); + emit(); + return id; +} + +export function dismissWorkspaceAlert(id: string): void { + const next = alerts.filter((alert) => alert.id !== id); + if (next.length === alerts.length) return; + alerts = next; + emit(); +} + +export function clearWorkspaceAlerts(): void { + if (alerts.length === 0) return; + alerts = []; + emit(); +} diff --git a/web/workspace/src/routes/+layout.svelte b/web/workspace/src/routes/+layout.svelte index 2950d0a0..98789a08 100644 --- a/web/workspace/src/routes/+layout.svelte +++ b/web/workspace/src/routes/+layout.svelte @@ -1,5 +1,6 @@ + +
+ import { pushWorkspaceAlert } from '$lib/workspace-alerts/store'; import { workspaceApiPath } from '$lib/workspace-api/http'; import type { CleanupWorkdirCandidate, @@ -9,9 +10,9 @@ import type { PageProps } from './$types'; let { data }: PageProps = $props(); - let cleanupStatus = $state(null); let cleanupBusyTarget = $state(null); let cleanupPlan = $state(null); + let workdirs = $state([]); let runtimeLabel = $derived( data.runtimes?.items.find((runtime) => runtime.runtime_id === data.runtimeId)?.label ?? data.runtimeId, ); @@ -19,6 +20,7 @@ $effect(() => { cleanupPlan = data.cleanupPlan ?? null; + workdirs = data.workdirs?.items ?? []; }); function commitLabel(workdir: WorkingDirectorySummary): string { @@ -29,26 +31,35 @@ return workdir.requested_selector ?? 'HEAD'; } - function cleanupLabel(candidate: CleanupWorkdirCandidate): string { - if (candidate.action === 'workdir_dirty_discard') { - return candidate.cleanliness === 'dirty' ? 'Discard' : 'Discard unknown'; - } - if (candidate.action === 'workdir_record_delete') return 'Delete record'; - return 'Clean up'; - } - function cleanupCandidate(workdir: WorkingDirectorySummary): CleanupWorkdirCandidate | undefined { return cleanupCandidates.find((candidate) => candidate.workdir_id === workdir.working_directory_id); } - async function executeWorkdirCleanup(candidate: CleanupWorkdirCandidate): Promise { - if (!cleanupPlan) return; - if (candidate.action === 'workdir_dirty_discard') { - const confirmed = window.confirm(`${cleanupLabel(candidate)} ${candidate.workdir_id}? This explicitly discards the Workdir contents.`); - if (!confirmed) return; + function isDeleteDisabled(candidate: CleanupWorkdirCandidate): boolean { + return Boolean(candidate.blocking_reason) || candidate.action === 'workdir_dirty_discard' || cleanupBusyTarget !== null; + } + + function errorMessage(payload: unknown, fallback: string): string { + if (payload && typeof payload === 'object') { + if ('message' in payload && typeof payload.message === 'string') return payload.message; + if ('error' in payload) { + const error = payload.error; + if (typeof error === 'string') return error; + if (error && typeof error === 'object' && 'message' in error && typeof error.message === 'string') return error.message; + } + if ('diagnostics' in payload && Array.isArray(payload.diagnostics)) { + const diagnostic = payload.diagnostics.find( + (entry): entry is { message: string } => Boolean(entry) && typeof entry === 'object' && 'message' in entry && typeof entry.message === 'string', + ); + if (diagnostic) return diagnostic.message; + } } + return fallback; + } + + async function deleteWorkdir(workdir: WorkingDirectorySummary, candidate: CleanupWorkdirCandidate): Promise { + if (!cleanupPlan || isDeleteDisabled(candidate)) return; cleanupBusyTarget = candidate.target_id; - cleanupStatus = null; try { const response = await fetch( workspaceApiPath(data.workspaceId, `/runtimes/${encodeURIComponent(data.runtimeId)}/cleanup-executions`), @@ -60,16 +71,26 @@ expected_plan_digest: cleanupPlan.digest, worker_target_ids: [], workdir_target_ids: [candidate.target_id], - confirm_dirty_discard_target_ids: candidate.action === 'workdir_dirty_discard' ? [candidate.target_id] : [], + confirm_dirty_discard_target_ids: [], }), }, ); - const payload = (await response.json().catch(() => null)) as RuntimeCleanupExecutionResponse | { message?: string; error?: string } | null; - if (!response.ok) throw new Error(payload && 'message' in payload ? (payload.message ?? payload.error) : response.statusText); - if (payload && 'plan_after' in payload) cleanupPlan = payload.plan_after; - cleanupStatus = `Executed cleanup for ${candidate.workdir_id}. Refresh to see the latest Workdir list.`; + const payload = (await response.json().catch(() => null)) as RuntimeCleanupExecutionResponse | unknown; + if (!response.ok) throw new Error(errorMessage(payload, response.statusText)); + if (payload && typeof payload === 'object' && 'plan_after' in payload) { + cleanupPlan = (payload as RuntimeCleanupExecutionResponse).plan_after; + } + const result = payload && typeof payload === 'object' && 'results' in payload + ? (payload as RuntimeCleanupExecutionResponse).results.find((entry) => entry.target_id === candidate.target_id) + : undefined; + if (!result || result.status !== 'deleted') { + throw new Error(result?.message ?? 'Runtime did not delete the selected Workdir'); + } + workdirs = workdirs.filter((item) => item.working_directory_id !== workdir.working_directory_id); } catch (error) { - cleanupStatus = error instanceof Error ? error.message : 'Workdir cleanup failed'; + pushWorkspaceAlert('error', error instanceof Error ? error.message : 'Workdir deletion failed', { + title: 'Workdir deletion failed', + }); } finally { cleanupBusyTarget = null; } @@ -88,7 +109,6 @@

Workdirs

Workdirs owned by {data.runtimeId}.

{#if data.cleanupPlanError}

{data.cleanupPlanError}

{/if} - {#if cleanupStatus}

{cleanupStatus}

{/if}
@@ -96,7 +116,7 @@

{data.workdirsError}

{:else if !data.workdirs}

Loading workdirs…

- {:else if data.workdirs.items.length === 0} + {:else if workdirs.length === 0}

No workdirs are visible for this Runtime.

{:else}
@@ -113,7 +133,7 @@ - {#each data.workdirs.items as workdir} + {#each workdirs as workdir} {@const cleanup = cleanupCandidate(workdir)} {workdir.working_directory_id} @@ -125,14 +145,19 @@ {#if cleanup} - {#if cleanup.blocking_reason}{cleanup.blocking_reason}{/if} {:else} {/if} @@ -144,3 +169,55 @@
{/if} + + diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/+page.svelte index 7f6b6984..6248e334 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/workers/+page.svelte @@ -1,4 +1,5 @@