UNPKG

assistant-cloud

Version:

Cloud integration for assistant-ui

593 lines (550 loc) • 20.6 kB
import type { ReadonlyJSONObject } from "assistant-stream/utils"; import type { AssistantCloud } from "./AssistantCloud"; import type { CloudMessage, CloudMessageRun, CloudRunLease, } from "./AssistantCloudThreadMessages"; import { CloudAPIError } from "./AssistantCloudAPI"; import type { CloudRunEndReason } from "./AssistantCloudThreadRuns"; const CLOUD_MESSAGE_PAGE_SIZE = 200; /** How a write relates to the message's run: `running` opens or renews its lease, a reason ends it. */ export type CloudMessageRunPhase = "running" | CloudRunEndReason; type OpenRun = { threadId: string; leaseId: string; intervalMs: number; timer: ReturnType<typeof setTimeout> | undefined; }; /** * A message's run lease as this persistence knows it: held, ended by our own * final write, or lost (ended elsewhere or refused). A lost run's message gets no * further writes; an ended one only plain writes. */ type Run = OpenRun | "ended" | "lost"; const isOpen = (run: Run | undefined): run is OpenRun => typeof run === "object"; /** How long a running lease may go without a heartbeat before its message reads as stale. */ export const DEFAULT_RUN_STALE_AFTER_MS = 30_000; export type CloudMessagePersistenceOptions = { /** * Milliseconds without a heartbeat after which a running lease reads as * stale, which is what a run whose tab went away looks like. Default * 30 000. The server never expires a run: a late run that beats again * reads as running again, and its writes still land. */ staleAfter?: number | undefined; }; /** Where a listed message's run stands for a reader: still beating, or quiet for longer than `staleAfter`. */ export type CloudRunState = | { status: "in-progress"; staleInMs: number } | { status: "stale" }; /** 409 codes after which the lease is gone for good: nothing more is written for its message. */ const LOST_LEASE_CODES = new Set(["run_ended", "lease_mismatch"]); const lostLeaseCode = (error: unknown): string | undefined => error instanceof CloudAPIError && error.status === 409 && error.code !== undefined && LOST_LEASE_CODES.has(error.code) ? error.code : undefined; // A server that predates run leases rejects the unknown `open_run` key of its strict create schema. const isRunUnsupported = (error: unknown) => error instanceof CloudAPIError && error.status === 400; type FailedAppend = { threadId: string; parentId: string | null; format: string; content: ReadonlyJSONObject; }; /** * 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. */ export class CloudMessagePersistence { private idMapping = new Map<string, string | Promise<string>>(); private failedAppends = new Map<string, FailedAppend>(); /** Every message this persistence opened a lease for; a repeated append of one writes its row instead of storing another. */ private runs = new Map<string, Run>(); private runWrites = new Map<string, Promise<unknown>>(); /** Writes waiting on an in-flight create, chained so they apply in append order. */ private pendingWrites = new Map<string, Promise<void>>(); /** The deletes each write waiting on an in-flight create saw start; each resolves to whether it deleted the message. */ private waiters = new Map<string, Set<Promise<boolean>[]>>(); private getCloud: () => AssistantCloud; private staleAfter: number; constructor(cloud: AssistantCloud, options?: CloudMessagePersistenceOptions); constructor( getCloud: () => AssistantCloud, options?: CloudMessagePersistenceOptions, ); constructor( cloud: AssistantCloud | (() => AssistantCloud), options: CloudMessagePersistenceOptions = {}, ) { this.getCloud = typeof cloud === "function" ? cloud : () => cloud; this.staleAfter = options.staleAfter ?? DEFAULT_RUN_STALE_AFTER_MS; } /** * 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: CloudMessageRun | undefined, sinceReadMs = 0, ): CloudRunState | undefined { if (run?.status !== "running") return undefined; 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: string, messageId: string, parentId: string | null, format: string, content: ReadonlyJSONObject, run?: CloudMessageRunPhase, ): Promise<void> { 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: Promise<boolean>[] = []; const waiters = this.waiters.get(messageId) ?? new Set(); waiters.add(deletes); this.waiters.set(messageId, waiters); const previous = this.pendingWrites.get(messageId) ?? existing; // The in-flight create may hold older content, such as the run tracker's running placeholder: write this content over its row, or create the row with it when that create failed. A reset while waiting, or a delete of the message that started while waiting and succeeded, drops the write; an append made after a delete started is newer than that delete and still lands. const write = previous .catch(() => undefined) .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 !== undefined && this.runs.has(messageId)) { await this.update(threadId, messageId, format, content, run); return; } this.failedAppends.delete(messageId); const task = (async () => { const resolvedParentId = parentId ? await this.resolveParentId(parentId) : null; const body = { parent_id: resolvedParentId, ...(messageId.length > 0 && messageId.length <= 255 ? { external_id: messageId } : undefined), format, content, }; if (run !== "running") { return (await cloud.threads.messages.create(threadId, body)).message_id; } let created: Awaited<ReturnType<typeof cloud.threads.messages.create>>; 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; } } private async resolveParentId(parentId: string): Promise<string | null> { await Promise.resolve(this.idMapping.get(parentId)).catch(() => undefined); 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: string, messageId: string, _format: string, content: ReadonlyJSONObject, run?: CloudMessageRunPhase, ): Promise<void> { 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; } // Writes of a leased message are serialized so a checkpoint can never land after the write that ends it. const previous = this.runWrites.get(messageId) ?? Promise.resolve(); const task = previous .catch(() => undefined) .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); } } private async writeRun( threadId: string, messageId: string, remoteId: string, content: ReadonlyJSONObject, phase: CloudMessageRunPhase, ): Promise<void> { const open = this.runs.get(messageId); if (open === "lost") return; const cloud = this.getCloud(); if (!isOpen(open)) { // Our own end closed the lease under an earlier write: a checkpoint is dropped, a final answer lands as a plain write. 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: unknown) => { 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: string, to: string): Promise<void> { const entry = this.idMapping.get(from); if (entry === undefined || this.idMapping.has(to)) return; await Promise.resolve(entry).catch(() => undefined); 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 === undefined) 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: string): boolean { return isOpen(this.runs.get(messageId)); } private openRun(threadId: string, messageId: string, lease: CloudRunLease) { const previous = this.runs.get(messageId); if (isOpen(previous) && previous.timer !== undefined) clearTimeout(previous.timer); this.runs.set(messageId, { threadId, leaseId: lease.lease_id, intervalMs: lease.heartbeat_interval_ms, timer: undefined, }); this.scheduleHeartbeat(messageId); } private scheduleHeartbeat(messageId: string) { const open = this.runs.get(messageId); if (!isOpen(open)) return; if (open.timer !== undefined) clearTimeout(open.timer); open.timer = setTimeout(() => { open.timer = undefined; void this.heartbeat(messageId, open); }, open.intervalMs); } private async heartbeat(messageId: string, open: OpenRun) { if (this.runs.get(messageId) !== open) return; try { const response = await this.getCloud().threads.runs.heartbeat( open.threadId, open.leaseId, ); open.intervalMs = response.heartbeat_interval_ms; } catch (error) { if (this.runs.get(messageId) !== open) return; // An ended lease, or one the server does not know, is final; a network error is retried on the next beat. 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. */ private loseRun(messageId: string, reason: string) { 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.`, ); } private closeRun(messageId: string, as: "ended" | "lost") { const open = this.runs.get(messageId); if (isOpen(open) && open.timer !== undefined) clearTimeout(open.timer); this.runs.set(messageId, as); } /** * Delete mapped messages from the cloud and clear their local ID mappings. */ async delete(threadId: string, messageIds: string[]): Promise<void> { let settle!: (deleted: boolean) => void; const deleting = new Promise<boolean>((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); } } private async deleteMessages( threadId: string, messageIds: string[], ): Promise<void> { const cloud = this.getCloud(); const mapping = this.idMapping; const resolved: { localId: string; entry: string | Promise<string>; remoteId: string; }[] = []; 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: string): boolean { return this.idMapping.has(messageId); } /** * Get the remote ID for a local message ID (resolved). * Returns undefined if not persisted. */ async getRemoteId(messageId: string): Promise<string | undefined> { const entry = this.idMapping.get(messageId); if (!entry) return undefined; return entry; } getResolvedRemoteId(messageId: string): string | undefined { const entry = this.idMapping.get(messageId); return typeof entry === "string" ? entry : undefined; } record(localId: string, remoteId: string): void { 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: string, format?: string) { const idMapping = this.idMapping; const cloud = this.getCloud(); const messages: CloudMessage[] = []; const seen = new Set<string>(); let after: string | undefined; while (true) { const page = await cloud.threads.messages.list(threadId, { ...(format ? { format } : undefined), limit: CLOUD_MESSAGE_PAGE_SIZE, ...(after ? { after } : undefined), }); const last = page.messages.at(-1); if (!last) break; // A cursor the server cannot resolve drops the keyset filter and replays // an earlier page, so already-seen rows end the walk instead of repeating. 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 === undefined) { idMapping.set(m.id, m.id); } else if (entry instanceof Promise) { void entry.catch(() => { const current = idMapping.get(m.id); if (current === undefined || 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 = new Map(); this.failedAppends = new Map(); this.pendingWrites = new Map(); this.waiters = new Map(); for (const run of this.runs.values()) if (isOpen(run) && run.timer !== undefined) clearTimeout(run.timer); this.runs = new Map(); } }