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 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 RuntimeTrustConflictKind = "stale_revision" | "fingerprint_in_use";
export type RuntimeTrustConflictResponse = { export type RuntimeTrustConflictResponse = {
@@ -1,10 +1,13 @@
import type { import type {
CreateRemoteRuntimeRequest, CreateRemoteRuntimeRequest,
Diagnostic, Diagnostic,
RemoveRuntimeRequest,
RevokeRuntimeTrustKeyRequest, RevokeRuntimeTrustKeyRequest,
RuntimeConnectionDisplayState, RuntimeConnectionDisplayState,
RuntimeIdentityAuthority, RuntimeIdentityAuthority,
RuntimeManagementSummary, RuntimeManagementSummary,
RuntimeRemovalOperationResponse,
RuntimeRemovalOperationState,
RuntimeSourceKind, RuntimeSourceKind,
RuntimeSourceStatus, RuntimeSourceStatus,
RuntimeSourceSummary, RuntimeSourceSummary,
@@ -83,6 +86,12 @@ const CONNECTION_STATES = new Set<RuntimeConnectionDisplayState>([
"unavailable", "unavailable",
"revoked", "revoked",
]); ]);
const REMOVAL_OPERATION_STATES = new Set<RuntimeRemovalOperationState>([
"pending",
"cleanup_pending",
"succeeded",
"failed",
]);
const encoder = new TextEncoder(); const encoder = new TextEncoder();
type JsonObject = Record<string, unknown>; 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 { function revisionForJson(revision: number | null): number | null {
if (revision === null) return null; if (revision === null) return null;
if (!Number.isSafeInteger(revision) || revision < 1) { if (!Number.isSafeInteger(revision) || revision < 1) {
@@ -940,28 +1026,51 @@ export async function updateRemoteRuntime(
return finishMutation(response, workspaceId, runtimeId); return finishMutation(response, workspaceId, runtimeId);
} }
export async function deleteRemoteRuntime( export async function removeRemoteRuntime(
workspaceId: string, workspaceId: string,
runtimeId: string, runtimeId: string,
request: RemoveRuntimeRequest,
fetchImpl: typeof fetch = fetch, fetchImpl: typeof fetch = fetch,
): Promise<void> { ): Promise<RuntimeRemovalOperationResponse> {
const response = await fetchImpl( const response = await fetchImpl(
workspaceApiPath( workspaceApiPath(
workspaceId, workspaceId,
`/runtimes/${encodeURIComponent(runtimeId)}`, `/runtimes/${encodeURIComponent(runtimeId)}`,
), ),
{ method: "DELETE" }, {
method: "DELETE",
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
},
); );
if (response.ok) return;
let payload: unknown; let payload: unknown;
try { try {
payload = await readBoundedJson(response); payload = await readBoundedJson(response);
} catch { } catch {
throw new RuntimeTrustRequestError( 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( export async function revealRuntimeTrustKey(
@@ -6,7 +6,7 @@
} from '$lib/generated/workspace-api'; } from '$lib/generated/workspace-api';
import { import {
createRemoteRuntime, createRemoteRuntime,
deleteRemoteRuntime, removeRemoteRuntime,
previewRuntimePublicKeyFingerprint, previewRuntimePublicKeyFingerprint,
revealRuntimeTrustKey, revealRuntimeTrustKey,
revokeRuntimeTrustKey, revokeRuntimeTrustKey,
@@ -284,36 +284,34 @@
return; 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'; busyAction = 'delete';
deleteRuntimeError = null; deleteRuntimeError = null;
try { try {
if (data.runtimeDetail.trust_key.status !== 'revoked') { await removeRemoteRuntime(
const trust = data.runtimeDetail.trust_key; data.workspaceId,
if (trust.revision == null || !trust.fingerprint) { routeOperation.runtimeId,
throw new Error('Runtime trust revision and fingerprint are required before deletion.'); {
} operation_id: crypto.randomUUID(),
await revokeRuntimeTrustKey( expected_binding_revision: trust.revision,
data.workspaceId, },
operation.runtimeId, );
{ expected_revision: trust.revision }, if (!isCurrentRoute(routeOperation)) return;
trust.fingerprint,
trust.fingerprint,
);
if (!isCurrentRoute(operation)) return;
}
await deleteRemoteRuntime(data.workspaceId, operation.runtimeId);
if (!isCurrentRoute(operation)) return;
await goto(`/w/${encodeURIComponent(data.workspaceId)}/settings/runtimes`, { await goto(`/w/${encodeURIComponent(data.workspaceId)}/settings/runtimes`, {
replaceState: true, replaceState: true,
}); });
} catch (error) { } catch (error) {
if (!isCurrentRoute(operation)) return; if (!isCurrentRoute(routeOperation)) return;
deleteRuntimeError = error instanceof Error deleteRuntimeError = error instanceof Error
? error.message ? error.message
: 'Runtime registration deletion failed.'; : 'Runtime registration deletion failed.';
} finally { } 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"> <section class="runtime-detail-section runtime-danger-zone" aria-labelledby="runtime-delete-heading">
<h2 id="runtime-delete-heading">Delete Runtime registration</h2> <h2 id="runtime-delete-heading">Delete Runtime registration</h2>
<p> <p>
Remove this Runtime binding from the current Workspace. This does not stop the Runtime process, The Backend removes Workspace trust and this Runtime registration as one guarded operation.
delete its Workers or Workdirs, or revoke this Workspace on the Runtime host. 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> </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> <label for="runtime-delete-confirmation">Confirm Runtime ID</label>
<input <input
id="runtime-delete-confirmation" id="runtime-delete-confirmation"
@@ -616,11 +613,7 @@
class="danger" class="danger"
disabled={busyAction !== null || deleteRuntimeConfirmation.trim() !== data.runtimeId} disabled={busyAction !== null || deleteRuntimeConfirmation.trim() !== data.runtimeId}
onclick={deleteRegistration} onclick={deleteRegistration}
>{busyAction === 'delete' >{busyAction === 'delete' ? 'Removing…' : 'Remove Runtime registration'}</button>
? 'Deleting…'
: trust.status === 'revoked'
? 'Delete registration'
: 'Revoke trust and delete registration'}</button>
</div> </div>
</section> </section>
{/if} {/if}
@@ -177,12 +177,13 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as
"Revoke Workspace trust", "Revoke Workspace trust",
"Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.", "Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.",
"await revokeRuntimeTrustKey(", "await revokeRuntimeTrustKey(",
"deleteRemoteRuntime(data.workspaceId, operation.runtimeId)", "await removeRemoteRuntime(",
"Revoke trust and delete registration", "operation_id: crypto.randomUUID()",
"trust.status !== 'revoked'", "expected_binding_revision: trust.revision",
"The Backend removes Workspace trust and this Runtime registration as one guarded operation.",
"deleteRuntimeConfirmation.trim() !== data.runtimeId", "deleteRuntimeConfirmation.trim() !== data.runtimeId",
"Delete Runtime registration", "Delete Runtime registration",
"Delete registration", "Remove Runtime registration",
"This does not stop the Runtime process", "This does not stop the Runtime process",
"RuntimeTrustConflictError", "RuntimeTrustConflictError",
"RuntimeTrustRouteFence", "RuntimeTrustRouteFence",
@@ -211,6 +212,9 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as
"fingerprintConfirmation", "fingerprintConfirmation",
"revokeFingerprintConfirmation", "revokeFingerprintConfirmation",
"Confirm current fingerprint", "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}`); assert(!page.includes(token), `Runtime detail must not require ${token}`);
+41 -6
View File
@@ -4,12 +4,13 @@ declare const Deno: {
import { import {
createRemoteRuntime, createRemoteRuntime,
deleteRemoteRuntime, parseRuntimeRemovalOperationResponse,
parseRuntimeTrustConflict, parseRuntimeTrustConflict,
parseRuntimeTrustKeyRevealResponse, parseRuntimeTrustKeyRevealResponse,
parseWorkspaceRuntimeDetail, parseWorkspaceRuntimeDetail,
parseWorkspaceRuntimeList, parseWorkspaceRuntimeList,
previewRuntimePublicKeyFingerprint, previewRuntimePublicKeyFingerprint,
removeRemoteRuntime,
revokeRuntimeTrustKey, revokeRuntimeTrustKey,
RuntimeTrustConflictError, RuntimeTrustConflictError,
RuntimeTrustRouteFence, 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"); 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 requestedUrl = "";
let requestedMethod = ""; let requestedMethod = "";
let requestedBody: unknown = null;
const fetchImpl = ((input: string | URL | Request, init?: RequestInit) => { const fetchImpl = ((input: string | URL | Request, init?: RequestInit) => {
requestedUrl = String(input); requestedUrl = String(input);
requestedMethod = init?.method ?? "GET"; 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; }) 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( assert(
requestedUrl === "/api/w/workspace%20a/runtimes/runtime%2Fa", 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 () => { Deno.test("Runtime route fence rejects a delayed reveal from the prior Runtime", async () => {