fix: scope Workdir removal to explicit requests
This commit is contained in:
@@ -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) {
|
||||
|
||||
+3
-2
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user