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.

458 lines (432 loc) 19.1 kB
/** * The "run driver" for the inverted/serverless sandbox model: pump a `chat()` * stream into core's two durable seams — a {@link RunStore} for the run's * lifecycle record and a {@link StreamDurability} for its event log — so a * trigger can return immediately while a durable orchestrator drives the run * and clients tail from an opaque offset. * * The key inversion vs. a classic request/response handler: there is no caller * holding the stream open, so nothing to throw an error *back to*. The event log * is the only channel — every chunk (including a terminal * {@link EventType.RUN_ERROR}) is appended and assigned a resumable offset, and * a thrown stream error is recorded as a synthesized `RUN_ERROR` event plus the * record's `error` field. Tailing clients therefore always observe failures; * {@link pipeToRunLog} never rejects. * * "Never rejects" is load-bearing rather than aspirational: {@link RunController} * consumes the returned promise fire-and-forget, so a rejection would be an * unhandled rejection (process-fatal on modern Node, instance-fatal inside a * Durable Object) with nobody to report it to. Every store/log call is therefore * individually guarded, and because absorbing a failure silently in the one * module whose premise is that nobody is listening would make the failure * invisible, each guard reports through the optional {@link RunDeps.logger}. */ import { EventType } from '@tanstack/ai' import { toRunErrorPayload } from '@tanstack/ai/adapter-internals' import type { InternalLogger } from '@tanstack/ai/adapter-internals' import type { RunError, RunRecord, RunStore, StreamChunk, StreamDurability, TerminalRunStatus, } from '@tanstack/ai' /** Whether a chunk is the terminal error event the chat engine emits. */ function isRunErrorChunk( chunk: StreamChunk, ): chunk is StreamChunk & { message: string; code?: string } { return chunk.type === EventType.RUN_ERROR } /** * Narrow a thrown value (or a `RUN_ERROR` chunk's payload) to the record's * structured error, keeping the provider's `code` when it supplies one: a bare * message is prose that changes between model versions, while `code` is what a * consumer branches on to retry, escalate, or show specific UI. */ function toRunError(error: unknown): RunError { const payload = toRunErrorPayload(error) return { message: payload.message, ...(payload.code === undefined ? {} : { code: payload.code }), } } /** * Fold a secondary failure into the primary error, mirroring `combineFailures` * in `packages/ai/src/stream-to-response.ts`: the primary cause stays first and * keeps its `code`, and the phase that produced the secondary failure is named. * The secondary must never *replace* the primary: the provider's error is what * an operator needs, and a failure while recording it is the lesser fact. */ function withSecondaryFailure( primary: RunError, secondary: unknown, phase: string, ): RunError { return { ...primary, message: `${primary.message}; ${phase}: ${toRunError(secondary).message}`, } } /** Build the synthetic RUN_ERROR chunk appended when the stream throws. */ function syntheticRunError(error: RunError): StreamChunk { const chunk: { type: EventType.RUN_ERROR; message: string; code?: string } = { type: EventType.RUN_ERROR, message: error.message, ...(error.code === undefined ? {} : { code: error.code }), } return chunk } /** * The two durable seams a run driver needs: lifecycle record + event log. * * Generic in the log's offset type, and DEFAULTED to `string` so every existing * call site keeps compiling unchanged. The parameter is not decoration: a * backend that brands its cursors — `@tanstack/ai-durable-stream`'s * `durableStream` returns `StreamDurability<DurableStreamOffset>` — is NOT * assignable to `StreamDurability<string>`, because `read` takes an offset and * is therefore contravariant in it. Hardcoding the default here made the * production multi-host backend unusable without a cast (see * `tests/offset-generics.test-d.ts`). */ export interface RunDeps<TOffset extends string = string> { /** Run lifecycle record (status, thread, timings). */ runs: RunStore /** * Per-run delivery-durable event log the run's chunks are appended to. * * A FACTORY, not an instance, and that is load-bearing rather than stylistic. * A `StreamDurability` is bound to one run — `memoryStream(request)` resolves * its `runId` from the request, and a backend adapter's offsets embed a cursor * into one log. Holding a single instance made two failures reachable: * * - **Silent mis-binding at concurrency 1.** `start({ runId })` accepted an * arbitrary id while the instance was bound to another, writing the record * under one id and the events under another with no error raised. Resolving * the log FROM the `runId` makes that unrepresentable. * - **Cross-talk at concurrency > 1.** Parallel runs interleaved their chunks * into one log, and whichever finished first called `close()` and * terminalized every other run's stream. * * Called exactly once per run, at the start of {@link pipeToRunLog}. An * implementation MUST return the same instance for the same `runId` within a * process if it wants `snapshot()` to see its own appends. */ durability: (runId: string) => StreamDurability<TOffset> /** * Optional sink for failures this driver absorbs rather than rejecting with. * A detached run has no caller to receive an error, so without a logger a * failing store or event log is invisible to an operator. Same * `logger?.errors(...)` contract core uses in `stream-to-response.ts`. */ logger?: InternalLogger } export interface PipeToRunLogOptions< TOffset extends string = string, > extends RunDeps<TOffset> { runId: string threadId: string /** Abort consumption mid-stream; the run finishes as `aborted`. */ signal?: AbortSignal } /** Everything {@link finish} needs, including the fields it rebuilds a record from. */ interface FinishContext { runs: RunStore /** * `close()` only — see {@link finish}. Narrowed to that one member rather than * threading `TOffset` through here, because `close` is the sole method this * context touches and it is offset-free, so a `Pick` accepts a log at ANY * offset instantiation without making `finish` generic for nothing. */ durability: Pick<StreamDurability, 'close'> runId: string threadId: string startedAt: number logger?: InternalLogger } /** * Report through a consumer-supplied logger without letting it break the * caller. Every logger call in this module sits inside a `catch` body, so an * throwing sink would escape that body and defeat the totality the guards * exist to provide. Swallowing here is deliberate: there is no second channel * left to report a reporting failure on. */ function safeLog( logger: InternalLogger | undefined, message: string, context: Record<string, unknown>, ): void { try { logger?.errors(message, context) } catch { // Intentionally empty: see above. } } /** * Record the terminal status, terminalize the event log, and answer with the * run's final record. * * TOTAL BY CONSTRUCTION: every step is individually guarded, so this never * throws and never rejects. Two consequences the guards buy: * * - `durability.close()` runs on EVERY exit path, including a failed `update`. * Skipping it would wedge the record at `running` *and* park every live * tailer forever, because a durability `read` only ends once the log closes. * - The re-read of the record is best effort. An eventually-consistent or * read-replica store may answer `null` for a run that was just driven, which * must not turn a successful run into a rejection; the locally rebuilt record * is returned instead. It is also preferred outright when `update` failed, * since the store then still holds the stale `running` row. * * THE TERMINAL WRITE IS NOT GUARANTEED TO LAND, and this function deliberately * does not check whether it did. Under `sandboxRunDriver` the `runs` handed in is * `fenceRunStore`d (`src/claim.ts`), which SUPPRESSES a terminal write — resolving * without writing — when the driver has lost its claim, because a host that no * longer owns the run must not declare it over while the successor is streaming * it. From here that is indistinguishable from a successful write, on purpose: * the epoch belongs to the claim module, not to this generic driver, and the * re-read below then answers with the successor's live record, which is the * truthful thing to resolve with. A driver wired without a claim (a plain * `pipeToRunLog` call) is unfenced and always writes. */ async function finish( ctx: FinishContext, status: TerminalRunStatus, error?: RunError, ): Promise<RunRecord> { // NOTE: every logger call below goes through `safeLog`. The logger is // consumer-supplied and is handed arbitrary thrown values, so a sink that // cannot serialize one (a circular payload, say) would otherwise throw from // inside a `catch` body and escape, skipping `durability.close()` and leaving // the run wedged at `'running'` with live tailers parked. The reporting // channel must never be able to break the guarantee it exists to report on. const { runs, durability, runId, logger } = ctx const finishedAt = Date.now() const patch = { status, finishedAt, ...(error === undefined ? {} : { error }), } const local: RunRecord = { runId, threadId: ctx.threadId, startedAt: ctx.startedAt, ...patch, } let recorded = true try { await runs.update(runId, patch) } catch (updateError) { recorded = false safeLog(logger, 'run: recording the terminal run record failed', { runId, status, error: updateError, }) } try { await durability.close() } catch (closeError) { safeLog(logger, 'run: closing the run event log failed', { runId, status, error: closeError, }) } if (!recorded) return local try { const latest = await runs.get(runId) if (latest !== null) return latest safeLog(logger, 'run: record vanished before the terminal re-read', { runId, status, }) } catch (getError) { safeLog(logger, 'run: re-reading the terminal run record failed', { runId, status, error: getError, }) } return local } /** * Open the run, append every chunk from `stream`, and finish with the right * terminal status. Resolves with the final {@link RunRecord} and never rejects: * a thrown stream error is surfaced as a `RUN_ERROR` event plus the record's * `error`, which is what tailing clients see. A store or event-log failure * along the way is logged through {@link RunDeps.logger} and still terminalizes * the run rather than escaping to a caller that does not exist. * * - normal completion → `completed` * - a `RUN_ERROR` chunk → append it, then `failed` * - the stream throws → append a synthesized `RUN_ERROR`, then `failed` * - `signal` aborts at ANY point before the stream ends → `aborted`, whether the * producer keeps yielding, ends its stream, or is never asked for another * chunk. An abort outranks a clean exit: the run did not complete. */ export async function pipeToRunLog<TOffset extends string = string>( stream: AsyncIterable<StreamChunk>, opts: PipeToRunLogOptions<TOffset>, ): Promise<RunRecord> { const { runs, runId, threadId, signal, logger } = opts // Resolved ONCE, from the runId being driven. Everything below — including // `finish`'s `close()` — uses this one instance, so a factory that mints a // fresh log per call cannot split one run across two logs, and the log can // never belong to a run other than the one whose record is being written. const durability = opts.durability(runId) const ctx: FinishContext = { runs, durability, runId, threadId, startedAt: Date.now(), ...(logger === undefined ? {} : { logger }), } try { // Inside the `try` so a store failure at creation is handled like any // other: recorded as a failed run with a terminalized log, not rejected. await runs.createOrResume({ runId, threadId, startedAt: ctx.startedAt }) if (signal?.aborted) return finish(ctx, 'aborted') for await (const chunk of stream) { if (signal?.aborted) return finish(ctx, 'aborted') await durability.append([chunk]) if (isRunErrorChunk(chunk)) { return finish( ctx, 'failed', toRunError({ message: chunk.message, code: chunk.code }), ) } } } catch (streamError) { // Detached run: no caller to throw to. Record the failure in the log so // tailing clients observe it, then return — do NOT rethrow. let recorded = toRunError(streamError) // Deliberately not "the stream failed": `runs.createOrResume` above is // inside this `try`, so an operator reading a wedged run must not be told // the provider stream broke when the store never let the run start. safeLog(logger, 'run: the run failed before completing', { runId, error: streamError, }) try { await durability.append([syntheticRunError(recorded)]) } catch (appendError) { // The recovery append is itself a failure path. It must not destroy the // cause it was recording, so the provider's error stays primary and this // secondary failure is merged in and logged separately. When the cause IS // a lost claim, this append is refused too and the `finish` below writes // nothing either — a fenced store suppresses the terminal record for the // same reason the fenced log refused the chunk (see `finish`). const phase = 'appending the synthesized RUN_ERROR failed' safeLog(logger, `run: ${phase}`, { runId, error: appendError }) recorded = withSecondaryFailure(recorded, appendError, phase) } return finish(ctx, 'failed', recorded) } // RE-CHECKED after the loop, and this is the common shape rather than the // exotic one. The in-loop check only fires if the producer yields at least // once MORE after the abort; two ways past it are routine: // // - The producer is signal-aware and reacts by ENDING its stream. `chat()` // does exactly this, so the loop exits NORMALLY. // - The abort lands BETWEEN two chunks, while the loop is suspended on a // producer that then finishes on its own. // // Falling through to `'completed'` in either case is a false transcript, not // a cosmetic mislabel: it was measured on the reaper's TTL-expiry path, where // a run the reaper had force-expired — and whose sandbox it had already // destroyed — was recorded as having completed successfully. Any caller whose // producer ends its stream on abort reaches the same gap, a takeover that // loses its claim mid-drive included, which is why the check belongs here and // not in one caller. // // A producer that THREW on the abort is deliberately untouched: it returned // from inside the `catch` above as `'failed'`, because a thrown value is a // reported failure a tailing client must be shown, and this driver's log is // that client's only channel. if (signal?.aborted) return finish(ctx, 'aborted') return finish(ctx, 'completed') } export interface RunControllerStartInput { runId: string threadId: string stream: AsyncIterable<StreamChunk> /** Abort consumption mid-stream; the run finishes as `aborted`. */ signal?: AbortSignal } export interface RunHandle { runId: string /** Resolves with the final record once the run reaches a terminal status. */ done: Promise<RunRecord> } /** * Thin orchestration helper over {@link RunDeps}: fire-and-track a run via * {@link pipeToRunLog}, tail one run by id, and `drain()` all in-flight runs * (e.g. inside a `ctx.waitUntil`). Holds no run state of its own beyond the set * of currently in-flight `done` promises. * * Safe for concurrent runs. {@link RunDeps.durability} is a per-run factory, so * each run appends to its own log and no run's `close()` terminalizes another's. * The identity trap this class used to document — `start({ runId })` writing the * lifecycle record under one id and the events under another, silently and at * concurrency 1 — is unrepresentable now that the log is resolved FROM the * `runId`. Every method is keyed by run accordingly: `attach(runId, …)` and * `status(runId)` no longer disagree about whether the surface is per-run. */ export class RunController<TOffset extends string = string> { private readonly inFlight = new Set<Promise<RunRecord>>() constructor(private readonly deps: RunDeps<TOffset>) {} /** * Kick off `pipeToRunLog` without awaiting it and return the `runId` * immediately plus a `done` promise the orchestrator may await or detach. */ start(input: RunControllerStartInput): RunHandle { const done = pipeToRunLog(input.stream, { ...this.deps, runId: input.runId, threadId: input.threadId, ...(input.signal !== undefined ? { signal: input.signal } : {}), }) this.inFlight.add(done) // Two-argument `then`, deliberately NOT `.finally`: `.finally` returns a new // promise that adopts any rejection, and discarding that promise would make // the rejection unhandled (fatal on modern Node defaults, and it kills the // instance inside a Durable Object). Handling both outcomes here means the // derived promise always settles fulfilled, so nothing is left unhandled // even if `pipeToRunLog`'s "never rejects" contract is ever broken. const forget = (): void => void this.inFlight.delete(done) void done.then(forget, forget) return { runId: input.runId, done } } /** * Resumable client tail for ONE run — replay from `fromOffset`, then * live-tail. Takes `runId` because the log it reads is per-run; the old * `attach(fromOffset)` signature advertised a multi-run surface the type could * not deliver. */ attach( runId: string, fromOffset: TOffset, signal?: AbortSignal, ): AsyncIterable<{ offset: TOffset; chunk: StreamChunk }> { return this.deps.durability(runId).read(fromOffset, signal) } /** Current run record, or null when the run is unknown. */ status(runId: string): Promise<RunRecord | null> { return this.deps.runs.get(runId) } /** * Await every currently in-flight run's `done` promise. * * Uses `allSettled` rather than `all` because this is typically awaited * inside a `ctx.waitUntil`: `all` would reject on the first failure, abandon * the wait on every other run, and surface that rejection to the platform. * Draining is about keeping the isolate alive until the runs settle; each * run's own outcome is already recorded in its record and log. */ async drain(): Promise<void> { await Promise.allSettled([...this.inFlight]) } }