feat: guarded Worker Restore from shared Web actions (T-701)
This commit is contained in:
@@ -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
@@ -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",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
+314
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user