fix: use authoritative runtime removal result

This commit is contained in:
2026-09-13 02:30:24 +09:00
parent 2d4c7b383a
commit 6c609808c9
5 changed files with 213 additions and 48 deletions
@@ -474,6 +474,30 @@ export type RuntimeTrustKeyRevealResponse = { public_key: string };
export type RevokeRuntimeTrustKeyRequest = { expected_revision: number };
export type RemoveRuntimeRequest = {
operation_id: string;
expected_binding_revision: number;
};
export type RuntimeRemovalOperationState =
| "pending"
| "cleanup_pending"
| "succeeded"
| "failed";
export type RuntimeRemovalOperationResponse = {
operation_id: string;
workspace_id: string;
runtime_id: string;
state: RuntimeRemovalOperationState;
binding_removed: boolean;
runtime_registration_removed: boolean | null;
failure_category?: string | null;
created_at: string;
updated_at: string;
completed_at?: string | null;
};
export type RuntimeTrustConflictKind = "stale_revision" | "fingerprint_in_use";
export type RuntimeTrustConflictResponse = {
@@ -1,10 +1,13 @@
import type {
CreateRemoteRuntimeRequest,
Diagnostic,
RemoveRuntimeRequest,
RevokeRuntimeTrustKeyRequest,
RuntimeConnectionDisplayState,
RuntimeIdentityAuthority,
RuntimeManagementSummary,
RuntimeRemovalOperationResponse,
RuntimeRemovalOperationState,
RuntimeSourceKind,
RuntimeSourceStatus,
RuntimeSourceSummary,
@@ -83,6 +86,12 @@ const CONNECTION_STATES = new Set<RuntimeConnectionDisplayState>([
"unavailable",
"revoked",
]);
const REMOVAL_OPERATION_STATES = new Set<RuntimeRemovalOperationState>([
"pending",
"cleanup_pending",
"succeeded",
"failed",
]);
const encoder = new TextEncoder();
type JsonObject = Record<string, unknown>;
@@ -770,6 +779,83 @@ export function parseRuntimeTrustConflict(
};
}
export function parseRuntimeRemovalOperationResponse(
value: unknown,
): RuntimeRemovalOperationResponse {
const response = object(value, "Runtime removal response");
exactKeys(
response,
[
"operation_id",
"workspace_id",
"runtime_id",
"state",
"binding_removed",
"runtime_registration_removed",
"created_at",
"updated_at",
],
["failure_category", "completed_at"],
"Runtime removal response",
);
const runtimeRegistrationRemoved = response.runtime_registration_removed;
if (
runtimeRegistrationRemoved !== null &&
typeof runtimeRegistrationRemoved !== "boolean"
) {
return fail(
"Runtime removal response.runtime_registration_removed",
"must be a boolean or null",
);
}
return {
operation_id: boundedString(
response.operation_id,
"Runtime removal response.operation_id",
LIMITS.idBytes,
),
workspace_id: boundedString(
response.workspace_id,
"Runtime removal response.workspace_id",
LIMITS.idBytes,
),
runtime_id: boundedString(
response.runtime_id,
"Runtime removal response.runtime_id",
LIMITS.idBytes,
),
state: enumValue(
response.state,
"Runtime removal response.state",
REMOVAL_OPERATION_STATES,
),
binding_removed: boolean(
response.binding_removed,
"Runtime removal response.binding_removed",
),
runtime_registration_removed: runtimeRegistrationRemoved,
failure_category: optionalNullableString(
response.failure_category,
"Runtime removal response.failure_category",
LIMITS.diagnosticCodeBytes,
),
created_at: boundedString(
response.created_at,
"Runtime removal response.created_at",
LIMITS.timestampBytes,
),
updated_at: boundedString(
response.updated_at,
"Runtime removal response.updated_at",
LIMITS.timestampBytes,
),
completed_at: optionalNullableTimestamp(
response.completed_at,
"Runtime removal response.completed_at",
),
};
}
function revisionForJson(revision: number | null): number | null {
if (revision === null) return null;
if (!Number.isSafeInteger(revision) || revision < 1) {
@@ -940,28 +1026,51 @@ export async function updateRemoteRuntime(
return finishMutation(response, workspaceId, runtimeId);
}
export async function deleteRemoteRuntime(
export async function removeRemoteRuntime(
workspaceId: string,
runtimeId: string,
request: RemoveRuntimeRequest,
fetchImpl: typeof fetch = fetch,
): Promise<void> {
): Promise<RuntimeRemovalOperationResponse> {
const response = await fetchImpl(
workspaceApiPath(
workspaceId,
`/runtimes/${encodeURIComponent(runtimeId)}`,
),
{ method: "DELETE" },
{
method: "DELETE",
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
},
);
if (response.ok) return;
let payload: unknown;
try {
payload = await readBoundedJson(response);
} catch {
throw new RuntimeTrustRequestError(
`Runtime registration delete failed (${response.status})`,
`Runtime removal failed (${response.status})`,
);
}
throw requestErrorFrom(payload, response.status);
if (!response.ok) throw requestErrorFrom(payload, response.status);
const operation = parseRuntimeRemovalOperationResponse(payload);
if (
operation.workspace_id !== workspaceId ||
operation.runtime_id !== runtimeId
) {
throw new RuntimeTrustRequestError(
"Runtime removal response did not match the selected Runtime",
);
}
if (
operation.state !== "succeeded" ||
!operation.binding_removed ||
operation.runtime_registration_removed !== true
) {
throw new RuntimeTrustRequestError(
"Runtime removal did not reach authoritative completion",
);
}
return operation;
}
export async function revealRuntimeTrustKey(
@@ -6,7 +6,7 @@
} from '$lib/generated/workspace-api';
import {
createRemoteRuntime,
deleteRemoteRuntime,
removeRemoteRuntime,
previewRuntimePublicKeyFingerprint,
revealRuntimeTrustKey,
revokeRuntimeTrustKey,
@@ -284,36 +284,34 @@
return;
}
const operation = routeFence.capture(data.runtimeId);
const routeOperation = routeFence.capture(data.runtimeId);
const trust = data.runtimeDetail.trust_key;
if (trust.revision == null) {
deleteRuntimeError = 'The authoritative Runtime binding revision is unavailable. Reload before removal.';
return;
}
busyAction = 'delete';
deleteRuntimeError = null;
try {
if (data.runtimeDetail.trust_key.status !== 'revoked') {
const trust = data.runtimeDetail.trust_key;
if (trust.revision == null || !trust.fingerprint) {
throw new Error('Runtime trust revision and fingerprint are required before deletion.');
}
await revokeRuntimeTrustKey(
data.workspaceId,
operation.runtimeId,
{ expected_revision: trust.revision },
trust.fingerprint,
trust.fingerprint,
);
if (!isCurrentRoute(operation)) return;
}
await deleteRemoteRuntime(data.workspaceId, operation.runtimeId);
if (!isCurrentRoute(operation)) return;
await removeRemoteRuntime(
data.workspaceId,
routeOperation.runtimeId,
{
operation_id: crypto.randomUUID(),
expected_binding_revision: trust.revision,
},
);
if (!isCurrentRoute(routeOperation)) return;
await goto(`/w/${encodeURIComponent(data.workspaceId)}/settings/runtimes`, {
replaceState: true,
});
} catch (error) {
if (!isCurrentRoute(operation)) return;
if (!isCurrentRoute(routeOperation)) return;
deleteRuntimeError = error instanceof Error
? error.message
: 'Runtime registration deletion failed.';
} finally {
if (isCurrentRoute(operation)) busyAction = null;
if (isCurrentRoute(routeOperation)) busyAction = null;
}
}
@@ -589,14 +587,13 @@
<section class="runtime-detail-section runtime-danger-zone" aria-labelledby="runtime-delete-heading">
<h2 id="runtime-delete-heading">Delete Runtime registration</h2>
<p>
Remove this Runtime binding from the current Workspace. This does not stop the Runtime process,
delete its Workers or Workdirs, or revoke this Workspace on the Runtime host.
The Backend removes Workspace trust and this Runtime registration as one guarded operation.
Active Workers, Workdirs, assignments, pending create or removal work, configuration references,
or another Workspace binding block the operation without changing trust.
</p>
<p class="section-state warning">
This does not stop the Runtime process or delete its Workers or Workdirs.
</p>
{#if trust.status !== 'revoked'}
<p class="section-state warning">
Deletion will revoke this Workspace trust first. Stop or move active Workers before continuing.
</p>
{/if}
<label for="runtime-delete-confirmation">Confirm Runtime ID</label>
<input
id="runtime-delete-confirmation"
@@ -616,11 +613,7 @@
class="danger"
disabled={busyAction !== null || deleteRuntimeConfirmation.trim() !== data.runtimeId}
onclick={deleteRegistration}
>{busyAction === 'delete'
? 'Deleting…'
: trust.status === 'revoked'
? 'Delete registration'
: 'Revoke trust and delete registration'}</button>
>{busyAction === 'delete' ? 'Removing…' : 'Remove Runtime registration'}</button>
</div>
</section>
{/if}
@@ -177,12 +177,13 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as
"Revoke Workspace trust",
"Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.",
"await revokeRuntimeTrustKey(",
"deleteRemoteRuntime(data.workspaceId, operation.runtimeId)",
"Revoke trust and delete registration",
"trust.status !== 'revoked'",
"await removeRemoteRuntime(",
"operation_id: crypto.randomUUID()",
"expected_binding_revision: trust.revision",
"The Backend removes Workspace trust and this Runtime registration as one guarded operation.",
"deleteRuntimeConfirmation.trim() !== data.runtimeId",
"Delete Runtime registration",
"Delete registration",
"Remove Runtime registration",
"This does not stop the Runtime process",
"RuntimeTrustConflictError",
"RuntimeTrustRouteFence",
@@ -211,6 +212,9 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as
"fingerprintConfirmation",
"revokeFingerprintConfirmation",
"Confirm current fingerprint",
"deleteRemoteRuntime",
"if (data.runtimeDetail.trust_key.status !== 'revoked')",
"Revoke trust and delete registration",
]
) {
assert(!page.includes(token), `Runtime detail must not require ${token}`);
+41 -6
View File
@@ -4,12 +4,13 @@ declare const Deno: {
import {
createRemoteRuntime,
deleteRemoteRuntime,
parseRuntimeRemovalOperationResponse,
parseRuntimeTrustConflict,
parseRuntimeTrustKeyRevealResponse,
parseWorkspaceRuntimeDetail,
parseWorkspaceRuntimeList,
previewRuntimePublicKeyFingerprint,
removeRemoteRuntime,
revokeRuntimeTrustKey,
RuntimeTrustConflictError,
RuntimeTrustRouteFence,
@@ -313,22 +314,56 @@ Deno.test("Runtime metadata update never sends public key authority", async () =
assert(!("public_key" in body), "metadata update sent public_key");
});
Deno.test("Runtime registration delete uses the Workspace-scoped resource route", async () => {
Deno.test("Runtime removal uses the Workspace-scoped operation route", async () => {
let requestedUrl = "";
let requestedMethod = "";
let requestedBody: unknown = null;
const fetchImpl = ((input: string | URL | Request, init?: RequestInit) => {
requestedUrl = String(input);
requestedMethod = init?.method ?? "GET";
return Promise.resolve(new Response(null, { status: 204 }));
requestedBody = JSON.parse(String(init?.body));
return Promise.resolve(Response.json({
operation_id: "remove-runtime-a",
workspace_id: "workspace a",
runtime_id: "runtime/a",
state: "succeeded",
binding_removed: true,
runtime_registration_removed: true,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:01Z",
completed_at: "2026-01-01T00:00:01Z",
}));
}) as typeof fetch;
await deleteRemoteRuntime("workspace a", "runtime/a", fetchImpl);
const operation = await removeRemoteRuntime(
"workspace a",
"runtime/a",
{ operation_id: "remove-runtime-a", expected_binding_revision: 7 },
fetchImpl,
);
assert(
requestedUrl === "/api/w/workspace%20a/runtimes/runtime%2Fa",
`unexpected delete URL: ${requestedUrl}`,
`unexpected removal URL: ${requestedUrl}`,
);
assert(requestedMethod === "DELETE", "Runtime removal must use DELETE");
assert(
JSON.stringify(requestedBody) === JSON.stringify({
operation_id: "remove-runtime-a",
expected_binding_revision: 7,
}),
"Runtime removal request body drifted",
);
assert(operation.state === "succeeded", "Runtime removal did not complete");
assertThrows(
() =>
parseRuntimeRemovalOperationResponse({
...operation,
unknown: "rejected",
}),
"not part of the wire contract",
);
assert(requestedMethod === "DELETE", "Runtime delete must use DELETE");
});
Deno.test("Runtime route fence rejects a delayed reveal from the prior Runtime", async () => {