diff --git a/.changeset/bright-cloudflare-snapshots.md b/.changeset/bright-cloudflare-snapshots.md new file mode 100644 index 0000000000..e5052fd239 --- /dev/null +++ b/.changeset/bright-cloudflare-snapshots.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-sandbox-cloudflare': patch +--- + +Support filesystem metadata needed by portable sandbox snapshots. diff --git a/.changeset/calm-daytona-snapshots.md b/.changeset/calm-daytona-snapshots.md new file mode 100644 index 0000000000..a41ab62fd6 --- /dev/null +++ b/.changeset/calm-daytona-snapshots.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-sandbox-daytona': patch +--- + +Support filesystem metadata needed by portable sandbox snapshots. diff --git a/.changeset/chunked-sandbox-fs-write.md b/.changeset/chunked-sandbox-fs-write.md new file mode 100644 index 0000000000..58a6dfae6f --- /dev/null +++ b/.changeset/chunked-sandbox-fs-write.md @@ -0,0 +1,7 @@ +--- +'@tanstack/ai-sandbox-docker': patch +'@tanstack/ai-sandbox-vercel': patch +'@tanstack/ai-sandbox-cloudflare': patch +--- + +Write files in 32KB base64 chunks so snapshot restore no longer hits Linux `MAX_ARG_STRLEN`. diff --git a/.changeset/direct-docker-snapshots.md b/.changeset/direct-docker-snapshots.md new file mode 100644 index 0000000000..3943fd89e3 --- /dev/null +++ b/.changeset/direct-docker-snapshots.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-sandbox-docker': patch +--- + +Support filesystem metadata needed by portable sandbox snapshots. diff --git a/.changeset/eager-local-process-snapshots.md b/.changeset/eager-local-process-snapshots.md new file mode 100644 index 0000000000..91fb7e8af7 --- /dev/null +++ b/.changeset/eager-local-process-snapshots.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-sandbox-local-process': patch +--- + +Support filesystem metadata needed by portable sandbox snapshots. diff --git a/.changeset/fresh-sprites-snapshots.md b/.changeset/fresh-sprites-snapshots.md new file mode 100644 index 0000000000..deeaa5d6c6 --- /dev/null +++ b/.changeset/fresh-sprites-snapshots.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-sandbox-sprites': patch +--- + +Support filesystem metadata needed by portable sandbox snapshots. diff --git a/.changeset/fuzzy-snapshots-build.md b/.changeset/fuzzy-snapshots-build.md new file mode 100644 index 0000000000..f6b0bc9faf --- /dev/null +++ b/.changeset/fuzzy-snapshots-build.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-sandbox': minor +--- + +Add portable sandbox checkpoints. `createSandboxSnapshots` and `memorySandboxSnapshots` return one object with `save`, `fork`, and `readArtifact`. `createSnapshotTools` turns those methods into host tools bound to the route `threadId`. diff --git a/.changeset/gentle-vercel-snapshots.md b/.changeset/gentle-vercel-snapshots.md new file mode 100644 index 0000000000..4788289100 --- /dev/null +++ b/.changeset/gentle-vercel-snapshots.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-sandbox-vercel': patch +--- + +Support filesystem metadata needed by portable sandbox snapshots. diff --git a/.changeset/tidy-artifact-history.md b/.changeset/tidy-artifact-history.md new file mode 100644 index 0000000000..d4e6eb4d31 --- /dev/null +++ b/.changeset/tidy-artifact-history.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-persistence': minor +--- + +Add complete thread artifact history for portable sandbox snapshots. diff --git a/docs/config.json b/docs/config.json index e1a5aa44b6..7026808467 100644 --- a/docs/config.json +++ b/docs/config.json @@ -240,7 +240,8 @@ { "label": "Overview", "to": "persistence/overview", - "addedAt": "2026-08-04" + "addedAt": "2026-08-04", + "updatedAt": "2026-08-14" }, { "label": "Chat Persistence", @@ -260,12 +261,14 @@ { "label": "Controls", "to": "persistence/controls", - "addedAt": "2026-08-04" + "addedAt": "2026-08-04", + "updatedAt": "2026-08-14" }, { "label": "Build Your Own Adapter", "to": "persistence/build-your-own-adapter", - "addedAt": "2026-08-04" + "addedAt": "2026-08-04", + "updatedAt": "2026-08-14" }, { "label": "Migrations", @@ -298,17 +301,20 @@ { "label": "Build a Generation Adapter", "to": "persistence/build-your-own-generation-adapter", - "addedAt": "2026-08-04" + "addedAt": "2026-08-04", + "updatedAt": "2026-08-14" }, { "label": "Build a Sandbox Adapter", "to": "persistence/build-a-sandbox-adapter", - "addedAt": "2026-08-04" + "addedAt": "2026-08-04", + "updatedAt": "2026-08-14" }, { "label": "Store Reference", "to": "persistence/store-reference", - "addedAt": "2026-08-04" + "addedAt": "2026-08-04", + "updatedAt": "2026-08-14" }, { "label": "How Persistence Works", @@ -509,7 +515,7 @@ "label": "Overview", "to": "sandbox/overview", "addedAt": "2026-06-16", - "updatedAt": "2026-08-12" + "updatedAt": "2026-08-14" }, { "label": "Quick Start", @@ -521,7 +527,7 @@ "label": "Providers", "to": "sandbox/providers", "addedAt": "2026-06-29", - "updatedAt": "2026-08-12" + "updatedAt": "2026-08-14" }, { "label": "Harnesses", @@ -551,12 +557,52 @@ "label": "Lifecycle & Snapshots", "to": "sandbox/lifecycle", "addedAt": "2026-06-29", - "updatedAt": "2026-08-12" + "updatedAt": "2026-08-14" + }, + { + "label": "Portable Snapshots", + "to": "sandbox/portable-snapshots", + "addedAt": "2026-08-14", + "updatedAt": "2026-08-14" + }, + { + "label": "Keep Files After Reload", + "to": "sandbox/portable-snapshots-configure", + "addedAt": "2026-08-14", + "updatedAt": "2026-08-14" + }, + { + "label": "Save a Named Version", + "to": "sandbox/portable-snapshots-save", + "addedAt": "2026-08-14", + "updatedAt": "2026-08-14" + }, + { + "label": "Branch From a Version", + "to": "sandbox/portable-snapshots-fork", + "addedAt": "2026-08-14", + "updatedAt": "2026-08-14" + }, + { + "label": "Send a Frozen File", + "to": "sandbox/portable-snapshots-artifacts", + "addedAt": "2026-08-14" + }, + { + "label": "Let the Agent Save and Fork", + "to": "sandbox/portable-snapshots-tools", + "addedAt": "2026-08-14" + }, + { + "label": "What a Snapshot Stores", + "to": "sandbox/portable-snapshots-safety", + "addedAt": "2026-08-14" }, { "label": "Instance Durability", "to": "sandbox/durability", - "addedAt": "2026-08-04" + "addedAt": "2026-08-04", + "updatedAt": "2026-08-14" }, { "label": "Durable Runs", diff --git a/docs/persistence/build-a-sandbox-adapter.md b/docs/persistence/build-a-sandbox-adapter.md index 540463f310..8f351a2eae 100644 --- a/docs/persistence/build-a-sandbox-adapter.md +++ b/docs/persistence/build-a-sandbox-adapter.md @@ -27,6 +27,10 @@ place the two meet. It is the third of the adapter walkthroughs, next to [generation](./build-your-own-generation-adapter), and it needs neither of their store contracts. +To rebuild completed workspace files after the provider sandbox is gone, use +[Keep Files After Reload](../sandbox/portable-snapshots-configure). Pass the same +persistence object that `withPersistence` uses. + ## Decide what you store | You keep | Wire | You get | You give up | diff --git a/docs/persistence/build-your-own-adapter.md b/docs/persistence/build-your-own-adapter.md index 8cbbe457de..77fee806dd 100644 --- a/docs/persistence/build-your-own-adapter.md +++ b/docs/persistence/build-your-own-adapter.md @@ -67,23 +67,29 @@ object inline so you never annotate it by hand. Each store switches on one capability. Find your column and implement the rows marked with a tick: -| Store | Save the transcript | Rejoin a run after reload | Durable approvals | App key/value | Persist generation runs | Keep generated files | -| --- | :-: | :-: | :-: | :-: | :-: | :-: | -| `messages` | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | -| `runs` | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | -| `interrupts` | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | -| `metadata` | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | -| `generationRuns` | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | -| `artifacts` | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | -| `blobs` | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | - -- **Columns stack.** Durable approvals and generated files means the union of both. -- **Two pairs cannot be split.** `interrupts` needs `runs`, and `artifacts` needs - `blobs`. -- **The generation stores feed `withGenerationPersistence`** instead, and need none of - the chat stores. See [Generation persistence](./generation-persistence). - -The common production shape is `messages` + `runs` + `interrupts`. +| Store | Save the transcript | Rejoin a run after reload | Durable approvals | App key/value | Persist generation runs | Keep generated files | Rebuild sandbox files | +| --- | :-: | :-: | :-: | :-: | :-: | :-: | :-: | +| `messages` | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | +| `runs` | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | +| `interrupts` | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | +| `metadata` | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | +| `generationRuns` | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ | +| `artifacts` | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | +| `blobs` | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | + +- **Columns stack.** Chat plus sandbox files means `messages` + `artifacts` + + `blobs`. Durable approvals plus generated files means the union of both + columns. +- **Two pairs cannot be split.** `interrupts` needs `runs`, and `artifacts` + needs `blobs`. +- **Generation runs need none of the chat stores.** See + [Generation persistence](./generation-persistence). +- **Sandbox files need a checkpoint store too.** That store lives on + `@tanstack/ai-sandbox`, not in this table. See + [Keep Files After Reload](../sandbox/portable-snapshots-configure). + +The common production shape is `messages` + `runs` + `interrupts`. When you +keep generated files or rebuild sandbox files, add `artifacts` and `blobs`. You can also own only part of it. Put `messages` and `runs` in your database and fill the rest from somewhere else with `composePersistence`: @@ -158,6 +164,8 @@ for `withPersistence`, and with the generation stores for artifacts and blobs. - [Build a sandbox adapter](./build-a-sandbox-adapter): the sandbox instance store, and what a durable sandboxed run adds to `runs`. Only if you run sandboxes. +- [Keep Files After Reload](../sandbox/portable-snapshots-configure): reuse this + adapter for portable snapshots. You need `messages`, `artifacts`, and `blobs`. - [Store reference](./store-reference): every signature and invariant, and how the records relate. - [Controls](./controls): compose stores from different systems. diff --git a/docs/persistence/build-your-own-generation-adapter.md b/docs/persistence/build-your-own-generation-adapter.md index 0667ebe2cd..a6f1542c18 100644 --- a/docs/persistence/build-your-own-generation-adapter.md +++ b/docs/persistence/build-your-own-generation-adapter.md @@ -54,6 +54,10 @@ CREATE TABLE IF NOT EXISTS artifacts ( source_url text, created_at integer NOT NULL ); +CREATE INDEX IF NOT EXISTS artifacts_run_order + ON artifacts (run_id, created_at, artifact_id); +CREATE INDEX IF NOT EXISTS artifacts_thread_order + ON artifacts (thread_id, created_at, artifact_id); CREATE TABLE IF NOT EXISTS blobs ( key text PRIMARY KEY NOT NULL, bytes blob NOT NULL, @@ -218,6 +222,9 @@ function createGenerationRunStore(db: DatabaseSync) { - `save` is an upsert. - `list(runId)` returns every artifact for a run, `[]` when there are none. +- `listForThread(threadId)` returns every artifact for the thread in exact + `(createdAt, artifactId)` ascending order. It must return the complete thread + history, not a page or only the latest run. Snapshot capture uses this cut. - `delete` / `deleteForRun` are required. Retention and erasure are the point of storing media durably, and they mirror `BlobStore.delete`. @@ -261,7 +268,10 @@ function createArtifactStore(db: DatabaseSync) { ) const selectOne = db.prepare('SELECT * FROM artifacts WHERE artifact_id = ?') const byRun = db.prepare( - 'SELECT * FROM artifacts WHERE run_id = ? ORDER BY created_at ASC', + 'SELECT * FROM artifacts WHERE run_id = ? ORDER BY created_at ASC, artifact_id ASC', + ) + const byThread = db.prepare( + 'SELECT * FROM artifacts WHERE thread_id = ? ORDER BY created_at ASC, artifact_id ASC', ) return defineArtifactStore({ async save(record) { @@ -284,6 +294,9 @@ function createArtifactStore(db: DatabaseSync) { async list(runId) { return byRun.all(runId).map(mapArtifact) }, + async listForThread(threadId) { + return byThread.all(threadId).map(mapArtifact) + }, async delete(artifactId) { db.prepare('DELETE FROM artifacts WHERE artifact_id = ?').run(artifactId) }, diff --git a/docs/persistence/controls.md b/docs/persistence/controls.md index e74d9dff7c..d69a62faa6 100644 --- a/docs/persistence/controls.md +++ b/docs/persistence/controls.md @@ -90,6 +90,9 @@ values arrive from untyped JavaScript. - `withPersistence` requires `messages`. - `interrupts` requires `runs`: an interrupt record is scoped to a run. - `withGenerationPersistence` requires `generationRuns`. +- Portable sandbox snapshots require `messages`, `artifacts`, and `blobs` on + the same persistence object. See + [Keep Files After Reload](../sandbox/portable-snapshots-configure). To define a partial backend directly rather than by composing, use `defineAIPersistence({ stores: { ... } })` and pass only the stores you have. diff --git a/docs/persistence/overview.md b/docs/persistence/overview.md index cfdaa019e4..8e0cf080ca 100644 --- a/docs/persistence/overview.md +++ b/docs/persistence/overview.md @@ -23,6 +23,10 @@ streaming. That is [Resumable Streams](../resumable-streams/overview), a differe layer you can add on its own. Step 3 below combines them, which is what most apps end up wanting. +When the provider sandbox is gone, the workspace files can also disappear. Use +the same persistence object with +[Keep Files After Reload](../sandbox/portable-snapshots-configure). + ## Install ```bash @@ -164,6 +168,7 @@ To make the `POST` resumable too, hand the same adapter to the response: | A reload mid-answer to pick the answer back up | Steps 1, 2 and 3 | | A dropped socket to resume with the page still open | [Resumable Streams](../resumable-streams/overview) alone | | To pause for a human approval and resume it days later | Step 1 with an `interrupts` store | +| Sandbox files to come back after the provider sandbox is gone | [Keep Files After Reload](../sandbox/portable-snapshots-configure) | ## Where to go next diff --git a/docs/persistence/store-reference.md b/docs/persistence/store-reference.md index fd0633f425..0c76b69d93 100644 --- a/docs/persistence/store-reference.md +++ b/docs/persistence/store-reference.md @@ -16,8 +16,8 @@ there is no separate enable list. | `interrupts` | Pending, resolved or cancelled human waits. Needs `runs`. | `withPersistence` | | `metadata` | App and integration key/value state. | `withPersistence` | | `generationRuns` | Generation run status and result metadata, keyed by its own `runId`. | `withGenerationPersistence`, required | -| `artifacts` | Generated-file metadata. Needs `blobs`. | `withGenerationPersistence` | -| `blobs` | The generated bytes. Needs `artifacts`. | `withGenerationPersistence` | +| `artifacts` | File metadata. Needs `blobs`. | `withGenerationPersistence`, portable snapshots | +| `blobs` | File bytes. Needs `artifacts`. | `withGenerationPersistence`, portable snapshots | Named groupings of the chat stores (`ChatTranscriptStores`, `ChatPersistenceStores`, `ChatWithInterruptsStores`) are covered in [Controls](./controls). @@ -337,14 +337,20 @@ interface ArtifactRecord { } interface ArtifactStore { - save(record: ArtifactRecord): Promise - get(artifactId: string): Promise - list(runId: string): Promise> // [] when the run has none - delete(artifactId: string): Promise - deleteForRun(runId: string): Promise + save: (record: ArtifactRecord) => Promise + get: (artifactId: string) => Promise + list: (runId: string) => Promise> // [] when the run has none + // Complete thread history, ordered by (createdAt, artifactId) ascending. + listForThread: (threadId: string) => Promise> + delete: (artifactId: string) => Promise + deleteForRun: (runId: string) => Promise } ``` +`list` and `listForThread` use `createdAt` first, then ordinal bytewise +`artifactId` order. Compare UTF-8 bytes from left to right. Do not use locale +collation. + ## BlobStore A durable object/blob store for the bytes. `withGenerationPersistence` writes diff --git a/docs/sandbox/durability.md b/docs/sandbox/durability.md index 55a43396b5..c5ce0a8506 100644 --- a/docs/sandbox/durability.md +++ b/docs/sandbox/durability.md @@ -20,6 +20,10 @@ 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. +Instance durability does not copy the workspace into your application storage. +Use [Keep Files After Reload](./portable-snapshots-configure) when a new +sandbox must rebuild completed files, artifacts, and the saved conversation. + It is also not the agent's *output*. This page keeps a sandbox findable across processes; keeping the run's event stream readable across processes is [The Run Journal](./journal). The two compose: a resumed sandbox still holds the diff --git a/docs/sandbox/lifecycle.md b/docs/sandbox/lifecycle.md index 6298893a21..477dae8ea3 100644 --- a/docs/sandbox/lifecycle.md +++ b/docs/sandbox/lifecycle.md @@ -10,6 +10,11 @@ Bootstrapping a sandbox (cloning the repo, installing dependencies, running cost once and reuse the result: keep one sandbox per thread, snapshot it after setup, and resume instead of re-bootstrapping on the next run. +When you must also recover files after a sandbox is gone, configure +[Keep Files After Reload](./portable-snapshots-configure). Provider-native +snapshots make bootstrap faster. Portable snapshots save the completed +workspace as durable application data. + ```ts import { defineSandbox, defineWorkspace, githubRepo } from '@tanstack/ai-sandbox' import { dockerSandbox } from '@tanstack/ai-sandbox-docker' @@ -124,6 +129,10 @@ Each step falls through to the next only when the prior one is unavailable. This is what turns a warm thread into a near-instant start, and a cold one into a full bootstrap. +Portable sandbox snapshots run after this lifecycle work. They restore a saved +workspace only into a newly created private sandbox. They never overwrite a +live resumed sandbox. See [Portable Snapshots](./portable-snapshots). + > Which providers support durable disk, snapshots, and resume-by-id is listed on > [Providers](./providers). diff --git a/docs/sandbox/overview.md b/docs/sandbox/overview.md index 79259a6b84..2984c1563a 100644 --- a/docs/sandbox/overview.md +++ b/docs/sandbox/overview.md @@ -116,6 +116,8 @@ After that, pick the piece you need: - [Tools](./tools): bridge your app's own tools into the in-sandbox agent. - [Policy](./policy): allow, ask or deny guardrails on what the agent may run. - [Lifecycle & Snapshots](./lifecycle): reuse a sandbox, snapshot after setup, resume. +- [Portable Snapshots](./portable-snapshots): keep completed files after the + sandbox is gone. Start with [Keep Files After Reload](./portable-snapshots-configure). - [Instance Durability](./durability): reuse it across replicas too. - [Durable Runs](./durable-runs): let a run outlive the tab, and turn it on. - [Events](./events): stream the agent's edits and tool calls to a UI, and choose what diff --git a/docs/sandbox/portable-snapshots-artifacts.md b/docs/sandbox/portable-snapshots-artifacts.md new file mode 100644 index 0000000000..bb39c1c6d0 --- /dev/null +++ b/docs/sandbox/portable-snapshots-artifacts.md @@ -0,0 +1,68 @@ +--- +title: Send a Frozen File +id: portable-snapshots-artifacts +order: 14 +description: "Serve copied artifact bytes from one checkpoint through an authorized server route." +--- + +A user wants to download a generated file from a saved version. The blob store +is not a public URL. `snapshots.readArtifact` reads the copied bytes from one +checkpoint. Your route then returns an HTTP response. + +The method makes sure that the checkpoint belongs to the supplied thread. Your +route must still authorize that thread first. The method returns metadata and +`Uint8Array` bytes. It does not create an HTTP response. + +Keep this route on the server. If the session cannot access the thread, return +404. Then call `snapshots.readArtifact`. + +```ts +import { requireSession } from './auth' +import { snapshots } from './sandbox-server' + +export async function GET(request: Request) { + const session = await requireSession(request) + const url = new URL(request.url) + const threadId = url.searchParams.get('threadId') + const checkpointId = url.searchParams.get('checkpointId') + const artifactId = url.searchParams.get('artifactId') + + if (!threadId || !checkpointId || !artifactId) { + return new Response('Not found', { status: 404 }) + } + if (!(await session.canAccessThread(threadId))) { + return new Response('Not found', { status: 404 }) + } + + const { artifact, bytes } = await snapshots.readArtifact({ + threadId, + checkpointId, + artifactId, + }) + return new Response(bytes.slice(), { + headers: { + 'content-type': artifact.mimeType, + 'content-length': String(artifact.size), + }, + }) +} +``` + +The client uses the authorized route as an artifact URL. It must not read the +blob store or call `snapshots.readArtifact` in the browser. + +```ts +export function snapshotArtifactUrl( + threadId: string, + checkpointId: string, + artifactId: string, +) { + const query = new URLSearchParams({ threadId, checkpointId, artifactId }) + return `/api/snapshots/artifact?${query}` +} +``` + +A named save copies thread artifacts into the checkpoint. Automatic restore +does not change those copied bytes. See +[Save a Named Version](./portable-snapshots-save) and +[What a Snapshot Stores](./portable-snapshots-safety). diff --git a/docs/sandbox/portable-snapshots-configure.md b/docs/sandbox/portable-snapshots-configure.md new file mode 100644 index 0000000000..31c86ae0ff --- /dev/null +++ b/docs/sandbox/portable-snapshots-configure.md @@ -0,0 +1,176 @@ +--- +title: Keep Files After Reload +id: portable-snapshots-configure +order: 11 +description: "Wire portable snapshots so a later run rebuilds completed sandbox files from your persistence." +--- + +You have a sandbox chat. The agent writes files. The provider sandbox then goes +away. The next run starts empty. + +Portable snapshots save those files after each successful terminal run. A later +run restores the latest checkpoint into a new private sandbox. By the end of +this page, `chat()` writes and restores those checkpoints. + +Create one persistence value. Pass that exact value to `withPersistence` and to +the snapshots object. Put `withPersistence` before `withSandbox`. + +This page is enough for automatic save and restore. When you need a named +version, a fork, or a download, add +[Save a Named Version](./portable-snapshots-save), +[Branch From a Version](./portable-snapshots-fork), or +[Send a Frozen File](./portable-snapshots-artifacts). + +## Create new persistence + +Use `memorySandboxSnapshots` for local development. It creates persistence, a +checkpoint store, and the snapshot methods as one object. It does not load +`@tanstack/ai-persistence` at runtime. + +```ts +import { chat } from '@tanstack/ai' +import { grokBuildText } from '@tanstack/ai-grok-build' +import { withPersistence } from '@tanstack/ai-persistence' +import { + defineSandbox, + defineWorkspace, + InMemorySandboxInstanceStore, + memorySandboxSnapshots, + withSandbox, +} from '@tanstack/ai-sandbox' +import { dockerSandbox } from '@tanstack/ai-sandbox-docker' + +const instances = new InMemorySandboxInstanceStore() +const userId = 'user-123' + +const sandbox = defineSandbox({ + id: 'app-builder', + provider: dockerSandbox({ image: 'node:22' }), + workspace: defineWorkspace({ source: { type: 'none' } }), + lifecycle: { reuse: 'thread' }, +}) + +const snapshots = await memorySandboxSnapshots({ + sandbox, + instances, +}) + +const result = chat({ + threadId: 'app-thread', + context: { userId }, + adapter: grokBuildText('grok-build'), + messages: [{ role: 'user', content: 'Create a landing page.' }], + middleware: [ + withPersistence(snapshots.persistence), + withSandbox(sandbox, { + instances, + snapshots, + }), + ], +}) + +void result +``` + +You can bind `sandbox`, `instances`, `tenant`, and `locks` at create time. A +later `snapshots.save` call can override those values. See +[Save a Named Version](./portable-snapshots-save). + +Keep `instances` in the same server module as this middleware. A named save +must use this same instance store. + +Pass the session `userId` in `context` for every run. Pass that same user id as +`tenant.userId` on `snapshots.save`. + +## Reuse existing persistence + +If `withPersistence` already uses a persistence object, pass that same object +to `createSandboxSnapshots`. Do not create a second message store. + +The persistence object must include these stores: + +- `messages` +- `artifacts` (with `listForThread`) +- `blobs` + +You also need a checkpoint store. That store is not a persistence store. + +If you already keep generated files, you already have `artifacts` and `blobs`. +Use those same stores. + +If you only have `messages`, add `artifacts` and `blobs` to that same adapter. +See [Which stores do you need?](../persistence/build-your-own-adapter#which-stores-do-you-need) +and [Build a generation adapter](../persistence/build-your-own-generation-adapter). + +```ts +import { chat } from '@tanstack/ai' +import { grokBuildText } from '@tanstack/ai-grok-build' +import { withPersistence } from '@tanstack/ai-persistence' +import { + createSandboxSnapshots, + InMemorySandboxCheckpointStore, + withSandbox, +} from '@tanstack/ai-sandbox' +import { instances, persistence, sandbox } from './sandbox-server' + +const snapshots = createSandboxSnapshots({ + persistence, + checkpoints: new InMemorySandboxCheckpointStore(), + sandbox, + instances, +}) + +const result = chat({ + threadId: 'app-thread', + adapter: grokBuildText('grok-build'), + messages: [{ role: 'user', content: 'Create a landing page.' }], + middleware: [ + withPersistence(snapshots.persistence), + withSandbox(sandbox, { + instances, + snapshots, + }), + ], +}) + +void result +``` + +## Use durable stores in production + +`memorySandboxSnapshots` is for local development and tests. Production needs +durable message, artifact, and blob stores, plus a durable checkpoint store. + +The React chat example exports `sqliteSandboxSnapshots()` for a Node 22.5+ +server. That function is an example adapter. It is not a package export. + +Use one SQLite transaction for every checkpoint write, head update, and blob +reference count update. Use one transaction for a fork. The fork transaction +must also copy the source conversation. It must reject a destination thread +that already has persisted state. + +## Writer leases + +Each thread has one checkpoint writer lease. + +- A second run for the same thread gets a writer conflict while the lease is + active. +- The middleware renews the lease while the run is active. +- Pause and detach paths release the lease. They do not publish a partial + checkpoint. +- If the writer loses the lease, the middleware does not publish the + checkpoint. A later successful run can create a new checkpoint. + +## What happens on the next run + +A later run restores the latest checkpoint only into a new private sandbox. A +live resumed sandbox keeps its current files. See +[Portable Sandbox Snapshots](./portable-snapshots) for that restore rule. + +Instance durability finds a provider sandbox across server processes. When that +sandbox is gone, portable snapshots rebuild the workspace. See +[Instance Durability](./durability). + +The default policy excludes `.git`, `node_modules`, and `.env*` paths. Read +[What a Snapshot Stores](./portable-snapshots-safety) before you replace that +policy. diff --git a/docs/sandbox/portable-snapshots-fork.md b/docs/sandbox/portable-snapshots-fork.md new file mode 100644 index 0000000000..b8a8d7d90b --- /dev/null +++ b/docs/sandbox/portable-snapshots-fork.md @@ -0,0 +1,111 @@ +--- +title: Branch From a Version +id: portable-snapshots-fork +order: 13 +description: "Copy one selected checkpoint into an empty thread without changing the source thread." +--- + +A user wants to try a new idea from an older workspace. The original thread +must stay unchanged. `snapshots.fork` copies one selected checkpoint into an +empty destination thread. + +The method copies the checkpoint that you pass. It does not copy the latest +checkpoint unless that id is the one you pass. + +Your checkpoint store must implement `forkFromCheckpoint`. That operation must +copy these items in one step: + +- The selected checkpoint. +- The conversation. +- The destination head. +- The blob reference counts. + +The store must reject a destination thread that already has persisted state. + +Keep this route on the server. Make sure that the session can access the source +thread. Make sure that the session can create the destination thread. Then call +`snapshots.fork`. + +```ts +import { requireSession } from './auth' +import { snapshots } from './sandbox-server' + +export async function POST(request: Request) { + const session = await requireSession(request) + const { threadId, checkpointId, destinationThreadId } = await request.json() + + if ( + typeof threadId !== 'string' || + typeof checkpointId !== 'string' || + typeof destinationThreadId !== 'string' + ) { + return new Response('Invalid request', { status: 400 }) + } + if (!(await session.canAccessThread(threadId))) { + return new Response('Not found', { status: 404 }) + } + if (!(await session.canCreateThread(destinationThreadId))) { + return new Response('Not found', { status: 404 }) + } + + const checkpoint = await snapshots.fork({ + threadId, + checkpointId, + destinationThreadId, + }) + + return Response.json({ checkpointId: checkpoint.id }) +} +``` + +The client sends its request to this route. It does not call `snapshots.fork`. + +```ts +export async function forkCheckpoint( + threadId: string, + checkpointId: string, + destinationThreadId: string, +) { + const response = await fetch('/api/snapshots/fork', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + threadId, + checkpointId, + destinationThreadId, + }), + }) + if (!response.ok) throw new Error('Could not fork checkpoint') + return response.json() +} +``` + +Use the same authorization rule for both threads. A client-selected checkpoint +id is not proof of access. + +## See it in the example + +The React chat example has an App Studio page at `/app-studio`. That page +starts from one prompt, shows a live preview, then lets you fork the chat or +compare two directions. + +1. Open `examples/ts-react-chat`. +2. Set `XAI_API_KEY` and start Docker. +3. Run `pnpm dev` and open `/app-studio`. +4. Build an app. Then use **Fork chat** or **Compare two directions**. + +When you select **Compare two directions** and submit, the page calls +`/api/app-studio-fork` with `count: 2`. Each fork gets a new sandbox and the +saved files from the source thread. The agent installs dependencies if +`node_modules` is missing, then starts a preview. Each pane shows its own +preview URL. You keep one branch. The source thread stays unchanged. + +See the App Studio section in `examples/ts-react-chat/README.md`. +The page path is `/app-studio`. + +If you use SQLite, put the fork in one transaction. That transaction must copy +the source conversation and reject a destination thread that is not empty. See +[Keep Files After Reload](./portable-snapshots-configure). + +When the agent must call fork itself, use +[Let the Agent Save and Fork](./portable-snapshots-tools). diff --git a/docs/sandbox/portable-snapshots-safety.md b/docs/sandbox/portable-snapshots-safety.md new file mode 100644 index 0000000000..707457cc0d --- /dev/null +++ b/docs/sandbox/portable-snapshots-safety.md @@ -0,0 +1,68 @@ +--- +title: What a Snapshot Stores +id: portable-snapshots-safety +order: 15 +description: "See which workspace paths a portable snapshot stores, and how to replace the default policy." +--- + +You do not want secrets, git metadata, or install trees in durable storage. The +default snapshot policy excludes those paths. When a capture or restore finds +an unsafe filesystem entry, it stops. + +Portable snapshots store regular files and directories only. When a capture or +restore finds a symlink, an executable file, or a special filesystem entry, it +fails. + +## Default exclusions + +The default policy excludes these path segments at every depth: + +- `.git` +- `node_modules` +- `.env*` + +It also excludes these exact paths: + +- The projection marker, `.tanstack-projected-`, at the + workspace root only. +- `CLAUDE.md` and `GEMINI.md` at the workspace root. +- Direct `.claude/skills/`, `.codex/skills/`, and + `.grok/skills/` paths. + +These exclusions use paths for regular files and copied files too. + +## Replace the default policy + +A custom policy replaces the default exclusions. If you pass only `redact` or +`include`, the capture includes `.env`, `.git`, and `node_modules` unless you +copy the default policy first. + +```ts +import { defaultSandboxSnapshotPolicy } from '@tanstack/ai-sandbox' + +const policy = { + ...defaultSandboxSnapshotPolicy(), + redact({ bytes }: { bytes: Uint8Array }) { + return bytes + }, +} +``` + +When you create the snapshots object, pass `policy`. See +[Keep Files After Reload](./portable-snapshots-configure). + +The exact projection marker for the workspace stays protected. A custom policy +cannot capture or restore that marker. + +## Secrets + +Resolved secret values are replaced with zero bytes before the content is +hashed or stored. + +## Restore safety + +An invalid manifest, a missing blob, or changed blob content stops the restore +before it writes the workspace. The failed private sandbox is then discarded. +Your existing resumed sandbox stays unchanged. + +See [Providers](./providers) for provider-native snapshot and resume support. diff --git a/docs/sandbox/portable-snapshots-save.md b/docs/sandbox/portable-snapshots-save.md new file mode 100644 index 0000000000..01f5f7b60c --- /dev/null +++ b/docs/sandbox/portable-snapshots-save.md @@ -0,0 +1,76 @@ +--- +title: Save a Named Version +id: portable-snapshots-save +order: 12 +description: "Let a user mark one live sandbox workspace as a named checkpoint." +--- + +Automatic saves protect each completed run. A user can also mark one workspace +state, such as a release candidate. `snapshots.save` captures that live +sandbox into a named checkpoint. + +The method needs a live, reusable sandbox for the thread. A lifecycle with +`reuse: 'none'` cannot create a named checkpoint. + +Keep this route on the server. Read the owner from the session. If the owner +cannot access the thread, return 404. Then call `snapshots.save`. + +When you create the snapshots object, bind `sandbox` and `instances`. You can +also pass them on `save`. Use the same instance store that `withSandbox` uses. +See [Keep Files After Reload](./portable-snapshots-configure). + +```ts +import { requireSession } from './auth' +import { snapshots } from './sandbox-server' + +export async function POST(request: Request) { + const session = await requireSession(request) + const { threadId, runId, label } = await request.json() + + if ( + typeof threadId !== 'string' || + typeof runId !== 'string' || + typeof label !== 'string' + ) { + return new Response('Invalid request', { status: 400 }) + } + if (!(await session.canAccessThread(threadId))) { + return new Response('Not found', { status: 404 }) + } + + const checkpoint = await snapshots.save({ + threadId, + runId, + label, + tenant: { userId: session.userId }, + }) + return Response.json({ checkpointId: checkpoint.id, label: checkpoint.label }) +} +``` + +The client sends its request to this route. It does not call `snapshots.save` +or read the persistence stores. + +```ts +export async function saveCheckpoint( + threadId: string, + runId: string, + label: string, +) { + const response = await fetch('/api/snapshots/save', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ threadId, runId, label }), + }) + if (!response.ok) throw new Error('Could not save checkpoint') + return response.json() +} +``` + +A named checkpoint stays available for a read or a selected fork. Automatic +restore still uses the latest checkpoint. See +[Branch From a Version](./portable-snapshots-fork) to copy one selected +checkpoint into a new thread. + +When the agent must call save itself, use +[Let the Agent Save and Fork](./portable-snapshots-tools). diff --git a/docs/sandbox/portable-snapshots-tools.md b/docs/sandbox/portable-snapshots-tools.md new file mode 100644 index 0000000000..b8567f2fb6 --- /dev/null +++ b/docs/sandbox/portable-snapshots-tools.md @@ -0,0 +1,77 @@ +--- +title: Let the Agent Save and Fork +id: portable-snapshots-tools +order: 16 +description: "Give a chat() run host tools that save and fork this thread without letting the model pick thread ids." +--- + +You want the agent to mark a version or open a new direction. The route already +knows `threadId` and `runId`. `createSnapshotTools` turns those values into +host tools that you spread into `chat()`. + +The model can pass a label or a checkpoint id on this thread. It cannot pass a +thread id. Your factory mints every new thread id. + +```ts +import { chat } from '@tanstack/ai' +import { grokBuildText } from '@tanstack/ai-grok-build' +import { withPersistence } from '@tanstack/ai-persistence' +import { createSnapshotTools, withSandbox } from '@tanstack/ai-sandbox' +import { instances, sandbox, snapshots } from './sandbox-server' + +export function POST(threadId: string, runId: string) { + return chat({ + threadId, + runId, + adapter: grokBuildText('grok-build'), + messages: [{ role: 'user', content: 'Save this, then try a dark theme.' }], + tools: [ + ...createSnapshotTools(snapshots, { + threadId, + runId, + createThreadId: () => crypto.randomUUID(), + onForked({ destinationThreadId }) { + void destinationThreadId + }, + }), + ], + middleware: [ + withPersistence(snapshots.persistence), + withSandbox(sandbox, { instances, snapshots }), + ], + }) +} +``` + +The tools run on the server. In a sandbox chat they are bridged back to the +host. See [Tools](./tools). + +## What each tool does + +- `save_sandbox_snapshot`: saves the live sandbox for the bound thread. The + model passes `label` only. +- `fork_sandbox_snapshot`: copies one checkpoint into a new empty thread. The + model can pass `checkpointId`. When it omits that id, the tool copies the + latest checkpoint. `createThreadId()` sets the destination thread id. +- `read_sandbox_snapshot_artifact`: returns artifact metadata for a checkpoint + on this thread. It does not return file bytes. Serve bytes from + [Send a Frozen File](./portable-snapshots-artifacts). + +`onForked` runs after a successful fork. If you want the new branch to work +right away, start `chat()` on `destinationThreadId` in that callback. + +## When save and fork run + +`save_sandbox_snapshot` takes the writer lease for this thread. If this +`chat()` already holds that lease through portable snapshots, the save fails +with `SANDBOX_SNAPSHOT_WRITER_CONFLICT`. Save from a planner thread, or after +this run ends. + +`fork_sandbox_snapshot` takes the writer lease on the **new** thread. A fork +during this run can succeed. It copies a saved checkpoint. It does not copy +files the agent is still writing. + +See [Keep Files After Reload](./portable-snapshots-configure) to create the +`snapshots` object. See [Save a Named Version](./portable-snapshots-save) and +[Branch From a Version](./portable-snapshots-fork) for routes that you call +yourself. diff --git a/docs/sandbox/portable-snapshots.md b/docs/sandbox/portable-snapshots.md new file mode 100644 index 0000000000..6bbdbf3ff2 --- /dev/null +++ b/docs/sandbox/portable-snapshots.md @@ -0,0 +1,71 @@ +--- +title: Portable Sandbox Snapshots +id: portable-snapshots +order: 10 +description: "Keep completed sandbox files after the provider sandbox is gone, then rebuild them in a new sandbox." +--- + +An agent can finish work in a sandbox, then the sandbox can disappear. A reload +or a later run starts with an empty workspace. Portable snapshots store the +finished files in your persistence. A later run restores them into a new +sandbox. + +This work runs on the server. Your client calls routes that you own. + +## Pick your path + +Start with persistence. Then add a product page only when you need that action. + +| You have | You want | Required pages | +| --- | --- | --- | +| No persistence yet | Files come back after a reload | [Keep Files After Reload](./portable-snapshots-configure#create-new-persistence) | +| Chat persistence already | Files come back after a reload | [Keep Files After Reload](./portable-snapshots-configure#reuse-existing-persistence) | +| Snapshots already wired | A user marks one version | [Save a Named Version](./portable-snapshots-save) | +| Snapshots already wired | A user branches from one version | [Branch From a Version](./portable-snapshots-fork) | +| Snapshots already wired | A product page that forks and compares two directions | [Branch From a Version](./portable-snapshots-fork#see-it-in-the-example) | +| Snapshots already wired | A user downloads a generated file | [Send a Frozen File](./portable-snapshots-artifacts) | +| Snapshots already wired | The agent saves or forks this thread | [Let the Agent Save and Fork](./portable-snapshots-tools) | +| Snapshots already wired | Custom include or redact rules | [What a Snapshot Stores](./portable-snapshots-safety) | + +Automatic save and restore needs only the configure page. Save, fork, download, +and policy pages are extra. + +## What a checkpoint holds + +After a successful terminal run, the middleware waits for persistence to save +the conversation. It then writes one checkpoint for the thread. + +A checkpoint holds: + +- Regular workspace files. +- Empty directories. +- Generated artifacts that already belong to the thread. +- The saved conversation for the thread. + +File data and artifact data use separate content-addressed blob namespaces. +Equal file data shares one file blob. Equal artifact data shares one artifact +blob. Unused blobs stay until you delete them. + +## When a later run restores files + +A later run restores the latest checkpoint only into a new private sandbox. The +restore runs after bootstrap and before hooks or the harness see the sandbox. + +A live resumed sandbox keeps its current files. Portable snapshots do not write +into that sandbox. Provider-native snapshots make bootstrap faster. See +[Lifecycle & Snapshots](./lifecycle). + +A named checkpoint stays available for a read or a selected fork. Automatic +restore still uses the latest checkpoint. + +The conversation comes from the durable message store. The sandbox journal is a +run-output log. When you need to replay agent output, see +[The Run Journal](./journal). + +## Who calls the methods + +`createSandboxSnapshots` and `memorySandboxSnapshots` return one object. That +object has `save`, `fork`, and `readArtifact`. Call those methods on the server +after you make sure that the session can access the thread. + +Do not treat a checkpoint id, a thread id, or an artifact id as proof of access. diff --git a/docs/sandbox/providers.md b/docs/sandbox/providers.md index be63e7f340..14f849996b 100644 --- a/docs/sandbox/providers.md +++ b/docs/sandbox/providers.md @@ -12,6 +12,10 @@ it are provider-agnostic. Pick a provider for the isolation, auth, and snapshot/resume behaviour you need; the rest of your sandbox definition stays the same. +Provider-native snapshots and resume keep or recreate provider state. They can +reduce bootstrap time. [Portable Snapshots](./portable-snapshots) store +completed workspace data in your application persistence for reconstruction. + > The provider is _where_ the agent runs. For _which_ agent runs (Grok Build, > Claude Code, Codex, OpenCode, or any ACP agent via `acpCompatible`) see > [Harnesses](./harnesses). diff --git a/examples/ts-react-chat/README.md b/examples/ts-react-chat/README.md index ff61b66566..3cfc96056e 100644 --- a/examples/ts-react-chat/README.md +++ b/examples/ts-react-chat/README.md @@ -454,3 +454,30 @@ cloud runs skip the tools and do a plain triage. In production you wouldn't need ngrok — your orchestrator already has a public URL to advertise. Set keys in `.env.local`, then `pnpm dev` and open `/sandboxes`. + +## App Studio + +A small product page at `/app-studio` on top of portable snapshots. You describe an app. The +agent scaffolds a TanStack Start app in a Docker sandbox, starts the preview +server, and shows the live URL. After the first build: + +- **Fork chat** copies the checkpoint and conversation into a new thread. + Continue from that copy. The source thread stays unchanged. +- **Compare two directions** creates two forks. Each fork gets a different + visual prompt. Pick **Keep variant A** or **Keep variant B** to continue + on that branch. + +Needs: + +- Docker running on the host +- `XAI_API_KEY` in `.env.local` (Grok Build runs inside the sandbox) + +Stay on the page during the first build. The first run installs the CLI, +scaffolds the app, and starts the preview. If you leave, the request aborts. + +The snapshot store is `src/lib/sqlite-persistence.ts` at +`.data/app-studio.db` (gitignored). The server route is `/api/app-studio`. +The fork route is `/api/app-studio-fork`. + +See [Branch From a Version](../../docs/sandbox/portable-snapshots-fork.md) +for the `snapshots.fork` contract this page uses. diff --git a/examples/ts-react-chat/public/brand/fonts/BricolageGrotesque-Bold.ttf b/examples/ts-react-chat/public/brand/fonts/BricolageGrotesque-Bold.ttf new file mode 100644 index 0000000000..ea2465b48a Binary files /dev/null and b/examples/ts-react-chat/public/brand/fonts/BricolageGrotesque-Bold.ttf differ diff --git a/examples/ts-react-chat/public/brand/fonts/Inter-Regular.ttf b/examples/ts-react-chat/public/brand/fonts/Inter-Regular.ttf new file mode 100644 index 0000000000..399a6e0c3f Binary files /dev/null and b/examples/ts-react-chat/public/brand/fonts/Inter-Regular.ttf differ diff --git a/examples/ts-react-chat/public/brand/fonts/Inter-latin.woff2 b/examples/ts-react-chat/public/brand/fonts/Inter-latin.woff2 new file mode 100644 index 0000000000..b0d0e2e5c6 Binary files /dev/null and b/examples/ts-react-chat/public/brand/fonts/Inter-latin.woff2 differ diff --git a/examples/ts-react-chat/public/brand/fonts/OFL-Bricolage-Grotesque.txt b/examples/ts-react-chat/public/brand/fonts/OFL-Bricolage-Grotesque.txt new file mode 100644 index 0000000000..4dfd4fe066 --- /dev/null +++ b/examples/ts-react-chat/public/brand/fonts/OFL-Bricolage-Grotesque.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Bricolage Grotesque Project Authors (https://github.com/ateliertriay/bricolage) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/examples/ts-react-chat/public/brand/logos/tanstack-emblem-cream.svg b/examples/ts-react-chat/public/brand/logos/tanstack-emblem-cream.svg new file mode 100644 index 0000000000..ce610b729e --- /dev/null +++ b/examples/ts-react-chat/public/brand/logos/tanstack-emblem-cream.svg @@ -0,0 +1 @@ + diff --git a/examples/ts-react-chat/public/brand/logos/tanstack-landscape-white.svg b/examples/ts-react-chat/public/brand/logos/tanstack-landscape-white.svg new file mode 100644 index 0000000000..50ad4162f5 --- /dev/null +++ b/examples/ts-react-chat/public/brand/logos/tanstack-landscape-white.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + diff --git a/examples/ts-react-chat/src/components/Header.tsx b/examples/ts-react-chat/src/components/Header.tsx index 9b4d744f98..fa562489d9 100644 --- a/examples/ts-react-chat/src/components/Header.tsx +++ b/examples/ts-react-chat/src/components/Header.tsx @@ -340,6 +340,19 @@ export default function Header() { Persistent Chat + setIsOpen(false)} + className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2" + activeProps={{ + className: + 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2', + }} + > + + App Studio + + setIsOpen(false)} diff --git a/examples/ts-react-chat/src/lib/app-studio-fork.ts b/examples/ts-react-chat/src/lib/app-studio-fork.ts new file mode 100644 index 0000000000..459dda90dd --- /dev/null +++ b/examples/ts-react-chat/src/lib/app-studio-fork.ts @@ -0,0 +1,70 @@ +import { + SandboxCheckpointWriterConflictError, + SandboxSnapshotError, +} from '@tanstack/ai-sandbox' +import type { + SandboxSnapshots, + SaveSandboxSnapshotInput, +} from '@tanstack/ai-sandbox' + +function canUseSavedHead(error: unknown): boolean { + if (error instanceof SandboxSnapshotError) { + return ( + error.code === 'SANDBOX_SNAPSHOT_MISSING_SANDBOX' || + error.code === 'SANDBOX_SNAPSHOT_MISSING_INSTANCES' || + error.code === 'SANDBOX_SNAPSHOT_MISSING_REUSABLE_SANDBOX' || + error.code === 'SANDBOX_SNAPSHOT_REUSE_NONE' + ) + } + return error instanceof SandboxCheckpointWriterConflictError +} + +export async function forkStudioThreads(input: { + snapshots: SandboxSnapshots + threadId: string + runId: string + count: 1 | 2 + label?: string + sandbox?: SaveSandboxSnapshotInput['sandbox'] + instances?: SaveSandboxSnapshotInput['instances'] + locks?: SaveSandboxSnapshotInput['locks'] +}): Promise<{ + sourceCheckpointId: string + forks: Array<{ threadId: string; checkpointId: string }> +}> { + let sourceCheckpointId: string | null = null + try { + const saved = await input.snapshots.save({ + threadId: input.threadId, + runId: input.runId, + label: input.label ?? 'studio-fork', + ...(input.sandbox === undefined ? {} : { sandbox: input.sandbox }), + ...(input.instances === undefined ? {} : { instances: input.instances }), + ...(input.locks === undefined ? {} : { locks: input.locks }), + }) + sourceCheckpointId = saved.id + } catch (error) { + if (!canUseSavedHead(error)) throw error + sourceCheckpointId = await input.snapshots.checkpoints.getHead( + input.threadId, + ) + } + if (sourceCheckpointId === null) { + throw new Error('Build the app first. Then you can fork or compare.') + } + + const forks: Array<{ threadId: string; checkpointId: string }> = [] + for (let index = 0; index < input.count; index++) { + const destinationThreadId = `studio-${crypto.randomUUID()}` + const checkpoint = await input.snapshots.fork({ + threadId: input.threadId, + checkpointId: sourceCheckpointId, + destinationThreadId, + }) + forks.push({ + threadId: destinationThreadId, + checkpointId: checkpoint.id, + }) + } + return { sourceCheckpointId, forks } +} diff --git a/examples/ts-react-chat/src/lib/app-studio-helpers.ts b/examples/ts-react-chat/src/lib/app-studio-helpers.ts new file mode 100644 index 0000000000..ff02f2ad10 --- /dev/null +++ b/examples/ts-react-chat/src/lib/app-studio-helpers.ts @@ -0,0 +1,55 @@ +export function previewUrlFromText(text: string): string | null { + const match = text.match(/https?:\/\/[^\s)"']+/) + return match ? match[0] : null +} + +export function previewUrlFrom(output: unknown): string | null { + let value: unknown = output + if (typeof output === 'string') { + try { + value = JSON.parse(output) + } catch { + return /^https?:\/\//.test(output) ? output : null + } + } + if (value !== null && typeof value === 'object' && 'url' in value) { + const url = value.url + return typeof url === 'string' ? url : null + } + return null +} + +export const DEFAULT_COMPARE_PROMPT = + 'Keep the same product. Change only the visual direction.' + +export function comparePrompt(userText: string): string { + const trimmed = userText.trim() + return trimmed.length > 0 ? trimmed : DEFAULT_COMPARE_PROMPT +} + +export function variantPrompt(userText: string, variant: 'A' | 'B'): string { + const prompt = comparePrompt(userText) + if (variant === 'A') { + return `${prompt}\n\nThis is variant A. Keep the same product. Use a bold, high-contrast, compact visual direction.` + } + return `${prompt}\n\nThis is variant B. Keep the same product. Use a soft, spacious, calm visual direction.` +} + +export function threadIdsFromForkBody(body: unknown): Array { + if (body === null || typeof body !== 'object') return [] + const forks = Reflect.get(body, 'forks') + if (!Array.isArray(forks)) return [] + return forks.flatMap((fork) => { + if (fork === null || typeof fork !== 'object') return [] + const id = Reflect.get(fork, 'threadId') + return typeof id === 'string' && id.length > 0 ? [id] : [] + }) +} + +export function errorMessageFromBody(body: unknown, fallback: string): string { + if (body !== null && typeof body === 'object' && 'error' in body) { + const error = body.error + if (typeof error === 'string' && error.length > 0) return error + } + return fallback +} diff --git a/examples/ts-react-chat/src/lib/app-studio-store.ts b/examples/ts-react-chat/src/lib/app-studio-store.ts new file mode 100644 index 0000000000..c238b6939c --- /dev/null +++ b/examples/ts-react-chat/src/lib/app-studio-store.ts @@ -0,0 +1,22 @@ +import { InMemoryLockStore } from '@tanstack/ai/locks' +import { InMemorySandboxInstanceStore } from '@tanstack/ai-sandbox' +import { sqliteSandboxSnapshots } from './sqlite-persistence' + +let snapshots: ReturnType | undefined +const instances = new InMemorySandboxInstanceStore() +const locks = new InMemoryLockStore() + +export function appStudioSnapshots() { + return (snapshots ??= sqliteSandboxSnapshots({ + url: './.data/app-studio.db', + migrate: true, + })) +} + +export function appStudioInstances() { + return instances +} + +export function appStudioLocks() { + return locks +} diff --git a/examples/ts-react-chat/src/lib/app-studio.test.ts b/examples/ts-react-chat/src/lib/app-studio.test.ts new file mode 100644 index 0000000000..e889d68b9a --- /dev/null +++ b/examples/ts-react-chat/src/lib/app-studio.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from 'vitest' +import { memorySandboxSnapshots } from '@tanstack/ai-sandbox' +import { forkStudioThreads } from './app-studio-fork' +import { + comparePrompt, + errorMessageFromBody, + previewUrlFrom, + previewUrlFromText, + threadIdsFromForkBody, + variantPrompt, +} from './app-studio-helpers' + +async function seedHead( + snapshots: Awaited>, + threadId: string, + conversation: Array<{ role: 'user'; content: string }> = [], +) { + const writer = await snapshots.checkpoints.acquireWriter(threadId) + await snapshots.checkpoints.append({ + checkpoint: { + id: `${threadId}-head`, + threadId, + parentCheckpointId: null, + createdAt: 1, + reason: 'automatic', + files: [], + conversation, + artifacts: [], + }, + expectedHeadId: null, + writer, + }) + await writer.release() +} + +describe('app studio helpers', () => { + it('reads a preview URL from an exposePreview result', () => { + expect(previewUrlFrom({ url: 'http://127.0.0.1:5173' })).toBe( + 'http://127.0.0.1:5173', + ) + expect(previewUrlFrom('{"url":"http://127.0.0.1:5173"}')).toBe( + 'http://127.0.0.1:5173', + ) + expect(previewUrlFrom('http://127.0.0.1:5173')).toBe( + 'http://127.0.0.1:5173', + ) + expect(previewUrlFrom('not-a-url')).toBeNull() + expect( + previewUrlFromText( + 'exposePreview returned: http://localhost:62133 and that is the app.', + ), + ).toBe('http://localhost:62133') + }) + + it('builds distinct compare prompts', () => { + expect(variantPrompt('Make it warmer', 'A')).toContain('variant A') + expect(variantPrompt('Make it warmer', 'B')).toContain('variant B') + expect(comparePrompt(' ')).toBe( + 'Keep the same product. Change only the visual direction.', + ) + expect(comparePrompt('Make it warmer')).toBe('Make it warmer') + }) + + it('reads fork thread ids and error text from JSON bodies', () => { + expect( + threadIdsFromForkBody({ + forks: [{ threadId: 'a' }, { threadId: 'b' }], + }), + ).toEqual(['a', 'b']) + expect(threadIdsFromForkBody({ forks: [{}] })).toEqual([]) + expect(errorMessageFromBody({ error: 'no checkpoint' }, 'fallback')).toBe( + 'no checkpoint', + ) + expect(errorMessageFromBody({}, 'fallback')).toBe('fallback') + }) +}) + +describe('forkStudioThreads', () => { + it('throws when the thread has no checkpoint', async () => { + const snapshots = await memorySandboxSnapshots() + await expect( + forkStudioThreads({ + snapshots, + threadId: 'empty', + runId: 'run-1', + count: 1, + }), + ).rejects.toThrow('Build the app first') + }) + + it('forks one destination from the saved head', async () => { + const snapshots = await memorySandboxSnapshots() + await seedHead(snapshots, 'source') + const result = await forkStudioThreads({ + snapshots, + threadId: 'source', + runId: 'run-1', + count: 1, + }) + expect(result.sourceCheckpointId).toBe('source-head') + expect(result.forks).toHaveLength(1) + const destination = result.forks[0] + expect(destination?.threadId.startsWith('studio-')).toBe(true) + expect( + await snapshots.checkpoints.getHead(destination?.threadId ?? ''), + ).toBe(destination?.checkpointId) + }) + + it('forks two destinations for a compare', async () => { + const snapshots = await memorySandboxSnapshots() + await seedHead(snapshots, 'source') + const result = await forkStudioThreads({ + snapshots, + threadId: 'source', + runId: 'run-2', + count: 2, + }) + expect(result.forks).toHaveLength(2) + expect(result.forks[0]?.threadId).not.toBe(result.forks[1]?.threadId) + }) + + it('copies the source conversation onto each fork', async () => { + const snapshots = await memorySandboxSnapshots() + await seedHead(snapshots, 'source', [{ role: 'user', content: 'build it' }]) + const result = await forkStudioThreads({ + snapshots, + threadId: 'source', + runId: 'run-3', + count: 1, + }) + const destination = result.forks[0] + const checkpoint = await snapshots.checkpoints.get( + destination?.checkpointId ?? '', + ) + expect(checkpoint?.conversation).toEqual([ + { role: 'user', content: 'build it' }, + ]) + }) + + it('does not hide a save error that is not a missing-sandbox miss', async () => { + const snapshots = await memorySandboxSnapshots() + await seedHead(snapshots, 'source') + const failing = { + ...snapshots, + save: async () => { + throw new Error('disk full') + }, + } + await expect( + forkStudioThreads({ + snapshots: failing, + threadId: 'source', + runId: 'run-4', + count: 1, + }), + ).rejects.toThrow('disk full') + }) +}) diff --git a/examples/ts-react-chat/src/lib/app-studio.ts b/examples/ts-react-chat/src/lib/app-studio.ts new file mode 100644 index 0000000000..6f010acbe7 --- /dev/null +++ b/examples/ts-react-chat/src/lib/app-studio.ts @@ -0,0 +1,100 @@ +import { toolDefinition } from '@tanstack/ai' +import { + DEFAULT_GROK_ACP_PORT, + GROK_CLI_INSTALL_COMMAND, + grokBuildText, +} from '@tanstack/ai-grok-build' +import { + createSecrets, + defineSandbox, + defineWorkspace, +} from '@tanstack/ai-sandbox' +import { dockerSandbox } from '@tanstack/ai-sandbox-docker' +import { z } from 'zod' +import type { AnyTextAdapter } from '@tanstack/ai' +import type { + SandboxDefinition, + SandboxEnsureContext, +} from '@tanstack/ai-sandbox' + +export const PREVIEW_PORT = 5173 + +const SCAFFOLD = + 'Scaffold with the TanStack CLI via npx. Run it exactly like this: `npx --yes @tanstack/cli create my-app --framework react --no-examples --intent -y`. Do not guess other package names.' + +const APP = + 'Turn it into a self-contained interactive app. No external APIs, no env vars, no keys. Keep state in the browser (localStorage). Make it look polished.' + +const RUN = `Add \`server: { host: true, allowedHosts: true }\` to vite.config.ts. Start the dev server on port ${PREVIEW_PORT}: \`pnpm dev --host 0.0.0.0 --port ${PREVIEW_PORT}\`. When it is listening, call exposePreview with { "port": ${PREVIEW_PORT} } and share the URL.` + +export function missingAppStudioEnv(): Array { + return process.env.XAI_API_KEY ? [] : ['XAI_API_KEY'] +} + +export function buildAppStudioAdapter(): AnyTextAdapter { + return grokBuildText('grok-build') +} + +export function buildAppStudioSandbox(): SandboxDefinition { + const key = process.env.XAI_API_KEY + return defineSandbox({ + id: 'app-studio', + provider: dockerSandbox({ + image: process.env.SANDBOX_IMAGE ?? 'node:22', + publishPorts: [PREVIEW_PORT, DEFAULT_GROK_ACP_PORT], + }), + workspace: defineWorkspace({ + source: { type: 'none' }, + setup: ({ serial }) => serial(GROK_CLI_INSTALL_COMMAND), + secrets: createSecrets(key ? { XAI_API_KEY: key } : {}), + }), + lifecycle: { reuse: 'thread' }, + }) +} + +export const tanstackStartRecipe = toolDefinition({ + name: 'tanstackStartRecipe', + description: + 'The recipe for a self-contained TanStack Start app in this sandbox. Call this before you scaffold.', + inputSchema: z.object({ + section: z + .enum(['scaffold', 'app', 'run', 'all']) + .describe('Which part of the recipe you need. Use all first.'), + }), +}).server(({ section }) => { + const recipe = { scaffold: SCAFFOLD, app: APP, run: RUN } + return section === 'all' ? recipe : { [section]: recipe[section] } +}) + +export function makeExposePreviewTool( + definition: SandboxDefinition, + threadId: string, + bookkeeping?: Pick, +) { + return toolDefinition({ + name: 'exposePreview', + description: `Expose the sandbox port the dev server is listening on and return a preview URL. Call this after the server is up on port ${PREVIEW_PORT}.`, + inputSchema: z.object({ + port: z.number().int().min(1024).max(65535), + }), + }).server(async ({ port }) => { + const handle = await definition.ensure({ + threadId, + runId: 'expose-preview', + ...bookkeeping, + }) + const channel = await handle.ports.connect(port) + return { url: channel.url } + }) +} + +export const APP_STUDIO_SYSTEM_PROMPT = [ + 'You work in this sandbox.', + 'If the workspace is empty, call tanstackStartRecipe with section all, then scaffold, build the app, start the preview, and call exposePreview.', + 'If the workspace already has an app, do not scaffold again.', + 'Install dependencies if node_modules is missing.', + 'Apply the requested change.', + `Restart the preview on port ${PREVIEW_PORT}.`, + 'Then call exposePreview.', + `The preview port must be ${PREVIEW_PORT}.`, +].join(' ') diff --git a/examples/ts-react-chat/src/lib/sqlite-persistence.test.ts b/examples/ts-react-chat/src/lib/sqlite-persistence.test.ts index aaebaa2339..f4e30a188d 100644 --- a/examples/ts-react-chat/src/lib/sqlite-persistence.test.ts +++ b/examples/ts-react-chat/src/lib/sqlite-persistence.test.ts @@ -10,7 +10,11 @@ import { join } from 'node:path' import { DatabaseSync } from 'node:sqlite' import { describe, expect, it } from 'vitest' import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' -import { sqlitePersistence } from './sqlite-persistence' +import { + runSandboxCheckpointForkConformance, + runSandboxCheckpointStoreConformance, +} from '@tanstack/ai-sandbox/testkit' +import { sqlitePersistence, sqliteSandboxSnapshots } from './sqlite-persistence' // All seven stores are provided — the four chat state stores plus // `generationRuns` + `artifacts` + `blobs` — so no STORE is skipped. One @@ -29,6 +33,355 @@ runPersistenceConformance( { skipMethods: ['runs.listByThread'] }, ) +runSandboxCheckpointStoreConformance( + 'ts-react-chat example (node:sqlite)', + (options) => + sqliteSandboxSnapshots({ + url: ':memory:', + migrate: true, + ...options, + }).checkpoints, +) + +runSandboxCheckpointForkConformance( + 'ts-react-chat example (node:sqlite)', + () => { + const snapshots = sqliteSandboxSnapshots({ + url: ':memory:', + migrate: true, + }) + return snapshots + }, +) + +describe('sqliteSandboxSnapshots fork transaction', () => { + it('uses one durable writer lease across two SQLite connections', async () => { + const dir = mkdtempSync(join(tmpdir(), 'tanstack-sqlite-lease-')) + const file = join(dir, 'snapshots.db') + const first = sqliteSandboxSnapshots({ url: file, migrate: true }) + const second = sqliteSandboxSnapshots({ url: file, migrate: true }) + try { + const writer = await first.checkpoints.acquireWriter('thread') + await expect( + second.checkpoints.acquireWriter('thread'), + ).rejects.toMatchObject({ code: 'SANDBOX_SNAPSHOT_WRITER_CONFLICT' }) + await writer.release() + const replacement = await second.checkpoints.acquireWriter('thread') + await expect( + first.checkpoints.append({ + checkpoint: { + id: 'stale', + threadId: 'thread', + parentCheckpointId: null, + createdAt: 1, + reason: 'named', + files: [], + conversation: [], + artifacts: [], + }, + expectedHeadId: null, + writer, + }), + ).rejects.toMatchObject({ code: 'SANDBOX_SNAPSHOT_WRITER_LOST' }) + await replacement.release() + } finally { + first.close() + second.close() + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('deletes dependent rows and permits the checkpoint id to be reused', async () => { + const snapshots = sqliteSandboxSnapshots({ url: ':memory:', migrate: true }) + const writer = await snapshots.checkpoints.acquireWriter('thread') + const checkpoint = { + id: 'reusable', + threadId: 'thread', + parentCheckpointId: null, + createdAt: 1, + reason: 'named' as const, + files: [ + { + path: 'a.txt', + kind: 'file' as const, + blobKey: `sandbox-files/sha256/${'a'.repeat(64)}`, + size: 1, + }, + ], + conversation: [{ role: 'user' as const, content: 'one' }], + artifacts: [], + } + try { + await snapshots.checkpoints.append({ + checkpoint, + expectedHeadId: null, + writer, + }) + await snapshots.checkpoints.deleteHead({ + threadId: 'thread', + checkpointId: 'reusable', + writer, + }) + await expect( + snapshots.checkpoints.append({ + checkpoint, + expectedHeadId: null, + writer, + }), + ).resolves.toEqual({ headId: 'reusable' }) + } finally { + snapshots.close() + } + }) + + it('rejects a fork whose source blob reference is missing', async () => { + const dir = mkdtempSync(join(tmpdir(), 'tanstack-sqlite-ref-')) + const file = join(dir, 'snapshots.db') + const snapshots = sqliteSandboxSnapshots({ url: file, migrate: true }) + try { + const key = `sandbox-files/sha256/${'b'.repeat(64)}` + const sourceWriter = await snapshots.checkpoints.acquireWriter('source') + await snapshots.checkpoints.append({ + checkpoint: { + id: 'source', + threadId: 'source', + parentCheckpointId: null, + createdAt: 1, + reason: 'named', + files: [{ path: 'a.txt', kind: 'file', blobKey: key, size: 1 }], + conversation: [], + artifacts: [], + }, + expectedHeadId: null, + writer: sourceWriter, + }) + const inspector = new DatabaseSync(file) + inspector + .prepare( + 'DELETE FROM sandbox_checkpoint_blob_references WHERE blob_key = ?', + ) + .run(key) + inspector.close() + const writer = await snapshots.checkpoints.acquireWriter('destination') + await expect( + snapshots.checkpoints.forkFromCheckpoint({ + sourceThreadId: 'source', + sourceCheckpointId: 'source', + destinationThreadId: 'destination', + destinationCheckpointId: 'destination', + createdAt: 2, + writer, + }), + ).rejects.toMatchObject({ code: 'SANDBOX_SNAPSHOT_INVALID_ENTRY' }) + expect( + await snapshots.persistence.stores.messages.loadThread('destination'), + ).toEqual([]) + expect(await snapshots.checkpoints.getHead('destination')).toBeNull() + } finally { + snapshots.close() + rmSync(dir, { recursive: true, force: true }) + } + }) + + it.each([ + { + path: 'file/child', + kind: 'file', + blobKey: `sandbox-files/sha256/${'c'.repeat(64)}`, + size: 1, + }, + { + path: 'C:/absolute.txt', + kind: 'file', + blobKey: `sandbox-files/sha256/${'c'.repeat(64)}`, + size: 1, + }, + { + path: 'bad', + kind: 'other', + blobKey: `sandbox-files/sha256/${'c'.repeat(64)}`, + size: 1, + }, + { path: 'directory', kind: 'dir', blobKey: 'forbidden' }, + ])('rejects invalid checkpoint entry $path', async (invalid) => { + const snapshots = sqliteSandboxSnapshots({ url: ':memory:', migrate: true }) + try { + const writer = await snapshots.checkpoints.acquireWriter('thread') + const files = + invalid.path === 'file/child' + ? [ + { + path: 'file', + kind: 'file' as const, + blobKey: `sandbox-files/sha256/${'d'.repeat(64)}`, + size: 1, + }, + invalid, + ] + : [invalid] + const checkpoint = { + id: `invalid-${invalid.path}`, + threadId: 'thread', + parentCheckpointId: null, + createdAt: 1, + reason: 'named' as const, + files: [], + conversation: [], + artifacts: [], + } + Reflect.set(checkpoint, 'files', files) + await expect( + snapshots.checkpoints.append({ + checkpoint, + expectedHeadId: null, + writer, + }), + ).rejects.toMatchObject({ code: 'SANDBOX_SNAPSHOT_INVALID_ENTRY' }) + } finally { + snapshots.close() + } + }) + + it.each<['files' | 'artifacts', unknown]>([ + ['files', null], + ['files', 'not-an-array'], + ['files', [null]], + ['files', [1]], + ['artifacts', null], + ['artifacts', 'not-an-array'], + ['artifacts', [null]], + ['artifacts', [1]], + ])('rejects malformed checkpoint %s values', async (field, value) => { + const snapshots = sqliteSandboxSnapshots({ url: ':memory:', migrate: true }) + try { + const writer = await snapshots.checkpoints.acquireWriter('thread') + const checkpoint = { + id: `malformed-${field}-${String(value)}`, + threadId: 'thread', + parentCheckpointId: null, + createdAt: 1, + reason: 'named' as const, + files: [], + conversation: [], + artifacts: [], + } + Reflect.set(checkpoint, field, value) + await expect( + snapshots.checkpoints.append({ + checkpoint, + expectedHeadId: null, + writer, + }), + ).rejects.toMatchObject({ code: 'SANDBOX_SNAPSHOT_INVALID_ENTRY' }) + } finally { + snapshots.close() + } + }) + + it('rejects empty and non-string identifiers without changing state', async () => { + const snapshots = sqliteSandboxSnapshots({ url: ':memory:', migrate: true }) + try { + const writer = await snapshots.checkpoints.acquireWriter('thread') + const before = { + head: await snapshots.checkpoints.getHead('thread'), + list: await snapshots.checkpoints.list('thread'), + references: await snapshots.checkpoints.listBlobReferences(), + } + await expect( + Reflect.apply( + snapshots.checkpoints.acquireWriter, + snapshots.checkpoints, + [''], + ), + ).rejects.toMatchObject({ code: 'SANDBOX_SNAPSHOT_INVALID_ID' }) + await expect( + Reflect.apply(snapshots.checkpoints.list, snapshots.checkpoints, [{}]), + ).rejects.toMatchObject({ code: 'SANDBOX_SNAPSHOT_INVALID_ID' }) + await expect( + Reflect.apply(snapshots.checkpoints.append, snapshots.checkpoints, [ + { + checkpoint: { + id: 'invalid', + threadId: 7, + parentCheckpointId: null, + createdAt: 1, + reason: 'named', + files: [], + conversation: [], + artifacts: [], + }, + expectedHeadId: null, + writer, + }, + ]), + ).rejects.toMatchObject({ code: 'SANDBOX_SNAPSHOT_INVALID_ID' }) + expect(await snapshots.checkpoints.getHead('thread')).toBe(before.head) + expect(await snapshots.checkpoints.list('thread')).toEqual(before.list) + expect(await snapshots.checkpoints.listBlobReferences()).toEqual( + before.references, + ) + await writer.release() + } finally { + snapshots.close() + } + }) + + it('rolls back the destination when checkpoint storage fails after transcript staging', async () => { + const dir = mkdtempSync(join(tmpdir(), 'tanstack-sqlite-fork-')) + const file = join(dir, 'snapshots.db') + const snapshots = sqliteSandboxSnapshots({ url: file, migrate: true }) + try { + const sourceWriter = await snapshots.checkpoints.acquireWriter('source') + const blobKey = `sandbox-files/sha256/${'a'.repeat(64)}` + await snapshots.checkpoints.append({ + checkpoint: { + id: 'source-root', + threadId: 'source', + parentCheckpointId: null, + createdAt: 1, + reason: 'named', + files: [{ path: 'a.txt', kind: 'file', blobKey, size: 1 }], + conversation: [{ role: 'user', content: 'source' }], + artifacts: [], + }, + expectedHeadId: null, + writer: sourceWriter, + }) + const triggerDb = new DatabaseSync(file) + triggerDb.exec(` + CREATE TRIGGER fail_fork_checkpoint + BEFORE INSERT ON sandbox_checkpoints + WHEN NEW.checkpoint_id = 'fork-root' + BEGIN SELECT RAISE(ABORT, 'forced checkpoint failure'); END; + `) + triggerDb.close() + const destinationWriter = + await snapshots.checkpoints.acquireWriter('destination') + await expect( + snapshots.checkpoints.forkFromCheckpoint({ + sourceThreadId: 'source', + sourceCheckpointId: 'source-root', + destinationThreadId: 'destination', + destinationCheckpointId: 'fork-root', + createdAt: 2, + writer: destinationWriter, + }), + ).rejects.toThrow('forced checkpoint failure') + expect( + await snapshots.persistence.stores.messages.loadThread('destination'), + ).toEqual([]) + expect(await snapshots.checkpoints.list('destination')).toEqual([]) + expect(await snapshots.checkpoints.getHead('destination')).toBeNull() + expect(await snapshots.checkpoints.listBlobReferences()).toEqual([ + { key: blobKey, references: 1 }, + ]) + } finally { + snapshots.close() + rmSync(dir, { recursive: true, force: true }) + } + }) +}) + // SQL-specific case the in-memory reference backend cannot express: a real // query layer can get `NULL <= ?` wrong in ways JS's `undefined <= n` // (`NaN <= n`, always false) never surfaces. This pins the SQLite backend's @@ -141,3 +494,56 @@ describe('sqlitePersistence migrate — an existing pre-durability database', () } }) }) + +describe('sqliteSandboxSnapshots migrate — an existing artifacts table', () => { + it('adds the artifact thread-order index and uses it for listForThread', () => { + const dir = mkdtempSync(join(tmpdir(), 'tanstack-sqlite-artifacts-')) + const file = join(dir, 'old.db') + try { + const old = new DatabaseSync(file) + old.exec(` + CREATE TABLE artifacts ( + artifact_id text PRIMARY KEY NOT NULL, + run_id text NOT NULL, + thread_id text NOT NULL, + blob_key text, + name text NOT NULL, + mime_type text NOT NULL, + size integer NOT NULL, + source_url text, + created_at integer NOT NULL + ); + CREATE INDEX artifacts_run_order + ON artifacts (run_id, created_at ASC, artifact_id ASC); + `) + old.close() + + const snapshots = sqliteSandboxSnapshots({ url: file, migrate: true }) + snapshots.close() + + const inspector = new DatabaseSync(file) + try { + const indexes = inspector + .prepare("SELECT name FROM pragma_index_list('artifacts')") + .all() + expect(indexes).toContainEqual( + expect.objectContaining({ name: 'artifacts_thread_order' }), + ) + + const plan = inspector + .prepare( + 'EXPLAIN QUERY PLAN SELECT * FROM artifacts WHERE thread_id = ? ORDER BY created_at ASC, artifact_id ASC', + ) + .all('thread') + expect(JSON.stringify(plan)).toContain( + 'SEARCH artifacts USING INDEX artifacts_thread_order (thread_id=?)', + ) + expect(JSON.stringify(plan)).not.toContain('USE TEMP B-TREE') + } finally { + inspector.close() + } + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/examples/ts-react-chat/src/lib/sqlite-persistence.ts b/examples/ts-react-chat/src/lib/sqlite-persistence.ts index 7c9ef152f8..fc8958f435 100644 --- a/examples/ts-react-chat/src/lib/sqlite-persistence.ts +++ b/examples/ts-react-chat/src/lib/sqlite-persistence.ts @@ -59,6 +59,27 @@ import type { RunStatus, RunStore, } from '@tanstack/ai-persistence' +import { + createSandboxSnapshots, + SandboxCheckpointConflictError, + SandboxCheckpointDuplicateIdError, + SandboxCheckpointError, + SandboxCheckpointInvalidEntryError, + SandboxCheckpointInvalidIdError, + SandboxCheckpointNotHeadError, + SandboxCheckpointParentMismatchError, + SandboxCheckpointWriterConflictError, + SandboxCheckpointWriterLostError, +} from '@tanstack/ai-sandbox' +import type { + ForkCapableSandboxCheckpointStore, + SandboxCheckpoint, + SandboxCheckpointStoreOptions, + SandboxCheckpointWriter, + SandboxSnapshotArtifact, + SandboxSnapshotEntry, + SandboxSnapshots, +} from '@tanstack/ai-sandbox' // --------------------------------------------------------------------------- // Schema @@ -133,7 +154,10 @@ CREATE TABLE IF NOT EXISTS artifacts ( source_url text, created_at integer NOT NULL ); -CREATE INDEX IF NOT EXISTS artifacts_run ON artifacts (run_id); +CREATE INDEX IF NOT EXISTS artifacts_run_order + ON artifacts (run_id, created_at ASC, artifact_id ASC); +CREATE INDEX IF NOT EXISTS artifacts_thread_order + ON artifacts (thread_id, created_at ASC, artifact_id ASC); -- The bytes themselves. \`body\` is a BLOB column, so this file IS the object -- store; a production adapter would keep metadata here and put bytes in S3/R2. CREATE TABLE IF NOT EXISTS blobs ( @@ -146,6 +170,60 @@ CREATE TABLE IF NOT EXISTS blobs ( created_at integer NOT NULL, updated_at integer NOT NULL ); +CREATE TABLE IF NOT EXISTS sandbox_checkpoints ( + checkpoint_id text PRIMARY KEY NOT NULL, + thread_id text NOT NULL, + parent_checkpoint_id text, + created_at integer NOT NULL, + reason text NOT NULL, + label text, + source_run_id text, + conversation_json text NOT NULL +); +CREATE INDEX IF NOT EXISTS sandbox_checkpoints_thread_order + ON sandbox_checkpoints (thread_id, created_at ASC, checkpoint_id COLLATE BINARY ASC); +CREATE TABLE IF NOT EXISTS sandbox_checkpoint_entries ( + checkpoint_id text NOT NULL, + entry_index integer NOT NULL, + path text NOT NULL, + kind text NOT NULL, + blob_key text, + size integer, + PRIMARY KEY (checkpoint_id, entry_index) +); +CREATE INDEX IF NOT EXISTS sandbox_checkpoint_entries_checkpoint + ON sandbox_checkpoint_entries (checkpoint_id, entry_index ASC); +CREATE TABLE IF NOT EXISTS sandbox_checkpoint_artifacts ( + checkpoint_id text NOT NULL, + artifact_index integer NOT NULL, + artifact_id text NOT NULL, + name text NOT NULL, + mime_type text NOT NULL, + size integer NOT NULL, + blob_key text NOT NULL, + created_at integer NOT NULL, + PRIMARY KEY (checkpoint_id, artifact_index) +); +CREATE INDEX IF NOT EXISTS sandbox_checkpoint_artifacts_checkpoint + ON sandbox_checkpoint_artifacts (checkpoint_id, artifact_index ASC); +CREATE TABLE IF NOT EXISTS sandbox_checkpoint_heads ( + thread_id text PRIMARY KEY NOT NULL, + checkpoint_id text NOT NULL +); +CREATE TABLE IF NOT EXISTS sandbox_checkpoint_writers ( + thread_id text PRIMARY KEY NOT NULL, + owner_token text NOT NULL, + fence integer NOT NULL, + expires_at integer NOT NULL +); +CREATE TABLE IF NOT EXISTS sandbox_checkpoint_fences ( + thread_id text PRIMARY KEY NOT NULL, + fence integer NOT NULL +); +CREATE TABLE IF NOT EXISTS sandbox_checkpoint_blob_references ( + blob_key text PRIMARY KEY NOT NULL, + reference_count integer NOT NULL +); ` /** @@ -797,7 +875,10 @@ function createArtifactStore(db: DatabaseSync) { ) const selectStmt = db.prepare('SELECT * FROM artifacts WHERE artifact_id = ?') const byRunStmt = db.prepare( - 'SELECT * FROM artifacts WHERE run_id = ? ORDER BY created_at ASC', + 'SELECT * FROM artifacts WHERE run_id = ? ORDER BY created_at ASC, artifact_id ASC', + ) + const byThreadStmt = db.prepare( + 'SELECT * FROM artifacts WHERE thread_id = ? ORDER BY created_at ASC, artifact_id ASC', ) const deleteStmt = db.prepare('DELETE FROM artifacts WHERE artifact_id = ?') const deleteForRunStmt = db.prepare('DELETE FROM artifacts WHERE run_id = ?') @@ -826,6 +907,10 @@ function createArtifactStore(db: DatabaseSync) { const rows: Array = byRunStmt.all(runId) return Promise.resolve((rows as Array).map(mapArtifact)) }, + listForThread(threadId) { + const rows: Array = byThreadStmt.all(threadId) + return Promise.resolve((rows as Array).map(mapArtifact)) + }, delete(artifactId) { deleteStmt.run(artifactId) return Promise.resolve() @@ -1061,6 +1146,626 @@ export interface SqlitePersistenceOptions { migrate?: boolean } +function checkpointError( + code: ConstructorParameters[0], + message: string, +): never { + throw new SandboxCheckpointError(code, message) +} + +function cloneCheckpoint(value: T): T { + return structuredClone(value) +} + +function checkpointKeys(checkpoint: SandboxCheckpoint): Array { + return [ + ...new Set([ + ...checkpoint.files.flatMap((entry) => + entry.kind === 'file' ? [entry.blobKey] : [], + ), + ...checkpoint.artifacts.map((artifact) => artifact.blobKey), + ]), + ] +} + +function hasUnpairedSurrogate(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index) + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1) + if (Number.isNaN(next) || next < 0xdc00 || next > 0xdfff) return true + index++ + } else if (code >= 0xdc00 && code <= 0xdfff) return true + } + return false +} + +function assertCheckpointId( + value: unknown, + label: string, +): asserts value is string { + if ( + typeof value !== 'string' || + value.length === 0 || + value.includes('\0') || + hasUnpairedSurrogate(value) + ) { + throw new SandboxCheckpointInvalidIdError( + `${label} must be a non-empty well-formed Unicode string`, + ) + } +} + +function rollbackIfActive(db: DatabaseSync): void { + try { + db.exec('ROLLBACK') + } catch { + // SQLite can already abort the transaction. Keep the original error. + } +} + +function assertCheckpoint(checkpoint: SandboxCheckpoint): void { + assertCheckpointId(checkpoint.id, 'Checkpoint id') + assertCheckpointId(checkpoint.threadId, 'Checkpoint thread id') + if (checkpoint.parentCheckpointId !== null) + assertCheckpointId(checkpoint.parentCheckpointId, 'Parent checkpoint id') + if (!Number.isFinite(checkpoint.createdAt)) + throw new SandboxCheckpointInvalidEntryError( + 'Checkpoint createdAt must be a finite number', + ) + if (!Array.isArray(checkpoint.files)) + throw new SandboxCheckpointInvalidEntryError( + 'Checkpoint files must be an array', + ) + if (!Array.isArray(checkpoint.artifacts)) + throw new SandboxCheckpointInvalidEntryError( + 'Checkpoint artifacts must be an array', + ) + const files: ReadonlyArray = checkpoint.files + const artifacts: ReadonlyArray = checkpoint.artifacts + const paths = new Set() + const kinds = new Map() + for (const entry of files) { + if (entry === null || typeof entry !== 'object') + throw new SandboxCheckpointInvalidEntryError( + 'Checkpoint entry must be an object', + ) + if ( + !entry.path || + paths.has(entry.path) || + entry.path.includes('\0') || + entry.path.startsWith('/') || + entry.path.startsWith('\\') || + /^[A-Za-z]:([\\/]|$)/.test(entry.path) || + entry.path.includes('\\') || + entry.path + .split('/') + .some((part) => !part || part === '.' || part === '..') + ) + throw new SandboxCheckpointInvalidEntryError( + 'Checkpoint entry path must be a normalized workspace-relative path', + ) + for ( + let separator = entry.path.indexOf('/'); + separator !== -1; + separator = entry.path.indexOf('/', separator + 1) + ) { + if (kinds.get(entry.path.slice(0, separator)) === 'file') + throw new SandboxCheckpointInvalidEntryError( + 'Checkpoint entry cannot be beneath a file', + ) + } + if ( + entry.kind === 'file' && + [...kinds.keys()].some((path) => path.startsWith(`${entry.path}/`)) + ) + throw new SandboxCheckpointInvalidEntryError( + 'Checkpoint file cannot be an ancestor of another entry', + ) + paths.add(entry.path) + if (entry.kind === 'file') { + if ( + !/^sandbox-files\/sha256\/[0-9a-f]{64}$/.test(entry.blobKey) || + !Number.isSafeInteger(entry.size) || + entry.size < 0 + ) { + throw new SandboxCheckpointInvalidEntryError( + 'File entries require a valid blobKey and size', + ) + } + } else if (entry.kind === 'dir') { + if ('blobKey' in entry || 'size' in entry) + throw new SandboxCheckpointInvalidEntryError( + 'Directory entries cannot contain file fields', + ) + } else { + throw new SandboxCheckpointInvalidEntryError( + 'Checkpoint entry kind must be file or dir', + ) + } + kinds.set(entry.path, entry.kind) + } + for (const artifact of artifacts) { + if (artifact === null || typeof artifact !== 'object') + throw new SandboxCheckpointInvalidEntryError( + 'Checkpoint artifact must be an object', + ) + if ( + !artifact.artifactId || + !artifact.name || + !artifact.mimeType || + !/^sandbox-artifacts\/sha256\/[0-9a-f]{64}$/.test(artifact.blobKey) || + !Number.isSafeInteger(artifact.size) || + artifact.size < 0 || + !Number.isFinite(artifact.createdAt) + ) + throw new SandboxCheckpointInvalidEntryError( + 'Checkpoint artifact has invalid fields', + ) + } +} + +function createCheckpointStore( + db: DatabaseSync, + options: SandboxCheckpointStoreOptions = {}, +): ForkCapableSandboxCheckpointStore { + const now = options.now ?? (() => Date.now()) + const leaseDurationMs = options.leaseDurationMs ?? 120_000 + const renewAfterMs = options.renewAfterMs ?? 45_000 + if ( + !Number.isFinite(leaseDurationMs) || + leaseDurationMs <= 0 || + !Number.isFinite(renewAfterMs) || + renewAfterMs <= 0 || + renewAfterMs >= leaseDurationMs + ) + throw new Error('Invalid checkpoint writer lease options') + const getCheckpoint = (id: string): SandboxCheckpoint | null => { + const header = db + .prepare('SELECT * FROM sandbox_checkpoints WHERE checkpoint_id = ?') + .get(id) as + | { + checkpoint_id: string + thread_id: string + parent_checkpoint_id: string | null + created_at: number + reason: SandboxCheckpoint['reason'] + label: string | null + source_run_id: string | null + conversation_json: string + } + | undefined + if (!header) return null + const files = db + .prepare( + 'SELECT * FROM sandbox_checkpoint_entries WHERE checkpoint_id = ? ORDER BY entry_index ASC', + ) + .all(id) as Array<{ + path: string + kind: 'file' | 'dir' + blob_key: string | null + size: number | null + }> + const artifacts = db + .prepare( + 'SELECT * FROM sandbox_checkpoint_artifacts WHERE checkpoint_id = ? ORDER BY artifact_index ASC', + ) + .all(id) as Array<{ + artifact_id: string + name: string + mime_type: string + size: number + blob_key: string + created_at: number + }> + return { + id: header.checkpoint_id, + threadId: header.thread_id, + parentCheckpointId: header.parent_checkpoint_id, + createdAt: header.created_at, + reason: header.reason, + ...(header.label === null ? {} : { label: header.label }), + ...(header.source_run_id === null + ? {} + : { sourceRunId: header.source_run_id }), + files: files.map( + (row): SandboxSnapshotEntry => + row.kind === 'dir' + ? { path: row.path, kind: 'dir' } + : { + path: row.path, + kind: 'file', + blobKey: row.blob_key!, + size: row.size!, + }, + ), + conversation: JSON.parse(header.conversation_json), + artifacts: artifacts.map( + (row): SandboxSnapshotArtifact => ({ + artifactId: row.artifact_id, + name: row.name, + mimeType: row.mime_type, + size: row.size, + blobKey: row.blob_key, + createdAt: row.created_at, + }), + ), + } + } + const assertWriter = (writer: SandboxCheckpointWriter, threadId: string) => { + const row = db + .prepare( + 'SELECT owner_token, fence, expires_at FROM sandbox_checkpoint_writers WHERE thread_id = ?', + ) + .get(threadId) as + | { owner_token: string; fence: number; expires_at: number } + | undefined + if ( + !row || + writer.threadId !== threadId || + row.owner_token !== writer.ownerToken || + row.fence !== writer.fence || + row.expires_at <= now() + ) + throw new SandboxCheckpointWriterLostError( + `Checkpoint writer lease for thread '${threadId}' is no longer current`, + ) + } + const writeCheckpoint = ( + checkpoint: SandboxCheckpoint, + conversationJson: string, + ) => { + db.prepare( + 'INSERT INTO sandbox_checkpoints (checkpoint_id, thread_id, parent_checkpoint_id, created_at, reason, label, source_run_id, conversation_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + ).run( + checkpoint.id, + checkpoint.threadId, + checkpoint.parentCheckpointId, + checkpoint.createdAt, + checkpoint.reason, + checkpoint.label ?? null, + checkpoint.sourceRunId ?? null, + conversationJson, + ) + const entry = db.prepare( + 'INSERT INTO sandbox_checkpoint_entries (checkpoint_id, entry_index, path, kind, blob_key, size) VALUES (?, ?, ?, ?, ?, ?)', + ) + checkpoint.files.forEach((value, index) => + entry.run( + checkpoint.id, + index, + value.path, + value.kind, + value.kind === 'file' ? value.blobKey : null, + value.kind === 'file' ? value.size : null, + ), + ) + const artifact = db.prepare( + 'INSERT INTO sandbox_checkpoint_artifacts (checkpoint_id, artifact_index, artifact_id, name, mime_type, size, blob_key, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + ) + checkpoint.artifacts.forEach((value, index) => + artifact.run( + checkpoint.id, + index, + value.artifactId, + value.name, + value.mimeType, + value.size, + value.blobKey, + value.createdAt, + ), + ) + } + const incrementReferences = (checkpoint: SandboxCheckpoint) => { + const statement = db.prepare( + 'INSERT INTO sandbox_checkpoint_blob_references (blob_key, reference_count) VALUES (?, 1) ON CONFLICT(blob_key) DO UPDATE SET reference_count = reference_count + 1', + ) + checkpointKeys(checkpoint).forEach((key) => statement.run(key)) + } + return { + async get(id) { + assertCheckpointId(id, 'Checkpoint id') + const value = getCheckpoint(id) + return value && cloneCheckpoint(value) + }, + async list(threadId) { + assertCheckpointId(threadId, 'Thread id') + const rows = db + .prepare( + 'SELECT checkpoint_id FROM sandbox_checkpoints WHERE thread_id = ? ORDER BY created_at ASC, checkpoint_id COLLATE BINARY ASC', + ) + .all(threadId) as Array<{ checkpoint_id: string }> + return rows.map((row) => + cloneCheckpoint(getCheckpoint(row.checkpoint_id)!), + ) + }, + async getHead(threadId) { + assertCheckpointId(threadId, 'Thread id') + const row = db + .prepare( + 'SELECT checkpoint_id FROM sandbox_checkpoint_heads WHERE thread_id = ?', + ) + .get(threadId) as { checkpoint_id: string } | undefined + return row?.checkpoint_id ?? null + }, + async append(input) { + const checkpoint = cloneCheckpoint(input.checkpoint) + assertCheckpoint(checkpoint) + if (input.expectedHeadId !== null) + assertCheckpointId(input.expectedHeadId, 'Expected head id') + if (input.writer.threadId !== checkpoint.threadId) + throw new SandboxCheckpointWriterLostError( + 'Checkpoint writer thread does not match checkpoint thread', + ) + const conversationJson = JSON.stringify(checkpoint.conversation) + db.exec('BEGIN IMMEDIATE') + try { + assertWriter(input.writer, checkpoint.threadId) + if (getCheckpoint(checkpoint.id)) + throw new SandboxCheckpointDuplicateIdError( + `Checkpoint '${checkpoint.id}' already exists`, + ) + const head = + ( + db + .prepare( + 'SELECT checkpoint_id FROM sandbox_checkpoint_heads WHERE thread_id = ?', + ) + .get(checkpoint.threadId) as { checkpoint_id: string } | undefined + )?.checkpoint_id ?? null + if (head !== input.expectedHeadId) + throw new SandboxCheckpointConflictError( + `Expected head '${input.expectedHeadId}', but thread '${checkpoint.threadId}' is at '${head}'`, + ) + if (checkpoint.parentCheckpointId !== input.expectedHeadId) + throw new SandboxCheckpointParentMismatchError( + `Checkpoint '${checkpoint.id}' parent does not match expected head`, + ) + writeCheckpoint(checkpoint, conversationJson) + db.prepare( + 'INSERT INTO sandbox_checkpoint_heads (thread_id, checkpoint_id) VALUES (?, ?) ON CONFLICT(thread_id) DO UPDATE SET checkpoint_id = excluded.checkpoint_id', + ).run(checkpoint.threadId, checkpoint.id) + incrementReferences(checkpoint) + db.exec('COMMIT') + } catch (error) { + rollbackIfActive(db) + throw error + } + return { headId: checkpoint.id } + }, + async deleteHead(input) { + assertCheckpointId(input.threadId, 'Thread id') + assertCheckpointId(input.checkpointId, 'Checkpoint id') + db.exec('BEGIN IMMEDIATE') + try { + assertWriter(input.writer, input.threadId) + const head = + ( + db + .prepare( + 'SELECT checkpoint_id FROM sandbox_checkpoint_heads WHERE thread_id = ?', + ) + .get(input.threadId) as { checkpoint_id: string } | undefined + )?.checkpoint_id ?? null + if (head !== input.checkpointId) + throw new SandboxCheckpointNotHeadError( + `Checkpoint '${input.checkpointId}' is not the current head of thread '${input.threadId}'`, + ) + const checkpoint = getCheckpoint(input.checkpointId) + if (!checkpoint) + throw new SandboxCheckpointNotHeadError( + `Checkpoint '${input.checkpointId}' does not exist`, + ) + db.prepare( + 'DELETE FROM sandbox_checkpoint_entries WHERE checkpoint_id = ?', + ).run(checkpoint.id) + db.prepare( + 'DELETE FROM sandbox_checkpoint_artifacts WHERE checkpoint_id = ?', + ).run(checkpoint.id) + db.prepare( + 'DELETE FROM sandbox_checkpoints WHERE checkpoint_id = ?', + ).run(checkpoint.id) + if (checkpoint.parentCheckpointId) + db.prepare( + 'UPDATE sandbox_checkpoint_heads SET checkpoint_id = ? WHERE thread_id = ?', + ).run(checkpoint.parentCheckpointId, input.threadId) + else + db.prepare( + 'DELETE FROM sandbox_checkpoint_heads WHERE thread_id = ?', + ).run(input.threadId) + const decrement = db.prepare( + 'UPDATE sandbox_checkpoint_blob_references SET reference_count = reference_count - 1 WHERE blob_key = ?', + ) + checkpointKeys(checkpoint).forEach((key) => decrement.run(key)) + db.exec( + 'DELETE FROM sandbox_checkpoint_blob_references WHERE reference_count <= 0', + ) + db.exec('COMMIT') + } catch (error) { + rollbackIfActive(db) + throw error + } + }, + async acquireWriter(threadId) { + assertCheckpointId(threadId, 'Thread id') + db.exec('BEGIN IMMEDIATE') + let lease: { ownerToken: string; fence: number; expiresAt: number } + try { + const current = db + .prepare( + 'SELECT expires_at FROM sandbox_checkpoint_writers WHERE thread_id = ?', + ) + .get(threadId) as { expires_at: number } | undefined + if (current && current.expires_at > now()) + throw new SandboxCheckpointWriterConflictError( + `Thread '${threadId}' already has an active checkpoint writer`, + ) + const prior = db + .prepare( + 'SELECT fence FROM sandbox_checkpoint_fences WHERE thread_id = ?', + ) + .get(threadId) as { fence: number } | undefined + const fence = (prior?.fence ?? 0) + 1 + db.prepare( + 'INSERT INTO sandbox_checkpoint_fences (thread_id, fence) VALUES (?, ?) ON CONFLICT(thread_id) DO UPDATE SET fence = excluded.fence', + ).run(threadId, fence) + lease = { + ownerToken: crypto.randomUUID(), + fence, + expiresAt: now() + leaseDurationMs, + } + db.prepare( + 'INSERT INTO sandbox_checkpoint_writers (thread_id, owner_token, fence, expires_at) VALUES (?, ?, ?, ?) ON CONFLICT(thread_id) DO UPDATE SET owner_token = excluded.owner_token, fence = excluded.fence, expires_at = excluded.expires_at', + ).run(threadId, lease.ownerToken, fence, lease.expiresAt) + db.exec('COMMIT') + } catch (error) { + rollbackIfActive(db) + throw error + } + return { + threadId, + ownerToken: lease!.ownerToken, + fence: lease!.fence, + get expiresAt() { + return lease!.expiresAt + }, + renewAfterMs, + renew: async () => { + db.exec('BEGIN IMMEDIATE') + try { + assertWriter( + { threadId, ownerToken: lease!.ownerToken, fence: lease!.fence }, + threadId, + ) + const expiresAt = now() + leaseDurationMs + db.prepare( + 'UPDATE sandbox_checkpoint_writers SET expires_at = ? WHERE thread_id = ? AND owner_token = ? AND fence = ?', + ).run(expiresAt, threadId, lease!.ownerToken, lease!.fence) + lease!.expiresAt = expiresAt + db.exec('COMMIT') + return { expiresAt } + } catch (error) { + rollbackIfActive(db) + throw error + } + }, + release: async () => { + db.prepare( + 'DELETE FROM sandbox_checkpoint_writers WHERE thread_id = ? AND owner_token = ? AND fence = ?', + ).run(threadId, lease!.ownerToken, lease!.fence) + }, + } + }, + async listBlobReferences() { + return db + .prepare( + 'SELECT blob_key AS key, reference_count AS "references" FROM sandbox_checkpoint_blob_references ORDER BY blob_key COLLATE BINARY ASC', + ) + .all() as Array<{ key: string; references: number }> + }, + async forkFromCheckpoint(input) { + const staged = { + sourceThreadId: input.sourceThreadId, + sourceCheckpointId: input.sourceCheckpointId, + destinationThreadId: input.destinationThreadId, + destinationCheckpointId: input.destinationCheckpointId, + createdAt: input.createdAt, + writer: { + threadId: input.writer.threadId, + ownerToken: input.writer.ownerToken, + fence: input.writer.fence, + }, + } + assertCheckpointId(staged.sourceThreadId, 'Source thread id') + assertCheckpointId(staged.sourceCheckpointId, 'Source checkpoint id') + assertCheckpointId(staged.destinationThreadId, 'Destination thread id') + assertCheckpointId( + staged.destinationCheckpointId, + 'Destination checkpoint id', + ) + if (!Number.isFinite(staged.createdAt)) + throw new SandboxCheckpointInvalidEntryError( + 'Fork checkpoint createdAt must be a finite number', + ) + db.exec('BEGIN IMMEDIATE') + try { + if (staged.sourceThreadId === staged.destinationThreadId) + checkpointError( + 'SANDBOX_SNAPSHOT_FORK_SOURCE_THREAD_MISMATCH', + 'Source and destination threads must differ', + ) + const source = getCheckpoint(staged.sourceCheckpointId) + if (!source) + checkpointError( + 'SANDBOX_SNAPSHOT_FORK_SOURCE_NOT_FOUND', + 'Source checkpoint was not found', + ) + if (source.threadId !== staged.sourceThreadId) + checkpointError( + 'SANDBOX_SNAPSHOT_FORK_SOURCE_THREAD_MISMATCH', + 'Source checkpoint belongs to another thread', + ) + assertWriter(staged.writer, staged.destinationThreadId) + const nonempty = db + .prepare( + 'SELECT 1 FROM messages WHERE thread_id = ? UNION ALL SELECT 1 FROM runs WHERE thread_id = ? UNION ALL SELECT 1 FROM generation_runs WHERE thread_id = ? UNION ALL SELECT 1 FROM interrupts WHERE thread_id = ? UNION ALL SELECT 1 FROM artifacts WHERE thread_id = ? UNION ALL SELECT 1 FROM sandbox_checkpoints WHERE thread_id = ? UNION ALL SELECT 1 FROM sandbox_checkpoint_heads WHERE thread_id = ? UNION ALL SELECT 1 FROM sandbox_checkpoints WHERE checkpoint_id = ? LIMIT 1', + ) + .get( + staged.destinationThreadId, + staged.destinationThreadId, + staged.destinationThreadId, + staged.destinationThreadId, + staged.destinationThreadId, + staged.destinationThreadId, + staged.destinationThreadId, + staged.destinationCheckpointId, + ) + if (nonempty) + checkpointError( + 'SANDBOX_SNAPSHOT_FORK_DESTINATION_NOT_EMPTY', + 'Destination thread is not empty', + ) + const checkpoint: SandboxCheckpoint = cloneCheckpoint({ + id: staged.destinationCheckpointId, + threadId: staged.destinationThreadId, + parentCheckpointId: null, + createdAt: staged.createdAt, + reason: 'fork-root', + files: source.files, + conversation: source.conversation, + artifacts: source.artifacts, + }) + assertCheckpoint(checkpoint) + const conversationJson = JSON.stringify(checkpoint.conversation) + for (const key of checkpointKeys(source)) { + const reference = db + .prepare( + 'SELECT reference_count FROM sandbox_checkpoint_blob_references WHERE blob_key = ?', + ) + .get(key) as { reference_count: number } | undefined + if (!reference || reference.reference_count <= 0) + throw new SandboxCheckpointInvalidEntryError( + `Source checkpoint blob '${key}' has no positive reference count`, + ) + } + db.prepare( + 'INSERT INTO messages (thread_id, messages_json) VALUES (?, ?)', + ).run(checkpoint.threadId, conversationJson) + writeCheckpoint(checkpoint, conversationJson) + db.prepare( + 'INSERT INTO sandbox_checkpoint_heads (thread_id, checkpoint_id) VALUES (?, ?)', + ).run(checkpoint.threadId, checkpoint.id) + incrementReferences(checkpoint) + db.exec('COMMIT') + return { checkpoint: cloneCheckpoint(checkpoint) } + } catch (error) { + rollbackIfActive(db) + throw error + } + }, + } +} + /** * Every store this backend provides, spelled out. * @@ -1130,6 +1835,49 @@ export function sqlitePersistence( } } +/** Build the seven persistence stores and a durable SQLite checkpoint store. */ +export function sqliteSandboxSnapshots( + options: SqlitePersistenceOptions & SandboxCheckpointStoreOptions, +): SandboxSnapshots & { + close: () => void +} { + const filename = normalizeSqliteUrl(options.url) + ensureParentDirectory(filename) + const db = new DatabaseSync(filename) + try { + if (options.migrate) { + db.exec(SCHEMA_SQL) + addMissingColumns(db) + } + const messages = createMessageStore(db) + const persistence = defineAIPersistence({ + stores: { + messages, + runs: createRunStore(db), + interrupts: createInterruptStore(db), + metadata: createMetadataStore(db), + generationRuns: createGenerationRunStore(db), + artifacts: createArtifactStore(db), + blobs: createBlobStore(db), + }, + }) + const checkpoints = createCheckpointStore(db, options) + const snapshots = createSandboxSnapshots({ persistence, checkpoints }) + let closed = false + return { + ...snapshots, + close() { + if (closed) return + db.close() + closed = true + }, + } + } catch (error) { + db.close() + throw error + } +} + // --------------------------------------------------------------------------- // URL / path helpers (kept identical to the packaged Node SQLite factory so the // `{ url, migrate }` call site is a drop-in). diff --git a/examples/ts-react-chat/src/routeTree.gen.ts b/examples/ts-react-chat/src/routeTree.gen.ts index 7dfa0251b7..2192f295a6 100644 --- a/examples/ts-react-chat/src/routeTree.gen.ts +++ b/examples/ts-react-chat/src/routeTree.gen.ts @@ -26,6 +26,7 @@ import { Route as ImageToolReproRouteImport } from './routes/image-tool-repro' import { Route as ImageGenRouteImport } from './routes/image-gen' import { Route as GenerationHooksRouteImport } from './routes/generation-hooks' import { Route as CapabilityDemoRouteImport } from './routes/capability-demo' +import { Route as AppStudioRouteImport } from './routes/app-studio' import { Route as IndexRouteImport } from './routes/index' import { Route as GenerationsVideoRouteImport } from './routes/generations.video' import { Route as GenerationsTranscriptionRouteImport } from './routes/generations.transcription' @@ -59,6 +60,8 @@ import { Route as ApiImageToolReproRouteImport } from './routes/api.image-tool-r import { Route as ApiImageGenRouteImport } from './routes/api.image-gen' import { Route as ApiCapabilityDemoRouteImport } from './routes/api.capability-demo' import { Route as ApiArtifactsRouteImport } from './routes/api.artifacts' +import { Route as ApiAppStudioForkRouteImport } from './routes/api.app-studio-fork' +import { Route as ApiAppStudioRouteImport } from './routes/api.app-studio' import { Route as ExampleGuitarsIndexRouteImport } from './routes/example.guitars/index' import { Route as ExampleGuitarsGuitarIdRouteImport } from './routes/example.guitars/$guitarId' import { Route as ApiGenerateVideoRouteImport } from './routes/api.generate.video' @@ -152,6 +155,11 @@ const CapabilityDemoRoute = CapabilityDemoRouteImport.update({ path: '/capability-demo', getParentRoute: () => rootRouteImport, } as any) +const AppStudioRoute = AppStudioRouteImport.update({ + id: '/app-studio', + path: '/app-studio', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -321,6 +329,16 @@ const ApiArtifactsRoute = ApiArtifactsRouteImport.update({ path: '/api/artifacts', getParentRoute: () => rootRouteImport, } as any) +const ApiAppStudioForkRoute = ApiAppStudioForkRouteImport.update({ + id: '/api/app-studio-fork', + path: '/api/app-studio-fork', + getParentRoute: () => rootRouteImport, +} as any) +const ApiAppStudioRoute = ApiAppStudioRouteImport.update({ + id: '/api/app-studio', + path: '/api/app-studio', + getParentRoute: () => rootRouteImport, +} as any) const ExampleGuitarsIndexRoute = ExampleGuitarsIndexRouteImport.update({ id: '/example/guitars/', path: '/example/guitars/', @@ -360,6 +378,7 @@ const ApiGenerateImageArtifactRoute = export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/app-studio': typeof AppStudioRoute '/capability-demo': typeof CapabilityDemoRoute '/generation-hooks': typeof GenerationHooksRoute '/image-gen': typeof ImageGenRoute @@ -377,6 +396,8 @@ export interface FileRoutesByFullPath { '/server-fn-chat': typeof ServerFnChatRoute '/threads': typeof ThreadsRoute '/typesafe-tools': typeof TypesafeToolsRoute + '/api/app-studio': typeof ApiAppStudioRoute + '/api/app-studio-fork': typeof ApiAppStudioForkRoute '/api/artifacts': typeof ApiArtifactsRoute '/api/capability-demo': typeof ApiCapabilityDemoRoute '/api/image-gen': typeof ApiImageGenRoute @@ -419,6 +440,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/app-studio': typeof AppStudioRoute '/capability-demo': typeof CapabilityDemoRoute '/generation-hooks': typeof GenerationHooksRoute '/image-gen': typeof ImageGenRoute @@ -436,6 +458,8 @@ export interface FileRoutesByTo { '/server-fn-chat': typeof ServerFnChatRoute '/threads': typeof ThreadsRoute '/typesafe-tools': typeof TypesafeToolsRoute + '/api/app-studio': typeof ApiAppStudioRoute + '/api/app-studio-fork': typeof ApiAppStudioForkRoute '/api/artifacts': typeof ApiArtifactsRoute '/api/capability-demo': typeof ApiCapabilityDemoRoute '/api/image-gen': typeof ApiImageGenRoute @@ -479,6 +503,7 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/app-studio': typeof AppStudioRoute '/capability-demo': typeof CapabilityDemoRoute '/generation-hooks': typeof GenerationHooksRoute '/image-gen': typeof ImageGenRoute @@ -496,6 +521,8 @@ export interface FileRoutesById { '/server-fn-chat': typeof ServerFnChatRoute '/threads': typeof ThreadsRoute '/typesafe-tools': typeof TypesafeToolsRoute + '/api/app-studio': typeof ApiAppStudioRoute + '/api/app-studio-fork': typeof ApiAppStudioForkRoute '/api/artifacts': typeof ApiArtifactsRoute '/api/capability-demo': typeof ApiCapabilityDemoRoute '/api/image-gen': typeof ApiImageGenRoute @@ -540,6 +567,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/app-studio' | '/capability-demo' | '/generation-hooks' | '/image-gen' @@ -557,6 +585,8 @@ export interface FileRouteTypes { | '/server-fn-chat' | '/threads' | '/typesafe-tools' + | '/api/app-studio' + | '/api/app-studio-fork' | '/api/artifacts' | '/api/capability-demo' | '/api/image-gen' @@ -599,6 +629,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/app-studio' | '/capability-demo' | '/generation-hooks' | '/image-gen' @@ -616,6 +647,8 @@ export interface FileRouteTypes { | '/server-fn-chat' | '/threads' | '/typesafe-tools' + | '/api/app-studio' + | '/api/app-studio-fork' | '/api/artifacts' | '/api/capability-demo' | '/api/image-gen' @@ -658,6 +691,7 @@ export interface FileRouteTypes { id: | '__root__' | '/' + | '/app-studio' | '/capability-demo' | '/generation-hooks' | '/image-gen' @@ -675,6 +709,8 @@ export interface FileRouteTypes { | '/server-fn-chat' | '/threads' | '/typesafe-tools' + | '/api/app-studio' + | '/api/app-studio-fork' | '/api/artifacts' | '/api/capability-demo' | '/api/image-gen' @@ -718,6 +754,7 @@ export interface FileRouteTypes { } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + AppStudioRoute: typeof AppStudioRoute CapabilityDemoRoute: typeof CapabilityDemoRoute GenerationHooksRoute: typeof GenerationHooksRoute ImageGenRoute: typeof ImageGenRoute @@ -735,6 +772,8 @@ export interface RootRouteChildren { ServerFnChatRoute: typeof ServerFnChatRoute ThreadsRoute: typeof ThreadsRoute TypesafeToolsRoute: typeof TypesafeToolsRoute + ApiAppStudioRoute: typeof ApiAppStudioRoute + ApiAppStudioForkRoute: typeof ApiAppStudioForkRoute ApiArtifactsRoute: typeof ApiArtifactsRoute ApiCapabilityDemoRoute: typeof ApiCapabilityDemoRoute ApiImageGenRoute: typeof ApiImageGenRoute @@ -896,6 +935,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CapabilityDemoRouteImport parentRoute: typeof rootRouteImport } + '/app-studio': { + id: '/app-studio' + path: '/app-studio' + fullPath: '/app-studio' + preLoaderRoute: typeof AppStudioRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -1127,6 +1173,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiArtifactsRouteImport parentRoute: typeof rootRouteImport } + '/api/app-studio-fork': { + id: '/api/app-studio-fork' + path: '/api/app-studio-fork' + fullPath: '/api/app-studio-fork' + preLoaderRoute: typeof ApiAppStudioForkRouteImport + parentRoute: typeof rootRouteImport + } + '/api/app-studio': { + id: '/api/app-studio' + path: '/api/app-studio' + fullPath: '/api/app-studio' + preLoaderRoute: typeof ApiAppStudioRouteImport + parentRoute: typeof rootRouteImport + } '/example/guitars/': { id: '/example/guitars/' path: '/example/guitars' @@ -1192,6 +1252,7 @@ const ApiGenerateImageRouteWithChildren = const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + AppStudioRoute: AppStudioRoute, CapabilityDemoRoute: CapabilityDemoRoute, GenerationHooksRoute: GenerationHooksRoute, ImageGenRoute: ImageGenRoute, @@ -1209,6 +1270,8 @@ const rootRouteChildren: RootRouteChildren = { ServerFnChatRoute: ServerFnChatRoute, ThreadsRoute: ThreadsRoute, TypesafeToolsRoute: TypesafeToolsRoute, + ApiAppStudioRoute: ApiAppStudioRoute, + ApiAppStudioForkRoute: ApiAppStudioForkRoute, ApiArtifactsRoute: ApiArtifactsRoute, ApiCapabilityDemoRoute: ApiCapabilityDemoRoute, ApiImageGenRoute: ApiImageGenRoute, diff --git a/examples/ts-react-chat/src/routes/api.app-studio-fork.ts b/examples/ts-react-chat/src/routes/api.app-studio-fork.ts new file mode 100644 index 0000000000..ceb81e2f18 --- /dev/null +++ b/examples/ts-react-chat/src/routes/api.app-studio-fork.ts @@ -0,0 +1,57 @@ +import { createFileRoute } from '@tanstack/react-router' +import { buildAppStudioSandbox } from '../lib/app-studio' +import { forkStudioThreads } from '../lib/app-studio-fork' +import { + appStudioInstances, + appStudioLocks, + appStudioSnapshots, +} from '../lib/app-studio-store' + +function json(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +export const Route = createFileRoute('/api/app-studio-fork')({ + server: { + handlers: { + POST: async ({ request }) => { + let body: unknown + try { + body = await request.json() + } catch { + return json(400, { error: 'invalid JSON body' }) + } + if (body === null || typeof body !== 'object') { + return json(400, { error: 'invalid JSON body' }) + } + const threadId = Reflect.get(body, 'threadId') + const label = Reflect.get(body, 'label') + const countValue = Reflect.get(body, 'count') + if (typeof threadId !== 'string' || threadId.length === 0) { + return json(400, { error: 'threadId is required' }) + } + const count = countValue === 2 ? 2 : 1 + try { + const result = await forkStudioThreads({ + snapshots: appStudioSnapshots(), + threadId, + runId: `studio-fork-${crypto.randomUUID()}`, + count, + sandbox: buildAppStudioSandbox(), + instances: appStudioInstances(), + locks: appStudioLocks(), + ...(typeof label === 'string' && label.length > 0 ? { label } : {}), + }) + return json(200, result) + } catch (error) { + return json(409, { + error: error instanceof Error ? error.message : 'fork failed', + }) + } + }, + }, + }, +}) diff --git a/examples/ts-react-chat/src/routes/api.app-studio.ts b/examples/ts-react-chat/src/routes/api.app-studio.ts new file mode 100644 index 0000000000..18be4acc8a --- /dev/null +++ b/examples/ts-react-chat/src/routes/api.app-studio.ts @@ -0,0 +1,87 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + chat, + chatParamsFromRequestBody, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { reconstructChat, withPersistence } from '@tanstack/ai-persistence' +import { withLocks } from '@tanstack/ai/locks' +import { withSandbox } from '@tanstack/ai-sandbox' +import { + APP_STUDIO_SYSTEM_PROMPT, + buildAppStudioAdapter, + buildAppStudioSandbox, + makeExposePreviewTool, + missingAppStudioEnv, + tanstackStartRecipe, +} from '../lib/app-studio' +import { + appStudioInstances, + appStudioLocks, + appStudioSnapshots, +} from '../lib/app-studio-store' + +function jsonError(status: number, error: string): Response { + return new Response(JSON.stringify({ error }), { + status, + statusText: error.slice(0, 64), + headers: { 'content-type': 'application/json' }, + }) +} + +export const Route = createFileRoute('/api/app-studio')({ + server: { + handlers: { + POST: async ({ request }) => { + const missing = missingAppStudioEnv() + if (missing.length > 0) { + return jsonError( + 500, + `Missing required env: ${missing.join(', ')}. Set it and restart the dev server.`, + ) + } + + let params: Awaited> + try { + params = await chatParamsFromRequestBody(await request.json()) + } catch { + return jsonError(400, 'invalid JSON body') + } + const snapshots = appStudioSnapshots() + const instances = appStudioInstances() + const locks = appStudioLocks() + const sandbox = buildAppStudioSandbox() + const abortController = new AbortController() + request.signal.addEventListener('abort', () => abortController.abort()) + + const stream = chat({ + adapter: buildAppStudioAdapter(), + messages: params.messages, + threadId: params.threadId, + runId: params.runId, + systemPrompts: [APP_STUDIO_SYSTEM_PROMPT], + tools: [ + tanstackStartRecipe, + makeExposePreviewTool(sandbox, params.threadId, { + store: instances, + locks, + }), + ], + middleware: [ + withPersistence(snapshots.persistence), + withLocks(locks), + withSandbox(sandbox, { instances, snapshots }), + ], + abortController, + }) + + return toServerSentEventsResponse(stream, { abortController }) + }, + GET: ({ request }) => { + return reconstructChat(appStudioSnapshots().persistence, request, { + authorize: async (threadId) => threadId.length > 0, + }) + }, + }, + }, +}) diff --git a/examples/ts-react-chat/src/routes/app-studio.css b/examples/ts-react-chat/src/routes/app-studio.css new file mode 100644 index 0000000000..72605378ce --- /dev/null +++ b/examples/ts-react-chat/src/routes/app-studio.css @@ -0,0 +1,261 @@ +@font-face { + font-family: 'Bricolage Grotesque'; + src: url('/brand/fonts/BricolageGrotesque-Bold.ttf') format('truetype'); + font-weight: 700; + font-display: swap; +} + +@font-face { + font-family: 'Inter'; + src: url('/brand/fonts/Inter-latin.woff2') format('woff2'); + font-weight: 100 900; + font-display: swap; +} + +.app-studio { + --studio-bg: #111111; + --studio-surface: #1f1f1f; + --studio-elevated: #2b2b2b; + --studio-subtle: #1b1b1b; + --studio-ink: #ffffff; + --studio-secondary: #aea691; + --studio-muted: #756c5b; + --studio-border: #2d2d2d; + --studio-border-strong: #aea691; + --studio-accent: #e06e49; + --studio-accent-soft: #edaa8d; + --studio-error: #e06e49; + --studio-error-bg: #5f1a0633; + --studio-focus: #e06e49; + --studio-hover: #ffffff14; + --studio-display: 'Bricolage Grotesque', ui-sans-serif, system-ui, sans-serif; + --studio-sans: 'Inter', ui-sans-serif, system-ui, sans-serif; + background: var(--studio-bg); + color: var(--studio-ink); + font-family: var(--studio-sans); + font-weight: 300; +} + +.app-studio button, +.app-studio textarea, +.app-studio input, +.app-studio label { + font-family: inherit; +} + +.app-studio-title { + font-family: var(--studio-display); + font-weight: 700; + font-size: 2.5rem; + line-height: 2.875rem; + letter-spacing: -0.03em; + color: var(--studio-accent); +} + +.app-studio-brand { + font-family: var(--studio-display); + font-weight: 700; + font-size: 1.125rem; + line-height: 1.375rem; + letter-spacing: -0.02em; + color: var(--studio-accent); +} + +.app-studio-kicker { + font-size: 0.75rem; + line-height: 1rem; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--studio-secondary); + font-weight: 500; +} + +.app-studio-lede { + font-size: 1rem; + line-height: 1.5rem; + font-weight: 300; + color: var(--studio-secondary); +} + +.app-studio-lede code { + color: var(--studio-accent-soft); +} + +.app-studio-btn-primary { + display: inline-flex; + align-items: center; + gap: 0.25rem; + min-height: 2.5rem; + border-radius: 0.5rem; + background: var(--studio-accent); + color: #111111; + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + font-weight: 500; + line-height: 1.05rem; + transition-property: background-color, transform; + transition-duration: 120ms; + transition-timing-function: ease-out; +} + +.app-studio-btn-primary:hover:not(:disabled) { + background: var(--studio-accent-soft); +} + +.app-studio-btn-primary:active:not(:disabled), +.app-studio-btn-secondary:active:not(:disabled) { + transform: scale(0.96); +} + +@media (prefers-reduced-motion: reduce) { + .app-studio-btn-primary, + .app-studio-btn-secondary { + transition: none; + } + + .app-studio-btn-primary:active:not(:disabled), + .app-studio-btn-secondary:active:not(:disabled) { + transform: none; + } +} + +.app-studio-btn-primary:focus-visible, +.app-studio-btn-secondary:focus-visible, +.app-studio-thread:focus-visible, +.app-studio textarea:focus-visible, +.app-studio input[type='checkbox']:focus-visible { + outline: 2px solid var(--studio-focus); + outline-offset: 2px; +} + +.app-studio-btn-primary:disabled, +.app-studio-btn-secondary:disabled { + opacity: 0.5; +} + +.app-studio-btn-secondary { + display: inline-flex; + align-items: center; + gap: 0.25rem; + min-height: 2.5rem; + border-radius: 0.5rem; + border: 1px solid var(--studio-border-strong); + background: transparent; + color: var(--studio-ink); + padding: 0.375rem 0.75rem; + font-size: 0.875rem; + font-weight: 500; + transition-property: background-color, transform; + transition-duration: 120ms; + transition-timing-function: ease-out; +} + +.app-studio-btn-secondary:hover:not(:disabled) { + background: var(--studio-hover); +} + +.app-studio-thread { + width: 100%; + min-height: 2.5rem; + border-radius: 0.5rem; + padding: 0.5rem 0.75rem; + text-align: left; + font-size: 0.875rem; + font-weight: 400; + color: var(--studio-secondary); +} + +.app-studio-thread-horizontal { + width: auto; +} + +.app-studio-thread:hover { + background: var(--studio-hover); + color: var(--studio-ink); +} + +.app-studio-thread-active { + background: #5f1a06; + color: var(--studio-ink); +} + +.app-studio-input { + width: 100%; + border-radius: 0.375rem; + border: 1px solid var(--studio-border); + background: var(--studio-surface); + color: var(--studio-ink); + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + font-weight: 400; +} + +.app-studio input[type='checkbox'] { + width: 1.125rem; + height: 1.125rem; + accent-color: var(--studio-accent); +} + +.app-studio-alert { + border-radius: 0.5rem; + border: 1px solid var(--studio-error); + background: var(--studio-error-bg); + color: var(--studio-accent-soft); + padding: 0.5rem 0.75rem; + font-size: 0.875rem; +} + +.app-studio-tool { + border-radius: 0.375rem; + background: var(--studio-surface); + padding: 0.25rem 0.5rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.75rem; + color: var(--studio-secondary); +} + +.app-studio-role { + font-size: 0.75rem; + line-height: 1rem; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--studio-muted); +} + +.app-studio-status { + color: var(--studio-accent-soft); +} + +.app-studio-preview-empty { + border-top: 1px solid var(--studio-border); + background: var(--studio-subtle); + padding: 1rem; + font-size: 0.875rem; + color: var(--studio-muted); +} + +.app-studio-preview { + height: 100%; + min-height: 12rem; + width: 100%; + border-top: 1px solid var(--studio-border); + background: #ffffff; +} + +.app-studio-rule { + border-color: var(--studio-border); +} + +.app-studio .prose { + color: var(--studio-ink); +} + +.app-studio .prose a { + color: var(--studio-accent-soft); +} + +.app-studio-hint { + font-size: 0.75rem; + line-height: 1rem; + color: var(--studio-muted); +} diff --git a/examples/ts-react-chat/src/routes/app-studio.tsx b/examples/ts-react-chat/src/routes/app-studio.tsx new file mode 100644 index 0000000000..3381273cb4 --- /dev/null +++ b/examples/ts-react-chat/src/routes/app-studio.tsx @@ -0,0 +1,749 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { GitBranch, Play } from 'lucide-react' +import ReactMarkdown from 'react-markdown' +import rehypeHighlight from 'rehype-highlight' +import rehypeRaw from 'rehype-raw' +import rehypeSanitize from 'rehype-sanitize' +import remarkGfm from 'remark-gfm' +import { + errorMessageFromBody, + previewUrlFrom, + previewUrlFromText, + threadIdsFromForkBody, + variantPrompt, +} from '../lib/app-studio-helpers' +import type { UIMessage } from '@tanstack/ai-react' +import './app-studio.css' + +export const Route = createFileRoute('/app-studio')({ + component: AppStudioPage, + head: () => ({ + meta: [{ title: 'App Studio | TanStack AI' }], + }), +}) + +const connection = fetchServerSentEvents('/api/app-studio') +const THREADS_KEY = 'app-studio:threads' +const EMPTY_PREVIEW_URLS: Array = [] + +interface StudioThread { + id: string + title: string + parentId: string | null + variant?: 'A' | 'B' + inheritedPreviewUrls?: Array +} + +interface CompareState { + leftId: string + rightId: string + prompt: string + inheritedPreviewUrls: Array +} + +function loadThreads(): Array { + if (typeof window === 'undefined') return [] + try { + const raw = window.localStorage.getItem(THREADS_KEY) + const parsed: unknown = raw ? JSON.parse(raw) : null + if (!Array.isArray(parsed)) return [] + return parsed.flatMap((item) => { + if (item === null || typeof item !== 'object') return [] + const id = Reflect.get(item, 'id') + const title = Reflect.get(item, 'title') + const parentId = Reflect.get(item, 'parentId') + const variant = Reflect.get(item, 'variant') + const inherited = Reflect.get(item, 'inheritedPreviewUrls') + if (typeof id !== 'string' || typeof title !== 'string') return [] + const inheritedPreviewUrls = Array.isArray(inherited) + ? inherited.filter((url) => typeof url === 'string') + : [] + return [ + { + id, + title, + parentId: typeof parentId === 'string' ? parentId : null, + ...(variant === 'A' || variant === 'B' ? { variant } : {}), + ...(inheritedPreviewUrls.length > 0 ? { inheritedPreviewUrls } : {}), + }, + ] + }) + } catch { + return [] + } +} + +function newThread(parentId: string | null = null): StudioThread { + return { + id: `studio-${crypto.randomUUID()}`, + title: parentId ? 'Fork' : 'New app', + parentId, + } +} + +function ThreadNav({ + threads, + activeId, + compare, + onSelect, + horizontal = false, +}: { + threads: Array + activeId: string + compare: CompareState | null + onSelect: (id: string) => void + horizontal?: boolean +}) { + return ( + + ) +} + +function collectPreviewUrls(messages: Array): Set { + const urls = new Set() + for (const message of messages) { + for (const part of message.parts) { + if (part.type === 'tool-call' && part.name === 'exposePreview') { + const url = previewUrlFrom(part.output) + if (url) urls.add(url) + } + if (part.type === 'text' && part.content) { + const url = previewUrlFromText(part.content) + if (url) urls.add(url) + } + } + } + return urls +} + +function latestPreview( + messages: Array, + skip: ReadonlySet = new Set(), +): string | null { + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index] + if (!message) continue + for (const part of message.parts) { + if (part.type === 'tool-call' && part.name === 'exposePreview') { + const url = previewUrlFrom(part.output) + if (url && !skip.has(url)) return url + } + if (part.type === 'text' && part.content) { + const url = previewUrlFromText(part.content) + if (url && !skip.has(url)) return url + } + } + } + return null +} + +function hasUserText(messages: Array, text: string): boolean { + return messages.some( + (message) => + message.role === 'user' && + message.parts.some( + (part) => part.type === 'text' && part.content === text, + ), + ) +} + +function AppStudioPage() { + const [threads, setThreads] = useState>([]) + const [activeId, setActiveId] = useState(null) + const [hydrated, setHydrated] = useState(false) + const [compare, setCompare] = useState(null) + const [wantCompare, setWantCompare] = useState(false) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + const loaded = loadThreads() + if (loaded.length === 0) { + const first = newThread() + setThreads([first]) + setActiveId(first.id) + } else { + setThreads(loaded) + setActiveId(loaded[0]?.id ?? null) + } + setHydrated(true) + }, []) + + useEffect(() => { + if (!hydrated) return + window.localStorage.setItem(THREADS_KEY, JSON.stringify(threads)) + }, [threads, hydrated]) + + const createRoot = () => { + const thread = newThread() + setCompare(null) + setWantCompare(false) + setError(null) + setThreads((prev) => [thread, ...prev]) + setActiveId(thread.id) + } + + const titleFrom = useCallback((id: string, title: string) => { + setThreads((prev) => { + let changed = false + const next = prev.map((thread) => { + if ( + thread.id === id && + (thread.title === 'New app' || thread.title === 'Fork') + ) { + changed = true + return { ...thread, title } + } + return thread + }) + return changed ? next : prev + }) + }, []) + + const forkOne = async (inheritedPreviewUrls: Array) => { + if (!activeId || busy) return + setBusy(true) + setError(null) + try { + const response = await fetch('/api/app-studio-fork', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ threadId: activeId, label: 'continue' }), + }) + const body: unknown = await response.json() + if (!response.ok) { + throw new Error(errorMessageFromBody(body, 'Could not fork this chat')) + } + const [nextId] = threadIdsFromForkBody(body) + if (nextId === undefined) { + throw new Error('Could not fork this chat') + } + const child: StudioThread = { + id: nextId, + title: 'Fork', + parentId: activeId, + ...(inheritedPreviewUrls.length > 0 ? { inheritedPreviewUrls } : {}), + } + setThreads((prev) => [child, ...prev]) + setCompare(null) + setActiveId(nextId) + } catch (cause) { + setError( + cause instanceof Error ? cause.message : 'Could not fork this chat', + ) + } finally { + setBusy(false) + } + } + + const startCompare = async ( + prompt: string, + inheritedPreviewUrls: Array, + ) => { + if (!activeId || busy) return + setBusy(true) + setError(null) + try { + const response = await fetch('/api/app-studio-fork', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + threadId: activeId, + count: 2, + label: 'compare', + }), + }) + const body: unknown = await response.json() + if (!response.ok) { + throw new Error( + errorMessageFromBody(body, 'Could not start the comparison'), + ) + } + const ids = threadIdsFromForkBody(body) + const leftId = ids[0] + const rightId = ids[1] + if (leftId === undefined || rightId === undefined) { + throw new Error('Could not start the comparison') + } + const inherited = + inheritedPreviewUrls.length > 0 ? { inheritedPreviewUrls } : {} + setThreads((prev) => [ + { + id: leftId, + title: 'Variant A', + parentId: activeId, + variant: 'A', + ...inherited, + }, + { + id: rightId, + title: 'Variant B', + parentId: activeId, + variant: 'B', + ...inherited, + }, + ...prev, + ]) + setCompare({ leftId, rightId, prompt, inheritedPreviewUrls }) + setWantCompare(false) + } catch (cause) { + setError( + cause instanceof Error + ? cause.message + : 'Could not start the comparison', + ) + } finally { + setBusy(false) + } + } + + const keepVariant = (id: string) => { + setActiveId(id) + setCompare(null) + } + + const onActiveTitle = useCallback( + (title: string) => { + if (activeId === null) return + titleFrom(activeId, title) + }, + [activeId, titleFrom], + ) + + if (!hydrated || !activeId) { + return ( +
+

Loading chats.

+
+ ) + } + + const selectThread = (id: string) => { + setCompare(null) + setWantCompare(false) + setActiveId(id) + } + + return ( +
+ + +
+
+ + +
+
+

TanStack AI

+

App Studio

+

+ Describe an app. The agent builds it in a sandbox and shows a + preview. Fork the chat to continue, or compare two directions and + keep one. Needs Docker and XAI_API_KEY. +

+
+ + {error ? ( +

+ {error} +

+ ) : null} + + {compare ? ( +
+ keepVariant(compare.leftId)} + /> + keepVariant(compare.rightId)} + /> +
+ ) : ( + thread.id === activeId) + ?.inheritedPreviewUrls ?? EMPTY_PREVIEW_URLS + } + wantCompare={wantCompare} + setWantCompare={setWantCompare} + busy={busy} + onFork={forkOne} + onCompare={startCompare} + onTitle={onActiveTitle} + /> + )} +
+
+ ) +} + +function StudioPane({ + threadId, + inheritedPreviewUrls, + wantCompare, + setWantCompare, + busy, + onFork, + onCompare, + onTitle, +}: { + threadId: string + inheritedPreviewUrls: Array + wantCompare: boolean + setWantCompare: (value: boolean) => void + busy: boolean + onFork: (inheritedPreviewUrls: Array) => void + onCompare: ( + prompt: string, + inheritedPreviewUrls: Array, + ) => Promise + onTitle: (title: string) => void +}) { + const { + messages, + sendMessage, + isLoading, + error: chatError, + } = useChat({ + threadId, + connection, + persistence: true, + }) + const [input, setInput] = useState('') + const preview = useMemo( + () => latestPreview(messages, new Set(inheritedPreviewUrls)), + [inheritedPreviewUrls, messages], + ) + const hasBuiltApp = messages.some( + (message) => + message.role === 'assistant' && + message.parts.some( + (part) => + (part.type === 'text' && Boolean(part.content)) || + part.type === 'tool-call', + ), + ) + + useEffect(() => { + const firstUser = messages.find((message) => message.role === 'user') + const part = firstUser?.parts.find((item) => item.type === 'text') + if (part && 'content' in part && typeof part.content === 'string') { + onTitle(part.content.slice(0, 40)) + } + }, [messages, onTitle]) + + const send = async () => { + const trimmed = input.trim() + if (isLoading || busy) return + if (wantCompare) { + if (!hasBuiltApp) return + setInput('') + await onCompare(trimmed, [...collectPreviewUrls(messages)]) + return + } + if (!trimmed) return + setInput('') + void sendMessage(trimmed) + } + + return ( + <> +
+ + +
+
{ + event.preventDefault() + void send() + }} + > + + {chatError ? ( +

+ {chatError.message} +

+ ) : null} +