fix: show Workdir removal causes and refresh ordinary retries
This commit is contained in:
@@ -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<string>();
|
||||||
|
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<Response | null> => {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
/// <reference lib="dom" />
|
||||||
|
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<void>((resolve) => release = resolve);
|
||||||
|
let observed!: () => void;
|
||||||
|
const entered = new Promise<void>((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), []);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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 { 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 { 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 settingsFixture = settingsFixtureHandler();
|
||||||
|
|
||||||
const port = Number(Deno.args[0]);
|
const port = Number(Deno.args[0]);
|
||||||
@@ -30,6 +32,8 @@ const mime: Record<string, string> = {
|
|||||||
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
if (url.pathname === "/health") return new Response("ok");
|
if (url.pathname === "/health") return new Response("ok");
|
||||||
|
const workdirsResponse = await workdirsFixture(request);
|
||||||
|
if (workdirsResponse) return workdirsResponse;
|
||||||
const settingsResponse = await settingsFixture(request);
|
const settingsResponse = await settingsFixture(request);
|
||||||
if (settingsResponse) return settingsResponse;
|
if (settingsResponse) return settingsResponse;
|
||||||
if (url.pathname === "/api/workspaces") {
|
if (url.pathname === "/api/workspaces") {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts",
|
"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",
|
"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": "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": {
|
"fmt": {
|
||||||
"lineWidth": 100,
|
"lineWidth": 100,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
"dev": "deno run -A npm:vite@8.3.2 dev",
|
"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",
|
"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",
|
"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: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: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",
|
"build": "deno run -A npm:vite@8.3.2 build",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
WorkingDirectoryDetailResponse,
|
WorkingDirectoryDetailResponse,
|
||||||
WorkingDirectoryListResponse,
|
WorkingDirectoryListResponse,
|
||||||
WorkingDirectoryOccupancy,
|
WorkingDirectoryOccupancy,
|
||||||
|
WorkingDirectoryRemovalResponse,
|
||||||
WorkingDirectorySource,
|
WorkingDirectorySource,
|
||||||
WorkingDirectorySummary,
|
WorkingDirectorySummary,
|
||||||
} from "../../generated/workdir-api";
|
} 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(
|
export function parseWorkingDirectoryDetailResponse(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): WorkingDirectoryDetailResponse {
|
): WorkingDirectoryDetailResponse {
|
||||||
@@ -178,13 +213,26 @@ export function parseWorkingDirectorySummary(
|
|||||||
function parseWorkingDirectorySource(value: unknown): WorkingDirectorySource {
|
function parseWorkingDirectorySource(value: unknown): WorkingDirectorySource {
|
||||||
const source = exactRecord(
|
const source = exactRecord(
|
||||||
value,
|
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",
|
"Workdir source",
|
||||||
);
|
);
|
||||||
const kind = stringField(source, "kind");
|
const kind = stringField(source, "kind");
|
||||||
if (kind === "workspace_config") {
|
if (kind === "workspace_config") {
|
||||||
if (source.repository_key !== undefined || source.grant_id !== undefined || source.grant_permissions !== undefined) {
|
if (
|
||||||
throw new Error("Logical config source must not contain repository or External authority");
|
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");
|
const access = stringField(source, "access");
|
||||||
if (access !== "read_only" && access !== "read_write") {
|
if (access !== "read_only" && access !== "read_write") {
|
||||||
@@ -194,10 +242,20 @@ function parseWorkingDirectorySource(value: unknown): WorkingDirectorySource {
|
|||||||
if (content_path !== "/workspace-config") {
|
if (content_path !== "/workspace-config") {
|
||||||
throw new Error("Logical config must reference its WIP content entrance");
|
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) {
|
if (
|
||||||
throw new Error("Filesystem Workdir source must not contain logical config metadata");
|
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 (kind === "repository") {
|
||||||
if (
|
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;
|
||||||
|
}
|
||||||
+102
-169
@@ -1,102 +1,89 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { pushWorkspaceAlert } from '#lib/workspace/alerts/store.ts';
|
import { loadJson, workspaceApiPath } from '#lib/workspace/api/http.ts';
|
||||||
import { workspaceApiPath } from '#lib/workspace/api/http.ts';
|
import { parseRuntimeCleanupPlan } from '#lib/workspace/api/runtime-workers.ts';
|
||||||
import { parseRuntimeCleanupExecution } 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 { formatCurrentWorkdirRevision } from '#lib/workspace/settings/workdir-revision.ts';
|
||||||
import type {
|
import type { CleanupWorkdirCandidate, RuntimeCleanupPlanResponse, WorkingDirectorySummary } from '#lib/workspace/sidebar/types.ts';
|
||||||
CleanupWorkdirCandidate,
|
|
||||||
RuntimeCleanupPlanResponse,
|
|
||||||
WorkingDirectorySummary,
|
|
||||||
} from '#lib/workspace/sidebar/types.ts';
|
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
let cleanupBusyTarget = $state<string | null>(null);
|
let cleanupBusyTarget = $state<string | null>(null);
|
||||||
|
let refreshing = $state(false);
|
||||||
|
let refreshError = $state<string | null>(null);
|
||||||
let cleanupPlan = $state<RuntimeCleanupPlanResponse | null>(null);
|
let cleanupPlan = $state<RuntimeCleanupPlanResponse | null>(null);
|
||||||
let workdirs = $state<WorkingDirectorySummary[]>([]);
|
let workdirs = $state<WorkingDirectorySummary[]>([]);
|
||||||
let runtimeLabel = $derived(
|
let inventoryLoaded = $state(false);
|
||||||
data.runtimes?.items.find((runtime) => runtime.runtime_id === data.runtimeId)?.label ?? data.runtimeId,
|
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 ?? []);
|
let cleanupCandidates = $derived(cleanupPlan?.workdirs ?? []);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
cleanupPlan = data.cleanupPlan ?? null;
|
cleanupPlan = data.cleanupPlan ?? null;
|
||||||
workdirs = data.workdirs?.items ?? [];
|
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 {
|
function repositoryKey(workdir: WorkingDirectorySummary): string | null {
|
||||||
return workdir.source.kind === 'repository' ? workdir.source.repository_key : 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 {
|
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 {
|
function cleanupCandidate(workdir: WorkingDirectorySummary): CleanupWorkdirCandidate | undefined {
|
||||||
return cleanupCandidates.find((candidate) => candidate.workdir_id === workdir.working_directory_id);
|
return cleanupCandidates.find((candidate) => candidate.workdir_id === workdir.working_directory_id);
|
||||||
}
|
}
|
||||||
|
function isDeleteDisabled(workdir: WorkingDirectorySummary): boolean {
|
||||||
function isDeleteDisabled(candidate: CleanupWorkdirCandidate): boolean {
|
return !canManage || Boolean(refreshError) || refreshing || cleanupBusyTarget !== null || !canRemoveWorkdir(workdir, cleanupCandidate(workdir));
|
||||||
return Boolean(candidate.blocking_reason) || candidate.action === 'workdir_dirty_discard' || cleanupBusyTarget !== null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function errorMessage(payload: unknown, fallback: string): string {
|
async function refreshInventory(): Promise<void> {
|
||||||
if (payload && typeof payload === 'object') {
|
refreshing = true;
|
||||||
if ('message' in payload && typeof payload.message === 'string') return payload.message;
|
// Capture route authority before awaiting; a departed route must not receive this result.
|
||||||
if ('error' in payload) {
|
const workspaceId = data.workspaceId;
|
||||||
const error = payload.error;
|
const runtimeId = data.runtimeId;
|
||||||
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;
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const base = `/runtimes/${encodeURIComponent(runtimeId)}`;
|
||||||
workspaceApiPath(data.workspaceId, `/runtimes/${encodeURIComponent(data.runtimeId)}/cleanup-executions`),
|
const [inventory, plan] = await Promise.all([
|
||||||
{
|
loadJson(fetch, workspaceApiPath(workspaceId, `${base}/working-directories`), undefined, parseWorkingDirectoryListResponse),
|
||||||
method: 'POST',
|
loadJson(fetch, workspaceApiPath(workspaceId, `${base}/cleanup-plan`), undefined, parseRuntimeCleanupPlan),
|
||||||
headers: { 'content-type': 'application/json' },
|
]);
|
||||||
body: JSON.stringify({
|
if (workspaceId !== data.workspaceId || runtimeId !== data.runtimeId) return;
|
||||||
expected_plan_revision: cleanupPlan.revision,
|
// Keep the last observed list on failure, but never authorize another deletion with stale eligibility.
|
||||||
expected_plan_digest: cleanupPlan.digest,
|
if (inventory.data) { workdirs = inventory.data.items; inventoryLoaded = true; }
|
||||||
worker_target_ids: [],
|
cleanupPlan = plan.data;
|
||||||
workdir_target_ids: [candidate.target_id],
|
refreshError = inventory.error || plan.error ? 'Inventory or removal eligibility could not be refreshed. Refresh before another deletion.' : null;
|
||||||
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',
|
|
||||||
});
|
|
||||||
} finally {
|
} 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;
|
cleanupBusyTarget = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,76 +94,55 @@
|
|||||||
<meta name="description" content="Runtime workdirs" />
|
<meta name="description" content="Runtime workdirs" />
|
||||||
</svelte:head>
|
</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">
|
<header class="page-header-row">
|
||||||
<div>
|
<div>
|
||||||
<p class="breadcrumb"><a href={`/w/${data.workspaceId}/settings/runtimes`}>Runtimes</a> / {runtimeLabel}</p>
|
<p class="breadcrumb"><a href={`/w/${data.workspaceId}/settings/runtimes`}>Runtimes</a> / {runtimeLabel}</p>
|
||||||
<h1 id="workdirs-heading">Workdirs</h1>
|
<h1 id="workdirs-heading">Workdirs</h1>
|
||||||
<p>Workdirs owned by <code>{data.runtimeId}</code>.</p>
|
<p>Workdirs owned by <code>{data.runtimeId}</code>.</p>
|
||||||
{#if data.cleanupPlanError}<p class="section-state error">{data.cleanupPlanError}</p>{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
<button class="workdir-action" type="button" disabled={refreshing || cleanupBusyTarget !== null} onclick={refreshInventory}>{refreshing ? 'Refreshing…' : 'Refresh'}</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if data.workdirsError}
|
{#if feedback}
|
||||||
<p class="section-state error">{data.workdirsError}</p>
|
<div class="removal-feedback" class:error={!feedback.removed} role={feedback.removed ? 'status' : 'alert'}>
|
||||||
{:else if !data.workdirs}
|
<p><strong>{feedback.removed ? 'Workdir deleted' : 'Workdir not deleted'}</strong> · <code>{feedback.id}</code></p>
|
||||||
<p class="section-state">Loading workdirs…</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}
|
{:else if workdirs.length === 0}
|
||||||
<p class="section-state">No workdirs are visible for this Runtime.</p>
|
<p class="section-state">No workdirs are visible for this Runtime.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<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">
|
<table class="workdirs-table">
|
||||||
<thead>
|
<thead><tr><th>Workdir</th><th>Repository</th><th>Revision</th><th>Status</th><th>Cleanliness</th><th>Occupied by</th></tr></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>
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each workdirs as workdir}
|
{#each workdirs as workdir (workdir.working_directory_id)}
|
||||||
{@const cleanup = cleanupCandidate(workdir)}
|
{@const cleanup = cleanupCandidate(workdir)}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td class="workdir-identity">
|
||||||
<span>{workdir.display_name ?? '—'}</span>
|
<span>{workdir.display_name ?? '—'}</span>
|
||||||
<small><code>{workdir.working_directory_id}</code></small>
|
<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>
|
||||||
<td>{repositoryKey(workdir) ?? 'External'}</td>
|
<td>{repositoryKey(workdir) ?? 'External'}</td>
|
||||||
<td><code>{currentRevision(workdir)}</code></td>
|
<td><code>{currentRevision(workdir)}</code></td>
|
||||||
<td>{workdir.status}</td>
|
<td>{workdir.status}</td>
|
||||||
<td>{workdir.cleanliness ?? 'unknown'}</td>
|
<td>{workdir.cleanliness ?? 'unknown'}</td>
|
||||||
<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>
|
||||||
{#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>
|
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -186,53 +152,20 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.icon-action {
|
.workdirs-page { min-width: 0; }
|
||||||
display: inline-flex;
|
.page-header-row { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: start; gap: var(--space-3); }
|
||||||
align-items: center;
|
.table-wrap { max-width: 100%; overflow-x: auto; }
|
||||||
justify-content: center;
|
.workdirs-table { width: 100%; min-width: 54rem; border-collapse: collapse; }
|
||||||
width: 2rem;
|
th, td { padding: var(--space-3); text-align: left; vertical-align: top; border-bottom: 1px solid var(--line); }
|
||||||
height: 2rem;
|
th { color: var(--text-muted); font-weight: 500; }
|
||||||
padding: 0;
|
.workdir-identity { width: 18rem; min-width: 18rem; overflow-wrap: anywhere; }
|
||||||
border: 1px solid var(--line);
|
td small { display: block; color: var(--text-muted); margin-block: var(--space-1); }
|
||||||
border-radius: 0.5rem;
|
.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; }
|
||||||
background: var(--bg-raised);
|
.workdir-action:hover:not(:disabled) { background: var(--interactive-hover); }
|
||||||
color: var(--text);
|
.workdir-action.danger { color: var(--danger); }
|
||||||
cursor: pointer;
|
.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); }
|
||||||
.icon-action.danger:hover:not(:disabled),
|
.removal-feedback.error strong { color: var(--danger); }
|
||||||
.icon-action.danger:focus-visible:not(:disabled) {
|
.removal-guard { margin-block: var(--space-2) 0; color: var(--text-muted); }
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user