diff --git a/web/workspace/src/lib/generated/workspace-api.ts b/web/workspace/src/lib/generated/workspace-api.ts index 12d78f3f..7c3c42db 100644 --- a/web/workspace/src/lib/generated/workspace-api.ts +++ b/web/workspace/src/lib/generated/workspace-api.ts @@ -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 = { diff --git a/web/workspace/src/lib/workspace/api/runtime-management.ts b/web/workspace/src/lib/workspace/api/runtime-management.ts index 6ed93eb8..00fe6470 100644 --- a/web/workspace/src/lib/workspace/api/runtime-management.ts +++ b/web/workspace/src/lib/workspace/api/runtime-management.ts @@ -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([ "unavailable", "revoked", ]); +const REMOVAL_OPERATION_STATES = new Set([ + "pending", + "cleanup_pending", + "succeeded", + "failed", +]); const encoder = new TextEncoder(); type JsonObject = Record; @@ -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 { +): Promise { 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( diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte index a06ce45b..7ce80ef9 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte @@ -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 @@

Delete Runtime registration

- 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. +

+

+ This does not stop the Runtime process or delete its Workers or Workdirs.

- {#if trust.status !== 'revoked'} -

- Deletion will revoke this Workspace trust first. Stop or move active Workers before continuing. -

- {/if} {busyAction === 'delete' - ? 'Deleting…' - : trust.status === 'revoked' - ? 'Delete registration' - : 'Revoke trust and delete registration'} + >{busyAction === 'delete' ? 'Removing…' : 'Remove Runtime registration'}
{/if} diff --git a/web/workspace/tests/runtime-management-source.test.ts b/web/workspace/tests/runtime-management-source.test.ts index 36120765..37c3229a 100644 --- a/web/workspace/tests/runtime-management-source.test.ts +++ b/web/workspace/tests/runtime-management-source.test.ts @@ -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}`); diff --git a/web/workspace/tests/runtime-management.test.ts b/web/workspace/tests/runtime-management.test.ts index dc6e1a72..67c9c3bf 100644 --- a/web/workspace/tests/runtime-management.test.ts +++ b/web/workspace/tests/runtime-management.test.ts @@ -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 () => {