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/advanced/locks.md b/docs/advanced/locks.md new file mode 100644 index 000000000..cc9171408 --- /dev/null +++ b/docs/advanced/locks.md @@ -0,0 +1,180 @@ +--- +title: Locks +id: locks +order: 3 +description: "Cross-instance mutual exclusion with LockStore and withLocks — coordination middleware for multi-worker critical sections (e.g. sandbox ensure)." +keywords: + - tanstack ai + - locks + - withLocks + - LockStore + - InMemoryLockStore + - middleware + - multi-instance + - durable object + - AbortSignal + - coordination +--- + +Locks answer a different question from persistence: + +| Concern | Question | Seam | +| --- | --- | --- | +| **State** | What is durable? | Stores + `withPersistence` | +| **Locks** | Who may run this critical section right now? | `LockStore` + `withLocks` | + +They live in **`@tanstack/ai`** as a middleware capability — not in +`@tanstack/ai-persistence`, and never as a key on `AIPersistence.stores`. + +## When you need them + +Use locks when **more than one process or isolate** might enter the same critical +section for the same key: + +- **Sandbox resume-or-create** (`withSandbox` / `ensure`) — two concurrent runs + for the same thread must not both create a provider sandbox. See + [Sandbox Persistence](../sandbox/persistence). +- **Your own middleware** — any multi-writer work you want to serialize across + workers (e.g. a custom “one active job per thread” gate). + +You do **not** need locks for: + +- Single-process local dev (optional: `InMemoryLockStore` is fine). +- Ordinary chat state durability — that is stores, not mutexes. +- Automatically locking an entire `chat()` turn — `withLocks` only **provides** + the capability; consumers call `withLock` when they need exclusion. + +## Wire it up + +```ts +import { chat, withLocks, InMemoryLockStore } from '@tanstack/ai' +import { grokBuildText } from '@tanstack/ai-grok-build' +import type { ModelMessage } from '@tanstack/ai' + +const messages: Array = [{ role: 'user', content: 'hi' }] + +chat({ + adapter: grokBuildText('grok-build'), + messages, + middleware: [ + // Single process. Multi-instance: pass a distributed LockStore instead. + withLocks(new InMemoryLockStore()), + // later middleware can getLocks(ctx) / withSandbox will use the same token + ], +}) +``` + +Capability identity is by **object reference**. `withLocks` provides the shared +`LocksCapability` from core; any later middleware that reads that token (including +`@tanstack/ai-sandbox`) sees the same store. + +Typical order when composing with persistence and sandbox: + +```ts +import { withLocks, InMemoryLockStore } from '@tanstack/ai' +import { + InMemorySandboxInstanceStore, + withSandbox, + withSandboxInstanceStore, +} from '@tanstack/ai-sandbox' +import type { SandboxDefinition } from '@tanstack/ai-sandbox' + +declare const sandbox: SandboxDefinition + +const middleware = [ + withSandboxInstanceStore(new InMemorySandboxInstanceStore()), + withLocks(new InMemoryLockStore()), + withSandbox(sandbox), // after providers +] +``` + +## The contract + +```ts +import type { LockStore } from '@tanstack/ai' + +declare const locks: LockStore + +// Mutual exclusion for a key; lease-backed impls abort `signal` on loss. +await locks.withLock('thread:abc', async (signal) => { + // critical section — pass `signal` to cancellable work when using leases + void signal +}) +``` + +| Piece | Role | +| --- | --- | +| `LockStore` | Interface: `withLock(key, fn)` | +| `withLocks(store)` | Chat middleware that provides `LocksCapability` | +| `InMemoryLockStore` | Process-local implementation (promise chain per key) | +| `getLocks` / `provideLocks` | Low-level capability accessors for custom middleware | + +`InMemoryLockStore` is correct **within one process only**. It serializes +callers for the same key, does not poison the chain when a critical section +throws, and never aborts its signal (ownership cannot be lost in-process). + +## Distributed locks and leases + +Multi-instance deployments need a **distributed** implementation (Durable Object, +Redis, etc.). A good store: + +1. Serializes owners per `key`. +2. Uses **leases** (or equivalent) so a crashed owner cannot block forever. +3. Passes an `AbortSignal` into `fn`; when the lease is lost, **abort** so the + callback stops starting externally visible work and passes the signal to + cancellable dependencies. + +Callbacks that ignore `signal` still type-check (`() => Promise` is +assignable), but lease-backed backends cannot protect you if the critical +section keeps mutating after abort. + +There is no shared lock conformance suite in the chat store testkit — write +targeted tests for concurrency, release-on-throw, and lease expiry for your +backend. The Cloudflare Durable Object recipe lives in the +`ai-persistence/build-cloudflare-adapter` agent skill (app-owned file, +not a shipped package). + +## Not a persistence store + +```ts ignore +// Wrong — throws Unknown AIPersistence store key: locks +// defineAIPersistence({ stores: { messages, locks } }) + +// Wrong — composePersistence rejects locks +// composePersistence(base, { overrides: { locks: myLocks } }) +``` + +State stores answer durability; locks answer mutual exclusion. Keep them on +separate seams: `withPersistence` for the bag, `withLocks` for the mutex. + +## Consume in custom middleware + +```ts +import { + LocksCapability, + defineChatMiddleware, + getLocks, +} from '@tanstack/ai' + +const serializePerThread = defineChatMiddleware({ + name: 'serialize-per-thread', + requires: [LocksCapability], + async onStart(ctx) { + const locks = getLocks(ctx) + await locks.withLock(`thread:${ctx.threadId}`, async (signal) => { + // critical section — honor `signal` under lease-backed locks + void signal + }) + }, +}) +``` + +Or provide without `withLocks` by calling `provideLocks` in your own +`setup` hook if you already own a custom middleware. + +## See also + +- [Middleware](./middleware) — capability bus and lifecycle +- [Sandbox Persistence](../sandbox/persistence) — primary product consumer +- [Persistence Controls](../persistence/controls) — compose state stores (not locks) +- [Build Your Own Adapter](../persistence/build-your-own-adapter) — chat store contracts diff --git a/docs/config.json b/docs/config.json index d324cfba3..9983f04f4 100644 --- a/docs/config.json +++ b/docs/config.json @@ -242,7 +242,7 @@ "label": "Overview", "to": "persistence/overview", "addedAt": "2026-07-22", - "updatedAt": "2026-07-26" + "updatedAt": "2026-07-27" }, { "label": "Chat Persistence", @@ -260,13 +260,13 @@ "label": "Controls", "to": "persistence/controls", "addedAt": "2026-07-22", - "updatedAt": "2026-07-25" + "updatedAt": "2026-07-27" }, { "label": "Build Your Own Adapter", "to": "persistence/build-your-own-adapter", "addedAt": "2026-07-24", - "updatedAt": "2026-07-26" + "updatedAt": "2026-07-27" }, { "label": "Migrations", @@ -425,6 +425,11 @@ "to": "advanced/built-in-middleware", "addedAt": "2026-06-03" }, + { + "label": "Locks", + "to": "advanced/locks", + "addedAt": "2026-07-27" + }, { "label": "OpenTelemetry", "to": "advanced/otel", @@ -440,7 +445,7 @@ "label": "Overview", "to": "sandbox/overview", "addedAt": "2026-06-16", - "updatedAt": "2026-07-03" + "updatedAt": "2026-07-27" }, { "label": "Quick Start", @@ -485,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/getting-started/agent-skills.md b/docs/getting-started/agent-skills.md index e270a78bb..155aebdf9 100644 --- a/docs/getting-started/agent-skills.md +++ b/docs/getting-started/agent-skills.md @@ -43,16 +43,17 @@ TanStack AI publishes skills inside its packages so the guidance travels with `n | Package | Skill | What it teaches | |---------|-------|-----------------| | `@tanstack/ai` | `ai-core` | Chat experience, browser persistence on `useChat`, tool calling, adapters, middleware, structured outputs, media generation, AG-UI protocol, custom backends | -| `@tanstack/ai-persistence` | `tanstack-ai-persistence` | Server chat state (`withPersistence`), the store contracts, locks, and per-stack recipes that write a `chat-persistence.ts` into your app against your existing Drizzle, Prisma, or Cloudflare D1 setup | +| `@tanstack/ai-persistence` | `ai-persistence` | Server chat state (`withPersistence`), the store contracts, locks, and per-stack recipes that write a `chat-persistence.ts` into your app against your existing Drizzle, Prisma, or Cloudflare D1 setup | | `@tanstack/ai-memory` | `tanstack-ai-memory` | `memoryMiddleware`, the recall/save adapter contract, and the in-memory / Redis / Hindsight / Mem0 / Honcho adapters | | `@tanstack/ai-mcp` | `ai-mcp` | Connecting to MCP servers, running their tools inside `chat()`, resources, prompts, and the type-generating CLI | | `@tanstack/ai-sandbox` | `ai-sandbox` | Running harness adapters inside isolated sandboxes with `defineSandbox` / `withSandbox` | | `@tanstack/ai-code-mode` | `ai-code-mode` | Setting up Code Mode with a sandbox driver and registering server tools | Skills route to each other: `ai-core` points at the companion packages' -skills, and `tanstack-ai-persistence` is an entry point that routes to its own -sub-skills (`-server`, `-stores`, `-locks`, and the -`-build-{drizzle,prisma,cloudflare,custom}-adapter` recipes). +skills, and `ai-persistence` is an entry point that routes to its own +sub-skills (`server`, `stores`, `locks`, and the +`build-{drizzle,prisma,cloudflare,custom}-adapter` recipes) under +`skills/ai-persistence/`, same nesting style as `ai-core`. Each skill ships with the code it teaches. Browser persistence lives in the framework packages, so `ai-core/client-persistence` is in `@tanstack/ai` rather @@ -73,7 +74,7 @@ skills: - task: "Building chat, tool calling, adapters, or streaming with TanStack AI" load: "node_modules/@tanstack/ai/skills/ai-core/SKILL.md" - task: "Persisting chat state, building a persistence adapter, or wiring locks" - load: "node_modules/@tanstack/ai-persistence/skills/tanstack-ai-persistence/SKILL.md" + load: "node_modules/@tanstack/ai-persistence/skills/ai-persistence/SKILL.md" - task: "Setting up Code Mode with TanStack AI" load: "node_modules/@tanstack/ai-code-mode/skills/ai-code-mode/SKILL.md" diff --git a/docs/persistence/build-your-own-adapter.md b/docs/persistence/build-your-own-adapter.md index b68ae5018..ce73e6242 100644 --- a/docs/persistence/build-your-own-adapter.md +++ b/docs/persistence/build-your-own-adapter.md @@ -42,19 +42,22 @@ const persistence: ChatTranscriptPersistence = { Each store is independent. Provide only the ones you need: `messages` for the transcript, `runs` for run lifecycle, `interrupts` for durable approvals (needs -`runs`), `metadata` for namespaced key/value state. The middleware turns on +`runs`), `metadata` for namespaced key/value state, and optionally `sandbox` +for durable sandbox resume (see +[Sandbox Persistence](../sandbox/persistence)). The middleware turns on behavior for whatever stores it finds, so a `messages`-only adapter is a valid adapter. -Those four are the *only* keys `stores` accepts — anything else throws +Those five are the *only* keys `stores` accepts — anything else throws `Unknown AIPersistence store key` at construction. Cross-worker coordination is a separate concern with its own seam (`LockStore` + `withLocks`); see -[Controls](./controls). +[Controls](./controls). Chat-only adapters keep four keys; add `sandbox` only +when you compose `withSandbox` and need resume across processes. -Annotate the value with a named shape — `ChatPersistence` for all four, -`ChatTranscriptPersistence` for the floor. Bare `AIPersistence` is the -all-optional bag, and `withPersistence` rejects it because `stores.messages` is -possibly `undefined`. +Annotate the value with a named shape — `ChatPersistence` for all four chat +stores, `ChatTranscriptPersistence` for the floor, or +Bare `AIPersistence` is the all-optional bag, and `withPersistence` rejects it +because `stores.messages` is possibly `undefined`. Every method signature and invariant is in the [store interface reference](#store-interface-reference) at the end of this page. @@ -448,14 +451,14 @@ matching skill loads itself into context: | Skill | Covers | | -------------------------------------------------- | ---------------------------------------------------------------- | -| `tanstack-ai-persistence` | Entry point — routes to everything below | -| `tanstack-ai-persistence-server` | `withPersistence`, run lifecycle, interrupts, `reconstructChat` | -| `tanstack-ai-persistence-stores` | The store contracts and their invariants | -| `tanstack-ai-persistence-locks` | `LockStore` / `withLocks` coordination | -| `tanstack-ai-persistence-build-drizzle-adapter` | `chat-persistence.ts` for a Drizzle app (SQLite / Postgres / MySQL) | -| `tanstack-ai-persistence-build-prisma-adapter` | `chat-persistence.ts` for a Prisma app | -| `tanstack-ai-persistence-build-cloudflare-adapter` | `chat-persistence.ts` for a Worker on D1, plus Durable Object locks | -| `tanstack-ai-persistence-build-custom-adapter` | `chat-persistence.ts` for anything else — raw `pg`, Kysely, SQLite, Mongo, Supabase | +| `ai-persistence` | Entry point — routes to everything below | +| `ai-persistence/server` | `withPersistence`, run lifecycle, interrupts, `reconstructChat` | +| `ai-persistence/stores` | The store contracts and their invariants | +| `ai-persistence/locks` | `LockStore` / `withLocks` coordination | +| `ai-persistence/build-drizzle-adapter` | `chat-persistence.ts` for a Drizzle app (SQLite / Postgres / MySQL) | +| `ai-persistence/build-prisma-adapter` | `chat-persistence.ts` for a Prisma app | +| `ai-persistence/build-cloudflare-adapter` | `chat-persistence.ts` for a Worker on D1, plus Durable Object locks | +| `ai-persistence/build-custom-adapter` | `chat-persistence.ts` for anything else — raw `pg`, Kysely, SQLite, Mongo, Supabase | Browser-side persistence is not in this package — its skill ships with `@tanstack/ai` as `ai-core/client-persistence`, alongside the framework code it @@ -549,33 +552,23 @@ composite identity. A stored `null` is indistinguishable from absence at the typ level, so wrap a value you must persist as `null` (e.g. `{ value: null }`), or reject nullish values outright the way the SQLite store above does. -## Not a store: `LockStore` - -`LockStore` serializes work that may run on multiple workers. It is **not** part -of `AIPersistence.stores` and is not composed with `composePersistence` — state -persistence and mutual exclusion are separate concerns. Wire it with `withLocks` -instead: - -```ts -import { withLocks, InMemoryLockStore } from '@tanstack/ai-persistence' +## Sandbox instances are not chat stores -const locks = withLocks(new InMemoryLockStore()) -``` +Durable sandbox **instance** resume (`SandboxInstanceStore`) is owned by +`@tanstack/ai-sandbox` (`withSandboxInstanceStore`). It is **not** a key on +`AIPersistence.stores`. You may put both chat tables and a +`sandbox_instances` table in the same database in the app — compose +`withPersistence` and `withSandboxInstanceStore` separately. Full guide: +[Sandbox Instance Durability](../sandbox/persistence). -A lock implementation should use leases or another recovery mechanism so a -crashed owner cannot block forever. `withLock` passes an `AbortSignal` to the -critical section; lease-backed implementations abort that signal when ownership -can no longer be guaranteed, and callbacks must then stop starting external -mutations and pass the signal to cancellable dependencies. The package ships an -in-process `InMemoryLockStore` for single-process use; multi-instance -deployments need a distributed implementation, and the Cloudflare Durable Object -recipe is in the `tanstack-ai-persistence-build-cloudflare-adapter` skill. +## Not a store: `LockStore` -The conformance testkit covers state stores only, so a lock store needs its own -tests. +Mutual exclusion is **not** part of `AIPersistence.stores`. Wire it with +`withLocks` from `@tanstack/ai`. Full guide: [Locks](../advanced/locks). ## Where to go next -- [Controls](./controls): compose stores from different systems, add a distributed lock. +- [Controls](./controls): compose stores from different systems. +- [Locks](../advanced/locks): `LockStore` / `withLocks` coordination. - [Migrations](./migrations): who owns the schema and when to apply changes. - [Internals](./internals): the middleware lifecycle your stores plug into. diff --git a/docs/persistence/controls.md b/docs/persistence/controls.md index 31bddc562..caac6e1d3 100644 --- a/docs/persistence/controls.md +++ b/docs/persistence/controls.md @@ -9,7 +9,8 @@ Persistence has no feature flags. What you persist is decided by which **state** stores the backend provides, and you compose backends per store. Supply only the stores your workflow needs. -Locks are a **separate** concern — see [Locks](#locks-coordination) below. +Locks are a **separate** concern — see [Locks](../advanced/locks) (middleware +coordination, not state stores). ## Named shapes (prefer these) @@ -99,27 +100,16 @@ for the store contracts. ## Locks (coordination) -Locks are **not** state stores. They do not live on `AIPersistence` and cannot -be composed with `composePersistence`. Provide them with a separate middleware: +Locks are **not** state stores. They live in `@tanstack/ai` (`withLocks`, +`LockStore`) and cannot be composed with `composePersistence`. Full guide: +[Locks](../advanced/locks). ```ts -import { - withPersistence, - withLocks, - InMemoryLockStore, - memoryPersistence, -} from '@tanstack/ai-persistence' - -const persistence = memoryPersistence() +import { withLocks, InMemoryLockStore } from '@tanstack/ai' +import { withPersistence, memoryPersistence } from '@tanstack/ai-persistence' const middleware = [ - withPersistence(persistence), - // Single process. Multi-instance deployments swap in a distributed - // LockStore — see the build-your-own-adapter guide. - withLocks(new InMemoryLockStore()), + withPersistence(memoryPersistence()), + withLocks(new InMemoryLockStore()), // multi-instance: distributed LockStore ] ``` - -`withLocks` provides `LocksCapability` for downstream middleware (e.g. sandbox). -It does not lock the whole chat turn automatically — take a per-thread lock in -your own middleware when multi-writer coordination is required. diff --git a/docs/persistence/overview.md b/docs/persistence/overview.md index a7a712348..b144102c2 100644 --- a/docs/persistence/overview.md +++ b/docs/persistence/overview.md @@ -288,7 +288,8 @@ Named shapes: `ChatTranscriptStores` (messages floor), `ChatPersistenceStores` (all four), `ChatWithInterruptsStores`. See [Controls](./controls). **Locks** (cross-worker coordination) are a separate concern: use `withLocks` -and a `LockStore` implementation, not a fifth state store. +and a `LockStore` from `@tanstack/ai` — see [Locks](../advanced/locks) — not a +fifth state store. `@tanstack/ai-persistence` ships the contracts, the middleware, an in-memory reference backend, and a conformance testkit — not a backend for your database. 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-persistence/skills/tanstack-ai-persistence/SKILL.md b/packages/ai-persistence/skills/ai-persistence/SKILL.md similarity index 80% rename from packages/ai-persistence/skills/tanstack-ai-persistence/SKILL.md rename to packages/ai-persistence/skills/ai-persistence/SKILL.md index 1cf15c533..20d1d7986 100644 --- a/packages/ai-persistence/skills/tanstack-ai-persistence/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/SKILL.md @@ -1,5 +1,5 @@ --- -name: tanstack-ai-persistence +name: ai-persistence description: > Durability and state persistence for TanStack AI chats with @tanstack/ai-persistence. Routes to server chat persistence (withPersistence), @@ -8,7 +8,7 @@ description: > conversation state. Use when conversations must survive reloads, multi-device, approvals, or server restarts — NOT for stream reconnect alone. type: core -library: tanstack-ai-persistence +library: tanstack-ai library_version: '0.0.0' sources: - 'TanStack/ai:docs/persistence/overview.md' @@ -42,34 +42,34 @@ drives them, an in-memory reference backend, and a conformance testkit. It does stores against whatever you already run — Postgres, SQLite, D1, Mongo — and hand the result to `withPersistence`. The core never inspects your tables. -| Ships in the package | What it is | -| ---------------------------------------------------------------- | ---------------------------------------------- | -| `MessageStore` / `RunStore` / `InterruptStore` / `MetadataStore` | The four state contracts | -| `withPersistence` / `withGenerationPersistence` | Chat + generation middleware | -| `memoryPersistence()` | In-process reference backend (dev, tests) | -| `reconstructChat` | Server hydrate route helper | -| `LockStore` / `withLocks` / `InMemoryLockStore` | Coordination, **separate** from state stores | -| `@tanstack/ai-persistence/testkit` | `runPersistenceConformance` compatibility gate | +| Ships in the package | What it is | +| --------------------------------------------------------------------- | ---------------------------------------------------- | +| `MessageStore` / `RunStore` / `InterruptStore` / `MetadataStore` | The four state contracts | +| `withPersistence` / `withGenerationPersistence` | Chat + generation middleware | +| `memoryPersistence()` | In-process reference backend (dev, tests) | +| `reconstructChat` | Server hydrate route helper | +| `LockStore` / `withLocks` / `InMemoryLockStore` (from `@tanstack/ai`) | Coordination, **not** this package — see locks skill | +| `@tanstack/ai-persistence/testkit` | `runPersistenceConformance` compatibility gate | ## Sub-skills | Need to... | Read | | ----------------------------------------------- | ----------------------------------------------------- | -| Wire server-side chat history, runs, interrupts | tanstack-ai-persistence-server/SKILL.md | +| Wire server-side chat history, runs, interrupts | ai-persistence/server/SKILL.md | | Survive reloads in the browser | ai-core/client-persistence/SKILL.md in `@tanstack/ai` | -| Implement the store interfaces for your DB | tanstack-ai-persistence-stores/SKILL.md | -| Multi-instance locks (separate from state) | tanstack-ai-persistence-locks/SKILL.md | +| Implement the store interfaces for your DB | ai-persistence/stores/SKILL.md | +| Multi-instance locks (separate from state) | ai-persistence/locks/SKILL.md | Adding persistence to an app? Pick the recipe that matches what it already runs — each one writes a single `chat-persistence.ts` against the app's existing database client and schema: -| The app runs... | Read | -| ------------------------------------------------ | --------------------------------------------------------- | -| Drizzle ORM (SQLite / Postgres / MySQL) | tanstack-ai-persistence-build-drizzle-adapter/SKILL.md | -| Prisma | tanstack-ai-persistence-build-prisma-adapter/SKILL.md | -| Cloudflare Workers + D1 (± Durable Object locks) | tanstack-ai-persistence-build-cloudflare-adapter/SKILL.md | -| Anything else — raw `pg`, Kysely, SQLite, Mongo | tanstack-ai-persistence-build-custom-adapter/SKILL.md | +| The app runs... | Read | +| ------------------------------------------------ | ------------------------------------------------ | +| Drizzle ORM (SQLite / Postgres / MySQL) | ai-persistence/build-drizzle-adapter/SKILL.md | +| Prisma | ai-persistence/build-prisma-adapter/SKILL.md | +| Cloudflare Workers + D1 (± Durable Object locks) | ai-persistence/build-cloudflare-adapter/SKILL.md | +| Anything else — raw `pg`, Kysely, SQLite, Mongo | ai-persistence/build-custom-adapter/SKILL.md | ## State persistence has two halves @@ -109,7 +109,7 @@ Never post a delta as `messages` — that wipes history down to the delta. 1. **Client:** `persistence: { store, messages: false }` — resume pointer only. 2. **Server:** `withPersistence(backend)` — messages + runs + interrupts. 3. **Route:** delivery durability if mid-stream reconnect matters. -4. **Optional:** `withLocks(distributedLockStore)` when other middleware needs multi-instance coordination (not part of the state bag). +4. **Optional:** `withLocks(distributedLockStore)` from `@tanstack/ai` when other middleware needs multi-instance coordination (not part of the state bag). ## Minimal end-to-end sketch @@ -123,7 +123,7 @@ import { } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' import { withPersistence } from '@tanstack/ai-persistence' -// Your adapter — see tanstack-ai-persistence-stores. +// Your adapter — see ai-persistence/stores. import { persistence } from './persistence' export async function POST(request: Request) { @@ -171,7 +171,7 @@ mount (thread id is the key). Pair with a server load path such as 2. **`saveThread` is full overwrite**, never append. 3. **`createOrResume` is insert-if-absent** for the same `runId`. 4. **Interrupt `create` is insert-if-absent** — never clobber resolved → pending. -5. **Locks ≠ state.** Use `withLocks`, not a key on `AIPersistence.stores` — `stores` accepts only `messages`, `runs`, `interrupts`, `metadata` and throws on anything else. +5. **Locks ≠ state.** Import `withLocks` from `@tanstack/ai`. Sandbox instance resume is `@tanstack/ai-sandbox` (`withSandboxInstanceStore`) — not a `stores` key. `stores` accepts only `messages`, `runs`, `interrupts`, `metadata`. 6. **You own the schema.** No package invents migrations for you. 7. **Run the conformance testkit** against any adapter you write. 8. **Authorize thread access** at the route boundary. diff --git a/packages/ai-persistence/skills/tanstack-ai-persistence-build-cloudflare-adapter/SKILL.md b/packages/ai-persistence/skills/ai-persistence/build-cloudflare-adapter/SKILL.md similarity index 94% rename from packages/ai-persistence/skills/tanstack-ai-persistence-build-cloudflare-adapter/SKILL.md rename to packages/ai-persistence/skills/ai-persistence/build-cloudflare-adapter/SKILL.md index 638c2b9d3..921aa7aae 100644 --- a/packages/ai-persistence/skills/tanstack-ai-persistence-build-cloudflare-adapter/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/build-cloudflare-adapter/SKILL.md @@ -1,5 +1,5 @@ --- -name: tanstack-ai-persistence-build-cloudflare-adapter +name: ai-persistence/build-cloudflare-adapter description: Use when a Cloudflare Worker needs TanStack AI chat persistence — writes a chat-persistence.ts into the app against its D1 binding (raw or via Drizzle), plus a Durable Object LockStore. Covers per-request bindings, wrangler config, D1 migrations, and lease-based locks. --- @@ -16,7 +16,7 @@ migrations; a second bookkeeping table only creates drift. Read the **Build Your Own Adapter** guide (`docs/persistence/build-your-own-adapter.md`) for the store contracts, and -**tanstack-ai-persistence-stores** for the shape rules. This skill covers only +**ai-persistence/stores** for the shape rules. This skill covers only the Cloudflare-specific parts. ## 1. Read the app before writing anything @@ -79,7 +79,7 @@ Two routes, same invariants: - **Drizzle over D1** — if the app already runs Drizzle, wrap the binding with `drizzle(env.DB, { schema })` and follow - **tanstack-ai-persistence-build-drizzle-adapter** verbatim (its "if `db` is + **ai-persistence/build-drizzle-adapter** verbatim (its "if `db` is per-request" section is exactly this case). Stop reading here. - **Raw D1** — implement the four stores against `d1.prepare(sql).bind(...)`: `.first()` for `get`, `.all()` for `list*`, `.run()` for writes. D1 speaks @@ -179,7 +179,7 @@ route** — derive the user from the session, never trust a client-supplied id. ## 7. Durable Object lock store (only if needed) -Implement `LockStore` from `@tanstack/ai-persistence`. `withLock(key, fn)` +Implement `LockStore` from `@tanstack/ai`. `withLock(key, fn)` routes each key to a Durable Object instance (`idFromName(key)`) that serializes owners. Use **leases** so a crashed owner cannot block forever: the DO grants a lease with an expiry, an alarm reclaims it, and the lock passes the callback an @@ -195,7 +195,8 @@ export { ChatLockDurableObject } from './locks' Then wire both middlewares: ```ts ignore -import { withLocks, withPersistence } from '@tanstack/ai-persistence' +import { withLocks } from '@tanstack/ai' +import { withPersistence } from '@tanstack/ai-persistence' const middleware = [ withPersistence(chatPersistence(env.AI_STATE)), @@ -247,6 +248,10 @@ serialize; different keys do not block each other; a lease that expires aborts the signal handed to the critical section; and a callback that throws still releases the lock. +## Optional: sandbox instance durability + +Sandbox instance resume (`SandboxInstanceStore` / `withSandboxInstanceStore`) is owned by `@tanstack/ai-sandbox`, not chat persistence. See `docs/sandbox/persistence.md`. Compose `withSandboxInstanceStore` next to `withPersistence` when the app needs both. + ## Only if you are publishing this as a package For a reusable npm adapter rather than a file in the app: peer-dep diff --git a/packages/ai-persistence/skills/tanstack-ai-persistence-build-custom-adapter/SKILL.md b/packages/ai-persistence/skills/ai-persistence/build-custom-adapter/SKILL.md similarity index 94% rename from packages/ai-persistence/skills/tanstack-ai-persistence-build-custom-adapter/SKILL.md rename to packages/ai-persistence/skills/ai-persistence/build-custom-adapter/SKILL.md index 248bcadaf..d6175334f 100644 --- a/packages/ai-persistence/skills/tanstack-ai-persistence-build-custom-adapter/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/build-custom-adapter/SKILL.md @@ -1,5 +1,5 @@ --- -name: tanstack-ai-persistence-build-custom-adapter +name: ai-persistence/build-custom-adapter description: Use when an app needs TanStack AI chat persistence on a database with no dedicated recipe — raw Postgres (pg/postgres.js), Kysely, node:sqlite, MongoDB, Supabase, Redis. Writes a chat-persistence.ts against the app's existing client, covering the four stores, the idempotency invariants, and the conformance gate. Route to the Drizzle, Prisma, or Cloudflare skills instead when one of those matches. --- @@ -15,14 +15,14 @@ Do not create a package, a second client, or a migration runner. **Route first.** If the app already runs one of these, stop and use that skill — it has the driver-specific code: -| App runs | Use | -| ------------------------- | ------------------------------------------------ | -| Drizzle ORM (any dialect) | tanstack-ai-persistence-build-drizzle-adapter | -| Prisma | tanstack-ai-persistence-build-prisma-adapter | -| Cloudflare Workers + D1 | tanstack-ai-persistence-build-cloudflare-adapter | +| App runs | Use | +| ------------------------- | --------------------------------------- | +| Drizzle ORM (any dialect) | ai-persistence/build-drizzle-adapter | +| Prisma | ai-persistence/build-prisma-adapter | +| Cloudflare Workers + D1 | ai-persistence/build-cloudflare-adapter | Everything else lands here. The full contracts and their invariants are in -**tanstack-ai-persistence-stores**; the complete worked `node:sqlite` walkthrough +**ai-persistence/stores**; the complete worked `node:sqlite` walkthrough is `docs/persistence/build-your-own-adapter.md` and `examples/ts-react-chat/src/lib/sqlite-persistence.ts`. @@ -169,7 +169,7 @@ Annotate `ChatPersistence` — bare `AIPersistence` is the all-optional bag and `withPersistence` rejects it. There is no `locks` store: `stores` accepts only `messages`, `runs`, `interrupts`, `metadata`, and anything else throws `Unknown AIPersistence store key`. Coordination is wired separately with -`withLocks` (see **tanstack-ai-persistence-locks**). +`withLocks` (see **ai-persistence/locks**). If the client is per-request (Workers bindings, request-scoped transactions), export a `chatPersistence()` factory instead of a const and call it inside the @@ -274,3 +274,7 @@ Point it at a throwaway database and reset between runs. Declare intentional omissions with `skip: ['metadata']` — it accepts only `'messages' | 'runs' | 'interrupts' | 'metadata'`, never `'locks'`, which is not a state store. + +## Optional: sandbox instance durability + +Sandbox instance resume (`SandboxInstanceStore` / `withSandboxInstanceStore`) is owned by `@tanstack/ai-sandbox`, not chat persistence. See `docs/sandbox/persistence.md`. Compose `withSandboxInstanceStore` next to `withPersistence` when the app needs both. diff --git a/packages/ai-persistence/skills/tanstack-ai-persistence-build-drizzle-adapter/SKILL.md b/packages/ai-persistence/skills/ai-persistence/build-drizzle-adapter/SKILL.md similarity index 97% rename from packages/ai-persistence/skills/tanstack-ai-persistence-build-drizzle-adapter/SKILL.md rename to packages/ai-persistence/skills/ai-persistence/build-drizzle-adapter/SKILL.md index 9052cc6d9..073221294 100644 --- a/packages/ai-persistence/skills/tanstack-ai-persistence-build-drizzle-adapter/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/build-drizzle-adapter/SKILL.md @@ -1,5 +1,5 @@ --- -name: tanstack-ai-persistence-build-drizzle-adapter +name: ai-persistence/build-drizzle-adapter description: Use when an app already runs Drizzle ORM and needs TanStack AI chat persistence — writes a chat-persistence.ts into the app against its existing db handle, schema file, and drizzle-kit journal. Covers the four tables (SQLite/Postgres/MySQL), the onConflict idempotency rules, JSON columns, and per-request bindings like D1. --- @@ -15,7 +15,7 @@ Do not create a package, a second `db` instance, a migration runner, or a Read the **Build Your Own Adapter** guide (`docs/persistence/build-your-own-adapter.md`) for the store contracts and -invariants, and **tanstack-ai-persistence-stores** for the shape rules. Every +invariants, and **ai-persistence/stores** for the shape rules. Every store below mirrors the reference in-memory backend in `@tanstack/ai-persistence` (`memory.ts`); the shared conformance testkit is the proof. @@ -371,7 +371,7 @@ export const chatPersistence: ChatPersistence = defineAIPersistence({ Annotate `ChatPersistence` — bare `AIPersistence` is the all-optional bag and `withPersistence` rejects it. There is no `locks` store: `stores` accepts only those four keys, and coordination is wired separately with `withLocks` (see -**tanstack-ai-persistence-locks**). +**ai-persistence/locks**). ### If `db` is per-request @@ -396,7 +396,7 @@ export function chatPersistence(): ChatPersistence { The store factories are unchanged — only the export flips from a const to a function. For D1 specifically, see -**tanstack-ai-persistence-build-cloudflare-adapter**. +**ai-persistence/build-cloudflare-adapter**. ## 4. Wire it into the chat route @@ -441,6 +441,10 @@ that has the migration applied, and reset between runs. Every store is provided, so there is nothing to `skip` — and `skip` never accepts `'locks'`, which is not a state store. +## Optional: sandbox instance durability + +Sandbox instance resume (`SandboxInstanceStore` / `withSandboxInstanceStore`) is owned by `@tanstack/ai-sandbox`, not chat persistence. See `docs/sandbox/persistence.md`. Compose `withSandboxInstanceStore` next to `withPersistence` when the app needs both. + ## Only if you are publishing this as a package Everything above assumes the file lives in the app. If instead you are shipping diff --git a/packages/ai-persistence/skills/tanstack-ai-persistence-build-prisma-adapter/SKILL.md b/packages/ai-persistence/skills/ai-persistence/build-prisma-adapter/SKILL.md similarity index 97% rename from packages/ai-persistence/skills/tanstack-ai-persistence-build-prisma-adapter/SKILL.md rename to packages/ai-persistence/skills/ai-persistence/build-prisma-adapter/SKILL.md index e4b95498c..bb7b9d172 100644 --- a/packages/ai-persistence/skills/tanstack-ai-persistence-build-prisma-adapter/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/build-prisma-adapter/SKILL.md @@ -1,5 +1,5 @@ --- -name: tanstack-ai-persistence-build-prisma-adapter +name: ai-persistence/build-prisma-adapter description: Use when an app already runs Prisma and needs TanStack AI chat persistence — writes a chat-persistence.ts into the app against its existing PrismaClient and schema.prisma. Covers the four models, BigInt timestamps, JSON-as-string columns, upsert-with-empty-update idempotency, and model renaming. --- @@ -15,7 +15,7 @@ hand-written SQL migration. The app has those. Read the **Build Your Own Adapter** guide (`docs/persistence/build-your-own-adapter.md`) for the store contracts and -invariants, and **tanstack-ai-persistence-stores** for the shape rules. Every +invariants, and **ai-persistence/stores** for the shape rules. Every store below mirrors the reference in-memory backend in `@tanstack/ai-persistence` (`memory.ts`); the shared conformance testkit is the proof. @@ -366,7 +366,7 @@ Notes that bite: - Annotate `ChatPersistence` — bare `AIPersistence` is the all-optional bag and `withPersistence` rejects it. There is no `locks` store: `stores` accepts only those four keys, and coordination is wired separately with `withLocks` (see - **tanstack-ai-persistence-locks**). + **ai-persistence/locks**). - If the app renamed the models, the delegate accessors are **camelCase** (`prisma.chatThread` for `model ChatThread`), and the row types imported from the client are PascalCase. @@ -414,6 +414,10 @@ SQLite file is enough) and reset it between runs. All four state stores are provided, so pass no `skip` — and `skip` never accepts `'locks'`, which is not a state store. +## Optional: sandbox instance durability + +Sandbox instance resume (`SandboxInstanceStore` / `withSandboxInstanceStore`) is owned by `@tanstack/ai-sandbox`, not chat persistence. See `docs/sandbox/persistence.md`. Compose `withSandboxInstanceStore` next to `withPersistence` when the app needs both. + ## Only if you are publishing this as a package Everything above assumes the file lives in the app. For a reusable npm adapter, diff --git a/packages/ai-persistence/skills/tanstack-ai-persistence-locks/SKILL.md b/packages/ai-persistence/skills/ai-persistence/locks/SKILL.md similarity index 56% rename from packages/ai-persistence/skills/tanstack-ai-persistence-locks/SKILL.md rename to packages/ai-persistence/skills/ai-persistence/locks/SKILL.md index 3d1be047d..6a35cfc89 100644 --- a/packages/ai-persistence/skills/tanstack-ai-persistence-locks/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/locks/SKILL.md @@ -1,24 +1,26 @@ --- -name: tanstack-ai-persistence-locks +name: ai-persistence/locks description: > LockStore and withLocks for multi-instance coordination in TanStack AI. - Separate from AIPersistence state stores — not a stores key, not composable. + Lives in @tanstack/ai — NOT in @tanstack/ai-persistence. Separate from + AIPersistence state stores — not a stores key, not composable. InMemoryLockStore vs a distributed (e.g. Cloudflare Durable Object) lock, lease recovery, AbortSignal in critical sections. Use when sandbox or other middleware needs cross-worker mutual exclusion — NOT for storing messages/runs (use withPersistence). type: sub-skill -library: tanstack-ai-persistence +library: tanstack-ai library_version: '0.0.0' sources: - - 'TanStack/ai:docs/persistence/controls.md' - - 'TanStack/ai:packages/ai-persistence/src/locks.ts' + - 'TanStack/ai:docs/advanced/locks.md' + - 'TanStack/ai:packages/ai/src/activities/chat/middleware/locks.ts' --- -# Persistence Locks +# Locks (coordination — not persistence) -> Builds on **tanstack-ai-persistence**. Locks are **not** part of -> `AIPersistence.stores` and are **not** composed with `composePersistence`. +> Builds on **ai-persistence** for composition only. Locks are **not** +> part of `AIPersistence.stores` and are **not** composed with +> `composePersistence`. Import them from **`@tanstack/ai`**. ## Why separate? @@ -31,11 +33,8 @@ per-thread (or other) lock yourself when multi-writer races matter. ## Wire locks ```ts -import { - withPersistence, - withLocks, - InMemoryLockStore, -} from '@tanstack/ai-persistence' +import { withLocks, InMemoryLockStore } from '@tanstack/ai' +import { withPersistence } from '@tanstack/ai-persistence' middleware: [ withPersistence(persistence), @@ -55,11 +54,10 @@ interface LockStore { } ``` -`InMemoryLockStore` ships in `@tanstack/ai-persistence`: a per-key promise -chain, correct **within a single process only**. Multi-instance deployments -need a distributed implementation — you write it, the same way you write a -state adapter. The Cloudflare Durable Object recipe is in -**tanstack-ai-persistence-build-cloudflare-adapter**. +`InMemoryLockStore` ships in **`@tanstack/ai`**: a per-key promise chain, +correct **within a single process only**. Multi-instance deployments need a +distributed implementation — you write it. The Cloudflare Durable Object recipe +is in **ai-persistence/build-cloudflare-adapter**. ## Lease semantics @@ -76,18 +74,20 @@ cannot be lost. ## Capability identity -The `'locks'` capability token is defined **locally** in -`@tanstack/ai-persistence`. Capability identity is by object reference, not by -name, so it does not interoperate with the identically-named capability owned -by `@tanstack/ai-sandbox`. +The `'locks'` capability token lives in core `@tanstack/ai`. Capability identity +is by **object reference**, so one shared token means a `withLocks` in the chain +reaches `withSandbox` automatically. ## Common mistakes +### HIGH: Importing locks from `@tanstack/ai-persistence` + +They are not exported there. Use `@tanstack/ai`. + ### HIGH: Putting `locks` on `AIPersistence.stores` -Not supported. `stores` accepts only `messages`, `runs`, `interrupts`, -`metadata` and throws `Unknown AIPersistence store key: locks`. Use -`withLocks`. +Not supported. `stores` accepts `messages`, `runs`, `interrupts`, `metadata`, +and optional `sandbox` — never `locks`. Use `withLocks`. ### HIGH: Passing `locks` to `composePersistence` overrides @@ -95,8 +95,8 @@ Same rejection, at the override layer. Locks are not state. ### HIGH: Passing `'locks'` to the conformance testkit's `skip` -`skip` accepts only the four state store keys. The suite does not cover locks -at all, so there is nothing to skip — test lease expiry and abort separately. +`skip` accepts only chat state store keys. The suite does not cover locks +at all — test lease expiry and abort separately. ### HIGH: `InMemoryLockStore` across multiple processes @@ -108,5 +108,5 @@ Continuing work after losing the lease races other owners. ## Cross-references -- **tanstack-ai-persistence-server** — state middleware -- **tanstack-ai-persistence-build-cloudflare-adapter** — Durable Object lock recipe +- **ai-persistence/server** — state middleware +- **ai-persistence/build-cloudflare-adapter** — Durable Object lock recipe diff --git a/packages/ai-persistence/skills/tanstack-ai-persistence-server/SKILL.md b/packages/ai-persistence/skills/ai-persistence/server/SKILL.md similarity index 94% rename from packages/ai-persistence/skills/tanstack-ai-persistence-server/SKILL.md rename to packages/ai-persistence/skills/ai-persistence/server/SKILL.md index 655ba6cf3..6da8b6840 100644 --- a/packages/ai-persistence/skills/tanstack-ai-persistence-server/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/server/SKILL.md @@ -1,5 +1,5 @@ --- -name: tanstack-ai-persistence-server +name: ai-persistence/server description: > Server chat state with withPersistence from @tanstack/ai-persistence. Authoritative transcript, run lifecycle, durable interrupts/approvals, @@ -9,7 +9,7 @@ description: > stream reconnect alone. type: sub-skill -library: tanstack-ai-persistence +library: tanstack-ai library_version: '0.0.0' sources: - 'TanStack/ai:docs/persistence/chat-persistence.md' @@ -19,7 +19,7 @@ sources: # Server Chat Persistence -> Builds on **tanstack-ai-persistence**. Package: `@tanstack/ai-persistence`. +> Builds on **ai-persistence**. Package: `@tanstack/ai-persistence`. `withPersistence(persistence)` is a `ChatMiddleware` that writes chat **state** to a backend: messages, runs, interrupts (optional metadata). It does not @@ -35,7 +35,7 @@ import { } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' import { withPersistence } from '@tanstack/ai-persistence' -// Your adapter — see tanstack-ai-persistence-stores. +// Your adapter — see ai-persistence/stores. import { persistence } from './persistence' export async function POST(request: Request) { @@ -172,7 +172,7 @@ That is delivery durability (resumable streams), not state persistence. ## Cross-references -- **tanstack-ai-persistence** — layers and recommended stack -- **tanstack-ai-persistence-stores** — implement the store interfaces +- **ai-persistence** — layers and recommended stack +- **ai-persistence/stores** — implement the store interfaces - **ai-core/client-persistence** (`@tanstack/ai`) — browser half -- **tanstack-ai-persistence-locks** — multi-instance coordination +- **ai-persistence/locks** — multi-instance coordination diff --git a/packages/ai-persistence/skills/tanstack-ai-persistence-stores/SKILL.md b/packages/ai-persistence/skills/ai-persistence/stores/SKILL.md similarity index 92% rename from packages/ai-persistence/skills/tanstack-ai-persistence-stores/SKILL.md rename to packages/ai-persistence/skills/ai-persistence/stores/SKILL.md index 80eb2ce96..57bb4e3c2 100644 --- a/packages/ai-persistence/skills/tanstack-ai-persistence-stores/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/stores/SKILL.md @@ -1,5 +1,5 @@ --- -name: tanstack-ai-persistence-stores +name: ai-persistence/stores description: > Implement the MessageStore, RunStore, InterruptStore, MetadataStore contracts for @tanstack/ai-persistence against any database. defineAIPersistence, @@ -8,7 +8,7 @@ description: > access, runPersistenceConformance testkit. Use whenever you need server persistence — the package ships contracts, not a backend for your database. type: sub-skill -library: tanstack-ai-persistence +library: tanstack-ai library_version: '0.0.0' sources: - 'TanStack/ai:docs/persistence/build-your-own-adapter.md' @@ -18,7 +18,7 @@ sources: # Persistence Stores -> Builds on **tanstack-ai-persistence** and **tanstack-ai-persistence-server**. +> Builds on **ai-persistence** and **ai-persistence/server**. `@tanstack/ai-persistence` ships **contracts**, not a backend for your database. An adapter is an object with a `stores` map; implement the stores you @@ -28,7 +28,7 @@ need against whatever you already run and hand the result to Use `memoryPersistence()` for dev and tests. Everything durable is an adapter you write. This skill is the contract reference; the per-stack recipes that write a `chat-persistence.ts` into an app are -`tanstack-ai-persistence-build-{drizzle,prisma,cloudflare,custom}-adapter`, and +`ai-persistence/build-{drizzle,prisma,cloudflare,custom}-adapter`, and a complete `node:sqlite` implementation lives in `examples/ts-react-chat/src/lib/sqlite-persistence.ts`. @@ -53,7 +53,7 @@ export const persistence: ChatWithInterruptsPersistence = defineAIPersistence({ | ------------------------------- | ------------------------------------------------ | | `ChatTranscriptPersistence` | `messages` (+ optional runs/interrupts/metadata) | | `ChatWithInterruptsPersistence` | `messages` + `runs` + `interrupts` | -| `ChatPersistence` | all four | +| `ChatPersistence` | all four chat stores | `defineAIPersistence` preserves exact keys and rejects unknown keys at runtime. @@ -63,9 +63,10 @@ all-optional sparse bag, so `withPersistence` and `reconstructChat` reject it mistake when writing an adapter. **`stores` accepts exactly four keys** — `messages`, `runs`, `interrupts`, -`metadata`. Anything else (notably `locks`) throws -`Unknown AIPersistence store key` at runtime and fails to type-check. Locks are -a separate concern; see **tanstack-ai-persistence-locks**. +`metadata`. Anything else (notably `locks` or sandbox instance maps) throws +`Unknown AIPersistence store key` at runtime and fails to type-check. Locks: +**ai-persistence/locks** / `@tanstack/ai`. Sandbox instance resume: +`@tanstack/ai-sandbox` (`withSandboxInstanceStore`). ## Contracts and invariants @@ -196,7 +197,8 @@ There is **no cross-store transaction** — if `messages` lives in Postgres and invariants (idempotent `createOrResume`, insert-if-absent `create`) are exactly what make those retries safe. -`composePersistence` accepts only the four state keys. Locks are not composable. +`composePersistence` accepts the four state keys. Locks and sandbox instance +maps are not composable here. ## Map onto an existing schema @@ -267,6 +269,6 @@ Silent semantic drift shows up as stuck approvals or wiped history in prod. ## Cross-references -- **tanstack-ai-persistence-server** — when middleware calls each store -- **tanstack-ai-persistence-build-drizzle-adapter** / **-prisma-** / **-cloudflare-** / **-custom-** — per-stack recipes -- **tanstack-ai-persistence-locks** — not a state store +- **ai-persistence/server** — when middleware calls each store +- **ai-persistence/build-drizzle-adapter** / **-prisma-** / **-cloudflare-** / **-custom-** — per-stack recipes +- **ai-persistence/locks** — not a state store diff --git a/packages/ai-persistence/src/capabilities.ts b/packages/ai-persistence/src/capabilities.ts index 0236a8d10..093fdc718 100644 --- a/packages/ai-persistence/src/capabilities.ts +++ b/packages/ai-persistence/src/capabilities.ts @@ -2,8 +2,8 @@ * Persistence capability tokens. * * `withPersistence` PROVIDES persistence/interrupts so later middleware can - * read durable state. Locks are a separate concern: `withLocks` PROVIDES - * `LocksCapability` (defined in `./locks`). + * read durable chat state. Locks live in `@tanstack/ai`; sandbox instance + * resume lives in `@tanstack/ai-sandbox`. */ import { createCapability } from '@tanstack/ai' import type { AIPersistence, InterruptStore } from './types' @@ -17,6 +17,3 @@ export const InterruptsCapability = createCapability()( export const [getPersistence, providePersistence] = PersistenceCapability export const [getInterrupts, provideInterrupts] = InterruptsCapability - -// Locks token lives in ./locks; re-export for a single import surface. -export { LocksCapability, getLocks, provideLocks } from './locks' diff --git a/packages/ai-persistence/src/index.ts b/packages/ai-persistence/src/index.ts index e06abd68f..4d3ee0f0b 100644 --- a/packages/ai-persistence/src/index.ts +++ b/packages/ai-persistence/src/index.ts @@ -26,12 +26,8 @@ export type { // AIPersistenceStores is intentionally NOT re-exported — use a named chat // shape or AIPersistence<{ messages: MessageStore, … }>. -// Middleware (state + separate locks) -export { - withPersistence, - withGenerationPersistence, - withLocks, -} from './middleware' +// Middleware (state only — locks live in @tanstack/ai as withLocks) +export { withPersistence, withGenerationPersistence } from './middleware' // Server helper: rehydrate a thread's messages for a client load export { reconstructChat } from './reconstruct' @@ -44,7 +40,8 @@ export { memoryPersistence } from './memory' export { createInterruptController } from './interrupts' export type { InterruptController } from './interrupts' -// Capabilities +// Persistence-owned capabilities only. Locks: @tanstack/ai. Sandbox instances: +// @tanstack/ai-sandbox (withSandboxInstanceStore). export { PersistenceCapability, InterruptsCapability, @@ -52,11 +49,4 @@ export { providePersistence, getInterrupts, provideInterrupts, - LocksCapability, - getLocks, - provideLocks, } from './capabilities' - -// Lock primitive (separate from state stores; provide via withLocks) -export { InMemoryLockStore } from './locks' -export type { LockStore } from './locks' diff --git a/packages/ai-persistence/src/locks.ts b/packages/ai-persistence/src/locks.ts deleted file mode 100644 index ce4e2fe20..000000000 --- a/packages/ai-persistence/src/locks.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Distributed-mutex primitive for multi-instance coordination. - * - * Locks are **not** part of {@link AIPersistenceStores}. State persistence - * (messages/runs/interrupts/metadata) and mutual exclusion are separate - * concerns: wire locks with {@link withLocks} (or pass a `LockStore` into - * consumers that need one), not via `composePersistence`. - * - * ponytail: the `'locks'` capability token is defined LOCALLY here. Capability - * identity is by object reference (see `createCapability`), not by name, so this - * token does NOT interoperate with the identically-named `LocksCapability` that - * `@tanstack/ai-sandbox` owns. That is fine for this batch: the only consumer of - * a provided lock store is `withSandbox`, and sandbox persistence is deferred. - * When it lands, share ONE handle between provider and consumer (the neutral - * home is core `@tanstack/ai`); until then this token has no cross-package - * consumer and just keeps the API self-contained. - */ -import { createCapability } from '@tanstack/ai' - -/** - * Mutual exclusion around a critical section keyed by `key`. A Cloudflare - * Durable Object store is the reference multi-instance backend; the in-memory - * default is correct within a single process only. Lease-backed implementations - * abort `signal` as soon as ownership can no longer be guaranteed; the callback - * must stop externally visible mutations when it aborts. - */ -export interface LockStore { - withLock: ( - key: string, - fn: (signal: AbortSignal) => Promise, - ) => Promise -} - -/** The lock capability. Provided by {@link withLocks}. */ -export const LocksCapability = createCapability()('locks') - -/** Destructured accessors: `getLocks(ctx)` / `provideLocks(ctx, store)`. */ -export const [getLocks, provideLocks] = LocksCapability - -/** - * In-memory {@link LockStore} — a per-key promise chain. Correct within a single - * process; multi-instance correctness needs a distributed lock (e.g. a - * Cloudflare Durable Object — see the `build-cloudflare-adapter` skill). - */ -export class InMemoryLockStore implements LockStore { - private readonly chains = new Map>() - - withLock( - key: string, - fn: (signal: AbortSignal) => Promise, - ): Promise { - const prior = this.chains.get(key) ?? Promise.resolve() - const runCriticalSection = () => fn(new AbortController().signal) - // Chain after the prior holder regardless of how it settled. - const run = prior.then(runCriticalSection, runCriticalSection) - // Swallow rejections so one failure doesn't poison the lock, then drop the - // chain entry once this tail is still the latest — otherwise long-lived - // processes accumulate settled promises for every distinct key forever. - const settled = run.then( - () => undefined, - () => undefined, - ) - this.chains.set(key, settled) - void settled.then(() => { - if (this.chains.get(key) === settled) { - this.chains.delete(key) - } - }) - return run - } -} diff --git a/packages/ai-persistence/src/memory.ts b/packages/ai-persistence/src/memory.ts index e3a1d291b..70f8368f1 100644 --- a/packages/ai-persistence/src/memory.ts +++ b/packages/ai-persistence/src/memory.ts @@ -173,8 +173,8 @@ class MemoryMetadataStore implements MetadataStore { * In-process reference backend for **chat** state stores. * * Returns {@link ChatPersistence} (messages + runs + interrupts + metadata). - * Locks are not included — use {@link InMemoryLockStore} + {@link withLocks} - * when a test or single-process app needs coordination. + * Locks are not included — use `InMemoryLockStore` + `withLocks` from + * `@tanstack/ai` when a test or single-process app needs coordination. */ export function memoryPersistence(): ChatPersistence { return defineAIPersistence({ diff --git a/packages/ai-persistence/src/middleware.ts b/packages/ai-persistence/src/middleware.ts index b7da747a0..56400edbb 100644 --- a/packages/ai-persistence/src/middleware.ts +++ b/packages/ai-persistence/src/middleware.ts @@ -1,10 +1,8 @@ import { defineChatMiddleware } from '@tanstack/ai' import { InterruptsCapability, - LocksCapability, PersistenceCapability, provideInterrupts, - provideLocks, providePersistence, } from './capabilities' import { @@ -30,7 +28,6 @@ import type { ToolApprovalResolution, TokenUsage, } from '@tanstack/ai' -import type { LockStore } from './locks' import type { AIPersistence, AIPersistenceStores, @@ -272,9 +269,10 @@ interface PersistencePlan { } function resolvePersistencePlan(persistence: AIPersistence): PersistencePlan { + const stores = persistence.stores as AIPersistenceStores return { - wantsInterrupts: persistence.stores.interrupts !== undefined, - runs: persistence.stores.runs, + wantsInterrupts: stores.interrupts !== undefined, + runs: stores.runs, } } @@ -376,7 +374,7 @@ async function interruptRun( /** * Chat-only **state** persistence middleware. Provides durable transcript, * run records, and interrupts for `chat()`. Does **not** provide locks — - * use {@link withLocks} for multi-instance coordination. + * use `withLocks` from `@tanstack/ai` for multi-instance coordination. * * This middleware never mutates the chunk stream; delivery durability * (replaying a disconnected/reloaded stream) is a separate transport-layer @@ -620,34 +618,6 @@ export function withPersistence( }) } -// --------------------------------------------------------------------------- -// Locks middleware (coordination — not state persistence) -// --------------------------------------------------------------------------- - -/** - * Provide a {@link LockStore} on the chat middleware capability bus. - * - * Locks are independent of {@link withPersistence}: state backends (Drizzle, - * Prisma, D1, memory) do not ship locks, and multi-instance apps compose a - * distributed lock separately (e.g. Cloudflare Durable Objects). - * - * ```ts - * middleware: [ - * withPersistence(drizzlePersistence(db, opts)), - * withLocks(createDurableObjectLockStore(env.AI_LOCKS)), - * ] - * ``` - */ -export function withLocks(locks: LockStore): ChatMiddleware { - return defineChatMiddleware({ - name: 'locks', - provides: [LocksCapability], - setup(ctx: ChatMiddlewareContext) { - provideLocks(ctx, locks) - }, - }) -} - // --------------------------------------------------------------------------- // Generation middleware // --------------------------------------------------------------------------- diff --git a/packages/ai-persistence/src/testkit/conformance.ts b/packages/ai-persistence/src/testkit/conformance.ts index 8e591b06d..80d188d3e 100644 --- a/packages/ai-persistence/src/testkit/conformance.ts +++ b/packages/ai-persistence/src/testkit/conformance.ts @@ -8,7 +8,7 @@ * the store interfaces in `../types.ts`. * * Locks are not part of this suite — they are a separate coordination concern - * (`LockStore` + `withLocks`), not state stores. + * (`LockStore` + `withLocks` from `@tanstack/ai`), not state stores. * * SKIPPING: a backend that deliberately omits a state store must declare it in * `options.skip`. A store that is absent AND not listed in `skip` fails the diff --git a/packages/ai-persistence/src/types.ts b/packages/ai-persistence/src/types.ts index 594268968..12d65fe04 100644 --- a/packages/ai-persistence/src/types.ts +++ b/packages/ai-persistence/src/types.ts @@ -243,8 +243,10 @@ export interface MetadataStore { * * **Not a public product shape.** Prefer the named chat shapes below * ({@link ChatTranscriptStores}, {@link ChatPersistenceStores}, - * {@link ChatWithInterruptsStores}). Locks are not included (see - * {@link withLocks}). + * {@link ChatWithInterruptsStores}). + * Locks are not included (use `withLocks` from `@tanstack/ai`). Sandbox + * instance resume is not a chat store — see `@tanstack/ai-sandbox` + * (`withSandboxInstanceStore`). * * @internal Exported from this module for generics; the package root does not * re-export this type — use a named shape or `AIPersistence<{ … }>` instead. diff --git a/packages/ai-persistence/tests/capabilities.test.ts b/packages/ai-persistence/tests/capabilities.test.ts index 464f0c0a6..1494a99cc 100644 --- a/packages/ai-persistence/tests/capabilities.test.ts +++ b/packages/ai-persistence/tests/capabilities.test.ts @@ -1,20 +1,25 @@ import { describe, expect, it } from 'vitest' -import { EventType, chat, defineChatMiddleware } from '@tanstack/ai' +import { + EventType, + InMemoryLockStore, + LocksCapability, + chat, + defineChatMiddleware, + getLocks, + withLocks, +} from '@tanstack/ai' import type { AnyTextAdapter, ChatMiddlewareContext, + LockStore, StreamChunk, } from '@tanstack/ai' -import type { LockStore } from '../src/locks' -import { InMemoryLockStore } from '../src/locks' import { memoryPersistence } from '../src/memory' -import { withLocks, withPersistence } from '../src/middleware' +import { withPersistence } from '../src/middleware' import { InterruptsCapability, - LocksCapability, PersistenceCapability, getInterrupts, - getLocks, getPersistence, } from '../src/capabilities' import { createInterruptController } from '../src/interrupts' diff --git a/packages/ai-persistence/tests/persistence-types.test-d.ts b/packages/ai-persistence/tests/persistence-types.test-d.ts index 13c093208..c5df53d65 100644 --- a/packages/ai-persistence/tests/persistence-types.test-d.ts +++ b/packages/ai-persistence/tests/persistence-types.test-d.ts @@ -1,14 +1,13 @@ import { expectTypeOf } from 'vitest' +import { InMemoryLockStore, withLocks } from '@tanstack/ai' +import type { LockStore } from '@tanstack/ai' import { composePersistence, defineAIPersistence, memoryPersistence, withPersistence, withGenerationPersistence, - withLocks, - InMemoryLockStore, } from '../src' -import type { LockStore } from '../src/locks' import type { AIPersistence, ChatPersistence, diff --git a/packages/ai-sandbox-docker/CHANGELOG.md b/packages/ai-sandbox-docker/CHANGELOG.md index ef558b9a2..8b8b61e68 100644 --- a/packages/ai-sandbox-docker/CHANGELOG.md +++ b/packages/ai-sandbox-docker/CHANGELOG.md @@ -5,7 +5,7 @@ ### Minor Changes - [#774](https://github.com/TanStack/ai/pull/774) [`b628a4d`](https://github.com/TanStack/ai/commit/b628a4da5fd21184922c6944059768d1ed6071d4) - New provider-agnostic sandbox layer so harness adapters can run **inside** isolated sandboxes. - - **`@tanstack/ai-sandbox`** — `defineSandbox()` (lazy controller + resume→restoreSnapshot→create+bootstrap ensure algorithm), `withSandbox()` middleware, `defineWorkspace()` (git/local source, package-manager detection, setup, skills, secrets), `defineSandboxPolicy()`, the `SandboxProvider`/`SandboxHandle`/`SandboxCapabilities` contracts, capability tokens (`SandboxCapability` plus the optional `SandboxStore`/`Locks` persistence seams with in-memory defaults), `bootstrapWorkspace`, `createExecBackedGit`, `spawnNdjson` (run an agent CLI in a sandbox and stream its NDJSON stdout), the host MCP tool-proxy bridge (`startHostToolBridge` — exposes `chat()` server tools to the in-sandbox agent, with an optional permission-prompt tool), and the shared interactive-approval primitives (`resolveApproval`, `approvalId`, `buildApprovalRequestedEvent`) harness adapters use to enforce a policy and surface `approval-requested` events for client-in-the-loop approvals. + - **`@tanstack/ai-sandbox`** — `defineSandbox()` (lazy controller + resume→restoreSnapshot→create+bootstrap ensure algorithm), `withSandbox()` middleware, `defineWorkspace()` (git/local source, package-manager detection, setup, skills, secrets), `defineSandboxPolicy()`, the `SandboxProvider`/`SandboxHandle`/`SandboxCapabilities` contracts, capability tokens (`SandboxCapability` plus the optional `SandboxInstanceStore`/`Locks` persistence seams with in-memory defaults), `bootstrapWorkspace`, `createExecBackedGit`, `spawnNdjson` (run an agent CLI in a sandbox and stream its NDJSON stdout), the host MCP tool-proxy bridge (`startHostToolBridge` — exposes `chat()` server tools to the in-sandbox agent, with an optional permission-prompt tool), and the shared interactive-approval primitives (`resolveApproval`, `approvalId`, `buildApprovalRequestedEvent`) harness adapters use to enforce a policy and surface `approval-requested` events for client-in-the-loop approvals. - **`@tanstack/ai-sandbox-local-process`** — `localProcessSandbox()`: runs the agent on the host through the uniform `SandboxHandle` (no isolation; the fast dev loop). - **`@tanstack/ai-sandbox-docker`** — `dockerSandbox()`: runs the agent inside an isolated Docker container (dockerode), with commit-based snapshots, fork, and resume-by-id. - **`@tanstack/ai`** — `TextOptions.capabilities` exposes the middleware capability context to adapters so harness adapters that declare `requires: [...]` can read provided capabilities from `chatStream`; `TextOptions.approvals` threads client approval decisions through to adapters for the interactive-approval (deny + `approval-requested` + re-run) flow; `DefinedChatMiddleware` and `AnyChatMiddleware` are now exported for portable middleware authoring. diff --git a/packages/ai-sandbox-local-process/CHANGELOG.md b/packages/ai-sandbox-local-process/CHANGELOG.md index 10197a8f0..85217aeca 100644 --- a/packages/ai-sandbox-local-process/CHANGELOG.md +++ b/packages/ai-sandbox-local-process/CHANGELOG.md @@ -22,7 +22,7 @@ exec-poll automatically. - [#774](https://github.com/TanStack/ai/pull/774) [`b628a4d`](https://github.com/TanStack/ai/commit/b628a4da5fd21184922c6944059768d1ed6071d4) - New provider-agnostic sandbox layer so harness adapters can run **inside** isolated sandboxes. - - **`@tanstack/ai-sandbox`** — `defineSandbox()` (lazy controller + resume→restoreSnapshot→create+bootstrap ensure algorithm), `withSandbox()` middleware, `defineWorkspace()` (git/local source, package-manager detection, setup, skills, secrets), `defineSandboxPolicy()`, the `SandboxProvider`/`SandboxHandle`/`SandboxCapabilities` contracts, capability tokens (`SandboxCapability` plus the optional `SandboxStore`/`Locks` persistence seams with in-memory defaults), `bootstrapWorkspace`, `createExecBackedGit`, `spawnNdjson` (run an agent CLI in a sandbox and stream its NDJSON stdout), the host MCP tool-proxy bridge (`startHostToolBridge` — exposes `chat()` server tools to the in-sandbox agent, with an optional permission-prompt tool), and the shared interactive-approval primitives (`resolveApproval`, `approvalId`, `buildApprovalRequestedEvent`) harness adapters use to enforce a policy and surface `approval-requested` events for client-in-the-loop approvals. + - **`@tanstack/ai-sandbox`** — `defineSandbox()` (lazy controller + resume→restoreSnapshot→create+bootstrap ensure algorithm), `withSandbox()` middleware, `defineWorkspace()` (git/local source, package-manager detection, setup, skills, secrets), `defineSandboxPolicy()`, the `SandboxProvider`/`SandboxHandle`/`SandboxCapabilities` contracts, capability tokens (`SandboxCapability` plus the optional `SandboxInstanceStore`/`Locks` persistence seams with in-memory defaults), `bootstrapWorkspace`, `createExecBackedGit`, `spawnNdjson` (run an agent CLI in a sandbox and stream its NDJSON stdout), the host MCP tool-proxy bridge (`startHostToolBridge` — exposes `chat()` server tools to the in-sandbox agent, with an optional permission-prompt tool), and the shared interactive-approval primitives (`resolveApproval`, `approvalId`, `buildApprovalRequestedEvent`) harness adapters use to enforce a policy and surface `approval-requested` events for client-in-the-loop approvals. - **`@tanstack/ai-sandbox-local-process`** — `localProcessSandbox()`: runs the agent on the host through the uniform `SandboxHandle` (no isolation; the fast dev loop). - **`@tanstack/ai-sandbox-docker`** — `dockerSandbox()`: runs the agent inside an isolated Docker container (dockerode), with commit-based snapshots, fork, and resume-by-id. - **`@tanstack/ai`** — `TextOptions.capabilities` exposes the middleware capability context to adapters so harness adapters that declare `requires: [...]` can read provided capabilities from `chatStream`; `TextOptions.approvals` threads client approval decisions through to adapters for the interactive-approval (deny + `approval-requested` + re-run) flow; `DefinedChatMiddleware` and `AnyChatMiddleware` are now exported for portable middleware authoring. diff --git a/packages/ai-sandbox/CHANGELOG.md b/packages/ai-sandbox/CHANGELOG.md index 6f3ee0dc9..0bba903b1 100644 --- a/packages/ai-sandbox/CHANGELOG.md +++ b/packages/ai-sandbox/CHANGELOG.md @@ -69,7 +69,7 @@ exec-poll automatically. - [#774](https://github.com/TanStack/ai/pull/774) [`b628a4d`](https://github.com/TanStack/ai/commit/b628a4da5fd21184922c6944059768d1ed6071d4) - New provider-agnostic sandbox layer so harness adapters can run **inside** isolated sandboxes. - - **`@tanstack/ai-sandbox`** — `defineSandbox()` (lazy controller + resume→restoreSnapshot→create+bootstrap ensure algorithm), `withSandbox()` middleware, `defineWorkspace()` (git/local source, package-manager detection, setup, skills, secrets), `defineSandboxPolicy()`, the `SandboxProvider`/`SandboxHandle`/`SandboxCapabilities` contracts, capability tokens (`SandboxCapability` plus the optional `SandboxStore`/`Locks` persistence seams with in-memory defaults), `bootstrapWorkspace`, `createExecBackedGit`, `spawnNdjson` (run an agent CLI in a sandbox and stream its NDJSON stdout), the host MCP tool-proxy bridge (`startHostToolBridge` — exposes `chat()` server tools to the in-sandbox agent, with an optional permission-prompt tool), and the shared interactive-approval primitives (`resolveApproval`, `approvalId`, `buildApprovalRequestedEvent`) harness adapters use to enforce a policy and surface `approval-requested` events for client-in-the-loop approvals. + - **`@tanstack/ai-sandbox`** — `defineSandbox()` (lazy controller + resume→restoreSnapshot→create+bootstrap ensure algorithm), `withSandbox()` middleware, `defineWorkspace()` (git/local source, package-manager detection, setup, skills, secrets), `defineSandboxPolicy()`, the `SandboxProvider`/`SandboxHandle`/`SandboxCapabilities` contracts, capability tokens (`SandboxCapability` plus the optional `SandboxInstanceStore`/`Locks` persistence seams with in-memory defaults), `bootstrapWorkspace`, `createExecBackedGit`, `spawnNdjson` (run an agent CLI in a sandbox and stream its NDJSON stdout), the host MCP tool-proxy bridge (`startHostToolBridge` — exposes `chat()` server tools to the in-sandbox agent, with an optional permission-prompt tool), and the shared interactive-approval primitives (`resolveApproval`, `approvalId`, `buildApprovalRequestedEvent`) harness adapters use to enforce a policy and surface `approval-requested` events for client-in-the-loop approvals. - **`@tanstack/ai-sandbox-local-process`** — `localProcessSandbox()`: runs the agent on the host through the uniform `SandboxHandle` (no isolation; the fast dev loop). - **`@tanstack/ai-sandbox-docker`** — `dockerSandbox()`: runs the agent inside an isolated Docker container (dockerode), with commit-based snapshots, fork, and resume-by-id. - **`@tanstack/ai`** — `TextOptions.capabilities` exposes the middleware capability context to adapters so harness adapters that declare `requires: [...]` can read provided capabilities from `chatStream`; `TextOptions.approvals` threads client approval decisions through to adapters for the interactive-approval (deny + `approval-requested` + re-run) flow; `DefinedChatMiddleware` and `AnyChatMiddleware` are now exported for portable middleware authoring. 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 8abd7f95d..d4f2c01db 100644 --- a/packages/ai-sandbox/src/capabilities.ts +++ b/packages/ai-sandbox/src/capabilities.ts @@ -1,26 +1,19 @@ /** - * Capability tokens the sandbox layer provides/consumes through the - * `@tanstack/ai` middleware capability system. + * Capability tokens the sandbox layer owns and provides. * * - `SandboxCapability` is PROVIDED by `withSandbox` and REQUIRED by harness * adapters (`requires: [SandboxCapability]`). - * - `SandboxStoreCapability` / `LocksCapability` are OPTIONALLY required by - * `withSandbox`. v1 falls back to in-memory defaults; the future persistence - * package PROVIDES durable implementations. + * - `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 { LockStore, 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') - -export const LocksCapability = createCapability()('locks') - /** * The active sandbox policy, provided by `withSandbox` from the definition. * Harness adapters read it to map allow/ask/deny rules onto their native @@ -40,8 +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 [getLocks, provideLocks] = LocksCapability 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 2c5bf2855..6788ca3b7 100644 --- a/packages/ai-sandbox/src/index.ts +++ b/packages/ai-sandbox/src/index.ts @@ -1,22 +1,30 @@ -// Capability tokens + accessors +// Capability tokens + accessors (sandbox-owned only). +// LockStore / withLocks: import from @tanstack/ai. export { SandboxCapability, - SandboxStoreCapability, - LocksCapability, SandboxPolicyCapability, ToolBridgeProvisionerCapability, getSandbox, provideSandbox, - getSandboxStore, - provideSandboxStore, - getLocks, - provideLocks, 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, @@ -100,10 +108,6 @@ export type { SandboxDestroyInput, } from './contracts' -// Stores (interfaces + in-memory defaults) -export { InMemorySandboxStore, InMemoryLockStore } from './store' -export type { SandboxStore, LockStore, 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 d25314429..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 the optional SandboxStore/Locks - * capabilities when a persistence middleware supplied them (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. * @@ -14,15 +15,14 @@ * are emitted by the harness adapter's chatStream (which can yield CUSTOM * chunks), not from here — middleware setup runs before streaming begins. */ -import { defineChatMiddleware } from '@tanstack/ai' +import { LocksCapability, defineChatMiddleware } from '@tanstack/ai' import { getSandboxRuntime } from '@tanstack/ai/adapter-internals' import { - LocksCapability, 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' @@ -98,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, @@ -154,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 b1f20859e..642c35a2f 100644 --- a/packages/ai-sandbox/src/sandbox.ts +++ b/packages/ai-sandbox/src/sandbox.ts @@ -9,11 +9,12 @@ import { bootstrapWorkspace } from './bootstrap' import { resolveAllSecrets } from './secrets' import { computeSandboxKey } from './key' -import { InMemoryLockStore, InMemorySandboxStore } from './store' -import type { SandboxFileHookEvent } from '@tanstack/ai' +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 { LockStore, SandboxStore } from './store' import type { SandboxPolicy } from './policy' import type { WorkspaceDefinition } from './workspace' @@ -69,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 } @@ -111,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 7371cfbd6..000000000 --- a/packages/ai-sandbox/src/store.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Persistence seams for the sandbox layer. - * - * v1 ships ONLY in-memory implementations (single-process resume). These are - * deliberately pluggable OPTIONAL capabilities so the future persistence - * package can `provide` durable implementations (D1/Postgres/Durable Objects) - * without the sandbox layer changing. Do NOT hardcode storage here. - */ - -/** One persisted 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 persistence layer). */ - 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 -} - -/** - * Mutual exclusion around sandbox ensure so two concurrent runs for the same - * thread don't both create a sandbox. The in-memory default is single-process; - * the persistence layer provides a distributed lock (e.g. a Durable Object). - */ -export interface LockStore { - withLock: (key: string, fn: () => Promise) => 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() - } -} - -/** - * In-memory {@link LockStore} — a per-key promise chain. Correct within a - * single process; multi-instance correctness needs a distributed lock from the - * persistence layer. - */ -export class InMemoryLockStore implements LockStore { - private readonly chains = new Map>() - - withLock(key: string, fn: () => Promise): Promise { - const prior = this.chains.get(key) ?? Promise.resolve() - // Chain after the prior holder regardless of how it settled. - const run = prior.then(fn, fn) - // Keep the chain alive but swallow rejections so one failure doesn't poison the lock. - this.chains.set( - key, - run.then( - () => undefined, - () => undefined, - ), - ) - return run - } -} 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 1de55787c..d836ae236 100644 --- a/packages/ai-sandbox/tests/ensure.test.ts +++ b/packages/ai-sandbox/tests/ensure.test.ts @@ -1,14 +1,15 @@ import { describe, expect, it } from 'vitest' import { defineSandbox } from '../src/sandbox' import { defineWorkspace, githubRepo } from '../src/workspace' -import { InMemoryLockStore, InMemorySandboxStore } from '../src/store' +import { InMemoryLockStore } from '@tanstack/ai' +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 184d0d807..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 { InMemoryLockStore, 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', 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/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index a95900586..95518e25f 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -141,7 +141,7 @@ exec-poll automatically. - [#774](https://github.com/TanStack/ai/pull/774) [`b628a4d`](https://github.com/TanStack/ai/commit/b628a4da5fd21184922c6944059768d1ed6071d4) - New provider-agnostic sandbox layer so harness adapters can run **inside** isolated sandboxes. - - **`@tanstack/ai-sandbox`** — `defineSandbox()` (lazy controller + resume→restoreSnapshot→create+bootstrap ensure algorithm), `withSandbox()` middleware, `defineWorkspace()` (git/local source, package-manager detection, setup, skills, secrets), `defineSandboxPolicy()`, the `SandboxProvider`/`SandboxHandle`/`SandboxCapabilities` contracts, capability tokens (`SandboxCapability` plus the optional `SandboxStore`/`Locks` persistence seams with in-memory defaults), `bootstrapWorkspace`, `createExecBackedGit`, `spawnNdjson` (run an agent CLI in a sandbox and stream its NDJSON stdout), the host MCP tool-proxy bridge (`startHostToolBridge` — exposes `chat()` server tools to the in-sandbox agent, with an optional permission-prompt tool), and the shared interactive-approval primitives (`resolveApproval`, `approvalId`, `buildApprovalRequestedEvent`) harness adapters use to enforce a policy and surface `approval-requested` events for client-in-the-loop approvals. + - **`@tanstack/ai-sandbox`** — `defineSandbox()` (lazy controller + resume→restoreSnapshot→create+bootstrap ensure algorithm), `withSandbox()` middleware, `defineWorkspace()` (git/local source, package-manager detection, setup, skills, secrets), `defineSandboxPolicy()`, the `SandboxProvider`/`SandboxHandle`/`SandboxCapabilities` contracts, capability tokens (`SandboxCapability` plus the optional `SandboxInstanceStore`/`Locks` persistence seams with in-memory defaults), `bootstrapWorkspace`, `createExecBackedGit`, `spawnNdjson` (run an agent CLI in a sandbox and stream its NDJSON stdout), the host MCP tool-proxy bridge (`startHostToolBridge` — exposes `chat()` server tools to the in-sandbox agent, with an optional permission-prompt tool), and the shared interactive-approval primitives (`resolveApproval`, `approvalId`, `buildApprovalRequestedEvent`) harness adapters use to enforce a policy and surface `approval-requested` events for client-in-the-loop approvals. - **`@tanstack/ai-sandbox-local-process`** — `localProcessSandbox()`: runs the agent on the host through the uniform `SandboxHandle` (no isolation; the fast dev loop). - **`@tanstack/ai-sandbox-docker`** — `dockerSandbox()`: runs the agent inside an isolated Docker container (dockerode), with commit-based snapshots, fork, and resume-by-id. - **`@tanstack/ai`** — `TextOptions.capabilities` exposes the middleware capability context to adapters so harness adapters that declare `requires: [...]` can read provided capabilities from `chatStream`; `TextOptions.approvals` threads client approval decisions through to adapters for the interactive-approval (deny + `approval-requested` + re-run) flow; `DefinedChatMiddleware` and `AnyChatMiddleware` are now exported for portable middleware authoring. diff --git a/packages/ai/skills/ai-core/SKILL.md b/packages/ai/skills/ai-core/SKILL.md index eafd66307..64eb861d3 100644 --- a/packages/ai/skills/ai-core/SKILL.md +++ b/packages/ai/skills/ai-core/SKILL.md @@ -64,14 +64,14 @@ The skills ship **inside** the package, so they only exist on disk once it is installed — the second command re-scans `node_modules` and wires them into the agent config. Until then the paths below resolve to nothing. -Entry point: `node_modules/@tanstack/ai-persistence/skills/tanstack-ai-persistence/SKILL.md` - -| Need to... | Read | -| ----------------------------------------------- | ------------------------------------------------ | -| Wire server-side chat history, runs, interrupts | tanstack-ai-persistence-server/SKILL.md | -| Implement the store interfaces for your DB | tanstack-ai-persistence-stores/SKILL.md | -| Multi-instance locks (separate from state) | tanstack-ai-persistence-locks/SKILL.md | -| Write the adapter for the DB your app runs | tanstack-ai-persistence-build-*-adapter/SKILL.md | +Entry point: `node_modules/@tanstack/ai-persistence/skills/ai-persistence/SKILL.md` + +| Need to... | Read | +| ----------------------------------------------- | --------------------------------------- | +| Wire server-side chat history, runs, interrupts | ai-persistence/server/SKILL.md | +| Implement the store interfaces for your DB | ai-persistence/stores/SKILL.md | +| Multi-instance locks (separate from state) | ai-persistence/locks/SKILL.md | +| Write the adapter for the DB your app runs | ai-persistence/build-*-adapter/SKILL.md | Browser-side persistence is **not** in this package — it ships with the framework packages, so read **ai-core/client-persistence** instead. diff --git a/packages/ai/skills/ai-core/chat-experience/SKILL.md b/packages/ai/skills/ai-core/chat-experience/SKILL.md index 2ee72cd56..5f92e0021 100644 --- a/packages/ai/skills/ai-core/chat-experience/SKILL.md +++ b/packages/ai/skills/ai-core/chat-experience/SKILL.md @@ -766,4 +766,4 @@ If not handled, the UI appears to hang with no feedback. - See also: **ai-core/tool-calling/SKILL.md** -- Most chats include tools - See also: **ai-core/adapter-configuration/SKILL.md** -- Adapter choice affects available features - See also: **ai-core/middleware/SKILL.md** -- Use middleware for analytics and lifecycle events -- See also: **`@tanstack/ai-persistence` skills** (`skills/tanstack-ai-persistence/SKILL.md` in that package) -- Server + client state persistence, store contracts, adapter recipes (deeper than Pattern 8) +- See also: **`@tanstack/ai-persistence` skills** (`skills/ai-persistence/SKILL.md` in that package) -- Server + client state persistence, store contracts, adapter recipes (deeper than Pattern 8) diff --git a/packages/ai/skills/ai-core/client-persistence/SKILL.md b/packages/ai/skills/ai-core/client-persistence/SKILL.md index 3b8005eec..1fdbae62a 100644 --- a/packages/ai/skills/ai-core/client-persistence/SKILL.md +++ b/packages/ai/skills/ai-core/client-persistence/SKILL.md @@ -23,7 +23,7 @@ sources: > (`@tanstack/ai-react` and friends, re-exported from `@tanstack/ai-client`), > so browser persistence needs nothing installed beyond what a chat UI already > has. The **server** half is a separate package — see -> `@tanstack/ai-persistence` and its `tanstack-ai-persistence-server` skill. +> `@tanstack/ai-persistence` and its `ai-persistence/server` skill. A `ChatClient` / `useChat` keeps messages in memory. The `persistence` option stores one record per `threadId` so a reload can repaint the transcript, @@ -138,6 +138,6 @@ IndexedDB with care. ## Cross-references -- **tanstack-ai-persistence-server** (`@tanstack/ai-persistence`) — authoritative server half +- **ai-persistence/server** (`@tanstack/ai-persistence`) — authoritative server half - **ai-core/chat-experience** — `useChat`, resumable connections - Resumable streams docs — mid-stream rejoin diff --git a/packages/ai/skills/ai-core/middleware/SKILL.md b/packages/ai/skills/ai-core/middleware/SKILL.md index 7a6f821fa..01a3ca4c6 100644 --- a/packages/ai/skills/ai-core/middleware/SKILL.md +++ b/packages/ai/skills/ai-core/middleware/SKILL.md @@ -441,7 +441,7 @@ Locks are separate from state and are **not** a `stores` key: wire a `LockStore` with `withLocks(lockStore)`. **Full guidance lives in the package's own skills** — start at -`node_modules/@tanstack/ai-persistence/skills/tanstack-ai-persistence/SKILL.md`, +`node_modules/@tanstack/ai-persistence/skills/ai-persistence/SKILL.md`, which routes to the server, client, stores, locks, and adapter-recipe (Drizzle / Prisma / Cloudflare) sub-skills. @@ -649,4 +649,4 @@ Source: docs/advanced/middleware.md - See also: **ai-core/chat-experience/SKILL.md** -- Middleware hooks into the chat lifecycle - See also: **ai-core/structured-outputs/SKILL.md** -- Middleware now wraps the final structured-output call; use `onStructuredOutputConfig` for JSON-Schema transforms - See also: **ai-core/ag-ui-protocol/SKILL.md** -- Reading the `sandbox.file` / `sandbox.file.diff` `CUSTOM` chunks the sandbox runtime emits alongside these `sandbox` hooks, via `ChatStream`'s typed `KnownCustomEvent` narrowing -- See also: **`@tanstack/ai-persistence` skills** (`skills/tanstack-ai-persistence/SKILL.md` in that package) -- Full persistence suite (`withPersistence`, client storage, store contracts, adapter recipes, locks). This file only sketches server `withPersistence`. +- See also: **`@tanstack/ai-persistence` skills** (`skills/ai-persistence/SKILL.md` in that package) -- Full persistence suite (`withPersistence`, client storage, store contracts, adapter recipes, locks). This file only sketches server `withPersistence`. diff --git a/packages/ai/src/activities/chat/middleware/index.ts b/packages/ai/src/activities/chat/middleware/index.ts index ad4fa9dad..f27440c70 100644 --- a/packages/ai/src/activities/chat/middleware/index.ts +++ b/packages/ai/src/activities/chat/middleware/index.ts @@ -41,3 +41,12 @@ export type { } from './builder' export { validateCapabilities } from './validate' export type { AnyChatMiddleware } from './types' + +export { + LocksCapability, + getLocks, + provideLocks, + InMemoryLockStore, + withLocks, +} from './locks' +export type { LockStore } from './locks' diff --git a/packages/ai/src/activities/chat/middleware/locks.ts b/packages/ai/src/activities/chat/middleware/locks.ts new file mode 100644 index 000000000..9024b416a --- /dev/null +++ b/packages/ai/src/activities/chat/middleware/locks.ts @@ -0,0 +1,94 @@ +/** + * Distributed-mutex primitive — the neutral home for the `'locks'` capability. + * + * Capability identity is by object reference (see `createCapability`). Any + * middleware may PROVIDE a {@link LockStore} via {@link withLocks} / + * {@link provideLocks}; consumers (notably `@tanstack/ai-sandbox` `ensure`) + * read it with {@link getLocks}. Not a state-persistence concern — do not put + * locks on a stores bag. + */ +import { createCapability } from './capabilities' +import { defineChatMiddleware } from './define' +import type { ChatMiddleware, ChatMiddlewareContext } from './types' + +/** + * Mutual exclusion around a critical section keyed by `key`. A distributed + * backend (e.g. a Cloudflare Durable Object) is the only kind safe across + * instances; the in-memory default is correct within a single process only. + * Lease-backed implementations abort `signal` as soon as ownership can no longer + * be guaranteed; the callback must stop externally visible mutations when it + * aborts. Callbacks that ignore `signal` (e.g. the sandbox `ensure` critical + * section) remain valid — a `() => Promise` is assignable to the + * signal-taking parameter. + */ +export interface LockStore { + withLock: ( + key: string, + fn: (signal: AbortSignal) => Promise, + ) => Promise +} + +/** + * The lock capability. Provided by {@link withLocks} or any middleware that + * calls {@link provideLocks}. + */ +export const LocksCapability = createCapability()('locks') + +/** Destructured accessors: `getLocks(ctx)` / `provideLocks(ctx, store)`. */ +export const [getLocks, provideLocks] = LocksCapability + +/** + * In-memory {@link LockStore} — a per-key promise chain. Correct within a single + * process; multi-instance correctness needs a distributed lock backend. + */ +export class InMemoryLockStore implements LockStore { + private readonly chains = new Map>() + + withLock( + key: string, + fn: (signal: AbortSignal) => Promise, + ): Promise { + const prior = this.chains.get(key) ?? Promise.resolve() + const runCriticalSection = () => fn(new AbortController().signal) + // Chain after the prior holder regardless of how it settled. + const run = prior.then(runCriticalSection, runCriticalSection) + // Swallow rejections so one failure doesn't poison the lock, then drop the + // chain entry once this tail is still the latest — otherwise long-lived + // processes accumulate settled promises for every distinct key forever. + const settled = run.then( + () => undefined, + () => undefined, + ) + this.chains.set(key, settled) + void settled.then(() => { + if (this.chains.get(key) === settled) { + this.chains.delete(key) + } + }) + return run + } +} + +/** + * Provide a {@link LockStore} on the chat middleware capability bus. + * + * Coordination only — independent of chat state persistence. A lock provided + * here reaches any later middleware that reads {@link LocksCapability} + * (including `withSandbox`). + * + * ```ts + * middleware: [ + * withLocks(distributedLocks), + * withSandbox(sandbox), + * ] + * ``` + */ +export function withLocks(locks: LockStore): ChatMiddleware { + return defineChatMiddleware({ + name: 'locks', + provides: [LocksCapability], + setup(ctx: ChatMiddlewareContext) { + provideLocks(ctx, locks) + }, + }) +} diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 4448c4e44..d7a56065d 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -218,7 +218,17 @@ export type { DefinedChatMiddleware, AnyChatMiddleware, } from './activities/chat/middleware/index' - +// Distributed-mutex primitive — coordination, not state persistence. +// Consumers (e.g. @tanstack/ai-sandbox ensure) read LocksCapability; provide +// via withLocks. Import from @tanstack/ai — not from @tanstack/ai-persistence. +export { + LocksCapability, + getLocks, + provideLocks, + InMemoryLockStore, + withLocks, +} from './activities/chat/middleware/index' +export type { LockStore } from './activities/chat/middleware/index' // Well-known AG-UI CUSTOM event catalog (agent activity rides on CUSTOM events) export { CUSTOM_EVENT, isCustomEvent } from './custom-events' export type { diff --git a/packages/ai-persistence/tests/locks.test.ts b/packages/ai/tests/in-memory-lock-store.test.ts similarity index 95% rename from packages/ai-persistence/tests/locks.test.ts rename to packages/ai/tests/in-memory-lock-store.test.ts index 32f48fbbe..ea8778778 100644 --- a/packages/ai-persistence/tests/locks.test.ts +++ b/packages/ai/tests/in-memory-lock-store.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { InMemoryLockStore } from '../src/locks' +import { InMemoryLockStore } from '../src' describe('InMemoryLockStore', () => { it('serializes concurrent withLock calls on the same key', async () => { 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') + }) +})