workspace: refine runtime and cleanup lists

This commit is contained in:
Keisuke Hirata 2026-07-12 06:57:10 +09:00
parent c5aae4b234
commit d8c0853d55
No known key found for this signature in database
8 changed files with 161 additions and 128 deletions

View File

@ -50,6 +50,10 @@ impl Default for RuntimeOptions {
} }
} }
fn unknown_platform_component() -> String {
"unknown".to_string()
}
/// Management-plane summary for a Runtime. /// Management-plane summary for a Runtime.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeSummary { pub struct RuntimeSummary {
@ -63,6 +67,10 @@ pub struct RuntimeSummary {
pub cancelled_worker_count: usize, pub cancelled_worker_count: usize,
pub diagnostic_count: usize, pub diagnostic_count: usize,
pub limits: RuntimeLimits, pub limits: RuntimeLimits,
#[serde(default = "unknown_platform_component")]
pub os: String,
#[serde(default = "unknown_platform_component")]
pub arch: String,
#[serde(default)] #[serde(default)]
pub worker_creation_available: bool, pub worker_creation_available: bool,
} }

View File

@ -162,6 +162,8 @@ impl Runtime {
cancelled_worker_count, cancelled_worker_count,
diagnostic_count: state.diagnostics.len(), diagnostic_count: state.diagnostics.len(),
limits: state.limits.clone(), limits: state.limits.clone(),
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
worker_creation_available: state.execution_backend.is_some(), worker_creation_available: state.execution_backend.is_some(),
}) })
} }

View File

@ -1765,7 +1765,9 @@ impl RemoteRuntimeConfig {
display_name: display_name.into(), display_name: display_name.into(),
base_url: base_url.into(), base_url: base_url.into(),
bearer_token, bearer_token,
cached_capabilities: remote_runtime_capabilities(200, false, false), cached_capabilities: remote_runtime_capabilities(
200, false, false, "unknown", "unknown",
),
cached_status: "configured".to_string(), cached_status: "configured".to_string(),
timeout: Duration::from_secs(10), timeout: Duration::from_secs(10),
} }
@ -2033,6 +2035,8 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
limit, limit,
true, true,
response.runtime.worker_creation_available, response.runtime.worker_creation_available,
response.runtime.os,
response.runtime.arch,
), ),
diagnostics: Vec::new(), diagnostics: Vec::new(),
}, },
@ -2066,7 +2070,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
status: "configured".to_string(), status: "configured".to_string(),
observed_at: Utc::now().to_rfc3339(), observed_at: Utc::now().to_rfc3339(),
last_seen_at: None, last_seen_at: None,
capabilities: remote_runtime_capabilities(limit, true, false), capabilities: remote_runtime_capabilities(limit, true, false, "unknown", "unknown"),
diagnostics: Vec::new(), diagnostics: Vec::new(),
}], }],
Vec::new(), Vec::new(),
@ -2872,6 +2876,8 @@ fn remote_runtime_capabilities(
limit: usize, limit: usize,
available: bool, available: bool,
worker_creation_available: bool, worker_creation_available: bool,
os: impl Into<String>,
arch: impl Into<String>,
) -> RuntimeCapabilitySummary { ) -> RuntimeCapabilitySummary {
RuntimeCapabilitySummary { RuntimeCapabilitySummary {
can_list_hosts: true, can_list_hosts: true,
@ -2887,8 +2893,8 @@ fn remote_runtime_capabilities(
supports_backend_internal_tools: false, supports_backend_internal_tools: false,
workspace_scope: "remote_runtime_backend_private".to_string(), workspace_scope: "remote_runtime_backend_private".to_string(),
max_workers: limit, max_workers: limit,
os: "remote".to_string(), os: os.into(),
arch: "remote".to_string(), arch: arch.into(),
} }
} }

View File

@ -77,10 +77,6 @@
<dt>Runtime</dt> <dt>Runtime</dt>
<dd><code>{host.runtime_id}</code></dd> <dd><code>{host.runtime_id}</code></dd>
</div> </div>
<div>
<dt>Scope</dt>
<dd>{host.capabilities.workspace_scope}</dd>
</div>
<div> <div>
<dt>Platform</dt> <dt>Platform</dt>
<dd>{host.capabilities.os} / {host.capabilities.arch}</dd> <dd>{host.capabilities.os} / {host.capabilities.arch}</dd>

View File

@ -4,10 +4,6 @@
let { data }: PageProps = $props(); let { data }: PageProps = $props();
function runtimeScope(runtime: Runtime): string {
return runtime.capabilities.workspace_scope;
}
function runtimePlatform(runtime: Runtime): string { function runtimePlatform(runtime: Runtime): string {
return `${runtime.capabilities.os} / ${runtime.capabilities.arch}`; return `${runtime.capabilities.os} / ${runtime.capabilities.arch}`;
} }
@ -40,7 +36,6 @@
<th>Runtime</th> <th>Runtime</th>
<th>Kind</th> <th>Kind</th>
<th>Status</th> <th>Status</th>
<th>Scope</th>
<th>Platform</th> <th>Platform</th>
<th>Capacity</th> <th>Capacity</th>
<th>Workdirs</th> <th>Workdirs</th>
@ -55,7 +50,6 @@
</td> </td>
<td>{runtime.kind}</td> <td>{runtime.kind}</td>
<td>{runtime.status}</td> <td>{runtime.status}</td>
<td>{runtimeScope(runtime)}</td>
<td>{runtimePlatform(runtime)}</td> <td>{runtimePlatform(runtime)}</td>
<td>{runtime.capabilities.max_workers} workers</td> <td>{runtime.capabilities.max_workers} workers</td>
<td> <td>

View File

@ -1,19 +1,25 @@
<script lang="ts"> <script lang="ts">
import { workspaceApiPath } from '$lib/workspace-api/http'; import { workspaceApiPath } from '$lib/workspace-api/http';
import type { CleanupWorkdirCandidate, WorkingDirectorySummary } from '$lib/workspace-sidebar/types'; import type {
CleanupWorkdirCandidate,
RuntimeCleanupExecutionResponse,
RuntimeCleanupPlanResponse,
WorkingDirectorySummary,
} from '$lib/workspace-sidebar/types';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data }: PageProps = $props(); let { data }: PageProps = $props();
let selectedCleanupTargets = $state(new Set<string>());
let selectedWorkerCleanupTargets = $state(new Set<string>());
let confirmedDirtyTargets = $state(new Set<string>());
let cleanupStatus = $state<string | null>(null); let cleanupStatus = $state<string | null>(null);
let cleanupBusy = $state(false); let cleanupBusyTarget = $state<string | null>(null);
let cleanupCandidates = $derived(data.cleanupPlan?.workdirs ?? []); let cleanupPlan = $state<RuntimeCleanupPlanResponse | null>(null);
let workerCleanupCandidates = $derived(data.cleanupPlan?.workers ?? []);
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,
); );
let cleanupCandidates = $derived(cleanupPlan?.workdirs ?? []);
$effect(() => {
cleanupPlan = data.cleanupPlan ?? null;
});
function commitLabel(workdir: WorkingDirectorySummary): string { function commitLabel(workdir: WorkingDirectorySummary): string {
return workdir.resolved_commit ? workdir.resolved_commit.slice(0, 12) : '—'; return workdir.resolved_commit ? workdir.resolved_commit.slice(0, 12) : '—';
@ -25,36 +31,23 @@
function cleanupLabel(candidate: CleanupWorkdirCandidate): string { function cleanupLabel(candidate: CleanupWorkdirCandidate): string {
if (candidate.action === 'workdir_dirty_discard') { if (candidate.action === 'workdir_dirty_discard') {
return candidate.cleanliness === 'dirty' ? 'Discard dirty workdir' : 'Discard unknown-state workdir'; return candidate.cleanliness === 'dirty' ? 'Discard' : 'Discard unknown';
} }
if (candidate.action === 'workdir_record_delete') return 'Delete missing/removed record'; if (candidate.action === 'workdir_record_delete') return 'Delete record';
return 'Clean up verified-clean workdir'; return 'Clean up';
} }
function toggleSelected(targetId: string): void { function cleanupCandidate(workdir: WorkingDirectorySummary): CleanupWorkdirCandidate | undefined {
const next = new Set(selectedCleanupTargets); return cleanupCandidates.find((candidate) => candidate.workdir_id === workdir.working_directory_id);
if (next.has(targetId)) next.delete(targetId);
else next.add(targetId);
selectedCleanupTargets = next;
} }
function toggleWorkerSelected(targetId: string): void { async function executeWorkdirCleanup(candidate: CleanupWorkdirCandidate): Promise<void> {
const next = new Set(selectedWorkerCleanupTargets); if (!cleanupPlan) return;
if (next.has(targetId)) next.delete(targetId); if (candidate.action === 'workdir_dirty_discard') {
else next.add(targetId); const confirmed = window.confirm(`${cleanupLabel(candidate)} ${candidate.workdir_id}? This explicitly discards the Workdir contents.`);
selectedWorkerCleanupTargets = next; if (!confirmed) return;
} }
cleanupBusyTarget = candidate.target_id;
function toggleDirtyConfirmation(targetId: string): void {
const next = new Set(confirmedDirtyTargets);
if (next.has(targetId)) next.delete(targetId);
else next.add(targetId);
confirmedDirtyTargets = next;
}
async function executeCleanup(): Promise<void> {
if (!data.cleanupPlan || (selectedCleanupTargets.size === 0 && selectedWorkerCleanupTargets.size === 0)) return;
cleanupBusy = true;
cleanupStatus = null; cleanupStatus = null;
try { try {
const response = await fetch( const response = await fetch(
@ -63,24 +56,22 @@
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
expected_plan_revision: data.cleanupPlan.revision, expected_plan_revision: cleanupPlan.revision,
expected_plan_digest: data.cleanupPlan.digest, expected_plan_digest: cleanupPlan.digest,
worker_target_ids: Array.from(selectedWorkerCleanupTargets), worker_target_ids: [],
workdir_target_ids: Array.from(selectedCleanupTargets), workdir_target_ids: [candidate.target_id],
confirm_dirty_discard_target_ids: Array.from(confirmedDirtyTargets), confirm_dirty_discard_target_ids: candidate.action === 'workdir_dirty_discard' ? [candidate.target_id] : [],
}), }),
}, },
); );
const payload = await response.json().catch(() => null); const payload = (await response.json().catch(() => null)) as RuntimeCleanupExecutionResponse | { message?: string; error?: string } | null;
if (!response.ok) throw new Error(payload?.message ?? payload?.error ?? response.statusText); if (!response.ok) throw new Error(payload && 'message' in payload ? (payload.message ?? payload.error) : response.statusText);
cleanupStatus = `Executed ${payload?.results?.length ?? 0} cleanup action(s). Refresh to see the latest plan.`; if (payload && 'plan_after' in payload) cleanupPlan = payload.plan_after;
selectedCleanupTargets = new Set(); cleanupStatus = `Executed cleanup for ${candidate.workdir_id}. Refresh to see the latest Workdir list.`;
selectedWorkerCleanupTargets = new Set();
confirmedDirtyTargets = new Set();
} catch (error) { } catch (error) {
cleanupStatus = error instanceof Error ? error.message : 'Cleanup failed'; cleanupStatus = error instanceof Error ? error.message : 'Workdir cleanup failed';
} finally { } finally {
cleanupBusy = false; cleanupBusyTarget = null;
} }
} }
</script> </script>
@ -96,6 +87,8 @@
<p class="breadcrumb"><a href={`/w/${data.workspaceId}/runtimes`}>Runtimes</a> / {runtimeLabel}</p> <p class="breadcrumb"><a href={`/w/${data.workspaceId}/runtimes`}>Runtimes</a> / {runtimeLabel}</p>
<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 cleanupStatus}<p>{cleanupStatus}</p>{/if}
</div> </div>
</header> </header>
@ -106,68 +99,6 @@
{:else if data.workdirs.items.length === 0} {:else if data.workdirs.items.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}
<section class="cleanup-panel">
<div>
<h2>Manual cleanup preview</h2>
<p class="muted">Select explicit Workdir targets. Raw Runtime paths are intentionally not shown.</p>
</div>
{#if data.cleanupPlanError}
<p class="section-state error">{data.cleanupPlanError}</p>
{:else if cleanupCandidates.length === 0 && workerCleanupCandidates.length === 0}
<p>No cleanup candidates.</p>
{:else}
<div class="cleanup-list">
{#each workerCleanupCandidates as candidate (candidate.target_id)}
<label class:blocked={!!candidate.blocking_reason}>
<input
type="checkbox"
checked={selectedWorkerCleanupTargets.has(candidate.target_id)}
disabled={!!candidate.blocking_reason}
onchange={() => toggleWorkerSelected(candidate.target_id)}
/>
<span>
<strong>Delete Worker registry row:</strong> <code>{candidate.runtime_worker_id}</code>
<small>{candidate.retention_state}; linked Workdirs {candidate.linked_workdir_ids.length}</small>
{#if candidate.blocking_reason}<small class="error">Blocked: {candidate.blocking_reason}</small>{/if}
</span>
</label>
{/each}
{#each cleanupCandidates as candidate (candidate.target_id)}
<label class:blocked={!!candidate.blocking_reason} class:dirty={candidate.action === 'workdir_dirty_discard'}>
<input
type="checkbox"
checked={selectedCleanupTargets.has(candidate.target_id)}
disabled={!!candidate.blocking_reason}
onchange={() => toggleSelected(candidate.target_id)}
/>
<span>
<strong>{cleanupLabel(candidate)}:</strong> <code>{candidate.workdir_id}</code>
<small>
file {candidate.file_status}; {candidate.cleanliness}; linked Workers {candidate.linked_worker_ids.length}
</small>
{#if candidate.blocking_reason}
<small class="error">Blocked: {candidate.blocking_reason}</small>
{:else if candidate.action === 'workdir_dirty_discard'}
<label class="confirm-dirty">
<input
type="checkbox"
checked={confirmedDirtyTargets.has(candidate.target_id)}
onchange={() => toggleDirtyConfirmation(candidate.target_id)}
/>
Confirm discard
</label>
{/if}
</span>
</label>
{/each}
</div>
<button type="button" onclick={executeCleanup} disabled={cleanupBusy || (selectedCleanupTargets.size === 0 && selectedWorkerCleanupTargets.size === 0)}>
{cleanupBusy ? 'Executing…' : 'Execute selected cleanup'}
</button>
{#if cleanupStatus}<p>{cleanupStatus}</p>{/if}
{/if}
</section>
<div class="table-wrap"> <div class="table-wrap">
<table class="workdirs-table"> <table class="workdirs-table">
<thead> <thead>
@ -178,10 +109,12 @@
<th>Commit</th> <th>Commit</th>
<th>Status</th> <th>Status</th>
<th>Policy</th> <th>Policy</th>
<th>Action</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each data.workdirs.items as workdir} {#each data.workdirs.items as workdir}
{@const cleanup = cleanupCandidate(workdir)}
<tr> <tr>
<td><code>{workdir.working_directory_id}</code></td> <td><code>{workdir.working_directory_id}</code></td>
<td>{workdir.repository_id}</td> <td>{workdir.repository_id}</td>
@ -192,6 +125,21 @@
<span>{workdir.dirty_state_policy}</span> <span>{workdir.dirty_state_policy}</span>
<small>{workdir.cleanup_policy}</small> <small>{workdir.cleanup_policy}</small>
</td> </td>
<td>
{#if cleanup}
<button
type="button"
disabled={!!cleanup.blocking_reason || cleanupBusyTarget === cleanup.target_id}
title={cleanup.blocking_reason ?? cleanup.reason}
onclick={() => executeWorkdirCleanup(cleanup)}
>
{cleanupBusyTarget === cleanup.target_id ? 'Executing…' : cleanupLabel(cleanup)}
</button>
{#if cleanup.blocking_reason}<small class="error">{cleanup.blocking_reason}</small>{/if}
{:else}
<span class="muted"></span>
{/if}
</td>
</tr> </tr>
{/each} {/each}
</tbody> </tbody>

View File

@ -2,14 +2,20 @@
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';
import type { Worker } from '$lib/workspace-sidebar/types'; import type { CleanupWorkerCandidate, RuntimeCleanupExecutionResponse, RuntimeCleanupPlanResponse, Worker } from '$lib/workspace-sidebar/types';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data }: PageProps = $props(); let { data }: PageProps = $props();
let retentionStatus = $state<string | null>(null); let statusMessage = $state<string | null>(null);
let cleanupPlans = $state<Record<string, RuntimeCleanupPlanResponse>>({});
let busyCleanupTarget = $state<string | null>(null);
$effect(() => {
cleanupPlans = data.cleanupPlans;
});
async function setPinned(worker: Worker, pinned: boolean): Promise<void> { async function setPinned(worker: Worker, pinned: boolean): Promise<void> {
retentionStatus = null; statusMessage = null;
const response = await fetch( const response = await fetch(
workspaceApiPath( workspaceApiPath(
data.workspaceId, data.workspaceId,
@ -19,12 +25,56 @@
); );
const payload = await response.json().catch(() => null); const payload = await response.json().catch(() => null);
if (!response.ok) { if (!response.ok) {
retentionStatus = payload?.message ?? payload?.error ?? response.statusText; statusMessage = payload?.message ?? payload?.error ?? response.statusText;
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');
retentionStatus = `${worker.label} ${worker.pinned ? 'pinned' : 'unpinned'}.`; statusMessage = `${worker.label} ${worker.pinned ? 'pinned' : 'unpinned'}.`;
}
function cleanupCandidate(worker: Worker): CleanupWorkerCandidate | undefined {
return cleanupPlans?.[worker.runtime_id]?.workers.find(
(candidate) => candidate.runtime_id === worker.runtime_id && candidate.runtime_worker_id === worker.worker_id,
);
}
async function deleteWorkerRegistryRow(worker: Worker, candidate: CleanupWorkerCandidate): Promise<void> {
if (!cleanupPlans?.[worker.runtime_id]) return;
statusMessage = null;
busyCleanupTarget = candidate.target_id;
try {
const plan = cleanupPlans[worker.runtime_id];
const response = await fetch(
workspaceApiPath(data.workspaceId, `/runtimes/${encodeURIComponent(worker.runtime_id)}/cleanup-executions`),
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
expected_plan_revision: plan.revision,
expected_plan_digest: plan.digest,
worker_target_ids: [candidate.target_id],
workdir_target_ids: [],
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) {
cleanupPlans = { ...cleanupPlans, [worker.runtime_id]: payload.plan_after };
}
if (data.workers) {
data.workers.items = data.workers.items.filter(
(item) => !(item.runtime_id === worker.runtime_id && item.worker_id === worker.worker_id),
);
}
statusMessage = `Deleted Worker registry row for ${worker.label}.`;
} catch (error) {
statusMessage = error instanceof Error ? error.message : 'Worker cleanup failed';
} finally {
busyCleanupTarget = null;
}
} }
function workerStatus(worker: Worker): string { function workerStatus(worker: Worker): string {
@ -54,7 +104,7 @@
<div> <div>
<h1 id="workers-heading">Workers</h1> <h1 id="workers-heading">Workers</h1>
<p>Workers running or persisted for this workspace. Pinning only updates Backend retention.</p> <p>Workers running or persisted for this workspace. Pinning only updates Backend retention.</p>
{#if retentionStatus}<p>{retentionStatus}</p>{/if} {#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>
@ -81,6 +131,7 @@
</thead> </thead>
<tbody> <tbody>
{#each data.workers.items as worker} {#each data.workers.items as worker}
{@const cleanup = cleanupCandidate(worker)}
<tr> <tr>
<td> <td>
<strong>{worker.label}</strong> <strong>{worker.label}</strong>
@ -100,6 +151,16 @@
<button type="button" onclick={() => setPinned(worker, !worker.pinned)}> <button type="button" onclick={() => setPinned(worker, !worker.pinned)}>
{worker.pinned ? 'Unpin' : 'Pin'} {worker.pinned ? 'Unpin' : 'Pin'}
</button> </button>
{#if cleanup}
<button
type="button"
disabled={!!cleanup.blocking_reason || busyCleanupTarget === cleanup.target_id}
title={cleanup.blocking_reason ?? cleanup.reason}
onclick={() => deleteWorkerRegistryRow(worker, cleanup)}
>
{busyCleanupTarget === cleanup.target_id ? 'Deleting…' : 'Delete row'}
</button>
{/if}
</td> </td>
</tr> </tr>
{/each} {/each}

View File

@ -1,5 +1,5 @@
import { loadJson, workspaceApiPath } from "$lib/workspace-api/http"; import { loadJson, workspaceApiPath } from "$lib/workspace-api/http";
import type { ListResponse, Worker } from "$lib/workspace-sidebar/types"; import type { ListResponse, RuntimeCleanupPlanResponse, Worker } from "$lib/workspace-sidebar/types";
import type { PageLoad } from "./$types"; import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => { export const load: PageLoad = async ({ fetch, params }) => {
@ -7,10 +7,28 @@ export const load: PageLoad = async ({ fetch, params }) => {
fetch, fetch,
workspaceApiPath(params.workspaceId, "/workers"), workspaceApiPath(params.workspaceId, "/workers"),
); );
const runtimeIds = Array.from(new Set(workers.data?.items.map((worker) => worker.runtime_id) ?? []));
const cleanupPlanEntries = await Promise.all(
runtimeIds.map(async (runtimeId) => {
const cleanupPlan = await loadJson<RuntimeCleanupPlanResponse>(
fetch,
workspaceApiPath(params.workspaceId, `/runtimes/${encodeURIComponent(runtimeId)}/cleanup-plan`),
);
return [runtimeId, cleanupPlan] as const;
}),
);
const cleanupPlans: Record<string, RuntimeCleanupPlanResponse> = {};
const cleanupPlanErrors: Record<string, string> = {};
for (const [runtimeId, cleanupPlan] of cleanupPlanEntries) {
if (cleanupPlan.data) cleanupPlans[runtimeId] = cleanupPlan.data;
if (cleanupPlan.error) cleanupPlanErrors[runtimeId] = cleanupPlan.error;
}
return { return {
workspaceId: params.workspaceId, workspaceId: params.workspaceId,
workers: workers.data, workers: workers.data,
workersError: workers.error, workersError: workers.error,
cleanupPlans,
cleanupPlanErrors,
}; };
}; };