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.

264 lines (263 loc) 14.2 kB
import { JournalOptions } from './runner.js'; import { InternalLogger } from '@tanstack/ai/adapter-internals'; import { RunStore, StreamChunk, StreamDurability } from '@tanstack/ai'; /** `withSandbox(sandbox, { durability })`. */ export interface SandboxDurabilityOptions<TOffset extends string = string> { /** * Delivery-durable event log for the run. Same key and shape as the * transport's `durability.adapter`, so one adapter instance can be handed to * both `withSandbox` and `toServerSentEventsResponse`. * * Generic in the offset type, defaulted to `string`, for the same reason * {@link SandboxRunDriverOptions} and {@link ReapOptions} are: * `StreamDurability` is INVARIANT in `TOffset` (`read` takes an offset in), * so a backend that brands its cursors — `@tanstack/ai-durable-stream`'s * `durableStream`, the multi-host production backend the sandbox docs point * at — is not assignable to `StreamDurability<string>`. Without the parameter * the resume route could be wired with it and the route that STARTS the run * could not. */ adapter: StreamDurability<TOffset>; /** Journal directory inside the sandbox. Defaults to `/tmp/tanstack-runs`. */ journal?: string; /** * Whether a client disconnect DETACHES (leave the agent running) instead of * destroying the sandbox. Defaults to `true` whenever durability is wired, * because that is the whole point of wiring it. * * Set `false` to keep today's destroy-on-disconnect cost profile while still * getting resumable DELIVERY (a reload replays the log). An explicit cancel * destroys either way. */ detachOnDisconnect?: boolean; /** * Read an EXISTING run's journal instead of starting a new agent. Set by the * attach route's `drive()` callback, never by an application's POST handler. * * This is where `attach` lives, and deliberately NOT on `chat()`: `chat()` is * core and must not gain sandbox vocabulary, and the provider options are * per-model type state, not per-request lifecycle. */ attach?: boolean; /** Journal poll interval for providers that cannot follow. */ pollIntervalMs?: number; /** * How long an ATTACH waits for a live run's journal to appear before failing * with a `JournalAttachUnavailableError`. Defaults to * `DEFAULT_ATTACH_JOURNAL_WAIT_MS` (10s). Only the wait is configurable: an * unknown or terminal runId fails immediately regardless, since no amount of * waiting changes either verdict. */ attachWaitMs?: number; } /** * The view of a caller's event log that the capability bus carries. * * Deliberately NOT the whole `StreamDurability`. `read` is the only member that * takes an offset *in*, which is what makes `StreamDurability` invariant in * `TOffset` and a branded-cursor backend unassignable to * `StreamDurability<string>`. Every other member mentions the offset only in a * return position, so this type is a genuine SUPERTYPE of * `StreamDurability<TOffset>` for every `TOffset extends string` — which is the * one property that lets a single concrete capability instantiation accept a * branded backend. `createCapability<T>()` forces exactly one instantiation * (the value type is a plain type argument, and TypeScript has no higher-kinded * types), so the payload cannot be parameterized the way the *option* above is. * * Dropping `read` costs nothing, and that is a property of the seam rather than * luck: the bus is the JOURNAL/ALIGNMENT seam, and alignment reads the stored * prefix through `snapshot()` — never `read()`, which tails an open log forever * (see `alignToStoredLog`). Replay *by offset* belongs to the delivery seam, * and that seam (`toServerSentEventsResponse`, `sandboxRunDriver`) receives the * application's own adapter directly, with its brand intact. */ export type SandboxDurabilityLog = Omit<StreamDurability, 'read'>; /** * Resolved durability, published on the capability bus by `withSandbox`. * * Deliberately carries NO detached-run TTL. The only actor that enforces one is * `reapDetachedRuns`, which runs from a cron with no chat in flight — so it has * no `CapabilityContext` and cannot read this bus at all. A TTL published here * could therefore only ever be read by nobody, while the sweep took its own * `ReapOptions.detachedRunTtlMs`; the two would silently disagree. The reaper's * required option is the single source of truth. */ export interface SandboxRunDurability { runs: RunStore; adapter: SandboxDurabilityLog; journalDir: string; attach: boolean; detachOnDisconnect: boolean; pollIntervalMs?: number; attachWaitMs?: number; } /** * Provided by `withSandbox` only when a run is genuinely durable (both stores * wired). Harness adapters read it with `getOptional` and treat its absence as * "no journaling contract to honour", which is exactly today's behavior. */ export declare const SandboxDurabilityCapability: import('@tanstack/ai').Capability<SandboxRunDurability, "sandbox-durability">; /** Destructured accessors, matching `./capabilities`. */ export declare const getSandboxDurability: import('@tanstack/ai').CapabilityGetter<SandboxRunDurability>, provideSandboxDurability: import('@tanstack/ai').CapabilityProvider<SandboxRunDurability>; /** * A durable run was started without a caller-supplied `runId`. * * Thrown rather than defaulted because the failure is otherwise INVISIBLE: an * adapter-generated id (`${name}-${Date.now()}-${Math.random()...}`) produces a * journal path at `/tmp/tanstack-runs/<id>.ndjson` that no successor host can * recompute, so the run streams normally, records normally, and is silently * unrecoverable. A loud failure at the start of `chatStream` is strictly better * than a run that only reveals itself as non-durable during an incident. */ export declare class DurableRunIdRequiredError extends Error { readonly adapter: string; constructor(adapter: string); } /** * Resolve the `runId` a harness adapter will journal under. * * Replaces the bare `options.runId ?? this.generateId()` in every harness * adapter. The fallback is preserved for non-durable runs — several `chat()` * paths pass `runId` as a conditional spread, so `undefined` is reachable and * removing the fallback would break them for no benefit. * * The `durable` check runs BEFORE `fallback()`, and that ordering is load * bearing: a generated id must never be minted for a durable run, not even one * that is discarded, because the whole point is that no such id can exist. */ export declare function resolveDurableRunId(runId: string | undefined, options: { durable: boolean; adapter: string; fallback: () => string; }): string; /** * An ATTACHING durable run was driven without the run record's `threadId`. * * The sibling of {@link DurableRunIdRequiredError}, for the other id an attach * cannot mint for itself. `threadId` lands in EVERY chunk a harness adapter * emits (see each package's `stream/translate.ts`), so a replay that generates a * fresh one produces a stream that differs from the stored log in its very first * chunk. `alignToStoredLog` then fails at index 0 with a * `JournalReplayThreadIdMismatchError` — mid-stream, after the takeover has * already claimed the run. Refusing up front is strictly better, and mirrors * what `resolveDurableRunId` does for an id whose absence is equally fatal. * * Core already does its part: `startRunDriver` reads the record and hands * `active.threadId` to `drive({ runId, threadId, signal })`. This error exists * for the one gap it cannot close — application `drive` code that forgets to * forward it into `chat()`. */ export declare class DurableThreadIdRequiredError extends Error { readonly adapter: string; constructor(adapter: string); } /** * Resolve the `threadId` a harness adapter will stamp on every chunk. * * Replaces the bare `options.threadId ?? this.generateId()` in the journaling * harness adapters. Only the durable-AND-attaching quadrant throws; the other * three keep the generated fallback and are byte-identical to before: * * | durable | attaching | behavior | * | ------- | --------- | --------------------------------------------------- | * | no | no | fallback — a plain non-durable run | * | no | yes | fallback — not reachable today, and harmless anyway | * | yes | no | fallback — the FRESH run that ESTABLISHES the id | * | yes | yes | throw {@link DurableThreadIdRequiredError} | * * The durable-fresh row is the load-bearing one. A fresh durable run legitimately * mints its `threadId` (there is no record to reuse one from), so throwing on * `durable` alone — the obvious over-simplification — would break every durable * run that has ever worked. Only re-entering an existing run has an id it MUST * reuse, which is exactly the condition `attach` already expresses. * * As in `resolveDurableRunId`, the guard runs BEFORE `fallback()`: a generated id * must never be minted on this path, not even one that is then discarded. */ export declare function resolveDurableThreadId(threadId: string | undefined, options: { durable: boolean; attaching: boolean; adapter: string; fallback: () => string; }): string; /** * An ATTACH was driven into a code path that can never replay a run. * * The third sibling of {@link DurableRunIdRequiredError} and * {@link DurableThreadIdRequiredError}, and the one that is not about a missing * id: here every id is present and the path itself is the problem. * * `sandboxRunDriver`'s `drive()` re-invokes `chat()` with `attach: true`. On a * JOURNALING path that is genuinely a replay — `spawnNdjson` tails the journal * the previous host wrote, `awaitAttachableJournal` refuses a hopeless attach up * front, and `alignedIfAttaching` suppresses the prefix already delivered. A * protocol path with none of those three has no journal to tail and nothing to * align against, so `attach: true` does not resume anything: it starts the agent * over from scratch against the workspace the first attempt already mutated, and * appends its entire output to a log that still holds the first attempt's. * * Deliberately NOT a `JournalAttachUnavailableError`. That error means "a * journal that should exist has not appeared yet" — retryable, scoped to a wait * (`attachWaitMs`). This condition is categorically different: the path cannot * attach AT ALL, so telling a caller to wait would point it at something that is * never coming. A 5xx/501-shaped refusal, not a 504. * * `reason` names the missing capability in the adapter's own vocabulary (which * protocol, which spawn path), because the fix is always to change how the run * is spawned or routed, never to retry. */ export declare class DurableAttachNotSupportedError extends Error { readonly adapter: string; readonly reason: string; constructor(adapter: string, reason: string); } /** * Resolve `withSandbox`'s two durability options into the capability payload, or * `undefined` when the app has not opted in. * * BOTH `runs` and `durability` are required. A half-configured app gets * `undefined` **silently** rather than a warning: it has not asked for * durability, so there is nothing to warn about, and the resulting behavior * (destroy on disconnect, no journal) is exactly today's. */ export declare function resolveSandboxDurability<TOffset extends string = string>(options: { runs?: RunStore; durability?: SandboxDurabilityOptions<TOffset>; } | undefined): SandboxRunDurability | undefined; /** * Build the `spawnNdjson` journal option for a run, or `undefined` when the run * is not durable — in which case `spawnNdjson` takes its original, unjournaled * path (`isJournaled` tests `options.journal !== undefined`, `runner.ts:70-72`) * and behavior is byte-identical to a pre-durability run. * * `JournalOptions.dir` is optional, but this always supplies it: the resolved * durability has already defaulted `journalDir`, and a successor host must * recompute the same path rather than re-derive the default independently. * * `runs` and `attachWaitMs` are carried ONLY when attaching, and that is not a * micro-optimization: they exist for `awaitAttachableJournal`, which the reader * runs on the attach path alone. A fresh run has no journal yet BY DESIGN (its own * `journaledCommand` spawn creates it moments later), so handing it a run store * would only invite a future change to gate a path where absence proves nothing. */ export declare function journalOptionsFor(durability: SandboxRunDurability | undefined, runId: string): JournalOptions | undefined; /** * Align a harness stream against the run's stored log — but ONLY on an attach. * * The `attach` guard is not an optimization, it is a CORRECTNESS requirement. * `alignToStoredLog` snapshots the log before the first chunk is pulled and * treats everything in that snapshot as "already delivered". On a FRESH run that * premise is false: if such a run were aligned against a log that already holds * entries — a `runId` collision, a retried request — its own chunks would be * matched against those entries and silently SUPPRESSED instead of delivered, * which is silent data loss rather than a slow path. Aligning only when * re-entering an existing run keeps the transform's premise ("this stream is a * replay of what is already stored") actually true. * * `isBridgeCustomChunk` is passed because the stored log holds the previous * host's MERGED output, including live bridged-tool CUSTOM events that a replay * cannot reproduce; without it a bridged-tool run could not be taken over at * all. Wrap the merge RESULT, never the pre-merge translator, or the comparison * is against a stream the log never contained. */ export declare function alignedIfAttaching(chunks: AsyncIterable<StreamChunk>, durability: SandboxRunDurability | undefined, logger?: InternalLogger): AsyncIterable<StreamChunk>;