feat: guarded Worker Restore from shared Web actions (T-701)

This commit is contained in:
2026-10-06 17:45:54 +09:00
parent 1a6d391f83
commit fdce12a47b
54 changed files with 7214 additions and 527 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@8.3.2 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts",
"test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts",
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
"build": "deno run -A npm:vite@8.3.2 build",
"preview": "deno run -A npm:vite@8.3.2 preview",
@@ -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:6001e9f7273288195fb5a9859e4a5932a521d2f2e7c3a9ecd8ec660fd6a37779)
// Input contract: 0.1.0 (sha256:fd06f382c6d33a5c3cc98ef9e264545923c53fa95a9be555a85680f98c6065da)
// 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
File diff suppressed because one or more lines are too long
+5 -1
View File
@@ -359,7 +359,11 @@ alias: string, repository_key?: string | null, working_directory_id: Subscriptio
export type SubscriptionWorkerJob = { job_id: string, attempt_id: string, purpose: string, };
export type SubscriptionWorker = { worker_id: SubscriptionWorkerId,
export type SubscriptionWorker = {
/**
* Opaque Runtime-owned Restore observation identity, never a display-state hash.
*/
restore_observation_token?: string | null, worker_id: SubscriptionWorkerId,
/**
* Set by the Workspace Server when projecting a Runtime-owned Worker to clients.
* Runtime producers leave this unset because the connection identifies the Runtime.
@@ -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:6001e9f7273288195fb5a9859e4a5932a521d2f2e7c3a9ecd8ec660fd6a37779)
// Input contract: 0.1.0 (sha256:fd06f382c6d33a5c3cc98ef9e264545923c53fa95a9be555a85680f98c6065da)
// 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
@@ -71,7 +71,7 @@ export type WorkerState = { kind: "idle"; } | { kind: "busy"; state: WorkerBusyS
export type WorkerStateSnapshot = { last_command_id: number; last_finished_submission_request_id?: string | null; state: WorkerState; };
export type WorkerSummary = { availability: SubscriptionWorkerAvailability; diagnostics?: Array<Diagnostic>; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array<string>; workdir_attachments?: Array<WorkerWorkdirAttachmentSummary>; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; };
export type WorkerSummary = { availability: SubscriptionWorkerAvailability; diagnostics?: Array<Diagnostic>; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; restore_observation_token?: string | null; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array<string>; workdir_attachments?: Array<WorkerWorkdirAttachmentSummary>; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; };
export type WorkerWorkdirAttachmentSummary = { alias: string; effective_permissions: ExternalWorkdirPermissions; working_directory: WorkingDirectorySummary; };
@@ -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:6001e9f7273288195fb5a9859e4a5932a521d2f2e7c3a9ecd8ec660fd6a37779)
// Input contract: 0.1.0 (sha256:fd06f382c6d33a5c3cc98ef9e264545923c53fa95a9be555a85680f98c6065da)
// 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
@@ -139,7 +139,7 @@ export type RuntimeWorkerLifecycleResult = { diagnostics?: Array<Diagnostic>; ru
export type RuntimeWorkerListResponse = { diagnostics?: Array<Diagnostic>; items: Array<WorkerSummary>; limit: number; source: string; workspace_id: string; };
export type RuntimeWorkerShowResponse = { availability: SubscriptionWorkerAvailability; diagnostics?: Array<Diagnostic>; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array<string>; updated_at: string; workdir_attachments?: Array<WorkerWorkdirAttachmentSummary>; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; };
export type RuntimeWorkerShowResponse = { availability: SubscriptionWorkerAvailability; diagnostics?: Array<Diagnostic>; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; restore_observation_token?: string | null; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array<string>; updated_at: string; workdir_attachments?: Array<WorkerWorkdirAttachmentSummary>; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; };
export type RuntimeWorkerSpawnAcceptanceEvidence = { detail: string; kind: string; };
@@ -181,6 +181,8 @@ export type WorkerMaintenanceState = "compacting";
export type WorkerOperationState = "accepted" | "unsupported" | "rejected";
export type WorkerRestoreRequest = { expected_observation_token: string; request_id: string; };
export type WorkerRestoreResponse = { result: WorkerRestoreResult; runtime_id: string; worker_id: string; workspace_id: string; };
export type WorkerRestoreResult = { diagnostics?: Array<Diagnostic>; state: WorkerRestoreState; worker?: WorkerSummary | null; };
@@ -195,7 +197,7 @@ export type WorkerState = { kind: "idle"; } | { kind: "busy"; state: WorkerBusyS
export type WorkerStateSnapshot = { last_command_id: number; last_finished_submission_request_id?: string | null; state: WorkerState; };
export type WorkerSummary = { availability: SubscriptionWorkerAvailability; diagnostics?: Array<Diagnostic>; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array<string>; workdir_attachments?: Array<WorkerWorkdirAttachmentSummary>; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; };
export type WorkerSummary = { availability: SubscriptionWorkerAvailability; diagnostics?: Array<Diagnostic>; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; restore_observation_token?: string | null; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array<string>; workdir_attachments?: Array<WorkerWorkdirAttachmentSummary>; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; };
export type WorkerWorkdirAttachmentSummary = { alias: string; effective_permissions: ExternalWorkdirPermissions; working_directory: WorkingDirectorySummary; };
@@ -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:6001e9f7273288195fb5a9859e4a5932a521d2f2e7c3a9ecd8ec660fd6a37779)
// Input contract: 0.1.0 (sha256:fd06f382c6d33a5c3cc98ef9e264545923c53fa95a9be555a85680f98c6065da)
// 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:6001e9f7273288195fb5a9859e4a5932a521d2f2e7c3a9ecd8ec660fd6a37779)
// Input contract: 0.1.0 (sha256:fd06f382c6d33a5c3cc98ef9e264545923c53fa95a9be555a85680f98c6065da)
// 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
@@ -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:6001e9f7273288195fb5a9859e4a5932a521d2f2e7c3a9ecd8ec660fd6a37779)
// Input contract: 0.1.0 (sha256:fd06f382c6d33a5c3cc98ef9e264545923c53fa95a9be555a85680f98c6065da)
// 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
@@ -77,7 +77,7 @@ export type WorkerState = { kind: "idle"; } | { kind: "busy"; state: WorkerBusyS
export type WorkerStateSnapshot = { last_command_id: number; last_finished_submission_request_id?: string | null; state: WorkerState; };
export type WorkerSummary = { availability: SubscriptionWorkerAvailability; diagnostics?: Array<Diagnostic>; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array<string>; workdir_attachments?: Array<WorkerWorkdirAttachmentSummary>; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; };
export type WorkerSummary = { availability: SubscriptionWorkerAvailability; diagnostics?: Array<Diagnostic>; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; restore_observation_token?: string | null; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array<string>; workdir_attachments?: Array<WorkerWorkdirAttachmentSummary>; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; };
export type WorkerWorkdirAttachmentSummary = { alias: string; effective_permissions: ExternalWorkdirPermissions; working_directory: WorkingDirectorySummary; };
@@ -10,9 +10,13 @@ import type {
RuntimeCleanupPlanResponse,
RuntimeWorkerLifecycleResult,
WorkerOperationState,
WorkerRestoreResponse,
WorkerRestoreState,
WorkerRetentionResponse,
} from "#lib/generated/runtime-api.ts";
import { parseWorkerSummary } from "#lib/workspace/api/workers.ts";
const MAX_STRING_BYTES = 4_096;
const MAX_CANDIDATES = 1_000;
const MAX_RESULTS = 2_000;
@@ -68,14 +72,20 @@ function exactKeys(
): void {
const allowed = new Set([...required, ...optional]);
for (const key of Object.keys(value)) {
if (!allowed.has(key)) fail(`${path}.${key}`, "is not part of the wire contract");
if (!allowed.has(key)) {
fail(`${path}.${key}`, "is not part of the wire contract");
}
}
for (const key of required) {
if (!Object.hasOwn(value, key)) fail(`${path}.${key}`, "is required");
}
}
function boundedString(value: unknown, path: string, allowEmpty = false): string {
function boundedString(
value: unknown,
path: string,
allowEmpty = false,
): string {
if (typeof value !== "string") return fail(path, "must be a string");
if (!allowEmpty && value.length === 0) return fail(path, "must not be empty");
if (encoder.encode(value).byteLength > MAX_STRING_BYTES) {
@@ -91,7 +101,9 @@ function boolean(value: unknown, path: string): boolean {
function array(value: unknown, path: string, max: number): unknown[] {
if (!Array.isArray(value)) return fail(path, "must be an array");
if (value.length > max) return fail(path, `must contain at most ${max} items`);
if (value.length > max) {
return fail(path, `must contain at most ${max} items`);
}
return value;
}
@@ -101,12 +113,18 @@ function strings(value: unknown, path: string): string[] {
);
}
function optionalNullableString(value: unknown, path: string): string | null | undefined {
function optionalNullableString(
value: unknown,
path: string,
): string | null | undefined {
if (value === undefined || value === null) return value;
return boundedString(value, path);
}
function optionalNullableSafeInteger(value: unknown, path: string): number | null | undefined {
function optionalNullableSafeInteger(
value: unknown,
path: string,
): number | null | undefined {
if (value === undefined || value === null) return value;
if (!Number.isSafeInteger(value) || (value as number) < 0) {
return fail(path, "must be a non-negative safe integer");
@@ -115,7 +133,9 @@ function optionalNullableSafeInteger(value: unknown, path: string): number | nul
}
function targetKind(value: unknown, path: string): CleanupTargetKind {
if (typeof value !== "string" || !TARGET_KINDS.has(value as CleanupTargetKind)) {
if (
typeof value !== "string" || !TARGET_KINDS.has(value as CleanupTargetKind)
) {
return fail(path, "has an unknown cleanup action");
}
return value as CleanupTargetKind;
@@ -164,13 +184,25 @@ function workerCandidate(value: unknown, path: string): CleanupWorkerCandidate {
target_id: boundedString(item.target_id, `${path}.target_id`),
action: targetKind(item.action, `${path}.action`),
worker_id: boundedString(item.worker_id, `${path}.worker_id`),
runtime_worker_id: boundedString(item.runtime_worker_id, `${path}.runtime_worker_id`),
runtime_worker_id: boundedString(
item.runtime_worker_id,
`${path}.runtime_worker_id`,
),
runtime_id: boundedString(item.runtime_id, `${path}.runtime_id`),
reason: boundedString(item.reason, `${path}.reason`, true),
blocking_reason: optionalNullableString(item.blocking_reason, `${path}.blocking_reason`),
blocking_reason: optionalNullableString(
item.blocking_reason,
`${path}.blocking_reason`,
),
pinned: boolean(item.pinned, `${path}.pinned`),
retention_state: boundedString(item.retention_state, `${path}.retention_state`),
linked_workdir_ids: strings(item.linked_workdir_ids, `${path}.linked_workdir_ids`),
retention_state: boundedString(
item.retention_state,
`${path}.retention_state`,
),
linked_workdir_ids: strings(
item.linked_workdir_ids,
`${path}.linked_workdir_ids`,
),
running_linked: boolean(item.running_linked, `${path}.running_linked`),
estimated_reclaim_bytes: optionalNullableSafeInteger(
item.estimated_reclaim_bytes,
@@ -179,7 +211,10 @@ function workerCandidate(value: unknown, path: string): CleanupWorkerCandidate {
};
}
function workdirCandidate(value: unknown, path: string): CleanupWorkdirCandidate {
function workdirCandidate(
value: unknown,
path: string,
): CleanupWorkdirCandidate {
const item = object(value, path);
exactKeys(
item,
@@ -213,10 +248,19 @@ function workdirCandidate(value: unknown, path: string): CleanupWorkdirCandidate
action: targetKind(item.action, `${path}.action`),
workdir_id: boundedString(item.workdir_id, `${path}.workdir_id`),
runtime_id: boundedString(item.runtime_id, `${path}.runtime_id`),
repository_key: boundedString(item.repository_key, `${path}.repository_key`),
repository_key: boundedString(
item.repository_key,
`${path}.repository_key`,
),
reason: boundedString(item.reason, `${path}.reason`, true),
blocking_reason: optionalNullableString(item.blocking_reason, `${path}.blocking_reason`),
linked_worker_ids: strings(item.linked_worker_ids, `${path}.linked_worker_ids`),
blocking_reason: optionalNullableString(
item.blocking_reason,
`${path}.blocking_reason`,
),
linked_worker_ids: strings(
item.linked_worker_ids,
`${path}.linked_worker_ids`,
),
linked_running_worker_ids: strings(
item.linked_running_worker_ids,
`${path}.linked_running_worker_ids`,
@@ -232,7 +276,9 @@ function workdirCandidate(value: unknown, path: string): CleanupWorkdirCandidate
};
}
export function parseWorkerRetentionResponse(value: unknown): WorkerRetentionResponse {
export function parseWorkerRetentionResponse(
value: unknown,
): WorkerRetentionResponse {
const item = object(value, "Worker retention response");
exactKeys(
item,
@@ -241,9 +287,18 @@ export function parseWorkerRetentionResponse(value: unknown): WorkerRetentionRes
"Worker retention response",
);
return {
workspace_id: boundedString(item.workspace_id, "Worker retention response.workspace_id"),
runtime_id: boundedString(item.runtime_id, "Worker retention response.runtime_id"),
worker_id: boundedString(item.worker_id, "Worker retention response.worker_id"),
workspace_id: boundedString(
item.workspace_id,
"Worker retention response.workspace_id",
),
runtime_id: boundedString(
item.runtime_id,
"Worker retention response.runtime_id",
),
worker_id: boundedString(
item.worker_id,
"Worker retention response.worker_id",
),
pinned: boolean(item.pinned, "Worker retention response.pinned"),
retention_state: boundedString(
item.retention_state,
@@ -268,7 +323,10 @@ export function parseRuntimeWorkerLifecycleResult(
[],
"Runtime Worker lifecycle result",
);
const state = boundedString(item.state, "Runtime Worker lifecycle result.state");
const state = boundedString(
item.state,
"Runtime Worker lifecycle result.state",
);
if (!WORKER_OPERATION_STATES.has(state as WorkerOperationState)) {
fail("Runtime Worker lifecycle result.state", "has an unknown value");
}
@@ -289,7 +347,58 @@ export function parseRuntimeWorkerLifecycleResult(
};
}
export function parseRuntimeCleanupPlan(value: unknown): RuntimeCleanupPlanResponse {
/** Restore is a wrapped Workspace response, not the flat Stop lifecycle result. */
export function parseWorkerRestoreResponse(
value: unknown,
): WorkerRestoreResponse {
const path = "Worker Restore response";
const item = object(value, path);
exactKeys(
item,
["workspace_id", "runtime_id", "worker_id", "result"],
[],
path,
);
const result = object(item.result, `${path}.result`);
exactKeys(result, ["state"], ["diagnostics", "worker"], `${path}.result`);
const state = boundedString(result.state, `${path}.result.state`);
if (
!["accepted", "rejected", "rolled_back", "reconciliation_required"]
.includes(state)
) {
fail(`${path}.result.state`, "has an unknown value");
}
const workspace_id = boundedString(item.workspace_id, `${path}.workspace_id`);
const runtime_id = boundedString(item.runtime_id, `${path}.runtime_id`);
const worker_id = boundedString(item.worker_id, `${path}.worker_id`);
const worker = result.worker == null
? result.worker as null | undefined
: parseWorkerSummary(result.worker);
if (
worker &&
(worker.runtime_id !== runtime_id || worker.worker_id !== worker_id ||
(worker.workspace.workspace_id != null &&
worker.workspace.workspace_id !== workspace_id))
) {
fail(`${path}.result.worker`, "does not match the response identity");
}
return {
workspace_id,
runtime_id,
worker_id,
result: {
state: state as WorkerRestoreState,
diagnostics: result.diagnostics === undefined
? []
: diagnostics(result.diagnostics, `${path}.result.diagnostics`),
worker,
},
};
}
export function parseRuntimeCleanupPlan(
value: unknown,
): RuntimeCleanupPlanResponse {
const item = object(value, "Runtime cleanup plan");
exactKeys(
item,
@@ -307,22 +416,44 @@ export function parseRuntimeCleanupPlan(value: unknown): RuntimeCleanupPlanRespo
"Runtime cleanup plan",
);
return {
workspace_id: boundedString(item.workspace_id, "Runtime cleanup plan.workspace_id"),
runtime_id: boundedString(item.runtime_id, "Runtime cleanup plan.runtime_id"),
generated_at: boundedString(item.generated_at, "Runtime cleanup plan.generated_at"),
workspace_id: boundedString(
item.workspace_id,
"Runtime cleanup plan.workspace_id",
),
runtime_id: boundedString(
item.runtime_id,
"Runtime cleanup plan.runtime_id",
),
generated_at: boundedString(
item.generated_at,
"Runtime cleanup plan.generated_at",
),
revision: boundedString(item.revision, "Runtime cleanup plan.revision"),
digest: boundedString(item.digest, "Runtime cleanup plan.digest"),
workers: array(item.workers, "Runtime cleanup plan.workers", MAX_CANDIDATES).map(
(entry, index) => workerCandidate(entry, `Runtime cleanup plan.workers[${index}]`),
workers: array(item.workers, "Runtime cleanup plan.workers", MAX_CANDIDATES)
.map(
(entry, index) =>
workerCandidate(entry, `Runtime cleanup plan.workers[${index}]`),
),
workdirs: array(
item.workdirs,
"Runtime cleanup plan.workdirs",
MAX_CANDIDATES,
).map(
(entry, index) =>
workdirCandidate(entry, `Runtime cleanup plan.workdirs[${index}]`),
),
workdirs: array(item.workdirs, "Runtime cleanup plan.workdirs", MAX_CANDIDATES).map(
(entry, index) => workdirCandidate(entry, `Runtime cleanup plan.workdirs[${index}]`),
diagnostics: diagnostics(
item.diagnostics,
"Runtime cleanup plan.diagnostics",
),
diagnostics: diagnostics(item.diagnostics, "Runtime cleanup plan.diagnostics"),
};
}
function executionResult(value: unknown, path: string): RuntimeCleanupExecutionResult {
function executionResult(
value: unknown,
path: string,
): RuntimeCleanupExecutionResult {
const item = object(value, path);
exactKeys(item, ["target_id", "action", "status", "message"], [], path);
return {
@@ -339,18 +470,42 @@ export function parseRuntimeCleanupExecution(
const item = object(value, "Runtime cleanup execution");
exactKeys(
item,
["workspace_id", "runtime_id", "executed_at", "results", "plan_after", "diagnostics"],
[
"workspace_id",
"runtime_id",
"executed_at",
"results",
"plan_after",
"diagnostics",
],
[],
"Runtime cleanup execution",
);
return {
workspace_id: boundedString(item.workspace_id, "Runtime cleanup execution.workspace_id"),
runtime_id: boundedString(item.runtime_id, "Runtime cleanup execution.runtime_id"),
executed_at: boundedString(item.executed_at, "Runtime cleanup execution.executed_at"),
results: array(item.results, "Runtime cleanup execution.results", MAX_RESULTS).map(
(entry, index) => executionResult(entry, `Runtime cleanup execution.results[${index}]`),
workspace_id: boundedString(
item.workspace_id,
"Runtime cleanup execution.workspace_id",
),
runtime_id: boundedString(
item.runtime_id,
"Runtime cleanup execution.runtime_id",
),
executed_at: boundedString(
item.executed_at,
"Runtime cleanup execution.executed_at",
),
results: array(
item.results,
"Runtime cleanup execution.results",
MAX_RESULTS,
).map(
(entry, index) =>
executionResult(entry, `Runtime cleanup execution.results[${index}]`),
),
plan_after: parseRuntimeCleanupPlan(item.plan_after),
diagnostics: diagnostics(item.diagnostics, "Runtime cleanup execution.diagnostics"),
diagnostics: diagnostics(
item.diagnostics,
"Runtime cleanup execution.diagnostics",
),
};
}
+79 -31
View File
@@ -97,7 +97,11 @@ function optional<T>(
label: string,
parse: (item: unknown, label: string) => T,
): T | null | undefined {
return value === undefined ? undefined : value === null ? null : parse(value, label);
return value === undefined
? undefined
: value === null
? null
: parse(value, label);
}
function diagnostic(value: unknown, label: string): Diagnostic {
@@ -372,6 +376,7 @@ export function parseWorkerSummary(
"availability",
"state",
"worker_state",
"restore_observation_token",
"last_seen_at",
"pinned",
"retention_state",
@@ -406,13 +411,28 @@ export function parseWorkerSummary(
`${label}.singleton_key`,
string,
),
tags: item.tags === undefined ? [] : array(item.tags, `${label}.tags`, string),
tags: item.tags === undefined
? []
: array(item.tags, `${label}.tags`, string),
workspace: workspaceSummary(item.workspace, `${label}.workspace`),
availability,
state: string(item.state, `${label}.state`),
worker_state: workerState,
restore_observation_token: optional(
item.restore_observation_token,
`${label}.restore_observation_token`,
(value, path) => {
const token = string(value, path);
if (new TextEncoder().encode(token).byteLength > 256) {
throw new Error(`${path} exceeds its 256 UTF-8 byte limit`);
}
return token;
},
),
last_seen_at: optional(item.last_seen_at, `${label}.last_seen_at`, string),
pinned: item.pinned === undefined ? false : boolean(item.pinned, `${label}.pinned`),
pinned: item.pinned === undefined
? false
: boolean(item.pinned, `${label}.pinned`),
retention_state: item.retention_state === undefined
? ""
: string(item.retention_state, `${label}.retention_state`),
@@ -420,11 +440,13 @@ export function parseWorkerSummary(
item.implementation,
`${label}.implementation`,
),
workdir_attachments: item.workdir_attachments === undefined ? undefined : array(
item.workdir_attachments,
`${label}.workdir_attachments`,
workerWorkdirAttachment,
),
workdir_attachments: item.workdir_attachments === undefined
? undefined
: array(
item.workdir_attachments,
`${label}.workdir_attachments`,
workerWorkdirAttachment,
),
diagnostics: item.diagnostics === undefined
? []
: array(item.diagnostics, `${label}.diagnostics`, diagnostic),
@@ -634,11 +656,13 @@ function workerSummary(
item.implementation,
`${label}.implementation`,
),
workdir_attachments: item.workdir_attachments === undefined ? undefined : array(
item.workdir_attachments,
`${label}.workdir_attachments`,
runtimeWorkdirAttachment,
),
workdir_attachments: item.workdir_attachments === undefined
? undefined
: array(
item.workdir_attachments,
`${label}.workdir_attachments`,
runtimeWorkdirAttachment,
),
diagnostics: array(item.diagnostics, `${label}.diagnostics`, diagnostic),
};
}
@@ -728,11 +752,17 @@ function featureConnectionsRequest(
): WorkspaceWorkerFeatureConnectionsRequest {
const item = record(value, label);
exact(item, ["subjektiv"], label);
const subjektiv = optional(item.subjektiv, `${label}.subjektiv`, (value, itemLabel) => {
const connection = record(value, itemLabel);
exact(connection, ["subject_id"], itemLabel);
return { subject_id: string(connection.subject_id, `${itemLabel}.subject_id`) };
});
const subjektiv = optional(
item.subjektiv,
`${label}.subjektiv`,
(value, itemLabel) => {
const connection = record(value, itemLabel);
exact(connection, ["subject_id"], itemLabel);
return {
subject_id: string(connection.subject_id, `${itemLabel}.subject_id`),
};
},
);
return subjektiv === undefined ? {} : { subjektiv };
}
@@ -841,18 +871,32 @@ function uploadedFile(
function invocationValue(
value: unknown,
label: string,
): Extract<Extract<Segment, { kind: "feature_invoke" }>["invocation"]["arguments"][number], { name: string }>["value"] {
): Extract<
Extract<
Segment,
{ kind: "feature_invoke" }
>["invocation"]["arguments"][number],
{ name: string }
>["value"] {
const item = record(value, label);
const kind = string(item.kind, `${label}.kind`);
exact(item, ["kind", "value"], label);
if (kind === "string") return { kind, value: string(item.value, `${label}.value`) };
if (kind === "string") {
return { kind, value: string(item.value, `${label}.value`) };
}
if (kind === "integer") {
const parsed = number(item.value, `${label}.value`);
if (!Number.isSafeInteger(parsed)) throw new Error(`${label}.value must be a safe integer`);
if (parsed < -2147483648 || parsed > 2147483647) throw new Error(`${label}.value must be a signed 32-bit integer`);
if (!Number.isSafeInteger(parsed)) {
throw new Error(`${label}.value must be a safe integer`);
}
if (parsed < -2147483648 || parsed > 2147483647) {
throw new Error(`${label}.value must be a signed 32-bit integer`);
}
return { kind, value: parsed };
}
if (kind === "boolean") return { kind, value: boolean(item.value, `${label}.value`) };
if (kind === "boolean") {
return { kind, value: boolean(item.value, `${label}.value`) };
}
throw new Error(`${label}.kind is invalid`);
}
@@ -866,14 +910,18 @@ function featureInvocation(
invocation_id: string(item.invocation_id, `${label}.invocation_id`),
identity: string(item.identity, `${label}.identity`),
name: string(item.name, `${label}.name`),
arguments: array(item.arguments, `${label}.arguments`, (argument, argumentLabel) => {
const entry = record(argument, argumentLabel);
exact(entry, ["name", "value"], argumentLabel);
return {
name: string(entry.name, `${argumentLabel}.name`),
value: invocationValue(entry.value, `${argumentLabel}.value`),
};
}),
arguments: array(
item.arguments,
`${label}.arguments`,
(argument, argumentLabel) => {
const entry = record(argument, argumentLabel);
exact(entry, ["name", "value"], argumentLabel);
return {
name: string(entry.name, `${argumentLabel}.name`),
value: invocationValue(entry.value, `${argumentLabel}.value`),
};
},
),
};
}
@@ -1,8 +1,15 @@
<script lang="ts">
import { untrack } from 'svelte';
import Spinner from '#lib/workspace/console/Spinner.svelte';
import { workerConsoleHref } from '#lib/workspace/resource-links.ts';
import { pushWorkspaceAlert } from '#lib/workspace/alerts/store.ts';
import {
canRestoreWorker,
createRestoreRequest,
restoreWorkspaceWorker,
restoreNotice,
restoreErrorNotice,
type RestoreRequest,
canStopSidebarWorker,
canDeleteSidebarWorker,
deleteSidebarWorker,
@@ -10,6 +17,7 @@
} from './worker-actions';
import {
workspaceWorkersStore,
refreshWorkspaceWorkers,
type SidebarWorker,
} from './worker-subscription';
import {
@@ -21,7 +29,7 @@
import { sidebarWorkdirMeta } from './worker-workdir-meta';
const COLLAPSED_WORKER_COUNT = 6;
type WorkerActionKind = 'stop' | 'delete';
type WorkerActionKind = 'restore' | 'stop' | 'delete';
type Props = {
currentPath?: string;
@@ -35,6 +43,7 @@
let openWorkerKey = $state<string | null>(null);
let menuElement = $state<HTMLElement | null>(null);
let menuTrigger = $state<HTMLButtonElement | null>(null);
let restoreRetries = $state<Record<string, RestoreRequest>>({});
let busyAction = $state<{ workerKey: string; kind: WorkerActionKind } | null>(null);
let visibleWorkers = $derived(
visibleWorkersForSidebar(workers, {
@@ -90,58 +99,104 @@
closeWorkerMenu(true);
}
let lifetime = 0;
function actionScope() {
const id = workspaceId;
const epoch = lifetime;
return { workspaceId: id, isCurrent: () => lifetime === epoch && workspaceId === id };
}
async function restoreWorker(worker: SidebarWorker, retry = false) {
const key = workerKey(worker);
if (busyAction || (retry ? !restoreRetries[key] : restoreRetries[key] || !canRestoreWorker(worker))) return;
const scope = actionScope();
const request = retry ? restoreRetries[key] : createRestoreRequest(worker);
closeWorkerMenu();
busyAction = { workerKey: key, kind: 'restore' };
try {
let notice;
try {
notice = restoreNotice(await restoreWorkspaceWorker(scope.workspaceId, worker, request));
} catch (cause) {
notice = restoreErrorNotice(cause);
}
if (!scope.isCurrent()) return;
const next = { ...restoreRetries };
if (notice.retry) next[key] = request;
else delete next[key];
restoreRetries = next;
pushWorkspaceAlert(notice.level, notice.message, { title: notice.title });
await refreshWorkspaceWorkers(scope.workspaceId).catch(() => {});
} finally {
if (scope.isCurrent()) busyAction = null;
}
}
async function stopWorker(worker: SidebarWorker) {
if (busyAction || !canStopSidebarWorker(worker)) return;
const scope = actionScope();
closeWorkerMenu();
busyAction = { workerKey: workerKey(worker), kind: 'stop' };
try {
await stopSidebarWorker(workspaceId, worker);
workers = workers.map((item) =>
workerKey(item) === workerKey(worker)
? { ...item, state: 'stopped', lifecycleState: 'stopped' }
: item
);
await stopSidebarWorker(scope.workspaceId, worker);
if (!scope.isCurrent()) return;
await refreshWorkspaceWorkers(scope.workspaceId);
if (!scope.isCurrent()) return;
pushWorkspaceAlert('info', `${worker.display_name || worker.label} stopped`, {
title: 'Worker stopped',
});
} catch (cause) {
if (!scope.isCurrent()) return;
pushWorkspaceAlert('error', cause instanceof Error ? cause.message : 'Worker stop failed', {
title: 'Worker stop failed',
});
} finally {
busyAction = null;
if (scope.isCurrent()) busyAction = null;
}
}
async function deleteWorker(worker: SidebarWorker) {
if (busyAction || !canDeleteSidebarWorker(worker)) return;
const scope = actionScope();
closeWorkerMenu();
busyAction = { workerKey: workerKey(worker), kind: 'delete' };
try {
await deleteSidebarWorker(workspaceId, worker);
workers = workers.filter((item) => workerKey(item) !== workerKey(worker));
await deleteSidebarWorker(scope.workspaceId, worker, (input, init) => {
if (!scope.isCurrent()) throw new Error('Worker action is no longer current');
return fetch(input, init);
});
if (!scope.isCurrent()) return;
await refreshWorkspaceWorkers(scope.workspaceId);
if (!scope.isCurrent()) return;
pushWorkspaceAlert('info', `${worker.display_name || worker.label} deleted`, {
title: 'Worker deleted',
});
} catch (cause) {
if (!scope.isCurrent()) return;
pushWorkspaceAlert('error', cause instanceof Error ? cause.message : 'Worker deletion failed', {
title: 'Worker deletion failed',
});
} finally {
busyAction = null;
if (scope.isCurrent()) busyAction = null;
}
}
$effect(() => {
const id = workspaceId;
lifetime++;
busyAction = null;
restoreRetries = {};
expanded = false;
openWorkerKey = null;
menuElement = null;
menuTrigger = null;
const subscription = workspaceWorkersStore(workspaceId);
return subscription.subscribe((state) => {
untrack(closeWorkerMenu);
const unsubscribe = workspaceWorkersStore(id).subscribe((state) => {
loading = state.loading;
workers = state.workers.filter(canShowWorkerInSidebar);
});
return () => {
lifetime++;
unsubscribe();
};
});
</script>
@@ -221,6 +276,23 @@
</button>
{#if openWorkerKey === key}
<div class="worker-actions-menu" role="menu" aria-label={`Actions for ${label}`} bind:this={menuElement}>
<button
type="button"
role="menuitem"
disabled={busyAction !== null || !canRestoreWorker(worker) || Boolean(restoreRetries[key])}
onclick={() => restoreWorker(worker)}
>
{isBusy(worker, 'restore') ? 'Restoring…' : 'Restore'}
</button>
{#if restoreRetries[key]}
<button
type="button"
role="menuitem"
disabled={busyAction !== null}
onclick={() => restoreWorker(worker, true)}
>Retry Restore</button>
<p role="status">Restore outcome unresolved. Retry the same request to reconcile.</p>
{/if}
<button
type="button"
role="menuitem"
@@ -4,6 +4,8 @@ import type {
CleanupWorkerCandidate,
RuntimeCleanupExecutionResponse,
RuntimeCleanupPlanResponse,
WorkerRestoreResult,
WorkerRestoreState,
} from "#lib/generated/runtime-api.ts";
import type {
BrowserCreateWorkerResponse as SharedBrowserCreateWorkerResponse,
@@ -13,7 +15,6 @@ import type {
WorkerLaunchOptionsResponse as SharedWorkerLaunchOptionsResponse,
WorkerLaunchProfileCandidate as SharedWorkerLaunchProfileCandidate,
WorkerLaunchRuntimeOption as SharedWorkerLaunchRuntimeOption,
WorkerLaunchWorkerSummary,
WorkerSummary as SharedWorkerSummary,
WorkerWorkdirAttachmentSummary as SharedWorkerWorkdirAttachmentSummary,
WorkingDirectoryRepositoryOption as SharedWorkingDirectoryRepositoryOption,
@@ -95,18 +96,7 @@ export type Worker =
export type WorkerOperationState = "accepted" | "unsupported" | "rejected";
/** Typed result of restore across Runtime, Workspace, Web, and TUI clients. */
export type WorkerRestoreState =
| "accepted"
| "rejected"
| "rolled_back"
| "reconciliation_required";
export type WorkerRestoreResult = {
state: WorkerRestoreState;
worker?: WorkerLaunchWorkerSummary | null;
diagnostics: Diagnostic[];
};
export type { WorkerRestoreResult, WorkerRestoreState };
export type WorkerLaunchRuntimeOption = SharedWorkerLaunchRuntimeOption;
export type WorkerLaunchProfileCandidate = SharedWorkerLaunchProfileCandidate;
@@ -1,8 +1,10 @@
import { workspaceApiPath } from "#lib/workspace/api/http.ts";
import type { WorkerRestoreRequest } from "#lib/generated/runtime-api.ts";
import { readBoundedJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
import {
parseRuntimeCleanupExecution,
parseRuntimeCleanupPlan,
parseRuntimeWorkerLifecycleResult,
parseWorkerRestoreResponse,
} from "#lib/workspace/api/runtime-workers.ts";
import type { Diagnostic, Worker } from "./types";
@@ -21,7 +23,7 @@ function workerPath(workspaceId: string, worker: WorkerActionTarget): string {
async function responseError(response: Response): Promise<string> {
const fallback = `${response.status} ${response.statusText}`.trim();
try {
const payload: unknown = await response.json();
const payload: unknown = await readBoundedJson(response, 64 * 1024);
if (
typeof payload !== "object" || payload === null || Array.isArray(payload)
) {
@@ -52,6 +54,134 @@ function diagnosticMessage(
fallback;
}
export function canRestoreWorker(worker: {
availability: Worker["availability"];
state?: string;
lifecycleState?: string;
restore_observation_token?: string | null;
}): boolean {
return worker.availability === "observed" &&
(worker.lifecycleState ?? worker.state) === "stopped" &&
Boolean(worker.restore_observation_token);
}
/** Immutable intent: retained unchanged for explicit uncertain-result recovery. */
export type RestoreRequest = WorkerRestoreRequest;
export function createRestoreRequest(
worker: { restore_observation_token?: string | null },
): RestoreRequest {
if (!worker.restore_observation_token) {
throw new Error(
"Worker Restore observation is unavailable; refresh Workers",
);
}
return {
expected_observation_token: worker.restore_observation_token,
request_id: crypto.randomUUID(),
};
}
export class WorkerActionHttpError extends Error {
constructor(public readonly status: number, message: string) {
super(message);
}
}
export async function restoreWorkspaceWorker(
workspaceId: string,
worker: WorkerActionTarget,
request: RestoreRequest,
fetchFn: FetchFn = fetch,
) {
const response = await fetchFn(`${workerPath(workspaceId, worker)}/restore`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
});
if (!response.ok) {
throw new WorkerActionHttpError(
response.status,
await responseError(response),
);
}
const result = parseWorkerRestoreResponse(
await readBoundedJson(response, 8 * 1024 * 1024),
);
if (
result.workspace_id !== workspaceId ||
result.runtime_id !== worker.runtime_id ||
result.worker_id !== worker.worker_id
) {
throw new Error(
"Worker Restore response does not match the requested target",
);
}
return result;
}
export type RestoreNotice = {
level: "info" | "warning" | "error";
title: string;
message: string;
retry: boolean;
};
/** Shared classification: neither an HTTP 200 nor a GET settles an unknown operation. */
export function restoreNotice(
result: Awaited<ReturnType<typeof restoreWorkspaceWorker>>,
): RestoreNotice {
const state = result.result.state;
if (state === "accepted") {
return {
level: "info",
title: "Worker restored",
message: "Worker restored. Open Console to continue.",
retry: false,
};
}
const pending = state === "reconciliation_required";
return {
level: pending ? "warning" : "error",
title: pending
? "Worker Restore needs reconciliation"
: "Worker Restore failed",
message: diagnosticMessage(
result.result.diagnostics,
`Worker Restore was ${state}`,
) +
(pending
? ". Outcome is unresolved. Refresh observations and explicitly retry the same Restore to reconcile; refreshing alone does not settle it."
: ""),
retry: pending,
};
}
export function restoreErrorNotice(cause: unknown): RestoreNotice {
const conflict = cause instanceof WorkerActionHttpError &&
cause.status === 409;
const uncertain = !(cause instanceof WorkerActionHttpError) ||
cause.status >= 500;
return {
level: conflict || uncertain ? "warning" : "error",
title: conflict
? "Worker observation changed"
: uncertain
? "Worker Restore outcome unknown"
: "Worker Restore failed",
message:
(cause instanceof Error
? cause.message
: "Worker Restore request failed") +
(conflict
? ". Workers refreshed; review the latest state and explicitly try again."
: uncertain
? ". Outcome is unknown. Check the latest state and explicitly retry the same Restore; no automatic retry was made."
: ""),
retry: uncertain,
};
}
export function canStopSidebarWorker(worker: {
availability: Worker["availability"];
lifecycleState: string;
@@ -0,0 +1,233 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type {
SubscriptionFrame,
SubscriptionWorker,
} from "#lib/generated/protocol.ts";
import type { WorkspaceWorkersState } from "./worker-subscription";
const mock = vi.hoisted(() => ({ handlers: null as any }));
vi.mock("#lib/workspace/multiplexer.ts", () => ({
workspaceMultiplexer: () => ({
subscribe: (_: unknown, handlers: unknown) => {
mock.handlers = handlers;
return { close: vi.fn() };
},
}),
}));
vi.mock(
"#lib/workspace/alerts/store.ts",
() => ({ pushWorkspaceAlert: vi.fn(), dismissWorkspaceAlert: vi.fn() }),
);
import {
disposeWorkspaceWorkersStore,
refreshWorkspaceWorkers,
workspaceWorkersStore,
} from "./worker-subscription";
function summary(state = "stopped", token = "generation-1") {
return {
runtime_id: "runtime",
worker_id: "worker",
resource_key: "W-1",
host_id: "runtime",
display_name: "test",
label: "test",
availability: "observed",
state,
restore_observation_token: token,
workspace: {
visibility: "workspace",
identity: "registered",
workspace_id: "team",
},
implementation: { kind: "runtime", display_hint: "Runtime" },
pinned: true,
retention_state: "pinned",
workdir_attachments: [],
diagnostics: [],
};
}
function response(state = "stopped", token = "generation-1") {
return new Response(
JSON.stringify({
workspace_id: "team",
limit: 100,
items: [summary(state, token)],
source: "catalog",
diagnostics: [],
}),
);
}
function snapshot(
state = "stopped",
token = "generation-1",
): SubscriptionFrame {
const worker = {
worker_id: "worker",
runtime_id: "runtime",
resource_key: "W-1",
availability: "observed",
state,
restore_observation_token: token,
has_running_internal_workers: false,
workspace_id: "team",
workdir_attachments: [],
} as SubscriptionWorker;
return {
protocol_version: 2,
frame: "response",
message: {
result: "subscribed",
payload: {
request_id: "request",
subscription_id: "sub",
selector: { topic: "workspace_workers" },
snapshot: { topic: "workers", data: { workers: [worker] } },
},
},
};
}
const settle = async () => {
for (let i = 0; i < 12; i++) await Promise.resolve();
};
let unsubscribe: (() => void) | null;
beforeEach(() => {
mock.handlers = null;
unsubscribe = null;
});
afterEach(() => {
unsubscribe?.();
disposeWorkspaceWorkersStore("team");
vi.unstubAllGlobals();
});
describe("shared catalog observation refresh", () => {
it("keeps full catalog metadata while exposing catalog lifecycle separately from foreground display", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) =>
url.endsWith("/workers")
? response()
: new Response(JSON.stringify({ items: [] }))
),
);
let latest!: WorkspaceWorkersState;
unsubscribe = workspaceWorkersStore("team").subscribe((state) =>
latest = state
);
mock.handlers.onFrame(snapshot());
await settle();
expect(latest.catalogWorkers?.[0].pinned).toBe(true);
expect(latest.catalogWorkers?.[0].retention_state).toBe("pinned");
expect(latest.workers[0].lifecycleState).toBe("stopped");
expect(latest.workers[0].restore_observation_token).toBe("generation-1");
});
it("invalidates dependent plans before refresh and discards a GET superseded by a newer catalog event", async () => {
const pending: Array<(response: Response) => void> = [];
vi.stubGlobal(
"fetch",
vi.fn((url: string) =>
url.endsWith("/workers")
? new Promise<Response>((resolve) => pending.push(resolve))
: Promise.resolve(new Response(JSON.stringify({ items: [] })))
),
);
let latest!: WorkspaceWorkersState;
unsubscribe = workspaceWorkersStore("team").subscribe((state) =>
latest = state
);
mock.handlers.onFrame(snapshot());
const before = latest.observationVersion;
const old = pending.at(-1)!;
mock.handlers.onFrame(snapshot("idle", "generation-2"));
expect(latest.observationVersion).toBeGreaterThan(before);
expect(latest.workers[0].lifecycleState).toBe("idle");
pending.at(-1)!(response("idle", "generation-2"));
await settle();
old(response("stopped", "generation-1"));
await settle();
expect(latest.catalogWorkers?.[0].state).toBe("idle");
expect(latest.workers[0].restore_observation_token).toBe("generation-2");
});
it("explicit uncertain-outcome refresh is only GET and cannot settle or replay Restore", async () => {
const fetchMock = vi.fn(async (url: string) =>
url.endsWith("/workers")
? response()
: new Response(JSON.stringify({ items: [] }))
);
vi.stubGlobal("fetch", fetchMock);
let latest!: WorkspaceWorkersState;
unsubscribe = workspaceWorkersStore("team").subscribe((state) =>
latest = state
);
mock.handlers.onFrame(snapshot());
await settle();
const before = latest.observationVersion;
await refreshWorkspaceWorkers("team");
expect(latest.observationVersion).toBeGreaterThan(before);
expect(fetchMock.mock.calls.every(([url]) => !url.endsWith("/restore")))
.toBe(true);
});
it("does not filter catalog-only targets on a stream update while its metadata refresh is pending", async () => {
let hold = false;
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
if (!url.endsWith("/workers")) {
return new Response(JSON.stringify({ items: [] }));
}
if (hold) return new Promise<Response>(() => {});
return Response.json({
workspace_id: "team",
limit: 100,
source: "catalog",
diagnostics: [],
items: [summary(), {
...summary(),
worker_id: "registry-only",
resource_key: "W-2",
implementation: {
kind: "backend_worker_registry",
display_hint: "registry",
},
}],
});
}),
);
let latest!: WorkspaceWorkersState;
unsubscribe = workspaceWorkersStore("team").subscribe((state) =>
latest = state
);
mock.handlers.onFrame(snapshot());
await settle();
expect(latest.catalogWorkers).toHaveLength(2);
hold = true;
mock.handlers.onFrame(snapshot("idle", "generation-2"));
expect(latest.catalogRefreshing).toBe(true);
expect(latest.catalogWorkers).toHaveLength(2);
expect(latest.catalogWorkers?.[1].implementation.kind).toBe(
"backend_worker_registry",
);
});
it("does not publish a late catalog GET after disposal", async () => {
let resolve!: (response: Response) => void;
vi.stubGlobal(
"fetch",
vi.fn((url: string) =>
url.endsWith("/workers")
? new Promise<Response>((r) => resolve = r)
: Promise.resolve(new Response(JSON.stringify({ items: [] })))
),
);
const seen: WorkspaceWorkersState[] = [];
unsubscribe = workspaceWorkersStore("team").subscribe((state) =>
seen.push(state)
);
const before = seen.length;
disposeWorkspaceWorkersStore("team");
resolve(response("idle", "generation-2"));
await settle();
expect(seen.length).toBe(before);
});
});
@@ -14,6 +14,10 @@ import type {
} from "#lib/generated/protocol.ts";
import WorkersNavSection from "./WorkersNavSection.svelte";
import { disposeWorkspaceWorkersStore } from "./worker-subscription";
import {
clearWorkspaceAlerts,
workspaceAlerts,
} from "#lib/workspace/alerts/store.ts";
const transport = vi.hoisted(() => ({
listener: null as { onFrame(frame: SubscriptionFrame): void } | null,
@@ -34,6 +38,8 @@ vi.mock("#lib/workspace/multiplexer.ts", () => ({
afterEach(() => {
cleanup();
disposeWorkspaceWorkersStore("stop-test");
disposeWorkspaceWorkersStore("next-test");
clearWorkspaceAlerts();
transport.listener = null;
vi.unstubAllGlobals();
});
@@ -41,6 +47,7 @@ afterEach(() => {
function snapshot(
state: SubscriptionWorker["state"],
availability: SubscriptionWorker["availability"],
workspaceId = "stop-test",
): SubscriptionFrame {
return {
protocol_version: 2,
@@ -64,8 +71,9 @@ function snapshot(
worker_state: null,
has_running_internal_workers: false,
display_name: "Test worker",
workspace_id: "stop-test",
workspace_id: workspaceId,
workdir_attachments: [],
...{ restore_observation_token: "observation-1" },
}],
},
},
@@ -107,6 +115,14 @@ test.each(["idle", "running", "paused", "stopped"] as const)(
await fireEvent.click(
screen.getByRole("button", { name: "Actions for Test worker" }),
);
await waitFor(() =>
expect(
(screen.getByRole("menuitem", { name: "Stop" }) as HTMLButtonElement)
.disabled,
).toBe(false)
);
// A successful POST is not a lifecycle observation. Only a newer authoritative frame disables Stop.
transport.listener!.onFrame(snapshot("stopped", "observed"));
await waitFor(() =>
expect(
(screen.getByRole("menuitem", { name: "Stop" }) as HTMLButtonElement)
@@ -132,3 +148,249 @@ test("Unavailable observation disables Stop even when catalog state is running",
.disabled,
).toBe(true);
});
test.each(["switch", "dispose"] as const)(
"late Stop completion is ignored after workspace %s",
async (mode) => {
let resolve!: (response: Response) => void;
const pending = new Promise<Response>((done) => {
resolve = done;
});
const request = vi.fn((input: RequestInfo | URL) =>
String(input).endsWith("/stop")
? pending
: Promise.resolve(new Response(null, { status: 404 }))
);
vi.stubGlobal("fetch", request);
const view = render(WorkersNavSection, { workspaceId: "stop-test" });
await waitFor(() => expect(transport.listener).not.toBeNull());
transport.listener!.onFrame(snapshot("running", "observed"));
await fireEvent.click(
await screen.findByRole("button", { name: "Actions for Test worker" }),
);
await fireEvent.click(screen.getByRole("menuitem", { name: "Stop" }));
const operation = request.mock.calls.find(([url]) =>
String(url).endsWith("/stop")
);
expect(String(operation?.[0])).toContain("/api/w/stop-test/");
if (mode === "switch") {
await view.rerender({ workspaceId: "next-test" });
transport.listener!.onFrame(snapshot("idle", "observed", "next-test"));
await fireEvent.click(
await screen.findByRole("button", { name: "Actions for Test worker" }),
);
} else view.unmount();
clearWorkspaceAlerts();
let alerts: unknown[] = [];
const unsubscribe = workspaceAlerts.subscribe((value) => {
alerts = value;
});
const readsBeforeCompletion =
request.mock.calls.filter(([url]) => String(url).endsWith("/workers"))
.length;
resolve(
Response.json({
state: "accepted",
runtime_id: "runtime-a",
worker_id: "worker-a",
diagnostics: [],
}),
);
await pending;
await new Promise((done) => setTimeout(done, 0));
expect(alerts).toEqual([]);
expect(
request.mock.calls.filter(([url]) => String(url).endsWith("/workers"))
.length,
).toBe(readsBeforeCompletion);
if (mode === "switch") {
expect(
(screen.getByRole("menuitem", { name: "Stop" }) as HTMLButtonElement)
.disabled,
).toBe(false);
}
unsubscribe();
},
);
test.each(["idle", "running", "paused", "stopped"] as const)(
"sidebar Restore eligibility (%s)",
async (state) => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(null, { status: 404 })),
);
render(WorkersNavSection, { workspaceId: "stop-test" });
await waitFor(() => expect(transport.listener).not.toBeNull());
transport.listener!.onFrame(snapshot(state, "observed"));
await fireEvent.click(
await screen.findByRole("button", { name: "Actions for Test worker" }),
);
expect(
(screen.getByRole("menuitem", { name: "Restore" }) as HTMLButtonElement)
.disabled,
).toBe(state !== "stopped");
},
);
test("sidebar Restore pins the target and preserves an uncertain request for explicit retry", async () => {
let attempts = 0;
const request = vi.fn(
async (input: RequestInfo | URL, _init?: RequestInit) => {
if (!String(input).endsWith("/restore")) {
return new Response(null, { status: 404 });
}
return Response.json({
workspace_id: "stop-test",
runtime_id: "runtime-a",
worker_id: "worker-a",
result: {
state: ++attempts === 1 ? "reconciliation_required" : "accepted",
diagnostics: [],
},
});
},
);
vi.stubGlobal("fetch", request);
render(WorkersNavSection, { workspaceId: "stop-test" });
await waitFor(() => expect(transport.listener).not.toBeNull());
transport.listener!.onFrame(snapshot("stopped", "observed"));
await fireEvent.click(
await screen.findByRole("button", { name: "Actions for Test worker" }),
);
await fireEvent.click(screen.getByRole("menuitem", { name: "Restore" }));
await waitFor(() => expect(attempts).toBe(1));
await fireEvent.click(
screen.getByRole("button", { name: "Actions for Test worker" }),
);
const retry = await screen.findByRole("menuitem", { name: "Retry Restore" });
await waitFor(() =>
expect((retry as HTMLButtonElement).disabled).toBe(false)
);
expect(
(screen.getByRole("menuitem", { name: "Restore" }) as HTMLButtonElement)
.disabled,
).toBe(true);
await fireEvent.click(retry);
await waitFor(() => expect(attempts).toBe(2));
const operations = request.mock.calls.filter(([url]) =>
String(url).endsWith("/restore")
);
expect(String(operations[0][0])).toContain(
"/api/w/stop-test/runtimes/runtime-a/workers/worker-a/restore",
);
expect(operations[1][1]?.body).toBe(operations[0][1]?.body);
});
test.each(["switch", "dispose"] as const)(
"late sidebar Restore completion is ignored after %s",
async (mode) => {
let resolve!: (response: Response) => void;
const pending = new Promise<Response>((done) => {
resolve = done;
});
const request = vi.fn((input: RequestInfo | URL) =>
String(input).endsWith("/restore")
? pending
: Promise.resolve(new Response(null, { status: 404 }))
);
vi.stubGlobal("fetch", request);
const view = render(WorkersNavSection, { workspaceId: "stop-test" });
await waitFor(() => expect(transport.listener).not.toBeNull());
transport.listener!.onFrame(snapshot("stopped", "observed"));
await fireEvent.click(
await screen.findByRole("button", { name: "Actions for Test worker" }),
);
await fireEvent.click(screen.getByRole("menuitem", { name: "Restore" }));
if (mode === "switch") {
await view.rerender({ workspaceId: "next-test" });
transport.listener!.onFrame(snapshot("stopped", "observed", "next-test"));
await fireEvent.click(
await screen.findByRole("button", { name: "Actions for Test worker" }),
);
} else view.unmount();
clearWorkspaceAlerts();
let alerts: unknown[] = [];
const unsubscribe = workspaceAlerts.subscribe((value) => {
alerts = value;
});
const readsBeforeCompletion =
request.mock.calls.filter(([url]) => String(url).endsWith("/workers"))
.length;
resolve(
Response.json({
workspace_id: "stop-test",
runtime_id: "runtime-a",
worker_id: "worker-a",
result: { state: "accepted", diagnostics: [] },
}),
);
await pending;
await new Promise((done) => setTimeout(done, 0));
expect(alerts).toEqual([]);
expect(
request.mock.calls.filter(([url]) => String(url).endsWith("/workers"))
.length,
).toBe(readsBeforeCompletion);
if (mode === "switch") {
expect(
(screen.getByRole("menuitem", { name: "Restore" }) as HTMLButtonElement)
.disabled,
).toBe(false);
}
unsubscribe();
},
);
test("sidebar Delete cannot proceed to execution when its cleanup plan arrives after workspace switch", async () => {
let resolve!: (response: Response) => void;
const pending = new Promise<Response>((done) => {
resolve = done;
});
const request = vi.fn((input: RequestInfo | URL) =>
String(input).endsWith("/cleanup-plan")
? pending
: Promise.resolve(new Response(null, { status: 404 }))
);
vi.stubGlobal("fetch", request);
const view = render(WorkersNavSection, { workspaceId: "stop-test" });
await waitFor(() => expect(transport.listener).not.toBeNull());
transport.listener!.onFrame(snapshot("stopped", "observed"));
await fireEvent.click(
await screen.findByRole("button", { name: "Actions for Test worker" }),
);
await fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
expect(
request.mock.calls.some(([url]) => String(url).endsWith("/cleanup-plan")),
).toBe(true);
await view.rerender({ workspaceId: "next-test" });
clearWorkspaceAlerts();
resolve(Response.json({
workspace_id: "stop-test",
runtime_id: "runtime-a",
generated_at: "2026-10-06T00:00:00Z",
revision: "old-plan",
digest: "old-digest",
workdirs: [],
diagnostics: [],
workers: [{
target_id: "worker-target",
action: "worker_delete",
worker_id: "W-1",
runtime_worker_id: "worker-a",
runtime_id: "runtime-a",
reason: "stopped",
pinned: false,
retention_state: "normal",
linked_workdir_ids: [],
running_linked: false,
}],
}));
await pending;
await new Promise((done) => setTimeout(done, 0));
expect(
request.mock.calls.some(([url]) =>
String(url).endsWith("/cleanup-executions")
),
).toBe(false);
});
@@ -65,6 +65,9 @@ beforeEach(() => {
);
return Response.json({
workspace_id: workspaceId,
...(String(input).endsWith("/workers")
? { limit: 100, source: "catalog" }
: {}),
items: [],
diagnostics: [],
});
@@ -283,7 +286,17 @@ test("terminal subscription rejection is shown only through WorkspaceAlerts", as
test("Workdir fetch failure preserves Worker updates and uses a separate stable alert", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("Workdir lookup failed", { status: 503 })),
vi.fn(async (input: RequestInfo | URL) =>
String(input).endsWith("/workers")
? Response.json({
workspace_id: "workspace-a",
limit: 100,
source: "catalog",
items: [],
diagnostics: [],
})
: new Response("Workdir lookup failed", { status: 503 })
),
);
mount();
await waitFor(() => expect(transport.subscribe).toHaveBeenCalledOnce());
@@ -6,6 +6,7 @@ import {
pushWorkspaceAlert,
} from "#lib/workspace/alerts/store.ts";
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
import { parseWorkerListResponse } from "#lib/workspace/api/workers.ts";
import { parseWorkingDirectoryListResponse } from "#lib/workspace/api/workdirs.ts";
import {
workspaceMultiplexer,
@@ -31,11 +32,17 @@ export type SidebarWorker = Omit<Worker, "workdir_attachments"> & {
export type WorkspaceWorkersState = {
loading: boolean;
workers: SidebarWorker[];
/** Full catalog metadata; null until fetched. Never substitutes sidebar display state. */
catalogWorkers: Worker[] | null;
/** Changes immediately when an observation invalidates dependent cleanup plans. */
observationVersion: number;
catalogRefreshing: boolean;
};
type WorkerAlertKind = "subscription" | "workdirs";
type WorkerAlertKind = "subscription" | "workdirs" | "catalog";
type WorkspaceWorkersStoreEntry = {
store: Readable<WorkspaceWorkersState>;
refresh(): Promise<void>;
dispose(): void;
};
@@ -55,6 +62,8 @@ function reportWorkerFailure(
id: workerAlertId(workspaceId, kind),
title: kind === "workdirs"
? "Worker Workdirs unavailable"
: kind === "catalog"
? "Worker catalog unavailable"
: "Worker updates unavailable",
});
}
@@ -66,6 +75,7 @@ function clearWorkerFailure(workspaceId: string, kind: WorkerAlertKind): void {
function clearWorkerFailures(workspaceId: string): void {
clearWorkerFailure(workspaceId, "subscription");
clearWorkerFailure(workspaceId, "workdirs");
clearWorkerFailure(workspaceId, "catalog");
}
export function disposeWorkspaceWorkersStore(workspaceId: string): void {
@@ -78,6 +88,13 @@ export function disposeWorkspaceWorkersStore(workspaceId: string): void {
entry.dispose();
}
/** Refresh observations only; this does not POST or complete pending reconciliation. */
export async function refreshWorkspaceWorkers(
workspaceId: string,
): Promise<void> {
await stores.get(workspaceId)?.refresh();
}
export function workspaceWorkersStore(
workspaceId: string,
): Readable<WorkspaceWorkersState> {
@@ -85,11 +102,24 @@ export function workspaceWorkersStore(
if (cached) return cached.store;
let stopActive: (() => void) | null = null;
let refreshActive: (() => Promise<void>) | null = null;
const store = readable<WorkspaceWorkersState>(
{ loading: true, workers: [] },
{
loading: true,
workers: [],
catalogWorkers: null,
observationVersion: 0,
catalogRefreshing: true,
},
(set) => {
if (!workspaceId) {
set({ loading: false, workers: [] });
set({
loading: false,
workers: [],
catalogWorkers: [],
observationVersion: 0,
catalogRefreshing: false,
});
return;
}
const projection = createWorkspaceWorkersProjection();
@@ -99,13 +129,24 @@ export function workspaceWorkersStore(
let workers: SidebarWorker[] = [];
let loading = true;
let disposed = false;
let catalogWorkers: Worker[] | null = null;
let observationVersion = 0;
let catalogRefreshing = true;
let refreshSequence = 0;
let catalogAbort: AbortController | null = null;
const publish = (): boolean => {
if (disposed) return false;
try {
workers = [...projection.workers.values()]
.map((worker) => projectWorker(worker, workdirs))
.sort(compareWorkersForSidebar);
set({ loading, workers });
set({
loading,
workers,
catalogWorkers,
observationVersion,
catalogRefreshing,
});
return true;
} catch (cause) {
reportWorkerFailure(
@@ -115,10 +156,86 @@ export function workspaceWorkersStore(
? cause.message
: "Invalid Worker subscription frame",
);
set({ loading: false, workers });
set({
loading: false,
workers,
catalogWorkers,
observationVersion,
catalogRefreshing,
});
return false;
}
};
const refreshCatalog = async () => {
if (disposed) return;
const sequence = ++refreshSequence;
const version = ++observationVersion;
catalogAbort?.abort();
const abort = new AbortController();
catalogAbort = abort;
catalogRefreshing = true;
publish(); // Invalidate cleanup plans before any request can settle.
const result = await loadJson(
fetch,
workspaceApiPath(workspaceId, "/workers"),
{ signal: abort.signal },
parseWorkerListResponse,
{ diagnosticLabel: "Worker API", maxResponseBytes: 8 * 1024 * 1024 },
);
if (
disposed || sequence !== refreshSequence ||
version !== observationVersion
) return;
if (!result.data || result.data.workspace_id !== workspaceId) {
reportWorkerFailure(
workspaceId,
"catalog",
result.error ?? "Worker catalog identity mismatch",
"warning",
);
return;
}
catalogWorkers = result.data.items;
catalogRefreshing = false;
// GET is a new observation, never a cached POST response. Existing
// subscription-only fields (Job/Internal activity) retain their owners.
for (const summary of catalogWorkers) {
const key = JSON.stringify([summary.runtime_id, summary.worker_id]);
const current = projection.workers.get(key);
if (current) {
projection.workers.set(key, {
...current,
availability: summary.availability,
state: summary.state as SubscriptionWorker["state"],
worker_state: summary.worker_state,
restore_observation_token: summary.restore_observation_token,
});
}
}
clearWorkerFailure(workspaceId, "catalog");
publish();
};
refreshActive = refreshCatalog;
const observeCatalog = () => {
if (catalogWorkers !== null) {
catalogWorkers = catalogWorkers.map((worker) => {
const latest = projection.workers.get(
JSON.stringify([worker.runtime_id, worker.worker_id]),
);
// The catalog can contain targets absent from the sidebar stream;
// absence is not a new filtering rule for the Workers page.
if (!latest) return worker;
return {
...worker,
availability: latest.availability,
state: latest.state,
worker_state: latest.worker_state,
restore_observation_token: latest.restore_observation_token,
};
});
}
void refreshCatalog();
};
const loadWorkdirs = async () => {
const result = await loadJson(
fetch,
@@ -183,6 +300,13 @@ export function workspaceWorkersStore(
try {
applyWorkspaceWorkersFrame(projection, frame);
loading = false;
if (
(frame.frame === "response" &&
frame.message.result === "subscribed") ||
(frame.frame === "event" && frame.message.event === "event" &&
frame.message.data.subscription_id ===
projection.subscriptionId)
) observeCatalog();
if (publish()) clearWorkerFailure(workspaceId, "subscription");
} catch (cause) {
loading = false;
@@ -216,11 +340,14 @@ export function workspaceWorkersStore(
},
);
void loadWorkdirs();
void refreshCatalog();
const stop = () => {
if (disposed) return;
disposed = true;
workdirRequest.abort();
catalogAbort?.abort();
if (refreshActive === refreshCatalog) refreshActive = null;
subscription?.close();
clearWorkerFailures(workspaceId);
if (stopActive === stop) stopActive = null;
@@ -231,6 +358,9 @@ export function workspaceWorkersStore(
);
const entry: WorkspaceWorkersStoreEntry = {
store,
refresh: async () => {
await refreshActive?.();
},
dispose: () => {
stopActive?.();
clearWorkerFailures(workspaceId);
@@ -268,6 +398,7 @@ function projectWorker(
availability: worker.availability,
state: liveWorkerState(worker),
lifecycleState: worker.state,
restore_observation_token: worker.restore_observation_token,
worker_state: worker.worker_state,
pinned: false,
retention_state: "transient",
@@ -1,4 +1,7 @@
<script lang="ts">
import { untrack } from 'svelte';
import { canRestoreWorker, createRestoreRequest, restoreWorkspaceWorker, restoreNotice, restoreErrorNotice, type RestoreRequest } from '#lib/workspace/sidebar/worker-actions.ts';
import { workspaceWorkersStore, refreshWorkspaceWorkers } from '#lib/workspace/sidebar/worker-subscription.ts';
import { pushWorkspaceAlert } from '#lib/workspace/alerts/store.ts';
import { workspaceApiPath } from '#lib/workspace/api/http.ts';
import {
@@ -14,16 +17,71 @@
import type { CleanupWorkerCandidate, RuntimeCleanupPlanResponse, Worker } from '#lib/workspace/sidebar/types.ts';
import type { PageProps } from './$types';
type WorkerActionKind = 'pin' | 'delete';
type WorkerActionKind = 'restore' | 'pin' | 'delete';
let { data }: PageProps = $props();
let cleanupPlans = $state<Record<string, RuntimeCleanupPlanResponse>>({});
let workers = $state<Worker[]>([]);
let restoreRetries = $state<Record<string, RestoreRequest>>({});
let busyAction = $state<{ workerKey: string; kind: WorkerActionKind } | null>(null);
let lifetime = 0;
let cleanupEpoch = 0;
let catalogRefreshing = true;
let catalogReady = $state(false);
function actionScope() {
const id = data.workspaceId;
const epoch = lifetime;
return { workspaceId: id, isCurrent: () => lifetime === epoch && data.workspaceId === id };
}
$effect(() => {
cleanupPlans = data.cleanupPlans;
workers = data.workers?.items ?? [];
const id = data.workspaceId;
lifetime++;
cleanupEpoch++;
busyAction = null;
restoreRetries = {};
catalogReady = false;
const initial = untrack(() => data);
workers = initial.workers?.items ?? [];
cleanupPlans = initial.cleanupPlans;
let previousCatalog: string | undefined;
let previousObservation: number | undefined;
let previousRefreshing: boolean | undefined;
const scope = actionScope();
const unsubscribe = workspaceWorkersStore(id).subscribe((state) => {
if (!scope.isCurrent()) return;
const observationChanged = previousObservation !== state.observationVersion;
const refreshStarted = state.catalogRefreshing && previousRefreshing !== true;
const refreshCompleted = !state.catalogRefreshing && previousRefreshing === true;
const catalog = state.catalogWorkers === null ? undefined : JSON.stringify(state.catalogWorkers);
const catalogChanged = catalog !== previousCatalog;
previousObservation = state.observationVersion;
previousRefreshing = state.catalogRefreshing;
previousCatalog = catalog;
catalogRefreshing = state.catalogRefreshing;
if (observationChanged || catalogChanged || refreshStarted) {
cleanupPlans = {};
++cleanupEpoch;
}
if (state.catalogWorkers === null) return;
catalogReady = true;
workers = state.catalogWorkers;
// Subscription overlays stay visible during the GET, but cleanup must wait
// for its authoritative catalog result, even if that result is unchanged.
if (catalogRefreshing || (!catalogChanged && !observationChanged && !refreshCompleted)) return;
cleanupPlans = {};
const epoch = ++cleanupEpoch;
for (const runtimeId of new Set(state.catalogWorkers.map((worker) => worker.runtime_id))) {
void refreshCleanupPlan(runtimeId, scope, epoch).catch(() => {});
}
});
return () => {
lifetime++;
cleanupEpoch++;
unsubscribe();
};
});
function workerKey(worker: Worker): string {
@@ -56,41 +114,78 @@
return fallback;
}
async function refreshCleanupPlan(runtimeId: string): Promise<void> {
async function refreshCleanupPlan(
runtimeId: string,
scope = actionScope(),
epoch = cleanupEpoch,
): Promise<void> {
if (!scope.isCurrent() || catalogRefreshing || cleanupEpoch !== epoch) return;
const response = await fetch(
workspaceApiPath(data.workspaceId, `/runtimes/${encodeURIComponent(runtimeId)}/cleanup-plan`),
workspaceApiPath(scope.workspaceId, `/runtimes/${encodeURIComponent(runtimeId)}/cleanup-plan`),
);
if (!response.ok) return;
const plan = parseRuntimeCleanupPlan(await response.json());
if (!scope.isCurrent() || catalogRefreshing || cleanupEpoch !== epoch) return;
cleanupPlans = { ...cleanupPlans, [runtimeId]: plan };
}
async function restoreWorker(worker: Worker, retry = false): Promise<void> {
const key = workerKey(worker);
if (busyAction || (retry ? !restoreRetries[key] : restoreRetries[key] || !canRestoreWorker(worker))) return;
const scope = actionScope();
const request = retry ? restoreRetries[key] : createRestoreRequest(worker);
busyAction = { workerKey: key, kind: 'restore' };
cleanupPlans = {};
++cleanupEpoch;
try {
let notice;
try {
notice = restoreNotice(await restoreWorkspaceWorker(scope.workspaceId, worker, request));
} catch (cause) {
notice = restoreErrorNotice(cause);
}
if (!scope.isCurrent()) return;
const next = { ...restoreRetries };
if (notice.retry) next[key] = request;
else delete next[key];
restoreRetries = next;
pushWorkspaceAlert(notice.level, notice.message, { title: notice.title });
await refreshWorkspaceWorkers(scope.workspaceId).catch(() => {});
if (!scope.isCurrent()) return;
await refreshCleanupPlan(worker.runtime_id, scope).catch(() => {});
} finally {
if (scope.isCurrent()) busyAction = null;
}
}
async function setPinned(worker: Worker, pinned: boolean): Promise<void> {
if (busyAction) return;
const scope = actionScope();
busyAction = { workerKey: workerKey(worker), kind: 'pin' };
cleanupPlans = {};
++cleanupEpoch;
try {
const response = await fetch(
workspaceApiPath(
data.workspaceId,
scope.workspaceId,
`/runtimes/${encodeURIComponent(worker.runtime_id)}/workers/${encodeURIComponent(worker.worker_id)}/pin`,
),
{ method: pinned ? 'PUT' : 'DELETE' },
);
const payload = await response.json().catch(() => null);
if (!response.ok) {
pushWorkspaceAlert('error', errorMessage(payload, response.statusText), { title: 'Worker pin failed' });
return;
}
const retention = parseWorkerRetentionResponse(payload);
worker.pinned = retention.pinned;
worker.retention_state = retention.retention_state;
await refreshCleanupPlan(worker.runtime_id);
if (!scope.isCurrent()) return;
if (!response.ok) throw new Error(errorMessage(payload, response.statusText));
parseWorkerRetentionResponse(payload);
await refreshWorkspaceWorkers(scope.workspaceId);
if (!scope.isCurrent()) return;
await refreshCleanupPlan(worker.runtime_id, scope);
} catch (error) {
if (!scope.isCurrent()) return;
pushWorkspaceAlert('error', error instanceof Error ? error.message : 'Worker pin failed', {
title: 'Worker pin failed',
});
} finally {
busyAction = null;
if (scope.isCurrent()) busyAction = null;
}
}
@@ -101,12 +196,15 @@
}
async function deleteWorker(worker: Worker, candidate: CleanupWorkerCandidate): Promise<void> {
if (!cleanupPlans?.[worker.runtime_id] || busyAction) return;
const plan = cleanupPlans?.[worker.runtime_id];
if (!plan || busyAction || candidate.blocking_reason || cleanupCandidate(worker) !== candidate) return;
const scope = actionScope();
busyAction = { workerKey: workerKey(worker), kind: 'delete' };
cleanupPlans = {};
++cleanupEpoch;
try {
const plan = cleanupPlans[worker.runtime_id];
const response = await fetch(
workspaceApiPath(data.workspaceId, `/runtimes/${encodeURIComponent(worker.runtime_id)}/cleanup-executions`),
workspaceApiPath(scope.workspaceId, `/runtimes/${encodeURIComponent(worker.runtime_id)}/cleanup-executions`),
{
method: 'POST',
headers: { 'content-type': 'application/json' },
@@ -120,22 +218,23 @@
},
);
const payload = await response.json().catch(() => null);
if (!scope.isCurrent()) return;
if (!response.ok) throw new Error(errorMessage(payload, response.statusText));
const execution = parseRuntimeCleanupExecution(payload);
cleanupPlans = { ...cleanupPlans, [worker.runtime_id]: execution.plan_after };
const result = execution.results.find((entry) => entry.target_id === candidate.target_id);
if (!result || result.status !== 'deleted') {
throw new Error(result?.message ?? 'Runtime did not delete the selected Worker');
}
workers = workers.filter(
(item) => !(item.runtime_id === worker.runtime_id && item.worker_id === worker.worker_id),
);
await refreshWorkspaceWorkers(scope.workspaceId);
if (!scope.isCurrent()) return;
await refreshCleanupPlan(worker.runtime_id, scope);
} catch (error) {
if (!scope.isCurrent()) return;
pushWorkspaceAlert('error', error instanceof Error ? error.message : 'Worker deletion failed', {
title: 'Worker deletion failed',
});
} finally {
busyAction = null;
if (scope.isCurrent()) busyAction = null;
}
}
@@ -180,9 +279,9 @@
<a class="section-action" href={`/w/${data.workspaceId}/workers/new`}>New Worker</a>
</header>
{#if data.workersError}
{#if !catalogReady && data.workersError}
<p class="section-state error">{data.workersError}</p>
{:else if !data.workers}
{:else if !catalogReady && !data.workers}
<p class="section-state">Loading Workers…</p>
{:else if workers.length === 0}
<p class="section-state">No Workers are visible.</p>
@@ -222,6 +321,24 @@
<td>{workerDirectory(worker)}</td>
<td>
<div class="worker-actions" aria-label={`Actions for ${workerDisplayName}`}>
<button
class="icon-action"
type="button"
disabled={anyActionDisabled || !canRestoreWorker(worker) || Boolean(restoreRetries[workerKey(worker)])}
aria-label={`Restore ${workerDisplayName}`}
title="Restore"
onclick={() => restoreWorker(worker)}
>
{#if isActionBusy(worker, 'restore')}
<span class="spinner" aria-hidden="true"></span>
{:else}
<svg class="action-icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M3 11a9 9 0 1 1 2.6 6.4" /><path d="M3 3v8h8" /></svg>
{/if}
</button>
{#if restoreRetries[workerKey(worker)]}
<button type="button" disabled={anyActionDisabled} onclick={() => restoreWorker(worker, true)} aria-label={`Retry Restore ${workerDisplayName}`}>Retry Restore</button>
<span role="status">Restore outcome unresolved. Retry the same request to reconcile.</span>
{/if}
<button
class="icon-action"
type="button"
@@ -0,0 +1,600 @@
// @vitest-environment happy-dom
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import { writable } from "svelte/store";
import type { ComponentProps } from "svelte";
import type {
RuntimeCleanupPlanResponse,
Worker,
} from "#lib/workspace/sidebar/types.ts";
import type { WorkspaceWorkersState } from "#lib/workspace/sidebar/worker-subscription.ts";
import {
clearWorkspaceAlerts,
workspaceAlerts,
} from "#lib/workspace/alerts/store.ts";
import WorkersPage from "./+page.svelte";
const shared = vi.hoisted(() => ({
stores: new Map(),
refresh: vi.fn(async (_workspaceId: string) => {}),
}));
vi.mock("#lib/workspace/sidebar/worker-subscription.ts", () => ({
workspaceWorkersStore: (id: string) => shared.stores.get(id),
refreshWorkspaceWorkers: shared.refresh,
}));
type Data = ComponentProps<typeof WorkersPage>["data"];
function worker(overrides: Partial<Worker> = {}): Worker {
return {
runtime_id: "runtime-a",
worker_id: "worker-a",
resource_key: "W-1",
host_id: "host-a",
display_name: "Catalog worker",
label: "worker-a",
profile: "builtin:coder",
tags: [],
workspace: { visibility: "workspace", identity: "workspace-a" },
availability: "observed",
state: "stopped",
pinned: true,
retention_state: "pinned",
implementation: { kind: "runtime", display_hint: "Runtime Worker" },
workdir_attachments: [],
diagnostics: [],
...{ restore_observation_token: "observation-1" },
...overrides,
};
}
function plan(revision = "plan-1"): RuntimeCleanupPlanResponse {
return {
workspace_id: "page-test",
generated_at: "2026-10-06T00:00:00Z",
runtime_id: "runtime-a",
revision,
digest: `digest-${revision}`,
diagnostics: [],
workdirs: [],
workers: [{
target_id: `target-${revision}`,
action: "worker_delete",
worker_id: "W-1",
runtime_worker_id: "worker-a",
runtime_id: "runtime-a",
reason: "stopped",
pinned: false,
retention_state: "normal",
linked_workdir_ids: [],
running_linked: false,
}],
};
}
function catalog(id = "page-test") {
const state = writable<WorkspaceWorkersState>({
loading: true,
workers: [],
catalogWorkers: null,
observationVersion: 0,
catalogRefreshing: true,
});
shared.stores.set(id, state);
return state;
}
function data(id = "page-test", item = worker()): Data {
return {
workspaceId: id,
workers: { items: [item] },
workersError: null,
cleanupPlans: { "runtime-a": plan() },
cleanupPlanErrors: {},
} as unknown as Data;
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
function publish(store: ReturnType<typeof catalog>, items: Worker[]) {
store.update((previous) => ({
loading: false,
workers: [],
catalogWorkers: items,
observationVersion: previous.observationVersion + 1,
catalogRefreshing: false,
}));
}
afterEach(() => {
cleanup();
shared.stores.clear();
vi.clearAllMocks();
vi.unstubAllGlobals();
clearWorkspaceAlerts();
});
test("catalog updates preserve retention and workdirs, and stale page data never replaces a ready catalog", async () => {
const store = catalog();
vi.stubGlobal("fetch", vi.fn(async () => Response.json(plan())));
const view = render(WorkersPage, {
params: { workspaceId: "page-test" },
data: data(),
});
const attachment: NonNullable<Worker["workdir_attachments"]>[number] = {
alias: "source",
effective_permissions: { read: true, write: false, command: false },
working_directory: {
working_directory_id: "dir-a",
display_name: "Retained Workdir",
status: "active",
materializer_kind: "client_hosted_external",
source: {
kind: "external_grant",
grant_id: "grant-a",
grant_permissions: { read: true, write: false, command: false },
},
},
};
publish(store, [
worker({
display_name: "Live catalog",
retention_state: "retained",
workdir_attachments: [attachment],
}),
]);
expect(await screen.findByText("Live catalog")).toBeTruthy();
expect(screen.getByText("retained")).toBeTruthy();
expect(screen.getByText(/Retained Workdir/)).toBeTruthy();
expect(screen.getByRole("button", { name: "Unpin Live catalog" }))
.toBeTruthy();
expect(
screen.getByRole("link", { name: "Live catalog" }).getAttribute("href"),
).toBe("/w/page-test/workers/W-1-live-catalog");
await view.rerender({
data: data("page-test", worker({ display_name: "Stale loader worker" })),
});
expect(screen.getByText("Live catalog")).toBeTruthy();
expect(screen.queryByText("Stale loader worker")).toBeNull();
});
test("catalog change invalidates Delete immediately and an older cleanup response cannot restore a stale action", async () => {
const store = catalog();
const older = deferred<Response>();
const newer = deferred<Response>();
const request = vi.fn().mockReturnValueOnce(older.promise)
.mockReturnValueOnce(newer.promise);
vi.stubGlobal("fetch", request);
render(WorkersPage, { params: { workspaceId: "page-test" }, data: data() });
publish(store, [worker({ pinned: false })]);
await waitFor(() => expect(request).toHaveBeenCalledTimes(1));
expect(screen.queryByRole("button", { name: "Delete Catalog worker" }))
.toBeNull();
publish(store, [worker({ pinned: false, state: "idle" })]);
await waitFor(() => expect(request).toHaveBeenCalledTimes(2));
newer.resolve(Response.json({ ...plan("new"), workers: [] }));
older.resolve(Response.json(plan("old")));
await new Promise((done) => setTimeout(done, 0));
expect(screen.queryByRole("button", { name: "Delete Catalog worker" }))
.toBeNull();
expect(
request.mock.calls.every(([url]) =>
!String(url).endsWith("/cleanup-executions")
),
).toBe(true);
});
test("refresh start invalidates an installed Delete candidate without requesting cleanup from the old catalog", async () => {
const store = catalog();
const request = vi.fn(async () => Response.json(plan()));
vi.stubGlobal("fetch", request);
render(WorkersPage, { params: { workspaceId: "page-test" }, data: data() });
publish(store, [worker({ pinned: false })]);
expect(await screen.findByRole("button", { name: "Delete Catalog worker" }))
.toBeTruthy();
expect(request).toHaveBeenCalledTimes(1);
// The flag itself invalidates plans, independently of the observation version.
store.update((previous) => ({ ...previous, catalogRefreshing: true }));
await waitFor(() =>
expect(screen.queryByRole("button", { name: "Delete Catalog worker" }))
.toBeNull()
);
store.update((previous) => ({ ...previous }));
await new Promise((done) => setTimeout(done, 0));
expect(request).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("button", { name: "Delete Catalog worker" }))
.toBeNull();
// No catalog or version change on completion: the transition still reloads.
store.update((previous) => ({ ...previous, catalogRefreshing: false }));
expect(await screen.findByRole("button", { name: "Delete Catalog worker" }))
.toBeTruthy();
expect(request).toHaveBeenCalledTimes(2);
});
test.each(["during refresh", "after completion"] as const)(
"a previous cleanup GET arriving %s cannot reinstall a stale candidate; completion reschedules cleanup",
async (settlement) => {
const store = catalog();
const older = deferred<Response>();
const newer = deferred<Response>();
const request = vi.fn().mockReturnValueOnce(older.promise)
.mockReturnValueOnce(newer.promise);
vi.stubGlobal("fetch", request);
render(WorkersPage, { params: { workspaceId: "page-test" }, data: data() });
publish(store, [worker({ pinned: false })]);
await waitFor(() => expect(request).toHaveBeenCalledTimes(1));
store.update((previous) => ({
...previous,
catalogRefreshing: true,
observationVersion: previous.observationVersion + 1,
catalogWorkers: [
worker({ pinned: false, display_name: "Overlay worker" }),
],
}));
expect(await screen.findByText("Overlay worker")).toBeTruthy();
expect(screen.queryByRole("button", { name: "Delete Overlay worker" }))
.toBeNull();
expect(request).toHaveBeenCalledTimes(1);
if (settlement === "during refresh") {
older.resolve(Response.json(plan("stale")));
await new Promise((done) => setTimeout(done, 0));
expect(screen.queryByRole("button", { name: "Delete Overlay worker" }))
.toBeNull();
}
// Successful catalog GET retains exactly the same catalog and observationVersion.
store.update((previous) => ({ ...previous, catalogRefreshing: false }));
await waitFor(() => expect(request).toHaveBeenCalledTimes(2));
if (settlement === "after completion") {
older.resolve(Response.json(plan("stale")));
await new Promise((done) => setTimeout(done, 0));
expect(screen.queryByRole("button", { name: "Delete Overlay worker" }))
.toBeNull();
}
newer.resolve(Response.json({ ...plan("fresh"), workers: [] }));
await new Promise((done) => setTimeout(done, 0));
expect(screen.queryByRole("button", { name: "Delete Overlay worker" }))
.toBeNull();
expect(request).toHaveBeenCalledTimes(2);
expect(
request.mock.calls.every(([url]) =>
String(url).endsWith("/cleanup-plan")
),
).toBe(true);
},
);
test("restore follow-up cannot request cleanup while the authoritative catalog refresh remains unresolved", async () => {
const store = catalog();
const request = vi.fn(async (input: RequestInfo | URL) =>
String(input).endsWith("/restore")
? restoreResponse("accepted")
: Response.json(plan())
);
vi.stubGlobal("fetch", request);
render(WorkersPage, { params: { workspaceId: "page-test" }, data: data() });
publish(store, [worker({ pinned: false })]);
expect(await screen.findByRole("button", { name: "Delete Catalog worker" }))
.toBeTruthy();
shared.refresh.mockImplementationOnce(async () => {
store.update((previous) => ({
...previous,
catalogRefreshing: true,
observationVersion: previous.observationVersion + 1,
}));
});
await fireEvent.click(
screen.getByRole("button", { name: "Restore Catalog worker" }),
);
await waitFor(() =>
expect(
(screen.getByRole("button", {
name: "Pin Catalog worker",
}) as HTMLButtonElement).disabled,
).toBe(false)
);
expect(screen.queryByRole("button", { name: "Delete Catalog worker" }))
.toBeNull();
expect(
request.mock.calls.filter(([url]) => String(url).endsWith("/cleanup-plan")),
).toHaveLength(1);
});
test.each(["switch", "dispose"] as const)(
"late pin response has no UI, alert, refresh, or cleanup effects after %s",
async (mode) => {
catalog();
catalog("next-workspace");
const pending = deferred<Response>();
const request = vi.fn((_input: RequestInfo | URL) => pending.promise);
vi.stubGlobal("fetch", request);
let alerts: unknown[] = [];
const unsubscribe = workspaceAlerts.subscribe((value) => {
alerts = value;
});
const view = render(WorkersPage, {
params: { workspaceId: "page-test" },
data: data(),
});
await fireEvent.click(
screen.getByRole("button", { name: "Unpin Catalog worker" }),
);
expect(String(request.mock.calls[0][0])).toContain("/api/w/page-test/");
if (mode === "switch") {
await view.rerender({
data: data("next-workspace", worker({ display_name: "Next worker" })),
});
} else view.unmount();
pending.resolve(
Response.json({ message: "old pin failure" }, { status: 409 }),
);
await pending.promise;
await new Promise((done) => setTimeout(done, 0));
expect(shared.refresh).not.toHaveBeenCalled();
expect(request).toHaveBeenCalledTimes(1);
expect(alerts).toEqual([]);
if (mode === "switch") {
expect(screen.getByRole("button", { name: "Unpin Next worker" }))
.toBeTruthy();
}
unsubscribe();
},
);
test("pin response does not overwrite authoritative catalog retention", async () => {
const store = catalog();
const pending = deferred<Response>();
vi.stubGlobal(
"fetch",
vi.fn((input: RequestInfo | URL) =>
String(input).endsWith("/pin")
? pending.promise
: Promise.resolve(Response.json(plan()))
),
);
render(WorkersPage, { params: { workspaceId: "page-test" }, data: data() });
publish(store, [worker()]);
await fireEvent.click(
await screen.findByRole("button", { name: "Unpin Catalog worker" }),
);
publish(store, [worker({ retention_state: "newer catalog", pinned: true })]);
pending.resolve(
Response.json({
workspace_id: "page-test",
runtime_id: "runtime-a",
worker_id: "worker-a",
pinned: false,
retention_state: "old response",
}),
);
await waitFor(() => expect(shared.refresh).toHaveBeenCalledWith("page-test"));
expect(screen.getByText("newer catalog")).toBeTruthy();
expect(screen.queryByText("old response")).toBeNull();
});
test.each(["switch", "dispose"] as const)(
"late Delete response is ignored after %s",
async (mode) => {
const store = catalog();
catalog("next-workspace");
const pending = deferred<Response>();
const request = vi.fn((input: RequestInfo | URL) =>
String(input).endsWith("/cleanup-executions")
? pending.promise
: Promise.resolve(Response.json(plan()))
);
vi.stubGlobal("fetch", request);
const view = render(WorkersPage, {
params: { workspaceId: "page-test" },
data: data(),
});
publish(store, [worker({ pinned: false })]);
await fireEvent.click(
await screen.findByRole("button", { name: "Delete Catalog worker" }),
);
const execution = request.mock.calls.find(([url]) =>
String(url).endsWith("/cleanup-executions")
);
expect(String(execution?.[0])).toContain("/api/w/page-test/");
if (mode === "switch") {
await view.rerender({
data: data("next-workspace", worker({ display_name: "Next worker" })),
});
} else view.unmount();
const countBeforeCompletion = request.mock.calls.length;
let alerts: unknown[] = [];
const unsubscribe = workspaceAlerts.subscribe((value) => {
alerts = value;
});
pending.resolve(
Response.json({ message: "old deletion failure" }, { status: 409 }),
);
await pending.promise;
await new Promise((done) => setTimeout(done, 0));
expect(shared.refresh).not.toHaveBeenCalled();
expect(request).toHaveBeenCalledTimes(countBeforeCompletion);
expect(alerts).toEqual([]);
if (mode === "switch") {
expect(screen.getByRole("button", { name: "Unpin Next worker" }))
.toBeTruthy();
}
unsubscribe();
},
);
function restoreResponse(
state: "accepted" | "rejected" | "rolled_back" | "reconciliation_required",
extra = {},
) {
return Response.json({
workspace_id: "page-test",
runtime_id: "runtime-a",
worker_id: "worker-a",
result: { state, diagnostics: [], ...extra },
});
}
test.each(["stopped", "idle", "running", "paused", "unavailable"] as const)(
"Restore eligibility uses observed stopped catalog lifecycle (%s)",
async (state) => {
catalog();
vi.stubGlobal("fetch", vi.fn(async () => Response.json(plan())));
render(WorkersPage, {
params: { workspaceId: "page-test" },
data: data(
"page-test",
worker({
state,
availability: state === "unavailable" ? "unavailable" : "observed",
}),
),
});
expect(
(screen.getByRole("button", {
name: "Restore Catalog worker",
}) as HTMLButtonElement).disabled,
).toBe(state !== "stopped");
},
);
test.each(
["accepted", "rejected", "rolled_back", "reconciliation_required"] as const,
)(
"Restore classifies %s through the shared helper, without treating HTTP 200 as success",
async (state) => {
catalog();
let alerts: { title?: string; level: string }[] = [];
const unsubscribe = workspaceAlerts.subscribe((value) => {
alerts = value;
});
const request = vi.fn(async (input: RequestInfo | URL) =>
String(input).endsWith("/restore")
? restoreResponse(state)
: Response.json(plan())
);
vi.stubGlobal("fetch", request);
render(WorkersPage, { params: { workspaceId: "page-test" }, data: data() });
await fireEvent.click(
screen.getByRole("button", { name: "Restore Catalog worker" }),
);
await waitFor(() =>
expect(shared.refresh).toHaveBeenCalledWith("page-test")
);
expect(alerts.at(-1)?.level).toBe(
state === "accepted"
? "info"
: state === "reconciliation_required"
? "warning"
: "error",
);
expect(alerts.some((alert) => alert.title === "Worker restored")).toBe(
state === "accepted",
);
expect(
Boolean(
screen.queryByRole("button", { name: "Retry Restore Catalog worker" }),
),
).toBe(state === "reconciliation_required");
// The response is never projected into the catalog by the action.
expect(screen.getByRole("cell", { name: "stopped" })).toBeTruthy();
unsubscribe();
},
);
test.each(["transport", "reconciliation"] as const)(
"explicit %s retry reuses the exact guarded request despite newer observations",
async (failure) => {
const store = catalog();
let attempts = 0;
const request = vi.fn(
async (input: RequestInfo | URL, _init?: RequestInit) => {
if (!String(input).endsWith("/restore")) return Response.json(plan());
if (++attempts === 1) {
if (failure === "transport") throw new TypeError("connection lost");
return restoreResponse("reconciliation_required");
}
return restoreResponse("accepted");
},
);
vi.stubGlobal("fetch", request);
render(WorkersPage, { params: { workspaceId: "page-test" }, data: data() });
await fireEvent.click(
screen.getByRole("button", { name: "Restore Catalog worker" }),
);
const retry = await screen.findByRole("button", {
name: "Retry Restore Catalog worker",
});
publish(store, [
worker({
pinned: true,
...{ restore_observation_token: "observation-2" },
}),
]);
await waitFor(() =>
expect((retry as HTMLButtonElement).disabled).toBe(false)
);
expect(attempts).toBe(1);
expect(
(screen.getByRole("button", {
name: "Restore Catalog worker",
}) as HTMLButtonElement).disabled,
).toBe(true);
await fireEvent.click(retry);
await waitFor(() => expect(attempts).toBe(2));
const operations = request.mock.calls.filter(([url]) =>
String(url).endsWith("/restore")
);
expect(operations[1][1]?.body).toBe(operations[0][1]?.body);
expect(JSON.parse(String(operations[0][1]?.body))).toEqual({
expected_observation_token: "observation-1",
request_id: expect.any(String),
});
},
);
test.each(["switch", "dispose"] as const)(
"late restore result is ignored after %s",
async (mode) => {
catalog();
catalog("next-workspace");
const pending = deferred<Response>();
const request = vi.fn((_input: RequestInfo | URL) => pending.promise);
vi.stubGlobal("fetch", request);
let alerts: unknown[] = [];
const unsubscribe = workspaceAlerts.subscribe((value) => {
alerts = value;
});
const view = render(WorkersPage, {
params: { workspaceId: "page-test" },
data: data(),
});
await fireEvent.click(
screen.getByRole("button", { name: "Restore Catalog worker" }),
);
expect(
(screen.getByRole("button", {
name: "Unpin Catalog worker",
}) as HTMLButtonElement).disabled,
).toBe(true);
if (mode === "switch") {
await view.rerender({
data: data("next-workspace", worker({ display_name: "Next worker" })),
});
} else view.unmount();
pending.resolve(restoreResponse("accepted"));
await pending.promise;
await new Promise((done) => setTimeout(done, 0));
expect(shared.refresh).not.toHaveBeenCalled();
expect(request).toHaveBeenCalledTimes(1);
expect(alerts).toEqual([]);
unsubscribe();
},
);
@@ -0,0 +1,314 @@
// @vitest-environment happy-dom
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/svelte";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import type { ComponentProps } from "svelte";
import type {
SubscriptionFrame,
SubscriptionWorker,
} from "#lib/generated/protocol.ts";
import {
clearWorkspaceAlerts,
workspaceAlerts,
} from "#lib/workspace/alerts/store.ts";
import { disposeWorkspaceWorkersStore } from "#lib/workspace/sidebar/worker-subscription.ts";
import WorkersNavSection from "#lib/workspace/sidebar/WorkersNavSection.svelte";
import WorkersPage from "./+page.svelte";
const transport = vi.hoisted(() => ({
listener: null as null | { onFrame(frame: SubscriptionFrame): void },
}));
vi.mock("#lib/workspace/multiplexer.ts", () => ({
workspaceMultiplexer: () => ({
subscribe: (_: unknown, listener: typeof transport.listener) => {
transport.listener = listener;
return { close: vi.fn() };
},
}),
}));
const workspaceId = "team /";
const runtimeId = "runtime /";
const workerId = "worker /";
const restoreUrl =
"/api/w/team%20%2F/runtimes/runtime%20%2F/workers/worker%20%2F/restore";
function worker(state = "stopped", token = "generation-1") {
return {
runtime_id: runtimeId,
worker_id: workerId,
resource_key: "W-1",
host_id: runtimeId,
display_name: "Team Worker",
label: "Team Worker",
availability: "observed" as const,
state,
restore_observation_token: token,
tags: [],
diagnostics: [],
pinned: false,
retention_state: "normal",
workspace: {
visibility: "workspace",
identity: "registered",
workspace_id: workspaceId,
},
implementation: { kind: "runtime", display_hint: "Runtime" },
workdir_attachments: [],
worker_state: state === "stopped"
? null
: { last_command_id: 0, state: { kind: "idle" as const } },
};
}
function frame(current: ReturnType<typeof worker>): SubscriptionFrame {
const subscriptionWorker = {
...current,
has_running_internal_workers: false,
workspace_id: workspaceId,
} as SubscriptionWorker;
return {
protocol_version: 2,
frame: "response",
message: {
result: "subscribed",
payload: {
request_id: "request",
subscription_id: "sub",
selector: { topic: "workspace_workers" },
snapshot: { topic: "workers", data: { workers: [subscriptionWorker] } },
},
},
};
}
function plan(state: string) {
return {
workspace_id: workspaceId,
runtime_id: runtimeId,
generated_at: "2026-10-06T00:00:00Z",
revision: state,
digest: state,
diagnostics: [],
workdirs: [],
workers: state === "stopped"
? [{
target_id: "target",
action: "worker_delete",
worker_id: "W-1",
runtime_worker_id: workerId,
runtime_id: runtimeId,
reason: "stopped",
pinned: false,
retention_state: "normal",
linked_workdir_ids: [],
running_linked: false,
}]
: [],
};
}
function response(state: string, summary?: ReturnType<typeof worker>) {
return Response.json({
workspace_id: workspaceId,
runtime_id: runtimeId,
worker_id: workerId,
result: { state, worker: summary, diagnostics: [] },
});
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((r) => resolve = r);
return { promise, resolve };
}
function mount() {
const row = worker();
render(WorkersNavSection, {
workspaceId,
currentPath: `/w/${workspaceId}/workers`,
});
render(WorkersPage, {
params: { workspaceId },
data: {
workspaceId,
workers: { items: [row] },
workersError: null,
cleanupPlans: {},
cleanupPlanErrors: {},
} as unknown as ComponentProps<typeof WorkersPage>["data"],
});
}
async function sidebarRestore() {
await fireEvent.click(
await screen.findByRole("button", { name: "Actions for Team Worker" }),
);
return screen.findByRole("menuitem", { name: "Restore" });
}
function pageRestore() {
return screen.getByRole("button", { name: "Restore Team Worker" });
}
let current = worker();
let handleRestore: (init?: RequestInit) => Promise<Response>;
let requests: ReturnType<typeof vi.fn>;
beforeEach(() => {
transport.listener = null;
current = worker();
handleRestore = async () => response("accepted");
requests = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url === restoreUrl) return handleRestore(init);
if (url.endsWith("/workers")) {
return Response.json({
workspace_id: workspaceId,
limit: 100,
source: "catalog",
items: [current],
diagnostics: [],
});
}
if (url.endsWith("/cleanup-plan")) {
return Response.json(plan(current.state));
}
if (url.endsWith("/working-directories")) {
return Response.json({
workspace_id: workspaceId,
items: [],
diagnostics: [],
});
}
throw new Error(`Unexpected request ${url}`);
});
vi.stubGlobal("fetch", requests);
clearWorkspaceAlerts();
});
afterEach(() => {
cleanup();
disposeWorkspaceWorkersStore(workspaceId);
vi.unstubAllGlobals();
clearWorkspaceAlerts();
});
async function ready() {
await waitFor(() => expect(transport.listener).not.toBeNull());
transport.listener!.onFrame(frame(current));
await waitFor(() =>
expect((pageRestore() as HTMLButtonElement).disabled).toBe(false)
);
await screen.findByRole("button", { name: "Delete Team Worker" });
}
test.each(["sidebar", "page"] as const)(
"%s Restore updates both actual components through the real store and invalidates cleanup without reload",
async (entrance) => {
const pending = deferred<Response>();
handleRestore = () => pending.promise;
mount();
await ready();
await fireEvent.click(
entrance === "sidebar" ? await sidebarRestore() : pageRestore(),
);
const operations = requests.mock.calls.filter(([url]) =>
String(url) === restoreUrl
);
expect(operations).toHaveLength(1);
expect(operations[0][1].method).toBe("POST");
expect(JSON.parse(operations[0][1].body)).toEqual({
expected_observation_token: "generation-1",
request_id: expect.any(String),
});
current = worker("idle", "generation-2");
transport.listener!.onFrame(frame(current));
await screen.findByRole("cell", { name: "idle" });
await waitFor(() =>
expect(screen.queryByRole("button", { name: "Delete Team Worker" }))
.toBeNull()
);
// A late old response cannot put either component back into stopped/running.
pending.resolve(response("accepted", worker("stopped", "generation-1")));
await waitFor(() =>
expect((pageRestore() as HTMLButtonElement).disabled).toBe(true)
);
expect(screen.queryByRole("cell", { name: "stopped" })).toBeNull();
const menuRestore = await sidebarRestore();
expect((menuRestore as HTMLButtonElement).disabled).toBe(true);
expect(screen.getAllByRole("link", { name: /Team Worker/ })).toHaveLength(
2,
);
expect(requests.mock.calls.filter(([url]) => String(url) === restoreUrl))
.toHaveLength(1);
},
);
test("separate entrances remain independent while Runtime conflict triggers shared refresh, not automatic replay", async () => {
const first = deferred<Response>();
let count = 0;
handleRestore = async () =>
++count === 1 ? first.promise : Response.json({
error: "restore_observation_conflict",
message: "Observation changed",
}, { status: 409 });
const alerts: Array<unknown[]> = [];
const unsubscribe = workspaceAlerts.subscribe((value) => alerts.push(value));
mount();
await ready();
await fireEvent.click(await sidebarRestore());
expect((pageRestore() as HTMLButtonElement).disabled).toBe(false); // not a cross-screen lock
await fireEvent.click(pageRestore());
await waitFor(() =>
expect(JSON.stringify(alerts)).toContain("Worker observation changed")
);
const operations = requests.mock.calls.filter(([url]) =>
String(url) === restoreUrl
);
expect(operations).toHaveLength(2);
const [a, b] = operations.map(([, init]) => JSON.parse(init.body));
expect(a.expected_observation_token).toBe(b.expected_observation_token);
expect(a.request_id).not.toBe(b.request_id);
expect(screen.queryByRole("button", { name: "Retry Restore Team Worker" }))
.toBeNull();
current = worker("idle", "generation-2");
transport.listener!.onFrame(frame(current));
first.resolve(response("accepted"));
await screen.findByRole("cell", { name: "idle" });
await waitFor(() =>
expect((pageRestore() as HTMLButtonElement).disabled).toBe(true)
);
expect(count).toBe(2);
unsubscribe();
});
test.each(["http", "malformed"] as const)(
"%s Restore response never produces success in either entrance",
async (failure) => {
handleRestore = async () =>
failure === "http"
? Response.json({ message: "forbidden restore" }, { status: 403 })
: Response.json({
workspace_id: workspaceId,
runtime_id: runtimeId,
worker_id: workerId,
result: { state: "new-state" },
});
const alerts: Array<unknown[]> = [];
const unsubscribe = workspaceAlerts.subscribe((value) =>
alerts.push(value)
);
mount();
await ready();
await fireEvent.click(pageRestore());
await waitFor(() =>
expect(JSON.stringify(alerts)).toContain(
failure === "http" ? "forbidden restore" : "outcome unknown",
)
);
expect(JSON.stringify(alerts)).not.toContain("Worker restored");
expect(screen.getByRole("cell", { name: "stopped" })).toBeTruthy();
// The sidebar is a second explicit entrance, not an automatic retry.
await fireEvent.click(await sidebarRestore());
await waitFor(() =>
expect(requests.mock.calls.filter(([url]) => String(url) === restoreUrl))
.toHaveLength(2)
);
expect(JSON.stringify(alerts)).not.toContain("Worker restored");
unsubscribe();
},
);
@@ -1,7 +1,12 @@
import {
canDeleteSidebarWorker,
canRestoreWorker,
canStopSidebarWorker,
createRestoreRequest,
deleteSidebarWorker,
restoreErrorNotice,
restoreNotice,
restoreWorkspaceWorker,
stopSidebarWorker,
} from "../../src/lib/workspace/sidebar/worker-actions.ts";
import type { Worker } from "../../src/lib/workspace/sidebar/types.ts";
@@ -243,7 +248,176 @@ Deno.test("Worker navigation exposes an accessible hover action menu", async ()
assert(source.includes('aria-haspopup="menu"'));
assert(source.includes('role="menuitem"'));
assert(source.includes("stopSidebarWorker(workspaceId, worker)"));
assert(source.includes("deleteSidebarWorker(workspaceId, worker)"));
assert(source.includes("stopSidebarWorker"));
assert(source.includes("deleteSidebarWorker"));
assert(styles.includes(".worker-nav-item:hover .worker-actions-trigger"));
});
Deno.test("Restore eligibility uses observed catalog state and backend token, not foreground state", () => {
const stopped = {
availability: "observed" as const,
state: "unknown",
lifecycleState: "stopped",
restore_observation_token: "generation",
};
assert(canRestoreWorker(stopped));
assert(!canRestoreWorker({ ...stopped, availability: "unavailable" }));
assert(
!canRestoreWorker({ ...stopped, lifecycleState: "idle", state: "stopped" }),
);
assert(!canRestoreWorker({ ...stopped, restore_observation_token: null }));
assert(
canRestoreWorker({
availability: "observed",
state: "stopped",
restore_observation_token: "generation",
}),
);
});
Deno.test("Restore scoped POST encodes identities and sends immutable intent without assignment query", async () => {
const request = createRestoreRequest({
restore_observation_token: "generation",
});
const requests: Array<{ url: string; init?: RequestInit }> = [];
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
requests.push({ url: input.toString(), init });
return jsonResponse({
workspace_id: "team space",
runtime_id: worker.runtime_id,
worker_id: worker.worker_id,
result: { state: "accepted" },
});
}) as typeof fetch;
await restoreWorkspaceWorker("team space", worker, request, fetchFn);
await restoreWorkspaceWorker("team space", worker, request, fetchFn);
assertEquals(
requests[0].url,
"/api/w/team%20space/runtimes/runtime%20%2F/workers/worker%20%2F/restore",
);
assertEquals(requests[0].init?.method, "POST");
assertEquals(JSON.parse(String(requests[0].init?.body)), request);
assertEquals(requests[1].init?.body, requests[0].init?.body);
assert(request.request_id.length > 0);
});
Deno.test("Restore classification never promotes nonaccepted results and preserves unknown outcome recovery", async () => {
for (
const state of [
"accepted",
"rejected",
"rolled_back",
"reconciliation_required",
] as const
) {
const response = await restoreWorkspaceWorker(
"team",
worker,
{ expected_observation_token: "g", request_id: "id" },
(() =>
Promise.resolve(jsonResponse({
workspace_id: "team",
runtime_id: worker.runtime_id,
worker_id: worker.worker_id,
result: {
state,
diagnostics: [{
code: "result",
severity: "warning",
message: "backend reason",
}],
},
}))) as typeof fetch,
);
const notice = restoreNotice(response);
assertEquals(notice.level === "info", state === "accepted");
assertEquals(notice.retry, state === "reconciliation_required");
if (state !== "accepted") assert(notice.message.includes("backend reason"));
}
for (const status of [400, 403, 409, 500]) {
try {
await restoreWorkspaceWorker(
"team",
worker,
{ expected_observation_token: "g", request_id: "id" },
(() =>
Promise.resolve(
jsonResponse({ error: "conflict", message: "changed" }, status),
)) as typeof fetch,
);
throw new Error("must reject");
} catch (error) {
const notice = restoreErrorNotice(error);
assertEquals(notice.retry, status >= 500);
if (status === 409) {
assertEquals(notice.title, "Worker observation changed");
}
}
}
assert(restoreErrorNotice(new TypeError("network failed")).retry);
await assertRejects(
() =>
restoreWorkspaceWorker(
"team",
worker,
{ expected_observation_token: "g", request_id: "id" },
(() =>
Promise.resolve(
jsonResponse({
workspace_id: "other",
runtime_id: worker.runtime_id,
worker_id: worker.worker_id,
result: { state: "accepted" },
}),
)) as typeof fetch,
),
"requested target",
);
await assertRejects(
() =>
restoreWorkspaceWorker(
"team",
worker,
{ expected_observation_token: "g", request_id: "id" },
(() =>
Promise.resolve(
jsonResponse({
workspace_id: "team",
runtime_id: worker.runtime_id,
worker_id: worker.worker_id,
result: { state: "future" },
}),
)) as typeof fetch,
),
"unknown value",
);
});
Deno.test("Restore bounds the response before decoding and retains uncertain outcome instead of success", async () => {
let response = new Response("{}", {
headers: { "content-length": String(8 * 1024 * 1024 + 1) },
});
let caught: unknown;
try {
await restoreWorkspaceWorker("team", worker, {
expected_observation_token: "g",
request_id: "id",
}, (() => Promise.resolve(response)) as typeof fetch);
} catch (error) {
caught = error;
}
assert(caught instanceof Error);
assert(restoreErrorNotice(caught).retry);
response = new Response("{}", {
status: 409,
headers: { "content-length": String(64 * 1024 + 1) },
});
try {
await restoreWorkspaceWorker("team", worker, {
expected_observation_token: "g",
request_id: "id",
}, (() => Promise.resolve(response)) as typeof fetch);
} catch (error) {
caught = error;
}
assertEquals(restoreErrorNotice(caught).title, "Worker observation changed");
assertEquals(restoreErrorNotice(caught).retry, false);
});
+182 -6
View File
@@ -6,6 +6,7 @@ import {
parseRuntimeCleanupExecution,
parseRuntimeCleanupPlan,
parseRuntimeWorkerLifecycleResult,
parseWorkerRestoreResponse,
parseWorkerRetentionResponse,
} from "../src/lib/workspace/api/runtime-workers.ts";
@@ -19,7 +20,9 @@ function assertThrows(operation: () => unknown, expected: string): void {
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes(expected)) return;
throw new Error(`expected error containing ${expected}, received ${message}`);
throw new Error(
`expected error containing ${expected}, received ${message}`,
);
}
throw new Error("expected operation to throw");
}
@@ -67,7 +70,10 @@ function plan() {
Deno.test("Runtime cleanup parsers accept the generated exact response shapes", () => {
const parsedPlan = parseRuntimeCleanupPlan(plan());
assert(parsedPlan.workers[0]?.runtime_worker_id === "worker-1", "worker candidate missing");
assert(
parsedPlan.workers[0]?.runtime_worker_id === "worker-1",
"worker candidate missing",
);
const execution = parseRuntimeCleanupExecution({
workspace_id: "workspace-a",
@@ -82,7 +88,10 @@ Deno.test("Runtime cleanup parsers accept the generated exact response shapes",
plan_after: plan(),
diagnostics: [],
});
assert(execution.results[0]?.status === "deleted", "execution result missing");
assert(
execution.results[0]?.status === "deleted",
"execution result missing",
);
const lifecycle = parseRuntimeWorkerLifecycleResult({
state: "accepted",
@@ -108,15 +117,182 @@ Deno.test("Runtime cleanup parsers reject unknown fields and unsafe values", ()
"not part of the wire contract",
);
assertThrows(
() => parseRuntimeCleanupPlan({ ...plan(), workers: new Array(1_001).fill(plan().workers[0]) }),
() =>
parseRuntimeCleanupPlan({
...plan(),
workers: new Array(1_001).fill(plan().workers[0]),
}),
"at most 1000",
);
assertThrows(
() => parseRuntimeCleanupPlan({ ...plan(), workers: [{ ...plan().workers[0], estimated_reclaim_bytes: Number.MAX_SAFE_INTEGER + 1 }] }),
() =>
parseRuntimeCleanupPlan({
...plan(),
workers: [{
...plan().workers[0],
estimated_reclaim_bytes: Number.MAX_SAFE_INTEGER + 1,
}],
}),
"safe integer",
);
assertThrows(
() => parseRuntimeWorkerLifecycleResult({ state: "future", runtime_id: "runtime-a", worker_id: "worker-1", diagnostics: [] }),
() =>
parseRuntimeWorkerLifecycleResult({
state: "future",
runtime_id: "runtime-a",
worker_id: "worker-1",
diagnostics: [],
}),
"unknown value",
);
});
function restore(state = "accepted", worker?: unknown) {
return {
workspace_id: "workspace-a",
runtime_id: "runtime-a",
worker_id: "worker-1",
result: { state, diagnostics: [], worker },
};
}
function restoredWorker() {
return {
runtime_id: "runtime-a",
worker_id: "worker-1",
resource_key: "W-1",
host_id: "runtime-a",
label: "Worker",
availability: "observed",
state: "idle",
workspace: {
visibility: "workspace",
identity: "registered",
workspace_id: "workspace-a",
},
implementation: { kind: "runtime", display_hint: "Runtime" },
restore_observation_token: "generation-b",
};
}
Deno.test("Restore parser preserves all four states and optional Workspace summary", () => {
for (
const state of [
"accepted",
"rejected",
"rolled_back",
"reconciliation_required",
]
) {
assert(
parseWorkerRestoreResponse(restore(state)).result.state === state,
"state lost",
);
assert(
parseWorkerRestoreResponse(restore(state, null)).result.worker === null,
"null lost",
);
}
const parsed = parseWorkerRestoreResponse(
restore("accepted", restoredWorker()),
);
assert(
parsed.result.worker?.resource_key === "W-1",
"Workspace summary lost",
);
assert(parsed.result.worker?.state === "idle", "must not force running");
});
Deno.test("Restore parser bounds diagnostics, validates wrapper/summary identity and rejects malformed results", () => {
assertThrows(() => parseWorkerRestoreResponse(restore("accepted", {
...restoredWorker(), restore_observation_token: "😀".repeat(65),
})), "256 UTF-8 byte limit");
assertThrows(
() => parseWorkerRestoreResponse(restore("future")),
"unknown value",
);
assertThrows(
() => parseWorkerRestoreResponse({ ...restore(), runtime_id: null }),
"must be a string",
);
assertThrows(
() => parseWorkerRestoreResponse({ ...restore(), extra: true }),
"not part of the wire contract",
);
assertThrows(
() =>
parseWorkerRestoreResponse({
...restore(),
result: {
state: "accepted",
diagnostics: new Array(65).fill({
code: "e",
severity: "error",
message: "e",
}),
},
}),
"at most 64",
);
assertThrows(
() =>
parseWorkerRestoreResponse({
...restore(),
result: {
state: "rejected",
diagnostics: [{
code: "e",
severity: "error",
message: "x".repeat(4097),
}],
},
}),
"4096",
);
assertThrows(
() =>
parseWorkerRestoreResponse({
...restore(),
result: {
state: "rejected",
diagnostics: [{ code: "e", severity: "invalid", message: "e" }],
},
}),
"unknown value",
);
assertThrows(
() =>
parseWorkerRestoreResponse(
restore("accepted", { ...restoredWorker(), worker_id: "other" }),
),
"identity",
);
assertThrows(
() =>
parseWorkerRestoreResponse(
restore("accepted", {
...restoredWorker(),
workspace: {
visibility: "workspace",
identity: "registered",
workspace_id: "other",
},
}),
),
"identity",
);
assertThrows(
() =>
parseWorkerRestoreResponse(
restore("accepted", { worker_id: "worker-1", state: "idle" }),
),
"availability",
);
assertThrows(
() =>
parseWorkerRestoreResponse({
state: "accepted",
runtime_id: "runtime-a",
worker_id: "worker-1",
diagnostics: [],
}),
"wire contract",
);
});