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.

541 lines (514 loc) 17.4 kB
import { captureSandboxArtifacts, captureSandboxFiles, resolveSandboxSnapshotPolicy, SandboxSnapshotError, } from './snapshots' import { resolveAllSecrets } from './secrets' import { computeSandboxKey, computeWorkspaceHash } from './key' import { stageEnsureExistingSandbox } from './sandbox' import type { ModelMessage } from '@tanstack/ai' import type { LockStore } from '@tanstack/ai/locks' import type { SandboxCheckpoint, SandboxCheckpointStore, SandboxCheckpointWriterLease, } from './checkpoint-store' import type { SandboxInstanceStore } from './instance-store' import type { SandboxDefinition } from './sandbox' import type { SandboxSnapshotBundle, SandboxSnapshotPolicy } from './snapshots' import type { WorkspaceDefinition } from './workspace' export interface SnapshotPersistence { stores: { messages: { loadThread: (threadId: string) => Promise<ReadonlyArray<ModelMessage>> } artifacts: NonNullable<SandboxSnapshotBundle['artifacts']> blobs: SandboxSnapshotBundle['blobs'] } } export interface CreateSandboxSnapshotsInput< TPersistence extends SnapshotPersistence = SnapshotPersistence, TCheckpoints extends SandboxCheckpointStore = SandboxCheckpointStore, > { persistence: TPersistence checkpoints: TCheckpoints policy?: SandboxSnapshotPolicy sandbox?: SandboxDefinition instances?: SandboxInstanceStore tenant?: { userId?: string; orgId?: string } locks?: LockStore } export interface SaveSandboxSnapshotInput { threadId: string runId: string label: string sandbox?: SandboxDefinition instances?: SandboxInstanceStore tenant?: { userId?: string; orgId?: string } locks?: LockStore signal?: AbortSignal adapterName?: string } export interface ForkSandboxSnapshotInput { threadId: string checkpointId: string destinationThreadId: string destinationCheckpointId?: string createdAt?: number } export interface ReadSandboxSnapshotArtifactInput { threadId: string checkpointId: string artifactId: string } export interface SandboxSnapshots< TPersistence extends SnapshotPersistence = SnapshotPersistence, TCheckpoints extends SandboxCheckpointStore = SandboxCheckpointStore, > { persistence: TPersistence checkpoints: TCheckpoints policy?: SandboxSnapshotPolicy save: (input: SaveSandboxSnapshotInput) => Promise<SandboxCheckpoint> fork: (input: ForkSandboxSnapshotInput) => Promise<SandboxCheckpoint> readArtifact: (input: ReadSandboxSnapshotArtifactInput) => Promise<{ artifact: SandboxCheckpoint['artifacts'][number] bytes: Uint8Array }> } type Failure = { error: unknown } async function withWriterLease<T>( acquire: () => Promise<SandboxCheckpointWriterLease>, renew: boolean, operation: ( writer: SandboxCheckpointWriterLease, throwIfLost: () => Promise<void>, ) => Promise<T>, ): Promise<T> { const writer = await acquire() const release = writer.release.bind(writer) const renewWriter = renew ? writer.renew.bind(writer) : undefined const renewAfterMs = renew ? writer.renewAfterMs : undefined let renewalTimer: ReturnType<typeof setTimeout> | undefined let renewalTask: Promise<void> | undefined let renewalFailure: Failure | undefined let stopped = false const scheduleRenewal = (): void => { if (renewWriter === undefined || renewAfterMs === undefined) return renewalTimer = setTimeout(() => { renewalTimer = undefined renewalTask = (async () => { try { await renewWriter() } catch (error) { renewalFailure = { error } } finally { renewalTask = undefined } if (!stopped && renewalFailure === undefined) scheduleRenewal() })() }, renewAfterMs) } if (renew) scheduleRenewal() const throwIfLost = async (): Promise<void> => { await renewalTask if (renewalFailure !== undefined) throw renewalFailure.error } let outcome: { value: T } | undefined let operationFailure: Failure | undefined try { outcome = { value: await operation(writer, throwIfLost) } } catch (error) { operationFailure = { error } } stopped = true if (renewalTimer !== undefined) clearTimeout(renewalTimer) await renewalTask let releaseFailure: Failure | undefined try { await release() } catch (error) { releaseFailure = { error } } if (renewalFailure !== undefined) throw renewalFailure.error if (operationFailure !== undefined) throw operationFailure.error if (releaseFailure !== undefined) throw releaseFailure.error if (outcome === undefined) throw new Error('Writer operation had no outcome') return outcome.value } function stageWorkspace( workspace: WorkspaceDefinition | undefined, ): WorkspaceDefinition | undefined { if (workspace === undefined) return undefined const source = workspace.source const packageManager = workspace.packageManager const setup = workspace.setup const scripts = workspace.scripts const skills = workspace.skills const instructions = workspace.instructions const plugins = workspace.plugins const secrets = workspace.secrets const root = workspace.root return { source, ...(Object.hasOwn(workspace, 'packageManager') ? { packageManager } : {}), ...(Object.hasOwn(workspace, 'setup') ? { setup } : {}), ...(Object.hasOwn(workspace, 'scripts') ? { scripts } : {}), ...(Object.hasOwn(workspace, 'skills') ? { skills } : {}), ...(Object.hasOwn(workspace, 'instructions') ? { instructions } : {}), ...(Object.hasOwn(workspace, 'plugins') ? { plugins } : {}), ...(Object.hasOwn(workspace, 'secrets') ? { secrets } : {}), ...(Object.hasOwn(workspace, 'root') ? { root } : {}), } } function effectivePolicy( supplied: SandboxSnapshotPolicy | undefined, workspaceHash: string | undefined, ): SandboxSnapshotPolicy { return resolveSandboxSnapshotPolicy(supplied, workspaceHash) } function stageInstanceStore(store: SandboxInstanceStore): SandboxInstanceStore { const get = store.get.bind(store) const upsert = store.upsert.bind(store) const deleteRecord = store.delete.bind(store) return { get, upsert, delete: deleteRecord } } function stageLockStore(locks: LockStore | undefined): LockStore | undefined { if (locks === undefined) return undefined const withLock = locks.withLock.bind(locks) return { withLock } } function requireSnapshotPersistence<TPersistence extends SnapshotPersistence>( persistence: TPersistence, ): TPersistence { const stores = persistence.stores if (!stores?.messages || !stores.artifacts || !stores.blobs) { throw new SandboxSnapshotError( 'SANDBOX_SNAPSHOT_MISSING_PERSISTENCE_STORES', 'Sandbox snapshots require persistence stores.messages, stores.artifacts, and stores.blobs', ) } return persistence } export function createSandboxSnapshots< TPersistence extends SnapshotPersistence, TCheckpoints extends SandboxCheckpointStore, >( input: CreateSandboxSnapshotsInput<TPersistence, TCheckpoints>, ): SandboxSnapshots<TPersistence, TCheckpoints> { const persistence = requireSnapshotPersistence(input.persistence) const checkpoints = input.checkpoints const policy = input.policy const boundSandbox = input.sandbox const boundInstances = input.instances const boundTenant = input.tenant const boundLocks = input.locks return { persistence, checkpoints, ...(policy === undefined ? {} : { policy }), async save(saveInput) { const sandbox = saveInput.sandbox ?? boundSandbox const instances = saveInput.instances ?? boundInstances if (sandbox === undefined) throw new SandboxSnapshotError( 'SANDBOX_SNAPSHOT_MISSING_SANDBOX', 'Named snapshots require a sandbox at create time or on save', ) if (instances === undefined) throw new SandboxSnapshotError( 'SANDBOX_SNAPSHOT_MISSING_INSTANCES', 'Named snapshots require instances at create time or on save', ) return saveNamedSandboxSnapshot({ definition: sandbox, threadId: saveInput.threadId, runId: saveInput.runId, instances, persistence, checkpoints, policy, label: saveInput.label, tenant: saveInput.tenant ?? boundTenant, locks: saveInput.locks ?? boundLocks, signal: saveInput.signal, adapterName: saveInput.adapterName, }) }, fork(forkInput) { return forkFromSandboxSnapshot({ threadId: forkInput.threadId, checkpointId: forkInput.checkpointId, destinationThreadId: forkInput.destinationThreadId, checkpoints, destinationCheckpointId: forkInput.destinationCheckpointId, createdAt: forkInput.createdAt, }) }, readArtifact(readInput) { return resolveSnapshotArtifact({ threadId: readInput.threadId, checkpointId: readInput.checkpointId, artifactId: readInput.artifactId, persistence, checkpoints, }) }, } } async function saveNamedSandboxSnapshot(input: { definition: SandboxDefinition threadId: string runId: string instances: SandboxInstanceStore persistence: SnapshotPersistence checkpoints: SandboxCheckpointStore policy?: SandboxSnapshotPolicy label: string tenant?: { userId?: string; orgId?: string } locks?: LockStore signal?: AbortSignal adapterName?: string }): Promise<SandboxCheckpoint> { const definition = input.definition const threadId = input.threadId const runId = input.runId const instances = stageInstanceStore(input.instances) const label = input.label const suppliedTenant = input.tenant const tenantUserId = suppliedTenant?.userId const tenantOrgId = suppliedTenant?.orgId const tenant = suppliedTenant ? { ...(tenantUserId === undefined ? {} : { userId: tenantUserId }), ...(tenantOrgId === undefined ? {} : { orgId: tenantOrgId }), } : undefined const locks = stageLockStore(input.locks) const signal = input.signal const adapterName = input.adapterName const lifecycle = definition.lifecycle const reuse = lifecycle?.reuse const snapshotMaxAge = lifecycle?.snapshotMaxAge const workspace = stageWorkspace(definition.workspace) const sandboxId = definition.id const provider = definition.provider const providerName = provider.name const resume = provider.resume.bind(provider) const ensureExisting = stageEnsureExistingSandbox(definition) const persistence = input.persistence const stores = persistence.stores const messages = stores.messages const loadThread = messages.loadThread.bind(messages) const artifactStore = stores.artifacts const listForThread = artifactStore.listForThread.bind(artifactStore) const suppliedBlobs = stores.blobs const getBlob = suppliedBlobs.get.bind(suppliedBlobs) const headBlob = suppliedBlobs.head.bind(suppliedBlobs) const putBlob = suppliedBlobs.put.bind(suppliedBlobs) const blobs: SandboxSnapshotBundle['blobs'] = { get: getBlob, head: headBlob, put: putBlob, } const checkpoints = input.checkpoints const acquireWriter = checkpoints.acquireWriter.bind(checkpoints) const getHead = checkpoints.getHead.bind(checkpoints) const append = checkpoints.append.bind(checkpoints) const policy = effectivePolicy( input.policy, workspace === undefined ? undefined : computeWorkspaceHash(workspace), ) const workspaceSecrets = workspace?.secrets const secrets = workspaceSecrets ? resolveAllSecrets(workspaceSecrets) : {} const workspaceRoot = workspace?.root const key = computeSandboxKey({ threadId, sandboxId, providerName, workspace, tenant, }) return withWriterLease( () => acquireWriter(threadId), true, async (writer, throwIfLost) => { if (reuse === 'none') throw new SandboxSnapshotError( 'SANDBOX_SNAPSHOT_REUSE_NONE', 'Named snapshots require a reusable sandbox lifecycle', ) const handle = await ensureExisting( { threadId, runId, store: instances, locks, tenant, signal, adapterName, }, { key, workspace, resolvedSecrets: workspaceSecrets ? secrets : undefined, snapshotMaxAge, resume, }, ) if (!handle) throw new SandboxSnapshotError( 'SANDBOX_SNAPSHOT_MISSING_REUSABLE_SANDBOX', 'Named snapshots require an existing resumable sandbox', ) const conversation = await loadThread(threadId) const files = await captureSandboxFiles( handle, { blobs, workspaceRoot }, policy, secrets, ) const artifacts = await captureSandboxArtifacts( { blobs, artifacts: { listForThread }, }, threadId, secrets, ) const parentCheckpointId = await getHead(threadId) await throwIfLost() const checkpoint: SandboxCheckpoint = { id: crypto.randomUUID(), threadId, parentCheckpointId, createdAt: Date.now(), reason: 'named', label, sourceRunId: runId, files: files.files, conversation, artifacts, } await append({ checkpoint, expectedHeadId: parentCheckpointId, writer, }) await throwIfLost() return checkpoint }, ) } async function forkFromSandboxSnapshot(input: { threadId: string checkpointId: string destinationThreadId: string checkpoints: SandboxCheckpointStore destinationCheckpointId?: string createdAt?: number }): Promise<SandboxCheckpoint> { const sourceThreadId = input.threadId const sourceCheckpointId = input.checkpointId const destinationThreadId = input.destinationThreadId const suppliedDestinationCheckpointId = input.destinationCheckpointId const suppliedCreatedAt = input.createdAt const destinationCheckpointId = suppliedDestinationCheckpointId ?? crypto.randomUUID() const createdAt = suppliedCreatedAt ?? Date.now() const checkpoints = input.checkpoints const acquireWriter = checkpoints.acquireWriter.bind(checkpoints) const forkFromCheckpoint = checkpoints.forkFromCheckpoint?.bind(checkpoints) return withWriterLease( () => acquireWriter(destinationThreadId), false, async (writer) => { if (forkFromCheckpoint === undefined) throw new SandboxSnapshotError( 'SANDBOX_SNAPSHOT_FORK_UNAVAILABLE', 'The checkpoint store does not support atomic forks', ) const result = await forkFromCheckpoint({ sourceThreadId, sourceCheckpointId, destinationThreadId, destinationCheckpointId, createdAt, writer, }) return result.checkpoint }, ) } async function sha256(bytes: Uint8Array): Promise<string> { const digest = await crypto.subtle.digest('SHA-256', new Uint8Array(bytes)) return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0'), ).join('') } async function resolveSnapshotArtifact(input: { threadId: string checkpointId: string artifactId: string persistence: SnapshotPersistence checkpoints: SandboxCheckpointStore }): Promise<{ artifact: SandboxCheckpoint['artifacts'][number] bytes: Uint8Array }> { const threadId = input.threadId const checkpointId = input.checkpointId const artifactId = input.artifactId const checkpoints = input.checkpoints const getCheckpoint = checkpoints.get.bind(checkpoints) const persistence = input.persistence const stores = persistence.stores const blobs = stores.blobs const getBlob = blobs.get.bind(blobs) const checkpoint = await getCheckpoint(checkpointId) if (!checkpoint) throw new SandboxSnapshotError( 'SANDBOX_SNAPSHOT_MISSING_CHECKPOINT_ARTIFACT', 'Snapshot checkpoint does not exist', ) const checkpointThreadId = checkpoint.threadId const checkpointArtifacts = checkpoint.artifacts if (checkpointThreadId !== threadId) throw new SandboxSnapshotError( 'SANDBOX_SNAPSHOT_FOREIGN_CHECKPOINT_ARTIFACT', 'Snapshot checkpoint belongs to another thread', ) const foundArtifact = checkpointArtifacts.find( (value) => value.artifactId === artifactId, ) if (!foundArtifact) throw new SandboxSnapshotError( 'SANDBOX_SNAPSHOT_MISSING_CHECKPOINT_ARTIFACT', 'Snapshot artifact does not exist', ) const artifact = { artifactId: foundArtifact.artifactId, name: foundArtifact.name, mimeType: foundArtifact.mimeType, size: foundArtifact.size, blobKey: foundArtifact.blobKey, createdAt: foundArtifact.createdAt, } const blob = await getBlob(artifact.blobKey) if (!blob) throw new SandboxSnapshotError( 'SANDBOX_SNAPSHOT_INVALID_ARTIFACT_BYTES', 'Snapshot artifact blob does not exist', ) const arrayBuffer = blob.arrayBuffer.bind(blob) const bytes = new Uint8Array(await arrayBuffer()) if ( bytes.byteLength !== artifact.size || artifact.blobKey !== `sandbox-artifacts/sha256/${await sha256(bytes)}` ) throw new SandboxSnapshotError( 'SANDBOX_SNAPSHOT_INVALID_ARTIFACT_BYTES', 'Snapshot artifact bytes do not match metadata', ) return { artifact: { ...artifact }, bytes: bytes.slice() } }