Replace Worker list revisions with ordered subscriptions

This commit is contained in:
2026-10-02 10:41:02 +09:00
parent 8f8c2676b4
commit 2c329072a2
24 changed files with 1320 additions and 963 deletions
File diff suppressed because one or more lines are too long
+3 -7
View File
@@ -304,11 +304,7 @@ resource_key?: string | null,
*/
availability: SubscriptionWorkerAvailability,
/**
* Producer-owned monotonic revision for this Worker subject.
*/
subject_revision: number,
/**
* Latest revisioned foreground state observed from the Worker. This remains
* Latest foreground state observed from the Worker. This remains
* absent until an authoritative Worker snapshot/event has been applied.
*/
worker_state?: WorkerStateSnapshot | null,
@@ -336,9 +332,9 @@ export type SubscriptionRequest = { "method": "subscribe_events", "params": { re
export type SubscriptionWorkerProtocolMethod = { subscription_id: SubscriptionId, method: Method, };
export type SubscriptionResponse = { "result": "subscribed", "payload": { request_id: SubscriptionRequestId, subscription_id: SubscriptionId, selector: EventSubscriptionSelector, snapshot_revision: number, snapshot: SubscriptionSnapshot, } } | { "result": "unsubscribed", "payload": { request_id: SubscriptionRequestId, subscription_id: SubscriptionId, } } | { "result": "subscription_rejected", "payload": { request_id: SubscriptionRequestId, subscription_id?: SubscriptionId | null, code: SubscriptionRejectionCode, message: string, } };
export type SubscriptionResponse = { "result": "subscribed", "payload": { request_id: SubscriptionRequestId, subscription_id: SubscriptionId, selector: EventSubscriptionSelector, snapshot: SubscriptionSnapshot, } } | { "result": "unsubscribed", "payload": { request_id: SubscriptionRequestId, subscription_id: SubscriptionId, } } | { "result": "subscription_rejected", "payload": { request_id: SubscriptionRequestId, subscription_id?: SubscriptionId | null, code: SubscriptionRejectionCode, message: string, } };
export type SubscriptionEvent = { "event": "event", "data": { subscription_id: SubscriptionId, subject_revision: number, payload: SubscriptionEventPayload, } } | { "event": "subscription_closed", "data": { subscription_id: SubscriptionId, code: SubscriptionTerminationCode, message: string, } };
export type SubscriptionEvent = { "event": "event", "data": { subscription_id: SubscriptionId, payload: SubscriptionEventPayload, } } | { "event": "subscription_closed", "data": { subscription_id: SubscriptionId, code: SubscriptionTerminationCode, message: string, } };
export type SubscriptionFramePayload = { "frame": "request", "message": SubscriptionRequest } | { "frame": "response", "message": SubscriptionResponse } | { "frame": "event", "message": SubscriptionEvent } | { "frame": "worker_protocol", "message": SubscriptionWorkerProtocolMethod };
@@ -83,7 +83,7 @@ test('invalid inbound frame closes before dispatch and reconnects for a fresh sn
first.message(
JSON.stringify({
protocol_version: 1,
protocol_version: 2,
frame: 'event',
message: {
event: 'subscription_closed',
@@ -141,7 +141,7 @@ test('valid subscription closure remains isolated and resubscribes only its sele
: { topic: 'workspace_workdirs', data: { workdirs: [] } };
socket.message(
JSON.stringify({
protocol_version: 1,
protocol_version: 2,
frame: 'response',
message: {
result: 'subscribed',
@@ -149,7 +149,6 @@ test('valid subscription closure remains isolated and resubscribes only its sele
request_id: request.message.params.request_id,
subscription_id: `subscription-${index + 1}`,
selector,
snapshot_revision: 1,
snapshot,
},
},
@@ -160,7 +159,7 @@ test('valid subscription closure remains isolated and resubscribes only its sele
const sentBeforeClosure = socket.sent.length;
socket.message(
JSON.stringify({
protocol_version: 1,
protocol_version: 2,
frame: 'event',
message: {
event: 'subscription_closed',
@@ -138,7 +138,7 @@ export class WorkspaceMultiplexer {
subscription.requestId = requestId;
this.#requests.set(requestId, subscription.clientId);
this.#send({
protocol_version: 1,
protocol_version: 2,
frame: 'request',
message: {
method: 'subscribe_events',
@@ -213,7 +213,7 @@ export class WorkspaceMultiplexer {
const subscription = this.#subscriptions.get(clientId);
if (!subscription?.subscriptionId) throw new Error('Worker protocol subscription is not open');
this.#send({
protocol_version: 1,
protocol_version: 2,
frame: 'worker_protocol',
message: { subscription_id: subscription.subscriptionId, method },
});
@@ -225,7 +225,7 @@ export class WorkspaceMultiplexer {
this.#subscriptions.delete(clientId);
if (subscription.subscriptionId && this.#socket?.readyState === WebSocket.OPEN) {
this.#send({
protocol_version: 1,
protocol_version: 2,
frame: 'request',
message: {
method: 'unsubscribe_events',
@@ -16,7 +16,7 @@ function assert(condition: unknown, message: string): asserts condition {
function subscribedFrame(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
protocol_version: 1,
protocol_version: 2,
frame: 'response',
message: {
result: 'subscribed',
@@ -24,7 +24,6 @@ function subscribedFrame(overrides: Record<string, unknown> = {}): Record<string
request_id: 'request-1',
subscription_id: 'subscription-1',
selector: { topic: 'workspace_workers' },
snapshot_revision: 1,
snapshot: { topic: 'workers', data: { workers: [] } },
...overrides,
},
@@ -86,7 +85,6 @@ Deno.test('generated subscription validator accepts an external Workdir Worker',
runtime_id: 'runtime-1',
resource_key: 'worker-resource-1',
availability: 'observed',
subject_revision: 1,
state: 'idle',
has_running_internal_workers: false,
workspace_id: 'workspace-1',
@@ -123,8 +121,8 @@ Deno.test('generated subscription validator rejects malformed and newer shapes',
'unknown nested field',
subscribedFrame({ snapshot: { topic: 'workers', data: { workers: [], future: true } } }),
],
['wrong field type', subscribedFrame({ snapshot_revision: '1' })],
['unsafe integer', subscribedFrame({ snapshot_revision: Number.MAX_SAFE_INTEGER + 1 })],
['wrong field type', { ...subscribedFrame(), protocol_version: '2' }],
['unsafe integer', { ...subscribedFrame(), protocol_version: Number.MAX_SAFE_INTEGER + 1 }],
[
'missing serialized Worker defaults',
subscribedFrame({
@@ -132,23 +130,22 @@ Deno.test('generated subscription validator rejects malformed and newer shapes',
topic: 'workers',
data: {
workers: [
{ worker_id: 'worker-1', subject_revision: 1, state: 'idle' },
{ worker_id: 'worker-1', state: 'idle' },
],
},
},
}),
],
['unsupported version', { ...subscribedFrame(), protocol_version: 2 }],
['unsupported version', { ...subscribedFrame(), protocol_version: 1 }],
[
'newer Worker event',
{
protocol_version: 1,
protocol_version: 2,
frame: 'event',
message: {
event: 'event',
data: {
subscription_id: 'subscription-1',
subject_revision: 2,
payload: {
event: 'worker_protocol',
data: { worker_id: 'worker-1', event: { event: 'future_event' } },
@@ -160,13 +157,12 @@ Deno.test('generated subscription validator rejects malformed and newer shapes',
[
'out-of-range Rust u32',
{
protocol_version: 1,
protocol_version: 2,
frame: 'event',
message: {
event: 'event',
data: {
subscription_id: 'subscription-1',
subject_revision: 2,
payload: {
event: 'worker_protocol',
data: {
@@ -207,7 +203,7 @@ Deno.test('generated subscription validator enforces Rust semantic bounds and se
[
'oversized rejection message',
{
protocol_version: 1,
protocol_version: 2,
frame: 'response',
message: {
result: 'subscription_rejected',
@@ -242,7 +238,6 @@ Deno.test('generated subscription validator enforces Rust semantic bounds and se
worker_id: 'worker-1',
runtime_id: 'runtime-1',
availability: 'observed',
subject_revision: 1,
state: 'idle',
has_running_internal_workers: false,
},
@@ -250,7 +245,6 @@ Deno.test('generated subscription validator enforces Rust semantic bounds and se
worker_id: 'worker-1',
runtime_id: 'runtime-1',
availability: 'observed',
subject_revision: 2,
state: 'running',
has_running_internal_workers: false,
},
@@ -262,7 +256,7 @@ Deno.test('generated subscription validator enforces Rust semantic bounds and se
[
'server-to-Browser direction mismatch',
{
protocol_version: 1,
protocol_version: 2,
frame: 'request',
message: {
method: 'unsubscribe_events',
@@ -281,13 +275,12 @@ Deno.test('generated subscription validator enforces Rust semantic bounds and se
Deno.test('aggregate limits include keys in unconstrained Worker JSON values', () => {
const oversizedKey = 'k'.repeat(MAX_SUBSCRIPTION_STRING_BYTES + 1);
const result = decode({
protocol_version: 1,
protocol_version: 2,
frame: 'event',
message: {
event: 'event',
data: {
subscription_id: 'subscription-1',
subject_revision: 2,
payload: {
event: 'worker_protocol',
data: {
@@ -306,13 +299,12 @@ Deno.test('aggregate limits include keys in unconstrained Worker JSON values', (
Deno.test('stateful selector validation fences a routed event before projection', () => {
const frame: SubscriptionFrame = {
protocol_version: 1,
protocol_version: 2,
frame: 'event',
message: {
event: 'event',
data: {
subscription_id: 'subscription-1',
subject_revision: 2,
payload: {
event: 'worker_protocol',
data: { worker_id: 'worker-2', event: { event: 'thinking_start' } },
@@ -43,7 +43,7 @@ function snapshot(
availability: SubscriptionWorker["availability"],
): SubscriptionFrame {
return {
protocol_version: 1,
protocol_version: 2,
frame: "response",
message: {
result: "subscribed",
@@ -51,7 +51,6 @@ function snapshot(
request_id: "request-1",
subscription_id: "subscription-1",
selector: { topic: "workspace_workers" },
snapshot_revision: 1,
snapshot: {
topic: "workers",
data: {
@@ -60,7 +59,6 @@ function snapshot(
runtime_id: "runtime-a",
resource_key: "W-1",
availability,
subject_revision: 1,
state,
// An absent foreground snapshot must not prevent stopping an active catalog Worker.
worker_state: null,
@@ -6,55 +6,55 @@ import type {
export type WorkspaceWorkersProjection = {
workers: Map<string, SubscriptionWorker>;
revisions: Map<string, number>;
subscriptionId: string | null;
};
export function createWorkspaceWorkersProjection(): WorkspaceWorkersProjection {
return { workers: new Map(), revisions: new Map() };
return { workers: new Map(), subscriptionId: null };
}
export function applyWorkspaceWorkersFrame(
projection: WorkspaceWorkersProjection,
frame: SubscriptionFrame,
): void {
if (frame.protocol_version !== 1) throw new Error('unsupported Worker subscription protocol');
if (frame.protocol_version !== 2) 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();
// Build the replacement before mutating the live projection so one malformed
// Worker cannot partially clear the previous subscription lifetime.
const workers = new Map<string, SubscriptionWorker>();
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);
workers.set(workerKey(worker.runtime_id, worker.worker_id), worker);
}
projection.workers = workers;
projection.subscriptionId = frame.message.payload.subscription_id;
return;
}
if (frame.frame !== 'event' || frame.message.event !== 'event') return;
applyPayload(projection, frame.message.data.subject_revision, frame.message.data.payload);
const subscriptionId = frame.message.data.subscription_id;
if (!projection.subscriptionId) {
throw new Error('workspace_workers event arrived before its snapshot');
}
if (subscriptionId !== projection.subscriptionId) return;
applyPayload(projection, 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);
projection.workers.set(workerKey(worker.runtime_id, worker.worker_id), 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);
projection.workers.delete(workerKey(payload.data.runtime_id, payload.data.worker_id));
}
}
function workerKey(runtimeId: string | null | undefined, workerId: string): string {
if (!runtimeId) throw new Error('Workspace Worker projection is missing runtime_id');
return `${runtimeId}:${workerId}`;
return JSON.stringify([runtimeId, workerId]);
}
@@ -1,15 +1,29 @@
import type { SubscriptionFrame, SubscriptionWorker } from '$lib/generated/protocol';
import type {
SubscriptionEventPayload,
SubscriptionFrame,
SubscriptionWorker,
} from '$lib/generated/protocol';
import { liveWorkerState } from './worker-state';
import {
applyWorkspaceWorkersFrame,
createWorkspaceWorkersProjection,
} from './worker-subscription-model';
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 { liveWorkerState } from './worker-state';
import {
applyWorkspaceWorkersFrame,
createWorkspaceWorkersProjection,
} from './worker-subscription-model';
function assertThrows(fn: () => void, message: string): void {
try {
fn();
} catch {
return;
}
throw new Error(message);
}
declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void;
@@ -18,7 +32,6 @@ declare const Deno: {
function worker(
runtimeId: string,
workerId: string,
revision: number,
hasRunningInternalWorkers = false,
availability: 'observed' | 'unavailable' = 'observed',
): SubscriptionWorker {
@@ -26,7 +39,6 @@ function worker(
worker_id: workerId,
runtime_id: runtimeId,
availability,
subject_revision: revision,
state: 'idle',
has_running_internal_workers: hasRunningInternalWorkers,
workspace_id: 'workspace-test',
@@ -36,15 +48,52 @@ function worker(
};
}
function key(runtimeId: string, workerId: string): string {
return JSON.stringify([runtimeId, workerId]);
}
function snapshot(
subscriptionId: string,
workers: SubscriptionWorker[],
): SubscriptionFrame {
return {
protocol_version: 2,
frame: 'response',
message: {
result: 'subscribed',
payload: {
request_id: `request-${subscriptionId}`,
subscription_id: subscriptionId,
selector: { topic: 'workspace_workers' },
snapshot: { topic: 'workers', data: { workers } },
},
},
};
}
function event(
subscriptionId: string,
payload: SubscriptionEventPayload,
): SubscriptionFrame {
return {
protocol_version: 2,
frame: 'event',
message: {
event: 'event',
data: { subscription_id: subscriptionId, payload },
},
};
}
Deno.test('Worker list state uses the authoritative live snapshot separately from lifecycle', () => {
const active = worker('runtime-a', 'worker-1', 1);
const active = worker('runtime-a', 'worker-1');
active.worker_state = {
last_command_id: 1,
state: { kind: 'busy', state: { kind: 'run', state: 'paused' } },
};
assertEquals(liveWorkerState(active), 'paused');
const unavailable = worker('runtime-a', 'worker-2', 1, false, 'unavailable');
const unavailable = worker('runtime-a', 'worker-2', false, 'unavailable');
unavailable.worker_state = {
last_command_id: 2,
state: { kind: 'busy', state: { kind: 'run', state: 'running' } },
@@ -67,127 +116,118 @@ Deno.test('Worker list state uses the authoritative live snapshot separately fro
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']);
applyWorkspaceWorkersFrame(
projection,
snapshot('subscription-1', [worker('runtime-a', '1'), worker('runtime-b', '1')]),
);
assertEquals([...projection.workers.keys()].sort(), [key('runtime-a', '1'), key('runtime-b', '1')]);
});
Deno.test('workspace Worker snapshot and live update preserve canonical Job metadata', () => {
Deno.test('workspace Worker snapshot and ordered live update preserve canonical Job metadata', () => {
const projection = createWorkspaceWorkersProjection();
const snapshotWorker = worker('runtime-a', 'job-worker', 1);
const snapshotWorker = worker('runtime-a', 'job-worker');
snapshotWorker.job = {
job_id: 'check:T-1:r1',
attempt_id: 'check:T-1:r1:attempt:1',
purpose: 'ticket_item_check',
};
applyWorkspaceWorkersFrame(projection, {
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: [snapshotWorker] } },
},
},
});
assertEquals(projection.workers.get('runtime-a:job-worker')?.job, snapshotWorker.job);
applyWorkspaceWorkersFrame(projection, snapshot('subscription-1', [snapshotWorker]));
assertEquals(projection.workers.get(key('runtime-a', 'job-worker'))?.job, snapshotWorker.job);
const updated = { ...snapshotWorker, subject_revision: 2 };
const updated = { ...snapshotWorker };
updated.job = { ...snapshotWorker.job, attempt_id: 'check:T-1:r1:attempt:2' };
applyWorkspaceWorkersFrame(projection, {
protocol_version: 1,
frame: 'event',
message: {
event: 'event',
data: {
subscription_id: 'subscription-1',
subject_revision: 2,
payload: { event: 'worker_upserted', data: { worker: updated } },
},
},
});
assertEquals(projection.workers.get('runtime-a:job-worker')?.job, updated.job);
applyWorkspaceWorkersFrame(
projection,
event('subscription-1', { event: 'worker_upserted', data: { worker: updated } }),
);
assertEquals(projection.workers.get(key('runtime-a', 'job-worker'))?.job, updated.job);
});
Deno.test('workspace Worker reducer ignores stale events and removes composite subject', () => {
Deno.test('fresh subscription snapshot replaces old state and fences delayed old events', () => {
const projection = createWorkspaceWorkersProjection();
projection.workers.set('runtime-a:1', worker('runtime-a', '1', 3));
projection.revisions.set('runtime-a:1', 3);
applyWorkspaceWorkersFrame(
projection,
snapshot('subscription-old', [worker('runtime-a', 'old'), worker('runtime-b', 'same')]),
);
applyWorkspaceWorkersFrame(
projection,
event('subscription-old', {
event: 'worker_upserted',
data: { worker: worker('runtime-a', 'before-reconnect') },
}),
);
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,
snapshot('subscription-new', [worker('runtime-a', 'current')]),
);
applyWorkspaceWorkersFrame(
projection,
event('subscription-old', {
event: 'worker_upserted',
data: { worker: worker('runtime-a', 'late-old-event') },
}),
);
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);
assertEquals(projection.subscriptionId, 'subscription-new');
assertEquals([...projection.workers.keys()], [key('runtime-a', 'current')]);
});
Deno.test('workspace Worker reducer applies ordered updates and composite removal', () => {
const projection = createWorkspaceWorkersProjection();
applyWorkspaceWorkersFrame(
projection,
snapshot('subscription-1', [worker('runtime-a', '1'), worker('runtime-b', '1')]),
);
const updated = worker('runtime-a', '1');
updated.state = 'running';
applyWorkspaceWorkersFrame(
projection,
event('subscription-1', { event: 'worker_upserted', data: { worker: updated } }),
);
applyWorkspaceWorkersFrame(
projection,
event('subscription-1', {
event: 'worker_removed',
data: { worker_id: '1', runtime_id: 'runtime-a' },
}),
);
assertEquals([...projection.workers.keys()], [key('runtime-b', '1')]);
});
Deno.test('workspace Worker event before a snapshot is rejected', () => {
const projection = createWorkspaceWorkersProjection();
assertThrows(
() =>
applyWorkspaceWorkersFrame(
projection,
event('subscription-1', {
event: 'worker_upserted',
data: { worker: worker('runtime-a', '1') },
}),
),
'event-before-snapshot must be rejected',
);
});
Deno.test('fatal child stop replaces the running-child sidebar projection', () => {
const projection = createWorkspaceWorkersProjection();
projection.workers.set('runtime-a:1', worker('runtime-a', '1', 1, true));
projection.revisions.set('runtime-a:1', 1);
applyWorkspaceWorkersFrame(
projection,
snapshot('subscription-1', [worker('runtime-a', '1', true)]),
);
applyWorkspaceWorkersFrame(
projection,
event('subscription-1', {
event: 'worker_upserted',
data: { worker: worker('runtime-a', '1', false) },
}),
);
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, false) },
},
},
},
});
assertEquals(projection.workers.get('runtime-a:1')?.has_running_internal_workers, false);
assertEquals(projection.revisions.get('runtime-a:1'), 2);
assertEquals(
projection.workers.get(key('runtime-a', '1'))?.has_running_internal_workers,
false,
);
});