import { create } from 'zustand'; import { devtools, persist } from 'zustand/middleware'; import { z } from 'zod'; import type { Event } from '@opencode-ai/sdk/v2'; import { createInputHistoryIdentity, createInputHistorySubmission, useInputHistoryStore } from './useInputHistoryStore'; import { createDeferredSafeJSONStorage } from './utils/safeStorage'; import type { AttachedFile } from './types/sessionTypes'; import { contextPartMetadataSchema, type ContextPartMetadata } from '@/lib/messages/contextParts'; import { updateDesktopSettings } from '@/lib/persistence'; import { getRuntimeKey } from '@/lib/runtime-switch'; import { isVSCodeRuntime } from '@/lib/desktop'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { normalizePath } from '@/lib/pathNormalization'; export type FollowUpBehavior = 'steer' | 'queue'; const DEFAULT_FOLLOW_UP_BEHAVIOR: FollowUpBehavior = 'queue'; export const isFollowUpBehavior = (value: unknown): value is FollowUpBehavior => ( value === 'steer' || value === 'queue' ); export const normalizeFollowUpBehavior = ( value: unknown, legacyQueueModeEnabled?: boolean | null, ): FollowUpBehavior => { // "immediate" was removed: on a busy session it was wire-identical to // "steer" (OpenCode only supports delivery "steer" | "queue", defaulting // to "steer"), so collapse any persisted/legacy "immediate" onto "steer". if (value === 'immediate') { return 'steer'; } if (isFollowUpBehavior(value)) { return value; } if (legacyQueueModeEnabled === false) { return 'steer'; } if (legacyQueueModeEnabled === true) { return 'queue'; } return DEFAULT_FOLLOW_UP_BEHAVIOR; }; /** * Who delivers the queue. Web, desktop, and mobile talk to an OpenChamber * server that owns the queue and sends it whether or not any UI is open. VS * Code has no server of its own, so the extension UI keeps the local queue * and the foreground auto-send hook. */ export const isServerOwnedMessageQueue = (): boolean => !isVSCodeRuntime(); export interface QueuedMessageSendConfig { providerID: string; modelID: string; agent?: string; variant?: string; } /** * Context captured with a queued message: whatever the composer had attached * when the message was queued. It leaves the composer with the message, so * delivery (by the server, or by the auto-send hook in VS Code) carries it and * editing the message brings it back. */ export type QueuedContextPart = | { /** An attached context item: a draft chip or a linked issue/PR. Restored on edit. */ kind: 'context'; text: string; metadata: ContextPartMetadata; /** Delivered as its own synthetic part right before this one (a linked PR's reading instructions). */ instructions?: string; } | { /** Derived from the message text (the skill instruction); re-derived when the text is sent again, so never restored. */ kind: 'instruction'; text: string; } | { /** Handed to the composer by another surface (conflict resolution); restored as pending on edit. */ kind: 'synthetic'; text: string; }; export interface QueuedMessage { id: string; /** What the user typed, for display and editing. */ content: string; /** What is delivered: `content` without its leading agent mention, file mentions already resolved. */ text: string; /** Agent mentioned at the start of `content`, delivered as an agent part. */ agentMention?: string; attachments?: AttachedFile[]; /** Absent on a server projection item; a take brings it back. */ context?: QueuedContextPart[]; createdAt: number; /** Send config captured at queue time — used as-is when auto-sending */ sendConfig?: QueuedMessageSendConfig; } interface QueuedMessageInput { content: string; /** Defaults to `content`. */ text?: string; agentMention?: string; attachments?: AttachedFile[]; context?: QueuedContextPart[]; sendConfig?: QueuedMessageSendConfig; } export type MessageQueueTarget = { runtimeKey: string; directory: string; sessionId: string; }; const MAX_QUEUE_TARGETS = 50; const MAX_MESSAGES_PER_QUEUE = 20; export const createMessageQueueTarget = ( sessionId: string, directory: string | null | undefined, runtimeKey: string = getRuntimeKey(), ): MessageQueueTarget | null => { const normalizedDirectory = normalizePath(directory); if (!runtimeKey || !normalizedDirectory || !sessionId) return null; return { runtimeKey, directory: normalizedDirectory, sessionId }; }; export const getMessageQueueKey = (target: MessageQueueTarget): string => `${target.runtimeKey}\n${target.directory}\n${target.sessionId}`; export const parseMessageQueueKey = (key: string): MessageQueueTarget | null => { const [runtimeKey, directory, ...sessionParts] = key.split('\n'); return createMessageQueueTarget(sessionParts.join('\n'), directory, runtimeKey); }; // --------------------------------------------------------------------------- // Server contract (packages/web/server/lib/message-queue) // --------------------------------------------------------------------------- const serverSendConfigSchema = z.object({ providerID: z.string().min(1), modelID: z.string().min(1), agent: z.string().optional(), variant: z.string().optional(), }); const serverAttachmentSchema = z.object({ id: z.string(), filename: z.string(), mimeType: z.string(), size: z.number(), source: z.enum(['local', 'server', 'vscode']), serverPath: z.string().optional(), /** Present only on a taken item; broadcasts and snapshots omit payloads. */ dataUrl: z.string().optional(), }); const serverContextPartSchema = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('context'), text: z.string(), metadata: contextPartMetadataSchema, instructions: z.string().optional(), }), z.object({ kind: z.literal('instruction'), text: z.string() }), z.object({ kind: z.literal('synthetic'), text: z.string() }), ]); const serverItemSchema = z.object({ id: z.string().min(1), createdAt: z.number(), content: z.string(), text: z.string(), agentMention: z.string().optional(), attachments: z.array(serverAttachmentSchema), /** Present only on a taken item; broadcasts and snapshots omit it like attachment payloads. */ context: z.array(serverContextPartSchema).optional(), sendConfig: serverSendConfigSchema, }); const serverSessionSchema = z.object({ sessionId: z.string().min(1), directory: z.string(), items: z.array(serverItemSchema), sendingId: z.string().nullable(), }); const serverSnapshotSchema = z.object({ revision: z.number(), sessions: z.array(serverSessionSchema), }); const serverSessionResponseSchema = z.object({ revision: z.number(), session: serverSessionSchema, }); const serverTakeResponseSchema = serverSessionResponseSchema.extend({ item: serverItemSchema }); const serverTakeAllResponseSchema = serverSessionResponseSchema.extend({ items: z.array(serverItemSchema) }); type ServerQueueSession = z.infer; type ServerQueueItem = z.infer; type ServerQueueAttachment = z.infer; const decodeDataUrl = (dataUrl: string): ArrayBuffer | null => { const commaIndex = dataUrl.indexOf(','); if (!dataUrl.startsWith('data:') || commaIndex === -1) return null; const meta = dataUrl.slice(5, commaIndex); const payload = dataUrl.slice(commaIndex + 1); try { if (meta.endsWith(';base64')) { const binary = atob(payload); const buffer = new ArrayBuffer(binary.length); const bytes = new Uint8Array(buffer); for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); return buffer; } const encoded = new TextEncoder().encode(decodeURIComponent(payload)); const buffer = new ArrayBuffer(encoded.byteLength); new Uint8Array(buffer).set(encoded); return buffer; } catch { return null; } }; /** A taken item carries its payload; a projection item has an empty file. */ const toAttachedFile = (attachment: ServerQueueAttachment): AttachedFile => { const dataUrl = attachment.dataUrl ?? ''; const bytes = dataUrl ? decodeDataUrl(dataUrl) : null; const file: AttachedFile = { id: attachment.id, file: new File(bytes ? [bytes] : [], attachment.filename, { type: attachment.mimeType }), dataUrl, mimeType: attachment.mimeType, filename: attachment.filename, size: attachment.size, source: attachment.source, }; if (attachment.serverPath) file.serverPath = attachment.serverPath; return file; }; const toQueuedMessage = (item: ServerQueueItem): QueuedMessage => { const message: QueuedMessage = { id: item.id, content: item.content, text: item.text, createdAt: item.createdAt, sendConfig: { ...item.sendConfig }, }; if (item.agentMention) message.agentMention = item.agentMention; if (item.attachments.length > 0) message.attachments = item.attachments.map(toAttachedFile); if (item.context) message.context = item.context; return message; }; type ServerQueueAttachmentInput = Omit & { dataUrl: string }; type ServerQueueItemInput = { content: string; text: string; agentMention?: string; attachments: ServerQueueAttachmentInput[]; context: QueuedContextPart[]; sendConfig: QueuedMessageSendConfig; }; type ServerQueueRequestBody = | { directory: string; item: ServerQueueItemInput } | { itemIds: string[] } | { held: boolean }; const toServerAttachment = (attachment: AttachedFile): ServerQueueAttachmentInput => { const input: ServerQueueAttachmentInput = { id: attachment.id, filename: attachment.filename, mimeType: attachment.mimeType, size: attachment.size, source: attachment.source, dataUrl: attachment.dataUrl, }; if (attachment.serverPath) input.serverPath = attachment.serverPath; return input; }; const toServerItemInput = (message: QueuedMessageInput, sendConfig: QueuedMessageSendConfig): ServerQueueItemInput => { const item: ServerQueueItemInput = { content: message.content, text: message.text ?? message.content, attachments: (message.attachments ?? []).filter((file) => Boolean(file.dataUrl)).map(toServerAttachment), context: message.context ?? [], sendConfig, }; if (message.agentMention) item.agentMention = message.agentMention; return item; }; const requestJson = async (schema: z.ZodType, path: string, init?: RequestInit): Promise => { const response = await runtimeFetch(path, init); if (!response.ok) { const error: Error & { status?: number } = new Error(`Message queue request failed (${response.status})`); error.status = response.status; throw error; } const parsed = schema.safeParse(await response.json()); if (!parsed.success) throw new Error('Invalid message queue response'); return parsed.data; }; const jsonInit = (method: string, body?: ServerQueueRequestBody): RequestInit => { if (body === undefined) return { method }; return { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }; }; const sessionPath = (sessionId: string) => `/api/message-queue/sessions/${encodeURIComponent(sessionId)}`; /** * Runtime keys whose queue the server owns, established by a successful * hydration. Their entries are a projection and must not be persisted: a * stale local copy would resurrect messages the server already delivered. */ const serverOwnedRuntimeKeys = new Set(); /** Server revision last applied per queue key; older snapshots are ignored. */ const appliedRevisions = new Map(); let hydrationGeneration = 0; interface MessageQueueState { queuedMessages: Record; // runtime + directory + session → queue quarantinedLegacyMessages: Record; followUpBehavior: FollowUpBehavior; /** * Queued messages whose send is currently awaiting the server, per target. * * A queued item is removed only after its send resolves, so between * dispatch and resolution it is still visible to every other reader — and * a composer submit merges the whole queue into its own send. Over a relay * that window is seconds, long enough for the same message to be delivered * twice. Dispatchers must skip entries listed here. * * Never persisted: a restart has no in-flight sends, and a stale flag would * strand a queued message permanently. With a server-owned queue this * mirrors the server's in-flight item. */ sendingIds: Record; } interface MessageQueueActions { addToQueue: (target: MessageQueueTarget, message: QueuedMessageInput) => Promise; removeFromQueue: (target: MessageQueueTarget, messageId: string) => void; reorderQueue: (target: MessageQueueTarget, fromId: string, toId: string) => void; /** Removes the message and returns it in full, attachments included. */ popToInput: (target: MessageQueueTarget, messageId: string) => Promise; /** * Removes what the composer is about to send itself — one message or every * message not already being delivered — and returns it in full. */ takeForSend: (target: MessageQueueTarget, messageId?: string) => Promise; clearQueue: (target: MessageQueueTarget) => void; /** Drops the local projection only (the session is gone); never a server call. */ forgetQueue: (target: MessageQueueTarget) => void; clearAllQueues: () => void; markSending: (target: MessageQueueTarget, messageId: string) => void; clearSending: (target: MessageQueueTarget, messageId: string) => void; getSendableQueue: (target: MessageQueueTarget) => QueuedMessage[]; setFollowUpBehavior: (behavior: FollowUpBehavior) => void; getQueueForTarget: (target: MessageQueueTarget) => QueuedMessage[]; /** Server-owned queue: load the authoritative queue for the active runtime. */ hydrate: () => Promise; /** Server-owned queue: re-read after an event-stream gap. */ resync: () => Promise; /** Server-owned queue: apply one session's authoritative state (broadcast or response). */ applyServerSession: (session: ServerQueueSession, revision: number, expectedRuntimeKey: string) => void; /** Server-owned queue: tell the server to hold or release a session's delivery. */ setServerHold: (sessionId: string, held: boolean) => Promise; resetForRuntimeSwitch: (previousRuntimeKey: string | null | undefined) => void; } type MessageQueueStore = MessageQueueState & MessageQueueActions; /** Messages persisted before version 3 carried only `content`. */ type PersistedQueuedMessage = Omit & { text?: string }; type PersistedMessageQueueState = { queuedMessages?: Record; quarantinedLegacyMessages?: Record; followUpBehavior?: FollowUpBehavior; queueModeEnabled?: boolean; }; const withDeliveryText = (queues: Record): Record => ( Object.fromEntries(Object.entries(queues).map(([key, queue]) => [ key, queue.map((message) => ({ ...message, text: message.text ?? message.content })), ])) ); export const migrateMessageQueueState = (persistedState: unknown, version: number): Partial => { const state = (persistedState ?? {}) as PersistedMessageQueueState; const legacyQueues = version < 2 ? (state.queuedMessages ?? {}) : {}; return { queuedMessages: version < 2 ? {} : withDeliveryText(state.queuedMessages ?? {}), quarantinedLegacyMessages: withDeliveryText({ ...(state.quarantinedLegacyMessages ?? {}), ...legacyQueues, }), followUpBehavior: normalizeFollowUpBehavior(state.followUpBehavior, state.queueModeEnabled ?? null), }; }; const withoutKey = (record: Record, key: string): Record => { const { [key]: _removed, ...rest } = record; void _removed; return rest; }; const removeMessageLocally = ( state: Pick, key: string, messageId: string, ): Pick => { const newQueue = (state.queuedMessages[key] ?? []).filter((m) => m.id !== messageId); if (newQueue.length === 0) return { queuedMessages: withoutKey(state.queuedMessages, key) }; return { queuedMessages: { ...state.queuedMessages, [key]: newQueue } }; }; /** Every projection of one session in this runtime, whatever directory it was keyed under. */ const clearSessionProjection = ( state: Pick, runtimeKey: string, sessionId: string, revision: number, ): Pick => { let queuedMessages = state.queuedMessages; let sendingIds = state.sendingIds; for (const key of new Set([...Object.keys(queuedMessages), ...Object.keys(sendingIds)])) { const parsed = parseMessageQueueKey(key); if (parsed?.runtimeKey !== runtimeKey || parsed.sessionId !== sessionId) continue; if ((appliedRevisions.get(key) ?? -1) > revision) continue; appliedRevisions.set(key, revision); queuedMessages = withoutKey(queuedMessages, key); sendingIds = withoutKey(sendingIds, key); } return { queuedMessages, sendingIds }; }; export const useMessageQueueStore = create()( devtools( persist( (set, get) => { const applyServerSession = (session: ServerQueueSession, revision: number, expectedRuntimeKey: string) => { if (expectedRuntimeKey !== getRuntimeKey()) return; const target = createMessageQueueTarget(session.sessionId, session.directory, expectedRuntimeKey); if (!target) { // Servers before 1.22.2 drop a session's directory once its // queue is empty. A session id is unique across directories, // so an empty session still says which projection is done. if (session.items.length > 0) return; set((state) => clearSessionProjection(state, expectedRuntimeKey, session.sessionId, revision)); return; } const key = getMessageQueueKey(target); if ((appliedRevisions.get(key) ?? -1) > revision) return; appliedRevisions.set(key, revision); set((state) => { const queue = session.items.map(toQueuedMessage); const queuedMessages = queue.length > 0 ? { ...state.queuedMessages, [key]: queue } : withoutKey(state.queuedMessages, key); const sendingIds = session.sendingId ? { ...state.sendingIds, [key]: [session.sendingId] } : withoutKey(state.sendingIds, key); return { queuedMessages, sendingIds }; }); }; /** Server state wins; a failed round-trip re-reads it instead of guessing. */ const refreshSession = async (target: MessageQueueTarget) => { try { const snapshot = await requestJson(serverSnapshotSchema, '/api/message-queue'); const session = snapshot.sessions.find((entry) => entry.sessionId === target.sessionId) ?? { sessionId: target.sessionId, directory: target.directory, items: [], sendingId: null }; applyServerSession(session, snapshot.revision, target.runtimeKey); } catch { // Offline: keep the optimistic projection; the next broadcast or hydration corrects it. } }; const serverMutation = async ( target: MessageQueueTarget, path: string, init: RequestInit, ) => { try { const result = await requestJson(serverSessionResponseSchema, path, init); applyServerSession(result.session, result.revision, target.runtimeKey); } catch (error) { console.warn('[queue] server update failed:', error); await refreshSession(target); } }; return { queuedMessages: {}, quarantinedLegacyMessages: {}, followUpBehavior: DEFAULT_FOLLOW_UP_BEHAVIOR, sendingIds: {}, addToQueue: async (target, message) => { const key = getMessageQueueKey(target); const id = `queued-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; const queuedMessage: QueuedMessage = { id, content: message.content, text: message.text ?? message.content, createdAt: Date.now(), sendConfig: message.sendConfig, }; if (message.agentMention) queuedMessage.agentMention = message.agentMention; if (message.attachments && message.attachments.length > 0) queuedMessage.attachments = message.attachments; if (message.context && message.context.length > 0) queuedMessage.context = message.context; set((state) => { const currentQueue = state.queuedMessages[key] ?? []; const queuedMessages = { ...state.queuedMessages, [key]: [...currentQueue, queuedMessage].slice(-MAX_MESSAGES_PER_QUEUE), }; const keys = Object.keys(queuedMessages); if (keys.length > MAX_QUEUE_TARGETS) { keys.sort((left, right) => ( (queuedMessages[left]?.[0]?.createdAt ?? 0) - (queuedMessages[right]?.[0]?.createdAt ?? 0) )); for (const staleKey of keys.slice(0, keys.length - MAX_QUEUE_TARGETS)) delete queuedMessages[staleKey]; } return { queuedMessages, }; }); if (!isServerOwnedMessageQueue()) return; if (!message.sendConfig) { set((state) => removeMessageLocally(state, key, id)); throw new Error('A queued message needs a provider and model to be delivered later.'); } const historyIdentity = createInputHistoryIdentity(target.runtimeKey, target.directory, target.sessionId); const historySubmission = createInputHistorySubmission(message.content, message.attachments ?? []); try { const result = await requestJson(serverSessionResponseSchema, `${sessionPath(target.sessionId)}/items`, jsonInit('POST', { directory: target.directory, item: toServerItemInput(message, message.sendConfig), })); // The optimistic entry is replaced by the server's copy of the queue. set((state) => removeMessageLocally(state, key, id)); applyServerSession(result.session, result.revision, target.runtimeKey); if (historyIdentity) { useInputHistoryStore.getState().appendSubmissions(historyIdentity, [historySubmission]); } } catch (error) { set((state) => removeMessageLocally(state, key, id)); throw error; } }, removeFromQueue: (target, messageId) => { const key = getMessageQueueKey(target); set((state) => removeMessageLocally(state, key, messageId)); if (isServerOwnedMessageQueue()) { void serverMutation(target, `${sessionPath(target.sessionId)}/items/${encodeURIComponent(messageId)}`, jsonInit('DELETE')); } }, reorderQueue: (target, fromId, toId) => { if (fromId === toId) return; const key = getMessageQueueKey(target); const currentQueue = get().queuedMessages[key]; if (!currentQueue) return; const fromIndex = currentQueue.findIndex((m) => m.id === fromId); const toIndex = currentQueue.findIndex((m) => m.id === toId); if (fromIndex === -1 || toIndex === -1) return; const newQueue = currentQueue.slice(); const [moved] = newQueue.splice(fromIndex, 1); newQueue.splice(toIndex, 0, moved); set((state) => ({ queuedMessages: { ...state.queuedMessages, [key]: newQueue, }, })); if (isServerOwnedMessageQueue()) { const itemIds = newQueue.map((message) => message.id); void serverMutation(target, `${sessionPath(target.sessionId)}/order`, jsonInit('PUT', { itemIds })); } }, popToInput: async (target, messageId) => { const [message] = await get().takeForSend(target, messageId); return message ?? null; }, takeForSend: async (target, messageId) => { const key = getMessageQueueKey(target); if (isServerOwnedMessageQueue()) { try { if (messageId) { const result = await requestJson( serverTakeResponseSchema, `${sessionPath(target.sessionId)}/items/${encodeURIComponent(messageId)}/take`, jsonInit('POST'), ); applyServerSession(result.session, result.revision, target.runtimeKey); return [toQueuedMessage(result.item)]; } const result = await requestJson(serverTakeAllResponseSchema, `${sessionPath(target.sessionId)}/take`, jsonInit('POST')); applyServerSession(result.session, result.revision, target.runtimeKey); return result.items.map(toQueuedMessage); } catch (error) { await refreshSession(target); throw error; } } const state = get(); const sending = state.sendingIds[key] ?? []; const taken = (state.queuedMessages[key] ?? []).filter((message) => ( (messageId ? message.id === messageId : true) && !sending.includes(message.id) )); if (taken.length === 0) return []; const takenIds = new Set(taken.map((message) => message.id)); set((prevState) => { const remaining = (prevState.queuedMessages[key] ?? []).filter((message) => !takenIds.has(message.id)); if (remaining.length === 0) return { queuedMessages: withoutKey(prevState.queuedMessages, key) }; return { queuedMessages: { ...prevState.queuedMessages, [key]: remaining } }; }); return taken; }, clearQueue: (target) => { const key = getMessageQueueKey(target); set((state) => { // Clearing drops what is still queued, never a message // already handed to the server: that send will resolve // and must find its entry to remove or restore. const sending = state.sendingIds[key] ?? []; const retained = (state.queuedMessages[key] ?? []).filter((m) => sending.includes(m.id)); if (retained.length > 0) { return { queuedMessages: { ...state.queuedMessages, [key]: retained } }; } return { queuedMessages: withoutKey(state.queuedMessages, key) }; }); if (isServerOwnedMessageQueue()) { void serverMutation(target, sessionPath(target.sessionId), jsonInit('DELETE')); } }, forgetQueue: (target) => { const key = getMessageQueueKey(target); appliedRevisions.delete(key); set((state) => ({ queuedMessages: withoutKey(state.queuedMessages, key), sendingIds: withoutKey(state.sendingIds, key), })); }, clearAllQueues: () => { set({ queuedMessages: {}, sendingIds: {} }); }, markSending: (target, messageId) => { const key = getMessageQueueKey(target); set((state) => { const current = state.sendingIds[key] ?? []; if (current.includes(messageId)) return state; return { sendingIds: { ...state.sendingIds, [key]: [...current, messageId] } }; }); }, clearSending: (target, messageId) => { const key = getMessageQueueKey(target); set((state) => { const current = state.sendingIds[key]; if (!current || !current.includes(messageId)) return state; const next = current.filter((id) => id !== messageId); if (next.length === 0) return { sendingIds: withoutKey(state.sendingIds, key) }; return { sendingIds: { ...state.sendingIds, [key]: next } }; }); }, getSendableQueue: (target) => { const key = getMessageQueueKey(target); const state = get(); const queue = state.queuedMessages[key] ?? []; const sending = state.sendingIds[key]; if (!sending || sending.length === 0) return queue; return queue.filter((message) => !sending.includes(message.id)); }, setFollowUpBehavior: (behavior) => { set({ followUpBehavior: behavior }); void updateDesktopSettings({ followUpBehavior: behavior }); }, getQueueForTarget: (target) => { return get().queuedMessages[getMessageQueueKey(target)] ?? []; }, hydrate: async () => { if (!isServerOwnedMessageQueue()) return; const runtimeKey = getRuntimeKey(); const generation = ++hydrationGeneration; const isCurrent = () => generation === hydrationGeneration && runtimeKey === getRuntimeKey(); // Messages queued by an older build live in this browser only. // Hand them to the server once so they are still delivered; // whatever cannot be uploaded is superseded by the server's queue. const legacyEntries = Object.entries(get().queuedMessages) .map(([key, queue]) => ({ target: parseMessageQueueKey(key), queue })) .filter((entry): entry is { target: MessageQueueTarget; queue: QueuedMessage[] } => ( entry.target !== null && entry.target.runtimeKey === runtimeKey && !serverOwnedRuntimeKeys.has(runtimeKey) )); for (const { target, queue } of legacyEntries) { for (const message of queue) { if (!message.sendConfig) continue; try { await requestJson(serverSessionResponseSchema, `${sessionPath(target.sessionId)}/items`, jsonInit('POST', { directory: target.directory, item: toServerItemInput(message, message.sendConfig), })); } catch (error) { console.warn('[queue] failed to migrate a locally queued message to the server:', error); } if (!isCurrent()) return; } } const snapshot = await requestJson(serverSnapshotSchema, '/api/message-queue'); if (!isCurrent()) return; serverOwnedRuntimeKeys.add(runtimeKey); set((state) => { // A broadcast newer than this snapshot wins, listed in it or not. const isNewerThanSnapshot = (key: string) => (appliedRevisions.get(key) ?? -1) > snapshot.revision; const keep = (key: string) => parseMessageQueueKey(key)?.runtimeKey !== runtimeKey || isNewerThanSnapshot(key); const queuedMessages: Record = {}; const sendingIds: Record = {}; for (const [key, queue] of Object.entries(state.queuedMessages)) { if (keep(key)) queuedMessages[key] = queue; } for (const [key, ids] of Object.entries(state.sendingIds)) { if (keep(key)) sendingIds[key] = ids; } for (const session of snapshot.sessions) { const target = createMessageQueueTarget(session.sessionId, session.directory, runtimeKey); if (!target) continue; const key = getMessageQueueKey(target); if (isNewerThanSnapshot(key)) continue; appliedRevisions.set(key, snapshot.revision); if (session.items.length > 0) queuedMessages[key] = session.items.map(toQueuedMessage); if (session.sendingId) sendingIds[key] = [session.sendingId]; } return { queuedMessages, sendingIds }; }); }, resync: async () => { if (!serverOwnedRuntimeKeys.has(getRuntimeKey())) return; await get().hydrate(); }, applyServerSession, setServerHold: async (sessionId, held) => { if (!isServerOwnedMessageQueue()) return; const response = await runtimeFetch(`${sessionPath(sessionId)}/hold`, jsonInit('PUT', { held })); if (!response.ok) throw new Error(`Message queue hold request failed (${response.status})`); }, resetForRuntimeSwitch: (previousRuntimeKey) => { hydrationGeneration += 1; if (!previousRuntimeKey || !serverOwnedRuntimeKeys.has(previousRuntimeKey)) return; // The previous runtime's projection belongs to its server; // switching back re-hydrates it from there. set((state) => { const queuedMessages: Record = {}; const sendingIds: Record = {}; for (const [key, queue] of Object.entries(state.queuedMessages)) { if (parseMessageQueueKey(key)?.runtimeKey === previousRuntimeKey) appliedRevisions.delete(key); else queuedMessages[key] = queue; } for (const [key, ids] of Object.entries(state.sendingIds)) { if (parseMessageQueueKey(key)?.runtimeKey !== previousRuntimeKey) sendingIds[key] = ids; } return { queuedMessages, sendingIds }; }); }, }; }, { name: 'message-queue-store', version: 3, storage: createDeferredSafeJSONStorage(), partialize: (state) => ({ queuedMessages: Object.fromEntries( Object.entries(state.queuedMessages).filter(([key]) => { const runtimeKey = parseMessageQueueKey(key)?.runtimeKey; return !runtimeKey || !serverOwnedRuntimeKeys.has(runtimeKey); }), ), quarantinedLegacyMessages: state.quarantinedLegacyMessages, followUpBehavior: state.followUpBehavior, }), migrate: migrateMessageQueueState, } ), { name: 'message-queue-store', } ) ); const serverUpdatedEventSchema = z.object({ properties: z.object({ revision: z.number(), session: serverSessionSchema }), }); export type MessageQueueUpdatedEvent = { type: 'openchamber:message-queue.updated'; properties: z.infer['properties']; }; /** `openchamber:message-queue.updated` broadcast → projection. */ export const applyMessageQueueUpdatedEvent = (payload: Event | MessageQueueUpdatedEvent, expectedRuntimeKey: string): void => { if (!isServerOwnedMessageQueue()) return; const parsed = serverUpdatedEventSchema.safeParse(payload); if (!parsed.success) return; const { session, revision } = parsed.data.properties; useMessageQueueStore.getState().applyServerSession(session, revision, expectedRuntimeKey); };