UNPKG

@tanstack/ai-sandbox

Version:

Provider-agnostic sandbox layer for TanStack AI — run harness adapters inside isolated sandboxes (defineSandbox, defineWorkspace, withSandbox) with a uniform SandboxHandle, workspace bootstrap, policy, and resumable lifecycle.

937 lines (900 loc) 32.3 kB
import type { ModelMessage } from '@tanstack/ai' import type { MemoryArtifactRecord as ArtifactRecord, MemoryBlobBody as BlobBody, MemoryBlobRecord as BlobRecord, MemoryGenerationRunRecord as GenerationRunRecord, MemoryInterruptRecord as InterruptRecord, MemoryRunRecord as RunRecord, MemorySnapshotPersistence, } from './memory-snapshot-types' import { SandboxCheckpointConflictError, SandboxCheckpointDuplicateIdError, SandboxCheckpointError, SandboxCheckpointInvalidEntryError, SandboxCheckpointInvalidIdError, SandboxCheckpointNotHeadError, SandboxCheckpointParentMismatchError, SandboxCheckpointWriterConflictError, SandboxCheckpointWriterLostError, } from './checkpoint-store' import type { ForkCapableSandboxCheckpointStore, SandboxCheckpoint, SandboxCheckpointWriter, SandboxCheckpointWriterLease, SandboxCheckpointForkInput, } from './checkpoint-store' import { createSandboxSnapshots } from './snapshot-operations' import type { CreateSandboxSnapshotsInput, SandboxSnapshots, } from './snapshot-operations' type BlobGetOptions = { range?: { offset: number; length?: number } } function resolveBlobRange( size: number, range: { offset: number; length?: number }, ): { offset: number; length: number } { if ( !Number.isInteger(range.offset) || range.offset < 0 || range.offset >= size ) { throw new RangeError( `Blob range offset ${range.offset} is outside the object (size ${size}).`, ) } const remaining = size - range.offset if (range.length === undefined) { return { offset: range.offset, length: remaining } } if (!Number.isInteger(range.length) || range.length < 0) { throw new RangeError(`Blob range length ${range.length} is not valid.`) } return { offset: range.offset, length: Math.min(range.length, remaining), } } export type MemorySandboxSnapshots = SandboxSnapshots< MemorySnapshotPersistence, ForkCapableSandboxCheckpointStore > export type MemorySandboxSnapshotsOptions = Omit< CreateSandboxSnapshotsInput< MemorySnapshotPersistence, ForkCapableSandboxCheckpointStore >, 'persistence' | 'checkpoints' > const encoder = new TextEncoder() const compare = (a: string, b: string) => { const left = encoder.encode(a) const right = encoder.encode(b) for (let i = 0; i < Math.min(left.length, right.length); i++) { const leftByte = left[i] const rightByte = right[i] if (leftByte !== rightByte) return (leftByte ?? 0) - (rightByte ?? 0) } return left.length - right.length } const clone = <T>(value: T): T => structuredClone(value) interface MemoryCheckpointState { checkpoints: Map<string, SandboxCheckpoint> heads: Map<string, string> writers: Map<string, { ownerToken: string; fence: number; expiresAt: number }> fences: Map<string, number> references: Map<string, number> } interface MemorySnapshotState extends MemoryCheckpointState { messages: Map<string, Array<ModelMessage>> runs: Map<string, RunRecord> generations: Map<string, GenerationRunRecord> interrupts: Map<string, InterruptRecord> metadata: Map<string, Map<string, unknown>> artifacts: Map<string, ArtifactRecord> blobs: Map<string, { record: BlobRecord; bytes: Uint8Array }> } function hasUnpairedSurrogate(value: string): boolean { for (let index = 0; index < value.length; index++) { const code = value.charCodeAt(index) if (code >= 0xd800 && code <= 0xdbff) { const next = value.charCodeAt(index + 1) if (Number.isNaN(next) || next < 0xdc00 || next > 0xdfff) return true index++ } else if (code >= 0xdc00 && code <= 0xdfff) { return true } } return false } function assertValidIdentifier( value: unknown, label: string, ): asserts value is string { if ( typeof value !== 'string' || value.length === 0 || hasUnpairedSurrogate(value) ) { throw new SandboxCheckpointInvalidIdError( `${label} must be a non-empty well-formed Unicode string`, ) } } function hasOwn(value: object, key: string): boolean { return Object.prototype.hasOwnProperty.call(value, key) } function validateEntries(checkpoint: SandboxCheckpoint): void { if (!Array.isArray(checkpoint.files)) { throw new SandboxCheckpointInvalidEntryError( 'Checkpoint files must be an array', ) } const paths = new Set<string>() const kinds = new Map<string, 'file' | 'dir'>() for (const entry of checkpoint.files as ReadonlyArray<unknown>) { if (entry === null || typeof entry !== 'object') { throw new SandboxCheckpointInvalidEntryError( 'Checkpoint entry must be an object', ) } const candidate = entry as Record<string, unknown> if ( typeof candidate.path !== 'string' || candidate.path.length === 0 || candidate.path.includes('\0') || candidate.path.startsWith('/') || candidate.path.startsWith('\\') || /^[A-Za-z]:([\\/]|$)/.test(candidate.path) || candidate.path.includes('\\') || candidate.path .split('/') .some((part) => part.length === 0 || part === '.' || part === '..') ) { throw new SandboxCheckpointInvalidEntryError( 'Checkpoint entry path must be a normalized workspace-relative path', ) } const path = candidate.path if (paths.has(path)) { throw new SandboxCheckpointInvalidEntryError( `Checkpoint contains duplicate entry path '${path}'`, ) } for ( let separator = path.indexOf('/'); separator !== -1; separator = path.indexOf('/', separator + 1) ) { const ancestor = path.slice(0, separator) if (kinds.get(ancestor) === 'file') { throw new SandboxCheckpointInvalidEntryError( `Checkpoint entry '${path}' is beneath file '${ancestor}'`, ) } } if ( candidate.kind === 'file' && Array.from(kinds.keys()).some((other) => other.startsWith(`${path}/`)) ) { throw new SandboxCheckpointInvalidEntryError( `Checkpoint file '${path}' is an ancestor of another entry`, ) } paths.add(path) if (candidate.kind === 'file') { if ( typeof candidate.blobKey !== 'string' || candidate.blobKey.length === 0 || hasUnpairedSurrogate(candidate.blobKey) || !/^sandbox-files\/sha256\/[0-9a-f]{64}$/.test(candidate.blobKey) ) { throw new SandboxCheckpointInvalidEntryError( 'File entries require a valid content-addressed blobKey', ) } if ( !hasOwn(candidate, 'size') || typeof candidate.size !== 'number' || !Number.isSafeInteger(candidate.size) || candidate.size < 0 ) { throw new SandboxCheckpointInvalidEntryError( 'File entry size must be a non-negative safe integer', ) } } else if (candidate.kind === 'dir') { if (hasOwn(candidate, 'blobKey') || hasOwn(candidate, 'size')) { throw new SandboxCheckpointInvalidEntryError( 'Directory entries cannot contain file fields', ) } } else { throw new SandboxCheckpointInvalidEntryError( 'Checkpoint entry kind must be file or dir', ) } kinds.set(path, candidate.kind) } } function validateArtifacts(checkpoint: SandboxCheckpoint): void { if (!Array.isArray(checkpoint.artifacts)) { throw new SandboxCheckpointInvalidEntryError( 'Checkpoint artifacts must be an array', ) } for (const artifact of checkpoint.artifacts as ReadonlyArray<unknown>) { if (artifact === null || typeof artifact !== 'object') { throw new SandboxCheckpointInvalidEntryError( 'Checkpoint artifact must be an object', ) } const candidate = artifact as Record<string, unknown> if ( typeof candidate.artifactId !== 'string' || candidate.artifactId.length === 0 || hasUnpairedSurrogate(candidate.artifactId) || typeof candidate.name !== 'string' || candidate.name.length === 0 || typeof candidate.mimeType !== 'string' || candidate.mimeType.length === 0 || typeof candidate.blobKey !== 'string' || candidate.blobKey.length === 0 || hasUnpairedSurrogate(candidate.blobKey) || !/^sandbox-artifacts\/sha256\/[0-9a-f]{64}$/.test(candidate.blobKey) || typeof candidate.size !== 'number' || !Number.isSafeInteger(candidate.size) || candidate.size < 0 || typeof candidate.createdAt !== 'number' || !Number.isFinite(candidate.createdAt) ) { throw new SandboxCheckpointInvalidEntryError( 'Checkpoint artifact has invalid fields', ) } } } function validateCheckpoint(checkpoint: SandboxCheckpoint): void { assertValidIdentifier(checkpoint.id, 'Checkpoint id') assertValidIdentifier(checkpoint.threadId, 'Checkpoint thread id') if (checkpoint.parentCheckpointId !== null) { assertValidIdentifier(checkpoint.parentCheckpointId, 'Parent checkpoint id') } if (!Number.isFinite(checkpoint.createdAt)) { throw new SandboxCheckpointInvalidEntryError( 'Checkpoint createdAt must be a finite number', ) } validateEntries(checkpoint) validateArtifacts(checkpoint) } function blobKeys(checkpoint: SandboxCheckpoint): Set<string> { const keys = new Set<string>() for (const entry of checkpoint.files) { if (entry.kind === 'file') keys.add(entry.blobKey) } for (const artifact of checkpoint.artifacts) keys.add(artifact.blobKey) return keys } class MemorySnapshotCheckpointStore implements ForkCapableSandboxCheckpointStore { private readonly now = () => Date.now() private readonly leaseDurationMs = 120_000 private readonly renewAfterMs = 45_000 constructor(private readonly state: MemorySnapshotState) {} async get(id: string): Promise<SandboxCheckpoint | null> { assertValidIdentifier(id, 'Checkpoint id') const checkpoint = this.state.checkpoints.get(id) return checkpoint ? clone(checkpoint) : null } async list(threadId: string): Promise<Array<SandboxCheckpoint>> { assertValidIdentifier(threadId, 'Thread id') return [...this.state.checkpoints.values()] .filter((checkpoint) => checkpoint.threadId === threadId) .sort((a, b) => a.createdAt - b.createdAt || compare(a.id, b.id)) .map(clone) } async getHead(threadId: string): Promise<string | null> { assertValidIdentifier(threadId, 'Thread id') return this.state.heads.get(threadId) ?? null } async append(input: { checkpoint: SandboxCheckpoint expectedHeadId: string | null writer: SandboxCheckpointWriter }): Promise<{ headId: string }> { const checkpoint = clone(input.checkpoint) const { expectedHeadId, writer } = input assertValidIdentifier(checkpoint.id, 'Checkpoint id') assertValidIdentifier(checkpoint.threadId, 'Checkpoint thread id') assertValidIdentifier(writer.threadId, 'Writer thread id') if (expectedHeadId !== null) { assertValidIdentifier(expectedHeadId, 'Expected head id') } if (checkpoint.parentCheckpointId != null) { assertValidIdentifier( checkpoint.parentCheckpointId, 'Parent checkpoint id', ) } if (writer.threadId !== checkpoint.threadId) { throw new SandboxCheckpointWriterLostError( 'Checkpoint writer thread does not match checkpoint thread', ) } validateCheckpoint(checkpoint) // Re-check live state after staging because cloning caller data may // re-enter append and advance this thread's head. this.assertWriter(writer, checkpoint.threadId) if (this.state.checkpoints.has(checkpoint.id)) { throw new SandboxCheckpointDuplicateIdError( `Checkpoint '${checkpoint.id}' already exists`, ) } const actualHeadId = this.state.heads.get(checkpoint.threadId) ?? null if (actualHeadId !== expectedHeadId) { throw new SandboxCheckpointConflictError( `Expected head '${expectedHeadId}', but thread '${checkpoint.threadId}' is at '${actualHeadId}'`, ) } const parentCheckpointId = checkpoint.parentCheckpointId ?? null if (parentCheckpointId !== expectedHeadId) { throw new SandboxCheckpointParentMismatchError( `Checkpoint '${checkpoint.id}' parent does not match expected head`, ) } const stored = { ...checkpoint, parentCheckpointId } const keys = blobKeys(stored) this.state.checkpoints.set(stored.id, stored) this.state.heads.set(stored.threadId, stored.id) for (const key of keys) { this.state.references.set(key, (this.state.references.get(key) ?? 0) + 1) } return { headId: stored.id } } async deleteHead(input: { threadId: string checkpointId: string writer: SandboxCheckpointWriter }): Promise<void> { const { threadId, checkpointId, writer } = input assertValidIdentifier(threadId, 'Thread id') assertValidIdentifier(checkpointId, 'Checkpoint id') assertValidIdentifier(writer.threadId, 'Writer thread id') if (writer.threadId !== threadId) { throw new SandboxCheckpointWriterLostError( 'Checkpoint writer thread does not match operation thread', ) } this.assertWriter(writer, threadId) if ((this.state.heads.get(threadId) ?? null) !== checkpointId) { throw new SandboxCheckpointNotHeadError( `Checkpoint '${checkpointId}' is not the current head of thread '${threadId}'`, ) } const checkpoint = this.state.checkpoints.get(checkpointId) if (!checkpoint) { throw new SandboxCheckpointNotHeadError( `Checkpoint '${checkpointId}' does not exist`, ) } this.state.checkpoints.delete(checkpointId) if (checkpoint.parentCheckpointId) { this.state.heads.set(threadId, checkpoint.parentCheckpointId) } else { this.state.heads.delete(threadId) } for (const key of blobKeys(checkpoint)) { const references = (this.state.references.get(key) ?? 0) - 1 if (references > 0) this.state.references.set(key, references) else this.state.references.delete(key) } } async acquireWriter(threadId: string): Promise<SandboxCheckpointWriterLease> { assertValidIdentifier(threadId, 'Thread id') const current = this.state.writers.get(threadId) if (current && current.expiresAt > this.now()) { throw new SandboxCheckpointWriterConflictError( `Thread '${threadId}' already has an active checkpoint writer`, ) } const fence = (this.state.fences.get(threadId) ?? 0) + 1 this.state.fences.set(threadId, fence) const ownerToken = globalThis.crypto.randomUUID() const lease = { threadId, ownerToken, fence, expiresAt: this.now() + this.leaseDurationMs, } this.state.writers.set(threadId, lease) return { ...lease, get expiresAt() { return lease.expiresAt }, renewAfterMs: this.renewAfterMs, renew: async () => { this.assertWriter(lease, threadId) lease.expiresAt = this.now() + this.leaseDurationMs return { expiresAt: lease.expiresAt } }, release: async () => { const currentLease = this.state.writers.get(threadId) if ( currentLease?.ownerToken === ownerToken && currentLease.fence === fence ) { this.state.writers.delete(threadId) } }, } } async listBlobReferences(): Promise< Array<{ key: string; references: number }> > { return [...this.state.references.entries()] .sort(([a], [b]) => compare(a, b)) .map(([key, references]) => ({ key, references })) } async forkFromCheckpoint( input: SandboxCheckpointForkInput, ): Promise<{ checkpoint: SandboxCheckpoint }> { const sourceThreadId = input.sourceThreadId const sourceCheckpointId = input.sourceCheckpointId const destinationThreadId = input.destinationThreadId const destinationCheckpointId = input.destinationCheckpointId const createdAt = input.createdAt const suppliedWriter = input.writer const writer: SandboxCheckpointWriter = { threadId: suppliedWriter.threadId, ownerToken: suppliedWriter.ownerToken, fence: suppliedWriter.fence, } assertValidIdentifier(sourceThreadId, 'Source thread id') assertValidIdentifier(sourceCheckpointId, 'Source checkpoint id') assertValidIdentifier(destinationThreadId, 'Destination thread id') assertValidIdentifier(destinationCheckpointId, 'Destination checkpoint id') assertValidIdentifier(writer.threadId, 'Writer thread id') if (!Number.isFinite(createdAt)) { throw new SandboxCheckpointInvalidEntryError( 'Fork checkpoint createdAt must be a finite number', ) } if (sourceThreadId === destinationThreadId) { throw new SandboxCheckpointError( 'SANDBOX_SNAPSHOT_FORK_SOURCE_THREAD_MISMATCH', 'Source and destination threads must differ', ) } const source = this.state.checkpoints.get(sourceCheckpointId) if (!source) { throw new SandboxCheckpointError( 'SANDBOX_SNAPSHOT_FORK_SOURCE_NOT_FOUND', 'Source checkpoint was not found', ) } if (source.threadId !== sourceThreadId) { throw new SandboxCheckpointError( 'SANDBOX_SNAPSHOT_FORK_SOURCE_THREAD_MISMATCH', 'Source checkpoint belongs to another thread', ) } if (writer.threadId !== destinationThreadId) { throw new SandboxCheckpointWriterLostError( 'Checkpoint writer thread does not match destination thread', ) } this.assertWriter(writer, destinationThreadId) this.assertDestinationEmpty(destinationThreadId, destinationCheckpointId) const stagedCheckpoint: SandboxCheckpoint = clone({ id: destinationCheckpointId, threadId: destinationThreadId, parentCheckpointId: null, createdAt, reason: 'fork-root', files: source.files, conversation: source.conversation, artifacts: source.artifacts, }) validateCheckpoint(stagedCheckpoint) const stagedTranscript = clone([...stagedCheckpoint.conversation]) const result = { checkpoint: clone(stagedCheckpoint) } const stagedReferences = [...blobKeys(stagedCheckpoint)].map((key) => ({ key, references: (this.state.references.get(key) ?? 0) + 1, })) // Cloning can invoke user-defined getters. Revalidate immediately before // the synchronous publication block so a reentrant save is never lost. this.assertWriter(writer, destinationThreadId) this.assertDestinationEmpty(destinationThreadId, destinationCheckpointId) this.state.messages.set(stagedCheckpoint.threadId, stagedTranscript) this.state.checkpoints.set(stagedCheckpoint.id, stagedCheckpoint) this.state.heads.set(stagedCheckpoint.threadId, stagedCheckpoint.id) for (const reference of stagedReferences) { this.state.references.set(reference.key, reference.references) } return result } private assertDestinationEmpty( destinationThreadId: string, destinationCheckpointId: string, ): void { if ( this.state.messages.has(destinationThreadId) || [...this.state.runs.values()].some( (value) => value.threadId === destinationThreadId, ) || [...this.state.generations.values()].some( (value) => value.threadId === destinationThreadId, ) || [...this.state.interrupts.values()].some( (value) => value.threadId === destinationThreadId, ) || [...this.state.artifacts.values()].some( (value) => value.threadId === destinationThreadId, ) || [...this.state.checkpoints.values()].some( (value) => value.threadId === destinationThreadId, ) || this.state.heads.has(destinationThreadId) || this.state.checkpoints.has(destinationCheckpointId) ) { throw new SandboxCheckpointError( 'SANDBOX_SNAPSHOT_FORK_DESTINATION_NOT_EMPTY', 'Destination thread is not empty', ) } } private assertWriter( writer: SandboxCheckpointWriter, threadId: string, ): void { const current = this.state.writers.get(threadId) if ( !current || current.ownerToken !== writer.ownerToken || current.fence !== writer.fence || current.expiresAt <= this.now() ) { throw new SandboxCheckpointWriterLostError( `Checkpoint writer lease for thread '${threadId}' is no longer current`, ) } } } async function bodyBytes(body: BlobBody): Promise<Uint8Array> { if (typeof body === 'string') return encoder.encode(body) if (body instanceof ArrayBuffer) return new Uint8Array(body.slice(0)) if (ArrayBuffer.isView(body)) return new Uint8Array( body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength), ) if (typeof Blob !== 'undefined' && body instanceof Blob) return new Uint8Array(await body.arrayBuffer()) if (typeof ReadableStream !== 'undefined' && body instanceof ReadableStream) { const reader = body.getReader() const parts: Array<Uint8Array> = [] try { for (;;) { const next = await reader.read() if (next.done) break parts.push(new Uint8Array(next.value)) } } finally { reader.releaseLock() } const result = new Uint8Array( parts.reduce((total, part) => total + part.byteLength, 0), ) let offset = 0 for (const part of parts) { result.set(part, offset) offset += part.byteLength } return result } throw new TypeError('Unsupported blob body.') } export async function memorySandboxSnapshots( options: MemorySandboxSnapshotsOptions = {}, ): Promise<MemorySandboxSnapshots> { const { persistence, checkpoints } = await createMemorySandboxSnapshots() return createSandboxSnapshots({ persistence, checkpoints, ...options, }) } async function createMemorySandboxSnapshots(): Promise<{ persistence: MemorySnapshotPersistence checkpoints: ForkCapableSandboxCheckpointStore }> { const messages = new Map<string, Array<ModelMessage>>() const runs = new Map<string, RunRecord>() const generations = new Map<string, GenerationRunRecord>() const interrupts = new Map<string, InterruptRecord>() const metadata = new Map<string, Map<string, unknown>>() const artifacts = new Map<string, ArtifactRecord>() const blobs = new Map<string, { record: BlobRecord; bytes: Uint8Array }>() const state: MemorySnapshotState = { messages, runs, generations, interrupts, metadata, artifacts, blobs, checkpoints: new Map(), heads: new Map(), writers: new Map(), fences: new Map(), references: new Map(), } let etag = 0 const persistence: MemorySnapshotPersistence = { stores: { messages: { loadThread: async (threadId: string) => messages.get(threadId)?.slice() ?? [], saveThread: async (threadId: string, value: Array<ModelMessage>) => { messages.set(threadId, value.slice()) }, }, runs: { createOrResume: async (input: { runId: string threadId: string status?: RunRecord['status'] startedAt: number }) => { const existing = runs.get(input.runId) if (existing) return existing const record: RunRecord = { ...input, status: input.status ?? 'running', } runs.set(record.runId, record) return record }, update: async (runId: string, patch: Partial<RunRecord>) => { const value = runs.get(runId) if (value) runs.set(runId, { ...value, ...patch }) }, get: async (runId: string) => runs.get(runId) ?? null, findActiveRun: async (threadId: string) => [...runs.values()] .filter( (run) => run.threadId === threadId && run.status === 'running', ) .sort((a, b) => b.startedAt - a.startedAt)[0] ?? null, listByThread: async (threadId: string) => [...runs.values()] .filter((run) => run.threadId === threadId) .sort((a, b) => a.startedAt - b.startedAt), listReclaimable: async (input: { now: number; ttlMs: number }) => [...runs.values()].filter( (run) => run.status === 'running' && run.detachedSince !== undefined && run.detachedSince <= input.now - input.ttlMs, ), }, generationRuns: { createOrResume: async ( input: Pick< GenerationRunRecord, | 'runId' | 'threadId' | 'activity' | 'provider' | 'model' | 'startedAt' > & { status?: GenerationRunRecord['status'] }, ) => { const value = generations.get(input.runId) ?? { ...input, status: input.status ?? 'running', } generations.set(input.runId, value) return value }, update: async (runId: string, patch: Partial<GenerationRunRecord>) => { const value = generations.get(runId) if (value) generations.set(runId, { ...value, ...patch }) }, get: async (runId: string) => generations.get(runId) ?? null, findLatestForThread: async (threadId: string) => [...generations.values()] .filter((run) => run.threadId === threadId) .sort((a, b) => b.startedAt - a.startedAt)[0] ?? null, }, interrupts: { create: async ( record: Omit<InterruptRecord, 'status' | 'resolvedAt'>, ) => { if (!interrupts.has(record.interruptId)) interrupts.set(record.interruptId, { ...record, status: 'pending' }) }, resolve: async (id: string, response?: unknown) => { const value = interrupts.get(id) if (value) interrupts.set(id, { ...value, status: 'resolved', resolvedAt: Date.now(), response, }) }, cancel: async (id: string) => { const value = interrupts.get(id) if (value) interrupts.set(id, { ...value, status: 'cancelled', resolvedAt: Date.now(), }) }, get: async (id: string) => interrupts.get(id) ?? null, list: async (threadId: string) => [...interrupts.values()] .filter((value) => value.threadId === threadId) .sort((a, b) => a.requestedAt - b.requestedAt), listPending: async (threadId: string) => [...interrupts.values()] .filter( (value) => value.threadId === threadId && value.status === 'pending', ) .sort((a, b) => a.requestedAt - b.requestedAt), listByRun: async (runId: string) => [...interrupts.values()] .filter((value) => value.runId === runId) .sort((a, b) => a.requestedAt - b.requestedAt), listPendingByRun: async (runId: string) => [...interrupts.values()] .filter( (value) => value.runId === runId && value.status === 'pending', ) .sort((a, b) => a.requestedAt - b.requestedAt), }, metadata: { get: async (namespace: string, key: string) => { const bucket = metadata.get(namespace) return bucket?.has(key) ? bucket.get(key) : null }, set: async (namespace: string, key: string, value: unknown) => { let bucket = metadata.get(namespace) if (!bucket) { bucket = new Map() metadata.set(namespace, bucket) } bucket.set(key, value) }, delete: async (namespace: string, key: string) => { metadata.get(namespace)?.delete(key) }, }, artifacts: { save: async (value: ArtifactRecord) => { artifacts.set(value.artifactId, { ...value }) }, get: async (id: string) => artifacts.get(id) ?? null, list: async (runId: string) => [...artifacts.values()] .filter((value) => value.runId === runId) .sort( (a, b) => a.createdAt - b.createdAt || compare(a.artifactId, b.artifactId), ), listForThread: async (threadId: string) => [...artifacts.values()] .filter((value) => value.threadId === threadId) .sort( (a, b) => a.createdAt - b.createdAt || compare(a.artifactId, b.artifactId), ), delete: async (id: string) => { artifacts.delete(id) }, deleteForRun: async (runId: string) => { for (const [id, value] of artifacts) if (value.runId === runId) artifacts.delete(id) }, }, blobs: { put: async ( key: string, body: BlobBody, putOptions?: { contentType?: string customMetadata?: Record<string, string> }, ) => { const bytes = await bodyBytes(body) const now = Date.now() const record: BlobRecord = { key, size: bytes.byteLength, etag: String(++etag), contentType: putOptions?.contentType ?? (typeof Blob !== 'undefined' && body instanceof Blob ? body.type || undefined : undefined), customMetadata: putOptions?.customMetadata ? { ...putOptions.customMetadata } : undefined, createdAt: blobs.get(key)?.record.createdAt ?? now, updatedAt: now, } blobs.set(key, { record, bytes: new Uint8Array(bytes) }) return clone(record) }, get: async (key: string, getOptions?: BlobGetOptions) => { const value = blobs.get(key) if (!value) return null const range = getOptions?.range ? resolveBlobRange(value.bytes.byteLength, getOptions.range) : { offset: 0, length: value.bytes.byteLength } const bytes = value.bytes.slice( range.offset, range.offset + range.length, ) return { ...clone(value.record), ...(getOptions?.range ? { range } : {}), body: new Blob([bytes]).stream(), arrayBuffer: async () => bytes.buffer.slice( bytes.byteOffset, bytes.byteOffset + bytes.byteLength, ), text: async () => new TextDecoder().decode(bytes), } }, head: async (key: string) => clone(blobs.get(key)?.record ?? null), delete: async (key: string) => { blobs.delete(key) }, list: async (listOptions?: { prefix?: string cursor?: string limit?: number }) => { const keys = [...blobs.keys()] .filter((key) => key.startsWith(listOptions?.prefix ?? '')) .filter( (key) => listOptions?.cursor === undefined || key > listOptions.cursor, ) .sort() if (listOptions?.limit === 0) return { objects: [], truncated: false } const page = listOptions?.limit === undefined ? keys : keys.slice(0, listOptions.limit) const truncated = listOptions?.limit !== undefined && keys.length > page.length const objects = page.map((key) => { const value = blobs.get(key) if (!value) throw new Error(`Missing blob for listed key: ${key}`) return value.record }) return { objects: clone(objects), ...(truncated ? { cursor: page.at(-1), truncated: true } : {}), } }, }, }, } const checkpointStore = new MemorySnapshotCheckpointStore(state) return { persistence, checkpoints: checkpointStore, } }