workspace: finalize workdir deletion flow

This commit is contained in:
Keisuke Hirata 2026-07-12 16:33:14 +09:00
parent 52cccc7f2f
commit 21802d68f8
No known key found for this signature in database
9 changed files with 471 additions and 122 deletions

View File

@ -125,7 +125,6 @@ pub struct WorkingDirectoryClaim {
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum WorkingDirectoryStatusKind { pub enum WorkingDirectoryStatusKind {
Active, Active,
Removed,
CleanupPending, CleanupPending,
Corrupted, Corrupted,
NotFound, NotFound,

View File

@ -524,12 +524,23 @@ impl WorkingDirectoryMaterializer for LocalGitWorktreeMaterializer {
})?; })?;
} }
let mut summary = status.summary; let mut summary = status.summary;
summary.status = WorkingDirectoryStatusKind::Removed; summary.status = WorkingDirectoryStatusKind::NotFound;
return Ok(WorkingDirectoryStatus { summary }); return Ok(WorkingDirectoryStatus { summary });
} }
let binding = self.read_binding(working_directory_id)?; let binding = self.read_binding(working_directory_id)?;
self.cleanup(&binding)?; 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> { fn cleanup(&self, binding: &WorkingDirectoryBinding) -> Result<(), WorkingDirectoryDiagnostic> {
@ -569,11 +580,8 @@ impl WorkingDirectoryMaterializer for LocalGitWorktreeMaterializer {
Ok(()) Ok(())
} }
}); });
working_directory.status = if remove_result.is_ok() { if remove_result.is_err() {
WorkingDirectoryStatusKind::Removed working_directory.status = WorkingDirectoryStatusKind::CleanupPending;
} else {
WorkingDirectoryStatusKind::CleanupPending
};
let updated = WorkingDirectoryBinding { let updated = WorkingDirectoryBinding {
working_directory, working_directory,
root: binding.root.clone(), root: binding.root.clone(),
@ -582,6 +590,7 @@ impl WorkingDirectoryMaterializer for LocalGitWorktreeMaterializer {
source_repository_path: binding.source_repository_path.clone(), source_repository_path: binding.source_repository_path.clone(),
}; };
let _ = self.write_record(&updated); let _ = self.write_record(&updated);
}
remove_result remove_result
} }
} }
@ -954,7 +963,7 @@ mod tests {
} }
#[test] #[test]
fn cleanup_removes_worktree_and_updates_record() { fn cleanup_working_directory_removes_worktree_and_record() {
let repo = create_clean_repo(); let repo = create_clean_repo();
let runtime_root = tempfile::tempdir().unwrap(); let runtime_root = tempfile::tempdir().unwrap();
let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path()); let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path());
@ -962,16 +971,14 @@ mod tests {
.materialize(&worker_ref(1), &request(repo.path())) .materialize(&worker_ref(1), &request(repo.path()))
.unwrap(); .unwrap();
let root = binding.root.clone(); 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)
assert!(!root.exists());
let raw = fs::read_to_string(
binding
.working_directory_root()
.join(MATERIALIZATION_RECORD),
)
.unwrap(); .unwrap();
assert!(raw.contains("removed"));
assert_eq!(status.summary.status, WorkingDirectoryStatusKind::NotFound);
assert!(!root.exists());
assert!(!record_root.exists());
} }
} }

View File

@ -1686,9 +1686,9 @@ fn build_runtime_cleanup_plan(
.map(|summary| format!("{:?}", summary.status).to_lowercase()); .map(|summary| format!("{:?}", summary.status).to_lowercase());
let file_status = observed_status.unwrap_or_else(|| record.materialization_status.clone()); let file_status = observed_status.unwrap_or_else(|| record.materialization_status.clone());
let cleanliness = record.cleanliness.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 CleanupTargetKind::WorkdirRecordDelete
} else if cleanliness == "clean" { } else if file_status == "corrupted" || cleanliness == "clean" {
CleanupTargetKind::WorkdirCleanCleanup CleanupTargetKind::WorkdirCleanCleanup
} else { } else {
CleanupTargetKind::WorkdirDirtyDiscard CleanupTargetKind::WorkdirDirtyDiscard
@ -1708,8 +1708,11 @@ fn build_runtime_cleanup_plan(
repository_id: record.repository_id.clone(), repository_id: record.repository_id.clone(),
reason: if blocking_reason.is_some() { reason: if blocking_reason.is_some() {
"Workdir cleanup is blocked until linked Worker state is safe".to_string() "Workdir cleanup is blocked until linked Worker state is safe".to_string()
} else if matches!(file_status.as_str(), "removed" | "missing" | "not_found") { } else if matches!(file_status.as_str(), "missing" | "not_found") {
"Removed or not-found Workdir record can be deleted from the Backend registry" "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() .to_string()
} else if cleanliness == "dirty" { } else if cleanliness == "dirty" {
"Dirty Workdir requires explicit discard confirmation before cleanup".to_string() "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)?; 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 { results.push(RuntimeCleanupExecutionResult {
target_id: candidate.target_id.clone(), target_id: candidate.target_id.clone(),
action: candidate.action.clone(), action: candidate.action.clone(),
status: "discarded".to_string(), status: "deleted".to_string(),
message: 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(), .to_string(),
}); });
} }
CleanupTargetKind::WorkdirCleanCleanup => { CleanupTargetKind::WorkdirCleanCleanup => {
cleanup_runtime_workdir_for_execution(api, runtime_id, candidate)?; cleanup_runtime_workdir_for_execution(api, runtime_id, candidate)?;
results.push(RuntimeCleanupExecutionResult { let deleted = api
target_id: candidate.target_id.clone(), .store
action: candidate.action.clone(), .delete_workdir_registry(&api.config.workspace_id, candidate.workdir_id.as_str())?;
status: "cleaned".to_string(), if !deleted {
message: "Clean Workdir cleanup was executed".to_string(), return Err(cleanup_api_error(
}); runtime_id,
"workspace_cleanup_workdir_registry_not_found",
"Backend Workdir registry row was not found after Runtime cleanup",
));
} }
CleanupTargetKind::WorkdirRecordDelete => {
api.store.delete_workdir_registry(
&api.config.workspace_id,
candidate.workdir_id.as_str(),
)?;
results.push(RuntimeCleanupExecutionResult { results.push(RuntimeCleanupExecutionResult {
target_id: candidate.target_id.clone(), target_id: candidate.target_id.clone(),
action: candidate.action.clone(), action: candidate.action.clone(),
status: "deleted".to_string(), 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 => { 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 plan_after = build_runtime_cleanup_plan(api, runtime_id)?;
let executed_at = now_registry_timestamp(); let executed_at = now_registry_timestamp();
Ok(RuntimeCleanupExecutionResponse { Ok(RuntimeCleanupExecutionResponse {
@ -1929,7 +1968,7 @@ fn cleanup_runtime_workdir_for_execution(
.runtime .runtime
.cleanup_working_directory(runtime_id, candidate.workdir_id.as_str()) .cleanup_working_directory(runtime_id, candidate.workdir_id.as_str())
.map_err(|err| err.into_error())?; .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( return Err(ApiError::with_diagnostics(
Error::RuntimeOperationFailed { Error::RuntimeOperationFailed {
runtime_id: runtime_id.to_string(), runtime_id: runtime_id.to_string(),
@ -1939,13 +1978,6 @@ fn cleanup_runtime_workdir_for_execution(
result.diagnostics, result.diagnostics,
)); ));
}; };
let record = workdir_record_from_summary(
api,
runtime_id,
&working_directory.summary,
"backend_managed",
);
api.store.upsert_workdir_registry(&record)?;
Ok(()) Ok(())
} }
@ -4158,6 +4190,13 @@ fn sync_runtime_workdir_observations(
let mut observed = std::collections::BTreeSet::new(); let mut observed = std::collections::BTreeSet::new();
for status in &response.items { for status in &response.items {
observed.insert(status.summary.working_directory_id.clone()); 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( let existing = api.store.get_workdir_registry(
&api.config.workspace_id, &api.config.workspace_id,
&status.summary.working_directory_id, &status.summary.working_directory_id,
@ -4183,6 +4222,12 @@ fn sync_runtime_workdir_observations(
{ {
Ok(result) => { Ok(result) => {
if let Some(status) = result.working_directory { if let Some(status) = result.working_directory {
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 management_kind = record.management_kind.clone();
let mut updated = workdir_record_from_summary( let mut updated = workdir_record_from_summary(
api, api,
@ -4192,6 +4237,7 @@ fn sync_runtime_workdir_observations(
); );
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)?; api.store.upsert_workdir_registry(&updated)?;
}
} else { } else {
record.materialization_status = record.materialization_status =
workdir_status_from_runtime_miss(result.diagnostics.as_slice()).to_string(); 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(), resolved_commit: summary.resolved_commit.clone(),
materialization_status: match summary.status { materialization_status: match summary.status {
WorkingDirectoryStatusKind::Active => "present", WorkingDirectoryStatusKind::Active => "present",
WorkingDirectoryStatusKind::Removed => "removed",
WorkingDirectoryStatusKind::CleanupPending => "pending", WorkingDirectoryStatusKind::CleanupPending => "pending",
WorkingDirectoryStatusKind::Corrupted => "corrupted", WorkingDirectoryStatusKind::Corrupted => "corrupted",
WorkingDirectoryStatusKind::NotFound => "not_found", WorkingDirectoryStatusKind::NotFound => "not_found",
@ -4339,7 +4384,7 @@ fn workdir_summary_from_record(record: &WorkdirRegistryRecord) -> WorkingDirecto
"corrupted" => WorkingDirectoryStatusKind::Corrupted, "corrupted" => WorkingDirectoryStatusKind::Corrupted,
"not_found" | "missing" => WorkingDirectoryStatusKind::NotFound, "not_found" | "missing" => WorkingDirectoryStatusKind::NotFound,
"unknown" => WorkingDirectoryStatusKind::Unknown, "unknown" => WorkingDirectoryStatusKind::Unknown,
_ => WorkingDirectoryStatusKind::Removed, _ => WorkingDirectoryStatusKind::Unknown,
}; };
WorkingDirectorySummary { WorkingDirectorySummary {
working_directory_id: record.workdir_id.clone(), working_directory_id: record.workdir_id.clone(),
@ -5652,7 +5697,7 @@ mod tests {
response.results[0].action, response.results[0].action,
CleanupTargetKind::WorkdirCleanCleanup CleanupTargetKind::WorkdirCleanCleanup
); );
assert_eq!(response.results[0].status, "cleaned"); assert_eq!(response.results[0].status, "deleted");
} }
#[tokio::test] #[tokio::test]
@ -5699,16 +5744,16 @@ mod tests {
} }
#[tokio::test] #[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(); let workspace = tempfile::tempdir().unwrap();
init_clean_git_workspace(workspace.path()); init_clean_git_workspace(workspace.path());
let api = test_api(workspace.path()).await; let api = test_api(workspace.path()).await;
let pinned = seed_cleanup_worker(&api, 1, "pinned"); let pinned = seed_cleanup_worker(&api, 1, "pinned");
let unobserved = seed_cleanup_worker(&api, 2, "normal"); let unobserved = seed_cleanup_worker(&api, 2, "normal");
seed_cleanup_workdir(&api, "workdir-dirty", "present", "dirty"); 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, 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") let plan = build_runtime_cleanup_plan(&api, "runtime-test")
.unwrap_or_else(|err| panic!("cleanup plan: {}", err.error)); .unwrap_or_else(|err| panic!("cleanup plan: {}", err.error));
@ -5725,7 +5770,7 @@ mod tests {
let running_linked_workdir = plan let running_linked_workdir = plan
.workdirs .workdirs
.iter() .iter()
.find(|candidate| candidate.workdir_id == "workdir-removed") .find(|candidate| candidate.workdir_id == "workdir-not-found")
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
running_linked_workdir.action, running_linked_workdir.action,
@ -5783,7 +5828,7 @@ mod tests {
init_clean_git_workspace(workspace.path()); init_clean_git_workspace(workspace.path());
let api = test_api(workspace.path()).await; let api = test_api(workspace.path()).await;
seed_cleanup_workdir(&api, "workdir-dirty", "present", "dirty"); 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") let plan = build_runtime_cleanup_plan(&api, "runtime-test")
.unwrap_or_else(|err| panic!("cleanup plan: {}", err.error)); .unwrap_or_else(|err| panic!("cleanup plan: {}", err.error));
let dirty_target = plan let dirty_target = plan
@ -5796,7 +5841,7 @@ mod tests {
let removed_target = plan let removed_target = plan
.workdirs .workdirs
.iter() .iter()
.find(|candidate| candidate.workdir_id == "workdir-removed") .find(|candidate| candidate.workdir_id == "workdir-not-found")
.unwrap() .unwrap()
.target_id .target_id
.clone(); .clone();
@ -5820,7 +5865,7 @@ mod tests {
assert_eq!(response.results[0].status, "deleted"); assert_eq!(response.results[0].status, "deleted");
assert!( assert!(
api.store api.store
.get_workdir_registry(&api.config.workspace_id, "workdir-removed") .get_workdir_registry(&api.config.workspace_id, "workdir-not-found")
.unwrap() .unwrap()
.is_none() .is_none()
); );
@ -6103,7 +6148,7 @@ mod tests {
StatusCode::OK, StatusCode::OK,
) )
.await; .await;
assert_eq!(removed["results"][0]["status"], "cleaned"); assert_eq!(removed["results"][0]["status"], "deleted");
} }
#[tokio::test] #[tokio::test]

View File

@ -627,7 +627,7 @@ CREATE TABLE IF NOT EXISTS workdir_registry (
repository_id TEXT NOT NULL, repository_id TEXT NOT NULL,
selector TEXT, selector TEXT,
resolved_commit 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')), cleanliness TEXT NOT NULL CHECK (cleanliness IN ('clean', 'dirty', 'unknown')),
management_kind TEXT NOT NULL CHECK (management_kind IN ('backend_managed', 'runtime_unmanaged')), management_kind TEXT NOT NULL CHECK (management_kind IN ('backend_managed', 'runtime_unmanaged')),
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
@ -946,7 +946,7 @@ fn add_workdir_runtime_observation_states(conn: &Connection) -> Result<()> {
repository_id TEXT NOT NULL, repository_id TEXT NOT NULL,
selector TEXT, selector TEXT,
resolved_commit 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')), cleanliness TEXT NOT NULL CHECK (cleanliness IN ('clean', 'dirty', 'unknown')),
management_kind TEXT NOT NULL CHECK (management_kind IN ('backend_managed', 'runtime_unmanaged')), management_kind TEXT NOT NULL CHECK (management_kind IN ('backend_managed', 'runtime_unmanaged')),
created_at TEXT NOT NULL, 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, workspace_id, workdir_id, runtime_id, repository_id, selector, resolved_commit,
CASE materialization_status CASE materialization_status
WHEN 'missing' THEN 'not_found' WHEN 'missing' THEN 'not_found'
WHEN 'removed' THEN 'not_found'
ELSE materialization_status ELSE materialization_status
END, END,
cleanliness, management_kind, created_at, updated_at cleanliness, management_kind, created_at, updated_at
@ -1547,7 +1548,7 @@ mod tests {
repository_id: "repo".to_string(), repository_id: "repo".to_string(),
selector: Some("develop".to_string()), selector: Some("develop".to_string()),
resolved_commit: Some("abcdef".to_string()), resolved_commit: Some("abcdef".to_string()),
materialization_status: "removed".to_string(), materialization_status: "not_found".to_string(),
cleanliness: "clean".to_string(), cleanliness: "clean".to_string(),
management_kind: "backend_managed".to_string(), management_kind: "backend_managed".to_string(),
created_at: "2".to_string(), created_at: "2".to_string(),

View File

@ -0,0 +1,130 @@
<script lang="ts">
import { dismissWorkspaceAlert, workspaceAlerts, type WorkspaceAlertLevel } from './store';
function label(level: WorkspaceAlertLevel): string {
switch (level) {
case 'success': return 'Success';
case 'info': return 'Info';
case 'warning': return 'Warning';
case 'error': return 'Error';
case 'system': return 'System';
case 'debug': return 'Debug';
}
}
</script>
<div class="workspace-alerts" aria-live="polite" aria-atomic="false">
{#each $workspaceAlerts as alert (alert.id)}
<article class={`workspace-alert ${alert.level}`} role={alert.level === 'error' ? 'alert' : 'status'}>
<div class="workspace-alert-icon" aria-hidden="true">
{#if alert.level === 'success'}
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><path d="m9 12 2 2 4-4" /></svg>
{:else if alert.level === 'info'}
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><path d="M12 16v-4" /><path d="M12 8h.01" /></svg>
{:else if alert.level === 'warning' || alert.level === 'error'}
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><line x1="12" x2="12" y1="8" y2="12" /><line x1="12" x2="12.01" y1="16" y2="16" /></svg>
{:else if alert.level === 'system'}
<svg viewBox="0 0 24 24"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" /><circle cx="12" cy="12" r="3" /></svg>
{:else}
<svg viewBox="0 0 24 24"><path d="m8 2 1.88 1.88" /><path d="M14.12 3.88 16 2" /><path d="M9 7.13v-1a3.003 3.003 0 1 1 6 0v1" /><path d="M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6" /><path d="M12 20v-9" /><path d="M6.53 9C4.6 8.8 3 7.1 3 5" /><path d="M6 13H2" /><path d="M3 21c0-2.1 1.7-3.9 3.8-4" /><path d="M20.97 5c0 2.1-1.6 3.8-3.5 4" /><path d="M22 13h-4" /><path d="M17.2 17c2.1.1 3.8 1.9 3.8 4" /></svg>
{/if}
</div>
<div class="workspace-alert-body">
<strong>{alert.title ?? label(alert.level)}</strong>
<p>{alert.message}</p>
</div>
<button type="button" class="workspace-alert-dismiss" aria-label="Dismiss alert" onclick={() => dismissWorkspaceAlert(alert.id)}>×</button>
</article>
{/each}
</div>
<style>
.workspace-alerts {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 1000;
display: grid;
gap: 0.65rem;
width: min(28rem, calc(100vw - 2rem));
pointer-events: none;
}
.workspace-alert {
--alert-color: var(--text);
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 0.7rem;
align-items: start;
padding: 0.8rem 0.85rem;
border: 1px solid color-mix(in srgb, var(--alert-color) 45%, var(--line));
border-radius: 0.85rem;
background: color-mix(in srgb, var(--alert-color) 12%, var(--bg-raised) 88%);
color: var(--text);
box-shadow: 0 18px 50px rgb(0 0 0 / 0.28);
pointer-events: auto;
}
.workspace-alert.success { --alert-color: oklch(70% 0.18 145); }
.workspace-alert.info { --alert-color: oklch(68% 0.16 245); }
.workspace-alert.warning { --alert-color: oklch(78% 0.18 85); }
.workspace-alert.error { --alert-color: oklch(62% 0.2 30); }
.workspace-alert.system { --alert-color: oklch(96% 0.01 260); }
.workspace-alert.debug { --alert-color: oklch(68% 0.18 305); }
.workspace-alert-icon {
color: var(--alert-color);
margin-top: 0.1rem;
}
.workspace-alert-icon svg {
display: block;
width: 1.15rem;
height: 1.15rem;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 2;
}
.workspace-alert-body {
min-width: 0;
}
.workspace-alert-body strong {
display: block;
margin-bottom: 0.15rem;
color: var(--alert-color);
font-size: 0.82rem;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.workspace-alert-body p {
margin: 0;
overflow-wrap: anywhere;
color: var(--text);
font-size: 0.9rem;
line-height: 1.35;
}
.workspace-alert-dismiss {
width: 1.5rem;
height: 1.5rem;
padding: 0;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--text-muted);
cursor: pointer;
font-size: 1.2rem;
line-height: 1;
}
.workspace-alert-dismiss:hover,
.workspace-alert-dismiss:focus-visible {
background: color-mix(in srgb, var(--alert-color) 18%, transparent);
color: var(--text);
}
</style>

View File

@ -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<AlertSubscriber>();
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();
}

View File

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import WorkspaceAlerts from '$lib/workspace-alerts/WorkspaceAlerts.svelte';
import WorkspaceSidebar from '$lib/workspace-sidebar/WorkspaceSidebar.svelte'; import WorkspaceSidebar from '$lib/workspace-sidebar/WorkspaceSidebar.svelte';
import '../app.css'; import '../app.css';
import type { LayoutProps } from './$types'; import type { LayoutProps } from './$types';
@ -8,6 +9,8 @@
let sidebarCollapsed = $state(false); let sidebarCollapsed = $state(false);
</script> </script>
<WorkspaceAlerts />
<div class:sidebar-collapsed={sidebarCollapsed} class="workspace-layout"> <div class:sidebar-collapsed={sidebarCollapsed} class="workspace-layout">
<WorkspaceSidebar <WorkspaceSidebar
workspace={data.workspace} workspace={data.workspace}

View File

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { pushWorkspaceAlert } from '$lib/workspace-alerts/store';
import { workspaceApiPath } from '$lib/workspace-api/http'; import { workspaceApiPath } from '$lib/workspace-api/http';
import type { import type {
CleanupWorkdirCandidate, CleanupWorkdirCandidate,
@ -9,9 +10,9 @@
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data }: PageProps = $props(); let { data }: PageProps = $props();
let cleanupStatus = $state<string | null>(null);
let cleanupBusyTarget = $state<string | null>(null); let cleanupBusyTarget = $state<string | null>(null);
let cleanupPlan = $state<RuntimeCleanupPlanResponse | null>(null); let cleanupPlan = $state<RuntimeCleanupPlanResponse | null>(null);
let workdirs = $state<WorkingDirectorySummary[]>([]);
let runtimeLabel = $derived( let runtimeLabel = $derived(
data.runtimes?.items.find((runtime) => runtime.runtime_id === data.runtimeId)?.label ?? data.runtimeId, data.runtimes?.items.find((runtime) => runtime.runtime_id === data.runtimeId)?.label ?? data.runtimeId,
); );
@ -19,6 +20,7 @@
$effect(() => { $effect(() => {
cleanupPlan = data.cleanupPlan ?? null; cleanupPlan = data.cleanupPlan ?? null;
workdirs = data.workdirs?.items ?? [];
}); });
function commitLabel(workdir: WorkingDirectorySummary): string { function commitLabel(workdir: WorkingDirectorySummary): string {
@ -29,26 +31,35 @@
return workdir.requested_selector ?? 'HEAD'; 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 { function cleanupCandidate(workdir: WorkingDirectorySummary): CleanupWorkdirCandidate | undefined {
return cleanupCandidates.find((candidate) => candidate.workdir_id === workdir.working_directory_id); return cleanupCandidates.find((candidate) => candidate.workdir_id === workdir.working_directory_id);
} }
async function executeWorkdirCleanup(candidate: CleanupWorkdirCandidate): Promise<void> { function isDeleteDisabled(candidate: CleanupWorkdirCandidate): boolean {
if (!cleanupPlan) return; return Boolean(candidate.blocking_reason) || candidate.action === 'workdir_dirty_discard' || cleanupBusyTarget !== null;
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 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<void> {
if (!cleanupPlan || isDeleteDisabled(candidate)) return;
cleanupBusyTarget = candidate.target_id; cleanupBusyTarget = candidate.target_id;
cleanupStatus = null;
try { try {
const response = await fetch( const response = await fetch(
workspaceApiPath(data.workspaceId, `/runtimes/${encodeURIComponent(data.runtimeId)}/cleanup-executions`), workspaceApiPath(data.workspaceId, `/runtimes/${encodeURIComponent(data.runtimeId)}/cleanup-executions`),
@ -60,16 +71,26 @@
expected_plan_digest: cleanupPlan.digest, expected_plan_digest: cleanupPlan.digest,
worker_target_ids: [], worker_target_ids: [],
workdir_target_ids: [candidate.target_id], 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; const payload = (await response.json().catch(() => null)) as RuntimeCleanupExecutionResponse | unknown;
if (!response.ok) throw new Error(payload && 'message' in payload ? (payload.message ?? payload.error) : response.statusText); if (!response.ok) throw new Error(errorMessage(payload, response.statusText));
if (payload && 'plan_after' in payload) cleanupPlan = payload.plan_after; if (payload && typeof payload === 'object' && 'plan_after' in payload) {
cleanupStatus = `Executed cleanup for ${candidate.workdir_id}. Refresh to see the latest Workdir list.`; 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) { } 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 { } finally {
cleanupBusyTarget = null; cleanupBusyTarget = null;
} }
@ -88,7 +109,6 @@
<h1 id="workdirs-heading">Workdirs</h1> <h1 id="workdirs-heading">Workdirs</h1>
<p>Workdirs owned by <code>{data.runtimeId}</code>.</p> <p>Workdirs owned by <code>{data.runtimeId}</code>.</p>
{#if data.cleanupPlanError}<p class="section-state error">{data.cleanupPlanError}</p>{/if} {#if data.cleanupPlanError}<p class="section-state error">{data.cleanupPlanError}</p>{/if}
{#if cleanupStatus}<p>{cleanupStatus}</p>{/if}
</div> </div>
</header> </header>
@ -96,7 +116,7 @@
<p class="section-state error">{data.workdirsError}</p> <p class="section-state error">{data.workdirsError}</p>
{:else if !data.workdirs} {:else if !data.workdirs}
<p class="section-state">Loading workdirs…</p> <p class="section-state">Loading workdirs…</p>
{:else if data.workdirs.items.length === 0} {:else if workdirs.length === 0}
<p class="section-state">No workdirs are visible for this Runtime.</p> <p class="section-state">No workdirs are visible for this Runtime.</p>
{:else} {:else}
<div class="table-wrap"> <div class="table-wrap">
@ -113,7 +133,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each data.workdirs.items as workdir} {#each workdirs as workdir}
{@const cleanup = cleanupCandidate(workdir)} {@const cleanup = cleanupCandidate(workdir)}
<tr> <tr>
<td><code>{workdir.working_directory_id}</code></td> <td><code>{workdir.working_directory_id}</code></td>
@ -125,14 +145,19 @@
<td> <td>
{#if cleanup} {#if cleanup}
<button <button
class="icon-action danger"
type="button" type="button"
disabled={!!cleanup.blocking_reason || cleanupBusyTarget === cleanup.target_id} disabled={isDeleteDisabled(cleanup)}
title={cleanup.blocking_reason ?? cleanup.reason} aria-label={`Delete ${workdir.working_directory_id}`}
onclick={() => executeWorkdirCleanup(cleanup)} title={cleanup.action === 'workdir_dirty_discard' ? 'Dirty Workdirs must be cleaned before deletion' : (cleanup.blocking_reason ?? cleanup.reason)}
onclick={() => deleteWorkdir(workdir, cleanup)}
> >
{cleanupBusyTarget === cleanup.target_id ? 'Executing…' : cleanupLabel(cleanup)} {#if cleanupBusyTarget === cleanup.target_id}
<span class="spinner" aria-hidden="true"></span>
{:else}
<svg class="action-icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M3 6h18" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg>
{/if}
</button> </button>
{#if cleanup.blocking_reason}<small class="error">{cleanup.blocking_reason}</small>{/if}
{:else} {:else}
<span class="muted"></span> <span class="muted"></span>
{/if} {/if}
@ -144,3 +169,55 @@
</div> </div>
{/if} {/if}
</section> </section>
<style>
.icon-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
border: 1px solid var(--border);
border-radius: 0.5rem;
background: var(--surface);
color: var(--text);
cursor: pointer;
}
.icon-action.danger:hover:not(:disabled),
.icon-action.danger:focus-visible:not(:disabled) {
border-color: var(--danger, oklch(60% 0.18 30));
color: var(--danger, oklch(60% 0.18 30));
}
.icon-action:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.action-icon {
width: 1rem;
height: 1rem;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.spinner {
width: 1rem;
height: 1rem;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 999px;
animation: workdir-action-spin 0.8s linear infinite;
}
@keyframes workdir-action-spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { pushWorkspaceAlert } from '$lib/workspace-alerts/store';
import { workspaceApiPath } from '$lib/workspace-api/http'; import { workspaceApiPath } from '$lib/workspace-api/http';
import { workerConsoleHref } from '$lib/workspace-console/model'; import { workerConsoleHref } from '$lib/workspace-console/model';
import { canOpenWorkerConsole } from '$lib/workspace-sidebar/workers'; import { canOpenWorkerConsole } from '$lib/workspace-sidebar/workers';
@ -8,12 +9,13 @@
type WorkerActionKind = 'pin' | 'delete'; type WorkerActionKind = 'pin' | 'delete';
let { data }: PageProps = $props(); let { data }: PageProps = $props();
let statusMessage = $state<string | null>(null);
let cleanupPlans = $state<Record<string, RuntimeCleanupPlanResponse>>({}); let cleanupPlans = $state<Record<string, RuntimeCleanupPlanResponse>>({});
let workers = $state<Worker[]>([]);
let busyAction = $state<{ workerKey: string; kind: WorkerActionKind } | null>(null); let busyAction = $state<{ workerKey: string; kind: WorkerActionKind } | null>(null);
$effect(() => { $effect(() => {
cleanupPlans = data.cleanupPlans; cleanupPlans = data.cleanupPlans;
workers = data.workers?.items ?? [];
}); });
function workerKey(worker: Worker): string { function workerKey(worker: Worker): string {
@ -28,6 +30,24 @@
return busyAction !== null; return busyAction !== 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 refreshCleanupPlan(runtimeId: string): Promise<void> { async function refreshCleanupPlan(runtimeId: string): Promise<void> {
const response = await fetch( const response = await fetch(
workspaceApiPath(data.workspaceId, `/runtimes/${encodeURIComponent(runtimeId)}/cleanup-plan`), workspaceApiPath(data.workspaceId, `/runtimes/${encodeURIComponent(runtimeId)}/cleanup-plan`),
@ -40,7 +60,6 @@
async function setPinned(worker: Worker, pinned: boolean): Promise<void> { async function setPinned(worker: Worker, pinned: boolean): Promise<void> {
if (busyAction) return; if (busyAction) return;
busyAction = { workerKey: workerKey(worker), kind: 'pin' }; busyAction = { workerKey: workerKey(worker), kind: 'pin' };
statusMessage = null;
try { try {
const response = await fetch( const response = await fetch(
workspaceApiPath( workspaceApiPath(
@ -51,13 +70,16 @@
); );
const payload = await response.json().catch(() => null); const payload = await response.json().catch(() => null);
if (!response.ok) { if (!response.ok) {
statusMessage = payload?.message ?? payload?.error ?? response.statusText; pushWorkspaceAlert('error', errorMessage(payload, response.statusText), { title: 'Worker pin failed' });
return; return;
} }
worker.pinned = Boolean(payload?.pinned); worker.pinned = Boolean(payload?.pinned);
worker.retention_state = payload?.retention_state ?? (worker.pinned ? 'pinned' : 'normal'); worker.retention_state = payload?.retention_state ?? (worker.pinned ? 'pinned' : 'normal');
await refreshCleanupPlan(worker.runtime_id); await refreshCleanupPlan(worker.runtime_id);
statusMessage = `${worker.label} ${worker.pinned ? 'pinned' : 'unpinned'}.`; } catch (error) {
pushWorkspaceAlert('error', error instanceof Error ? error.message : 'Worker pin failed', {
title: 'Worker pin failed',
});
} finally { } finally {
busyAction = null; busyAction = null;
} }
@ -71,7 +93,6 @@
async function deleteWorker(worker: Worker, candidate: CleanupWorkerCandidate): Promise<void> { async function deleteWorker(worker: Worker, candidate: CleanupWorkerCandidate): Promise<void> {
if (!cleanupPlans?.[worker.runtime_id] || busyAction) return; if (!cleanupPlans?.[worker.runtime_id] || busyAction) return;
statusMessage = null;
busyAction = { workerKey: workerKey(worker), kind: 'delete' }; busyAction = { workerKey: workerKey(worker), kind: 'delete' };
try { try {
const plan = cleanupPlans[worker.runtime_id]; const plan = cleanupPlans[worker.runtime_id];
@ -89,19 +110,25 @@
}), }),
}, },
); );
const payload = (await response.json().catch(() => null)) as RuntimeCleanupExecutionResponse | { message?: string; error?: string } | null; const payload = (await response.json().catch(() => null)) as RuntimeCleanupExecutionResponse | unknown;
if (!response.ok) throw new Error(payload && 'message' in payload ? (payload.message ?? payload.error) : response.statusText); if (!response.ok) throw new Error(errorMessage(payload, response.statusText));
if (payload && 'plan_after' in payload) { if (payload && typeof payload === 'object' && 'plan_after' in payload) {
cleanupPlans = { ...cleanupPlans, [worker.runtime_id]: payload.plan_after }; const execution = payload as RuntimeCleanupExecutionResponse;
cleanupPlans = { ...cleanupPlans, [worker.runtime_id]: execution.plan_after };
} }
if (data.workers) { const result = payload && typeof payload === 'object' && 'results' in payload
data.workers.items = data.workers.items.filter( ? (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 Worker');
}
workers = workers.filter(
(item) => !(item.runtime_id === worker.runtime_id && item.worker_id === worker.worker_id), (item) => !(item.runtime_id === worker.runtime_id && item.worker_id === worker.worker_id),
); );
}
statusMessage = `Deleted Worker ${worker.label}.`;
} catch (error) { } catch (error) {
statusMessage = error instanceof Error ? error.message : 'Worker cleanup failed'; pushWorkspaceAlert('error', error instanceof Error ? error.message : 'Worker deletion failed', {
title: 'Worker deletion failed',
});
} finally { } finally {
busyAction = null; busyAction = null;
} }
@ -134,7 +161,6 @@
<div> <div>
<h1 id="workers-heading">Workers</h1> <h1 id="workers-heading">Workers</h1>
<p>Workers running or persisted for this workspace. Pinning updates Backend retention.</p> <p>Workers running or persisted for this workspace. Pinning updates Backend retention.</p>
{#if statusMessage}<p>{statusMessage}</p>{/if}
</div> </div>
<a class="section-action" href={`/w/${data.workspaceId}/workers/new`}>New Worker</a> <a class="section-action" href={`/w/${data.workspaceId}/workers/new`}>New Worker</a>
</header> </header>
@ -143,7 +169,7 @@
<p class="section-state error">{data.workersError}</p> <p class="section-state error">{data.workersError}</p>
{:else if !data.workers} {:else if !data.workers}
<p class="section-state">Loading Workers…</p> <p class="section-state">Loading Workers…</p>
{:else if data.workers.items.length === 0} {:else if workers.length === 0}
<p class="section-state">No Workers are visible.</p> <p class="section-state">No Workers are visible.</p>
{:else} {:else}
<div class="table-wrap workers-table-wrap"> <div class="table-wrap workers-table-wrap">
@ -160,7 +186,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each data.workers.items as worker} {#each workers as worker}
{@const cleanup = cleanupCandidate(worker)} {@const cleanup = cleanupCandidate(worker)}
{@const canDelete = cleanup && !cleanup.blocking_reason} {@const canDelete = cleanup && !cleanup.blocking_reason}
{@const anyActionDisabled = actionsDisabled()} {@const anyActionDisabled = actionsDisabled()}