web: subscribe sidebar to workspace workers

This commit is contained in:
2026-08-01 20:12:27 +09:00
parent b977c4cbad
commit 72cef5ed9e
4 changed files with 289 additions and 54 deletions
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { workspaceApiPath } from '$lib/workspace/api/http';
import { workerConsoleHref } from '$lib/workspace/console/model'; import { workerConsoleHref } from '$lib/workspace/console/model';
import { workspaceWorkersStore } from './worker-subscription';
import { canShowWorkerInSidebar } from './workers'; import { canShowWorkerInSidebar } from './workers';
import type { ListResponse, Worker } from './types'; import type { Worker } from './types';
const MAX_VISIBLE_WORKERS = 6; const MAX_VISIBLE_WORKERS = 6;
@@ -12,61 +12,18 @@
}; };
let { currentPath = '/', workspaceId }: Props = $props(); let { currentPath = '/', workspaceId }: Props = $props();
function workerApiPath(path: string): string {
return workspaceApiPath(workspaceId, path);
}
let loading = $state(true); let loading = $state(true);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let workers = $state<Worker[]>([]); let workers = $state<Worker[]>([]);
let placeholder = $state<string | null>(null);
$effect(() => { $effect(() => {
if (!workspaceId) { const subscription = workspaceWorkersStore(workspaceId);
loading = false; return subscription.subscribe((state) => {
workers = []; loading = state.loading;
return; error = state.error;
} workers = state.workers.filter(canShowWorkerInSidebar).slice(0, MAX_VISIBLE_WORKERS);
});
const controller = new AbortController();
void loadWorkers(controller.signal);
return () => controller.abort();
}); });
async function loadWorkers(signal?: AbortSignal) {
loading = true;
error = null;
placeholder = null;
try {
const response = await fetch(workerApiPath('/workers'), { signal });
if (response.status === 404) {
workers = [];
placeholder = 'Worker API is not integrated in this build yet.';
return;
}
if (!response.ok) {
throw new Error(`workers request failed (${response.status})`);
}
const payload = (await response.json()) as ListResponse<Worker>;
workers = Array.isArray(payload.items)
? payload.items.filter(canShowWorkerInSidebar).slice(0, MAX_VISIBLE_WORKERS)
: [];
if (workers.length === 0) {
placeholder = 'No workers reported by the current API.';
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
return;
}
error = err instanceof Error ? err.message : 'workers request failed';
workers = [];
} finally {
if (!signal?.aborted) {
loading = false;
}
}
}
</script> </script>
<section class="nav-section" aria-labelledby="workers-heading"> <section class="nav-section" aria-labelledby="workers-heading">
@@ -94,11 +51,10 @@
{#if loading} {#if loading}
<p class="section-state">Checking workers…</p> <p class="section-state">Checking workers…</p>
{:else if error}
<p class="section-state error">{error}</p>
{:else if workers.length === 0} {:else if workers.length === 0}
<p class="section-state">{placeholder ?? 'Workers will appear here when an API is connected.'}</p> <p class="section-state" class:error={Boolean(error)}>{error ?? 'No Workers are active.'}</p>
{:else} {:else}
{#if error}<p class="section-state error">{error}</p>{/if}
<ul class="nav-list" aria-label="Workers"> <ul class="nav-list" aria-label="Workers">
{#each workers as worker (`${worker.runtime_id}:${worker.worker_id}`)} {#each workers as worker (`${worker.runtime_id}:${worker.worker_id}`)}
{@const href = workerConsoleHref(worker, workspaceId)} {@const href = workerConsoleHref(worker, workspaceId)}
@@ -0,0 +1,60 @@
import type {
SubscriptionEventPayload,
SubscriptionFrame,
SubscriptionWorker,
} from '$lib/generated/protocol';
export type WorkspaceWorkersProjection = {
workers: Map<string, SubscriptionWorker>;
revisions: Map<string, number>;
};
export function createWorkspaceWorkersProjection(): WorkspaceWorkersProjection {
return { workers: new Map(), revisions: new Map() };
}
export function applyWorkspaceWorkersFrame(
projection: WorkspaceWorkersProjection,
frame: SubscriptionFrame,
): void {
if (frame.protocol_version !== 1) throw new Error('unsupported Worker subscription protocol');
if (frame.frame === 'response' && frame.message.result === 'subscribed') {
if (frame.message.payload.selector.topic !== 'workspace_workers') return;
const snapshot = frame.message.payload.snapshot;
if (snapshot.topic !== 'workers') throw new Error('workspace_workers returned a non-Worker snapshot');
projection.workers.clear();
projection.revisions.clear();
for (const worker of snapshot.data.workers) {
const key = workerKey(worker.runtime_id, worker.worker_id);
projection.workers.set(key, worker);
projection.revisions.set(key, worker.subject_revision);
}
return;
}
if (frame.frame !== 'event' || frame.message.event !== 'event') return;
applyPayload(projection, frame.message.data.subject_revision, frame.message.data.payload);
}
function applyPayload(
projection: WorkspaceWorkersProjection,
subjectRevision: number,
payload: SubscriptionEventPayload,
): void {
if (payload.event === 'worker_upserted') {
const worker = payload.data.worker;
const key = workerKey(worker.runtime_id, worker.worker_id);
if (subjectRevision <= (projection.revisions.get(key) ?? 0)) return;
projection.revisions.set(key, subjectRevision);
projection.workers.set(key, worker);
} else if (payload.event === 'worker_removed') {
const key = workerKey(payload.data.runtime_id, payload.data.worker_id);
if (subjectRevision <= (projection.revisions.get(key) ?? 0)) return;
projection.revisions.set(key, subjectRevision);
projection.workers.delete(key);
}
}
function workerKey(runtimeId: string | null | undefined, workerId: string): string {
if (!runtimeId) throw new Error('Workspace Worker projection is missing runtime_id');
return `${runtimeId}:${workerId}`;
}
@@ -0,0 +1,89 @@
import type { SubscriptionFrame, SubscriptionWorker } from '$lib/generated/protocol';
function assertEquals(actual: unknown, expected: unknown): void {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
}
import {
applyWorkspaceWorkersFrame,
createWorkspaceWorkersProjection,
} from './worker-subscription-model';
declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void;
};
function worker(runtimeId: string, workerId: string, revision: number): SubscriptionWorker {
return {
worker_id: workerId,
runtime_id: runtimeId,
subject_revision: revision,
state: 'idle',
workspace_id: 'workspace-test',
display_name: null,
profile: null,
working_directory_id: null,
};
}
Deno.test('workspace Worker snapshot keeps equal local ids from different Runtimes', () => {
const projection = createWorkspaceWorkersProjection();
const frame: SubscriptionFrame = {
protocol_version: 1,
frame: 'response',
message: {
result: 'subscribed',
payload: {
request_id: 'request-1',
subscription_id: 'subscription-1',
selector: { topic: 'workspace_workers' },
snapshot_revision: 1,
snapshot: {
topic: 'workers',
data: { workers: [worker('runtime-a', '1', 1), worker('runtime-b', '1', 1)] },
},
},
},
};
applyWorkspaceWorkersFrame(projection, frame);
assertEquals([...projection.workers.keys()].sort(), ['runtime-a:1', 'runtime-b:1']);
});
Deno.test('workspace Worker reducer ignores stale events and removes composite subject', () => {
const projection = createWorkspaceWorkersProjection();
projection.workers.set('runtime-a:1', worker('runtime-a', '1', 3));
projection.revisions.set('runtime-a:1', 3);
applyWorkspaceWorkersFrame(projection, {
protocol_version: 1,
frame: 'event',
message: {
event: 'event',
data: {
subscription_id: 'subscription-1',
subject_revision: 2,
payload: { event: 'worker_upserted', data: { worker: worker('runtime-a', '1', 2) } },
},
},
});
assertEquals(projection.revisions.get('runtime-a:1'), 3);
applyWorkspaceWorkersFrame(projection, {
protocol_version: 1,
frame: 'event',
message: {
event: 'event',
data: {
subscription_id: 'subscription-1',
subject_revision: 4,
payload: {
event: 'worker_removed',
data: { worker_id: '1', runtime_id: 'runtime-a' },
},
},
},
});
assertEquals(projection.workers.size, 0);
assertEquals(projection.revisions.get('runtime-a:1'), 4);
});
@@ -0,0 +1,130 @@
import { browser } from '$app/environment';
import { readable, type Readable } from 'svelte/store';
import type { SubscriptionFrame, SubscriptionWorker } from '$lib/generated/protocol';
import { workspaceApiPath } from '$lib/workspace/api/http';
import {
applyWorkspaceWorkersFrame,
createWorkspaceWorkersProjection,
} from './worker-subscription-model';
import type { Worker } from './types';
export type WorkspaceWorkersState = {
loading: boolean;
error: string | null;
workers: Worker[];
};
const stores = new Map<string, Readable<WorkspaceWorkersState>>();
export function workspaceWorkersStore(workspaceId: string): Readable<WorkspaceWorkersState> {
const cached = stores.get(workspaceId);
if (cached) return cached;
const store = readable<WorkspaceWorkersState>(
{ loading: true, error: null, workers: [] },
(set) => {
if (!browser || !workspaceId) {
set({ loading: false, error: null, workers: [] });
return;
}
let closed = false;
let socket: WebSocket | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
const projection = createWorkspaceWorkersProjection();
const publish = (loading = false, error: string | null = null) => {
const workers = [...projection.workers.values()]
.map(projectWorker)
.sort((left, right) =>
left.runtime_id.localeCompare(right.runtime_id) ||
left.worker_id.localeCompare(right.worker_id)
);
set({ loading, error, workers });
};
const connect = () => {
if (closed) return;
const url = new URL(workspaceApiPath(workspaceId, '/protocol/ws'), window.location.origin);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
socket = new WebSocket(url);
socket.addEventListener('open', () => {
const frame: SubscriptionFrame = {
protocol_version: 1,
frame: 'request',
message: {
method: 'subscribe_events',
params: {
request_id: crypto.randomUUID(),
selector: { topic: 'workspace_workers' },
},
},
};
socket?.send(JSON.stringify(frame));
});
socket.addEventListener('message', (message) => {
try {
const frame = JSON.parse(String(message.data)) as SubscriptionFrame;
let closedMessage: string | null = null;
if (frame.frame === 'event' && frame.message.event === 'subscription_closed') {
closedMessage = frame.message.data.message;
} else if (
frame.frame === 'response' &&
frame.message.result === 'subscription_rejected'
) {
closedMessage = frame.message.payload.message;
}
if (closedMessage) {
socket?.close();
throw new Error(closedMessage);
}
applyWorkspaceWorkersFrame(projection, frame);
publish(false, null);
} catch (error) {
publish(false, error instanceof Error ? error.message : 'invalid Worker subscription frame');
}
});
socket.addEventListener('close', () => {
socket = null;
if (closed) return;
publish(projection.workers.size === 0, 'Worker subscription disconnected; reconnecting…');
reconnectTimer = setTimeout(connect, 500);
});
socket.addEventListener('error', () => socket?.close());
};
connect();
return () => {
closed = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
socket?.close();
};
},
);
stores.set(workspaceId, store);
return store;
}
function projectWorker(worker: SubscriptionWorker): Worker {
if (!worker.runtime_id) throw new Error('Workspace Worker projection is missing runtime_id');
const displayName = worker.display_name ?? `Worker ${worker.worker_id}`;
return {
runtime_id: worker.runtime_id,
worker_id: worker.worker_id,
host_id: worker.runtime_id,
display_name: displayName,
label: displayName,
profile: worker.profile ?? null,
tags: [],
workspace: { visibility: 'workspace', identity: 'runtime_subscription_worker' },
state: worker.state,
pinned: false,
retention_state: 'transient',
implementation: {
kind: 'runtime_subscription_worker',
display_hint: 'Workspace-authorized Runtime Worker',
},
capabilities: {
can_stop: worker.state !== 'stopped' && worker.state !== 'cancelled',
can_spawn_followup: false,
},
working_directory: null,
diagnostics: [],
};
}