UNPKG

assistant-cloud

Version:

Cloud integration for assistant-ui

395 lines (394 loc) • 14.9 kB
import { CloudAPIError } from "./AssistantCloudAPI.js"; // src/CloudMessagePersistence.ts const CLOUD_MESSAGE_PAGE_SIZE = 200; const isOpen = (run) => typeof run === "object"; /** How long a running lease may go without a heartbeat before its message reads as stale. */ const DEFAULT_RUN_STALE_AFTER_MS = 3e4; /** 409 codes after which the lease is gone for good: nothing more is written for its message. */ const LOST_LEASE_CODES = /* @__PURE__ */ new Set(["run_ended", "lease_mismatch"]); const lostLeaseCode = (error) => error instanceof CloudAPIError && error.status === 409 && error.code !== void 0 && LOST_LEASE_CODES.has(error.code) ? error.code : void 0; const isRunUnsupported = (error) => error instanceof CloudAPIError && error.status === 400; /** * Appends, updates and loads cloud messages while mapping local ids to cloud * ids and chaining parent_id. A parent that is still being created is awaited, * so concurrent appends land under the right parent. A parent whose create * failed is created again before its child, and a child whose parent still * cannot be created is stored as a root. * * A message appended as running holds a run lease, renewed by heartbeats on * the server's interval until a write with an end reason ends it. Heartbeats * keep running while the page is in the background. The server never * expires a lease; readers show a run as stale once it has gone `staleAfter` * without a beat, and a late run keeps writing its own message. A lease that * is lost (ended elsewhere or refused) is final: the message gets no further * writes from this persistence. */ var CloudMessagePersistence = class { idMapping = /* @__PURE__ */ new Map(); failedAppends = /* @__PURE__ */ new Map(); /** Every message this persistence opened a lease for; a repeated append of one writes its row instead of storing another. */ runs = /* @__PURE__ */ new Map(); runWrites = /* @__PURE__ */ new Map(); /** Writes waiting on an in-flight create, chained so they apply in append order. */ pendingWrites = /* @__PURE__ */ new Map(); /** The deletes each write waiting on an in-flight create saw start; each resolves to whether it deleted the message. */ waiters = /* @__PURE__ */ new Map(); getCloud; staleAfter; constructor(cloud, options = {}) { this.getCloud = typeof cloud === "function" ? cloud : () => cloud; this.staleAfter = options.staleAfter ?? 3e4; } /** * Reads a listed message's run against `staleAfter`; `sinceReadMs` is the * time since the list was read. Undefined for no run or an ended one. */ runStateOf(run, sinceReadMs = 0) { if (run?.status !== "running") return void 0; const quietMs = run.last_heartbeat_ms_ago + sinceReadMs; return quietMs >= this.staleAfter ? { status: "stale" } : { status: "in-progress", staleInMs: this.staleAfter - quietMs }; } /** * Persist a message to the cloud. * * @param threadId - Remote thread ID * @param messageId - Local message ID (used for tracking) * @param parentId - Local parent message ID (or null for first message) * @param format - Message format (e.g., "aui/v0", "ai-sdk/v6") * @param content - Message content (format-specific) * @param run - `running` stores the message with an open run lease that is * kept alive by heartbeats until a write with an end reason closes it. * Once the lease is lost (it was ended elsewhere or the server refused * it), later writes for the message are dropped. */ async append(threadId, messageId, parentId, format, content, run) { if (this.runs.get(messageId) === "lost") return; const cloud = this.getCloud(); const existing = this.idMapping.get(messageId); if (existing instanceof Promise) { const mapping = this.idMapping; const deletes = []; const waiters = this.waiters.get(messageId) ?? /* @__PURE__ */ new Set(); waiters.add(deletes); this.waiters.set(messageId, waiters); const write = (this.pendingWrites.get(messageId) ?? existing).catch(() => void 0).then(async () => { for (let i = 0; i < deletes.length; i++) if (await deletes[i]) return; if (this.idMapping !== mapping) return; if (this.idMapping.has(messageId)) await this.update(threadId, messageId, format, content, run); else await this.append(threadId, messageId, parentId, format, content, run); }); this.pendingWrites.set(messageId, write); try { await write; } finally { if (this.pendingWrites.get(messageId) === write) this.pendingWrites.delete(messageId); waiters.delete(deletes); if (waiters.size === 0 && this.waiters.get(messageId) === waiters) this.waiters.delete(messageId); } return; } if (existing !== void 0 && this.runs.has(messageId)) { await this.update(threadId, messageId, format, content, run); return; } this.failedAppends.delete(messageId); const task = (async () => { const body = { parent_id: parentId ? await this.resolveParentId(parentId) : null, ...messageId.length > 0 && messageId.length <= 255 ? { external_id: messageId } : void 0, format, content }; if (run !== "running") return (await cloud.threads.messages.create(threadId, body)).message_id; let created; try { created = await cloud.threads.messages.create(threadId, { ...body, open_run: true }); } catch (error) { if (!isRunUnsupported(error)) throw error; created = await cloud.threads.messages.create(threadId, body); } if (created.run) this.openRun(threadId, messageId, created.run); return created.message_id; })(); this.idMapping.set(messageId, task); try { const remoteId = await task; if (this.idMapping.get(messageId) === task) this.idMapping.set(messageId, remoteId); } catch (err) { if (this.idMapping.get(messageId) === task) { this.idMapping.delete(messageId); this.failedAppends.set(messageId, { threadId, parentId, format, content }); } throw err; } } async resolveParentId(parentId) { await Promise.resolve(this.idMapping.get(parentId)).catch(() => void 0); const failedParent = this.failedAppends.get(parentId); try { if (failedParent && !this.idMapping.has(parentId)) await this.append(failedParent.threadId, parentId, failedParent.parentId, failedParent.format, failedParent.content); return await this.idMapping.get(parentId) ?? parentId; } catch { return null; } } /** * Update an already-persisted message in the cloud. */ async update(threadId, messageId, _format, content, run) { if (this.runs.get(messageId) === "lost") return; const remoteId = await this.getRemoteId(messageId); if (this.runs.get(messageId) === "lost") return; if (!remoteId) { console.warn(`Skipping update for message ${messageId}: no remote id is mapped.`); return; } if (!this.runs.has(messageId)) { await this.getCloud().threads.messages.update(threadId, remoteId, { content }); return; } const task = (this.runWrites.get(messageId) ?? Promise.resolve()).catch(() => void 0).then(() => this.writeRun(threadId, messageId, remoteId, content, run ?? "done")); this.runWrites.set(messageId, task); try { await task; } finally { if (this.runWrites.get(messageId) === task) this.runWrites.delete(messageId); } } async writeRun(threadId, messageId, remoteId, content, phase) { const open = this.runs.get(messageId); if (open === "lost") return; const cloud = this.getCloud(); if (!isOpen(open)) { if (phase === "running") return; await cloud.threads.messages.update(threadId, remoteId, { content }); return; } try { await cloud.threads.messages.update(open.threadId, remoteId, { content, lease_id: open.leaseId }); } catch (error) { const code = lostLeaseCode(error); if (!code) throw error; this.loseRun(messageId, code); return; } if (phase === "running") { this.scheduleHeartbeat(messageId); return; } this.closeRun(messageId, "ended"); await cloud.threads.runs.end(open.threadId, open.leaseId, { reason: phase }).catch((error) => { if (!(error instanceof CloudAPIError) || error.status >= 500) throw error; }); } /** * Moves a stored message's id mapping and run lease to a new local id, for * runtimes that swap a placeholder's id once the backend names the message. */ async rename(from, to) { const entry = this.idMapping.get(from); if (entry === void 0 || this.idMapping.has(to)) return; await Promise.resolve(entry).catch(() => void 0); const remoteId = this.idMapping.get(from); if (typeof remoteId !== "string" || this.idMapping.has(to)) return; this.idMapping.delete(from); this.idMapping.set(to, remoteId); const run = this.runs.get(from); if (run === void 0) return; this.runs.delete(from); this.runs.set(to, run); if (isOpen(run)) this.scheduleHeartbeat(to); } /** Whether a run lease this persistence opened for the message is still held. */ hasOpenRun(messageId) { return isOpen(this.runs.get(messageId)); } openRun(threadId, messageId, lease) { const previous = this.runs.get(messageId); if (isOpen(previous) && previous.timer !== void 0) clearTimeout(previous.timer); this.runs.set(messageId, { threadId, leaseId: lease.lease_id, intervalMs: lease.heartbeat_interval_ms, timer: void 0 }); this.scheduleHeartbeat(messageId); } scheduleHeartbeat(messageId) { const open = this.runs.get(messageId); if (!isOpen(open)) return; if (open.timer !== void 0) clearTimeout(open.timer); open.timer = setTimeout(() => { open.timer = void 0; this.heartbeat(messageId, open); }, open.intervalMs); } async heartbeat(messageId, open) { if (this.runs.get(messageId) !== open) return; try { open.intervalMs = (await this.getCloud().threads.runs.heartbeat(open.threadId, open.leaseId)).heartbeat_interval_ms; } catch (error) { if (this.runs.get(messageId) !== open) return; if (error instanceof CloudAPIError && (error.status === 409 || error.status === 404)) { this.loseRun(messageId, error.code ?? String(error.status)); return; } } if (this.runs.get(messageId) === open) this.scheduleHeartbeat(messageId); } /** The lease is gone for good: stop heartbeating and drop every later write for the message. */ loseRun(messageId, reason) { if (this.runs.get(messageId) === "lost") return; this.closeRun(messageId, "lost"); console.warn(`[assistant-cloud] Lost the run lease of message ${messageId} (${reason}); later writes for it are dropped.`); } closeRun(messageId, as) { const open = this.runs.get(messageId); if (isOpen(open) && open.timer !== void 0) clearTimeout(open.timer); this.runs.set(messageId, as); } /** * Delete mapped messages from the cloud and clear their local ID mappings. */ async delete(threadId, messageIds) { let settle; const deleting = new Promise((resolve) => { settle = resolve; }); for (const localId of messageIds) for (const deletes of this.waiters.get(localId) ?? []) deletes.push(deleting); let deleted = false; try { await this.deleteMessages(threadId, messageIds); deleted = true; } finally { settle(deleted); } } async deleteMessages(threadId, messageIds) { const cloud = this.getCloud(); const mapping = this.idMapping; const resolved = []; for (const localId of messageIds) { const entry = mapping.get(localId); if (!entry) continue; try { resolved.push({ localId, entry, remoteId: await entry }); } catch { continue; } } for (const localId of messageIds) this.failedAppends.delete(localId); if (resolved.length === 0) return; try { await cloud.threads.messages.delete(threadId, resolved.map(({ remoteId }) => remoteId)); } catch (error) { if (!(error instanceof CloudAPIError && error.status === 404)) throw error; } for (const { localId, entry, remoteId } of resolved) { const current = mapping.get(localId); if (current === entry || current === remoteId) mapping.delete(localId); } } /** * Check if a message has been persisted (or is currently being persisted). */ isPersisted(messageId) { return this.idMapping.has(messageId); } /** * Get the remote ID for a local message ID (resolved). * Returns undefined if not persisted. */ async getRemoteId(messageId) { const entry = this.idMapping.get(messageId); if (!entry) return void 0; return entry; } getResolvedRemoteId(messageId) { const entry = this.idMapping.get(messageId); return typeof entry === "string" ? entry : void 0; } record(localId, remoteId) { this.idMapping.set(localId, remoteId); } /** * Load messages from the cloud and populate the ID mapping. * * The list endpoint caps a response at 200 rows, so pages are followed by * message ID cursor until a short page and concatenated in server order. * * The ID mapping is populated so that `isPersisted()` returns true for * loaded messages, preventing re-persistence of already-stored messages. * * A loaded ID that an append already maps keeps the remote ID from that append, and falls back to the loaded ID if the append fails. * * @param threadId - Remote thread ID * @param format - Optional format filter * @returns Array of cloud messages */ async load(threadId, format) { const idMapping = this.idMapping; const cloud = this.getCloud(); const messages = []; const seen = /* @__PURE__ */ new Set(); let after; while (true) { const page = await cloud.threads.messages.list(threadId, { ...format ? { format } : void 0, limit: CLOUD_MESSAGE_PAGE_SIZE, ...after ? { after } : void 0 }); const last = page.messages.at(-1); if (!last) break; const fresh = page.messages.filter((m) => !seen.has(m.id)); if (fresh.length === 0) break; for (const m of fresh) seen.add(m.id); messages.push(...fresh); if (page.messages.length < CLOUD_MESSAGE_PAGE_SIZE) break; after = last.id; } if (this.idMapping === idMapping) for (const m of messages) { const entry = idMapping.get(m.id); if (entry === void 0) idMapping.set(m.id, m.id); else if (entry instanceof Promise) entry.catch(() => { const current = idMapping.get(m.id); if (current === void 0 || current === entry) idMapping.set(m.id, m.id); }); } return messages; } /** * Reset the ID mapping (call when switching threads). * * Pending `load()` and `append()` calls are not cancelled and still settle * normally, but their results no longer populate the ID mapping. */ reset() { this.idMapping = /* @__PURE__ */ new Map(); this.failedAppends = /* @__PURE__ */ new Map(); this.pendingWrites = /* @__PURE__ */ new Map(); this.waiters = /* @__PURE__ */ new Map(); for (const run of this.runs.values()) if (isOpen(run) && run.timer !== void 0) clearTimeout(run.timer); this.runs = /* @__PURE__ */ new Map(); } }; export { CloudMessagePersistence, DEFAULT_RUN_STALE_AFTER_MS }; //# sourceMappingURL=CloudMessagePersistence.js.map