Files
2026-09-17 10:39:30 +01:00

1691 lines
58 KiB
TypeScript

/* eslint-disable max-lines -- WebSocketClient intentionally owns one connection's complete request, subscription, and reconnect lifecycle. */
import type { BackendMessageMap, BackendMessageType } from "@/lib/types/backend";
import type { ConversationChangedPayload } from "@/lib/types/session-events";
import type { ConnectionStatus } from "@/lib/types/connection";
import { generateUUID } from "@/lib/utils";
import { createDebugLogger, isDebug } from "@/lib/debug/log";
import { dispatchToPluginWsHandlers } from "@/lib/ws/plugin-bridge";
import {
isWebSocketRequestTimeoutError,
toWebSocketRequestError,
WebSocketRequestTimeoutError,
} from "./request-error";
import {
isRawSessionEvent,
orderedCoreDisposition,
orderedCoreAction,
projectCoreSessionPayload,
type CoreSessionStream,
type RawSessionEvent,
} from "./ordered-session-events";
export type { RawSessionEvent } from "./ordered-session-events";
export {
isWebSocketRequestTimeoutError,
WebSocketRequestError,
WebSocketRequestTimeoutError,
type WebSocketRequestErrorDetails,
} from "./request-error";
const debugDispatch = createDebugLogger("ws:dispatch");
// High-frequency notification types we skip in the dispatch log to avoid
// drowning the console during agent streams. Filter [ws:dispatch] to see
// everything else; if you need the streaming traffic, comment this out.
const DISPATCH_LOG_DENYLIST = new Set<string>([
"session.message.added",
"session.message.updated",
"session.message.deleted",
"session.shell.output",
"session.process.output",
]);
type MessageHandler<T extends BackendMessageType> = (message: BackendMessageMap[T]) => void;
// Internal alias for the status vocabulary shared with the UI.
type WebSocketStatus = ConnectionStatus;
export interface ReconnectOptions {
enabled?: boolean;
maxAttempts?: number;
initialDelay?: number;
maxDelay?: number;
backoffMultiplier?: number;
conversationProtocol?: "v1" | "v2";
}
export interface SessionSubscriptionHandle {
ready: Promise<void>;
unsubscribe: () => void;
}
export type CoreSessionRecoveryHandler = () => Promise<boolean>;
const CORE_CONVERSATION_ACTIONS = new Set<BackendMessageType>([
"session.message.added",
"session.message.updated",
"session.message.deleted",
"session.turn.started",
"session.turn.completed",
"session.turn.removed",
]);
const SESSION_SUBSCRIBE_ACTION = "session.subscribe";
const SESSION_UNSUBSCRIBE_ACTION = "session.unsubscribe";
const SESSION_CONVERSATION_CHANGED_ACTION = "session.conversation.changed" as const;
function isConversationNotificationAction(action: BackendMessageType): boolean {
return CORE_CONVERSATION_ACTIONS.has(action);
}
function isCoreConversationOperation(value: unknown): boolean {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const operation = value as {
kind?: unknown;
entity?: unknown;
id?: unknown;
message?: unknown;
turn?: unknown;
};
if (
(operation.kind !== "upsert" && operation.kind !== "remove") ||
(operation.entity !== "message" && operation.entity !== "turn") ||
typeof operation.id !== "string" ||
operation.id === ""
) {
return false;
}
if (operation.kind === "remove") return true;
const payload = operation.entity === "message" ? operation.message : operation.turn;
return Boolean(payload && typeof payload === "object" && !Array.isArray(payload));
}
type ParsedCoreConversationChange = {
sessionId: string;
payload: Partial<ConversationChangedPayload> & { operations?: unknown[] };
valid: boolean;
};
function isDecimalRevision(value: unknown): value is string {
return typeof value === "string" && /^(0|[1-9][0-9]*)$/.test(value);
}
function parseCoreConversationChange(value: unknown): ParsedCoreConversationChange | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const payload = value as Partial<ConversationChangedPayload> & { operations?: unknown[] };
if (typeof payload.session_id !== "string") return null;
const valid =
payload.protocol_version === 2 &&
typeof payload.scope_id === "string" &&
typeof payload.epoch === "string" &&
isDecimalRevision(payload.base_revision) &&
isDecimalRevision(payload.revision) &&
Array.isArray(payload.operations) &&
payload.operations.length <= 256 &&
payload.operations.every(isCoreConversationOperation) &&
BigInt(payload.revision) > BigInt(payload.base_revision);
return { sessionId: payload.session_id, payload, valid };
}
function needsCoreConversationRecovery(
parsed: ParsedCoreConversationChange,
stream: CoreSessionStream,
): boolean {
return !parsed.valid || parsed.payload.scope_id !== stream.conversationScopeId;
}
function hasCoreConversationRevisionGap(
payload: ConversationChangedPayload,
stream: CoreSessionStream,
appliedRevision: string,
): boolean {
return (
payload.reset === true ||
payload.epoch !== stream.conversationEpoch ||
payload.base_revision !== appliedRevision
);
}
type CoreConversationOperation = {
kind: "upsert" | "remove";
entity: "message" | "turn";
id: string;
message?: Record<string, unknown>;
turn?: Record<string, unknown>;
};
function coreTurnAction(operation: CoreConversationOperation): BackendMessageType {
if (operation.kind === "remove") return "session.turn.removed";
return operation.turn?.completed_at ? "session.turn.completed" : "session.turn.started";
}
function projectCoreMessageOperation(
sessionId: string,
operation: CoreConversationOperation & { entity: "message" },
): { action: BackendMessageType; payload: Record<string, unknown> } {
const message = operation.message;
const payload: Record<string, unknown> = {
type: operation.kind === "remove" ? "message.deleted" : (message?.type ?? "message"),
session_id: sessionId,
task_id: message?.task_id ?? null,
message_id: operation.id,
};
if (operation.kind === "upsert" && message) {
Object.assign(payload, {
author_type: message.author_type,
author_id: message.author_id,
content: message.content,
raw_content: message.raw_content,
message_type: message.type,
turn_id: message.turn_id,
metadata: message.metadata,
requests_input: message.requests_input,
created_at: message.created_at,
updated_at: message.updated_at ?? message.created_at,
prompt_index: message.prompt_index,
});
}
return {
action: operation.kind === "remove" ? "session.message.deleted" : "session.message.added",
payload,
};
}
function projectCoreTurnOperation(
sessionId: string,
operation: CoreConversationOperation & { entity: "turn" },
): { action: BackendMessageType; payload: Record<string, unknown> } {
const turn = operation.turn;
const payload: Record<string, unknown> = {
type: operation.kind === "remove" ? "session.turn.removed" : "session.turn.started",
session_id: sessionId,
task_id: turn?.task_id ?? null,
id: operation.id,
};
if (operation.kind === "upsert" && turn) {
Object.assign(payload, {
started_at: turn.started_at,
completed_at: turn.completed_at,
updated_at: turn.updated_at ?? turn.started_at,
created_at: turn.created_at ?? turn.started_at,
execution_profile_id: turn.execution_profile_id,
route_generation: turn.route_generation,
metadata: turn.metadata,
had_output: turn.had_output,
});
}
return { action: coreTurnAction(operation), payload };
}
function projectCoreConversationOperation(
sessionId: string,
operation: CoreConversationOperation,
): { action: BackendMessageType; payload: Record<string, unknown> } {
if (operation.entity === "message") {
return projectCoreMessageOperation(
sessionId,
operation as CoreConversationOperation & { entity: "message" },
);
}
return projectCoreTurnOperation(
sessionId,
operation as CoreConversationOperation & { entity: "turn" },
);
}
type SessionSubscriptionReadiness = {
promise: Promise<void>;
resolve: () => void;
reject: (reason: unknown) => void;
requestStarted: boolean;
settled: boolean;
attempt: number;
retryTimer: ReturnType<typeof setTimeout> | null;
};
export const SESSION_ENTRY_REQUEST_TIMEOUT_MS = 10000;
export const SESSION_ENTRY_RETRY_DELAY_MS = 1000;
const MAX_SESSION_SUBSCRIPTION_ATTEMPTS = 2;
// i18n-exempt: transport/API diagnostic, never rendered as user-facing copy.
const SESSION_SUBSCRIPTION_RELEASED_ERROR = "Session subscription released";
type OrderedSessionResponse = Record<string, unknown>;
// i18n-exempt: transport protocol validation diagnostics are never rendered as user-facing copy.
function invalidOrderedSessionResponse(): Error {
return new Error("Invalid ordered session response");
}
function isNonNegativeSafeInteger(value: unknown): value is number {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
}
function requiredOrderedString(response: OrderedSessionResponse, key: string): string {
const value = response[key];
if (typeof value !== "string" || value === "") throw invalidOrderedSessionResponse();
return value;
}
// eslint-disable-next-line complexity -- The wire contract is validated field-by-field before state mutation.
function validateOrderedSubscribeResponse(
response: unknown,
sessionId: string,
wireId: string,
): { eventWatermark: number; resumeToken: string; result: "fresh" | "replay" | "invalid_resume" } {
if (!response || typeof response !== "object" || Array.isArray(response)) {
throw invalidOrderedSessionResponse();
}
const payload = response as OrderedSessionResponse;
if (payload.success !== true || payload.session_id !== sessionId || payload.wire_id !== wireId) {
throw invalidOrderedSessionResponse();
}
const eventWatermark = payload.event_watermark;
if (
!isNonNegativeSafeInteger(eventWatermark) ||
payload.snapshot_cutoff !== eventWatermark ||
!["fresh", "replay", "invalid_resume"].includes(payload.result as string)
) {
throw invalidOrderedSessionResponse();
}
requiredOrderedString(payload, "snapshot_token");
const resumeToken = requiredOrderedString(payload, "resume_token");
requiredOrderedString(payload, "expires_at");
if (payload.result === "replay") {
const replayFrom = payload.replay_from;
const replayTo = payload.replay_to;
if (
!isNonNegativeSafeInteger(replayFrom) ||
replayFrom === 0 ||
!isNonNegativeSafeInteger(replayTo) ||
replayTo < replayFrom ||
replayTo > eventWatermark
) {
throw invalidOrderedSessionResponse();
}
}
return {
eventWatermark,
resumeToken,
result: payload.result as "fresh" | "replay" | "invalid_resume",
};
}
function validateOrderedAckResponse(
response: unknown,
sessionId: string,
wireId: string,
sequence: number,
): string {
if (!response || typeof response !== "object" || Array.isArray(response)) {
throw invalidOrderedSessionResponse();
}
const payload = response as OrderedSessionResponse;
if (
payload.success !== true ||
payload.session_id !== sessionId ||
payload.wire_id !== wireId ||
payload.acknowledged_sequence !== sequence
) {
throw invalidOrderedSessionResponse();
}
return requiredOrderedString(payload, "resume_token");
}
type RawWebSocketMessage = {
type?: unknown;
id?: unknown;
payload?: unknown;
};
const DEFAULT_RECONNECT_OPTIONS: Required<ReconnectOptions> = {
enabled: true,
maxAttempts: 10,
initialDelay: 1000,
maxDelay: 30000,
backoffMultiplier: 1.5,
conversationProtocol: "v2",
};
// i18n-exempt: transport/API diagnostic. Callers branch on the error code and
// render translated copy; this text only ever appears in a console or as an
// interpolated English diagnostic (see docs/i18n.md on interpolated values).
const WEBSOCKET_CONNECTION_CLOSED_ERROR = "WebSocket connection closed";
export class WebSocketClient {
private socket: WebSocket | null = null;
private status: WebSocketStatus = "disconnected";
private handlers = new Map<BackendMessageType, Set<MessageHandler<BackendMessageType>>>();
private pendingRequests = new Map<
string,
{
resolve: (payload: unknown) => void;
reject: (error: Error) => void;
timeout: ReturnType<typeof setTimeout>;
}
>();
private rawSessionEventHandlers = new Set<(event: RawSessionEvent) => void>();
private statusHandlers = new Set<(status: WebSocketStatus) => void>();
private pendingQueue: string[] = [];
private reconnectOptions: Required<ReconnectOptions>;
private reconnectAttempts = 0;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private intentionalClose = false;
private subscriptions = new Map<string, number>();
private sessionSubscriptions = new Map<string, number>();
private sessionSubscriptionReadiness = new Map<string, SessionSubscriptionReadiness>();
private coreSessionStreams = new Map<string, CoreSessionStream>();
private coreSessionRecoveryHandlers = new Map<string, Set<CoreSessionRecoveryHandler>>();
private coreConversationSessions = new Set<string>();
// Ref-counted focus signals: a session can be focused by both the task panel
// and the task details page if both are mounted. Backend wakes its workspace
// tracker into fast-poll mode while any client has focus, falling back to
// slow when the count reaches 0 (debounced server-side).
private sessionFocusCounts = new Map<string, number>();
private userSubscriptionCount = 0;
private runSubscriptions = new Map<string, number>();
private systemMetricsSubscriptionCount = 0;
constructor(
private url: string,
private onStatusChange?: (status: WebSocketStatus) => void,
reconnectOptions?: ReconnectOptions,
) {
this.reconnectOptions = { ...DEFAULT_RECONNECT_OPTIONS, ...reconnectOptions };
}
getStatus() {
return this.status;
}
connect() {
if (this.socket) return;
this.intentionalClose = false;
this.clearReconnectTimer();
this.setStatus("connecting");
const socket = new WebSocket(this.url);
this.socket = socket;
socket.onopen = () => {
if (this.socket !== socket) return;
this.reconnectAttempts = 0;
this.setStatus("connected");
this.resubscribe();
this.flushQueue();
};
socket.onmessage = (event) => {
if (this.socket !== socket) return;
const parts = (event.data as string).split("\n");
for (const part of parts) {
const trimmed = part.trim();
if (!trimmed) continue;
try {
const message = JSON.parse(trimmed) as BackendMessageMap[BackendMessageType];
this.handleParsedMessage(message);
} catch {
// Ignore parse errors for individual messages
}
}
};
socket.onerror = () => (this.socket === socket ? this.setStatus("error") : undefined);
socket.onclose = (event) => {
if (this.socket !== socket) return;
this.socket = null;
this.handleDisconnect(event);
};
}
disconnect() {
this.intentionalClose = true;
this.clearReconnectTimer();
if (this.socket) {
this.socket.close();
this.socket = null;
}
this.setStatus("disconnected");
this.resetSessionSubscriptionReadiness(new Error(WEBSOCKET_CONNECTION_CLOSED_ERROR));
this.cleanupPendingRequests();
}
send(payload: unknown) {
const data = JSON.stringify(payload);
if (isDebug()) {
const p = payload as { action?: string; id?: string; type?: string } | null;
const action = p?.action ?? "?";
if (!DISPATCH_LOG_DENYLIST.has(action)) {
const sessionId = (p as { payload?: { session_id?: string } } | null)?.payload?.session_id;
debugDispatch("send", {
action,
id: p?.id ?? null,
type: p?.type ?? null,
sessionId: sessionId ?? null,
queued: this.status !== "connected" || !this.socket,
});
}
}
if (this.status !== "connected" || !this.socket) {
this.pendingQueue.push(data);
return;
}
this.socket.send(data);
}
request<T>(action: string, payload: unknown, timeoutMs = 5000): Promise<T> {
const id = generateUUID();
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.pendingRequests.delete(id);
reject(new WebSocketRequestTimeoutError(action));
}, timeoutMs);
this.pendingRequests.set(id, {
resolve: resolve as (payload: unknown) => void,
reject,
timeout,
});
this.send({ id, type: "request", action, payload });
});
}
subscribe(taskId: string) {
const currentCount = this.subscriptions.get(taskId) ?? 0;
const nextCount = currentCount + 1;
this.subscriptions.set(taskId, nextCount);
if (this.status === "connected" && nextCount === 1) {
this.send({
id: generateUUID(),
type: "request",
action: "task.subscribe",
payload: { task_id: taskId },
});
}
return () => this.unsubscribe(taskId);
}
subscribeSession(sessionId: string) {
return this.subscribeSessionWithReady(sessionId).unsubscribe;
}
subscribeSessionWithReady(sessionId: string): SessionSubscriptionHandle {
const currentCount = this.sessionSubscriptions.get(sessionId) ?? 0;
const nextCount = currentCount + 1;
this.sessionSubscriptions.set(sessionId, nextCount);
const readiness = this.getOrCreateSessionSubscriptionReadiness(sessionId);
if (this.status === "connected" && this.socket) {
this.startSessionSubscription(sessionId, readiness);
}
return {
ready: readiness.promise,
unsubscribe: () => this.unsubscribeSession(sessionId),
};
}
resubscribeSession(sessionId: string): Promise<void> {
if (!this.sessionSubscriptions.has(sessionId)) return Promise.resolve();
const readiness = this.getOrCreateSessionSubscriptionReadiness(sessionId, true);
if (this.status === "connected" && this.socket) {
this.startSessionSubscription(sessionId, readiness);
}
return readiness.promise;
}
getSessionSubscriptionReadiness(sessionId: string): Promise<void> {
if (!this.sessionSubscriptions.has(sessionId)) return Promise.resolve();
if (
!this.sessionSubscriptionReadiness.has(sessionId) &&
(this.status === "disconnected" || this.status === "error")
) {
const unavailable = Promise.reject<void>(new Error(WEBSOCKET_CONNECTION_CLOSED_ERROR));
void unavailable.catch(() => undefined);
return unavailable;
}
const readiness = this.getOrCreateSessionSubscriptionReadiness(sessionId);
if (this.status === "connected" && this.socket) {
this.startSessionSubscription(sessionId, readiness);
}
return readiness.promise;
}
focusSession(sessionId: string) {
const currentCount = this.sessionFocusCounts.get(sessionId) ?? 0;
const nextCount = currentCount + 1;
this.sessionFocusCounts.set(sessionId, nextCount);
if (this.status === "connected" && nextCount === 1) {
this.send({
id: generateUUID(),
type: "request",
action: "session.focus",
payload: { session_id: sessionId },
});
}
return () => this.unfocusSession(sessionId);
}
/**
* Request a fresh git snapshot for an already-focused session without
* changing its focus ref-count. Focus itself is intentionally ACK-only;
* explicit git refresh keeps tab activation from replaying session data during
* ordinary task switching.
*/
refreshSessionData(sessionId: string) {
if (this.status !== "connected") return;
if (!this.sessionFocusCounts.get(sessionId)) return;
this.send({
id: generateUUID(),
type: "request",
action: "session.git.refresh",
payload: { session_id: sessionId },
});
}
unfocusSession(sessionId: string) {
const currentCount = this.sessionFocusCounts.get(sessionId);
if (!currentCount) return;
const nextCount = currentCount - 1;
if (nextCount <= 0) {
this.sessionFocusCounts.delete(sessionId);
if (this.status === "connected") {
this.send({
id: generateUUID(),
type: "request",
action: "session.unfocus",
payload: { session_id: sessionId },
});
}
return;
}
this.sessionFocusCounts.set(sessionId, nextCount);
}
subscribeUser() {
this.userSubscriptionCount += 1;
if (this.status === "connected" && this.userSubscriptionCount === 1) {
this.send({
id: generateUUID(),
type: "request",
action: "user.subscribe",
payload: {},
});
}
}
unsubscribe(taskId: string) {
const currentCount = this.subscriptions.get(taskId);
if (!currentCount) return;
const nextCount = currentCount - 1;
if (nextCount <= 0) {
this.subscriptions.delete(taskId);
if (this.status === "connected") {
this.send({
id: generateUUID(),
type: "request",
action: "task.unsubscribe",
payload: { task_id: taskId },
});
}
return;
}
this.subscriptions.set(taskId, nextCount);
}
unsubscribeSession(sessionId: string) {
const currentCount = this.sessionSubscriptions.get(sessionId);
if (!currentCount) return;
const nextCount = currentCount - 1;
if (nextCount <= 0) {
this.sessionSubscriptions.delete(sessionId);
this.cancelSessionSubscriptionReadiness(sessionId);
const stream = this.coreSessionStreams.get(sessionId);
if (
this.status === "connected" &&
stream &&
this.reconnectOptions.conversationProtocol === "v1"
) {
this.send({
id: generateUUID(),
type: "request",
action: SESSION_UNSUBSCRIBE_ACTION,
payload: {
session_id: sessionId,
consumer_kind: "core",
wire_id: stream.wireId,
},
});
}
if (
this.status === "connected" &&
stream &&
this.reconnectOptions.conversationProtocol === "v2"
) {
this.send({
id: generateUUID(),
type: "request",
action: "session.conversation.unsubscribe",
payload: {
scope_id: stream.conversationScopeId,
session_id: sessionId,
consumer_kind: "core",
},
});
}
clearTimeout(stream?.conversationCheckTimer);
this.coreSessionStreams.delete(sessionId);
this.coreConversationSessions.delete(sessionId);
this.coreSessionRecoveryHandlers.delete(sessionId);
if (this.status === "connected") {
this.send({
id: generateUUID(),
type: "request",
action: SESSION_UNSUBSCRIBE_ACTION,
payload: { session_id: sessionId },
});
}
return;
}
this.sessionSubscriptions.set(sessionId, nextCount);
}
/**
* Subscribe to office run-event notifications for the given run id.
* Ref-counted so multiple components can subscribe independently;
* only the first call sends `run.subscribe` over the wire, only the
* last unsubscribe sends `run.unsubscribe`. Returns an
* unsubscribe function.
*/
subscribeRun(runId: string) {
const currentCount = this.runSubscriptions.get(runId) ?? 0;
const nextCount = currentCount + 1;
this.runSubscriptions.set(runId, nextCount);
if (this.status === "connected" && nextCount === 1) {
this.send({
id: generateUUID(),
type: "request",
action: "run.subscribe",
payload: { run_id: runId },
});
}
return () => this.unsubscribeRun(runId);
}
subscribeSystemMetrics() {
this.systemMetricsSubscriptionCount += 1;
if (this.status === "connected" && this.systemMetricsSubscriptionCount === 1) {
this.send({
id: generateUUID(),
type: "request",
action: "system.metrics.subscribe",
payload: {},
});
}
return () => this.unsubscribeSystemMetrics();
}
unsubscribeSystemMetrics() {
this.systemMetricsSubscriptionCount = Math.max(0, this.systemMetricsSubscriptionCount - 1);
if (this.status === "connected" && this.systemMetricsSubscriptionCount === 0) {
this.send({
id: generateUUID(),
type: "request",
action: "system.metrics.unsubscribe",
payload: {},
});
}
}
unsubscribeRun(runId: string) {
const currentCount = this.runSubscriptions.get(runId);
if (!currentCount) return;
const nextCount = currentCount - 1;
if (nextCount <= 0) {
this.runSubscriptions.delete(runId);
if (this.status === "connected") {
this.send({
id: generateUUID(),
type: "request",
action: "run.unsubscribe",
payload: { run_id: runId },
});
}
return;
}
this.runSubscriptions.set(runId, nextCount);
}
unsubscribeUser() {
this.userSubscriptionCount = Math.max(0, this.userSubscriptionCount - 1);
if (this.status === "connected" && this.userSubscriptionCount === 0) {
this.send({
id: generateUUID(),
type: "request",
action: "user.unsubscribe",
payload: {},
});
}
}
on<T extends BackendMessageType>(type: T, handler: MessageHandler<T>) {
const handlers = this.handlers.get(type) ?? new Set();
handlers.add(handler as MessageHandler<BackendMessageType>);
this.handlers.set(type, handlers);
return () => this.off(type, handler);
}
off<T extends BackendMessageType>(type: T, handler: MessageHandler<T>) {
const handlers = this.handlers.get(type);
if (!handlers) return;
handlers.delete(handler as MessageHandler<BackendMessageType>);
if (!handlers.size) {
this.handlers.delete(type);
}
}
onConnectionStatus(handler: (status: WebSocketStatus) => void) {
this.statusHandlers.add(handler);
return () => this.statusHandlers.delete(handler);
}
onRawSessionEvent(handler: (event: RawSessionEvent) => void) {
this.rawSessionEventHandlers.add(handler);
return () => {
this.rawSessionEventHandlers.delete(handler);
};
}
registerCoreSessionRecovery(sessionId: string, handler: CoreSessionRecoveryHandler) {
const handlers = this.coreSessionRecoveryHandlers.get(sessionId) ?? new Set();
handlers.add(handler);
this.coreSessionRecoveryHandlers.set(sessionId, handlers);
const stream = this.coreSessionStreams.get(sessionId);
if (stream?.needsHydration) {
void this.continueCoreSessionRecovery(sessionId, stream);
}
return () => {
const current = this.coreSessionRecoveryHandlers.get(sessionId);
if (!current) return;
current.delete(handler);
if (current.size === 0) this.coreSessionRecoveryHandlers.delete(sessionId);
};
}
retryCoreSessionRecovery(sessionId: string): Promise<boolean> | undefined {
const stream = this.coreSessionStreams.get(sessionId);
if (!stream?.needsHydration) return undefined;
return this.continueCoreSessionRecovery(sessionId, stream);
}
private debugNotification(action: BackendMessageType, payload: unknown, handlerCount: number) {
if (!isDebug() || DISPATCH_LOG_DENYLIST.has(action)) return;
const payloadSessionId = (payload as { session_id?: string } | undefined)?.session_id;
debugDispatch("notification", {
action,
sessionId: payloadSessionId ?? null,
handlers: handlerCount,
});
}
private handleParsedMessage(value: unknown) {
if (isRawSessionEvent(value)) {
const disposition = orderedCoreDisposition(value);
this.handleCoreSessionEvent(value, disposition);
this.rawSessionEventHandlers.forEach((handler) => handler(value));
return;
}
if (this.handleControlFrame(value)) return;
const message = this.notificationMessage(value);
if (!message) return;
const action = message.action;
if (action === SESSION_CONVERSATION_CHANGED_ACTION) {
this.dispatchConversationChanged(message);
return;
}
const payloadSessionID =
message.payload && typeof message.payload === "object"
? (message.payload as { session_id?: unknown }).session_id
: undefined;
if (this.shouldSuppressCoreConversationNotification(action, payloadSessionID)) return;
const handlers = this.handlers.get(action);
this.debugNotification(action, message.payload, handlers?.size ?? 0);
if (handlers) {
handlers.forEach((handler) => handler(message));
}
dispatchToPluginWsHandlers(action, message.payload);
}
private handleControlFrame(value: unknown): boolean {
if (this.handleMalformedSessionEnvelope(value)) return true;
if (!value || typeof value !== "object" || !("type" in value)) return true;
return this.handleRequestResult(value as RawWebSocketMessage);
}
private notificationMessage(value: unknown): BackendMessageMap[BackendMessageType] | null {
if (!value || typeof value !== "object" || !("type" in value)) return null;
const message = value as BackendMessageMap[BackendMessageType];
if (message.type !== "notification" || !message.action) return null;
return message;
}
private dispatchConversationChanged(
message: BackendMessageMap[typeof SESSION_CONVERSATION_CHANGED_ACTION],
) {
this.handleCoreConversationChanged(message.payload);
const handlers = this.handlers.get(SESSION_CONVERSATION_CHANGED_ACTION);
this.debugNotification(
SESSION_CONVERSATION_CHANGED_ACTION,
message.payload,
handlers?.size ?? 0,
);
handlers?.forEach((handler) => handler(message));
dispatchToPluginWsHandlers(SESSION_CONVERSATION_CHANGED_ACTION, message.payload);
}
private shouldSuppressCoreConversationNotification(
action: BackendMessageType,
sessionId: unknown,
): boolean {
return (
this.reconnectOptions.conversationProtocol === "v2" &&
typeof sessionId === "string" &&
this.coreConversationSessions.has(sessionId) &&
isConversationNotificationAction(action)
);
}
private handleCoreConversationChanged(value: unknown) {
const parsed = parseCoreConversationChange(value);
if (!parsed) return;
const { sessionId } = parsed;
const stream = this.coreSessionStreams.get(sessionId);
if (!stream || this.reconnectOptions.conversationProtocol !== "v2") return;
if (this.handleCoreConversationControl(parsed, stream)) return;
if (needsCoreConversationRecovery(parsed, stream)) {
this.beginCoreConversationRecovery(
sessionId,
stream,
stream.conversationAppliedRevision ?? "0",
);
return;
}
if (!parsed.valid) return;
const validPayload = parsed.payload as ConversationChangedPayload;
if (!stream.ready || stream.needsHydration || stream.projectionPaused) {
stream.conversationPendingChanges.push(validPayload);
this.trimCoreConversationPendingChanges(sessionId, stream, validPayload.revision);
return;
}
const applied = stream.conversationAppliedRevision ?? "0";
const revision = validPayload.revision;
if (BigInt(revision) <= BigInt(applied)) return;
if (hasCoreConversationRevisionGap(validPayload, stream, applied)) {
stream.conversationPendingChanges.push(validPayload);
this.beginCoreConversationRecovery(sessionId, stream, revision);
return;
}
stream.conversationAppliedRevision = revision;
this.projectCoreConversationOperations(sessionId, validPayload.operations ?? []);
}
private handleCoreConversationControl(
parsed: ParsedCoreConversationChange,
stream: CoreSessionStream,
): boolean {
const { sessionId } = parsed;
if (parsed.payload.scope_id !== stream.conversationScopeId) return true;
if (parsed.payload.terminal) {
clearTimeout(stream.conversationCheckTimer);
stream.projectionPaused = true;
stream.conversationPendingChanges.length = 0;
this.handlers.get("session.removed")?.forEach((handler) =>
handler({
type: "notification",
action: "session.removed",
payload: { session_id: sessionId },
}),
);
return true;
}
if (parsed.payload.check) {
this.checkCoreConversationRevision(sessionId, stream, parsed.payload);
return true;
}
return false;
}
private checkCoreConversationRevision(
sessionId: string,
stream: CoreSessionStream,
payload: Partial<ConversationChangedPayload>,
) {
const applied = stream.conversationAppliedRevision ?? "0";
if (
!isDecimalRevision(payload.revision) ||
payload.epoch !== stream.conversationEpoch ||
BigInt(payload.revision) < BigInt(applied)
) {
this.beginCoreConversationRecovery(sessionId, stream, applied);
return;
}
if (payload.revision === applied || stream.conversationCheckTimer !== undefined) return;
const observed = payload.revision;
stream.conversationCheckTimer = setTimeout(() => {
stream.conversationCheckTimer = undefined;
if (this.coreSessionStreams.get(sessionId) !== stream || this.status !== "connected") return;
if (BigInt(observed) > BigInt(stream.conversationAppliedRevision ?? "0")) {
this.beginCoreConversationRecovery(sessionId, stream, observed);
}
}, 1000);
}
private trimCoreConversationPendingChanges(
sessionId: string,
stream: CoreSessionStream,
recoveryRevision: string,
) {
if (stream.conversationPendingChanges.length <= 256) return;
stream.conversationPendingChanges.splice(0, stream.conversationPendingChanges.length - 256);
this.beginCoreConversationRecovery(sessionId, stream, recoveryRevision);
}
private drainCoreConversationChanges(sessionId: string, stream: CoreSessionStream) {
if (!stream.ready || stream.needsHydration || stream.projectionPaused) return;
const pending = stream.conversationPendingChanges.splice(0);
for (const change of pending) {
this.handleCoreConversationChanged(change);
if (stream.needsHydration || stream.projectionPaused) return;
}
if (this.coreSessionStreams.get(sessionId) !== stream) return;
}
private projectCoreConversationOperations(sessionId: string, operations: unknown[]) {
for (const value of operations) {
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
const operation = value as CoreConversationOperation;
if (!isCoreConversationOperation(operation)) continue;
const projected = projectCoreConversationOperation(sessionId, operation);
this.dispatchConversationNotification(projected.action, projected.payload);
}
}
private dispatchConversationNotification(action: BackendMessageType, payload: unknown) {
const handlers = this.handlers.get(action);
this.debugNotification(action, payload, handlers?.size ?? 0);
handlers?.forEach((handler) => handler({ type: "notification", action, payload } as never));
dispatchToPluginWsHandlers(action, payload);
}
private beginCoreConversationRecovery(
sessionId: string,
stream: CoreSessionStream,
revision: string,
) {
stream.projectionPaused = true;
stream.needsHydration = true;
if (
!stream.conversationRecoveryRevision ||
BigInt(revision) > BigInt(stream.conversationRecoveryRevision)
) {
stream.conversationRecoveryRevision = revision;
}
stream.recoveryGeneration += 1;
void this.continueCoreSessionRecovery(sessionId, stream);
}
/**
* A frame that is session.event-shaped but fails the strict envelope check
* (bad protocol version, missing identity fields, malformed payload) can
* never be a valid ordered row. If it targets a live core stream at the
* expected next sequence, run the same durable poison recovery as a poison
* disposition so the stream rebinds to the authoritative watermark instead
* of stalling silently on a hole; the malformed frame itself is never
* dispatched to consumers.
*/
private handleMalformedSessionEnvelope(value: unknown): boolean {
if (!value || typeof value !== "object") return false;
const candidate = value as {
type?: unknown;
session_id?: unknown;
task_id?: unknown;
sequence?: unknown;
};
if (candidate.type !== "session.event") return false;
const sessionId = candidate.session_id;
const sequence = candidate.sequence;
const taskId = candidate.task_id;
const stream =
typeof sessionId === "string" && typeof sequence === "number"
? this.coreSessionStreams.get(sessionId)
: undefined;
if (stream && sequence === stream.lastSeenSequence + 1) {
if (isDebug()) {
console.warn(
`[ws] malformed ordered session.event frame for "${sessionId}" at ${sequence}`,
);
}
this.recoverCoreSessionPoison(sessionId as string, stream);
}
if (
typeof sessionId === "string" &&
Number.isSafeInteger(sequence) &&
(sequence as number) > 0
) {
this.notifyMalformedSessionEvent(
sessionId,
sequence as number,
typeof taskId === "string" ? taskId : null,
);
}
return true;
}
private notifyMalformedSessionEvent(sessionId: string, sequence: number, taskId: string | null) {
const poison: RawSessionEvent = {
type: "session.event",
protocol_version: 1,
event_type: "session.event.poison",
session_id: sessionId,
task_id: taskId,
sequence,
event_id: `poison:${sessionId}:${sequence}`,
payload: {
type: "session.event.poison",
session_id: sessionId,
task_id: taskId,
},
};
this.rawSessionEventHandlers.forEach((handler) => handler(poison));
}
private handleRequestResult(message: RawWebSocketMessage): boolean {
if (message.type !== "response" && message.type !== "error") return false;
if (typeof message.id !== "string") return false;
if (message.type === "response") {
if (isDebug()) debugDispatch("response", { id: message.id });
this.resolvePendingRequest(message.id, message.payload);
} else {
if (isDebug()) debugDispatch("error-response", { id: message.id });
this.rejectPendingRequest(message.id, message.payload);
}
return true;
}
private resolvePendingRequest(msgId: string, payload: unknown) {
const pending = this.pendingRequests.get(msgId);
if (!pending) return;
clearTimeout(pending.timeout);
this.pendingRequests.delete(msgId);
pending.resolve(payload);
}
// i18n-exempt: transport/API diagnostic. Callers branch on the error code and
// render translated copy; this text only ever appears in a console or as an
// interpolated English diagnostic (see docs/i18n.md on interpolated values).
private rejectPendingRequest(msgId: string, payload: unknown) {
const pending = this.pendingRequests.get(msgId);
if (!pending) return;
clearTimeout(pending.timeout);
this.pendingRequests.delete(msgId);
pending.reject(toWebSocketRequestError(payload));
}
private handleDisconnect(event: CloseEvent) {
this.setStatus("disconnected");
const shouldRetainSessionReadiness =
!this.intentionalClose &&
this.reconnectOptions.enabled &&
this.reconnectAttempts < this.reconnectOptions.maxAttempts;
this.resetSessionSubscriptionReadiness(
new Error(WEBSOCKET_CONNECTION_CLOSED_ERROR),
shouldRetainSessionReadiness,
);
// Don't reconnect if this was an intentional close
if (this.intentionalClose) {
return;
}
// Don't reconnect if reconnect is disabled
if (!this.reconnectOptions.enabled) {
return;
}
// Don't reconnect if we've exceeded max attempts
if (this.reconnectAttempts >= this.reconnectOptions.maxAttempts) {
console.warn(
`WebSocket max reconnect attempts (${this.reconnectOptions.maxAttempts}) reached`,
);
this.setStatus("error");
this.cleanupPendingRequests();
return;
}
// Calculate delay with exponential backoff
const delay = Math.min(
this.reconnectOptions.initialDelay *
Math.pow(this.reconnectOptions.backoffMultiplier, this.reconnectAttempts),
this.reconnectOptions.maxDelay,
);
this.reconnectAttempts++;
this.setStatus("reconnecting");
console.log(
`WebSocket disconnected (code: ${event.code}, reason: ${event.reason || "none"}). ` +
`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.reconnectOptions.maxAttempts})...`,
);
this.reconnectTimer = setTimeout(() => {
this.connect();
}, delay);
}
private clearReconnectTimer() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
private cleanupPendingRequests() {
// Reject all pending requests
this.pendingRequests.forEach(({ reject, timeout }) => {
clearTimeout(timeout);
reject(new Error(WEBSOCKET_CONNECTION_CLOSED_ERROR));
});
this.pendingRequests.clear();
}
private getOrCreateSessionSubscriptionReadiness(
sessionId: string,
forceNewAfterSettled = false,
): SessionSubscriptionReadiness {
const existing = this.sessionSubscriptionReadiness.get(sessionId);
if (existing && !(forceNewAfterSettled && existing.settled)) return existing;
let resolve!: () => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<void>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
const readiness: SessionSubscriptionReadiness = {
promise,
resolve,
reject,
requestStarted: false,
settled: false,
attempt: 0,
retryTimer: null,
};
// subscribeSession() consumers do not await readiness, so handle failures
// while returning the original promise to readiness-aware consumers.
void promise.catch(() => undefined);
this.sessionSubscriptionReadiness.set(sessionId, readiness);
return readiness;
}
private startSessionSubscription(sessionId: string, readiness: SessionSubscriptionReadiness) {
if (this.sessionSubscriptionReadiness.get(sessionId) !== readiness) return;
if (readiness.requestStarted) return;
readiness.requestStarted = true;
readiness.attempt += 1;
const stream = this.getOrCreateCoreSessionStream(sessionId);
if (this.reconnectOptions.conversationProtocol === "v2") {
this.startSourceSessionSubscription(sessionId, readiness, stream);
return;
}
stream.ready = false;
stream.pendingEvents.length = 0;
const legacySubscription = this.request(
SESSION_SUBSCRIBE_ACTION,
{ session_id: sessionId },
SESSION_ENTRY_REQUEST_TIMEOUT_MS,
);
const orderedSubscription = this.request<unknown>(
SESSION_SUBSCRIBE_ACTION,
{
session_id: sessionId,
consumer_kind: "core",
wire_id: stream.wireId,
...(stream.resumeToken || stream.lastSeenSequence > 0
? { last_seen_sequence: stream.lastSeenSequence }
: {}),
...(stream.resumeToken ? { resume_token: stream.resumeToken } : {}),
},
SESSION_ENTRY_REQUEST_TIMEOUT_MS,
).then((response) => {
const validated = validateOrderedSubscribeResponse(response, sessionId, stream.wireId);
const recoveryRequired = validated.result === "invalid_resume" || stream.needsHydration;
if (recoveryRequired) {
stream.projectionPaused = true;
stream.needsHydration = true;
stream.recoveryGeneration += 1;
stream.recoveryWatermark = validated.eventWatermark;
} else if (validated.result !== "replay") {
stream.lastSeenSequence = Math.max(stream.lastSeenSequence, validated.eventWatermark);
}
stream.resumeToken = validated.resumeToken;
stream.ready = true;
this.drainCoreSessionEvents(sessionId, stream);
});
void Promise.all([legacySubscription, orderedSubscription])
.then(() => {
if (this.sessionSubscriptionReadiness.get(sessionId) !== readiness) return;
readiness.retryTimer = null;
readiness.settled = true;
readiness.resolve();
const stream = this.coreSessionStreams.get(sessionId);
if (stream?.needsHydration) {
void this.continueCoreSessionRecovery(sessionId, stream);
}
})
.catch((error: unknown) => {
stream.ready = false;
stream.pendingEvents.length = 0;
if (this.sessionSubscriptionReadiness.get(sessionId) !== readiness) return;
readiness.requestStarted = false;
if (
isWebSocketRequestTimeoutError(error) &&
readiness.attempt < MAX_SESSION_SUBSCRIPTION_ATTEMPTS &&
this.status === "connected" &&
this.socket
) {
readiness.retryTimer = setTimeout(() => {
readiness.retryTimer = null;
this.startSessionSubscription(sessionId, readiness);
}, SESSION_ENTRY_RETRY_DELAY_MS);
return;
}
this.sessionSubscriptionReadiness.delete(sessionId);
readiness.settled = true;
readiness.reject(error);
});
}
private startSourceSessionSubscription(
sessionId: string,
readiness: SessionSubscriptionReadiness,
stream: CoreSessionStream,
) {
stream.ready = false;
stream.conversationPendingChanges.length = 0;
const legacySubscription = this.request(
SESSION_SUBSCRIBE_ACTION,
{ session_id: sessionId },
SESSION_ENTRY_REQUEST_TIMEOUT_MS,
);
const conversationSubscription = this.request<unknown>(
"session.conversation.subscribe",
{
scope_id: stream.conversationScopeId,
session_id: sessionId,
consumer_kind: "core",
},
SESSION_ENTRY_REQUEST_TIMEOUT_MS,
).then((response) => {
const payload = response as Partial<ConversationChangedPayload> & {
success?: boolean;
error?: unknown;
};
if (
payload.success !== true ||
payload.protocol_version !== 2 ||
payload.scope_id !== stream.conversationScopeId ||
payload.session_id !== sessionId ||
typeof payload.epoch !== "string" ||
typeof payload.revision !== "string" ||
!/^(0|[1-9][0-9]*)$/.test(payload.revision)
) {
// i18n-exempt: transport/API diagnostic. The caller renders the
// structured error code through translated UI copy.
throw new Error("Invalid conversation subscription response");
}
const hadConversationState = stream.conversationEpoch !== undefined;
const missedConversationState =
hadConversationState &&
(stream.conversationEpoch !== payload.epoch ||
BigInt(payload.revision) > BigInt(stream.conversationAppliedRevision ?? "0"));
stream.conversationEpoch = payload.epoch;
if (!missedConversationState) stream.conversationAppliedRevision = payload.revision;
if (missedConversationState) {
stream.needsHydration = true;
stream.projectionPaused = true;
stream.conversationRecoveryRevision = payload.revision;
stream.recoveryGeneration += 1;
}
stream.ready = true;
this.coreConversationSessions.add(sessionId);
this.drainCoreConversationChanges(sessionId, stream);
});
void Promise.all([legacySubscription, conversationSubscription])
.then(() => {
if (this.sessionSubscriptionReadiness.get(sessionId) !== readiness) return;
readiness.retryTimer = null;
readiness.settled = true;
readiness.resolve();
if (stream.needsHydration) void this.continueCoreSessionRecovery(sessionId, stream);
})
.catch((error: unknown) => {
stream.ready = false;
stream.conversationPendingChanges.length = 0;
if (this.sessionSubscriptionReadiness.get(sessionId) !== readiness) return;
readiness.requestStarted = false;
if (
isWebSocketRequestTimeoutError(error) &&
readiness.attempt < MAX_SESSION_SUBSCRIPTION_ATTEMPTS &&
this.status === "connected" &&
this.socket
) {
readiness.retryTimer = setTimeout(() => {
readiness.retryTimer = null;
this.startSessionSubscription(sessionId, readiness);
}, SESSION_ENTRY_RETRY_DELAY_MS);
return;
}
this.sessionSubscriptionReadiness.delete(sessionId);
readiness.settled = true;
readiness.reject(error);
});
}
private getOrCreateCoreSessionStream(sessionId: string): CoreSessionStream {
const current = this.coreSessionStreams.get(sessionId);
if (current) return current;
const stream = {
wireId: `core:web:${generateUUID()}`,
conversationScopeId: `core:web:${generateUUID()}`,
lastSeenSequence: 0,
ready: false,
pendingEvents: [],
projectionPaused: false,
needsHydration: false,
recoveryGeneration: 0,
conversationPendingChanges: [],
};
this.coreSessionStreams.set(sessionId, stream);
return stream;
}
private handleCoreSessionEvent(
event: RawSessionEvent,
disposition = orderedCoreDisposition(event),
) {
const stream = this.coreSessionStreams.get(event.session_id);
if (!stream) return;
if (stream.projectionPaused || stream.needsHydration) {
stream.pendingEvents.push(event);
return;
}
if (!stream.ready) {
stream.pendingEvents.push(event);
return;
}
if (stream.pendingEvents.length > 0) {
stream.pendingEvents.push(event);
this.drainCoreSessionEvents(event.session_id, stream);
return;
}
this.processCoreSessionEvent(event, stream, disposition);
this.drainCoreSessionEvents(event.session_id, stream);
}
private drainCoreSessionEvents(sessionId: string, stream: CoreSessionStream) {
if (
!stream.ready ||
stream.projectionPaused ||
stream.needsHydration ||
stream.pendingEvents.length === 0
)
return;
stream.pendingEvents.sort((left, right) => left.sequence - right.sequence);
while (stream.pendingEvents.length > 0) {
const event = stream.pendingEvents[0];
if (!event) return;
if (event.sequence > stream.lastSeenSequence + 1) {
stream.pendingEvents.length = 0;
this.recoverCoreSessionPoison(sessionId, stream);
return;
}
stream.pendingEvents.shift();
this.processCoreSessionEvent(event, stream);
}
}
private processCoreSessionEvent(
event: RawSessionEvent,
stream: CoreSessionStream,
disposition = orderedCoreDisposition(event),
) {
if (event.sequence <= stream.lastSeenSequence) {
void this.acknowledgeCoreSessionEvent(
event.session_id,
stream,
stream.lastSeenSequence,
).catch(() => this.recoverCoreSessionPoison(event.session_id, stream));
return;
}
if (event.sequence !== stream.lastSeenSequence + 1) {
this.recoverCoreSessionPoison(event.session_id, stream);
return;
}
if (disposition === "poison") {
this.recoverCoreSessionPoison(event.session_id, stream);
return;
}
if (disposition === "project") {
const action = orderedCoreAction(event.event_type);
if (!action) return;
const payload = projectCoreSessionPayload(event);
const message = {
type: "notification",
action,
payload,
} as BackendMessageMap[BackendMessageType];
const handlers = this.handlers.get(action);
this.debugNotification(action, payload, handlers?.size ?? 0);
handlers?.forEach((handler) => handler(message));
dispatchToPluginWsHandlers(action, payload);
}
stream.lastSeenSequence = event.sequence;
void this.acknowledgeCoreSessionEvent(event.session_id, stream, event.sequence).catch(() =>
this.recoverCoreSessionPoison(event.session_id, stream),
);
}
private recoverCoreSessionPoison(sessionId: string, stream: CoreSessionStream) {
if (stream.poisonRecovery) return;
stream.projectionPaused = true;
stream.recoveryGeneration += 1;
stream.recoveryWatermark = undefined;
stream.poisonRecovery = this.request(SESSION_SUBSCRIBE_ACTION, {
session_id: sessionId,
consumer_kind: "core",
wire_id: stream.wireId,
last_seen_sequence: stream.lastSeenSequence,
...(stream.resumeToken ? { resume_token: stream.resumeToken } : {}),
replace_cursor: true,
})
.then((response) => {
const validated = validateOrderedSubscribeResponse(response, sessionId, stream.wireId);
if (this.coreSessionStreams.get(sessionId) !== stream) return;
stream.resumeToken = validated.resumeToken;
stream.needsHydration = true;
stream.recoveryWatermark = validated.eventWatermark;
return this.continueCoreSessionRecovery(sessionId, stream);
})
.catch(() => undefined)
.finally(() => {
stream.poisonRecovery = undefined;
});
}
private continueCoreSessionRecovery(
sessionId: string,
stream: CoreSessionStream,
): Promise<boolean> {
if (
!stream.needsHydration ||
(this.reconnectOptions.conversationProtocol === "v1" &&
stream.recoveryWatermark === undefined) ||
(this.reconnectOptions.conversationProtocol === "v2" &&
stream.conversationRecoveryRevision === undefined)
) {
return Promise.resolve(false);
}
const existing = stream.recoveryHydration;
if (existing) return existing;
const handlers = [...(this.coreSessionRecoveryHandlers.get(sessionId) ?? [])];
if (handlers.length === 0) return Promise.resolve(false);
const generation = stream.recoveryGeneration;
const pending = Promise.all(handlers.map((handler) => Promise.resolve().then(() => handler())))
.then((results) => {
if (
this.coreSessionStreams.get(sessionId) !== stream ||
stream.recoveryGeneration !== generation ||
!results.every(Boolean)
) {
return false;
}
if (this.reconnectOptions.conversationProtocol === "v2") {
const revision = stream.conversationRecoveryRevision;
if (revision === undefined) return false;
stream.conversationAppliedRevision = revision;
stream.conversationPendingChanges = stream.conversationPendingChanges.filter((change) => {
if (!change || typeof change !== "object") return false;
const candidate = change as { revision?: unknown };
return (
typeof candidate.revision === "string" &&
BigInt(candidate.revision) > BigInt(revision)
);
});
stream.conversationRecoveryRevision = undefined;
} else {
const watermark = stream.recoveryWatermark;
if (watermark === undefined) return false;
stream.lastSeenSequence = Math.max(stream.lastSeenSequence, watermark);
stream.pendingEvents = stream.pendingEvents.filter(
(event) => event.sequence > stream.lastSeenSequence,
);
}
stream.needsHydration = false;
stream.projectionPaused = false;
stream.recoveryWatermark = undefined;
this.drainCoreConversationChanges(sessionId, stream);
this.drainCoreSessionEvents(sessionId, stream);
return true;
})
.catch(() => false)
.finally(() => {
if (stream.recoveryHydration !== pending) return;
stream.recoveryHydration = undefined;
if (stream.needsHydration && stream.recoveryGeneration !== generation) {
void this.continueCoreSessionRecovery(sessionId, stream);
}
});
stream.recoveryHydration = pending;
return pending;
}
private async acknowledgeCoreSessionEvent(
sessionId: string,
stream: CoreSessionStream,
sequence: number,
) {
const response = await this.request<unknown>("session.ack", {
session_id: sessionId,
consumer_kind: "core",
wire_id: stream.wireId,
sequence,
resume_token: stream.resumeToken,
});
stream.resumeToken = validateOrderedAckResponse(response, sessionId, stream.wireId, sequence);
}
private cancelSessionSubscriptionReadiness(sessionId: string) {
const readiness = this.sessionSubscriptionReadiness.get(sessionId);
if (!readiness) return;
this.sessionSubscriptionReadiness.delete(sessionId);
if (readiness.retryTimer) {
clearTimeout(readiness.retryTimer);
readiness.retryTimer = null;
}
if (!readiness.settled) {
readiness.settled = true;
readiness.reject(new Error(SESSION_SUBSCRIPTION_RELEASED_ERROR));
}
}
private resetSessionSubscriptionReadiness(error: Error, retainActiveSubscriptions = false) {
const readinessEntries = [...this.sessionSubscriptionReadiness.entries()];
this.sessionSubscriptionReadiness.clear();
for (const [, readiness] of readinessEntries) {
if (readiness.retryTimer) {
clearTimeout(readiness.retryTimer);
readiness.retryTimer = null;
}
if (readiness.settled) continue;
readiness.settled = true;
readiness.reject(error);
}
if (retainActiveSubscriptions) {
this.sessionSubscriptions.forEach((_, id) =>
this.getOrCreateSessionSubscriptionReadiness(id),
);
}
}
private resubscribe() {
// Re-subscribe to all tasks after reconnection
this.subscriptions.forEach((_count, taskId) => {
this.send({
id: generateUUID(),
type: "request",
action: "task.subscribe",
payload: { task_id: taskId },
});
});
this.sessionSubscriptions.forEach((_count, sessionId) => {
const readiness = this.getOrCreateSessionSubscriptionReadiness(sessionId);
this.startSessionSubscription(sessionId, readiness);
});
this.sessionFocusCounts.forEach((_count, sessionId) => {
this.send({
id: generateUUID(),
type: "request",
action: "session.focus",
payload: { session_id: sessionId },
});
});
this.runSubscriptions.forEach((_count, runId) => {
this.send({
id: generateUUID(),
type: "request",
action: "run.subscribe",
payload: { run_id: runId },
});
});
if (this.userSubscriptionCount > 0) {
this.send({
id: generateUUID(),
type: "request",
action: "user.subscribe",
payload: {},
});
}
if (this.systemMetricsSubscriptionCount > 0) {
this.send({
id: generateUUID(),
type: "request",
action: "system.metrics.subscribe",
payload: {},
});
}
}
private flushQueue() {
if (!this.socket || this.status !== "connected") return;
this.pendingQueue.forEach((data) => this.socket?.send(data));
this.pendingQueue = [];
}
private setStatus(status: WebSocketStatus) {
this.status = status;
this.onStatusChange?.(status);
this.statusHandlers.forEach((handler) => handler(status));
}
}