@tanstack/ai-sandbox-cloudflare 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/esm/agent.d.ts +4 -0
  2. package/dist/esm/agent.js +9 -21
  3. package/dist/esm/chat-coordinator.js +144 -132
  4. package/dist/esm/chat-coordinator.js.map +1 -1
  5. package/dist/esm/container-coordinator.js +251 -247
  6. package/dist/esm/container-coordinator.js.map +1 -1
  7. package/dist/esm/coordinator.d.ts +3 -2
  8. package/dist/esm/coordinator.js +204 -184
  9. package/dist/esm/coordinator.js.map +1 -1
  10. package/dist/esm/durability.d.ts +32 -0
  11. package/dist/esm/durability.js +104 -0
  12. package/dist/esm/durability.js.map +1 -0
  13. package/dist/esm/factory.js +98 -59
  14. package/dist/esm/factory.js.map +1 -1
  15. package/dist/esm/handle.js +205 -203
  16. package/dist/esm/handle.js.map +1 -1
  17. package/dist/esm/index.js +2 -8
  18. package/dist/esm/preview-tool.d.ts +7 -1
  19. package/dist/esm/preview-tool.js +75 -32
  20. package/dist/esm/preview-tool.js.map +1 -1
  21. package/dist/esm/protocol.js +61 -50
  22. package/dist/esm/protocol.js.map +1 -1
  23. package/dist/esm/provider.js +43 -62
  24. package/dist/esm/provider.js.map +1 -1
  25. package/dist/esm/public-host.js +84 -39
  26. package/dist/esm/public-host.js.map +1 -1
  27. package/dist/esm/run-log-do.d.ts +19 -5
  28. package/dist/esm/run-log-do.js +196 -121
  29. package/dist/esm/run-log-do.js.map +1 -1
  30. package/dist/esm/run-log.d.ts +127 -0
  31. package/dist/esm/run-log.js +198 -0
  32. package/dist/esm/run-log.js.map +1 -0
  33. package/dist/esm/runner.js +146 -95
  34. package/dist/esm/runner.js.map +1 -1
  35. package/dist/esm/web-crypto.js +27 -16
  36. package/dist/esm/web-crypto.js.map +1 -1
  37. package/dist/esm/worker.js +84 -72
  38. package/dist/esm/worker.js.map +1 -1
  39. package/package.json +9 -9
  40. package/src/agent.ts +26 -0
  41. package/src/coordinator.ts +36 -14
  42. package/src/durability.ts +164 -0
  43. package/src/handle.ts +5 -0
  44. package/src/run-log-do.ts +85 -20
  45. package/src/run-log.ts +352 -0
  46. package/dist/esm/agent.js.map +0 -1
  47. package/dist/esm/index.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"runner.js","sources":["../../src/runner.ts"],"sourcesContent":["/**\n * `runInContainerHarness` — the IN-CONTAINER harness runner, shipped from the\n * package so a co-located app's container program is a single function call.\n *\n * This is the heart of the CO-LOCATED model: the agent harness loop AND its MCP\n * tool-bridge run HERE, on the container's own localhost. The Durable Object\n * outside never calls `chat()`; it POSTs `/run` to this server and reads the\n * NDJSON stream back.\n *\n * DO ── POST /run {messages, harness, model, workspace, toolDescriptors,\n * toolExecUrl, toolExecToken} ──▶ THIS\n * THIS ── NDJSON stream of StreamChunk ──────────────────────────────────▶ DO\n *\n * It is a tiny `node:http` server (NODE/container side — NOT Workers; it uses\n * `localProcessSandbox`). On `POST /run` it validates the {@link\n * ContainerRunRequest}, builds `chat()` with the in-container `local-process`\n * sandbox and the adapter the CALLER resolves, and streams each {@link\n * StreamChunk} back as NDJSON (one JSON object per line).\n *\n * Why the MCP bridge is genuinely in-container: the in-container sandbox is\n * `localProcessSandbox()` — the container IS the host — so the harness adapter\n * serves its tool-bridge over the container's own `localhost` and feeds the\n * prompt over NATIVE writable stdin (no file-redirect; the bridge URL/token\n * never leave the container). The MCP protocol never crosses the network.\n *\n * The ONE thing that still crosses back to the DO is host-tool EXECUTION: each\n * tool rebuilt by {@link remoteToolStubs} delegates its `execute()` to {@link\n * httpRemoteToolExecutor}, which POSTs `{ name, args }` (bearer-gated) to the\n * DO's `toolExecUrl`:\n *\n * agent → in-container MCP bridge → stub.execute → httpRemoteToolExecutor → DO\n *\n * The app supplies only `resolveAdapter` — which `*Text` adapter to build for a\n * given `{ harness, model }`. The server + `chat()` wiring lives here, so the\n * package doesn't depend on every adapter package.\n *\n * NOTE: container-side Node code — compiles against the real TanStack AI types;\n * not runtime-verified in this repo (no container build in CI).\n */\nimport { createServer } from 'node:http'\nimport { EventType, chat } from '@tanstack/ai'\nimport {\n createSecrets,\n defineSandbox,\n defineWorkspace,\n httpRemoteToolExecutor,\n remoteToolStubs,\n withSandbox,\n} from '@tanstack/ai-sandbox'\nimport { localProcessSandbox } from '@tanstack/ai-sandbox-local-process'\nimport { parseContainerRunRequest } from './protocol'\nimport type { IncomingMessage, Server, ServerResponse } from 'node:http'\nimport type { AnyTextAdapter, StreamChunk } from '@tanstack/ai'\nimport type { WorkspaceDefinition } from '@tanstack/ai-sandbox'\nimport type { ContainerRunRequest, HarnessId } from './protocol'\n\n/** The `{ harness, model }` the caller maps to a concrete `*Text` adapter. */\nexport interface ResolveAdapterInput {\n harness: HarnessId\n model: string\n}\n\n/** Options for {@link runInContainerHarness}. */\nexport interface RunInContainerHarnessOptions {\n /**\n * Build the text adapter `chat()` runs for one request's `{ harness, model }`.\n * The app supplies this so the package doesn't depend on every adapter package\n * — e.g. `({ model }) => claudeCodeText(model)`.\n */\n resolveAdapter: (input: ResolveAdapterInput) => AnyTextAdapter\n /** Port to listen on. Defaults to `RUNNER_PORT` env, then `8080`. */\n port?: number\n}\n\n/** What {@link runInContainerHarness} returns: the listening `node:http` server. */\nexport interface ContainerHarnessServer {\n /** The underlying `node:http` server (already `listen()`ing). */\n server: Server\n /** The port it is listening on. */\n port: number\n}\n\n/** Read a request body fully into a string (small JSON payloads only). */\nfunction readBody(req: IncomingMessage): Promise<string> {\n return new Promise((resolve, reject) => {\n let body = ''\n req.setEncoding('utf8')\n req.on('data', (chunk: string) => {\n body += chunk\n })\n req.on('end', () => resolve(body))\n req.on('error', reject)\n })\n}\n\n/**\n * Rebuild the request's workspace with a real `createSecrets`, pulling each\n * referenced secret's VALUE from the container env. Secret values never cross\n * the `POST /run` boundary (`createSecrets` stores them under a non-enumerable\n * symbol, so serializing the workspace carries only the names) — the DO injects\n * them into the container env via `sandbox.setEnvVars`, and we reconstitute them\n * here. A referenced secret missing from the env is a hard error, never a silent\n * keyless run.\n */\nfunction reconstituteWorkspace(\n workspace: WorkspaceDefinition,\n): WorkspaceDefinition {\n if (workspace.secrets === undefined) return workspace\n const names = Object.keys(workspace.secrets)\n if (names.length === 0) return workspace\n const values: Record<string, string> = {}\n for (const name of names) {\n const value = process.env[name]\n if (value === undefined || value === '') {\n throw new Error(\n `runInContainerHarness: secret \"${name}\" is not set in the container env`,\n )\n }\n values[name] = value\n }\n return defineWorkspace({ ...workspace, secrets: createSecrets(values) })\n}\n\n/**\n * Build the `chat()` stream that runs the harness on THIS container via the\n * `local-process` sandbox. The agent's `chat()` tools are stubs that delegate\n * back to the DO; everything else (the harness loop, the MCP bridge, stdin)\n * stays on localhost.\n */\nfunction runAgent(\n request: ContainerRunRequest,\n resolveAdapter: (input: ResolveAdapterInput) => AnyTextAdapter,\n): AsyncIterable<StreamChunk> {\n const sandbox = defineSandbox({\n // The container IS the host: no isolation, just run on its own filesystem.\n id: 'colocated-in-container',\n provider: localProcessSandbox(),\n // Honor the app's workspace (source / setup / skills / …), with the secrets\n // re-resolved from the container env.\n workspace: reconstituteWorkspace(request.workspace),\n })\n\n // `stream: true` (no outputSchema) makes chat() return AsyncIterable<StreamChunk>.\n return chat({\n threadId: request.threadId,\n adapter: resolveAdapter({\n harness: request.harness,\n model: request.model,\n }),\n messages: request.messages,\n stream: true,\n // Rebuild the DO's host tools as stubs whose execute() POSTs back to the DO.\n // The adapter bridges them over the in-container localhost MCP transport.\n tools: remoteToolStubs(\n request.toolDescriptors,\n httpRemoteToolExecutor(request.toolExecUrl, request.toolExecToken),\n ),\n // Provide the in-container local-process sandbox handle the adapter needs.\n middleware: [withSandbox(sandbox)],\n })\n}\n\n/** Stream the agent's chunks to the response as NDJSON, one object per line. */\nasync function handleRun(\n req: IncomingMessage,\n res: ServerResponse,\n resolveAdapter: (input: ResolveAdapterInput) => AnyTextAdapter,\n): Promise<void> {\n const parsed: unknown = JSON.parse(await readBody(req))\n const request = parseContainerRunRequest(parsed)\n res.writeHead(200, {\n 'content-type': 'application/x-ndjson',\n 'cache-control': 'no-cache',\n })\n // The DO appends each line to its durable run-log; here we are the producer,\n // so we surface a mid-stream failure as a terminal RUN_ERROR line the DO will\n // append + finish on, never a silently truncated stream.\n try {\n for await (const chunk of runAgent(request, resolveAdapter)) {\n res.write(`${JSON.stringify(chunk)}\\n`)\n }\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n res.write(`${JSON.stringify({ type: EventType.RUN_ERROR, message })}\\n`)\n } finally {\n res.end()\n }\n}\n\n/**\n * Start the in-container harness runner: a `node:http` server with `GET /health`\n * and `POST /run`. Call this as the container's program; the app supplies only\n * `resolveAdapter`.\n */\nexport function runInContainerHarness(\n options: RunInContainerHarnessOptions,\n): ContainerHarnessServer {\n const port =\n options.port ?? Number.parseInt(process.env.RUNNER_PORT ?? '8080', 10)\n\n const server = createServer((req, res) => {\n if (req.method === 'POST' && req.url === '/run') {\n handleRun(req, res, options.resolveAdapter).catch((error: unknown) => {\n // A failure BEFORE we start streaming (e.g. a malformed body) is a 400 —\n // surfaced, never swallowed.\n const message = error instanceof Error ? error.message : String(error)\n if (!res.headersSent) {\n res.writeHead(400, { 'content-type': 'text/plain' })\n }\n res.end(message)\n })\n return\n }\n if (req.method === 'GET' && req.url === '/health') {\n res.writeHead(200).end('ok')\n return\n }\n res.writeHead(404).end('not found')\n })\n\n server.listen(port, () => {\n console.log(`[container-runner] listening on :${port}`)\n })\n\n return { server, port }\n}\n"],"names":[],"mappings":";;;;;AAmFA,SAAS,SAAS,KAAuC;AACvD,SAAO,IAAI,QAAQ,CAAC,SAAS,WAAW;AACtC,QAAI,OAAO;AACX,QAAI,YAAY,MAAM;AACtB,QAAI,GAAG,QAAQ,CAAC,UAAkB;AAChC,cAAQ;AAAA,IACV,CAAC;AACD,QAAI,GAAG,OAAO,MAAM,QAAQ,IAAI,CAAC;AACjC,QAAI,GAAG,SAAS,MAAM;AAAA,EACxB,CAAC;AACH;AAWA,SAAS,sBACP,WACqB;AACrB,MAAI,UAAU,YAAY,OAAW,QAAO;AAC5C,QAAM,QAAQ,OAAO,KAAK,UAAU,OAAO;AAC3C,MAAI,MAAM,WAAW,EAAG,QAAO;AAC/B,QAAM,SAAiC,CAAA;AACvC,aAAW,QAAQ,OAAO;AACxB,UAAM,QAAQ,QAAQ,IAAI,IAAI;AAC9B,QAAI,UAAU,UAAa,UAAU,IAAI;AACvC,YAAM,IAAI;AAAA,QACR,kCAAkC,IAAI;AAAA,MAAA;AAAA,IAE1C;AACA,WAAO,IAAI,IAAI;AAAA,EACjB;AACA,SAAO,gBAAgB,EAAE,GAAG,WAAW,SAAS,cAAc,MAAM,GAAG;AACzE;AAQA,SAAS,SACP,SACA,gBAC4B;AAC5B,QAAM,UAAU,cAAc;AAAA;AAAA,IAE5B,IAAI;AAAA,IACJ,UAAU,oBAAA;AAAA;AAAA;AAAA,IAGV,WAAW,sBAAsB,QAAQ,SAAS;AAAA,EAAA,CACnD;AAGD,SAAO,KAAK;AAAA,IACV,UAAU,QAAQ;AAAA,IAClB,SAAS,eAAe;AAAA,MACtB,SAAS,QAAQ;AAAA,MACjB,OAAO,QAAQ;AAAA,IAAA,CAChB;AAAA,IACD,UAAU,QAAQ;AAAA,IAClB,QAAQ;AAAA;AAAA;AAAA,IAGR,OAAO;AAAA,MACL,QAAQ;AAAA,MACR,uBAAuB,QAAQ,aAAa,QAAQ,aAAa;AAAA,IAAA;AAAA;AAAA,IAGnE,YAAY,CAAC,YAAY,OAAO,CAAC;AAAA,EAAA,CAClC;AACH;AAGA,eAAe,UACb,KACA,KACA,gBACe;AACf,QAAM,SAAkB,KAAK,MAAM,MAAM,SAAS,GAAG,CAAC;AACtD,QAAM,UAAU,yBAAyB,MAAM;AAC/C,MAAI,UAAU,KAAK;AAAA,IACjB,gBAAgB;AAAA,IAChB,iBAAiB;AAAA,EAAA,CAClB;AAID,MAAI;AACF,qBAAiB,SAAS,SAAS,SAAS,cAAc,GAAG;AAC3D,UAAI,MAAM,GAAG,KAAK,UAAU,KAAK,CAAC;AAAA,CAAI;AAAA,IACxC;AAAA,EACF,SAAS,OAAO;AACd,UAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AACrE,QAAI,MAAM,GAAG,KAAK,UAAU,EAAE,MAAM,UAAU,WAAW,SAAS,CAAC;AAAA,CAAI;AAAA,EACzE,UAAA;AACE,QAAI,IAAA;AAAA,EACN;AACF;AAOO,SAAS,sBACd,SACwB;AACxB,QAAM,OACJ,QAAQ,QAAQ,OAAO,SAAS,QAAQ,IAAI,eAAe,QAAQ,EAAE;AAEvE,QAAM,SAAS,aAAa,CAAC,KAAK,QAAQ;AACxC,QAAI,IAAI,WAAW,UAAU,IAAI,QAAQ,QAAQ;AAC/C,gBAAU,KAAK,KAAK,QAAQ,cAAc,EAAE,MAAM,CAAC,UAAmB;AAGpE,cAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AACrE,YAAI,CAAC,IAAI,aAAa;AACpB,cAAI,UAAU,KAAK,EAAE,gBAAgB,cAAc;AAAA,QACrD;AACA,YAAI,IAAI,OAAO;AAAA,MACjB,CAAC;AACD;AAAA,IACF;AACA,QAAI,IAAI,WAAW,SAAS,IAAI,QAAQ,WAAW;AACjD,UAAI,UAAU,GAAG,EAAE,IAAI,IAAI;AAC3B;AAAA,IACF;AACA,QAAI,UAAU,GAAG,EAAE,IAAI,WAAW;AAAA,EACpC,CAAC;AAED,SAAO,OAAO,MAAM,MAAM;AACxB,YAAQ,IAAI,oCAAoC,IAAI,EAAE;AAAA,EACxD,CAAC;AAED,SAAO,EAAE,QAAQ,KAAA;AACnB;"}
1
+ {"version":3,"file":"runner.js","names":[],"sources":["../../src/runner.ts"],"sourcesContent":["/**\n * `runInContainerHarness` — the IN-CONTAINER harness runner, shipped from the\n * package so a co-located app's container program is a single function call.\n *\n * This is the heart of the CO-LOCATED model: the agent harness loop AND its MCP\n * tool-bridge run HERE, on the container's own localhost. The Durable Object\n * outside never calls `chat()`; it POSTs `/run` to this server and reads the\n * NDJSON stream back.\n *\n * DO ── POST /run {messages, harness, model, workspace, toolDescriptors,\n * toolExecUrl, toolExecToken} ──▶ THIS\n * THIS ── NDJSON stream of StreamChunk ──────────────────────────────────▶ DO\n *\n * It is a tiny `node:http` server (NODE/container side — NOT Workers; it uses\n * `localProcessSandbox`). On `POST /run` it validates the {@link\n * ContainerRunRequest}, builds `chat()` with the in-container `local-process`\n * sandbox and the adapter the CALLER resolves, and streams each {@link\n * StreamChunk} back as NDJSON (one JSON object per line).\n *\n * Why the MCP bridge is genuinely in-container: the in-container sandbox is\n * `localProcessSandbox()` — the container IS the host — so the harness adapter\n * serves its tool-bridge over the container's own `localhost` and feeds the\n * prompt over NATIVE writable stdin (no file-redirect; the bridge URL/token\n * never leave the container). The MCP protocol never crosses the network.\n *\n * The ONE thing that still crosses back to the DO is host-tool EXECUTION: each\n * tool rebuilt by {@link remoteToolStubs} delegates its `execute()` to {@link\n * httpRemoteToolExecutor}, which POSTs `{ name, args }` (bearer-gated) to the\n * DO's `toolExecUrl`:\n *\n * agent → in-container MCP bridge → stub.execute → httpRemoteToolExecutor → DO\n *\n * The app supplies only `resolveAdapter` — which `*Text` adapter to build for a\n * given `{ harness, model }`. The server + `chat()` wiring lives here, so the\n * package doesn't depend on every adapter package.\n *\n * NOTE: container-side Node code — compiles against the real TanStack AI types;\n * not runtime-verified in this repo (no container build in CI).\n */\nimport { createServer } from 'node:http'\nimport { EventType, chat } from '@tanstack/ai'\nimport {\n createSecrets,\n defineSandbox,\n defineWorkspace,\n httpRemoteToolExecutor,\n remoteToolStubs,\n withSandbox,\n} from '@tanstack/ai-sandbox'\nimport { localProcessSandbox } from '@tanstack/ai-sandbox-local-process'\nimport { parseContainerRunRequest } from './protocol'\nimport type { IncomingMessage, Server, ServerResponse } from 'node:http'\nimport type { AnyTextAdapter, StreamChunk } from '@tanstack/ai'\nimport type { WorkspaceDefinition } from '@tanstack/ai-sandbox'\nimport type { ContainerRunRequest, HarnessId } from './protocol'\n\n/** The `{ harness, model }` the caller maps to a concrete `*Text` adapter. */\nexport interface ResolveAdapterInput {\n harness: HarnessId\n model: string\n}\n\n/** Options for {@link runInContainerHarness}. */\nexport interface RunInContainerHarnessOptions {\n /**\n * Build the text adapter `chat()` runs for one request's `{ harness, model }`.\n * The app supplies this so the package doesn't depend on every adapter package\n * — e.g. `({ model }) => claudeCodeText(model)`.\n */\n resolveAdapter: (input: ResolveAdapterInput) => AnyTextAdapter\n /** Port to listen on. Defaults to `RUNNER_PORT` env, then `8080`. */\n port?: number\n}\n\n/** What {@link runInContainerHarness} returns: the listening `node:http` server. */\nexport interface ContainerHarnessServer {\n /** The underlying `node:http` server (already `listen()`ing). */\n server: Server\n /** The port it is listening on. */\n port: number\n}\n\n/** Read a request body fully into a string (small JSON payloads only). */\nfunction readBody(req: IncomingMessage): Promise<string> {\n return new Promise((resolve, reject) => {\n let body = ''\n req.setEncoding('utf8')\n req.on('data', (chunk: string) => {\n body += chunk\n })\n req.on('end', () => resolve(body))\n req.on('error', reject)\n })\n}\n\n/**\n * Rebuild the request's workspace with a real `createSecrets`, pulling each\n * referenced secret's VALUE from the container env. Secret values never cross\n * the `POST /run` boundary (`createSecrets` stores them under a non-enumerable\n * symbol, so serializing the workspace carries only the names) — the DO injects\n * them into the container env via `sandbox.setEnvVars`, and we reconstitute them\n * here. A referenced secret missing from the env is a hard error, never a silent\n * keyless run.\n */\nfunction reconstituteWorkspace(\n workspace: WorkspaceDefinition,\n): WorkspaceDefinition {\n if (workspace.secrets === undefined) return workspace\n const names = Object.keys(workspace.secrets)\n if (names.length === 0) return workspace\n const values: Record<string, string> = {}\n for (const name of names) {\n const value = process.env[name]\n if (value === undefined || value === '') {\n throw new Error(\n `runInContainerHarness: secret \"${name}\" is not set in the container env`,\n )\n }\n values[name] = value\n }\n return defineWorkspace({ ...workspace, secrets: createSecrets(values) })\n}\n\n/**\n * Build the `chat()` stream that runs the harness on THIS container via the\n * `local-process` sandbox. The agent's `chat()` tools are stubs that delegate\n * back to the DO; everything else (the harness loop, the MCP bridge, stdin)\n * stays on localhost.\n */\nfunction runAgent(\n request: ContainerRunRequest,\n resolveAdapter: (input: ResolveAdapterInput) => AnyTextAdapter,\n): AsyncIterable<StreamChunk> {\n const sandbox = defineSandbox({\n // The container IS the host: no isolation, just run on its own filesystem.\n id: 'colocated-in-container',\n provider: localProcessSandbox(),\n // Honor the app's workspace (source / setup / skills / …), with the secrets\n // re-resolved from the container env.\n workspace: reconstituteWorkspace(request.workspace),\n })\n\n // `stream: true` (no outputSchema) makes chat() return AsyncIterable<StreamChunk>.\n return chat({\n threadId: request.threadId,\n adapter: resolveAdapter({\n harness: request.harness,\n model: request.model,\n }),\n messages: request.messages,\n stream: true,\n // Rebuild the DO's host tools as stubs whose execute() POSTs back to the DO.\n // The adapter bridges them over the in-container localhost MCP transport.\n tools: remoteToolStubs(\n request.toolDescriptors,\n httpRemoteToolExecutor(request.toolExecUrl, request.toolExecToken),\n ),\n // Provide the in-container local-process sandbox handle the adapter needs.\n middleware: [withSandbox(sandbox)],\n })\n}\n\n/** Stream the agent's chunks to the response as NDJSON, one object per line. */\nasync function handleRun(\n req: IncomingMessage,\n res: ServerResponse,\n resolveAdapter: (input: ResolveAdapterInput) => AnyTextAdapter,\n): Promise<void> {\n const parsed: unknown = JSON.parse(await readBody(req))\n const request = parseContainerRunRequest(parsed)\n res.writeHead(200, {\n 'content-type': 'application/x-ndjson',\n 'cache-control': 'no-cache',\n })\n // The DO appends each line to its durable run-log; here we are the producer,\n // so we surface a mid-stream failure as a terminal RUN_ERROR line the DO will\n // append + finish on, never a silently truncated stream.\n try {\n for await (const chunk of runAgent(request, resolveAdapter)) {\n res.write(`${JSON.stringify(chunk)}\\n`)\n }\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n res.write(`${JSON.stringify({ type: EventType.RUN_ERROR, message })}\\n`)\n } finally {\n res.end()\n }\n}\n\n/**\n * Start the in-container harness runner: a `node:http` server with `GET /health`\n * and `POST /run`. Call this as the container's program; the app supplies only\n * `resolveAdapter`.\n */\nexport function runInContainerHarness(\n options: RunInContainerHarnessOptions,\n): ContainerHarnessServer {\n const port =\n options.port ?? Number.parseInt(process.env.RUNNER_PORT ?? '8080', 10)\n\n const server = createServer((req, res) => {\n if (req.method === 'POST' && req.url === '/run') {\n handleRun(req, res, options.resolveAdapter).catch((error: unknown) => {\n // A failure BEFORE we start streaming (e.g. a malformed body) is a 400 —\n // surfaced, never swallowed.\n const message = error instanceof Error ? error.message : String(error)\n if (!res.headersSent) {\n res.writeHead(400, { 'content-type': 'text/plain' })\n }\n res.end(message)\n })\n return\n }\n if (req.method === 'GET' && req.url === '/health') {\n res.writeHead(200).end('ok')\n return\n }\n res.writeHead(404).end('not found')\n })\n\n server.listen(port, () => {\n console.log(`[container-runner] listening on :${port}`)\n })\n\n return { server, port }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmFA,SAAS,SAAS,KAAuC;CACvD,OAAO,IAAI,SAAS,SAAS,WAAW;EACtC,IAAI,OAAO;EACX,IAAI,YAAY,MAAM;EACtB,IAAI,GAAG,SAAS,UAAkB;GAChC,QAAQ;EACV,CAAC;EACD,IAAI,GAAG,aAAa,QAAQ,IAAI,CAAC;EACjC,IAAI,GAAG,SAAS,MAAM;CACxB,CAAC;AACH;;;;;;;;;;AAWA,SAAS,sBACP,WACqB;CACrB,IAAI,UAAU,YAAY,KAAA,GAAW,OAAO;CAC5C,MAAM,QAAQ,OAAO,KAAK,UAAU,OAAO;CAC3C,IAAI,MAAM,WAAW,GAAG,OAAO;CAC/B,MAAM,SAAiC,CAAC;CACxC,KAAK,MAAM,QAAQ,OAAO;EACxB,MAAM,QAAQ,QAAQ,IAAI;EAC1B,IAAI,UAAU,KAAA,KAAa,UAAU,IACnC,MAAM,IAAI,MACR,kCAAkC,KAAK,kCACzC;EAEF,OAAO,QAAQ;CACjB;CACA,OAAO,gBAAgB;EAAE,GAAG;EAAW,SAAS,cAAc,MAAM;CAAE,CAAC;AACzE;;;;;;;AAQA,SAAS,SACP,SACA,gBAC4B;CAC5B,MAAM,UAAU,cAAc;EAE5B,IAAI;EACJ,UAAU,oBAAoB;EAG9B,WAAW,sBAAsB,QAAQ,SAAS;CACpD,CAAC;CAGD,OAAO,KAAK;EACV,UAAU,QAAQ;EAClB,SAAS,eAAe;GACtB,SAAS,QAAQ;GACjB,OAAO,QAAQ;EACjB,CAAC;EACD,UAAU,QAAQ;EAClB,QAAQ;EAGR,OAAO,gBACL,QAAQ,iBACR,uBAAuB,QAAQ,aAAa,QAAQ,aAAa,CACnE;EAEA,YAAY,CAAC,YAAY,OAAO,CAAC;CACnC,CAAC;AACH;;AAGA,eAAe,UACb,KACA,KACA,gBACe;CAEf,MAAM,UAAU,yBADQ,KAAK,MAAM,MAAM,SAAS,GAAG,CACZ,CAAM;CAC/C,IAAI,UAAU,KAAK;EACjB,gBAAgB;EAChB,iBAAiB;CACnB,CAAC;CAID,IAAI;EACF,WAAW,MAAM,SAAS,SAAS,SAAS,cAAc,GACxD,IAAI,MAAM,GAAG,KAAK,UAAU,KAAK,EAAE,GAAG;CAE1C,SAAS,OAAO;EACd,MAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;EACrE,IAAI,MAAM,GAAG,KAAK,UAAU;GAAE,MAAM,UAAU;GAAW;EAAQ,CAAC,EAAE,GAAG;CACzE,UAAU;EACR,IAAI,IAAI;CACV;AACF;;;;;;AAOA,SAAgB,sBACd,SACwB;CACxB,MAAM,OACJ,QAAQ,QAAQ,OAAO,SAAS,QAAQ,IAAI,eAAe,QAAQ,EAAE;CAEvE,MAAM,SAAS,cAAc,KAAK,QAAQ;EACxC,IAAI,IAAI,WAAW,UAAU,IAAI,QAAQ,QAAQ;GAC/C,UAAU,KAAK,KAAK,QAAQ,cAAc,CAAC,CAAC,OAAO,UAAmB;IAGpE,MAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;IACrE,IAAI,CAAC,IAAI,aACP,IAAI,UAAU,KAAK,EAAE,gBAAgB,aAAa,CAAC;IAErD,IAAI,IAAI,OAAO;GACjB,CAAC;GACD;EACF;EACA,IAAI,IAAI,WAAW,SAAS,IAAI,QAAQ,WAAW;GACjD,IAAI,UAAU,GAAG,CAAC,CAAC,IAAI,IAAI;GAC3B;EACF;EACA,IAAI,UAAU,GAAG,CAAC,CAAC,IAAI,WAAW;CACpC,CAAC;CAED,OAAO,OAAO,YAAY;EACxB,QAAQ,IAAI,oCAAoC,MAAM;CACxD,CAAC;CAED,OAAO;EAAE;EAAQ;CAAK;AACxB"}
@@ -1,18 +1,29 @@
1
+ //#region src/web-crypto.ts
2
+ /**
3
+ * Web Crypto helpers for the Workers runtime, where `node:crypto` is
4
+ * unavailable. The sandbox layer's `timingSafeBearerEqual` is node-based; this
5
+ * is the equivalent for a Worker / Durable Object.
6
+ */
7
+ /**
8
+ * Constant-time check of an `Authorization: Bearer <token>` header against the
9
+ * expected token. A length mismatch returns false early (token length is not
10
+ * secret); the equal-length comparison is timing-safe.
11
+ */
1
12
  function timingSafeBearerEqualWeb(header, token) {
2
- if (header === void 0) return false;
3
- const a = new TextEncoder().encode(header);
4
- const b = new TextEncoder().encode(`Bearer ${token}`);
5
- if (a.length !== b.length) return false;
6
- let diff = 0;
7
- for (let i = 0; i < a.length; i += 1) {
8
- const ai = a[i];
9
- const bi = b[i];
10
- if (ai === void 0 || bi === void 0) return false;
11
- diff |= ai ^ bi;
12
- }
13
- return diff === 0;
13
+ if (header === void 0) return false;
14
+ const a = new TextEncoder().encode(header);
15
+ const b = new TextEncoder().encode(`Bearer ${token}`);
16
+ if (a.length !== b.length) return false;
17
+ let diff = 0;
18
+ for (let i = 0; i < a.length; i += 1) {
19
+ const ai = a[i];
20
+ const bi = b[i];
21
+ if (ai === void 0 || bi === void 0) return false;
22
+ diff |= ai ^ bi;
23
+ }
24
+ return diff === 0;
14
25
  }
15
- export {
16
- timingSafeBearerEqualWeb
17
- };
18
- //# sourceMappingURL=web-crypto.js.map
26
+ //#endregion
27
+ export { timingSafeBearerEqualWeb };
28
+
29
+ //# sourceMappingURL=web-crypto.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"web-crypto.js","sources":["../../src/web-crypto.ts"],"sourcesContent":["/**\n * Web Crypto helpers for the Workers runtime, where `node:crypto` is\n * unavailable. The sandbox layer's `timingSafeBearerEqual` is node-based; this\n * is the equivalent for a Worker / Durable Object.\n */\n\n/**\n * Constant-time check of an `Authorization: Bearer <token>` header against the\n * expected token. A length mismatch returns false early (token length is not\n * secret); the equal-length comparison is timing-safe.\n */\nexport function timingSafeBearerEqualWeb(\n header: string | undefined,\n token: string,\n): boolean {\n if (header === undefined) return false\n const a = new TextEncoder().encode(header)\n const b = new TextEncoder().encode(`Bearer ${token}`)\n if (a.length !== b.length) return false\n let diff = 0\n for (let i = 0; i < a.length; i += 1) {\n const ai = a[i]\n const bi = b[i]\n // In-bounds by construction (i < a.length === b.length); the guard satisfies\n // `noUncheckedIndexedAccess` without a non-null assertion and treats any\n // impossible out-of-bounds read as \"not equal\".\n if (ai === undefined || bi === undefined) return false\n diff |= ai ^ bi\n }\n return diff === 0\n}\n"],"names":[],"mappings":"AAWO,SAAS,yBACd,QACA,OACS;AACT,MAAI,WAAW,OAAW,QAAO;AACjC,QAAM,IAAI,IAAI,cAAc,OAAO,MAAM;AACzC,QAAM,IAAI,IAAI,YAAA,EAAc,OAAO,UAAU,KAAK,EAAE;AACpD,MAAI,EAAE,WAAW,EAAE,OAAQ,QAAO;AAClC,MAAI,OAAO;AACX,WAAS,IAAI,GAAG,IAAI,EAAE,QAAQ,KAAK,GAAG;AACpC,UAAM,KAAK,EAAE,CAAC;AACd,UAAM,KAAK,EAAE,CAAC;AAId,QAAI,OAAO,UAAa,OAAO,OAAW,QAAO;AACjD,YAAQ,KAAK;AAAA,EACf;AACA,SAAO,SAAS;AAClB;"}
1
+ {"version":3,"file":"web-crypto.js","names":[],"sources":["../../src/web-crypto.ts"],"sourcesContent":["/**\n * Web Crypto helpers for the Workers runtime, where `node:crypto` is\n * unavailable. The sandbox layer's `timingSafeBearerEqual` is node-based; this\n * is the equivalent for a Worker / Durable Object.\n */\n\n/**\n * Constant-time check of an `Authorization: Bearer <token>` header against the\n * expected token. A length mismatch returns false early (token length is not\n * secret); the equal-length comparison is timing-safe.\n */\nexport function timingSafeBearerEqualWeb(\n header: string | undefined,\n token: string,\n): boolean {\n if (header === undefined) return false\n const a = new TextEncoder().encode(header)\n const b = new TextEncoder().encode(`Bearer ${token}`)\n if (a.length !== b.length) return false\n let diff = 0\n for (let i = 0; i < a.length; i += 1) {\n const ai = a[i]\n const bi = b[i]\n // In-bounds by construction (i < a.length === b.length); the guard satisfies\n // `noUncheckedIndexedAccess` without a non-null assertion and treats any\n // impossible out-of-bounds read as \"not equal\".\n if (ai === undefined || bi === undefined) return false\n diff |= ai ^ bi\n }\n return diff === 0\n}\n"],"mappings":";;;;;;;;;;;AAWA,SAAgB,yBACd,QACA,OACS;CACT,IAAI,WAAW,KAAA,GAAW,OAAO;CACjC,MAAM,IAAI,IAAI,YAAY,CAAC,CAAC,OAAO,MAAM;CACzC,MAAM,IAAI,IAAI,YAAY,CAAC,CAAC,OAAO,UAAU,OAAO;CACpD,IAAI,EAAE,WAAW,EAAE,QAAQ,OAAO;CAClC,IAAI,OAAO;CACX,KAAK,IAAI,IAAI,GAAG,IAAI,EAAE,QAAQ,KAAK,GAAG;EACpC,MAAM,KAAK,EAAE;EACb,MAAM,KAAK,EAAE;EAIb,IAAI,OAAO,KAAA,KAAa,OAAO,KAAA,GAAW,OAAO;EACjD,QAAQ,KAAK;CACf;CACA,OAAO,SAAS;AAClB"}
@@ -1,83 +1,95 @@
1
1
  import { proxyToSandbox } from "@cloudflare/sandbox";
2
+ //#region src/worker.ts
3
+ /**
4
+ * `createSandboxAgentWorker` — the STATELESS trigger + router Worker for the
5
+ * serverless/edge agent run model, factored so an app never writes it by hand.
6
+ *
7
+ * It never drives a run; it forwards to the owning {@link SandboxCoordinator}
8
+ * Durable Object and returns immediately:
9
+ *
10
+ * POST /runs → coordinator.startRun(...) → `202 { runId }` (the
11
+ * Worker invocation ENDS here; it does NOT wait for
12
+ * the agent run — that is the whole point).
13
+ * * (with ?threadId) → forward to coordinator.fetch(request), which the
14
+ * base handles for `/runs/:id`, `/runs/:id/stream`,
15
+ * and a subclass handles for `/_bridge`/`/tool-exec`.
16
+ *
17
+ * The coordinator that owns a thread's runs is resolved by the caller-supplied
18
+ * `resolveCoordinator(env, threadId)` — usually a DO addressed by `threadId`, so
19
+ * every event for a conversation lands in one coordinator and the sandbox is
20
+ * reused per thread. {@link createCloudflareSandboxAgent} supplies a resolver
21
+ * that uses the `RUN_COORDINATOR` binding.
22
+ *
23
+ * NOTE: Workers-runtime code — compiles against the real Cloudflare + TanStack
24
+ * AI types; not runtime-verified in this repo (no Workers runtime here).
25
+ */
26
+ /** Narrow the parsed JSON body without casting (project rule: no `as`). */
2
27
  function parseCreateRunBody(value) {
3
- if (value === null || typeof value !== "object") {
4
- throw new Error("body must be a JSON object");
5
- }
6
- if (!("threadId" in value) || typeof value.threadId !== "string" || value.threadId === "") {
7
- throw new Error("body.threadId must be a non-empty string");
8
- }
9
- if (!("messages" in value) || !Array.isArray(value.messages) || value.messages.length === 0) {
10
- throw new Error("body.messages must be a non-empty array");
11
- }
12
- for (const message of value.messages) {
13
- if (message === null || typeof message !== "object") {
14
- throw new Error("each message must be an object");
15
- }
16
- }
17
- let metadata;
18
- if ("metadata" in value && value.metadata !== void 0) {
19
- if (!isRecord(value.metadata)) {
20
- throw new Error("body.metadata must be an object");
21
- }
22
- metadata = value.metadata;
23
- }
24
- return { threadId: value.threadId, messages: value.messages, metadata };
28
+ if (value === null || typeof value !== "object") throw new Error("body must be a JSON object");
29
+ if (!("threadId" in value) || typeof value.threadId !== "string" || value.threadId === "") throw new Error("body.threadId must be a non-empty string");
30
+ if (!("messages" in value) || !Array.isArray(value.messages) || value.messages.length === 0) throw new Error("body.messages must be a non-empty array");
31
+ for (const message of value.messages) if (message === null || typeof message !== "object") throw new Error("each message must be an object");
32
+ let metadata;
33
+ if ("metadata" in value && value.metadata !== void 0) {
34
+ if (!isRecord(value.metadata)) throw new Error("body.metadata must be an object");
35
+ metadata = value.metadata;
36
+ }
37
+ return {
38
+ threadId: value.threadId,
39
+ messages: value.messages,
40
+ metadata
41
+ };
25
42
  }
43
+ /** A JSON object — narrows `unknown` to `Record<string, unknown>` cast-free. */
26
44
  function isRecord(value) {
27
- return value !== null && typeof value === "object" && !Array.isArray(value);
45
+ return value !== null && typeof value === "object" && !Array.isArray(value);
28
46
  }
47
+ /** Narrow an env to one with a Sandbox binding (so previews can be proxied). */
29
48
  function hasSandboxBinding(env) {
30
- return env !== null && typeof env === "object" && "Sandbox" in env && env.Sandbox !== void 0;
49
+ return env !== null && typeof env === "object" && "Sandbox" in env && env.Sandbox !== void 0;
31
50
  }
32
51
  function jsonResponse(body, status = 200) {
33
- return new Response(JSON.stringify(body), {
34
- status,
35
- headers: { "content-type": "application/json" }
36
- });
52
+ return new Response(JSON.stringify(body), {
53
+ status,
54
+ headers: { "content-type": "application/json" }
55
+ });
37
56
  }
57
+ /**
58
+ * Build the Worker fetch handler. `resolveCoordinator` maps `(env, threadId)` to
59
+ * the DO stub that owns that thread's runs.
60
+ */
38
61
  function createSandboxAgentWorker(resolveCoordinator) {
39
- return {
40
- async fetch(request, env) {
41
- if (hasSandboxBinding(env)) {
42
- const proxied = await proxyToSandbox(request, env);
43
- if (proxied) return proxied;
44
- }
45
- const url = new URL(request.url);
46
- const parts = url.pathname.split("/").filter(Boolean);
47
- if (request.method === "POST" && parts.length === 1 && parts[0] === "runs") {
48
- let body;
49
- try {
50
- body = parseCreateRunBody(await request.json());
51
- } catch (error) {
52
- const message = error instanceof Error ? error.message : String(error);
53
- return jsonResponse({ error: message }, 400);
54
- }
55
- const runId = crypto.randomUUID();
56
- const input = {
57
- runId,
58
- threadId: body.threadId,
59
- messages: body.messages,
60
- // The host this request arrived on. Coordinators derive the container's
61
- // callback hosts from it when `PUBLIC_HOSTNAME`/`PREVIEW_HOSTNAME` are
62
- // unset. On Cloudflare this is safe to trust — the edge only routes
63
- // hostnames you own to your Worker. See `resolveBridgeOrigin` /
64
- // `resolvePreviewHost`.
65
- publicHost: url.host,
66
- // Forwarded verbatim to the app's resolvers (e.g. the chosen harness).
67
- metadata: body.metadata
68
- };
69
- await resolveCoordinator(env, body.threadId).startRun(input);
70
- return jsonResponse({ runId }, 202);
71
- }
72
- const threadId = url.searchParams.get("threadId");
73
- if (threadId !== null) {
74
- return resolveCoordinator(env, threadId).fetch(request);
75
- }
76
- return jsonResponse({ error: "threadId query param required" }, 400);
77
- }
78
- };
62
+ return { async fetch(request, env) {
63
+ if (hasSandboxBinding(env)) {
64
+ const proxied = await proxyToSandbox(request, env);
65
+ if (proxied) return proxied;
66
+ }
67
+ const url = new URL(request.url);
68
+ const parts = url.pathname.split("/").filter(Boolean);
69
+ if (request.method === "POST" && parts.length === 1 && parts[0] === "runs") {
70
+ let body;
71
+ try {
72
+ body = parseCreateRunBody(await request.json());
73
+ } catch (error) {
74
+ return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400);
75
+ }
76
+ const runId = crypto.randomUUID();
77
+ const input = {
78
+ runId,
79
+ threadId: body.threadId,
80
+ messages: body.messages,
81
+ publicHost: url.host,
82
+ metadata: body.metadata
83
+ };
84
+ await resolveCoordinator(env, body.threadId).startRun(input);
85
+ return jsonResponse({ runId }, 202);
86
+ }
87
+ const threadId = url.searchParams.get("threadId");
88
+ if (threadId !== null) return resolveCoordinator(env, threadId).fetch(request);
89
+ return jsonResponse({ error: "threadId query param required" }, 400);
90
+ } };
79
91
  }
80
- export {
81
- createSandboxAgentWorker
82
- };
83
- //# sourceMappingURL=worker.js.map
92
+ //#endregion
93
+ export { createSandboxAgentWorker };
94
+
95
+ //# sourceMappingURL=worker.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"worker.js","sources":["../../src/worker.ts"],"sourcesContent":["/**\n * `createSandboxAgentWorker` — the STATELESS trigger + router Worker for the\n * serverless/edge agent run model, factored so an app never writes it by hand.\n *\n * It never drives a run; it forwards to the owning {@link SandboxCoordinator}\n * Durable Object and returns immediately:\n *\n * POST /runs → coordinator.startRun(...) → `202 { runId }` (the\n * Worker invocation ENDS here; it does NOT wait for\n * the agent run — that is the whole point).\n * * (with ?threadId) → forward to coordinator.fetch(request), which the\n * base handles for `/runs/:id`, `/runs/:id/stream`,\n * and a subclass handles for `/_bridge`/`/tool-exec`.\n *\n * The coordinator that owns a thread's runs is resolved by the caller-supplied\n * `resolveCoordinator(env, threadId)` — usually a DO addressed by `threadId`, so\n * every event for a conversation lands in one coordinator and the sandbox is\n * reused per thread. {@link createCloudflareSandboxAgent} supplies a resolver\n * that uses the `RUN_COORDINATOR` binding.\n *\n * NOTE: Workers-runtime code — compiles against the real Cloudflare + TanStack\n * AI types; not runtime-verified in this repo (no Workers runtime here).\n */\nimport { proxyToSandbox } from '@cloudflare/sandbox'\nimport type { SandboxCoordinator, StartRunInput } from './coordinator'\nimport type { ModelMessage } from '@tanstack/ai'\nimport type { Sandbox } from '@cloudflare/sandbox'\n\n/** Resolve the coordinator DO that owns a thread's runs. */\nexport type ResolveCoordinator<TEnv> = (\n env: TEnv,\n threadId: string,\n) => DurableObjectStub<SandboxCoordinator<TEnv>>\n\n/** Body of `POST /runs`. */\ninterface CreateRunBody {\n threadId: string\n messages: Array<ModelMessage>\n /** Forwarded verbatim to the app's resolvers — see {@link StartRunInput.metadata}. */\n metadata?: Record<string, unknown>\n}\n\n/** Narrow the parsed JSON body without casting (project rule: no `as`). */\nfunction parseCreateRunBody(value: unknown): CreateRunBody {\n if (value === null || typeof value !== 'object') {\n throw new Error('body must be a JSON object')\n }\n if (\n !('threadId' in value) ||\n typeof value.threadId !== 'string' ||\n value.threadId === ''\n ) {\n throw new Error('body.threadId must be a non-empty string')\n }\n if (\n !('messages' in value) ||\n !Array.isArray(value.messages) ||\n value.messages.length === 0\n ) {\n throw new Error('body.messages must be a non-empty array')\n }\n // The chat engine validates message shape; we only assert it is an array of\n // objects here so the request fails fast with a clear 400 on garbage input.\n for (const message of value.messages) {\n if (message === null || typeof message !== 'object') {\n throw new Error('each message must be an object')\n }\n }\n // Optional free-form pass-through (app-validated). Must be an object if present.\n let metadata: Record<string, unknown> | undefined\n if ('metadata' in value && value.metadata !== undefined) {\n if (!isRecord(value.metadata)) {\n throw new Error('body.metadata must be an object')\n }\n metadata = value.metadata\n }\n return { threadId: value.threadId, messages: value.messages, metadata }\n}\n\n/** A JSON object — narrows `unknown` to `Record<string, unknown>` cast-free. */\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return value !== null && typeof value === 'object' && !Array.isArray(value)\n}\n\n/** A Worker env that carries the Sandbox DO namespace `proxyToSandbox` needs. */\ninterface SandboxBindingEnv {\n Sandbox: DurableObjectNamespace<Sandbox>\n}\n\n/** Narrow an env to one with a Sandbox binding (so previews can be proxied). */\nfunction hasSandboxBinding<TEnv>(env: TEnv): env is TEnv & SandboxBindingEnv {\n return (\n env !== null &&\n typeof env === 'object' &&\n 'Sandbox' in env &&\n env.Sandbox !== undefined\n )\n}\n\nfunction jsonResponse(body: unknown, status = 200): Response {\n return new Response(JSON.stringify(body), {\n status,\n headers: { 'content-type': 'application/json' },\n })\n}\n\n/**\n * Build the Worker fetch handler. `resolveCoordinator` maps `(env, threadId)` to\n * the DO stub that owns that thread's runs.\n */\nexport function createSandboxAgentWorker<TEnv>(\n resolveCoordinator: ResolveCoordinator<TEnv>,\n): ExportedHandler<TEnv> {\n return {\n async fetch(request: Request, env: TEnv): Promise<Response> {\n // Preview-port traffic for exposed sandbox ports is routed by hostname; let\n // the sandbox runtime claim those requests before our app routes run. Only\n // possible when the env actually carries the Sandbox binding.\n if (hasSandboxBinding(env)) {\n const proxied = await proxyToSandbox(request, env)\n if (proxied) return proxied\n }\n\n const url = new URL(request.url)\n const parts = url.pathname.split('/').filter(Boolean)\n\n // POST /runs — trigger a run, return 202 immediately.\n if (\n request.method === 'POST' &&\n parts.length === 1 &&\n parts[0] === 'runs'\n ) {\n let body: CreateRunBody\n try {\n body = parseCreateRunBody(await request.json())\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n return jsonResponse({ error: message }, 400)\n }\n const runId = crypto.randomUUID()\n const input: StartRunInput = {\n runId,\n threadId: body.threadId,\n messages: body.messages,\n // The host this request arrived on. Coordinators derive the container's\n // callback hosts from it when `PUBLIC_HOSTNAME`/`PREVIEW_HOSTNAME` are\n // unset. On Cloudflare this is safe to trust — the edge only routes\n // hostnames you own to your Worker. See `resolveBridgeOrigin` /\n // `resolvePreviewHost`.\n publicHost: url.host,\n // Forwarded verbatim to the app's resolvers (e.g. the chosen harness).\n metadata: body.metadata,\n }\n // RPC into the coordinator. `startRun` registers the run and returns\n // immediately under `ctx.waitUntil`; we do NOT await the agent loop.\n await resolveCoordinator(env, body.threadId).startRun(input)\n return jsonResponse({ runId }, 202)\n }\n\n // Everything else for a run needs the owning coordinator, addressed by the\n // `threadId` query the Worker carries so it never reads run state itself.\n // The base coordinator routes `/runs/:id` + `/runs/:id/stream`, and a\n // subclass routes `/_bridge/:runId` (DO-drives) or `/tool-exec/:runId`\n // (co-located) — all reachable through one forward.\n const threadId = url.searchParams.get('threadId')\n if (threadId !== null) {\n return resolveCoordinator(env, threadId).fetch(request)\n }\n\n return jsonResponse({ error: 'threadId query param required' }, 400)\n },\n }\n}\n"],"names":[],"mappings":";AA2CA,SAAS,mBAAmB,OAA+B;AACzD,MAAI,UAAU,QAAQ,OAAO,UAAU,UAAU;AAC/C,UAAM,IAAI,MAAM,4BAA4B;AAAA,EAC9C;AACA,MACE,EAAE,cAAc,UAChB,OAAO,MAAM,aAAa,YAC1B,MAAM,aAAa,IACnB;AACA,UAAM,IAAI,MAAM,0CAA0C;AAAA,EAC5D;AACA,MACE,EAAE,cAAc,UAChB,CAAC,MAAM,QAAQ,MAAM,QAAQ,KAC7B,MAAM,SAAS,WAAW,GAC1B;AACA,UAAM,IAAI,MAAM,yCAAyC;AAAA,EAC3D;AAGA,aAAW,WAAW,MAAM,UAAU;AACpC,QAAI,YAAY,QAAQ,OAAO,YAAY,UAAU;AACnD,YAAM,IAAI,MAAM,gCAAgC;AAAA,IAClD;AAAA,EACF;AAEA,MAAI;AACJ,MAAI,cAAc,SAAS,MAAM,aAAa,QAAW;AACvD,QAAI,CAAC,SAAS,MAAM,QAAQ,GAAG;AAC7B,YAAM,IAAI,MAAM,iCAAiC;AAAA,IACnD;AACA,eAAW,MAAM;AAAA,EACnB;AACA,SAAO,EAAE,UAAU,MAAM,UAAU,UAAU,MAAM,UAAU,SAAA;AAC/D;AAGA,SAAS,SAAS,OAAkD;AAClE,SAAO,UAAU,QAAQ,OAAO,UAAU,YAAY,CAAC,MAAM,QAAQ,KAAK;AAC5E;AAQA,SAAS,kBAAwB,KAA4C;AAC3E,SACE,QAAQ,QACR,OAAO,QAAQ,YACf,aAAa,OACb,IAAI,YAAY;AAEpB;AAEA,SAAS,aAAa,MAAe,SAAS,KAAe;AAC3D,SAAO,IAAI,SAAS,KAAK,UAAU,IAAI,GAAG;AAAA,IACxC;AAAA,IACA,SAAS,EAAE,gBAAgB,mBAAA;AAAA,EAAmB,CAC/C;AACH;AAMO,SAAS,yBACd,oBACuB;AACvB,SAAO;AAAA,IACL,MAAM,MAAM,SAAkB,KAA8B;AAI1D,UAAI,kBAAkB,GAAG,GAAG;AAC1B,cAAM,UAAU,MAAM,eAAe,SAAS,GAAG;AACjD,YAAI,QAAS,QAAO;AAAA,MACtB;AAEA,YAAM,MAAM,IAAI,IAAI,QAAQ,GAAG;AAC/B,YAAM,QAAQ,IAAI,SAAS,MAAM,GAAG,EAAE,OAAO,OAAO;AAGpD,UACE,QAAQ,WAAW,UACnB,MAAM,WAAW,KACjB,MAAM,CAAC,MAAM,QACb;AACA,YAAI;AACJ,YAAI;AACF,iBAAO,mBAAmB,MAAM,QAAQ,KAAA,CAAM;AAAA,QAChD,SAAS,OAAO;AACd,gBAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AACrE,iBAAO,aAAa,EAAE,OAAO,QAAA,GAAW,GAAG;AAAA,QAC7C;AACA,cAAM,QAAQ,OAAO,WAAA;AACrB,cAAM,QAAuB;AAAA,UAC3B;AAAA,UACA,UAAU,KAAK;AAAA,UACf,UAAU,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,UAMf,YAAY,IAAI;AAAA;AAAA,UAEhB,UAAU,KAAK;AAAA,QAAA;AAIjB,cAAM,mBAAmB,KAAK,KAAK,QAAQ,EAAE,SAAS,KAAK;AAC3D,eAAO,aAAa,EAAE,MAAA,GAAS,GAAG;AAAA,MACpC;AAOA,YAAM,WAAW,IAAI,aAAa,IAAI,UAAU;AAChD,UAAI,aAAa,MAAM;AACrB,eAAO,mBAAmB,KAAK,QAAQ,EAAE,MAAM,OAAO;AAAA,MACxD;AAEA,aAAO,aAAa,EAAE,OAAO,gCAAA,GAAmC,GAAG;AAAA,IACrE;AAAA,EAAA;AAEJ;"}
1
+ {"version":3,"file":"worker.js","names":[],"sources":["../../src/worker.ts"],"sourcesContent":["/**\n * `createSandboxAgentWorker` — the STATELESS trigger + router Worker for the\n * serverless/edge agent run model, factored so an app never writes it by hand.\n *\n * It never drives a run; it forwards to the owning {@link SandboxCoordinator}\n * Durable Object and returns immediately:\n *\n * POST /runs → coordinator.startRun(...) → `202 { runId }` (the\n * Worker invocation ENDS here; it does NOT wait for\n * the agent run — that is the whole point).\n * * (with ?threadId) → forward to coordinator.fetch(request), which the\n * base handles for `/runs/:id`, `/runs/:id/stream`,\n * and a subclass handles for `/_bridge`/`/tool-exec`.\n *\n * The coordinator that owns a thread's runs is resolved by the caller-supplied\n * `resolveCoordinator(env, threadId)` — usually a DO addressed by `threadId`, so\n * every event for a conversation lands in one coordinator and the sandbox is\n * reused per thread. {@link createCloudflareSandboxAgent} supplies a resolver\n * that uses the `RUN_COORDINATOR` binding.\n *\n * NOTE: Workers-runtime code — compiles against the real Cloudflare + TanStack\n * AI types; not runtime-verified in this repo (no Workers runtime here).\n */\nimport { proxyToSandbox } from '@cloudflare/sandbox'\nimport type { SandboxCoordinator, StartRunInput } from './coordinator'\nimport type { ModelMessage } from '@tanstack/ai'\nimport type { Sandbox } from '@cloudflare/sandbox'\n\n/** Resolve the coordinator DO that owns a thread's runs. */\nexport type ResolveCoordinator<TEnv> = (\n env: TEnv,\n threadId: string,\n) => DurableObjectStub<SandboxCoordinator<TEnv>>\n\n/** Body of `POST /runs`. */\ninterface CreateRunBody {\n threadId: string\n messages: Array<ModelMessage>\n /** Forwarded verbatim to the app's resolvers — see {@link StartRunInput.metadata}. */\n metadata?: Record<string, unknown>\n}\n\n/** Narrow the parsed JSON body without casting (project rule: no `as`). */\nfunction parseCreateRunBody(value: unknown): CreateRunBody {\n if (value === null || typeof value !== 'object') {\n throw new Error('body must be a JSON object')\n }\n if (\n !('threadId' in value) ||\n typeof value.threadId !== 'string' ||\n value.threadId === ''\n ) {\n throw new Error('body.threadId must be a non-empty string')\n }\n if (\n !('messages' in value) ||\n !Array.isArray(value.messages) ||\n value.messages.length === 0\n ) {\n throw new Error('body.messages must be a non-empty array')\n }\n // The chat engine validates message shape; we only assert it is an array of\n // objects here so the request fails fast with a clear 400 on garbage input.\n for (const message of value.messages) {\n if (message === null || typeof message !== 'object') {\n throw new Error('each message must be an object')\n }\n }\n // Optional free-form pass-through (app-validated). Must be an object if present.\n let metadata: Record<string, unknown> | undefined\n if ('metadata' in value && value.metadata !== undefined) {\n if (!isRecord(value.metadata)) {\n throw new Error('body.metadata must be an object')\n }\n metadata = value.metadata\n }\n return { threadId: value.threadId, messages: value.messages, metadata }\n}\n\n/** A JSON object — narrows `unknown` to `Record<string, unknown>` cast-free. */\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return value !== null && typeof value === 'object' && !Array.isArray(value)\n}\n\n/** A Worker env that carries the Sandbox DO namespace `proxyToSandbox` needs. */\ninterface SandboxBindingEnv {\n Sandbox: DurableObjectNamespace<Sandbox>\n}\n\n/** Narrow an env to one with a Sandbox binding (so previews can be proxied). */\nfunction hasSandboxBinding<TEnv>(env: TEnv): env is TEnv & SandboxBindingEnv {\n return (\n env !== null &&\n typeof env === 'object' &&\n 'Sandbox' in env &&\n env.Sandbox !== undefined\n )\n}\n\nfunction jsonResponse(body: unknown, status = 200): Response {\n return new Response(JSON.stringify(body), {\n status,\n headers: { 'content-type': 'application/json' },\n })\n}\n\n/**\n * Build the Worker fetch handler. `resolveCoordinator` maps `(env, threadId)` to\n * the DO stub that owns that thread's runs.\n */\nexport function createSandboxAgentWorker<TEnv>(\n resolveCoordinator: ResolveCoordinator<TEnv>,\n): ExportedHandler<TEnv> {\n return {\n async fetch(request: Request, env: TEnv): Promise<Response> {\n // Preview-port traffic for exposed sandbox ports is routed by hostname; let\n // the sandbox runtime claim those requests before our app routes run. Only\n // possible when the env actually carries the Sandbox binding.\n if (hasSandboxBinding(env)) {\n const proxied = await proxyToSandbox(request, env)\n if (proxied) return proxied\n }\n\n const url = new URL(request.url)\n const parts = url.pathname.split('/').filter(Boolean)\n\n // POST /runs — trigger a run, return 202 immediately.\n if (\n request.method === 'POST' &&\n parts.length === 1 &&\n parts[0] === 'runs'\n ) {\n let body: CreateRunBody\n try {\n body = parseCreateRunBody(await request.json())\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n return jsonResponse({ error: message }, 400)\n }\n const runId = crypto.randomUUID()\n const input: StartRunInput = {\n runId,\n threadId: body.threadId,\n messages: body.messages,\n // The host this request arrived on. Coordinators derive the container's\n // callback hosts from it when `PUBLIC_HOSTNAME`/`PREVIEW_HOSTNAME` are\n // unset. On Cloudflare this is safe to trust — the edge only routes\n // hostnames you own to your Worker. See `resolveBridgeOrigin` /\n // `resolvePreviewHost`.\n publicHost: url.host,\n // Forwarded verbatim to the app's resolvers (e.g. the chosen harness).\n metadata: body.metadata,\n }\n // RPC into the coordinator. `startRun` registers the run and returns\n // immediately under `ctx.waitUntil`; we do NOT await the agent loop.\n await resolveCoordinator(env, body.threadId).startRun(input)\n return jsonResponse({ runId }, 202)\n }\n\n // Everything else for a run needs the owning coordinator, addressed by the\n // `threadId` query the Worker carries so it never reads run state itself.\n // The base coordinator routes `/runs/:id` + `/runs/:id/stream`, and a\n // subclass routes `/_bridge/:runId` (DO-drives) or `/tool-exec/:runId`\n // (co-located) — all reachable through one forward.\n const threadId = url.searchParams.get('threadId')\n if (threadId !== null) {\n return resolveCoordinator(env, threadId).fetch(request)\n }\n\n return jsonResponse({ error: 'threadId query param required' }, 400)\n },\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AA2CA,SAAS,mBAAmB,OAA+B;CACzD,IAAI,UAAU,QAAQ,OAAO,UAAU,UACrC,MAAM,IAAI,MAAM,4BAA4B;CAE9C,IACE,EAAE,cAAc,UAChB,OAAO,MAAM,aAAa,YAC1B,MAAM,aAAa,IAEnB,MAAM,IAAI,MAAM,0CAA0C;CAE5D,IACE,EAAE,cAAc,UAChB,CAAC,MAAM,QAAQ,MAAM,QAAQ,KAC7B,MAAM,SAAS,WAAW,GAE1B,MAAM,IAAI,MAAM,yCAAyC;CAI3D,KAAK,MAAM,WAAW,MAAM,UAC1B,IAAI,YAAY,QAAQ,OAAO,YAAY,UACzC,MAAM,IAAI,MAAM,gCAAgC;CAIpD,IAAI;CACJ,IAAI,cAAc,SAAS,MAAM,aAAa,KAAA,GAAW;EACvD,IAAI,CAAC,SAAS,MAAM,QAAQ,GAC1B,MAAM,IAAI,MAAM,iCAAiC;EAEnD,WAAW,MAAM;CACnB;CACA,OAAO;EAAE,UAAU,MAAM;EAAU,UAAU,MAAM;EAAU;CAAS;AACxE;;AAGA,SAAS,SAAS,OAAkD;CAClE,OAAO,UAAU,QAAQ,OAAO,UAAU,YAAY,CAAC,MAAM,QAAQ,KAAK;AAC5E;;AAQA,SAAS,kBAAwB,KAA4C;CAC3E,OACE,QAAQ,QACR,OAAO,QAAQ,YACf,aAAa,OACb,IAAI,YAAY,KAAA;AAEpB;AAEA,SAAS,aAAa,MAAe,SAAS,KAAe;CAC3D,OAAO,IAAI,SAAS,KAAK,UAAU,IAAI,GAAG;EACxC;EACA,SAAS,EAAE,gBAAgB,mBAAmB;CAChD,CAAC;AACH;;;;;AAMA,SAAgB,yBACd,oBACuB;CACvB,OAAO,EACL,MAAM,MAAM,SAAkB,KAA8B;EAI1D,IAAI,kBAAkB,GAAG,GAAG;GAC1B,MAAM,UAAU,MAAM,eAAe,SAAS,GAAG;GACjD,IAAI,SAAS,OAAO;EACtB;EAEA,MAAM,MAAM,IAAI,IAAI,QAAQ,GAAG;EAC/B,MAAM,QAAQ,IAAI,SAAS,MAAM,GAAG,CAAC,CAAC,OAAO,OAAO;EAGpD,IACE,QAAQ,WAAW,UACnB,MAAM,WAAW,KACjB,MAAM,OAAO,QACb;GACA,IAAI;GACJ,IAAI;IACF,OAAO,mBAAmB,MAAM,QAAQ,KAAK,CAAC;GAChD,SAAS,OAAO;IAEd,OAAO,aAAa,EAAE,OADN,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,EAChC,GAAG,GAAG;GAC7C;GACA,MAAM,QAAQ,OAAO,WAAW;GAChC,MAAM,QAAuB;IAC3B;IACA,UAAU,KAAK;IACf,UAAU,KAAK;IAMf,YAAY,IAAI;IAEhB,UAAU,KAAK;GACjB;GAGA,MAAM,mBAAmB,KAAK,KAAK,QAAQ,CAAC,CAAC,SAAS,KAAK;GAC3D,OAAO,aAAa,EAAE,MAAM,GAAG,GAAG;EACpC;EAOA,MAAM,WAAW,IAAI,aAAa,IAAI,UAAU;EAChD,IAAI,aAAa,MACf,OAAO,mBAAmB,KAAK,QAAQ,CAAC,CAAC,MAAM,OAAO;EAGxD,OAAO,aAAa,EAAE,OAAO,gCAAgC,GAAG,GAAG;CACrE,EACF;AACF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/ai-sandbox-cloudflare",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "description": "Cloudflare sandbox provider for TanStack AI — run harness adapters inside Cloudflare Containers (edge) through the uniform SandboxHandle.",
5
5
  "author": "",
6
6
  "license": "MIT",
@@ -45,25 +45,25 @@
45
45
  },
46
46
  "peerDependencies": {
47
47
  "zod": "^4.0.0",
48
- "@tanstack/ai": "^0.42.0",
49
- "@tanstack/ai-sandbox": "^0.2.4",
50
- "@tanstack/ai-sandbox-local-process": "^0.2.0"
48
+ "@tanstack/ai": "^0.43.0",
49
+ "@tanstack/ai-sandbox": "^0.3.0",
50
+ "@tanstack/ai-sandbox-local-process": "^0.2.1"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@cloudflare/workers-types": "^4.20260317.1",
54
54
  "@types/node": "^24.10.1",
55
55
  "@vitest/coverage-v8": "4.0.14",
56
56
  "zod": "^4.2.0",
57
- "@tanstack/ai": "0.42.0",
58
- "@tanstack/ai-sandbox": "0.2.4",
59
- "@tanstack/ai-sandbox-local-process": "0.2.0"
57
+ "@tanstack/ai": "0.43.0",
58
+ "@tanstack/ai-sandbox": "0.3.0",
59
+ "@tanstack/ai-sandbox-local-process": "0.2.1"
60
60
  },
61
61
  "scripts": {
62
62
  "build": "vite build",
63
63
  "clean": "premove ./build ./dist",
64
- "lint:fix": "eslint ./src --fix",
64
+ "lint:fix": "oxlint src --type-aware --fix",
65
65
  "test:build": "publint --strict",
66
- "test:eslint": "eslint ./src",
66
+ "test:oxlint": "oxlint src --type-aware",
67
67
  "test:lib": "vitest",
68
68
  "test:lib:dev": "pnpm test:lib --watch",
69
69
  "test:types": "tsc"
package/src/agent.ts CHANGED
@@ -64,3 +64,29 @@ export type { ResolveCoordinator } from './worker'
64
64
  // The durable run-log + the Web Crypto bearer helper (for direct composition).
65
65
  export { DurableObjectRunEventLog } from './run-log-do'
66
66
  export { timingSafeBearerEqualWeb } from './web-crypto'
67
+
68
+ // The run event-log surface, for apps bringing their own backend.
69
+ // `DurableObjectRunEventLog` (above) is this log's DO-storage-backed mirror;
70
+ // `InMemoryRunEventLog` is the single-process reference implementation.
71
+ //
72
+ // The vocabulary is core's: statuses, `RunError`, and `isTerminalRunStatus`
73
+ // come from `@tanstack/ai` (the pre-1.0 `Legacy*`-prefixed types are gone —
74
+ // see the CONVERGED VOCABULARY note in `./run-log` for the storage
75
+ // migration). `RunLogRecord` is core's `RunRecord` plus the log's own
76
+ // `lastSeq` cursor and `updatedAt` activity clock.
77
+ export { InMemoryRunEventLog, migrateStoredRunRecord } from './run-log'
78
+ export type {
79
+ RunEventLog,
80
+ RunEvent,
81
+ RunEventLogReadOptions,
82
+ RunLogRecord,
83
+ RunRecordPatch,
84
+ } from './run-log'
85
+
86
+ // The portable seams the coordinator itself is built on: a `RunEventLog` as
87
+ // core's `RunStore` (`runLogStore`) and one of its runs as core's
88
+ // `StreamDurability` (`runLogStream`). Apps composing directly bind core's
89
+ // `RunController` from `@tanstack/ai-sandbox` with these, exactly as
90
+ // `SandboxCoordinator` does.
91
+ export { runLogStore, runLogStream } from './durability'
92
+ export type { RunLogStreamInit } from './durability'
@@ -22,11 +22,18 @@
22
22
  * runtime-verified in this repo.
23
23
  */
24
24
  import { DurableObject } from 'cloudflare:workers'
25
- import { EventType } from '@tanstack/ai'
26
- import { RunController, isTerminalRunStatus } from '@tanstack/ai-sandbox'
25
+ import { EventType, isTerminalRunStatus } from '@tanstack/ai'
26
+ // The PORTABLE run driver: this coordinator is a platform binding of core's
27
+ // `RunController`, not a driver of its own. The DO run log backs both of the
28
+ // driver's seams through the adapters in './durability' — `runLogStore` for
29
+ // the lifecycle record, `runLogStream` for the per-run event log — and the
30
+ // vocabulary is core's throughout (historical `done`/`error` records are
31
+ // migrated on read; see './run-log').
32
+ import { RunController } from '@tanstack/ai-sandbox'
33
+ import { runLogStore, runLogStream } from './durability'
27
34
  import { DurableObjectRunEventLog } from './run-log-do'
28
35
  import type { ModelMessage, StreamChunk } from '@tanstack/ai'
29
- import type { RunRecord } from '@tanstack/ai-sandbox'
36
+ import type { RunLogRecord } from './run-log'
30
37
 
31
38
  /** Re-arm window for the liveness watchdog while a run is in flight (ms). */
32
39
  const WATCHDOG_MS = 30_000
@@ -103,7 +110,10 @@ export abstract class SandboxCoordinator<
103
110
  constructor(ctx: DurableObjectState, env: TEnv) {
104
111
  super(ctx, env)
105
112
  this.log = new DurableObjectRunEventLog(ctx.storage)
106
- this.controller = new RunController(this.log)
113
+ this.controller = new RunController({
114
+ runs: runLogStore(this.log),
115
+ durability: (runId) => runLogStream(this.log, { runId }),
116
+ })
107
117
  }
108
118
 
109
119
  // ===========================================================================
@@ -162,7 +172,7 @@ export abstract class SandboxCoordinator<
162
172
  type: EventType.RUN_ERROR,
163
173
  message,
164
174
  })
165
- await this.log.finish(input.runId, 'error', { message })
175
+ await this.log.finish(input.runId, 'failed', { message })
166
176
  this.onRunSettled(input.runId)
167
177
  return { runId: input.runId }
168
178
  }
@@ -172,15 +182,21 @@ export abstract class SandboxCoordinator<
172
182
  threadId: input.threadId,
173
183
  stream,
174
184
  })
175
- // Keep the instance alive until the run is terminal; `pipeToRunLog` never
176
- // rejects (failures land in the log), so no `.catch` is needed.
177
- this.ctx.waitUntil(done.finally(() => this.onRunSettled(input.runId)))
185
+ // Keep the instance alive until the run is terminal. `pipeToRunLog` never
186
+ // rejects (failures land in the log), but this must not DEPEND on that:
187
+ // `.finally` adopts a rejection, which would hand `waitUntil` a rejected
188
+ // promise. Two-argument `then` settles fulfilled either way while still
189
+ // running the settle hook.
190
+ const settle = (): void => this.onRunSettled(input.runId)
191
+ this.ctx.waitUntil(done.then(settle, settle))
178
192
  await this.ctx.storage.setAlarm(Date.now() + WATCHDOG_MS)
179
193
  return { runId: input.runId }
180
194
  }
181
195
 
182
- async status(runId: string): Promise<RunRecord | null> {
183
- return this.controller.status(runId)
196
+ async status(runId: string): Promise<RunLogRecord | null> {
197
+ // Straight off the log (not `controller.status`) so the answer keeps the
198
+ // log-level fields (`lastSeq`) a reconnecting client resumes from.
199
+ return this.log.get(runId)
184
200
  }
185
201
 
186
202
  // ===========================================================================
@@ -244,7 +260,11 @@ export abstract class SandboxCoordinator<
244
260
  this.pumping.add(socket)
245
261
  const done = (async () => {
246
262
  try {
247
- for await (const event of this.controller.attach(runId, { fromSeq })) {
263
+ // The tail reads the log directly by seq — the client wire protocol
264
+ // (`?lastSeq`, `{seq, chunk}` frames) is seq-based, and `log.read` is
265
+ // the seq-cursor surface. Core's `controller.attach` serves consumers
266
+ // that speak opaque `StreamDurability` offsets instead.
267
+ for await (const event of this.log.read(runId, { fromSeq })) {
248
268
  socket.send(JSON.stringify(event))
249
269
  socket.serializeAttachment({
250
270
  runId,
@@ -301,10 +321,12 @@ export abstract class SandboxCoordinator<
301
321
 
302
322
  override async alarm(): Promise<void> {
303
323
  try {
304
- const runs = await this.ctx.storage.list<RunRecord>({ prefix: 'rec:' })
324
+ // Through the log (not a raw `rec:` list) so legacy records are migrated
325
+ // on the way out — the storage layout is the log's private concern.
326
+ const runs = await this.log.list()
305
327
  const now = Date.now()
306
328
  let active = false
307
- for (const record of runs.values()) {
329
+ for (const record of runs) {
308
330
  if (isTerminalRunStatus(record.status)) continue
309
331
  if (now - record.updatedAt > WATCHDOG_STALL_MS) {
310
332
  // No progress for too long — the driver is presumed dead. Fail the run
@@ -332,7 +354,7 @@ export abstract class SandboxCoordinator<
332
354
  } catch {
333
355
  // The run may have just reached terminal concurrently; finish is idempotent.
334
356
  }
335
- await this.log.finish(runId, 'error', { message })
357
+ await this.log.finish(runId, 'failed', { message })
336
358
  this.onRunSettled(runId)
337
359
  }
338
360
  }