From d040b1645776050db1852a2370c3127c5b444712 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:42:47 +1000 Subject: [PATCH 1/2] feat(sandbox): SandboxInstanceStore + withSandboxInstanceStore Durable sandbox instance resume owned by @tanstack/ai-sandbox only. - SandboxInstanceStore / withSandboxInstanceStore / InMemorySandboxInstanceStore - withSandbox consumes the capability (in-memory fallback) - Conformance testkit, e2e durability, docs/sandbox/persistence - Does not touch core locks docs, chat persistence docs, or ai-persistence skills --- .changeset/sandbox-persistence.md | 13 ++ docs/config.json | 8 +- docs/sandbox/overview.md | 9 +- docs/sandbox/persistence.md | 182 ++++++++++++++++++ packages/ai-sandbox/README.md | 2 +- packages/ai-sandbox/package.json | 13 +- .../ai-sandbox/skills/ai-sandbox/SKILL.md | 32 +++ packages/ai-sandbox/src/capabilities.ts | 11 +- packages/ai-sandbox/src/index.ts | 20 +- packages/ai-sandbox/src/instance-store.ts | 116 +++++++++++ packages/ai-sandbox/src/middleware.ts | 15 +- packages/ai-sandbox/src/sandbox.ts | 12 +- packages/ai-sandbox/src/store.ts | 49 ----- .../ai-sandbox/src/testkit/conformance.ts | 102 ++++++++++ packages/ai-sandbox/src/workspace.ts | 2 +- packages/ai-sandbox/tests/ensure.test.ts | 4 +- .../tests/resume-from-store.test.ts | 76 ++++++++ .../tests/store.conformance.test.ts | 9 + packages/ai-sandbox/tests/store.test.ts | 52 ++++- packages/ai-sandbox/vite.config.ts | 8 +- pnpm-lock.yaml | 9 + testing/e2e/package.json | 2 + testing/e2e/src/routeTree.gen.ts | 21 ++ .../e2e/src/routes/api.sandbox-durability.ts | 170 ++++++++++++++++ testing/e2e/tests/sandbox-durability.spec.ts | 46 +++++ 25 files changed, 890 insertions(+), 93 deletions(-) create mode 100644 .changeset/sandbox-persistence.md create mode 100644 docs/sandbox/persistence.md create mode 100644 packages/ai-sandbox/src/instance-store.ts delete mode 100644 packages/ai-sandbox/src/store.ts create mode 100644 packages/ai-sandbox/src/testkit/conformance.ts create mode 100644 packages/ai-sandbox/tests/resume-from-store.test.ts create mode 100644 packages/ai-sandbox/tests/store.conformance.test.ts create mode 100644 testing/e2e/src/routes/api.sandbox-durability.ts create mode 100644 testing/e2e/tests/sandbox-durability.spec.ts diff --git a/.changeset/sandbox-persistence.md b/.changeset/sandbox-persistence.md new file mode 100644 index 000000000..1f4b8b6d5 --- /dev/null +++ b/.changeset/sandbox-persistence.md @@ -0,0 +1,13 @@ +--- +'@tanstack/ai': minor +'@tanstack/ai-sandbox': minor +'@tanstack/ai-persistence': minor +--- + +Durable **sandbox instance** resume for `@tanstack/ai-sandbox`, owned by the sandbox package (not chat persistence). + +- **`SandboxInstanceStore` / `SandboxInstanceRecord` / `InMemorySandboxInstanceStore` / `SandboxInstanceStoreCapability`** live in `@tanstack/ai-sandbox`. +- **`withSandboxInstanceStore(store)`** provides the capability; **`withSandbox`** consumes it in `ensure` (in-memory fallback when absent). +- **Locks** stay in `@tanstack/ai` (`withLocks`) — multi-instance mutual exclusion around resume-or-create. +- **Chat persistence is independent** — no `stores.sandboxInstance` on `@tanstack/ai-persistence`. Compose both middlewares when an app needs transcript durability _and_ instance reuse. +- Conformance: `runSandboxInstanceStoreConformance` from `@tanstack/ai-sandbox/testkit`. diff --git a/docs/config.json b/docs/config.json index d97a137c3..fa8ad32b0 100644 --- a/docs/config.json +++ b/docs/config.json @@ -445,7 +445,7 @@ "label": "Overview", "to": "sandbox/overview", "addedAt": "2026-06-16", - "updatedAt": "2026-07-03" + "updatedAt": "2026-07-27" }, { "label": "Quick Start", @@ -490,6 +490,12 @@ "addedAt": "2026-06-29", "updatedAt": "2026-07-09" }, + { + "label": "Instance Durability", + "to": "sandbox/persistence", + "addedAt": "2026-07-23", + "updatedAt": "2026-07-27" + }, { "label": "Events", "to": "sandbox/events", diff --git a/docs/sandbox/overview.md b/docs/sandbox/overview.md index 52adcf8ba..03e399325 100644 --- a/docs/sandbox/overview.md +++ b/docs/sandbox/overview.md @@ -133,7 +133,8 @@ hands back a live preview URL, see `examples/sandbox-web` — one app with harne (Claude Code / Codex / OpenCode / Grok) and provider (Docker / local / Vercel / Daytona) pickers. -> **Persistence-ready:** the sandbox layer ships with in-memory stores for -> resume bookkeeping. A future persistence package can provide durable -> `SandboxStore` / `LockStore` implementations (and event-log replay) by -> supplying those optional capabilities — no changes to the sandbox layer. +> **Durable instance resume:** bookkeeping defaults to in-memory (single-process). +> For cross-process / multi-instance reuse, see +> [Sandbox Instance Durability](./persistence): implement a +> `SandboxInstanceStore`, provide it with `withSandboxInstanceStore`, and pair a +> distributed `LockStore` via `withLocks` from `@tanstack/ai`. diff --git a/docs/sandbox/persistence.md b/docs/sandbox/persistence.md new file mode 100644 index 000000000..d0d840c4c --- /dev/null +++ b/docs/sandbox/persistence.md @@ -0,0 +1,182 @@ +--- +title: Sandbox Instance Durability +id: sandbox-persistence +order: 9 +description: "Durable sandbox instance resume across processes with SandboxInstanceStore and withSandboxInstanceStore." +--- + +Your agent runs behind more than one server instance, or at the edge. A run +spins up a sandbox, clones the repo, installs deps, does its work. The next run +for the same thread should pick that sandbox back up. Instead it builds a fresh +one every time and pays the whole cold-start cost again. + +[Lifecycle & Snapshots](./lifecycle) already knows how to resume, but its +bookkeeping is in-memory, so it only holds within one process. The moment a run +lands on a different replica (or a fresh isolate), that instance has never seen +the sandbox and re-creates it. + +**Sandbox instance durability** is runtime placement — not chat history. It is +owned by `@tanstack/ai-sandbox`, independent of `@tanstack/ai-persistence` +(transcript / runs / interrupts). You may share a database with chat stores, but +you compose a separate middleware. + +Two pieces: + +- **`SandboxInstanceStore`**: map of compound key → provider sandbox id (+ + optional snapshot). Durable, shared across instances. +- **`LockStore`** (from `@tanstack/ai`): mutual exclusion around resume-or-create. + Multi-instance needs a distributed lock. See [Locks](../advanced/locks). + +## Wire it up + +Middleware order: **providers before** `withSandbox`. + +```ts +import { chat, InMemoryLockStore, withLocks } from '@tanstack/ai' +import { grokBuildText } from '@tanstack/ai-grok-build' +import { + InMemorySandboxInstanceStore, + defineSandbox, + defineWorkspace, + withSandbox, + withSandboxInstanceStore, +} from '@tanstack/ai-sandbox' +import type { ModelMessage } from '@tanstack/ai' + +// Single-process: in-memory is fine for local dev. +// Multi-instance: your durable SandboxInstanceStore + distributed LockStore. +const instanceStore = new InMemorySandboxInstanceStore() +const messages: Array = [{ role: 'user', content: 'hi' }] + +const sandbox = defineSandbox({ + id: 'repo', + provider: { + name: 'example', + capabilities: () => ({ + fs: true, + exec: true, + env: true, + ports: false, + backgroundProcesses: false, + writableStdin: false, + snapshots: false, + networkPolicy: false, + durableFilesystem: false, + fork: false, + }), + create: () => { + throw new Error('example provider — wire a real SandboxProvider') + }, + resume: () => Promise.resolve(null), + destroy: () => Promise.resolve(), + }, + workspace: defineWorkspace({ source: { type: 'none' } }), +}) + +chat({ + adapter: grokBuildText('grok-build'), + messages, + middleware: [ + withSandboxInstanceStore(instanceStore), + withLocks(new InMemoryLockStore()), + withSandbox(sandbox), + ], +}) +``` + +With `reuse: 'thread'` (the default), the first run creates and records the +instance. A later run for the same `threadId` resumes it when the store (and +lock) are shared across processes. + +Optional chat persistence is independent: + +```ts +import { withLocks, InMemoryLockStore } from '@tanstack/ai' +import { withPersistence, memoryPersistence } from '@tanstack/ai-persistence' +import { + withSandbox, + withSandboxInstanceStore, +} from '@tanstack/ai-sandbox' +import type { SandboxInstanceStore } from '@tanstack/ai-sandbox' +import type { SandboxDefinition } from '@tanstack/ai-sandbox' + +declare const instanceStore: SandboxInstanceStore +declare const sandbox: SandboxDefinition + +const middleware = [ + withPersistence(memoryPersistence()), // chat state only + withSandboxInstanceStore(instanceStore), + withLocks(new InMemoryLockStore()), // multi-instance: distributed LockStore + withSandbox(sandbox), +] +``` + +## Implement `SandboxInstanceStore` + +```ts +import type { + SandboxInstanceRecord, + SandboxInstanceStore, +} from '@tanstack/ai-sandbox' + +export const instanceStore: SandboxInstanceStore = { + async get(_key) { + return null + }, + async upsert(_record: SandboxInstanceRecord) { + // insert or FULLY replace by record.key + }, + async delete(_key) { + // no-op if missing + }, +} +``` + +### Invariants (conformance) + +```ts +import { + runSandboxInstanceStoreConformance, +} from '@tanstack/ai-sandbox/testkit' +import type { SandboxInstanceStore } from '@tanstack/ai-sandbox' + +declare const instanceStore: SandboxInstanceStore + +runSandboxInstanceStoreConformance('my-instance-store', () => instanceStore) +``` + +| Method | Invariant | +| --- | --- | +| `get` | Missing key → `null` (not throw). | +| `upsert` | **Full replace** by `record.key`. Omitted optionals clear prior values. | +| `delete` | Missing key is a **no-op**. | +| timestamps | `updatedAt` is epoch **milliseconds**. | + +### Suggested schema (SQLite) + +```sql +CREATE TABLE IF NOT EXISTS sandbox_instances ( + key text PRIMARY KEY NOT NULL, + provider text NOT NULL, + provider_sandbox_id text NOT NULL, + latest_snapshot_id text, + thread_id text NOT NULL, + latest_run_id text, + updated_at integer NOT NULL +); +``` + +You can put this table next to chat tables in the same DB — that is an **app** +choice, not a requirement of `@tanstack/ai-persistence`. + +## Locks + +A durable instance map without a distributed lock is still wrong across +replicas. Pair `withSandboxInstanceStore` with `withLocks` from `@tanstack/ai`. +Full guide: [Locks](../advanced/locks). + +## See also + +- [Locks](../advanced/locks) +- [Lifecycle](./lifecycle) +- [Persistence overview](../persistence/overview) — chat state only diff --git a/packages/ai-sandbox/README.md b/packages/ai-sandbox/README.md index 5b73ddd4f..7f21b9fef 100644 --- a/packages/ai-sandbox/README.md +++ b/packages/ai-sandbox/README.md @@ -180,4 +180,4 @@ Full guides on [tanstack.com/ai](https://tanstack.com/ai/latest/docs/sandbox/ove Use a sandbox when the agent needs to **act on a real codebase** — run commands, edit files, clone repos, start dev servers. For read-only Q&A over code you already have in context, a normal `chat()` with server tools is enough. -Persistence (durable `SandboxStore` / `LockStore`, event-log replay) is out of scope for v1 but every seam is persistence-ready via optional capabilities. +Persistence (durable `SandboxInstanceStore` / `LockStore`, event-log replay) is out of scope for v1 but every seam is persistence-ready via optional capabilities. diff --git a/packages/ai-sandbox/package.json b/packages/ai-sandbox/package.json index 09796516a..c9206734b 100644 --- a/packages/ai-sandbox/package.json +++ b/packages/ai-sandbox/package.json @@ -33,6 +33,10 @@ "./ngrok": { "types": "./dist/esm/ngrok.d.ts", "import": "./dist/esm/ngrok.js" + }, + "./testkit": { + "types": "./dist/esm/testkit/conformance.d.ts", + "import": "./dist/esm/testkit/conformance.js" } }, "files": [ @@ -55,16 +59,21 @@ }, "peerDependencies": { "@ngrok/ngrok": "^1.0.0", - "@tanstack/ai": "workspace:^" + "@tanstack/ai": "workspace:^", + "vitest": "^4.1.10" }, "peerDependenciesMeta": { "@ngrok/ngrok": { "optional": true + }, + "vitest": { + "optional": true } }, "devDependencies": { "@ngrok/ngrok": "^1.7.0", "@tanstack/ai": "workspace:*", - "@vitest/coverage-v8": "4.0.14" + "@vitest/coverage-v8": "4.0.14", + "vitest": "^4.1.10" } } diff --git a/packages/ai-sandbox/skills/ai-sandbox/SKILL.md b/packages/ai-sandbox/skills/ai-sandbox/SKILL.md index b1914c4d9..ff727266e 100644 --- a/packages/ai-sandbox/skills/ai-sandbox/SKILL.md +++ b/packages/ai-sandbox/skills/ai-sandbox/SKILL.md @@ -211,6 +211,38 @@ const policy = defineSandboxPolicy({ provider + workspace hash + tenant so changing the repo/setup/image starts fresh. Ensure order: resume running → restore snapshot → create + bootstrap. +## Instance durability (durable resume) + +Resume bookkeeping defaults to in-memory (single-process). For cross-process / +multi-instance resume, implement a durable `SandboxInstanceStore` (BYO) and +provide it with `withSandboxInstanceStore`. Pair multi-instance with +`withLocks` from `@tanstack/ai`. Order: providers **before** `withSandbox`. + +```typescript +import { chat, InMemoryLockStore, withLocks } from '@tanstack/ai' +import { + InMemorySandboxInstanceStore, + withSandbox, + withSandboxInstanceStore, +} from '@tanstack/ai-sandbox' +// Production: your BYO store — docs/sandbox/persistence.md +import { instanceStore } from './sandbox-instance-store' + +chat({ + adapter, + messages, + middleware: [ + withSandboxInstanceStore(instanceStore), + withLocks(new InMemoryLockStore()), // multi-instance: distributed lock + withSandbox(sandbox), + ], +}) +``` + +Chat transcript durability (`withPersistence`) is independent — compose both +when the app needs history _and_ instance reuse. Prove adapters with +`runSandboxInstanceStoreConformance` from `@tanstack/ai-sandbox/testkit`. + ## File-event hooks Watch the workspace for create/change/delete events. Provider-agnostic: native diff --git a/packages/ai-sandbox/src/capabilities.ts b/packages/ai-sandbox/src/capabilities.ts index 9452d5235..d4f2c01db 100644 --- a/packages/ai-sandbox/src/capabilities.ts +++ b/packages/ai-sandbox/src/capabilities.ts @@ -3,21 +3,17 @@ * * - `SandboxCapability` is PROVIDED by `withSandbox` and REQUIRED by harness * adapters (`requires: [SandboxCapability]`). - * - `SandboxStoreCapability` is OPTIONALLY required by `withSandbox` (in-memory - * fallback when absent). - * - `LocksCapability` lives in `@tanstack/ai` and is not re-exported here. + * - `SandboxInstanceStoreCapability` / `withSandboxInstanceStore` live in + * `./instance-store` (same package). `LocksCapability` / `withLocks` stay in + * `@tanstack/ai` and are not re-exported here. */ import { createCapability } from '@tanstack/ai' import type { SandboxHandle } from './contracts' -import type { SandboxStore } from './store' import type { SandboxPolicy } from './policy' import type { ToolBridgeProvisioner } from './tool-bridge' export const SandboxCapability = createCapability()('sandbox') -export const SandboxStoreCapability = - createCapability()('sandbox-store') - /** * The active sandbox policy, provided by `withSandbox` from the definition. * Harness adapters read it to map allow/ask/deny rules onto their native @@ -37,7 +33,6 @@ export const ToolBridgeProvisionerCapability = /** Destructured accessors for adapters: `getSandbox(ctx)` reads the handle. */ export const [getSandbox, provideSandbox] = SandboxCapability -export const [getSandboxStore, provideSandboxStore] = SandboxStoreCapability export const [getSandboxPolicy, provideSandboxPolicy] = SandboxPolicyCapability export const [getToolBridgeProvisioner, provideToolBridgeProvisioner] = ToolBridgeProvisionerCapability diff --git a/packages/ai-sandbox/src/index.ts b/packages/ai-sandbox/src/index.ts index 51698d831..6788ca3b7 100644 --- a/packages/ai-sandbox/src/index.ts +++ b/packages/ai-sandbox/src/index.ts @@ -2,19 +2,29 @@ // LockStore / withLocks: import from @tanstack/ai. export { SandboxCapability, - SandboxStoreCapability, SandboxPolicyCapability, ToolBridgeProvisionerCapability, getSandbox, provideSandbox, - getSandboxStore, - provideSandboxStore, getSandboxPolicy, provideSandboxPolicy, getToolBridgeProvisioner, provideToolBridgeProvisioner, } from './capabilities' +// Durable instance map (resume-or-create across processes) +export { + SandboxInstanceStoreCapability, + getSandboxInstanceStore, + provideSandboxInstanceStore, + InMemorySandboxInstanceStore, + withSandboxInstanceStore, +} from './instance-store' +export type { + SandboxInstanceStore, + SandboxInstanceRecord, +} from './instance-store' + // Workspace projection capability (provided by withSandbox, consumed by harness adapters) export { ProjectionCapability, @@ -98,10 +108,6 @@ export type { SandboxDestroyInput, } from './contracts' -// Instance map (interfaces + in-memory default) -export { InMemorySandboxStore } from './store' -export type { SandboxStore, SandboxRecord } from './store' - // Bootstrap engine (exported for provider/adapter authors + tests) export { bootstrapWorkspace, diff --git a/packages/ai-sandbox/src/instance-store.ts b/packages/ai-sandbox/src/instance-store.ts new file mode 100644 index 000000000..a756383ca --- /dev/null +++ b/packages/ai-sandbox/src/instance-store.ts @@ -0,0 +1,116 @@ +/** + * Durable sandbox **instance** map — which provider sandbox (and snapshot) to + * resume for a compound key. Owned by `@tanstack/ai-sandbox` (not chat + * persistence): domain is runtime placement for `ensure`, not conversation state. + * + * Provide with {@link withSandboxInstanceStore}; {@link withSandbox} consumes + * the same {@link SandboxInstanceStoreCapability} in `ensure` (in-memory + * fallback when absent). + */ +import { createCapability, defineChatMiddleware } from '@tanstack/ai' +import type { ChatMiddleware, ChatMiddlewareContext } from '@tanstack/ai' + +/** One persisted sandbox instance, keyed by the compound sandbox instance key. */ +export interface SandboxInstanceRecord { + /** Compound key (see `computeSandboxKey`). */ + key: string + /** Provider name that owns `providerSandboxId`. */ + provider: string + /** Provider-assigned sandbox id used to resume. */ + providerSandboxId: string + /** Most recent snapshot id, when the provider supports snapshots. */ + latestSnapshotId?: string + threadId: string + latestRunId?: string + /** + * Epoch ms of last write (for keepAlive / GC by the host app). + */ + updatedAt: number +} + +/** + * Maps a compound key to the provider sandbox that should be resumed. + * + * Implement against your own database (BYO). Prove the contract with + * `runSandboxInstanceStoreConformance` from `@tanstack/ai-sandbox/testkit`. + */ +export interface SandboxInstanceStore { + /** + * Return the record for `key`, or `null` if none exists. + * + * INVARIANT: missing keys return `null` (never throw). + */ + get: (key: string) => Promise + /** + * Insert or fully replace the record for `record.key`. + * + * INVARIANT (full replace): omitted optional fields (`latestSnapshotId`, + * `latestRunId`) MUST clear any previously stored values. Do not merge with + * the prior row — a create-without-snapshot path must not leave a stale + * snapshot id. + */ + upsert: (record: SandboxInstanceRecord) => Promise + /** + * Remove the record for `key`. + * + * INVARIANT: deleting a missing key is a **no-op** (must not throw). + */ + delete: (key: string) => Promise +} + +/** + * Capability for the instance map. Provided by {@link withSandboxInstanceStore}; + * consumed by {@link withSandbox}. + */ +export const SandboxInstanceStoreCapability = + createCapability()('sandbox-instance-store') + +/** Destructured accessors: `getSandboxInstanceStore` / `provideSandboxInstanceStore`. */ +export const [getSandboxInstanceStore, provideSandboxInstanceStore] = + SandboxInstanceStoreCapability + +/** In-memory {@link SandboxInstanceStore}. Resume works only within one process. */ +export class InMemorySandboxInstanceStore implements SandboxInstanceStore { + private readonly map = new Map() + + get(key: string): Promise { + return Promise.resolve(this.map.get(key) ?? null) + } + + upsert(record: SandboxInstanceRecord): Promise { + this.map.set(record.key, record) + return Promise.resolve() + } + + delete(key: string): Promise { + this.map.delete(key) + return Promise.resolve() + } +} + +/** + * Provide a durable {@link SandboxInstanceStore} on the chat capability bus so + * later {@link withSandbox} can resume across processes. + * + * Compose **before** `withSandbox`. Independent of chat state persistence — + * pair with `withPersistence` only if the app also needs transcript durability. + * + * ```ts + * middleware: [ + * withSandboxInstanceStore(instanceStore), + * withLocks(locks), // from @tanstack/ai — multi-instance + * withSandbox(sandbox), + * ] + * ``` + */ +export function withSandboxInstanceStore( + store: SandboxInstanceStore, +): ChatMiddleware { + return defineChatMiddleware({ + name: 'sandbox-instance-store', + provides: [SandboxInstanceStoreCapability], + setup(ctx: ChatMiddlewareContext) { + provideSandboxInstanceStore(ctx, store) + }, + }) +} diff --git a/packages/ai-sandbox/src/middleware.ts b/packages/ai-sandbox/src/middleware.ts index b58e3a381..f6741fac9 100644 --- a/packages/ai-sandbox/src/middleware.ts +++ b/packages/ai-sandbox/src/middleware.ts @@ -3,10 +3,11 @@ * {@link SandboxCapability} a harness adapter requires. * * - `setup`: resume-or-create the sandbox (via the definition's ensure - * algorithm), provide the handle, using optional SandboxStoreCapability / - * LocksCapability when provided earlier (in-memory fallback otherwise). - * If `fileEvents` is not false, starts a watcher that dispatches to - * sandbox-scoped hooks and forwards to the runtime sink. + * algorithm), provide the handle, using optional + * SandboxInstanceStoreCapability / LocksCapability when provided earlier + * (in-memory fallback otherwise). If `fileEvents` is not false, starts a + * watcher that dispatches to sandbox-scoped hooks and forwards to the runtime + * sink. * - `onFinish`/`onAbort`/`onError`: stop the watcher, snapshot (`after-run`) * and/or destroy per lifecycle. * @@ -18,10 +19,10 @@ import { LocksCapability, defineChatMiddleware } from '@tanstack/ai' import { getSandboxRuntime } from '@tanstack/ai/adapter-internals' import { SandboxCapability, - SandboxStoreCapability, provideSandbox, provideSandboxPolicy, } from './capabilities' +import { SandboxInstanceStoreCapability } from './instance-store' import { computeWorkspaceHash } from './key' import { buildFileHookEvent, resolveFileEvents } from './file-diff' import { ProjectionCapability, provideWorkspaceProjection } from './projection' @@ -97,7 +98,7 @@ function buildEnsureCtx(ctx: ChatMiddlewareContext): SandboxEnsureContext { return { threadId: ctx.threadId, runId: ctx.runId, - store: ctx.getOptional(SandboxStoreCapability), + store: ctx.getOptional(SandboxInstanceStoreCapability), locks: ctx.getOptional(LocksCapability), tenant: tenantFrom(ctx.context), signal: ctx.signal, @@ -153,7 +154,7 @@ export function withSandbox( // SandboxPolicyCapability is provided conditionally (only when the // definition has a policy), so it is intentionally NOT declared here — // consumers read it via `getOptional`. - optionalRequires: [SandboxStoreCapability, LocksCapability], + optionalRequires: [SandboxInstanceStoreCapability, LocksCapability], async setup(ctx) { const ensureCtx = buildEnsureCtx(ctx) diff --git a/packages/ai-sandbox/src/sandbox.ts b/packages/ai-sandbox/src/sandbox.ts index 37c592150..642c35a2f 100644 --- a/packages/ai-sandbox/src/sandbox.ts +++ b/packages/ai-sandbox/src/sandbox.ts @@ -6,15 +6,15 @@ * into a stable instance key and coordinates through the (optional) lock + * sandbox stores. */ -import { InMemoryLockStore } from '@tanstack/ai' -import type { LockStore, SandboxFileHookEvent } from '@tanstack/ai' import { bootstrapWorkspace } from './bootstrap' import { resolveAllSecrets } from './secrets' import { computeSandboxKey } from './key' -import { InMemorySandboxStore } from './store' +import { InMemoryLockStore } from '@tanstack/ai' +import type { LockStore, SandboxFileHookEvent } from '@tanstack/ai' +import { InMemorySandboxInstanceStore } from './instance-store' +import type { SandboxInstanceStore } from './instance-store' import type { SandboxHandle, SandboxProvider } from './contracts' import type { SandboxKeyInput } from './key' -import type { SandboxStore } from './store' import type { SandboxPolicy } from './policy' import type { WorkspaceDefinition } from './workspace' @@ -70,7 +70,7 @@ export interface SandboxEnsureContext { threadId: string runId: string /** Persistence seam; falls back to an in-memory store when absent. */ - store?: SandboxStore + store?: SandboxInstanceStore /** Lock seam; falls back to an in-memory lock when absent. */ locks?: LockStore tenant?: { userId?: string; orgId?: string } @@ -112,7 +112,7 @@ function parseMaxAgeMs(value: string | undefined): number | undefined { // Process-lifetime fallbacks shared across all definitions so concurrent // ensures for the same key serialize even without an injected store/lock. -const fallbackStore = new InMemorySandboxStore() +const fallbackStore = new InMemorySandboxInstanceStore() const fallbackLocks = new InMemoryLockStore() export function defineSandbox(config: SandboxConfig): SandboxDefinition { diff --git a/packages/ai-sandbox/src/store.ts b/packages/ai-sandbox/src/store.ts deleted file mode 100644 index 72d8255e6..000000000 --- a/packages/ai-sandbox/src/store.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Sandbox instance map (in-memory for single-process resume). - * - * Durable multi-process resume is a follow-up on the sandbox package - * (`SandboxInstanceStore` / `withSandboxInstanceStore`). Locks live in - * `@tanstack/ai` (`LockStore` / `withLocks`) — not here. - */ - -/** One sandbox instance, keyed by the compound sandbox instance key. */ -export interface SandboxRecord { - /** Compound key (see computeSandboxKey). */ - key: string - /** Provider name that owns `providerSandboxId`. */ - provider: string - /** Provider-assigned sandbox id used to resume. */ - providerSandboxId: string - /** Most recent snapshot id, when the provider supports snapshots. */ - latestSnapshotId?: string - threadId: string - latestRunId?: string - /** Epoch ms of last write (for keepAlive / GC by the host). */ - updatedAt: number -} - -/** Maps a compound key to the provider sandbox that should be resumed. */ -export interface SandboxStore { - get: (key: string) => Promise - upsert: (record: SandboxRecord) => Promise - delete: (key: string) => Promise -} - -/** In-memory {@link SandboxStore}. Resume works only within one process. */ -export class InMemorySandboxStore implements SandboxStore { - private readonly map = new Map() - - get(key: string): Promise { - return Promise.resolve(this.map.get(key) ?? null) - } - - upsert(record: SandboxRecord): Promise { - this.map.set(record.key, record) - return Promise.resolve() - } - - delete(key: string): Promise { - this.map.delete(key) - return Promise.resolve() - } -} diff --git a/packages/ai-sandbox/src/testkit/conformance.ts b/packages/ai-sandbox/src/testkit/conformance.ts new file mode 100644 index 000000000..e0f5660cb --- /dev/null +++ b/packages/ai-sandbox/src/testkit/conformance.ts @@ -0,0 +1,102 @@ +/** + * Conformance suite for a {@link SandboxInstanceStore} implementation. + * + * Run this against a fresh BYO store (or the in-memory reference) to prove it + * satisfies the get / upsert / delete contract `@tanstack/ai-sandbox`'s ensure + * algorithm relies on — including insert-vs-overwrite and optional-field + * handling (full replace must clear omitted optionals). + * + * Vitest is an OPTIONAL peer dependency: this module is imported only from test + * files, which already run under Vitest. + */ +import { describe, expect, it } from 'vitest' +import type { + SandboxInstanceRecord, + SandboxInstanceStore, +} from '../instance-store' + +function makeRecord( + overrides?: Partial, +): SandboxInstanceRecord { + return { + key: 'thread-1', + provider: 'fake', + providerSandboxId: 'sb-1', + threadId: 'thread-1', + updatedAt: 1, + ...overrides, + } +} + +/** + * Assert `makeStore()` produces a spec-compliant {@link SandboxInstanceStore}. Each + * `it` gets a fresh store, so implementations may share process state across + * calls without cross-test bleed only if `makeStore` returns an isolated store. + */ +export function runSandboxInstanceStoreConformance( + name: string, + makeStore: () => SandboxInstanceStore | Promise, +): void { + describe(`SandboxInstanceStore conformance: ${name}`, () => { + it('returns null for a missing key', async () => { + const store = await makeStore() + expect(await store.get('absent')).toBeNull() + }) + + it('round-trips an upserted record with all fields', async () => { + const store = await makeStore() + const record = makeRecord({ + latestSnapshotId: 'snap-1', + latestRunId: 'run-1', + }) + await store.upsert(record) + expect(await store.get(record.key)).toEqual(record) + }) + + it('omits absent optional fields on read', async () => { + const store = await makeStore() + const record = makeRecord() + await store.upsert(record) + const loaded = await store.get(record.key) + expect(loaded).toEqual(record) + expect(loaded && 'latestSnapshotId' in loaded).toBe(false) + expect(loaded && 'latestRunId' in loaded).toBe(false) + }) + + it('overwrites an existing record on re-upsert', async () => { + const store = await makeStore() + await store.upsert(makeRecord({ latestSnapshotId: 'snap-1' })) + await store.upsert( + makeRecord({ providerSandboxId: 'sb-2', updatedAt: 2 }), + ) + const loaded = await store.get('thread-1') + expect(loaded?.providerSandboxId).toBe('sb-2') + expect(loaded?.updatedAt).toBe(2) + // The overwrite dropped latestSnapshotId — a durable store must clear it, + // not retain the prior value. + expect(loaded && 'latestSnapshotId' in loaded).toBe(false) + }) + + it('isolates records by key', async () => { + const store = await makeStore() + await store.upsert(makeRecord({ key: 'a', threadId: 'a' })) + await store.upsert( + makeRecord({ key: 'b', threadId: 'b', providerSandboxId: 'sb-b' }), + ) + expect((await store.get('a'))?.providerSandboxId).toBe('sb-1') + expect((await store.get('b'))?.providerSandboxId).toBe('sb-b') + }) + + it('deletes a record', async () => { + const store = await makeStore() + await store.upsert(makeRecord()) + await store.delete('thread-1') + expect(await store.get('thread-1')).toBeNull() + }) + + it('delete of a missing key is a no-op', async () => { + const store = await makeStore() + await expect(store.delete('absent')).resolves.toBeUndefined() + }) + }) +} diff --git a/packages/ai-sandbox/src/workspace.ts b/packages/ai-sandbox/src/workspace.ts index c3e2ae872..0a9a602b3 100644 --- a/packages/ai-sandbox/src/workspace.ts +++ b/packages/ai-sandbox/src/workspace.ts @@ -137,7 +137,7 @@ export interface WorkspaceDefinition { /** * Typed secret references. The underlying values are injected into the * sandbox env at create/resume — NEVER written to snapshots, the - * SandboxStore, or the event log. + * SandboxInstanceStore, or the event log. */ secrets?: Secrets /** Workspace root inside the sandbox. Defaults to `/workspace`. */ diff --git a/packages/ai-sandbox/tests/ensure.test.ts b/packages/ai-sandbox/tests/ensure.test.ts index c02d521e2..d836ae236 100644 --- a/packages/ai-sandbox/tests/ensure.test.ts +++ b/packages/ai-sandbox/tests/ensure.test.ts @@ -2,14 +2,14 @@ import { describe, expect, it } from 'vitest' import { defineSandbox } from '../src/sandbox' import { defineWorkspace, githubRepo } from '../src/workspace' import { InMemoryLockStore } from '@tanstack/ai' -import { InMemorySandboxStore } from '../src/store' +import { InMemorySandboxInstanceStore } from '../src/instance-store' import { FULL_CAPS, makeFakeProvider } from './fakes' import type { SandboxCapabilities } from '../src/contracts' const baseCtx = () => ({ threadId: 'thread-1', runId: 'run-1', - store: new InMemorySandboxStore(), + store: new InMemorySandboxInstanceStore(), locks: new InMemoryLockStore(), }) diff --git a/packages/ai-sandbox/tests/resume-from-store.test.ts b/packages/ai-sandbox/tests/resume-from-store.test.ts new file mode 100644 index 000000000..14851b40c --- /dev/null +++ b/packages/ai-sandbox/tests/resume-from-store.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { EventType, InMemoryLockStore, chat, withLocks } from '@tanstack/ai' +import { + InMemorySandboxInstanceStore, + withSandboxInstanceStore, +} from '../src/instance-store' +import { withSandbox } from '../src/middleware' +import { defineSandbox } from '../src/sandbox' +import { defineWorkspace } from '../src/workspace' +import { makeFakeProvider } from './fakes' +import type { AnyTextAdapter, StreamChunk } from '@tanstack/ai' + +function mockAdapter(): AnyTextAdapter { + return { + kind: 'text', + name: 'mock', + model: 'test-model', + '~types': {}, + chatStream: ({ runId, threadId }: { runId: string; threadId: string }) => + (async function* (): AsyncGenerator { + yield { type: EventType.RUN_STARTED, runId, threadId, timestamp: 1 } + yield { + type: EventType.RUN_FINISHED, + runId, + threadId, + finishReason: 'stop', + timestamp: 1, + } + })(), + structuredOutput: async () => ({ data: {}, rawText: '{}' }), + } as unknown as AnyTextAdapter +} + +async function drain(stream: AsyncIterable): Promise { + for await (const _ of stream) void _ +} + +describe('withSandbox resume from withSandboxInstanceStore', () => { + it('resumes the persisted instance across runs instead of re-creating it', async () => { + const provider = makeFakeProvider() + const instanceStore = new InMemorySandboxInstanceStore() + const locks = new InMemoryLockStore() + const sandbox = defineSandbox({ + id: 'repo', + provider, + workspace: defineWorkspace({ source: { type: 'none' } }), + fileEvents: false, + }) + + const run = (runId: string) => + drain( + chat({ + adapter: mockAdapter(), + messages: [{ role: 'user', content: 'hi' }], + runId, + threadId: 'thread-1', + middleware: [ + withSandboxInstanceStore(instanceStore), + withLocks(locks), + withSandbox(sandbox), + ], + }), + ) + + await run('run-1') + await run('run-2') + + expect(provider.calls.create).toBe(1) + expect(provider.calls.resume).toBe(1) + + const key = sandbox.key({ threadId: 'thread-1', runId: 'run-2' }) + const record = await instanceStore.get(key) + expect(record?.providerSandboxId).toBeTruthy() + expect(record?.latestRunId).toBe('run-2') + }) +}) diff --git a/packages/ai-sandbox/tests/store.conformance.test.ts b/packages/ai-sandbox/tests/store.conformance.test.ts new file mode 100644 index 000000000..bf8a68cf3 --- /dev/null +++ b/packages/ai-sandbox/tests/store.conformance.test.ts @@ -0,0 +1,9 @@ +import { runSandboxInstanceStoreConformance } from '../src/testkit/conformance' +import { InMemorySandboxInstanceStore } from '../src/instance-store' + +// The reference in-memory store must satisfy the same contract the durable +// backends are held to. +runSandboxInstanceStoreConformance( + 'in-memory', + () => new InMemorySandboxInstanceStore(), +) diff --git a/packages/ai-sandbox/tests/store.test.ts b/packages/ai-sandbox/tests/store.test.ts index 310a69d82..efffd9422 100644 --- a/packages/ai-sandbox/tests/store.test.ts +++ b/packages/ai-sandbox/tests/store.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from 'vitest' -import { InMemorySandboxStore } from '../src/store' +import { InMemoryLockStore } from '@tanstack/ai' +import { InMemorySandboxInstanceStore } from '../src/instance-store' -describe('InMemorySandboxStore', () => { +describe('InMemorySandboxInstanceStore', () => { it('round-trips upsert/get/delete', async () => { - const store = new InMemorySandboxStore() + const store = new InMemorySandboxInstanceStore() expect(await store.get('k')).toBeNull() await store.upsert({ key: 'k', @@ -17,3 +18,48 @@ describe('InMemorySandboxStore', () => { expect(await store.get('k')).toBeNull() }) }) + +describe('InMemoryLockStore', () => { + it('serializes same-key critical sections', async () => { + const locks = new InMemoryLockStore() + const order: Array = [] + const slow = (tag: string, ms: number) => + locks.withLock('k', async () => { + order.push(`${tag}:start`) + await new Promise((r) => setTimeout(r, ms)) + order.push(`${tag}:end`) + }) + + await Promise.all([slow('a', 20), slow('b', 1)]) + + // b cannot start until a fully ends. + expect(order).toEqual(['a:start', 'a:end', 'b:start', 'b:end']) + }) + + it('a rejection in one holder does not poison the lock', async () => { + const locks = new InMemoryLockStore() + await expect( + locks.withLock('k', () => Promise.reject(new Error('boom'))), + ).rejects.toThrow('boom') + // subsequent acquire still works + await expect( + locks.withLock('k', () => Promise.resolve('ok')), + ).resolves.toBe('ok') + }) + + it('runs different keys concurrently', async () => { + const locks = new InMemoryLockStore() + const order: Array = [] + await Promise.all([ + locks.withLock('a', async () => { + await new Promise((r) => setTimeout(r, 20)) + order.push('a') + }), + locks.withLock('b', async () => { + order.push('b') + }), + ]) + // b (different key) finishes first despite a starting first + expect(order).toEqual(['b', 'a']) + }) +}) diff --git a/packages/ai-sandbox/vite.config.ts b/packages/ai-sandbox/vite.config.ts index 27306737e..5eaa0e507 100644 --- a/packages/ai-sandbox/vite.config.ts +++ b/packages/ai-sandbox/vite.config.ts @@ -32,9 +32,13 @@ export default mergeConfig( tanstackViteConfig({ // Core entry + the optional ngrok tool-bridge provisioner subpath // (`@tanstack/ai-sandbox/ngrok`), which lazy-loads the optional `@ngrok/ngrok` - // peer dep so the core never pulls in its native binary. - entry: ['./src/index.ts', './src/ngrok.ts'], + // peer dep so the core never pulls in its native binary + the SandboxInstanceStore + // conformance testkit (`@tanstack/ai-sandbox/testkit`). + entry: ['./src/index.ts', './src/ngrok.ts', './src/testkit/conformance.ts'], srcDir: './src', + // The conformance testkit imports Vitest; keep it external so the built + // artifact references the consumer's Vitest at test time. + externalDeps: ['vitest'], cjs: false, }), ) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index caec892a8..78954521a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2203,6 +2203,9 @@ importers: '@vitest/coverage-v8': specifier: 4.0.14 version: 4.0.14(supports-color@7.2.0)(vitest@4.1.10) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.0.14)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.19))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) packages/ai-sandbox-cloudflare: dependencies: @@ -2642,12 +2645,18 @@ importers: '@tanstack/ai-openrouter': specifier: workspace:* version: link:../../packages/ai-openrouter + '@tanstack/ai-persistence': + specifier: workspace:* + version: link:../../packages/ai-persistence '@tanstack/ai-react': specifier: workspace:* version: link:../../packages/ai-react '@tanstack/ai-react-ui': specifier: workspace:* version: link:../../packages/ai-react-ui + '@tanstack/ai-sandbox': + specifier: workspace:* + version: link:../../packages/ai-sandbox '@tanstack/devtools-event-bus': specifier: ^0.4.1 version: 0.4.1 diff --git a/testing/e2e/package.json b/testing/e2e/package.json index b84b22a92..378b550fd 100644 --- a/testing/e2e/package.json +++ b/testing/e2e/package.json @@ -32,8 +32,10 @@ "@tanstack/ai-ollama": "workspace:*", "@tanstack/ai-openai": "workspace:*", "@tanstack/ai-openrouter": "workspace:*", + "@tanstack/ai-persistence": "workspace:*", "@tanstack/ai-react": "workspace:*", "@tanstack/ai-react-ui": "workspace:*", + "@tanstack/ai-sandbox": "workspace:*", "@tanstack/devtools-event-bus": "^0.4.1", "@tanstack/nitro-v2-vite-plugin": "^1.155.0", "@tanstack/react-ai-devtools": "workspace:*", diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index 4abac986d..54dc78951 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -31,6 +31,7 @@ import { Route as ApiTranscriptionRouteImport } from './routes/api.transcription import { Route as ApiToolsTestRouteImport } from './routes/api.tools-test' import { Route as ApiToolCallLifecycleWireRouteImport } from './routes/api.tool-call-lifecycle-wire' import { Route as ApiSummarizeRouteImport } from './routes/api.summarize' +import { Route as ApiSandboxDurabilityRouteImport } from './routes/api.sandbox-durability' import { Route as ApiPersistenceDurabilityRouteImport } from './routes/api.persistence-durability' import { Route as ApiOtelUsageRouteImport } from './routes/api.otel-usage' import { Route as ApiOtelMediaRouteImport } from './routes/api.otel-media' @@ -179,6 +180,11 @@ const ApiSummarizeRoute = ApiSummarizeRouteImport.update({ path: '/api/summarize', getParentRoute: () => rootRouteImport, } as any) +const ApiSandboxDurabilityRoute = ApiSandboxDurabilityRouteImport.update({ + id: '/api/sandbox-durability', + path: '/api/sandbox-durability', + getParentRoute: () => rootRouteImport, +} as any) const ApiPersistenceDurabilityRoute = ApiPersistenceDurabilityRouteImport.update({ id: '/api/persistence-durability', @@ -412,6 +418,7 @@ export interface FileRoutesByFullPath { '/api/otel-media': typeof ApiOtelMediaRoute '/api/otel-usage': typeof ApiOtelUsageRoute '/api/persistence-durability': typeof ApiPersistenceDurabilityRoute + '/api/sandbox-durability': typeof ApiSandboxDurabilityRoute '/api/summarize': typeof ApiSummarizeRoute '/api/tool-call-lifecycle-wire': typeof ApiToolCallLifecycleWireRoute '/api/tools-test': typeof ApiToolsTestRoute @@ -472,6 +479,7 @@ export interface FileRoutesByTo { '/api/otel-media': typeof ApiOtelMediaRoute '/api/otel-usage': typeof ApiOtelUsageRoute '/api/persistence-durability': typeof ApiPersistenceDurabilityRoute + '/api/sandbox-durability': typeof ApiSandboxDurabilityRoute '/api/summarize': typeof ApiSummarizeRoute '/api/tool-call-lifecycle-wire': typeof ApiToolCallLifecycleWireRoute '/api/tools-test': typeof ApiToolsTestRoute @@ -533,6 +541,7 @@ export interface FileRoutesById { '/api/otel-media': typeof ApiOtelMediaRoute '/api/otel-usage': typeof ApiOtelUsageRoute '/api/persistence-durability': typeof ApiPersistenceDurabilityRoute + '/api/sandbox-durability': typeof ApiSandboxDurabilityRoute '/api/summarize': typeof ApiSummarizeRoute '/api/tool-call-lifecycle-wire': typeof ApiToolCallLifecycleWireRoute '/api/tools-test': typeof ApiToolsTestRoute @@ -595,6 +604,7 @@ export interface FileRouteTypes { | '/api/otel-media' | '/api/otel-usage' | '/api/persistence-durability' + | '/api/sandbox-durability' | '/api/summarize' | '/api/tool-call-lifecycle-wire' | '/api/tools-test' @@ -655,6 +665,7 @@ export interface FileRouteTypes { | '/api/otel-media' | '/api/otel-usage' | '/api/persistence-durability' + | '/api/sandbox-durability' | '/api/summarize' | '/api/tool-call-lifecycle-wire' | '/api/tools-test' @@ -715,6 +726,7 @@ export interface FileRouteTypes { | '/api/otel-media' | '/api/otel-usage' | '/api/persistence-durability' + | '/api/sandbox-durability' | '/api/summarize' | '/api/tool-call-lifecycle-wire' | '/api/tools-test' @@ -776,6 +788,7 @@ export interface RootRouteChildren { ApiOtelMediaRoute: typeof ApiOtelMediaRoute ApiOtelUsageRoute: typeof ApiOtelUsageRoute ApiPersistenceDurabilityRoute: typeof ApiPersistenceDurabilityRoute + ApiSandboxDurabilityRoute: typeof ApiSandboxDurabilityRoute ApiSummarizeRoute: typeof ApiSummarizeRoute ApiToolCallLifecycleWireRoute: typeof ApiToolCallLifecycleWireRoute ApiToolsTestRoute: typeof ApiToolsTestRoute @@ -941,6 +954,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiSummarizeRouteImport parentRoute: typeof rootRouteImport } + '/api/sandbox-durability': { + id: '/api/sandbox-durability' + path: '/api/sandbox-durability' + fullPath: '/api/sandbox-durability' + preLoaderRoute: typeof ApiSandboxDurabilityRouteImport + parentRoute: typeof rootRouteImport + } '/api/persistence-durability': { id: '/api/persistence-durability' path: '/api/persistence-durability' @@ -1301,6 +1321,7 @@ const rootRouteChildren: RootRouteChildren = { ApiOtelMediaRoute: ApiOtelMediaRoute, ApiOtelUsageRoute: ApiOtelUsageRoute, ApiPersistenceDurabilityRoute: ApiPersistenceDurabilityRoute, + ApiSandboxDurabilityRoute: ApiSandboxDurabilityRoute, ApiSummarizeRoute: ApiSummarizeRoute, ApiToolCallLifecycleWireRoute: ApiToolCallLifecycleWireRoute, ApiToolsTestRoute: ApiToolsTestRoute, diff --git a/testing/e2e/src/routes/api.sandbox-durability.ts b/testing/e2e/src/routes/api.sandbox-durability.ts new file mode 100644 index 000000000..a54c222d0 --- /dev/null +++ b/testing/e2e/src/routes/api.sandbox-durability.ts @@ -0,0 +1,170 @@ +import { createFileRoute } from '@tanstack/react-router' +import { EventType, InMemoryLockStore, chat, withLocks } from '@tanstack/ai' +import { + InMemorySandboxInstanceStore, + defineSandbox, + defineWorkspace, + withSandbox, + withSandboxInstanceStore, +} from '@tanstack/ai-sandbox' +import type { AnyTextAdapter, StreamChunk } from '@tanstack/ai' +import type { SandboxHandle, SandboxProvider } from '@tanstack/ai-sandbox' + +/** + * Server-side sandbox instance durability harness. + * + * Proves that a durable `SandboxInstanceStore` (module-singleton stand-in for a + * BYO backend) makes resume durable ACROSS independent runs: each POST is a + * fresh `chat()` with a brand-new middleware context; the only shared state is + * the instance store. A second run for the same `threadId` must RESUME the + * sandbox the first run created. + * + * Wired as production apps do: `withSandboxInstanceStore` + `withLocks` + + * `withSandbox` (providers before consumer). No chat persistence required. + * + * Provider-free: fixed AG-UI stream + fake sandbox provider (exempt from aimock). + */ + +const instanceStore = new InMemorySandboxInstanceStore() +const locks = new InMemoryLockStore() + +/** Per compound sandbox key — isolates concurrent / leftover threads. */ +const createCountByKey = new Map() +const resumeCountByKey = new Map() + +function bump(map: Map, key: string): void { + map.set(key, (map.get(key) ?? 0) + 1) +} + +function fakeHandle(id: string): SandboxHandle { + return { + id, + provider: 'fake', + capabilities: { + fs: true, + exec: true, + env: true, + ports: false, + backgroundProcesses: false, + writableStdin: false, + snapshots: false, + networkPolicy: false, + durableFilesystem: false, + fork: false, + }, + fs: { + read: () => Promise.resolve(''), + readBytes: () => Promise.resolve(new Uint8Array()), + write: () => Promise.resolve(), + list: () => Promise.resolve([]), + mkdir: () => Promise.resolve(), + remove: () => Promise.resolve(), + rename: () => Promise.resolve(), + exists: () => Promise.resolve(false), + }, + git: { + clone: () => Promise.resolve(), + status: () => Promise.resolve(''), + add: () => Promise.resolve(), + commit: () => Promise.resolve(), + push: () => Promise.resolve(), + pull: () => Promise.resolve(), + branch: () => Promise.resolve('main'), + }, + process: { + exec: () => Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), + spawn: () => Promise.reject(new Error('not supported')), + }, + ports: { connect: () => Promise.reject(new Error('not supported')) }, + env: { set: () => Promise.resolve() }, + destroy: () => Promise.resolve(), + } +} + +const provider: SandboxProvider = { + name: 'fake', + capabilities: () => fakeHandle('probe').capabilities, + create: (input) => { + const id = input.id ?? 'fake-sandbox' + bump(createCountByKey, id) + return Promise.resolve(fakeHandle(id)) + }, + resume: (input) => { + bump(resumeCountByKey, input.id) + return Promise.resolve(fakeHandle(input.id)) + }, + destroy: () => Promise.resolve(), +} + +const sandbox = defineSandbox({ + id: 'durable', + provider, + workspace: defineWorkspace({ source: { type: 'none' } }), + fileEvents: false, +}) + +function fixedRun(threadId: string, runId: string): AsyncIterable { + return (async function* () { + yield { type: EventType.RUN_STARTED, threadId, runId, timestamp: 1 } + yield { + type: EventType.RUN_FINISHED, + threadId, + runId, + finishReason: 'stop', + timestamp: 1, + } + })() +} + +const adapter: AnyTextAdapter = { + kind: 'text', + name: 'fixed', + model: 'test-model', + '~types': {}, + chatStream: ({ runId, threadId }: { runId: string; threadId: string }) => + fixedRun(threadId, runId), + structuredOutput: () => Promise.resolve({ data: {}, rawText: '{}' }), +} as unknown as AnyTextAdapter + +function stringField(body: unknown, key: string): string | undefined { + if (typeof body !== 'object' || body === null || !(key in body)) { + return undefined + } + const value: unknown = (body as Record)[key] + return typeof value === 'string' ? value : undefined +} + +export const Route = createFileRoute('/api/sandbox-durability')({ + server: { + handlers: { + POST: async ({ request }) => { + const body: unknown = await request.json() + const threadId = stringField(body, 'threadId') ?? 'sandbox-thread' + const runId = stringField(body, 'runId') ?? crypto.randomUUID() + + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'go' }], + runId, + threadId, + middleware: [ + withSandboxInstanceStore(instanceStore), + withLocks(locks), + withSandbox(sandbox), + ], + }) + for await (const _ of stream) void _ + + const key = sandbox.key({ threadId, runId }) + const record = await instanceStore.get(key) + const countKey = record?.providerSandboxId ?? key + return Response.json({ + create: createCountByKey.get(countKey) ?? 0, + resume: resumeCountByKey.get(countKey) ?? 0, + providerSandboxId: record?.providerSandboxId ?? null, + latestRunId: record?.latestRunId ?? null, + }) + }, + }, + }, +}) diff --git a/testing/e2e/tests/sandbox-durability.spec.ts b/testing/e2e/tests/sandbox-durability.spec.ts new file mode 100644 index 000000000..f2a53d0c1 --- /dev/null +++ b/testing/e2e/tests/sandbox-durability.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from '@playwright/test' + +/** + * Server-side sandbox instance durability. + * + * Two independent runs (separate HTTP requests → fresh middleware contexts) for + * the same thread. Shared state is only the harness route's module-singleton + * `SandboxInstanceStore` via `withSandboxInstanceStore` + `withLocks`. The + * second run must RESUME the sandbox the first created. + * + * Provider-free (fixed AG-UI stream + fake sandbox provider); exempt from aimock. + */ +interface DurabilityResult { + create: number + resume: number + providerSandboxId: string | null + latestRunId: string | null +} + +test.describe('sandbox instance durability (server-side resume)', () => { + test('a second run resumes the persisted sandbox instead of creating a new one', async ({ + request, + }) => { + const threadId = `sandbox-durability-${Date.now()}` + + const first = await request.post('/api/sandbox-durability', { + data: { threadId, runId: 'run-1' }, + }) + expect(first.ok()).toBe(true) + const firstBody = (await first.json()) as DurabilityResult + expect(firstBody.create).toBe(1) + expect(firstBody.resume).toBe(0) + expect(firstBody.providerSandboxId).not.toBeNull() + + const second = await request.post('/api/sandbox-durability', { + data: { threadId, runId: 'run-2' }, + }) + expect(second.ok()).toBe(true) + const secondBody = (await second.json()) as DurabilityResult + + expect(secondBody.create).toBe(1) + expect(secondBody.resume).toBe(1) + expect(secondBody.providerSandboxId).toBe(firstBody.providerSandboxId) + expect(secondBody.latestRunId).toBe('run-2') + }) +}) From e6716bcc6cef1c2fc8dac3676287495960bd22da Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:55:35 +1000 Subject: [PATCH 2/2] =?UTF-8?q?docs(sandbox):=20rename=20persistence=20?= =?UTF-8?q?=E2=86=92=20durability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doc path, id, nav, skill pointer, and changeset use "durability" so this is not confused with chat persistence. Changeset only bumps @tanstack/ai-sandbox. --- .changeset/sandbox-durability.md | 11 +++++++++++ .changeset/sandbox-persistence.md | 13 ------------- docs/config.json | 2 +- docs/sandbox/{persistence.md => durability.md} | 2 +- docs/sandbox/overview.md | 2 +- packages/ai-sandbox/skills/ai-sandbox/SKILL.md | 2 +- 6 files changed, 15 insertions(+), 17 deletions(-) create mode 100644 .changeset/sandbox-durability.md delete mode 100644 .changeset/sandbox-persistence.md rename docs/sandbox/{persistence.md => durability.md} (99%) diff --git a/.changeset/sandbox-durability.md b/.changeset/sandbox-durability.md new file mode 100644 index 000000000..3ed0719a4 --- /dev/null +++ b/.changeset/sandbox-durability.md @@ -0,0 +1,11 @@ +--- +'@tanstack/ai-sandbox': minor +--- + +Durable **sandbox instance** resume for multi-process / multi-replica deploy. + +- **`SandboxInstanceStore` / `SandboxInstanceRecord` / `InMemorySandboxInstanceStore` / `SandboxInstanceStoreCapability`** in `@tanstack/ai-sandbox` +- **`withSandboxInstanceStore(store)`** provides the capability; **`withSandbox`** consumes it in `ensure` (in-memory fallback when absent) +- Pair multi-instance with **`withLocks`** from `@tanstack/ai` (distributed lock) +- Independent of chat persistence — compose both when the app needs transcript durability _and_ instance reuse +- Conformance: `runSandboxInstanceStoreConformance` from `@tanstack/ai-sandbox/testkit` diff --git a/.changeset/sandbox-persistence.md b/.changeset/sandbox-persistence.md deleted file mode 100644 index 1f4b8b6d5..000000000 --- a/.changeset/sandbox-persistence.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@tanstack/ai': minor -'@tanstack/ai-sandbox': minor -'@tanstack/ai-persistence': minor ---- - -Durable **sandbox instance** resume for `@tanstack/ai-sandbox`, owned by the sandbox package (not chat persistence). - -- **`SandboxInstanceStore` / `SandboxInstanceRecord` / `InMemorySandboxInstanceStore` / `SandboxInstanceStoreCapability`** live in `@tanstack/ai-sandbox`. -- **`withSandboxInstanceStore(store)`** provides the capability; **`withSandbox`** consumes it in `ensure` (in-memory fallback when absent). -- **Locks** stay in `@tanstack/ai` (`withLocks`) — multi-instance mutual exclusion around resume-or-create. -- **Chat persistence is independent** — no `stores.sandboxInstance` on `@tanstack/ai-persistence`. Compose both middlewares when an app needs transcript durability _and_ instance reuse. -- Conformance: `runSandboxInstanceStoreConformance` from `@tanstack/ai-sandbox/testkit`. diff --git a/docs/config.json b/docs/config.json index fa8ad32b0..d7a72e0c7 100644 --- a/docs/config.json +++ b/docs/config.json @@ -492,7 +492,7 @@ }, { "label": "Instance Durability", - "to": "sandbox/persistence", + "to": "sandbox/durability", "addedAt": "2026-07-23", "updatedAt": "2026-07-27" }, diff --git a/docs/sandbox/persistence.md b/docs/sandbox/durability.md similarity index 99% rename from docs/sandbox/persistence.md rename to docs/sandbox/durability.md index d0d840c4c..72f67e763 100644 --- a/docs/sandbox/persistence.md +++ b/docs/sandbox/durability.md @@ -1,6 +1,6 @@ --- title: Sandbox Instance Durability -id: sandbox-persistence +id: sandbox-durability order: 9 description: "Durable sandbox instance resume across processes with SandboxInstanceStore and withSandboxInstanceStore." --- diff --git a/docs/sandbox/overview.md b/docs/sandbox/overview.md index 03e399325..001487565 100644 --- a/docs/sandbox/overview.md +++ b/docs/sandbox/overview.md @@ -135,6 +135,6 @@ Daytona) pickers. > **Durable instance resume:** bookkeeping defaults to in-memory (single-process). > For cross-process / multi-instance reuse, see -> [Sandbox Instance Durability](./persistence): implement a +> [Sandbox Instance Durability](./durability): implement a > `SandboxInstanceStore`, provide it with `withSandboxInstanceStore`, and pair a > distributed `LockStore` via `withLocks` from `@tanstack/ai`. diff --git a/packages/ai-sandbox/skills/ai-sandbox/SKILL.md b/packages/ai-sandbox/skills/ai-sandbox/SKILL.md index ff727266e..58fba8d61 100644 --- a/packages/ai-sandbox/skills/ai-sandbox/SKILL.md +++ b/packages/ai-sandbox/skills/ai-sandbox/SKILL.md @@ -225,7 +225,7 @@ import { withSandbox, withSandboxInstanceStore, } from '@tanstack/ai-sandbox' -// Production: your BYO store — docs/sandbox/persistence.md +// Production: your BYO store — docs/sandbox/durability.md import { instanceStore } from './sandbox-instance-store' chat({