fix: unify worker protocol websocket

This commit is contained in:
2026-07-21 20:29:30 +09:00
parent 70a26a3042
commit 8841d063be
10 changed files with 377 additions and 1046 deletions
@@ -65,17 +65,17 @@ Deno.test("Worker Console uses protocol observation events without transcript fe
);
assert(
consolePage.includes("seenObservationEventIds") &&
consolePage.includes(
"rememberObservationEvent(frame.envelope.event_id)",
) &&
consolePage.includes("connectProtocolTransport") &&
consolePage.includes("handleIncomingProtocolEvent") &&
consolePage.includes("/protocol/ws") &&
!consolePage.includes("seenObservationEventIds") &&
consolePage.includes("createConsoleProjector") &&
consolePage.includes("consoleProjector.append(eventBatch)") &&
consolePage.includes("{#each lines as item (item.id)}") &&
!consolePage.includes("projectConsole(observedEvents.map") &&
!consolePage.includes("/transcript") &&
!consolePage.includes("WorkerTranscriptProjection"),
"Console should render protocol observation replay/live events directly and dedupe repeated frames by event id",
"Console should render raw protocol replay/live events directly from the unified protocol WS",
);
});
@@ -285,8 +285,11 @@ Deno.test("Worker Console page is routed by runtime_id and worker_id through bac
);
assert(
!consolePage.includes("/transcript") &&
consolePage.includes("/events/ws") && consolePage.includes("/input"),
"Console should use protocol observation WS and input APIs without a transcript API",
consolePage.includes("/protocol/ws") &&
!consolePage.includes("/events" + "/ws") &&
!consolePage.includes("/input") &&
!consolePage.includes("/completions"),
"Console should use only the protocol WS for observation and operations",
);
assert(
!consolePage.includes("/api/companion"),
@@ -245,22 +245,6 @@ export type WorkerInputResult = {
diagnostics: Diagnostic[];
};
export type ClientWorkerEventWsEnvelope = {
event_id: string;
runtime_id: string;
worker_id: string;
payload: PodProtocolEvent;
};
export type ClientWorkerEventWsDiagnostic = {
code: string;
message: string;
};
export type ClientWorkerEventWsFrame =
| { kind: "event"; envelope: ClientWorkerEventWsEnvelope }
| { kind: "diagnostic"; diagnostic: ClientWorkerEventWsDiagnostic };
export type ListResponse<T> = {
workspace_id: string;
limit: number;
@@ -24,7 +24,6 @@
import type { Event as ProtocolEvent, Method as ProtocolMethod, RewindTarget, Segment } from "$lib/generated/protocol";
import { workspaceApiPath } from "$lib/workspace/api/http";
import type {
ClientWorkerEventWsFrame,
Diagnostic,
Worker,
PodProtocolEvent,
@@ -98,13 +97,10 @@
let rewindHeadEntries = $state(0);
let controlNotice = $state<string | null>(null);
let composerNotice = $state<string | null>(null);
let streamState = $state<"connecting" | "open" | "closed" | "error">(
let protocolState = $state<"connecting" | "open" | "closed" | "error">(
"connecting",
);
let commandState = $state<"connecting" | "open" | "closed" | "error">(
"connecting",
);
let commandSocket: WebSocket | null = null;
let protocolSocket: WebSocket | null = null;
let pendingCompletionRequest: {
resolve: (entries: ComposerCompletionEntry[]) => void;
reject: (error: Error) => void;
@@ -129,8 +125,8 @@
let consoleProjection = $state.raw<ConsoleProjection>(
consoleProjector.snapshot(),
);
let seenObservationEventIds = new Set<string>();
let pendingObservationEvents: ConsoleEventInput[] = [];
let protocolEventSequence = 0;
let pendingObservedStates: Array<string | null> = [];
let pendingStreamDiagnostics: Diagnostic[] = [];
let observationFlushHandle: number | null = null;
@@ -208,7 +204,6 @@
consoleProjection = consoleProjector.reset();
eventObservedAtById.clear();
advanceEventObservedAtVersion();
seenObservationEventIds = new Set();
}
function cancelObservationFlush() {
@@ -253,23 +248,27 @@
}
}
function queueObservationEvent(
frame: ClientWorkerEventWsFrame & { kind: "event" },
) {
if (!rememberObservationEvent(frame.envelope.event_id)) {
function handleIncomingProtocolEvent(payload: ProtocolEvent) {
handleProtocolCommandEvent(payload);
if (payload.event === "completions" || payload.event === "error") {
if (payload.event === "error") {
queueObservationDiagnostic({
code: payload.data.code,
severity: "error",
message: payload.data.message,
});
}
return;
}
const eventId = `protocol-${++protocolEventSequence}`;
const observedAtMs = Date.now();
eventObservedAtById.set(frame.envelope.event_id, observedAtMs);
const payload = frame.envelope.payload;
eventObservedAtById.set(eventId, observedAtMs);
pendingObservationEvents.push({
eventId: frame.envelope.event_id,
eventId,
event: payload,
observedAtMs,
});
if (payload.event === "rewind_targets") {
handleProtocolCommandEvent(payload as ProtocolEvent);
}
pendingObservedStates.push(workerStateFromProtocolEvent(payload));
scheduleObservationFlush();
}
@@ -279,14 +278,6 @@
scheduleObservationFlush();
}
function rememberObservationEvent(eventId: string): boolean {
if (seenObservationEventIds.has(eventId)) {
return false;
}
seenObservationEventIds.add(eventId);
return true;
}
async function applyComposerCompletion(event: KeyboardEvent) {
const target = event.currentTarget;
if (!(target instanceof HTMLTextAreaElement)) {
@@ -502,89 +493,16 @@
}
}
function connectObservation(
function connectProtocolTransport(
targetWorker: Worker | null,
token: number,
target: ConsoleTarget,
) {
if (!targetWorker) {
streamState = "closed";
protocolState = "closed";
return;
}
streamState = "connecting";
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsPath = workerApiPath(
`/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(
target.workerId,
)}/events/ws`,
);
const ws = new WebSocket(
`${protocol}//${window.location.host}${wsPath}`,
);
ws.onopen = () => {
if (token === reloadToken) {
streamState = "open";
}
};
ws.onmessage = (message) => {
if (token !== reloadToken) {
return;
}
try {
const frame = JSON.parse(
String(message.data),
) as ClientWorkerEventWsFrame;
if (frame.kind === "event") {
queueObservationEvent(frame);
} else {
queueObservationDiagnostic({
code: frame.diagnostic.code,
severity: "warning",
message: frame.diagnostic.message,
});
}
} catch (error) {
queueObservationDiagnostic({
code: "worker_observation_frame_invalid",
severity: "warning",
message:
error instanceof Error ? error.message : String(error),
});
}
};
ws.onerror = () => {
if (token === reloadToken) {
streamState = "error";
streamDiagnostics = [
...streamDiagnostics,
{
code: "worker_observation_ws_error",
severity: "error",
message: "Worker observation WebSocket failed.",
},
];
}
};
ws.onclose = () => {
if (token === reloadToken && streamState !== "error") {
streamState = "closed";
}
};
return () => ws.close();
}
function connectProtocolCommands(
targetWorker: Worker | null,
token: number,
target: ConsoleTarget,
) {
if (!targetWorker) {
commandState = "closed";
return;
}
commandState = "connecting";
protocolState = "connecting";
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsPath = workerApiPath(
`/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(
@@ -594,11 +512,11 @@
const ws = new WebSocket(
`${protocol}//${window.location.host}${wsPath}`,
);
commandSocket = ws;
protocolSocket = ws;
ws.onopen = () => {
if (token === reloadToken) {
commandState = "open";
protocolState = "open";
}
};
ws.onmessage = (message) => {
@@ -606,14 +524,14 @@
return;
}
try {
handleProtocolCommandEvent(
handleIncomingProtocolEvent(
JSON.parse(String(message.data)) as ProtocolEvent,
);
} catch (error) {
streamDiagnostics = [
...streamDiagnostics,
{
code: "worker_protocol_command_frame_invalid",
code: "worker_protocol_frame_invalid",
severity: "warning",
message:
error instanceof Error ? error.message : String(error),
@@ -623,42 +541,42 @@
};
ws.onerror = () => {
if (token === reloadToken) {
commandState = "error";
protocolState = "error";
streamDiagnostics = [
...streamDiagnostics,
{
code: "worker_protocol_command_ws_error",
code: "worker_protocol_ws_error",
severity: "error",
message: "Worker protocol command WebSocket failed.",
message: "Worker protocol WebSocket failed.",
},
];
}
};
ws.onclose = () => {
if (commandSocket === ws) {
commandSocket = null;
if (protocolSocket === ws) {
protocolSocket = null;
}
if (token === reloadToken && commandState !== "error") {
commandState = "closed";
if (token === reloadToken && protocolState !== "error") {
protocolState = "closed";
}
rejectPendingCompletion(
new Error("Worker protocol command WebSocket closed."),
new Error("Worker protocol WebSocket closed."),
);
};
return () => {
if (commandSocket === ws) {
commandSocket = null;
if (protocolSocket === ws) {
protocolSocket = null;
}
ws.close();
};
}
function sendProtocolMethod(method: ProtocolMethod) {
if (!commandSocket || commandSocket.readyState !== WebSocket.OPEN) {
throw new Error("Worker protocol command WebSocket is not open.");
if (!protocolSocket || protocolSocket.readyState !== WebSocket.OPEN) {
throw new Error("Worker protocol WebSocket is not open.");
}
commandSocket.send(JSON.stringify(method));
protocolSocket.send(JSON.stringify(method));
}
function handleProtocolCommandEvent(event: ProtocolEvent) {
@@ -1162,8 +1080,7 @@
void loadConsoleData(target);
});
$effect(() => connectObservation(worker, reloadToken, consoleTarget));
$effect(() => connectProtocolCommands(worker, reloadToken, consoleTarget));
$effect(() => connectProtocolTransport(worker, reloadToken, consoleTarget));
</script>
<svelte:head>
@@ -1182,14 +1099,14 @@
<div class="console-header-actions">
<div
class="console-status-pill"
class:warn={streamState !== "open"}
class:warn={protocolState !== "open"}
>
{workerState} · stream {streamState} · command {commandState}
{workerState} · protocol {protocolState}
</div>
<button
type="button"
class="secondary-button"
disabled={commandState !== "open"}
disabled={protocolState !== "open"}
onclick={() => sendControl({ method: "cancel" }, "Cancel")}
>
Cancel
@@ -1197,7 +1114,7 @@
<button
type="button"
class="secondary-button"
disabled={commandState !== "open"}
disabled={protocolState !== "open"}
onclick={() => sendControl({ method: "pause" }, "Pause")}
>
Pause
@@ -1205,7 +1122,7 @@
<button
type="button"
class="secondary-button"
disabled={commandState !== "open"}
disabled={protocolState !== "open"}
onclick={() => sendControl({ method: "resume" }, "Resume")}
>
Resume
@@ -1213,7 +1130,7 @@
<button
type="button"
class="secondary-button"
disabled={commandState !== "open"}
disabled={protocolState !== "open"}
onclick={() => sendControl({ method: "compact" }, "Compact")}
>
Compact
@@ -1221,7 +1138,7 @@
<button
type="button"
class="secondary-button"
disabled={commandState !== "open"}
disabled={protocolState !== "open"}
onclick={requestRewindTargets}
>
Rewind
@@ -1249,7 +1166,7 @@
<button
type="button"
class="secondary-button"
disabled={commandState !== "open" || !target.eligible}
disabled={protocolState !== "open" || !target.eligible}
title={target.disabled_reason ?? target.warning ?? undefined}
onclick={() => rewindTo(target)}
>
@@ -1377,7 +1294,7 @@
{#if diagnostics.length > 0}
<details
class="metadata-details"
open={streamState === "error"}
open={protocolState === "error"}
>
<summary>Diagnostics ({diagnostics.length})</summary>
<ul>