assistant-cloud
Version:
Cloud integration for assistant-ui
593 lines (550 loc) • 20.6 kB
text/typescript
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();
}
}