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.

206 lines (201 loc) 8.6 kB
/** * The convenience that turns core's *injected* takeover seams into this * package's real ones. * * `@tanstack/ai`'s `RunDriverOptions` deliberately takes `claim` and `pipe` as * functions instead of importing them: {@link withRunClaim} and * {@link pipeToRunLog} live here, and core must not depend on this package to * serve a plain chat run. {@link sandboxRunDriver} fills both in so an * application writes four fields instead of six, and — more importantly — so * the *fencing* is wired correctly by construction rather than by every caller * remembering to. * * WHAT IS EASY TO GET WRONG HERE, and therefore what this module exists to * make impossible: * * 1. **Carrying the real epoch into `pipe`.** Core's `pipe` receives only * `{ runId, threadId, signal }` — no epoch — because core has no concept of * one. But {@link fenceDurability} needs the epoch this driver actually * acquired: a hardcoded epoch (say `0`) is not a weaker fence, it is a * permanently *tripped* one, since `withRunClaim` bumps `driverEpoch` to at * least `1` before `fn` ever runs, so `observed > claim.epoch` holds on the * very first append and EVERY takeover fails. The claim is therefore * captured in a closure by the `claim` wrapper and read back by `pipe`. * 2. **Fencing `close()`.** {@link fenceDurability} wraps only `append` for the * reason spelled out in `claim.ts`: `close()` runs on every teardown path, * including the teardown caused by losing the claim, and a fenced `close` * would wedge the record at `'running'` with every live tailer parked * forever. This module must not add a second fence around it. * 2b. **Fencing only ONE of the two authoritative seams.** A run's facts live in * its log *and* in its record, and `pipeToRunLog` reacts to a refused append * by writing a terminal record — so wrapping the log alone just moves the harm * from "a dead host poisons the successor's stream" to "a dead host marks the * successor's live run failed". {@link fenceRunStore} must be wired here too, * over the SAME claim, which is what makes the two fences share one latch. * 3. **Skipping quiescence.** The successor's first append must come after the * stored log has stopped growing, so a predecessor still writing is observed * rather than raced. The gate belongs inside `pipe`, before `pipeToRunLog` * takes its first `snapshot`. */ import { pipeToRunLog } from './run' import { DEFAULT_FENCE_QUIET_MS, awaitLogQuiescence, fenceDurability, fenceRunStore, withRunClaim, } from './claim' import type { RunClaim } from './claim' import type { InternalLogger } from '@tanstack/ai/adapter-internals' import type { LockStore } from '@tanstack/ai/locks' import type { RunDriverOptions, RunStore, StreamChunk, StreamDurability, } from '@tanstack/ai' export interface SandboxRunDriverOptions<TOffset extends string = string> { /** The attach request; core reads its run id with `resolveResumeRunId`. */ request: Request runs: RunStore locks: LockStore /** * Per-run event log factory, the same shape `RunDeps.durability` takes — a * `StreamDurability` is bound to one run, so the log is resolved FROM the * `runId` rather than handed in pre-bound. * * Generic in the offset type, defaulted to `string` so an existing call site * needs no change. Hardcoding the default made a branded-cursor backend * unusable here: `durableStream` returns * `StreamDurability<DurableStreamOffset>`, which is not assignable to * `StreamDurability<string>` because `read` is contravariant in its offset. */ durability: (runId: string) => StreamDurability<TOffset> /** Produce the run's remaining events. Called only once the claim is held. */ drive: (input: { runId: string threadId: string signal: AbortSignal }) => AsyncIterable<StreamChunk> /** Quiescence window; defaults to {@link DEFAULT_FENCE_QUIET_MS}. */ fenceQuietMs?: number /** Platform keep-alive (e.g. `ctx.waitUntil`) for the background drive. */ waitUntil?: (promise: Promise<unknown>) => void logger?: InternalLogger } /** * `pipe` ran without a held claim. Not a recoverable condition: it means the * returned options object was taken apart and `pipe` called outside `claim`, so * there is no epoch to fence with and no lease guaranteeing exclusivity. Any * append made in that state is exactly the duplicate-write bug the claim exists * to prevent, so this fails loudly rather than appending unfenced. */ export class RunDriverPipeOutsideClaimError extends Error { constructor(readonly runId: string) { super( `run ${runId}: sandboxRunDriver.pipe was called outside its claim, so the driver epoch is unknown; call it from within the claim callback`, ) this.name = 'RunDriverPipeOutsideClaimError' } } /** * Fill in a core `driver` block with this package's claim and run log. * * `drive` receives an `AbortSignal` — the driver owns the abort, so it hands * out a signal rather than a controller — but `chat()` takes an * `AbortController`. Mirror one onto the other, exactly as * {@link https://tanstack.com/ai/latest/docs/sandbox/takeover | Takeover & Detached Runs}'s * `controllerFor` does, so a lost claim actually stops the drive. * * @example * ```typescript * function controllerFor(signal: AbortSignal): AbortController { * const controller = new AbortController() * const abort = (): void => controller.abort(signal.reason) * if (signal.aborted) abort() * else signal.addEventListener('abort', abort, { once: true }) * return controller * } * * export async function GET(request: Request) { * return resumeServerSentEventsResponse({ * adapter: memoryStream(request), * driver: sandboxRunDriver({ * request, * runs, * locks, * durability: (runId) => logFor(runId), * drive: ({ runId, threadId, signal }) => * chat({ * ...config, * runId, * threadId, * abortController: controllerFor(signal), * }), * }), * }) * } * ``` */ export function sandboxRunDriver<TOffset extends string = string>( input: SandboxRunDriverOptions<TOffset>, ): RunDriverOptions { const fenceQuietMs = input.fenceQuietMs ?? DEFAULT_FENCE_QUIET_MS // The seam between core's `claim` and core's `pipe`. One options object serves // one attach request and therefore one run, so a single slot is enough; it is // cleared on the way out so a `pipe` after the claim released cannot reuse a // stale epoch. let current: RunClaim | undefined return { request: input.request, runs: input.runs, locks: input.locks, drive: input.drive, claim: (claimInput, fn) => withRunClaim( { ...claimInput, fenceQuietMs, ...(input.logger === undefined ? {} : { logger: input.logger }), }, async (claim) => { const previous = current current = claim try { return await fn(claim) } finally { current = previous } }, ), pipe: async (stream, i) => { const claim = current if (claim === undefined) { throw new RunDriverPipeOutsideClaimError(i.runId) } // Before the first append, never after: `pipeToRunLog` snapshots to align // and a predecessor still writing must be observed, not raced. await awaitLogQuiescence(input.durability(i.runId), fenceQuietMs) return pipeToRunLog(stream, { // BOTH authoritative seams are fenced at the epoch this driver actually // acquired, and they must be: `pipeToRunLog` answers a refused append by // recording a terminal record, so fencing only the log leaves a // superseded host marking a live run `'failed'` (see `fenceRunStore`). // Neither fence covers `close()` — that stays unfenced on purpose. runs: fenceRunStore(input.runs, claim, { ...(input.logger === undefined ? {} : { logger: input.logger }), }), durability: (runId) => fenceDurability(input.durability(runId), claim, { runs: input.runs, }), runId: i.runId, threadId: i.threadId, signal: i.signal, ...(input.logger === undefined ? {} : { logger: input.logger }), }) }, ...(input.waitUntil === undefined ? {} : { waitUntil: input.waitUntil }), ...(input.logger === undefined ? {} : { logger: input.logger }), } }