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.

490 lines (489 loc) 24 kB
import { SandboxCheckpointConflictError, SandboxCheckpointDuplicateIdError, SandboxCheckpointError, SandboxCheckpointInvalidEntryError, SandboxCheckpointInvalidIdError, SandboxCheckpointNotHeadError, SandboxCheckpointParentMismatchError, SandboxCheckpointWriterConflictError, SandboxCheckpointWriterLostError } from "./checkpoint-store.js"; import { createSandboxSnapshots } from "./snapshot-operations.js"; //#region src/memory-snapshots.ts function resolveBlobRange(size, range) { 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 === void 0) 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) }; } var encoder = new TextEncoder(); var compare = (a, b) => { 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; }; var clone = (value) => structuredClone(value); function hasUnpairedSurrogate(value) { for (let index = 0; index < value.length; index++) { const code = value.charCodeAt(index); if (code >= 55296 && code <= 56319) { const next = value.charCodeAt(index + 1); if (Number.isNaN(next) || next < 56320 || next > 57343) return true; index++; } else if (code >= 56320 && code <= 57343) return true; } return false; } function assertValidIdentifier(value, label) { 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, key) { return Object.prototype.hasOwnProperty.call(value, key); } function validateEntries(checkpoint) { if (!Array.isArray(checkpoint.files)) throw new SandboxCheckpointInvalidEntryError("Checkpoint files must be an array"); const paths = /* @__PURE__ */ new Set(); const kinds = /* @__PURE__ */ new Map(); for (const entry of checkpoint.files) { if (entry === null || typeof entry !== "object") throw new SandboxCheckpointInvalidEntryError("Checkpoint entry must be an object"); const candidate = entry; 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) { if (!Array.isArray(checkpoint.artifacts)) throw new SandboxCheckpointInvalidEntryError("Checkpoint artifacts must be an array"); for (const artifact of checkpoint.artifacts) { if (artifact === null || typeof artifact !== "object") throw new SandboxCheckpointInvalidEntryError("Checkpoint artifact must be an object"); const candidate = artifact; 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) { 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) { const keys = /* @__PURE__ */ new Set(); 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; } var MemorySnapshotCheckpointStore = class { state; now = () => Date.now(); leaseDurationMs = 12e4; renewAfterMs = 45e3; constructor(state) { this.state = state; } async get(id) { assertValidIdentifier(id, "Checkpoint id"); const checkpoint = this.state.checkpoints.get(id); return checkpoint ? clone(checkpoint) : null; } async list(threadId) { 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) { assertValidIdentifier(threadId, "Thread id"); return this.state.heads.get(threadId) ?? null; } async append(input) { 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); 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) { 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) { 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() { return [...this.state.references.entries()].sort(([a], [b]) => compare(a, b)).map(([key, references]) => ({ key, references })); } async forkFromCheckpoint(input) { 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 = { 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 = 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 })); 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; } assertDestinationEmpty(destinationThreadId, destinationCheckpointId) { 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"); } assertWriter(writer, threadId) { 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) { 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 = []; 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."); } async function memorySandboxSnapshots(options = {}) { const { persistence, checkpoints } = await createMemorySandboxSnapshots(); return createSandboxSnapshots({ persistence, checkpoints, ...options }); } async function createMemorySandboxSnapshots() { const messages = /* @__PURE__ */ new Map(); const runs = /* @__PURE__ */ new Map(); const generations = /* @__PURE__ */ new Map(); const interrupts = /* @__PURE__ */ new Map(); const metadata = /* @__PURE__ */ new Map(); const artifacts = /* @__PURE__ */ new Map(); const blobs = /* @__PURE__ */ new Map(); const state = { messages, runs, generations, interrupts, metadata, artifacts, blobs, checkpoints: /* @__PURE__ */ new Map(), heads: /* @__PURE__ */ new Map(), writers: /* @__PURE__ */ new Map(), fences: /* @__PURE__ */ new Map(), references: /* @__PURE__ */ new Map() }; let etag = 0; return { persistence: { stores: { messages: { loadThread: async (threadId) => messages.get(threadId)?.slice() ?? [], saveThread: async (threadId, value) => { messages.set(threadId, value.slice()); } }, runs: { createOrResume: async (input) => { const existing = runs.get(input.runId); if (existing) return existing; const record = { ...input, status: input.status ?? "running" }; runs.set(record.runId, record); return record; }, update: async (runId, patch) => { const value = runs.get(runId); if (value) runs.set(runId, { ...value, ...patch }); }, get: async (runId) => runs.get(runId) ?? null, findActiveRun: async (threadId) => [...runs.values()].filter((run) => run.threadId === threadId && run.status === "running").sort((a, b) => b.startedAt - a.startedAt)[0] ?? null, listByThread: async (threadId) => [...runs.values()].filter((run) => run.threadId === threadId).sort((a, b) => a.startedAt - b.startedAt), listReclaimable: async (input) => [...runs.values()].filter((run) => run.status === "running" && run.detachedSince !== void 0 && run.detachedSince <= input.now - input.ttlMs) }, generationRuns: { createOrResume: async (input) => { const value = generations.get(input.runId) ?? { ...input, status: input.status ?? "running" }; generations.set(input.runId, value); return value; }, update: async (runId, patch) => { const value = generations.get(runId); if (value) generations.set(runId, { ...value, ...patch }); }, get: async (runId) => generations.get(runId) ?? null, findLatestForThread: async (threadId) => [...generations.values()].filter((run) => run.threadId === threadId).sort((a, b) => b.startedAt - a.startedAt)[0] ?? null }, interrupts: { create: async (record) => { if (!interrupts.has(record.interruptId)) interrupts.set(record.interruptId, { ...record, status: "pending" }); }, resolve: async (id, response) => { const value = interrupts.get(id); if (value) interrupts.set(id, { ...value, status: "resolved", resolvedAt: Date.now(), response }); }, cancel: async (id) => { const value = interrupts.get(id); if (value) interrupts.set(id, { ...value, status: "cancelled", resolvedAt: Date.now() }); }, get: async (id) => interrupts.get(id) ?? null, list: async (threadId) => [...interrupts.values()].filter((value) => value.threadId === threadId).sort((a, b) => a.requestedAt - b.requestedAt), listPending: async (threadId) => [...interrupts.values()].filter((value) => value.threadId === threadId && value.status === "pending").sort((a, b) => a.requestedAt - b.requestedAt), listByRun: async (runId) => [...interrupts.values()].filter((value) => value.runId === runId).sort((a, b) => a.requestedAt - b.requestedAt), listPendingByRun: async (runId) => [...interrupts.values()].filter((value) => value.runId === runId && value.status === "pending").sort((a, b) => a.requestedAt - b.requestedAt) }, metadata: { get: async (namespace, key) => { const bucket = metadata.get(namespace); return bucket?.has(key) ? bucket.get(key) : null; }, set: async (namespace, key, value) => { let bucket = metadata.get(namespace); if (!bucket) { bucket = /* @__PURE__ */ new Map(); metadata.set(namespace, bucket); } bucket.set(key, value); }, delete: async (namespace, key) => { metadata.get(namespace)?.delete(key); } }, artifacts: { save: async (value) => { artifacts.set(value.artifactId, { ...value }); }, get: async (id) => artifacts.get(id) ?? null, list: async (runId) => [...artifacts.values()].filter((value) => value.runId === runId).sort((a, b) => a.createdAt - b.createdAt || compare(a.artifactId, b.artifactId)), listForThread: async (threadId) => [...artifacts.values()].filter((value) => value.threadId === threadId).sort((a, b) => a.createdAt - b.createdAt || compare(a.artifactId, b.artifactId)), delete: async (id) => { artifacts.delete(id); }, deleteForRun: async (runId) => { for (const [id, value] of artifacts) if (value.runId === runId) artifacts.delete(id); } }, blobs: { put: async (key, body, putOptions) => { const bytes = await bodyBytes(body); const now = Date.now(); const record = { key, size: bytes.byteLength, etag: String(++etag), contentType: putOptions?.contentType ?? (typeof Blob !== "undefined" && body instanceof Blob ? body.type || void 0 : void 0), customMetadata: putOptions?.customMetadata ? { ...putOptions.customMetadata } : void 0, createdAt: blobs.get(key)?.record.createdAt ?? now, updatedAt: now }; blobs.set(key, { record, bytes: new Uint8Array(bytes) }); return clone(record); }, get: async (key, getOptions) => { 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) => clone(blobs.get(key)?.record ?? null), delete: async (key) => { blobs.delete(key); }, list: async (listOptions) => { const keys = [...blobs.keys()].filter((key) => key.startsWith(listOptions?.prefix ?? "")).filter((key) => listOptions?.cursor === void 0 || key > listOptions.cursor).sort(); if (listOptions?.limit === 0) return { objects: [], truncated: false }; const page = listOptions?.limit === void 0 ? keys : keys.slice(0, listOptions.limit); const truncated = listOptions?.limit !== void 0 && keys.length > page.length; return { objects: clone(page.map((key) => { const value = blobs.get(key); if (!value) throw new Error(`Missing blob for listed key: ${key}`); return value.record; })), ...truncated ? { cursor: page.at(-1), truncated: true } : {} }; } } } }, checkpoints: new MemorySnapshotCheckpointStore(state) }; } //#endregion export { memorySandboxSnapshots }; //# sourceMappingURL=memory-snapshots.js.map