From c889c9a68d59452de73fd2924ce7556f9ea451c8 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 9 Oct 2026 16:35:26 +0900 Subject: [PATCH] fix: show Workdir removal causes and refresh ordinary retries --- tools/web-ux/browser-tests/workdir_fixture.ts | 140 ++++++ .../workdir_removal_shell_test.ts | 456 ++++++++++++++++++ .../workspace_home_fixture_server.ts | 4 + tools/web-ux/deno.json | 2 +- .../scenarios/workdir-removal-dark.json | 117 +++++ .../scenarios/workdir-removal-light.json | 81 ++++ web/workspace/deno.json | 2 +- .../src/lib/workspace/api/workdirs.ts | 70 ++- .../lib/workspace/settings/workdir-removal.ts | 167 +++++++ .../[runtimeId]/workdirs/+page.svelte | 271 ++++------- web/workspace/tests/workdir-removal.test.ts | 248 ++++++++++ 11 files changed, 1381 insertions(+), 177 deletions(-) create mode 100644 tools/web-ux/browser-tests/workdir_fixture.ts create mode 100644 tools/web-ux/browser-tests/workdir_removal_shell_test.ts create mode 100644 tools/web-ux/scenarios/workdir-removal-dark.json create mode 100644 tools/web-ux/scenarios/workdir-removal-light.json create mode 100644 web/workspace/src/lib/workspace/settings/workdir-removal.ts create mode 100644 web/workspace/tests/workdir-removal.test.ts diff --git a/tools/web-ux/browser-tests/workdir_fixture.ts b/tools/web-ux/browser-tests/workdir_fixture.ts new file mode 100644 index 000000000..4e3597870 --- /dev/null +++ b/tools/web-ux/browser-tests/workdir_fixture.ts @@ -0,0 +1,140 @@ +// Synthetic API boundary only; the production route and shell own all rendering. +export function workdirFixture() { + let mountReleased = false; + const deleted = new Set(); + const requests: { + workspace: string; + method: string; + path: string; + body?: unknown; + }[] = []; + const summary = ( + id: string, + status = "active", + cleanliness = "clean", + occupied = false, + ) => ({ + working_directory_id: id, + display_name: id === "retry-dir" ? "Cleanup retry — distributed development checkout" : id, + source: { kind: "repository", repository_key: "main" }, + materializer_kind: "runtime_git_clone", + status, + cleanliness, + current_ref: "a".repeat(40), + current_selector: "develop", + occupied_by: occupied + ? { + runtime_id: "fixture-runtime", + worker_id: "worker-1", + display_name: "Active Worker", + linked_at: "2026-01-01T00:00:00Z", + } + : null, + }); + const items = (workspace: string) => + workspace === "home-empty" ? [] : [ + ...(deleted.has(workspace) ? [] : [summary("retry-dir", "cleanup_pending", "unknown")]), + summary("dirty-dir", "active", "dirty"), + summary("occupied-dir", "active", "clean", true), + ]; + const plan = (workspace: string) => ({ + workspace_id: workspace, + runtime_id: "fixture-runtime", + generated_at: "2026-01-01T00:00:00Z", + revision: "plan-1", + digest: "digest-1", + workers: [], + diagnostics: [], + workdirs: items(workspace).map((item) => ({ + target_id: `workdir:${item.working_directory_id}`, + action: item.cleanliness === "dirty" ? "workdir_dirty_discard" : "workdir_clean_cleanup", + workdir_id: item.working_directory_id, + runtime_id: "fixture-runtime", + repository_key: "main", + reason: "Ordinary cleanup rechecks current conditions", + linked_worker_ids: item.occupied_by ? ["worker-1"] : [], + linked_running_worker_ids: item.occupied_by ? ["worker-1"] : [], + running_linked: Boolean(item.occupied_by), + pinned_linked: false, + file_status: item.status === "cleanup_pending" ? "pending" : "present", + cleanliness: item.cleanliness, + blocking_reason: item.occupied_by ? "Workdir is linked to a running Worker" : null, + })), + }); + return async (request: Request): Promise => { + const url = new URL(request.url); + if ( + url.pathname === "/fixture-workdirs-control" && request.method === "POST" + ) { + const control = await request.json(); + mountReleased = control.mountReleased === true; + if (control.reset) { + deleted.clear(); + requests.length = 0; + } + return Response.json({ ok: true }); + } + if (url.pathname === "/fixture-workdirs") { + return Response.json({ requests }); + } + const match = url.pathname.match(/^\/api\/w\/([^/]+)(\/runtimes.*)$/); + if (!match) return null; + const [, workspace, path] = match; + if (path === "/runtimes") { + return Response.json({ + workspace_id: workspace, + limit: 100, + source: "fixture", + diagnostics: [], + items: [], + }); + } + if (!path.startsWith("/runtimes/fixture-runtime/")) return null; + const body = request.method !== "GET" ? await request.json() : undefined; + requests.push({ workspace, method: request.method, path, body }); + if (path.endsWith("/working-directories")) { + return Response.json({ + workspace_id: workspace, + items: items(workspace), + diagnostics: [], + }); + } + if (path.endsWith("/cleanup-plan")) return Response.json(plan(workspace)); + // Baseline route uses cleanup-executions; same retained outcome for before captures. + if (path.endsWith("/cleanup-executions")) { + return Response.json({ + workspace_id: workspace, + runtime_id: "fixture-runtime", + executed_at: "2026-01-01T00:00:00Z", + diagnostics: [], + plan_after: plan(workspace), + results: [{ + target_id: "workdir:retry-dir", + action: "workdir_clean_cleanup", + status: "retained", + message: "Provider cleanup failed", + }], + }); + } + if ( + path.endsWith("/working-directories/retry-dir") && + request.method === "DELETE" + ) { + if (workspace === "home-error") { + return Response.json({ + message: "Synthetic private host path must not render", + }, { status: 503 }); + } + if (mountReleased) deleted.add(workspace); + return Response.json({ + working_directory_id: "retry-dir", + disposition: mountReleased ? "removed" : "attention_required", + retryable: !mountReleased, + failure_category: mountReleased ? null : "mount_present", + }); + } + return Response.json({ error: "Unexpected fixture Workdir request" }, { + status: 404, + }); + }; +} diff --git a/tools/web-ux/browser-tests/workdir_removal_shell_test.ts b/tools/web-ux/browser-tests/workdir_removal_shell_test.ts new file mode 100644 index 000000000..e48f0724e --- /dev/null +++ b/tools/web-ux/browser-tests/workdir_removal_shell_test.ts @@ -0,0 +1,456 @@ +/// +import { assert, assertEquals, assertStringIncludes } from "@std/assert"; +import { dirname, fromFileUrl, join } from "@std/path"; +import { chromium, type Page } from "playwright"; +import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts"; +const root = join(dirname(fromFileUrl(import.meta.url)), "../../.."); +const evidence = join(root, "target/web-ux/t730-browser"); + +async function noOverflow(page: Page) { + assertEquals( + await page.locator("main").evaluate((main) => ({ + page: document.documentElement.scrollWidth > innerWidth, + main: main.scrollWidth > main.clientWidth, + })), + { page: false, main: false }, + ); +} + +Deno.test("ordinary removal refreshes failed and successful inventory, supports exact retry, and preserves permission and live guards across viewports and themes", async () => { + await Deno.mkdir(evidence, { recursive: true }); + const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); + const port = (listener.addr as Deno.NetAddr).port; + listener.close(); + const url = `http://127.0.0.1:${port}`; + const server = await startOwnedProcesses( + [{ + id: "workdirs", + command: Deno.execPath(), + cwd: root, + args: [ + "run", + "--allow-net", + "--allow-read", + "tools/web-ux/browser-tests/workspace_home_fixture_server.ts", + String(port), + "web/workspace/build", + ], + readyUrl: `${url}/health`, + }], + join(root, "tools/web-ux/scenarios/workdir-removal-light.json"), + join(evidence, "process-logs"), + [], + ); + const observations: unknown[] = []; + const injectedConsoleErrors: string[] = []; + const injectedUnexpectedErrors: string[] = []; + const observeInjectedFailure = (page: Page) => { + page.on("console", (message) => { + if (message.type() === "error") { + injectedConsoleErrors.push(message.text()); + } + }); + page.on( + "pageerror", + (error) => injectedUnexpectedErrors.push(String(error)), + ); + page.on( + "requestfailed", + (request) => injectedUnexpectedErrors.push(request.url()), + ); + }; + const control = async (value: unknown) => { + const response = await fetch(`${url}/fixture-workdirs-control`, { + method: "POST", + body: JSON.stringify(value), + }); + assert(response.ok); + await response.body?.cancel(); + }; + const state = async () => await (await fetch(`${url}/fixture-workdirs`)).json(); + const browser = await chromium.launch({ headless: true }); + try { + for (const colorScheme of ["light", "dark"] as const) { + for (const width of [1440, 768, 390, 320]) { + await control({ reset: true }); + const context = await browser.newContext({ + viewport: { width, height: 900 }, + colorScheme, + reducedMotion: "reduce", + }); + try { + const page = await context.newPage(); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(String(error))); + page.on("console", (message) => { + if (message.type() === "error") errors.push(message.text()); + }); + page.on("requestfailed", (request) => errors.push(request.url())); + const route = `${url}/w/home-owner/settings/runtimes/fixture-runtime/workdirs`; + await page.goto(route); + const deletion = page.getByRole("button", { + name: "Delete retry-dir", + exact: true, + }); + await deletion.waitFor(); + assert( + await deletion.isEnabled(), + "past pending/unknown state permanently disabled ordinary removal", + ); + assert( + await page.getByRole("button", { + name: "Delete dirty-dir", + exact: true, + }).isDisabled(), + ); + assert( + await page.getByRole("button", { + name: "Delete occupied-dir", + exact: true, + }).isDisabled(), + ); + await noOverflow(page); + assert( + await deletion.evaluate((button) => { + const r = button.getBoundingClientRect(); + return r.left >= 0 && r.right <= innerWidth; + }), + "Delete is outside initial viewport", + ); + await page.screenshot({ + path: join(evidence, `${colorScheme}-${width}-initial.png`), + }); + // Hold the actual request boundary to verify duplicate gestures cannot dispatch another DELETE. + let release!: () => void; + const gate = new Promise((resolve) => release = resolve); + let observed!: () => void; + const entered = new Promise((resolve) => observed = resolve); + let deletes = 0; + await page.route( + "**/working-directories/retry-dir", + async (intercept) => { + deletes++; + observed(); + await gate; + await intercept.continue(); + }, + ); + await deletion.focus(); + await page.keyboard.press("Enter"); + await page.getByRole("button", { + name: "Delete retry-dir", + exact: true, + }).filter({ hasText: "Deleting…" }).waitFor(); + assert(await deletion.isDisabled()); + await page.keyboard.press("Enter"); + await entered; + assertEquals(deletes, 1); + await page.screenshot({ + path: join(evidence, `${colorScheme}-${width}-deleting.png`), + }); + release(); + await page.getByRole("alert").filter({ + hasText: "mounted filesystem", + }).waitFor(); + const retry = page.getByRole("button", { + name: "Retry Delete retry-dir", + exact: true, + }); + await retry.waitFor(); + await page.waitForFunction(() => + !(document.querySelector( + "button[aria-label='Retry Delete retry-dir']", + ) as HTMLButtonElement)?.disabled + ); + const failed = await state(); + const beforeGets = failed.requests.filter(( + r: { workspace: string; method: string }, + ) => r.workspace === "home-owner" && r.method === "GET"); + assertEquals( + beforeGets.filter((r: { path: string }) => r.path.endsWith("/working-directories")) + .length, + 2, + ); + assertEquals( + beforeGets.filter((r: { path: string }) => r.path.endsWith("/cleanup-plan")).length, + 2, + ); + assertEquals(await page.locator("tbody tr").count(), 3); + await noOverflow(page); + await page.screenshot({ + path: join(evidence, `${colorScheme}-${width}-mount-failure.png`), + }); + await control({ mountReleased: true }); + await retry.focus(); + await page.keyboard.press("Enter"); + await page.getByRole("status").filter({ + hasText: "Deletion confirmed", + }).waitFor(); + await page.waitForFunction(() => document.querySelectorAll("tbody tr").length === 2); + assertEquals( + await page.getByRole("button", { name: /retry-dir/ }).count(), + 0, + ); + assert( + await page.getByRole("button", { + name: "Delete dirty-dir", + exact: true, + }).isDisabled(), + ); + const final = await state(); + const mutations = final.requests.filter((r: { method: string }) => r.method === "DELETE"); + assertEquals(mutations.length, 2); + assertEquals( + mutations[0], + mutations[1], + "ordinary retry changed its request", + ); + const gets = final.requests.filter(( + r: { workspace: string; method: string }, + ) => r.workspace === "home-owner" && r.method === "GET"); + assertEquals( + gets.filter((r: { path: string }) => r.path.endsWith("/working-directories")).length, + 3, + ); + assertEquals( + gets.filter((r: { path: string }) => r.path.endsWith("/cleanup-plan")).length, + 3, + ); + await page.screenshot({ + path: join(evidence, `${colorScheme}-${width}-removed.png`), + }); + await page.goto( + `${url}/w/home-member/settings/runtimes/fixture-runtime/workdirs`, + ); + await page.locator("tbody tr").first().waitFor(); + assertEquals( + await page.getByRole("button", { name: /Delete/ }).count(), + 0, + ); + await noOverflow(page); + await page.screenshot({ + path: join(evidence, `${colorScheme}-${width}-member.png`), + }); + await page.goto( + `${url}/w/home-empty/settings/runtimes/fixture-runtime/workdirs`, + ); + await page.getByText("No workdirs are visible for this Runtime.") + .waitFor(); + await noOverflow(page); + assertEquals(errors, []); + observations.push({ + colorScheme, + width, + requests: final.requests, + errors, + }); + } finally { + await context.close(); + } + } + } + + // Lost/HTTP failure responses still trigger fresh inventory and eligibility. Never echo provider bodies. + await control({ reset: true }); + const page = await browser.newPage(); + observeInjectedFailure(page); + await page.goto( + `${url}/w/home-error/settings/runtimes/fixture-runtime/workdirs`, + ); + await page.getByRole("button", { name: "Delete retry-dir", exact: true }) + .click(); + await page.getByRole("alert").filter({ hasText: "provider is unavailable" }) + .waitFor(); + await page.waitForFunction(() => + !(document.querySelector( + "button[aria-label='Delete retry-dir']", + ) as HTMLButtonElement)?.disabled + ); + assert( + !(await page.locator("main").innerText()).includes("Synthetic private"), + ); + const failed = await state(); + assertEquals( + failed.requests.filter((r: { method: string; path: string }) => + r.method === "GET" && r.path.endsWith("/working-directories") + ).length, + 2, + ); + assertEquals( + failed.requests.filter((r: { method: string; path: string }) => + r.method === "GET" && r.path.endsWith("/cleanup-plan") + ).length, + 2, + ); + await page.screenshot({ path: join(evidence, "http-failure.png") }); + await page.close(); + + // Plan refresh failure cannot leave stale delete authority enabled; manual Refresh recovers it. + await control({ reset: true }); + const stale = await browser.newPage(); + observeInjectedFailure(stale); + let planGets = 0; + let planFails = true; + await stale.route("**/cleanup-plan", async (route) => { + planGets++; + if (planGets > 1 && planFails) { + await route.fulfill({ + status: 503, + json: { message: "Synthetic private path" }, + }); + } else await route.continue(); + }); + await stale.goto( + `${url}/w/home-owner/settings/runtimes/fixture-runtime/workdirs`, + ); + await stale.getByRole("button", { name: "Delete retry-dir", exact: true }) + .click(); + await stale.getByRole("alert").filter({ hasText: "could not be refreshed" }) + .waitFor(); + assert( + await stale.getByRole("button", { + name: "Retry Delete retry-dir", + exact: true, + }).isDisabled(), + ); + assert( + !(await stale.locator("main").innerText()).includes("Synthetic private"), + ); + await stale.screenshot({ path: join(evidence, "refresh-failure.png") }); + planFails = false; + await stale.getByRole("button", { name: "Refresh", exact: true }).click(); + await stale.waitForFunction(() => + !(document.querySelector( + "button[aria-label='Retry Delete retry-dir']", + ) as HTMLButtonElement)?.disabled + ); + await stale.close(); + + // Current dirty changes after a failure override a retryable historical result. + await control({ reset: true }); + const changed = await browser.newPage(); + let changedGets = 0; + await changed.route("**/cleanup-plan", async (route) => { + const response = await route.fetch(); + const plan = await response.json(); + if (++changedGets > 1) plan.workdirs[0].action = "workdir_dirty_discard"; + await route.fulfill({ response, json: plan }); + }); + await changed.goto( + `${url}/w/home-owner/settings/runtimes/fixture-runtime/workdirs`, + ); + await changed.getByRole("button", { name: "Delete retry-dir", exact: true }) + .click(); + await changed.getByRole("button", { + name: "Retry Delete retry-dir", + exact: true, + }).waitFor(); + await changed.waitForFunction(() => + document.querySelector(".workdir-identity .removal-guard")?.textContent + ?.includes("Changes are protected") + ); + assert( + await changed.getByRole("button", { + name: "Retry Delete retry-dir", + exact: true, + }).isDisabled(), + ); + assertStringIncludes( + await changed.locator("main").innerText(), + "Changes are protected", + ); + await changed.screenshot({ + path: join(evidence, "new-changes-protected.png"), + }); + await changed.close(); + // A malformed response may follow a real side effect. Inventory still removes the now-absent row. + await control({ reset: true, mountReleased: true }); + const lost = await browser.newPage(); + observeInjectedFailure(lost); + await lost.route("**/working-directories/retry-dir", async (route) => { + await route.fetch(); + await route.fulfill({ + status: 200, + json: { + message: "Synthetic private path", + failure_category: "/private/path token=synthetic", + }, + }); + }); + await lost.goto( + `${url}/w/home-owner/settings/runtimes/fixture-runtime/workdirs`, + ); + await lost.getByRole("button", { name: "Delete retry-dir", exact: true }) + .click(); + await lost.getByRole("alert").filter({ + hasText: "Removal could not be confirmed", + }).waitFor(); + await lost.waitForFunction(() => document.querySelectorAll("tbody tr").length === 2); + assert( + !(await lost.locator("main").innerText()).includes("Synthetic private"), + ); + assert(!(await lost.locator("main").innerText()).includes("/private/path")); + await lost.screenshot({ + path: join(evidence, "malformed-response-refreshed.png"), + }); + await lost.close(); + + // Even a failed initial inventory can recover into a confirmed empty list with manual Refresh. + const emptyRecovery = await browser.newPage(); + observeInjectedFailure(emptyRecovery); + let inventoryFails = true; + await emptyRecovery.route( + "**/runtimes/fixture-runtime/working-directories", + async (route) => { + if (inventoryFails) { + await route.fulfill({ + status: 503, + json: { message: "Synthetic private path" }, + }); + } else await route.continue(); + }, + ); + await emptyRecovery.goto( + `${url}/w/home-empty/settings/runtimes/fixture-runtime/workdirs`, + ); + await emptyRecovery.getByText("Workdir inventory is unavailable.") + .waitFor(); + inventoryFails = false; + await emptyRecovery.getByRole("button", { name: "Refresh", exact: true }) + .click(); + await emptyRecovery.getByText("No workdirs are visible for this Runtime.") + .waitFor(); + await emptyRecovery.close(); + assertEquals(injectedUnexpectedErrors, []); + assert( + injectedConsoleErrors.every((message) => message.includes("503")), + "unexpected injected failure console diagnostic", + ); + await Deno.writeTextFile( + join(evidence, "evidence.json"), + JSON.stringify( + { + observations, + injectedConsoleErrors, + injectedUnexpectedErrors, + failureRefresh: failed.requests, + refreshFailureRecovered: true, + newChangesProtected: true, + malformedResponseReconciled: true, + emptyInventoryRecovered: true, + expectedFailureDiagnostics: [ + "Synthetic DELETE HTTP 503 on home-error", + "Synthetic cleanup-plan HTTP 503 after removal", + "Malformed DELETE result after fixture physical removal", + "Synthetic initial inventory HTTP 503", + ], + }, + null, + 2, + ), + ); + } finally { + await browser.close(); + assertEquals(await stopOwnedProcesses(server), []); + } +}); diff --git a/tools/web-ux/browser-tests/workspace_home_fixture_server.ts b/tools/web-ux/browser-tests/workspace_home_fixture_server.ts index c80c4f697..5d478044f 100644 --- a/tools/web-ux/browser-tests/workspace_home_fixture_server.ts +++ b/tools/web-ux/browser-tests/workspace_home_fixture_server.ts @@ -2,6 +2,8 @@ import { extname, join, normalize } from "jsr:@std/path@1.1.4"; import { dashboardFixture } from "../../../web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts"; import { settingsFixtureHandler } from "../../../web/workspace/src/lib/workspace/settings/identity.test-fixtures.ts"; +import { workdirFixture } from "./workdir_fixture.ts"; +const workdirsFixture = workdirFixture(); const settingsFixture = settingsFixtureHandler(); const port = Number(Deno.args[0]); @@ -30,6 +32,8 @@ const mime: Record = { Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { const url = new URL(request.url); if (url.pathname === "/health") return new Response("ok"); + const workdirsResponse = await workdirsFixture(request); + if (workdirsResponse) return workdirsResponse; const settingsResponse = await settingsFixture(request); if (settingsResponse) return settingsResponse; if (url.pathname === "/api/workspaces") { diff --git a/tools/web-ux/deno.json b/tools/web-ux/deno.json index e3e278be5..0ffbee433 100644 --- a/tools/web-ux/deno.json +++ b/tools/web-ux/deno.json @@ -11,7 +11,7 @@ "web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts", "check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts", "test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests", - "test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/ticket_shell_test.ts browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/console_pending_shell_test.ts browser-tests/console_live_shell_test.ts browser-tests/console_live_admission_shell_test.ts" + "test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/ticket_shell_test.ts browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/workdir_removal_shell_test.ts browser-tests/console_pending_shell_test.ts browser-tests/console_live_shell_test.ts browser-tests/console_live_admission_shell_test.ts" }, "fmt": { "lineWidth": 100, diff --git a/tools/web-ux/scenarios/workdir-removal-dark.json b/tools/web-ux/scenarios/workdir-removal-dark.json new file mode 100644 index 000000000..b689c72d1 --- /dev/null +++ b/tools/web-ux/scenarios/workdir-removal-dark.json @@ -0,0 +1,117 @@ +{ + "schemaVersion": 1, + "id": "workdir-removal-dark", + "title": "Ordinary Workdir removal and guarded retry", + "baseUrl": "http://127.0.0.1:15290", + "colorScheme": "dark", + "reducedMotion": "reduce", + "personas": [ + { + "id": "fixture", + "label": "Synthetic permission projections", + "auth": { + "kind": "anonymous" + } + } + ], + "viewports": [ + { + "label": "desktop", + "width": 1440, + "height": 900 + }, + { + "label": "narrow", + "width": 768, + "height": 900 + }, + { + "label": "mobile", + "width": 390, + "height": 844 + } + ], + "routes": [ + { + "id": "owner", + "label": "Owner removal", + "path": "/w/home-owner/settings/runtimes/fixture-runtime/workdirs", + "goal": "Remove a failed-cleanup Workdir using ordinary retry without enabling dirty or occupied removal.", + "dataState": "Pending/unknown retry plus dirty and occupied guards. First attempt finds a mount.", + "ready": { + "kind": "selector", + "selector": "main table tbody tr" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Current inventory" + }, + { + "id": "failed-attempt", + "label": "Removal blocked by mount", + "interaction": [ + { + "action": "click", + "selector": "button[aria-label='Delete retry-dir']" + } + ], + "ready": { + "kind": "selector", + "selector": "[role='alert']" + } + } + ] + }, + { + "id": "member", + "label": "No Runtime management permission", + "path": "/w/home-member/settings/runtimes/fixture-runtime/workdirs", + "goal": "Read inventory without owner-only removal controls.", + "dataState": "Management permission false.", + "ready": { + "kind": "selector", + "selector": "main table tbody tr" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Read only inventory" + } + ] + }, + { + "id": "empty", + "label": "Empty inventory", + "path": "/w/home-empty/settings/runtimes/fixture-runtime/workdirs", + "goal": "Recognize no remaining Workdirs.", + "dataState": "Empty list.", + "ready": { + "kind": "selector", + "selector": "main:has-text('No workdirs')" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Empty inventory" + } + ] + } + ], + "processes": [ + { + "id": "workdir-fixture", + "command": "deno", + "cwd": "..", + "args": [ + "run", + "--allow-net", + "--allow-read", + "browser-tests/workspace_home_fixture_server.ts", + "15290", + "../../web/workspace/build" + ], + "readyUrl": "http://127.0.0.1:15290/health" + } + ] +} diff --git a/tools/web-ux/scenarios/workdir-removal-light.json b/tools/web-ux/scenarios/workdir-removal-light.json new file mode 100644 index 000000000..2fd178d19 --- /dev/null +++ b/tools/web-ux/scenarios/workdir-removal-light.json @@ -0,0 +1,81 @@ +{ + "schemaVersion": 1, + "id": "workdir-removal-light", + "title": "Ordinary Workdir removal and guarded retry", + "baseUrl": "http://127.0.0.1:15290", + "colorScheme": "light", + "reducedMotion": "reduce", + "personas": [ + { + "id": "fixture", + "label": "Synthetic permission projections", + "auth": { "kind": "anonymous" } + } + ], + "viewports": [ + { "label": "desktop", "width": 1440, "height": 900 }, + { "label": "narrow", "width": 768, "height": 900 }, + { "label": "mobile", "width": 390, "height": 844 } + ], + "routes": [ + { + "id": "owner", + "label": "Owner removal", + "path": "/w/home-owner/settings/runtimes/fixture-runtime/workdirs", + "goal": "Remove a failed-cleanup Workdir using ordinary retry without enabling dirty or occupied removal.", + "dataState": "Pending/unknown retry plus dirty and occupied guards. First attempt finds a mount.", + "ready": { "kind": "selector", "selector": "main table tbody tr" }, + "capturePoints": [ + { "id": "initial", "label": "Current inventory" }, + { + "id": "failed-attempt", + "label": "Removal blocked by mount", + "interaction": [ + { + "action": "click", + "selector": "button[aria-label='Delete retry-dir']" + } + ], + "ready": { "kind": "selector", "selector": "[role='alert']" } + } + ] + }, + { + "id": "member", + "label": "No Runtime management permission", + "path": "/w/home-member/settings/runtimes/fixture-runtime/workdirs", + "goal": "Read inventory without owner-only removal controls.", + "dataState": "Management permission false.", + "ready": { "kind": "selector", "selector": "main table tbody tr" }, + "capturePoints": [{ "id": "initial", "label": "Read only inventory" }] + }, + { + "id": "empty", + "label": "Empty inventory", + "path": "/w/home-empty/settings/runtimes/fixture-runtime/workdirs", + "goal": "Recognize no remaining Workdirs.", + "dataState": "Empty list.", + "ready": { + "kind": "selector", + "selector": "main:has-text('No workdirs')" + }, + "capturePoints": [{ "id": "initial", "label": "Empty inventory" }] + } + ], + "processes": [ + { + "id": "workdir-fixture", + "command": "deno", + "cwd": "..", + "args": [ + "run", + "--allow-net", + "--allow-read", + "browser-tests/workspace_home_fixture_server.ts", + "15290", + "../../web/workspace/build" + ], + "readyUrl": "http://127.0.0.1:15290/health" + } + ] +} diff --git a/web/workspace/deno.json b/web/workspace/deno.json index ac4ca6e82..62fe36fd6 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -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", + "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", "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", "build": "deno run -A npm:vite@8.3.2 build", diff --git a/web/workspace/src/lib/workspace/api/workdirs.ts b/web/workspace/src/lib/workspace/api/workdirs.ts index dbfedcfb7..15619fe84 100644 --- a/web/workspace/src/lib/workspace/api/workdirs.ts +++ b/web/workspace/src/lib/workspace/api/workdirs.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 ( diff --git a/web/workspace/src/lib/workspace/settings/workdir-removal.ts b/web/workspace/src/lib/workspace/settings/workdir-removal.ts new file mode 100644 index 000000000..4c7194e86 --- /dev/null +++ b/web/workspace/src/lib/workspace/settings/workdir-removal.ts @@ -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> = { + 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 { + 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 { + 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; +} diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.svelte index cb44d6047..6275f9d94 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.svelte @@ -1,102 +1,89 @@