feat: add manual worker workdir cleanup
This commit is contained in:
@@ -85,6 +85,8 @@ export type Worker = {
|
||||
workspace: { visibility: string; identity: string };
|
||||
state: string;
|
||||
status: string;
|
||||
pinned?: boolean;
|
||||
retention_state?: string;
|
||||
last_seen_at?: string | null;
|
||||
implementation: { kind: string; display_hint: string };
|
||||
capabilities: WorkerCapabilities;
|
||||
@@ -145,6 +147,70 @@ export type BrowserWorkingDirectoryListResponse = {
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type CleanupTargetKind =
|
||||
| "worker_delete"
|
||||
| "workdir_clean_cleanup"
|
||||
| "workdir_dirty_discard"
|
||||
| "workdir_record_delete";
|
||||
|
||||
export type CleanupWorkerCandidate = {
|
||||
target_id: string;
|
||||
action: CleanupTargetKind;
|
||||
worker_id: string;
|
||||
runtime_worker_id: string;
|
||||
runtime_id: string;
|
||||
reason: string;
|
||||
blocking_reason?: string | null;
|
||||
pinned: boolean;
|
||||
retention_state: string;
|
||||
lifecycle_state: string;
|
||||
linked_workdir_ids: string[];
|
||||
running_linked: boolean;
|
||||
estimated_reclaim_bytes?: number | null;
|
||||
};
|
||||
|
||||
export type CleanupWorkdirCandidate = {
|
||||
target_id: string;
|
||||
action: CleanupTargetKind;
|
||||
workdir_id: string;
|
||||
runtime_id: string;
|
||||
repository_id: string;
|
||||
reason: string;
|
||||
blocking_reason?: string | null;
|
||||
linked_worker_ids: string[];
|
||||
linked_running_worker_ids: string[];
|
||||
running_linked: boolean;
|
||||
pinned_linked: boolean;
|
||||
file_status: string;
|
||||
cleanliness: string;
|
||||
estimated_reclaim_bytes?: number | null;
|
||||
};
|
||||
|
||||
export type RuntimeCleanupPlanResponse = {
|
||||
workspace_id: string;
|
||||
runtime_id: string;
|
||||
generated_at: string;
|
||||
revision: string;
|
||||
digest: string;
|
||||
workers: CleanupWorkerCandidate[];
|
||||
workdirs: CleanupWorkdirCandidate[];
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type RuntimeCleanupExecutionResponse = {
|
||||
workspace_id: string;
|
||||
runtime_id: string;
|
||||
executed_at: string;
|
||||
results: {
|
||||
target_id: string;
|
||||
action: CleanupTargetKind;
|
||||
status: string;
|
||||
message: string;
|
||||
}[];
|
||||
plan_after: RuntimeCleanupPlanResponse;
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type BrowserWorkerWorkingDirectorySelection = {
|
||||
working_directory_id: string;
|
||||
relative_cwd?: string | null;
|
||||
|
||||
+130
-1
@@ -1,8 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { WorkingDirectorySummary } from '$lib/workspace-sidebar/types';
|
||||
import { workspaceApiPath } from '$lib/workspace-api/http';
|
||||
import type { CleanupWorkdirCandidate, WorkingDirectorySummary } from '$lib/workspace-sidebar/types';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
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 cleanupBusy = $state(false);
|
||||
let cleanupCandidates = $derived(data.cleanupPlan?.workdirs ?? []);
|
||||
let workerCleanupCandidates = $derived(data.cleanupPlan?.workers ?? []);
|
||||
let runtimeLabel = $derived(
|
||||
data.runtimes?.items.find((runtime) => runtime.runtime_id === data.runtimeId)?.label ?? data.runtimeId,
|
||||
);
|
||||
@@ -14,6 +22,65 @@
|
||||
function selectorLabel(workdir: WorkingDirectorySummary): string {
|
||||
return workdir.requested_selector ?? 'HEAD';
|
||||
}
|
||||
|
||||
function cleanupLabel(candidate: CleanupWorkdirCandidate): string {
|
||||
if (candidate.action === 'workdir_dirty_discard') return 'Discard dirty workdir';
|
||||
if (candidate.action === 'workdir_record_delete') return 'Delete missing/removed record';
|
||||
return 'Clean up workdir';
|
||||
}
|
||||
|
||||
function toggleSelected(targetId: string): void {
|
||||
const next = new Set(selectedCleanupTargets);
|
||||
if (next.has(targetId)) next.delete(targetId);
|
||||
else next.add(targetId);
|
||||
selectedCleanupTargets = next;
|
||||
}
|
||||
|
||||
function toggleWorkerSelected(targetId: string): void {
|
||||
const next = new Set(selectedWorkerCleanupTargets);
|
||||
if (next.has(targetId)) next.delete(targetId);
|
||||
else next.add(targetId);
|
||||
selectedWorkerCleanupTargets = next;
|
||||
}
|
||||
|
||||
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;
|
||||
try {
|
||||
const response = await fetch(
|
||||
workspaceApiPath(data.workspaceId, `/runtimes/${encodeURIComponent(data.runtimeId)}/cleanup-executions`),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
expected_plan_revision: data.cleanupPlan.revision,
|
||||
expected_plan_digest: data.cleanupPlan.digest,
|
||||
worker_target_ids: Array.from(selectedWorkerCleanupTargets),
|
||||
workdir_target_ids: Array.from(selectedCleanupTargets),
|
||||
confirm_dirty_discard_target_ids: Array.from(confirmedDirtyTargets),
|
||||
}),
|
||||
},
|
||||
);
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(payload?.message ?? payload?.error ?? response.statusText);
|
||||
cleanupStatus = `Executed ${payload?.results?.length ?? 0} cleanup action(s). Refresh to see the latest plan.`;
|
||||
selectedCleanupTargets = new Set();
|
||||
selectedWorkerCleanupTargets = new Set();
|
||||
confirmedDirtyTargets = new Set();
|
||||
} catch (error) {
|
||||
cleanupStatus = error instanceof Error ? error.message : 'Cleanup failed';
|
||||
} finally {
|
||||
cleanupBusy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -37,6 +104,68 @@
|
||||
{:else if data.workdirs.items.length === 0}
|
||||
<p class="section-state">No workdirs are visible for this Runtime.</p>
|
||||
{: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.lifecycle_state}; {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 dirty 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">
|
||||
<table class="workdirs-table">
|
||||
<thead>
|
||||
|
||||
@@ -3,12 +3,13 @@ import type {
|
||||
BrowserWorkingDirectoryListResponse,
|
||||
ListResponse,
|
||||
Runtime,
|
||||
RuntimeCleanupPlanResponse,
|
||||
} from "$lib/workspace-sidebar/types";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = async ({ fetch, params }) => {
|
||||
const runtimeId = params.runtimeId;
|
||||
const [runtimes, workdirs] = await Promise.all([
|
||||
const [runtimes, workdirs, cleanupPlan] = await Promise.all([
|
||||
loadJson<ListResponse<Runtime>>(fetch, workspaceApiPath(params.workspaceId, "/runtimes")),
|
||||
loadJson<BrowserWorkingDirectoryListResponse>(
|
||||
fetch,
|
||||
@@ -17,6 +18,10 @@ export const load: PageLoad = async ({ fetch, params }) => {
|
||||
`/runtimes/${encodeURIComponent(runtimeId)}/working-directories`,
|
||||
),
|
||||
),
|
||||
loadJson<RuntimeCleanupPlanResponse>(
|
||||
fetch,
|
||||
workspaceApiPath(params.workspaceId, `/runtimes/${encodeURIComponent(runtimeId)}/cleanup-plan`),
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -26,5 +31,7 @@ export const load: PageLoad = async ({ fetch, params }) => {
|
||||
runtimesError: runtimes.error,
|
||||
workdirs: workdirs.data,
|
||||
workdirsError: workdirs.error,
|
||||
cleanupPlan: cleanupPlan.data,
|
||||
cleanupPlanError: cleanupPlan.error,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,9 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { workspaceApiPath } from '$lib/workspace-api/http';
|
||||
import { workerConsoleHref } from '$lib/workspace-console/model';
|
||||
import type { Worker } from '$lib/workspace-sidebar/types';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let retentionStatus = $state<string | null>(null);
|
||||
|
||||
async function setPinned(worker: Worker, pinned: boolean): Promise<void> {
|
||||
retentionStatus = null;
|
||||
const response = await fetch(
|
||||
workspaceApiPath(
|
||||
data.workspaceId,
|
||||
`/runtimes/${encodeURIComponent(worker.runtime_id)}/workers/${encodeURIComponent(worker.worker_id)}/pin`,
|
||||
),
|
||||
{ method: pinned ? 'PUT' : 'DELETE' },
|
||||
);
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
retentionStatus = payload?.message ?? payload?.error ?? response.statusText;
|
||||
return;
|
||||
}
|
||||
worker.pinned = Boolean(payload?.pinned);
|
||||
worker.retention_state = payload?.retention_state ?? (worker.pinned ? 'pinned' : 'normal');
|
||||
retentionStatus = `${worker.label} ${worker.pinned ? 'pinned' : 'unpinned'}.`;
|
||||
}
|
||||
|
||||
function workerStatus(worker: Worker): string {
|
||||
return `${worker.state} · ${worker.status}`;
|
||||
@@ -31,7 +52,8 @@
|
||||
<header class="workers-page-header">
|
||||
<div>
|
||||
<h1 id="workers-heading">Workers</h1>
|
||||
<p>Workers running or persisted for this workspace.</p>
|
||||
<p>Workers running or persisted for this workspace. Pinning only updates Backend retention.</p>
|
||||
{#if retentionStatus}<p>{retentionStatus}</p>{/if}
|
||||
</div>
|
||||
<a class="section-action" href={`/w/${data.workspaceId}/workers/new`}>New Worker</a>
|
||||
</header>
|
||||
@@ -51,6 +73,7 @@
|
||||
<th>Runtime</th>
|
||||
<th>Profile</th>
|
||||
<th>Status</th>
|
||||
<th>Retention</th>
|
||||
<th>Workdir</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
@@ -65,9 +88,13 @@
|
||||
<td><code>{worker.runtime_id}</code></td>
|
||||
<td>{workerProfile(worker)}</td>
|
||||
<td>{workerStatus(worker)}</td>
|
||||
<td><span class="pill {worker.pinned ? 'success' : 'muted'}">{worker.retention_state ?? 'normal'}</span></td>
|
||||
<td>{workerDirectory(worker)}</td>
|
||||
<td>
|
||||
<a class="inline-link" href={workerConsoleHref(worker, data.workspaceId)}>Open Console</a>
|
||||
<button type="button" onclick={() => setPinned(worker, !worker.pinned)}>
|
||||
{worker.pinned ? 'Unpin' : 'Pin'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
|
||||
Reference in New Issue
Block a user