/* eslint-disable max-lines -- this class owns one ordered source lifecycle */ import { generateUUID } from "@/lib/uuid"; import { getWebSocketClient } from "@/lib/ws/connection"; import type { RawSessionEvent } from "@/lib/ws/client"; import { fetchConversationBinding, pluginConversationUrl, type Binding, type ConversationScope, type OrderedReady, type SnapshotKind, } from "./conversation-scope"; import { reconcileConversationChange, type ConversationChange, type ConversationChangeOperation, type ConversationReconciliationState, } from "./conversation-reconciliation"; type Listener = (event: RawSessionEvent) => boolean; type RebindListener = () => void | Promise; type SourceSubscribeResponse = { success: true; protocol_version: 2; scope_id: string; session_id: string; epoch: string; revision: string; }; type SourceFailure = { success: false; error?: { code?: string; message?: string; retryable?: boolean }; }; const MAX_PENDING_CONVERSATION_OPERATIONS = 256; const MAX_PENDING_CONVERSATION_BYTES = 1 << 20; const MAX_PENDING_CONVERSATION_AGE_MS = 1000; const RECOVERY_RETRY_DELAY_MS = 1000; function sourceError(response: SourceFailure): Error { // i18n-exempt: transport/API diagnostic. The caller renders the structured // error code through translated UI copy. const error = new Error(response.error?.message ?? "Conversation subscription failed"); Object.assign(error, { code: response.error?.code ?? "upstream_failure", retryable: response.error?.retryable ?? true, }); return error; } function validRevision(value: unknown): value is string { return typeof value === "string" && /^(0|[1-9][0-9]*)$/.test(value); } function compareRevision(left: string, right: string): number { const leftValue = BigInt(left); const rightValue = BigInt(right); if (leftValue < rightValue) return -1; if (leftValue > rightValue) return 1; return 0; } function snapshotKindForOperation(operation: ConversationChangeOperation): SnapshotKind { return operation.entity === "message" ? "messages" : "turns"; } function operationTaskID(operation: ConversationChangeOperation, fallback: string): string { const value = operation.entity === "message" ? operation.message?.taskId : operation.turn?.taskId; return typeof value === "string" && value !== "" ? value : fallback; } function operationEventType(operation: ConversationChangeOperation): string { if (operation.entity === "message") { return operation.kind === "remove" ? "message.deleted" : "message.added"; } return operation.kind === "remove" ? "session.turn.removed" : "session.turn.started"; } function pendingChangeMetrics(value: unknown): { operations: number; bytes: number } | null { if (!value || typeof value !== "object" || Array.isArray(value)) return null; const operations = (value as Partial).operations; if (!Array.isArray(operations) || operations.length === 0) return null; let encoded: string | undefined; try { encoded = JSON.stringify(value); } catch { return null; } if (encoded === undefined) return null; return { operations: operations.length, bytes: new TextEncoder().encode(encoded).byteLength }; } function sourceOperationEvent( operation: ConversationChangeOperation, sessionID: string, taskID: string, revision: string, index: number, ): RawSessionEvent { const eventType = operationEventType(operation); const operationTaskId = operationTaskID(operation, taskID); const payload: Record = { type: eventType, session_id: sessionID, task_id: operationTaskId, }; if (operation.entity === "message") { payload.message_id = operation.id; if (operation.kind === "upsert" && operation.message) { const message = operation.message; payload.author_type = message.authorType; payload.content = message.content; payload.message_type = message.type; payload.created_at = message.createdAt; payload.updated_at = message.updatedAt; payload.turn_id = message.turnId; payload.prompt_index = message.promptIndex; payload.sender_task_id = message.senderTaskId; } } else { payload.id = operation.id; if (operation.kind === "upsert" && operation.turn) { const turn = operation.turn; payload.started_at = turn.startedAt; payload.completed_at = turn.completedAt; payload.updated_at = turn.updatedAt; } } const numericRevision = Number(revision); return { type: "session.event", protocol_version: 1, event_type: eventType, session_id: sessionID, task_id: operationTaskId, sequence: Number.isSafeInteger(numericRevision) ? numericRevision : index + 1, event_id: `conversation:${sessionID}:${revision}:${operation.entity}:${operation.id}`, payload, }; } export class SourceConversationScope implements ConversationScope { readonly source = true; readonly signal: AbortSignal; private readonly scopeID = `plugin-conversation:${generateUUID()}`; private readonly listeners = new Map(); private readonly rebindListeners = new Set(); private readonly committedSnapshots = new Set(); private readonly pendingChanges: unknown[] = []; private bindingPromise: Promise | null = null; private readyPromise: Promise | null = null; private recoveryPromise: Promise | null = null; private recovering = false; private recoveryNeeded = false; private recoveryRetryTimer: ReturnType | undefined; private pendingOperationCount = 0; private pendingPayloadBytes = 0; private pendingSince: number | undefined; private pendingBufferTimer: ReturnType | undefined; private pendingRecoveryMarker = false; private terminal = false; private revisionCheckTimer: ReturnType | undefined; private closed = false; private state: ConversationReconciliationState = { appliedRevision: "0", epoch: "" }; constructor( readonly pluginId: string, readonly taskId: string, readonly sessionId: string | null, private readonly controller: AbortController, ) { this.signal = controller.signal; } ready(): Promise { const pending = (this.readyPromise ??= this.initializeReady()); return pending .then(async (current) => { const binding = await this.getBinding(); if (binding.bindingToken === current.bindingToken) return current; if (binding.generation !== current.generation) return this.subscribeSource(binding); const next = { ...current, ...binding }; this.readyPromise = Promise.resolve(next); return next; }) .catch((error: unknown) => { if (this.readyPromise === pending) this.readyPromise = null; throw error; }); } subscribe(listener: Listener, kind: SnapshotKind, snapshotKey: string): () => void { this.listeners.set(listener, { kind, key: snapshotKey }); return () => { this.listeners.delete(listener); this.drainPending(); }; } async renewContinuation(cursor: string): Promise<{ cursor: string; binding: OrderedReady; }> { return { cursor, binding: await this.ready() }; } subscribeRebind(listener: RebindListener): () => void { this.rebindListeners.add(listener); return () => this.rebindListeners.delete(listener); } isTerminal(): boolean { return this.terminal; } invalidateSnapshot(kind: SnapshotKind, snapshotKey = "") { this.committedSnapshots.delete(`${kind}:${snapshotKey}`); } commitSnapshot(kind: SnapshotKind, snapshotKey = "") { if (this.closed) return; this.committedSnapshots.add(`${kind}:${snapshotKey}`); this.drainPending(); } setSourceSnapshot(epoch: string | undefined, revision: string | undefined) { if (this.closed || !validRevision(revision)) return; const nextEpoch = epoch ?? this.state.epoch; if (nextEpoch === "") return; if (nextEpoch !== this.state.epoch) { this.clearPendingBuffer(); this.recovering = false; this.state = { epoch: nextEpoch, appliedRevision: revision }; } else if (compareRevision(revision, this.state.appliedRevision) > 0) { this.state = { ...this.state, appliedRevision: revision }; this.recovering = false; } this.pendingRecoveryMarker = false; this.recoveryNeeded = false; this.clearRecoveryRetry(); this.discardCoveredChanges(this.state.appliedRevision); this.updateReadyState(); this.drainPending(); } acceptChange(value: unknown) { if (this.closed || this.terminal || !this.sessionId) return; const change = value as Partial; if (change.session_id !== this.sessionId || change.scope_id !== this.scopeID) return; if (change.terminal) { this.acceptTerminalRemoval(); return; } if (change.check) { this.checkRevision(change); return; } if (this.recovering || this.shouldBuffer(change)) { this.bufferPendingChange(value); return; } this.applyChange(value); } accept(_event: RawSessionEvent): void { // Source scopes consume v2 changes and terminal removal notifications separately. } acceptTerminalRemoval(): void { if (this.closed || this.terminal || !this.sessionId) return; this.terminal = true; clearTimeout(this.revisionCheckTimer); this.clearPendingBuffer(); this.clearRecoveryRetry(); const event: RawSessionEvent = { type: "session.event", protocol_version: 1, event_type: "session.removed", session_id: this.sessionId, task_id: null, sequence: 0, event_id: `conversation:removed:${this.sessionId}`, payload: { type: "session.removed", session_id: this.sessionId, }, }; for (const listener of this.listeners.keys()) { try { listener(event); } catch { // A terminal notification must not prevent other consumers from closing. } } } reconnect() { if (this.closed || this.terminal || !this.sessionId || !this.readyPromise) return; this.beginRecovery(); } close() { this.closed = true; clearTimeout(this.revisionCheckTimer); this.clearPendingBuffer(); this.clearRecoveryRetry(); this.listeners.clear(); this.rebindListeners.clear(); const ready = this.readyPromise; if (!ready || !this.sessionId) return; void ready .then((current) => getWebSocketClient()?.request("session.conversation.unsubscribe", { scope_id: this.scopeID, session_id: this.sessionId, consumer_kind: "plugin", plugin_id: this.pluginId, generation: current.generation, }), ) .catch(() => undefined); } private checkRevision(change: Partial) { if ( !validRevision(change.revision) || change.epoch !== this.state.epoch || compareRevision(change.revision, this.state.appliedRevision) < 0 ) { this.beginRecovery(); return; } if (change.revision === this.state.appliedRevision || this.revisionCheckTimer !== undefined) return; const observed = change.revision; this.revisionCheckTimer = setTimeout(() => { this.revisionCheckTimer = undefined; if ( !this.closed && !this.terminal && compareRevision(observed, this.state.appliedRevision) > 0 ) { this.beginRecovery(); } }, 1000); } private async initializeReady(): Promise { const binding = await this.getBinding(); if (!this.sessionId) { return { ...binding, source: true, snapshotToken: "", snapshotExpiresAt: binding.expiresAt, resumeToken: "", consumerId: this.scopeID, watermark: 0, }; } return this.subscribeSource(binding); } private async getBinding(): Promise { let pending = (this.bindingPromise ??= fetchConversationBinding(this.pluginId, this.signal)); try { const current = await pending; if (new Date(current.expiresAt).getTime() - Date.now() > 120_000) return current; if (this.bindingPromise === pending) { this.bindingPromise = fetchConversationBinding(this.pluginId, this.signal); } pending = this.bindingPromise!; return await pending; } catch (error) { if (this.bindingPromise === pending) this.bindingPromise = null; throw error; } } private async subscribeSource(binding: Binding, notifyOnChange = true): Promise { const client = getWebSocketClient(); // i18n-exempt: transport/API diagnostic. The caller renders the // structured error code through translated UI copy. if (!client) throw new Error("WebSocket unavailable"); const response = await client.request( "session.conversation.subscribe", { scope_id: this.scopeID, session_id: this.sessionId, consumer_kind: "plugin", plugin_id: this.pluginId, generation: binding.generation, binding_token: binding.bindingToken, }, ); if (!response.success) throw sourceError(response); if ( response.protocol_version !== 2 || response.session_id !== this.sessionId || response.scope_id !== this.scopeID || !response.epoch || !validRevision(response.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 changed = this.state.epoch !== "" && (this.state.epoch !== response.epoch || compareRevision(response.revision, this.state.appliedRevision) > 0); this.state = { epoch: response.epoch, appliedRevision: response.revision }; this.recovering = false; const next: OrderedReady = { ...binding, source: true, snapshotToken: "", snapshotExpiresAt: binding.expiresAt, resumeToken: "", consumerId: this.scopeID, watermark: 0, epoch: response.epoch, revision: response.revision, }; this.readyPromise = Promise.resolve(next); if (changed && notifyOnChange) await this.notifyRebindListeners(); return next; } private async resubscribe(notifyOnChange = true) { const binding = await this.getBinding(); await this.subscribeSource(binding, notifyOnChange); } private updateReadyState() { if (!this.readyPromise) return; void this.readyPromise.then((ready) => { if (this.readyPromise) { this.readyPromise = Promise.resolve({ ...ready, epoch: this.state.epoch, revision: this.state.appliedRevision, }); } }); } private shouldBuffer(value: Partial): boolean { if (!Array.isArray(value.operations) || value.operations.length === 0) return false; return value.operations.some((operation) => { if (!operation || (operation.kind !== "upsert" && operation.kind !== "remove")) return true; const kind = snapshotKindForOperation(operation as ConversationChangeOperation); return [...this.listeners.values()] .filter((listener) => listener.kind === kind) .some((listener) => !this.committedSnapshots.has(`${kind}:${listener.key}`)); }); } private applyChange(value: unknown) { const result = reconcileConversationChange(this.state, value); if (result.kind !== "applied") { if (result.kind === "recover") { this.bufferPendingChange(value); this.beginRecovery(); } return; } this.state = result.state; this.updateReadyState(); const change = value as ConversationChange; result.operations.forEach((operation, index) => { const event = sourceOperationEvent( operation, change.session_id, this.taskId, change.revision, index, ); for (const listener of this.listeners.keys()) { try { if (!listener(event)) { this.beginRecovery(); return; } } catch { this.beginRecovery(); return; } } }); } private discardCoveredChanges(revision: string) { this.pendingChanges.splice( 0, this.pendingChanges.length, ...this.pendingChanges.filter((value) => { const change = value as Partial; return validRevision(change.revision) && compareRevision(change.revision, revision) > 0; }), ); this.recalculatePendingMetrics(); } private drainPending() { if (this.closed || this.recovering) return; if (this.pendingRecoveryMarker) { this.beginRecovery(); return; } if (this.pendingChanges.length === 0) return; const pending = this.pendingChanges.splice(0); this.resetPendingMetrics(); for (const change of pending) { if (this.shouldBuffer(change as Partial)) { this.bufferPendingChange(change); continue; } this.applyChange(change); if (this.recovering || this.pendingRecoveryMarker) return; } } private beginRecovery() { if (this.closed || this.terminal) return; this.recoveryNeeded = true; if (this.recovering || this.recoveryPromise) return; this.clearRecoveryRetry(); this.recovering = true; const recovery = this.resubscribe(false) .then(() => this.notifyRebindListeners()) .then(() => { this.recovering = false; this.recoveryNeeded = false; }) .catch((error: unknown) => { this.recovering = false; if (this.isRetryableRecoveryError(error)) this.scheduleRecoveryRetry(); else this.recoveryNeeded = false; }) .finally(() => { if (this.recoveryPromise === recovery) this.recoveryPromise = null; }); this.recoveryPromise = recovery; } private isRetryableRecoveryError(error: unknown): boolean { return !( error && typeof error === "object" && "retryable" in error && (error as { retryable?: unknown }).retryable === false ); } private scheduleRecoveryRetry() { if ( this.recoveryRetryTimer !== undefined || this.closed || this.terminal || !this.recoveryNeeded ) { return; } this.recoveryRetryTimer = setTimeout(() => { this.recoveryRetryTimer = undefined; if (this.recoveryNeeded) this.beginRecovery(); }, RECOVERY_RETRY_DELAY_MS); } private clearRecoveryRetry() { clearTimeout(this.recoveryRetryTimer); this.recoveryRetryTimer = undefined; } private bufferPendingChange(value: unknown) { if (this.closed || this.pendingRecoveryMarker) { if (!this.closed) this.beginRecovery(); return; } const metrics = pendingChangeMetrics(value); if ( !metrics || metrics.operations > MAX_PENDING_CONVERSATION_OPERATIONS || metrics.bytes > MAX_PENDING_CONVERSATION_BYTES || this.pendingOperationCount + metrics.operations > MAX_PENDING_CONVERSATION_OPERATIONS || this.pendingPayloadBytes + metrics.bytes > MAX_PENDING_CONVERSATION_BYTES || (this.pendingSince !== undefined && Date.now() - this.pendingSince >= MAX_PENDING_CONVERSATION_AGE_MS) ) { this.pendingChanges.length = 0; this.resetPendingMetrics(); this.pendingRecoveryMarker = true; this.beginRecovery(); return; } if (this.pendingSince === undefined) { this.pendingSince = Date.now(); this.pendingBufferTimer = setTimeout(() => { this.pendingBufferTimer = undefined; this.pendingChanges.length = 0; this.resetPendingMetrics(); this.pendingRecoveryMarker = true; this.beginRecovery(); }, MAX_PENDING_CONVERSATION_AGE_MS); } this.pendingChanges.push(value); this.pendingOperationCount += metrics.operations; this.pendingPayloadBytes += metrics.bytes; } private resetPendingMetrics() { this.pendingOperationCount = 0; this.pendingPayloadBytes = 0; this.pendingSince = undefined; clearTimeout(this.pendingBufferTimer); this.pendingBufferTimer = undefined; } private clearPendingBuffer() { this.pendingChanges.length = 0; this.resetPendingMetrics(); this.pendingRecoveryMarker = false; } private recalculatePendingMetrics() { this.resetPendingMetrics(); if (this.pendingChanges.length === 0) return; let operations = 0; let bytes = 0; for (const change of this.pendingChanges) { const metrics = pendingChangeMetrics(change); if (!metrics) { this.pendingChanges.length = 0; this.pendingRecoveryMarker = true; return; } operations += metrics.operations; bytes += metrics.bytes; } this.pendingOperationCount = operations; this.pendingPayloadBytes = bytes; this.pendingSince = Date.now(); this.pendingBufferTimer = setTimeout(() => { this.pendingBufferTimer = undefined; this.pendingChanges.length = 0; this.resetPendingMetrics(); this.pendingRecoveryMarker = true; this.beginRecovery(); }, MAX_PENDING_CONVERSATION_AGE_MS); } private async notifyRebindListeners() { await Promise.all( [...this.rebindListeners].map((listener) => Promise.resolve().then(() => listener())), ); } } export function sourceConversationMessageUrl( pluginId: string, sessionId: string, query: URLSearchParams, ): string { return pluginConversationUrl( pluginId, `/conversation/v2/task-sessions/${encodeURIComponent(sessionId)}/messages?${query}`, ); }