fix: integrate safe Workdir removal recovery

Merge approved T-730 source b1dc8f9f into develop. Resolve the test-registration conflict by preserving both Drive and Workdir-removal tests. Integration root check, Runtime/Server/Worker suites, real Web Drive adapter, generated Workdir contract, Web check/unit/components/build/browser, fmt and diff checks passed.
This commit is contained in:
2026-10-09 17:43:19 +09:00
24 changed files with 4120 additions and 355 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@8.3.2 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts",
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts",
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
"test:browser:empty-workspace": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/empty-workspace.browser.test.ts",
"test:browser:drive": "deno test --config test/drive-browser/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/drive-browser/drive.browser.test.ts",
@@ -7,6 +7,7 @@ import type {
WorkingDirectoryDetailResponse,
WorkingDirectoryListResponse,
WorkingDirectoryOccupancy,
WorkingDirectoryRemovalResponse,
WorkingDirectorySource,
WorkingDirectorySummary,
} from "../../generated/workdir-api";
@@ -63,6 +64,40 @@ export function parseWorkingDirectoryListResponse(
};
}
export function parseWorkingDirectoryRemovalResponse(
value: unknown,
): WorkingDirectoryRemovalResponse {
const record = exactRecord(
value,
new Set([
"working_directory_id",
"disposition",
"retryable",
"failure_category",
]),
"Workdir removal response",
);
const working_directory_id = stringField(record, "working_directory_id");
const category = record.failure_category;
if (
working_directory_id.length > 128 ||
(category != null &&
(typeof category !== "string" || category.length > 128))
) {
throw new Error("Invalid Workdir removal response");
}
return {
working_directory_id,
disposition: enumField(record, "disposition", [
"removed",
"retained",
"attention_required",
]),
retryable: booleanField(record, "retryable", "Workdir removal response"),
failure_category: category as string | null | undefined,
};
}
export function parseWorkingDirectoryDetailResponse(
value: unknown,
): WorkingDirectoryDetailResponse {
@@ -178,13 +213,26 @@ export function parseWorkingDirectorySummary(
function parseWorkingDirectorySource(value: unknown): WorkingDirectorySource {
const source = exactRecord(
value,
new Set(["kind", "repository_key", "grant_id", "grant_permissions", "access", "content_path", "purpose"]),
new Set([
"kind",
"repository_key",
"grant_id",
"grant_permissions",
"access",
"content_path",
"purpose",
]),
"Workdir source",
);
const kind = stringField(source, "kind");
if (kind === "workspace_config") {
if (source.repository_key !== undefined || source.grant_id !== undefined || source.grant_permissions !== undefined) {
throw new Error("Logical config source must not contain repository or External authority");
if (
source.repository_key !== undefined || source.grant_id !== undefined ||
source.grant_permissions !== undefined
) {
throw new Error(
"Logical config source must not contain repository or External authority",
);
}
const access = stringField(source, "access");
if (access !== "read_only" && access !== "read_write") {
@@ -194,10 +242,20 @@ function parseWorkingDirectorySource(value: unknown): WorkingDirectorySource {
if (content_path !== "/workspace-config") {
throw new Error("Logical config must reference its WIP content entrance");
}
return { kind, access, content_path, purpose: stringField(source, "purpose") };
return {
kind,
access,
content_path,
purpose: stringField(source, "purpose"),
};
}
if (source.access !== undefined || source.content_path !== undefined || source.purpose !== undefined) {
throw new Error("Filesystem Workdir source must not contain logical config metadata");
if (
source.access !== undefined || source.content_path !== undefined ||
source.purpose !== undefined
) {
throw new Error(
"Filesystem Workdir source must not contain logical config metadata",
);
}
if (kind === "repository") {
if (
@@ -0,0 +1,167 @@
import type { WorkingDirectoryRemovalResponse } from "#lib/generated/workdir-api.ts";
import type {
CleanupWorkdirCandidate,
WorkingDirectorySummary,
} from "#lib/workspace/sidebar/types.ts";
import { workspaceApiPath } from "#lib/workspace/api/http.ts";
import { parseWorkingDirectoryRemovalResponse } from "#lib/workspace/api/workdirs.ts";
// Only reviewed category codes become public copy. Never render a provider message,
// category verbatim, OS error, or host path (including for unknown future codes).
const causes: Readonly<Record<string, string>> = {
mount_present:
"A mounted filesystem remains inside this Workdir. Ask its resource owner to verify ownership and safely release it before retrying. Unknown or shared mounts must not be forcibly unmounted.",
mount_check_unavailable:
"The Runtime could not check for mounted filesystems. Restore mount inspection on the Runtime before retrying.",
permission_denied:
"Removal permission was denied. Ask the Workspace or Runtime administrator to verify the required access; do not bypass protection with a forced deletion.",
resource_busy:
"A resource is still in use. Ask its owner to stop using it and release it safely before retrying.",
storage_unavailable:
"Runtime storage is unavailable. Restore storage access and capacity before retrying.",
ownership_unknown:
"Resource ownership could not be verified. Ask the Runtime administrator to verify ownership before any resource is released or removed.",
changes_present:
"This Workdir has changes. Preserve or clean those changes before requesting removal again.",
changes_unknown:
"Current changes could not be checked. Restore access so the Runtime can verify change protection before removal.",
provider_cleanup_failed:
"The provider could not finish removal. Ask the Runtime administrator to inspect the correlated removal diagnostics and resolve the cause before retrying.",
provider_observation_unknown:
"The provider could not confirm the remaining Workdir. Restore provider access so the next removal can recheck its current contents.",
provider_cleanup_outcome_unknown:
"The provider has not confirmed deletion. Restore provider access and retry the same removal request to recheck the remaining target. The Workdir stays listed until deletion is confirmed.",
authority_invalid:
"The removal authority could not be verified. Ask the Workspace administrator to verify the Workdir target and owning Runtime before requesting removal again.",
provider_identity_changed:
"The provider target identity changed. Ask the Runtime administrator to verify the target; do not delete a replacement resource.",
runtime_unavailable:
"The Runtime is unavailable. Restore its connection before retrying removal.",
provider_unavailable:
"The Workdir provider is unavailable. Restore its connection before retrying removal.",
unsupported_target:
"This target does not support ordinary removal. Ask its resource owner for the supported cleanup operation.",
blocked_by_live_authority:
"This Workdir has an active attachment, reservation, or hold. Release it through its owning operation before requesting removal again.",
dirty_or_unknown:
"Current change protection prevents removal. Preserve changes or restore change inspection before requesting removal again.",
authority_changed:
"Removal authority or current usage changed. Refresh the inventory and resolve current attachments, reservations, or holds before requesting removal again.",
};
const unknownCause =
"Removal could not be confirmed. Check the Runtime's correlated removal diagnostics and resolve the cause before requesting removal again. The Workdir remains listed until deletion is confirmed.";
export function removalCause(category: string | null | undefined): string {
return category != null && Object.hasOwn(causes, category)
? causes[category]
: unknownCause;
}
export function canRemoveWorkdir(
workdir: WorkingDirectorySummary,
candidate: CleanupWorkdirCandidate | undefined,
): boolean {
return Boolean(
candidate && !candidate.blocking_reason && !candidate.running_linked &&
!candidate.pinned_linked &&
(candidate.action === "workdir_clean_cleanup" ||
candidate.action === "workdir_record_delete") &&
workdir.cleanliness !== "dirty" && !workdir.occupied_by,
);
}
export function removalGuard(
workdir: WorkingDirectorySummary,
candidate: CleanupWorkdirCandidate | undefined,
): string {
if (
workdir.occupied_by || candidate?.running_linked ||
candidate?.pinned_linked || candidate?.blocking_reason
) {
return "An attachment, reservation, or hold protects this Workdir. Release it through its owning operation, then refresh.";
}
if (
workdir.cleanliness === "dirty" ||
candidate?.action === "workdir_dirty_discard"
) {
return "Changes are protected. Preserve or clean them, then refresh before deletion.";
}
return "Removal eligibility is unavailable. Refresh before deletion.";
}
export class WorkdirRemovalError extends Error {
constructor(readonly category?: string) {
super(removalCause(category));
}
}
// Read only a small public DTO. Discard HTTP error bodies without interpreting or
// displaying them, and drain small bodies rather than aborting completed requests.
async function removalBody(response: Response): Promise<string> {
const reader = response.body?.getReader();
if (!reader) return "";
const chunks: Uint8Array[] = [];
let bytes = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
bytes += value.byteLength;
if (bytes > 4096) {
await reader.cancel();
throw new WorkdirRemovalError();
}
chunks.push(value);
}
const body = new Uint8Array(bytes);
let offset = 0;
for (const chunk of chunks) {
body.set(chunk, offset);
offset += chunk.byteLength;
}
return new TextDecoder().decode(body);
} finally {
reader.releaseLock();
}
}
export async function removeWorkdir(
fetchFn: typeof fetch,
workspaceId: string,
runtimeId: string,
workdirId: string,
): Promise<WorkingDirectoryRemovalResponse> {
const response = await fetchFn(
workspaceApiPath(
workspaceId,
`/runtimes/${encodeURIComponent(runtimeId)}/working-directories/${
encodeURIComponent(workdirId)
}`,
),
{
method: "DELETE",
headers: { "content-type": "application/json" },
// An ordinary repeated request uses the existing durable Backend removal authority.
body: JSON.stringify({ reason: "User requested Workdir deletion" }),
},
);
if (!response.ok) {
await removalBody(response).catch(() => undefined);
throw new WorkdirRemovalError(
response.status === 403
? "permission_denied"
: response.status === 409
? "authority_changed"
: response.status === 503
? "provider_unavailable"
: undefined,
);
}
const result = parseWorkingDirectoryRemovalResponse(
JSON.parse(await removalBody(response)),
);
if (result.working_directory_id !== workdirId) {
throw new WorkdirRemovalError();
}
return result;
}
@@ -1,102 +1,89 @@
<script lang="ts">
import { pushWorkspaceAlert } from '#lib/workspace/alerts/store.ts';
import { workspaceApiPath } from '#lib/workspace/api/http.ts';
import { parseRuntimeCleanupExecution } from '#lib/workspace/api/runtime-workers.ts';
import { loadJson, workspaceApiPath } from '#lib/workspace/api/http.ts';
import { parseRuntimeCleanupPlan } from '#lib/workspace/api/runtime-workers.ts';
import { parseWorkingDirectoryListResponse } from '#lib/workspace/api/workdirs.ts';
import { canRemoveWorkdir, removalCause, removalGuard, removeWorkdir, WorkdirRemovalError } from '#lib/workspace/settings/workdir-removal.ts';
import { formatCurrentWorkdirRevision } from '#lib/workspace/settings/workdir-revision.ts';
import type {
CleanupWorkdirCandidate,
RuntimeCleanupPlanResponse,
WorkingDirectorySummary,
} from '#lib/workspace/sidebar/types.ts';
import type { CleanupWorkdirCandidate, RuntimeCleanupPlanResponse, WorkingDirectorySummary } from '#lib/workspace/sidebar/types.ts';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let cleanupBusyTarget = $state<string | null>(null);
let refreshing = $state(false);
let refreshError = $state<string | null>(null);
let cleanupPlan = $state<RuntimeCleanupPlanResponse | null>(null);
let workdirs = $state<WorkingDirectorySummary[]>([]);
let runtimeLabel = $derived(
data.runtimes?.items.find((runtime) => runtime.runtime_id === data.runtimeId)?.label ?? data.runtimeId,
);
let inventoryLoaded = $state(false);
let feedback = $state<{ id: string; message: string; retryable: boolean; removed: boolean } | null>(null);
let runtimeLabel = $derived(data.runtimes?.items.find((runtime) => runtime.runtime_id === data.runtimeId)?.label ?? data.runtimeId);
let canManage = $derived(data.workspace?.permissions.manage_runtimes === true);
let cleanupCandidates = $derived(cleanupPlan?.workdirs ?? []);
$effect(() => {
cleanupPlan = data.cleanupPlan ?? null;
workdirs = data.workdirs?.items ?? [];
inventoryLoaded = Boolean(data.workdirs);
refreshError = data.workdirsError || data.cleanupPlanError ? 'Inventory or removal eligibility is unavailable. Refresh before deletion.' : null;
feedback = null;
});
function repositoryKey(workdir: WorkingDirectorySummary): string | null {
return workdir.source.kind === 'repository' ? workdir.source.repository_key : null;
}
function repositoryProvider(workdir: WorkingDirectorySummary): string | null {
const key = repositoryKey(workdir);
return key
? data.repositories?.items.find((repository) => repository.repository_key === key)?.provider ?? null
: null;
}
function currentRevision(workdir: WorkingDirectorySummary): string {
return formatCurrentWorkdirRevision(workdir, repositoryProvider(workdir));
const key = repositoryKey(workdir);
const provider = key ? data.repositories?.items.find((repository) => repository.repository_key === key)?.provider ?? null : null;
return formatCurrentWorkdirRevision(workdir, provider);
}
function cleanupCandidate(workdir: WorkingDirectorySummary): CleanupWorkdirCandidate | undefined {
return cleanupCandidates.find((candidate) => candidate.workdir_id === workdir.working_directory_id);
}
function isDeleteDisabled(candidate: CleanupWorkdirCandidate): boolean {
return Boolean(candidate.blocking_reason) || candidate.action === 'workdir_dirty_discard' || cleanupBusyTarget !== null;
function isDeleteDisabled(workdir: WorkingDirectorySummary): boolean {
return !canManage || Boolean(refreshError) || refreshing || cleanupBusyTarget !== null || !canRemoveWorkdir(workdir, cleanupCandidate(workdir));
}
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;
async function refreshInventory(): Promise<void> {
refreshing = true;
// Capture route authority before awaiting; a departed route must not receive this result.
const workspaceId = data.workspaceId;
const runtimeId = data.runtimeId;
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: cleanupPlan.revision,
expected_plan_digest: cleanupPlan.digest,
worker_target_ids: [],
workdir_target_ids: [candidate.target_id],
confirm_dirty_discard_target_ids: [],
}),
},
);
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(errorMessage(payload, response.statusText));
const execution = parseRuntimeCleanupExecution(payload);
cleanupPlan = execution.plan_after;
const result = execution.results.find((entry) => entry.target_id === candidate.target_id);
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) {
pushWorkspaceAlert('error', error instanceof Error ? error.message : 'Workdir deletion failed', {
title: 'Workdir deletion failed',
});
const base = `/runtimes/${encodeURIComponent(runtimeId)}`;
const [inventory, plan] = await Promise.all([
loadJson(fetch, workspaceApiPath(workspaceId, `${base}/working-directories`), undefined, parseWorkingDirectoryListResponse),
loadJson(fetch, workspaceApiPath(workspaceId, `${base}/cleanup-plan`), undefined, parseRuntimeCleanupPlan),
]);
if (workspaceId !== data.workspaceId || runtimeId !== data.runtimeId) return;
// Keep the last observed list on failure, but never authorize another deletion with stale eligibility.
if (inventory.data) { workdirs = inventory.data.items; inventoryLoaded = true; }
cleanupPlan = plan.data;
refreshError = inventory.error || plan.error ? 'Inventory or removal eligibility could not be refreshed. Refresh before another deletion.' : null;
} finally {
refreshing = false;
}
}
async function deleteWorkdir(workdir: WorkingDirectorySummary): Promise<void> {
if (isDeleteDisabled(workdir)) return;
const workspaceId = data.workspaceId;
const runtimeId = data.runtimeId;
cleanupBusyTarget = workdir.working_directory_id;
feedback = null;
try {
const result = await removeWorkdir(fetch, workspaceId, runtimeId, workdir.working_directory_id);
if (workspaceId !== data.workspaceId || runtimeId !== data.runtimeId) return;
feedback = {
id: workdir.working_directory_id,
message: result.disposition === 'removed' ? 'Deletion confirmed.' : removalCause(result.failure_category),
retryable: result.retryable && result.disposition !== 'removed',
removed: result.disposition === 'removed',
};
} catch (error) {
if (workspaceId !== data.workspaceId || runtimeId !== data.runtimeId) return;
feedback = { id: workdir.working_directory_id, message: error instanceof WorkdirRemovalError ? error.message : removalCause(null), retryable: false, removed: false };
} finally {
// Even a lost/error response can follow a real provider side effect. Re-read inventory AND guards.
if (workspaceId === data.workspaceId && runtimeId === data.runtimeId) await refreshInventory();
cleanupBusyTarget = null;
}
}
@@ -107,76 +94,55 @@
<meta name="description" content="Runtime workdirs" />
</svelte:head>
<section class="workdirs-page" aria-labelledby="workdirs-heading">
<section class="workdirs-page" aria-labelledby="workdirs-heading" aria-busy={refreshing}>
<header class="page-header-row">
<div>
<p class="breadcrumb"><a href={`/w/${data.workspaceId}/settings/runtimes`}>Runtimes</a> / {runtimeLabel}</p>
<h1 id="workdirs-heading">Workdirs</h1>
<p>Workdirs owned by <code>{data.runtimeId}</code>.</p>
{#if data.cleanupPlanError}<p class="section-state error">{data.cleanupPlanError}</p>{/if}
</div>
<button class="workdir-action" type="button" disabled={refreshing || cleanupBusyTarget !== null} onclick={refreshInventory}>{refreshing ? 'Refreshing…' : 'Refresh'}</button>
</header>
{#if data.workdirsError}
<p class="section-state error">{data.workdirsError}</p>
{:else if !data.workdirs}
<p class="section-state">Loading workdirs…</p>
{#if feedback}
<div class="removal-feedback" class:error={!feedback.removed} role={feedback.removed ? 'status' : 'alert'}>
<p><strong>{feedback.removed ? 'Workdir deleted' : 'Workdir not deleted'}</strong> · <code>{feedback.id}</code></p>
<p>{feedback.message}</p>
{#if feedback.retryable}<p>After resolving this cause, retry Delete. Current safety conditions will be rechecked.</p>{/if}
</div>
{/if}
{#if refreshError}<p class="section-state error" role="alert">{refreshError}</p>{/if}
{#if !inventoryLoaded}
<p class="section-state">{data.workdirsError ? 'Workdir inventory is unavailable.' : 'Loading workdirs…'}</p>
{:else if workdirs.length === 0}
<p class="section-state">No workdirs are visible for this Runtime.</p>
{:else}
<div class="table-wrap">
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Horizontal inventory scroll must be keyboard-focusable.) -->
<div class="table-wrap" role="region" aria-label="Workdir inventory" tabindex="0">
<table class="workdirs-table">
<thead>
<tr>
<th>Workdir</th>
<th>Repository</th>
<th>Revision</th>
<th>Status</th>
<th>Cleanliness</th>
<th>Occupied by</th>
<th>Action</th>
</tr>
</thead>
<thead><tr><th>Workdir</th><th>Repository</th><th>Revision</th><th>Status</th><th>Cleanliness</th><th>Occupied by</th></tr></thead>
<tbody>
{#each workdirs as workdir}
{#each workdirs as workdir (workdir.working_directory_id)}
{@const cleanup = cleanupCandidate(workdir)}
<tr>
<td>
<td class="workdir-identity">
<span>{workdir.display_name ?? '—'}</span>
<small><code>{workdir.working_directory_id}</code></small>
{#if canManage}
<button class="workdir-action danger" type="button" disabled={isDeleteDisabled(workdir)}
aria-label={`${feedback?.id === workdir.working_directory_id && feedback.retryable ? 'Retry Delete' : 'Delete'} ${workdir.working_directory_id}`}
onclick={() => deleteWorkdir(workdir)}>
{cleanupBusyTarget === workdir.working_directory_id ? 'Deleting…' : feedback?.id === workdir.working_directory_id && feedback.retryable ? 'Retry Delete' : 'Delete'}
</button>
{#if !canRemoveWorkdir(workdir, cleanup)}<p class="removal-guard">{removalGuard(workdir, cleanup)}</p>{/if}
{/if}
</td>
<td>{repositoryKey(workdir) ?? 'External'}</td>
<td><code>{currentRevision(workdir)}</code></td>
<td>{workdir.status}</td>
<td>{workdir.cleanliness ?? 'unknown'}</td>
<td>
{#if workdir.occupied_by}
<span>{workdir.occupied_by.display_name}</span>
<small>{workdir.occupied_by.runtime_id}:{workdir.occupied_by.worker_id}</small>
{:else}
<span class="muted">—</span>
{/if}
</td>
<td>
{#if cleanup}
<button
class="icon-action danger"
type="button"
disabled={isDeleteDisabled(cleanup)}
aria-label={`Delete ${workdir.working_directory_id}`}
title={cleanup.action === 'workdir_dirty_discard' ? 'Dirty Workdirs must be cleaned before deletion' : (cleanup.blocking_reason ?? cleanup.reason)}
onclick={() => deleteWorkdir(workdir, 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>
{:else}
<span class="muted">—</span>
{/if}
</td>
<td>{#if workdir.occupied_by}<span>{workdir.occupied_by.display_name}</span><small>{workdir.occupied_by.runtime_id}:{workdir.occupied_by.worker_id}</small>{:else}<span class="muted">—</span>{/if}</td>
</tr>
{/each}
</tbody>
@@ -186,53 +152,20 @@
</section>
<style>
.icon-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
border: 1px solid var(--line);
border-radius: 0.5rem;
background: var(--bg-raised);
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);
}
}
.workdirs-page { min-width: 0; }
.page-header-row { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: start; gap: var(--space-3); }
.table-wrap { max-width: 100%; overflow-x: auto; }
.workdirs-table { width: 100%; min-width: 54rem; border-collapse: collapse; }
th, td { padding: var(--space-3); text-align: left; vertical-align: top; border-bottom: 1px solid var(--line); }
th { color: var(--text-muted); font-weight: 500; }
.workdir-identity { width: 18rem; min-width: 18rem; overflow-wrap: anywhere; }
td small { display: block; color: var(--text-muted); margin-block: var(--space-1); }
.workdir-action { display: inline-flex; align-items: center; justify-content: center; padding: var(--space-2) var(--space-3); border: 1px solid var(--line); border-radius: var(--radius-soft); background: var(--bg-raised); color: var(--text); cursor: pointer; white-space: nowrap; }
.workdir-action:hover:not(:disabled) { background: var(--interactive-hover); }
.workdir-action.danger { color: var(--danger); }
.workdir-action:disabled { cursor: not-allowed; opacity: 0.45; }
.removal-feedback { margin-block: var(--space-4); overflow-wrap: anywhere; }
.removal-feedback p { margin-block: var(--space-2); }
.removal-feedback.error strong { color: var(--danger); }
.removal-guard { margin-block: var(--space-2) 0; color: var(--text-muted); }
</style>
+248
View File
@@ -0,0 +1,248 @@
declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void;
};
import { parseWorkingDirectoryRemovalResponse } from "../src/lib/workspace/api/workdirs.ts";
import {
canRemoveWorkdir,
removalCause,
removeWorkdir,
} from "../src/lib/workspace/settings/workdir-removal.ts";
import type {
CleanupWorkdirCandidate,
WorkingDirectorySummary,
} from "../src/lib/workspace/sidebar/types.ts";
function assert(value: unknown, message = "assertion failed"): asserts value {
if (!value) throw new Error(message);
}
Deno.test("removal wire boundary rejects malformed results and mismatched identities", async () => {
for (
const extra of [
{ retryable: "true" },
{ disposition: "forgotten" },
{ failure_category: {} },
{ failure_category: "x".repeat(129) },
{ working_directory_id: "x".repeat(129) },
{ unexpected: true },
]
) {
let rejected = false;
try {
parseWorkingDirectoryRemovalResponse({
working_directory_id: "dir",
disposition: "retained",
retryable: true,
...extra,
});
} catch {
rejected = true;
}
assert(
rejected,
`accepted malformed removal result: ${JSON.stringify(extra)}`,
);
}
let rejected = false;
try {
await removeWorkdir(
(() =>
Promise.resolve(
Response.json({
working_directory_id: "other",
disposition: "removed",
retryable: false,
}),
)) as typeof fetch,
"workspace",
"runtime",
"dir",
);
} catch {
rejected = true;
}
assert(rejected, "accepted removal evidence for a different Workdir");
});
Deno.test("removal response size is bounded before decoding provider content", async () => {
let rejected = false;
try {
await removeWorkdir(
(() => Promise.resolve(new Response("x".repeat(4097)))) as typeof fetch,
"workspace",
"runtime",
"dir",
);
} catch {
rejected = true;
}
assert(rejected, "accepted an oversized removal response");
});
Deno.test("removal causes give bounded actions without echoing untrusted provider details", () => {
const categories = [
"mount_present",
"mount_check_unavailable",
"permission_denied",
"resource_busy",
"storage_unavailable",
"ownership_unknown",
"changes_present",
"changes_unknown",
"provider_cleanup_failed",
"provider_observation_unknown",
"provider_cleanup_outcome_unknown",
"authority_invalid",
"provider_identity_changed",
"runtime_unavailable",
"provider_unavailable",
"unsupported_target",
"blocked_by_live_authority",
"dirty_or_unknown",
"authority_changed",
];
for (const category of categories) {
const text = removalCause(category);
assert(text !== removalCause(null), `missing category ${category}`);
assert(
text.length < 400 && !text.includes("/"),
`unbounded or pathful category ${category}`,
);
}
for (
const untrusted of [
"/private/host/path",
"token=synthetic-secret",
"toString",
"__proto__",
"future_category",
"x".repeat(10000),
]
) {
const text = removalCause(untrusted);
assert(
text === removalCause(null) && !text.includes(untrusted),
"echoed unknown category",
);
}
});
Deno.test("pending unknown cleanup permits ordinary removal while current dirty and live guards remain protected", () => {
const workdir = {
working_directory_id: "dir",
status: "cleanup_pending",
cleanliness: "unknown",
source: { kind: "repository", repository_key: "main" },
materializer_kind: "runtime_git_clone",
} as WorkingDirectorySummary;
const candidate = {
action: "workdir_clean_cleanup",
blocking_reason: null,
running_linked: false,
pinned_linked: false,
} as CleanupWorkdirCandidate;
assert(canRemoveWorkdir(workdir, candidate));
for (
const guard of [
{ action: "workdir_dirty_discard" },
{ blocking_reason: "protected" },
{ running_linked: true },
{ pinned_linked: true },
]
) {
assert(
!canRemoveWorkdir(
workdir,
{ ...candidate, ...guard } as CleanupWorkdirCandidate,
),
);
}
assert(!canRemoveWorkdir({ ...workdir, cleanliness: "dirty" }, candidate));
assert(
canRemoveWorkdir(workdir, {
...candidate,
action: "workdir_record_delete",
}),
);
assert(!canRemoveWorkdir(workdir, { ...candidate, action: "worker_delete" }));
assert(!canRemoveWorkdir(workdir, undefined));
assert(
!canRemoveWorkdir({
...workdir,
occupied_by: {
worker_id: "worker",
runtime_id: "runtime",
display_name: "Worker",
linked_at: "now",
},
}, candidate),
);
});
Deno.test("ordinary retries send the same scoped DELETE body and expose only allowlisted HTTP causes", async () => {
const requests: { path: string; body: string; method: string }[] = [];
const fetchFn = (async (path: RequestInfo | URL, init?: RequestInit) => {
requests.push({
path: String(path),
body: String(init?.body),
method: String(init?.method),
});
return Response.json({
working_directory_id: "dir/x",
disposition: "attention_required",
retryable: true,
failure_category: "mount_present",
});
}) as typeof fetch;
for (let i = 0; i < 2; i++) {
const result = await removeWorkdir(
fetchFn,
"workspace/x",
"runtime/x",
"dir/x",
);
assert(result.retryable && result.failure_category === "mount_present");
}
assert(
JSON.stringify(requests[0]) === JSON.stringify(requests[1]),
"retry changed ordinary deletion request",
);
assert(
requests[0].path ===
"/api/w/workspace%2Fx/runtimes/runtime%2Fx/working-directories/dir%2Fx",
);
assert(
requests[0].method === "DELETE" &&
requests[0].body ===
JSON.stringify({ reason: "User requested Workdir deletion" }),
);
for (
const [status, category] of [
[403, "permission_denied"],
[409, "authority_changed"],
[503, "provider_unavailable"],
[500, null],
] as const
) {
let message = "";
try {
await removeWorkdir(
(() =>
Promise.resolve(
Response.json({ message: "/private/path token=synthetic-secret" }, {
status,
}),
)) as typeof fetch,
"workspace",
"runtime",
"dir",
);
} catch (error) {
message = error instanceof Error ? error.message : String(error);
}
assert(
message === removalCause(category),
`HTTP ${status} leaked provider text`,
);
}
});