merge: orchestration

This commit is contained in:
2026-07-11 04:52:53 +09:00
17 changed files with 3182 additions and 67 deletions
@@ -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;
@@ -124,6 +126,7 @@ export type WorkingDirectorySummary = {
resolved_commit: string;
resolved_tree?: string | null;
status: string;
management_kind?: "backend_managed" | "runtime_unmanaged" | string | null;
cleanup_policy: string;
cleanup_target: {
kind: string;
@@ -144,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;
@@ -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,67 @@
function selectorLabel(workdir: WorkingDirectorySummary): string {
return workdir.requested_selector ?? 'HEAD';
}
function cleanupLabel(candidate: CleanupWorkdirCandidate): string {
if (candidate.action === 'workdir_dirty_discard') {
return candidate.cleanliness === 'dirty' ? 'Discard dirty workdir' : 'Discard unknown-state workdir';
}
if (candidate.action === 'workdir_record_delete') return 'Delete missing/removed record';
return 'Clean up verified-clean 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 +106,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 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}