fix: scope Workdir removal to explicit requests

This commit is contained in:
2026-10-30 20:33:57 +09:00
parent 2b4bf4b7bd
commit 00d96b7163
16 changed files with 916 additions and 254 deletions
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:7e640cff6bdcf6dc298cfca695c5cb71a520288b20f4f29ec8df8138724b97c4)
// Input contract: 0.1.0 (sha256:3436eada52ceea48f4c50f40195cba02bec37fbbff4ea8f5a332133e6fcde962)
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:7e640cff6bdcf6dc298cfca695c5cb71a520288b20f4f29ec8df8138724b97c4)
// Input contract: 0.1.0 (sha256:3436eada52ceea48f4c50f40195cba02bec37fbbff4ea8f5a332133e6fcde962)
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:7e640cff6bdcf6dc298cfca695c5cb71a520288b20f4f29ec8df8138724b97c4)
// Input contract: 0.1.0 (sha256:3436eada52ceea48f4c50f40195cba02bec37fbbff4ea8f5a332133e6fcde962)
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:7e640cff6bdcf6dc298cfca695c5cb71a520288b20f4f29ec8df8138724b97c4)
// Input contract: 0.1.0 (sha256:3436eada52ceea48f4c50f40195cba02bec37fbbff4ea8f5a332133e6fcde962)
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:7e640cff6bdcf6dc298cfca695c5cb71a520288b20f4f29ec8df8138724b97c4)
// Input contract: 0.1.0 (sha256:3436eada52ceea48f4c50f40195cba02bec37fbbff4ea8f5a332133e6fcde962)
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/workdir-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
@@ -33,7 +33,7 @@ export type WorkingDirectoryOccupancy = { display_name: string; linked_at: strin
export type WorkingDirectoryRemovalDisposition = "removed" | "retained" | "attention_required";
export type WorkingDirectoryRemovalRequest = { reason: string; };
export type WorkingDirectoryRemovalRequest = { operation_id: string; reason: string; };
export type WorkingDirectoryRemovalResponse = { disposition: WorkingDirectoryRemovalDisposition; failure_category?: string | null; retryable: boolean; working_directory_id: string; };
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:7e640cff6bdcf6dc298cfca695c5cb71a520288b20f4f29ec8df8138724b97c4)
// Input contract: 0.1.0 (sha256:3436eada52ceea48f4c50f40195cba02bec37fbbff4ea8f5a332133e6fcde962)
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
@@ -30,7 +30,9 @@ const causes: Readonly<Record<string, string>> = {
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.",
"The provider has not confirmed deletion. Restore provider access and reconcile the same request. Recovery only observes the target; it never repeats deletion.",
request_interrupted:
"The request was interrupted before execution. Submit a new request if removal is still wanted.",
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:
@@ -125,11 +127,39 @@ async function removalBody(response: Response): Promise<string> {
}
}
// 要求IDは結果不明時だけ保持する。確定した失敗の次の明示要求は別IDとなる。
export class WorkdirRemovalRequests {
private readonly unresolved = new Map<string, string>();
async remove(
fetchFn: typeof fetch,
workspaceId: string,
runtimeId: string,
workdirId: string,
): Promise<WorkingDirectoryRemovalResponse> {
const key = JSON.stringify([workspaceId, runtimeId, workdirId]);
const operationId = this.unresolved.get(key) ?? crypto.randomUUID();
this.unresolved.set(key, operationId);
const result = await removeWorkdir(
fetchFn,
workspaceId,
runtimeId,
workdirId,
operationId,
);
if (result.failure_category !== "provider_cleanup_outcome_unknown") {
this.unresolved.delete(key);
}
return result;
}
}
export async function removeWorkdir(
fetchFn: typeof fetch,
workspaceId: string,
runtimeId: string,
workdirId: string,
operationId: string,
): Promise<WorkingDirectoryRemovalResponse> {
const response = await fetchFn(
workspaceApiPath(
@@ -141,8 +171,10 @@ export async function removeWorkdir(
{
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" }),
body: JSON.stringify({
operation_id: operationId,
reason: "User requested Workdir deletion",
}),
},
);
if (!response.ok) {
@@ -2,7 +2,7 @@
import { loadJson, workspaceApiPath } from '#lib/workspace/api/http.ts';
import { parseRuntimeCleanupPlan } from '#lib/workspace/api/runtime-workers.ts';
import { parseWorkingDirectoryListResponse } from '#lib/workspace/api/workdirs.ts';
import { canRemoveWorkdir, removalCause, removalGuard, removeWorkdir, WorkdirRemovalError } from '#lib/workspace/settings/workdir-removal.ts';
import { canRemoveWorkdir, removalCause, removalGuard, WorkdirRemovalRequests, WorkdirRemovalError } from '#lib/workspace/settings/workdir-removal.ts';
import { formatCurrentWorkdirCheckout } from '#lib/workspace/settings/workdir-checkout.ts';
import type { CleanupWorkdirCandidate, RuntimeCleanupPlanResponse, WorkingDirectorySummary } from '#lib/workspace/sidebar/types.ts';
import type { PageData as RouteData } from './$types';
@@ -10,6 +10,7 @@
type PageProps = { data: ResolvedPageData<RouteData> };
let { data }: PageProps = $props();
const removalRequests = new WorkdirRemovalRequests();
let cleanupBusyTarget = $state<string | null>(null);
let refreshing = $state(false);
let refreshError = $state<string | null>(null);
@@ -72,7 +73,7 @@
cleanupBusyTarget = workdir.working_directory_id;
feedback = null;
try {
const result = await removeWorkdir(fetch, workspaceId, runtimeId, workdir.working_directory_id);
const result = await removalRequests.remove(fetch, workspaceId, runtimeId, workdir.working_directory_id);
if (workspaceId !== data.workspaceId || runtimeId !== data.runtimeId) return;
feedback = {
id: workdir.working_directory_id,
+44 -6
View File
@@ -6,6 +6,7 @@ import {
canRemoveWorkdir,
removalCause,
removeWorkdir,
WorkdirRemovalRequests,
} from "../src/lib/workspace/settings/workdir-removal.ts";
import type {
CleanupWorkdirCandidate,
@@ -57,6 +58,7 @@ Deno.test("removal wire boundary rejects malformed results and mismatched identi
"workspace",
"runtime",
"dir",
"request-1",
);
} catch {
rejected = true;
@@ -72,6 +74,7 @@ Deno.test("removal response size is bounded before decoding provider content", a
"workspace",
"runtime",
"dir",
"request-1",
);
} catch {
rejected = true;
@@ -179,7 +182,7 @@ Deno.test("pending unknown cleanup permits ordinary removal while current dirty
);
});
Deno.test("ordinary retries send the same scoped DELETE body and expose only allowlisted HTTP causes", async () => {
Deno.test("new requests after confirmed failure get distinct IDs 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({
@@ -194,8 +197,9 @@ Deno.test("ordinary retries send the same scoped DELETE body and expose only all
failure_category: "mount_present",
});
}) as typeof fetch;
const removalRequests = new WorkdirRemovalRequests();
for (let i = 0; i < 2; i++) {
const result = await removeWorkdir(
const result = await removalRequests.remove(
fetchFn,
"workspace/x",
"runtime/x",
@@ -204,8 +208,9 @@ Deno.test("ordinary retries send the same scoped DELETE body and expose only all
assert(result.retryable && result.failure_category === "mount_present");
}
assert(
JSON.stringify(requests[0]) === JSON.stringify(requests[1]),
"retry changed ordinary deletion request",
JSON.parse(requests[0].body).operation_id !==
JSON.parse(requests[1].body).operation_id,
"new explicit request reused a failed request identity",
);
assert(
requests[0].path ===
@@ -213,8 +218,9 @@ Deno.test("ordinary retries send the same scoped DELETE body and expose only all
);
assert(
requests[0].method === "DELETE" &&
requests[0].body ===
JSON.stringify({ reason: "User requested Workdir deletion" }),
JSON.parse(requests[0].body).reason ===
"User requested Workdir deletion" &&
typeof JSON.parse(requests[0].body).operation_id === "string",
);
for (
const [status, category] of [
@@ -236,6 +242,7 @@ Deno.test("ordinary retries send the same scoped DELETE body and expose only all
"workspace",
"runtime",
"dir",
"request-1",
);
} catch (error) {
message = error instanceof Error ? error.message : String(error);
@@ -246,3 +253,34 @@ Deno.test("ordinary retries send the same scoped DELETE body and expose only all
);
}
});
Deno.test("unknown removal outcomes keep the same identity and body until a terminal result", async () => {
for (const transportLoss of [false, true]) {
const calls: string[] = [];
const requester = new WorkdirRemovalRequests();
const fetchFn = (async (_path: RequestInfo | URL, init?: RequestInit) => {
calls.push(String(init?.body));
if (calls.length === 1 && transportLoss) {
throw new Error("connection lost");
}
return Response.json({
working_directory_id: "dir",
disposition: "attention_required",
retryable: calls.length !== 1,
failure_category: calls.length === 1
? "provider_cleanup_outcome_unknown"
: "resource_busy",
});
}) as typeof fetch;
await requester.remove(fetchFn, "workspace", "runtime", "dir").catch(() =>
undefined
);
await requester.remove(fetchFn, "workspace", "runtime", "dir");
await requester.remove(fetchFn, "workspace", "runtime", "dir");
assert(calls[0] === calls[1], "unknown outcome changed request identity");
assert(
calls[1] !== calls[2],
"confirmed failure kept old request identity",
);
}
});