@tanstack/ai-sandbox-cloudflare 0.3.14 → 0.4.2

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.
@@ -1,6 +1,6 @@
1
1
  import { cloudflareSandbox } from "./provider.js";
2
2
  import { resolvePreviewHost } from "./public-host.js";
3
- import "./coordinator.js";
3
+ import { normalizeStallTimeoutMs } from "./coordinator.js";
4
4
  import { ChatSandboxCoordinator } from "./chat-coordinator.js";
5
5
  import { ContainerSandboxCoordinator } from "./container-coordinator.js";
6
6
  import { createSandboxAgentWorker } from "./worker.js";
@@ -65,10 +65,14 @@ function resolveCoordinator(env, threadId) {
65
65
  return env.RUN_COORDINATOR.get(env.RUN_COORDINATOR.idFromName(threadId));
66
66
  }
67
67
  function createCloudflareSandboxAgent(config) {
68
+ const stallTimeoutMs = normalizeStallTimeoutMs(config.stallTimeoutMs);
68
69
  const worker = createSandboxAgentWorker(resolveCoordinator);
69
70
  if (config.mode === "colocated") {
70
71
  const colocated = config;
71
72
  class ConfiguredContainerCoordinator extends ContainerSandboxCoordinator {
73
+ constructor(ctx, env) {
74
+ super(ctx, env, stallTimeoutMs);
75
+ }
72
76
  config(input) {
73
77
  return {
74
78
  hostTools: colocated.tools?.(input, this.env) ?? [],
@@ -86,6 +90,9 @@ function createCloudflareSandboxAgent(config) {
86
90
  }
87
91
  const doDrives = config;
88
92
  class ConfiguredChatCoordinator extends ChatSandboxCoordinator {
93
+ constructor(ctx, env) {
94
+ super(ctx, env, stallTimeoutMs);
95
+ }
89
96
  config(input) {
90
97
  const tools = doDrives.tools?.(input, this.env);
91
98
  return {
@@ -1 +1 @@
1
- {"version":3,"file":"factory.js","names":[],"sources":["../../src/factory.ts"],"sourcesContent":["/**\n * `createCloudflareSandboxAgent` — the headline DX: one configured function call\n * returns the Durable Object coordinator, the Sandbox DO, and the Worker fetch\n * handler, so a Cloudflare app's whole `worker.ts` is just export wiring:\n *\n * ```ts\n * const agent = createCloudflareSandboxAgent({\n * adapter: () => claudeCodeText('sonnet'),\n * })\n * export const RunCoordinator = agent.Coordinator\n * export const Sandbox = agent.Sandbox\n * export default agent.worker\n * ```\n *\n * Two modes, switched by `config.mode`:\n * - `'do-drives'` (default) → a {@link ChatSandboxCoordinator}: the DO runs\n * `chat()` itself and hosts the MCP tool-bridge.\n * - `'colocated'` → a {@link ContainerSandboxCoordinator}: an in-container\n * runner runs `chat()`; the DO is a thin coordinator that executes host tools.\n *\n * Env bindings (set in `wrangler.jsonc`):\n * - `RUN_COORDINATOR` — this coordinator DO's own namespace (so the Worker can\n * address it by `threadId`). Class name: whatever you export `Coordinator` as.\n * - `Sandbox` — the `@cloudflare/sandbox` Sandbox DO namespace (the container\n * hosts). Bind the exported `Sandbox` class.\n * - `PUBLIC_HOSTNAME` — OPTIONAL. Hostname the CONTAINER uses to reach the Worker's\n * tool-bridge / tool-exec endpoint. Unset → request-derived (local dev →\n * `host.docker.internal`). See `resolveBridgeOrigin`.\n * - `PREVIEW_HOSTNAME` — OPTIONAL. Custom domain (with a `*.<domain>` route) for\n * browser-facing `exposePort` preview URLs. Unset → request-derived (local dev →\n * `localhost`); REQUIRED on a `*.workers.dev` deploy, which has no wildcard\n * subdomains. See `resolvePreviewHost`.\n * - The harness's API key (`ANTHROPIC_API_KEY` for Claude Code, `CODEX_API_KEY` for\n * codex, …) — supplied by YOUR app, never by the package. Declare it as a secret\n * on the run's workspace (via a `sandbox`/`workspace` resolver) and add the field\n * to your own env type; the coordinator injects each declared secret into the\n * sandbox env by name. The package itself is harness-agnostic and binds no key.\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 { defineSandbox, defineWorkspace } from '@tanstack/ai-sandbox'\nimport { Sandbox } from '@cloudflare/sandbox'\nimport { cloudflareSandbox } from './provider'\nimport { ChatSandboxCoordinator } from './chat-coordinator'\nimport { ContainerSandboxCoordinator } from './container-coordinator'\nimport { createSandboxAgentWorker } from './worker'\nimport { resolvePreviewHost } from './coordinator'\nimport type { ChatCoordinatorEnv, ChatRunConfig } from './chat-coordinator'\nimport type {\n ContainerCoordinatorEnv,\n ContainerRunConfig,\n} from './container-coordinator'\nimport type { HarnessId } from './protocol'\nimport type { SandboxCoordinator, StartRunInput } from './coordinator'\nimport type { AnyTextAdapter, AnyTool, SystemPrompt } from '@tanstack/ai'\nimport type {\n SandboxDefinition,\n WorkspaceDefinition,\n} from '@tanstack/ai-sandbox'\n\n/**\n * The base Env every generated app binds: the coordinator's own namespace, the\n * Sandbox namespace, the OPTIONAL bridge/preview hostnames (request-derived when\n * unset), and the Anthropic key. The two modes extend this with exactly the\n * coordinator base each one requires.\n */\nexport interface SandboxAgentEnv\n extends ChatCoordinatorEnv, ContainerCoordinatorEnv {\n /** This coordinator DO's own namespace (so the Worker can address it). */\n RUN_COORDINATOR: DurableObjectNamespace<SandboxCoordinator<SandboxAgentEnv>>\n /**\n * Custom domain (with a `*.<domain>` route) for browser-facing `exposePort`\n * preview URLs. Optional: unset → request-derived (local dev → `localhost`).\n * REQUIRED on a `*.workers.dev` deploy (no wildcard subdomains). Distinct from\n * `PUBLIC_HOSTNAME`, which is the CONTAINER→Worker bridge host. See\n * {@link resolvePreviewHost}.\n */\n PREVIEW_HOSTNAME?: string\n}\n\n/** Shared config across both modes. */\ninterface BaseAgentConfig<TEnv extends SandboxAgentEnv> {\n /** chat()-provided server tools, resolved per run (DO-drives: bridged over MCP). */\n tools?: (input: StartRunInput, env: TEnv) => Array<AnyTool>\n}\n\n/** DO-drives config: the DO runs `chat()` with the given adapter. */\nexport interface DoDrivesAgentConfig<\n TEnv extends SandboxAgentEnv,\n> extends BaseAgentConfig<TEnv> {\n mode?: 'do-drives'\n /** The harness/text adapter `chat()` runs, resolved per run. */\n adapter: (input: StartRunInput, env: TEnv) => AnyTextAdapter\n /**\n * Base system prompts prepended to every run's `chat()` (DO-drives only — the DO\n * runs `chat()` itself). The natural home for transport-level guidance the agent\n * needs regardless of what it builds — e.g. `systemPrompts: [PREVIEW_GUIDANCE]`\n * so previews don't reload-loop. See {@link PREVIEW_GUIDANCE}.\n */\n systemPrompts?: Array<SystemPrompt>\n /**\n * The sandbox the agent runs in, resolved per run. When omitted, a default\n * Cloudflare sandbox (one per thread, no source clone, NO auth secrets) is built\n * from the `Sandbox` binding and the resolved preview host, optionally\n * bootstrapping `workspace`. Supply the harness's API key either here (a custom\n * `sandbox` resolver whose workspace declares the secret) or via `workspace`\n * below — the package binds no key of its own.\n */\n sandbox?: (input: StartRunInput, env: TEnv) => SandboxDefinition\n /**\n * Workspace for the default sandbox (ignored when `sandbox` is provided). This is\n * where a default-sandbox app declares its harness auth, e.g.\n * `defineWorkspace({ source: { type: 'none' }, secrets: createSecrets({ ANTHROPIC_API_KEY: env.ANTHROPIC_API_KEY }) })`.\n */\n workspace?: WorkspaceDefinition\n}\n\n/** Co-located config: an in-container runner runs `chat()`. */\nexport interface ColocatedAgentConfig<\n TEnv extends SandboxAgentEnv,\n> extends BaseAgentConfig<TEnv> {\n mode: 'colocated'\n /** Which in-sandbox harness the runner spawns. */\n harness: HarnessId\n /** Model id passed to that harness. */\n model: string\n /** Workspace the in-container runner bootstraps for the agent. */\n workspace: WorkspaceDefinition\n}\n\nexport type CloudflareSandboxAgentConfig<TEnv extends SandboxAgentEnv> =\n | DoDrivesAgentConfig<TEnv>\n | ColocatedAgentConfig<TEnv>\n\n/** What {@link createCloudflareSandboxAgent} returns: the app's whole worker. */\nexport interface CloudflareSandboxAgent<TEnv extends SandboxAgentEnv> {\n /** The coordinator Durable Object class — export as your `RUN_COORDINATOR` binding. */\n Coordinator: new (\n ctx: DurableObjectState,\n env: TEnv,\n ) => SandboxCoordinator<TEnv>\n /** The `@cloudflare/sandbox` Sandbox DO class — export for the `Sandbox` binding. */\n Sandbox: typeof Sandbox\n /** The Worker fetch handler — `export default` it. */\n worker: ExportedHandler<TEnv>\n}\n\n/** Build the default per-thread Cloudflare sandbox for the DO-drives mode. */\nfunction defaultSandbox<TEnv extends SandboxAgentEnv>(\n env: TEnv,\n input: StartRunInput,\n workspace: WorkspaceDefinition | undefined,\n): SandboxDefinition {\n return defineSandbox({\n id: 'cf-edge-agent',\n provider: cloudflareSandbox({\n binding: env.Sandbox,\n // Browser-facing preview host: `PREVIEW_HOSTNAME` if set, else derived from\n // the trigger request (local dev → `localhost`; deployed → a custom domain,\n // since `*.workers.dev` has no wildcard). See `resolvePreviewHost`.\n previewHostname: resolvePreviewHost(env, input),\n }),\n workspace:\n workspace ??\n // The container image ships the harness CLI; no source to clone, and NO auth\n // secrets — the package is harness-agnostic, so it can't know which key the\n // CLI needs. Supply the harness's API key via a `workspace` with `secrets`\n // (or a custom `sandbox` resolver), e.g.:\n // workspace: defineWorkspace({\n // source: { type: 'none' },\n // secrets: createSecrets({ ANTHROPIC_API_KEY: env.ANTHROPIC_API_KEY }),\n // })\n defineWorkspace({ source: { type: 'none' } }),\n // One sandbox per thread, so a follow-up run resumes the same workspace.\n lifecycle: { reuse: 'thread' },\n })\n}\n\n/** Resolve the coordinator DO that owns a thread's runs (`RUN_COORDINATOR`). */\nfunction resolveCoordinator<TEnv extends SandboxAgentEnv>(\n env: TEnv,\n threadId: string,\n): DurableObjectStub<SandboxCoordinator<TEnv>> {\n return env.RUN_COORDINATOR.get(env.RUN_COORDINATOR.idFromName(threadId))\n}\n\nexport function createCloudflareSandboxAgent<\n TEnv extends SandboxAgentEnv = SandboxAgentEnv,\n>(config: CloudflareSandboxAgentConfig<TEnv>): CloudflareSandboxAgent<TEnv> {\n const worker = createSandboxAgentWorker<TEnv>(resolveCoordinator)\n\n if (config.mode === 'colocated') {\n const colocated = config\n class ConfiguredContainerCoordinator extends ContainerSandboxCoordinator<TEnv> {\n protected override config(input: StartRunInput): ContainerRunConfig {\n return {\n hostTools: colocated.tools?.(input, this.env) ?? [],\n workspace: colocated.workspace,\n harness: colocated.harness,\n model: colocated.model,\n }\n }\n }\n return { Coordinator: ConfiguredContainerCoordinator, Sandbox, worker }\n }\n\n const doDrives = config\n class ConfiguredChatCoordinator extends ChatSandboxCoordinator<TEnv> {\n protected override config(input: StartRunInput): ChatRunConfig {\n const tools = doDrives.tools?.(input, this.env)\n return {\n adapter: doDrives.adapter(input, this.env),\n sandbox:\n doDrives.sandbox?.(input, this.env) ??\n defaultSandbox(this.env, input, doDrives.workspace),\n ...(tools !== undefined ? { tools } : {}),\n ...(doDrives.systemPrompts !== undefined\n ? { systemPrompts: doDrives.systemPrompts }\n : {}),\n }\n }\n }\n return { Coordinator: ConfiguredChatCoordinator, Sandbox, worker }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqJA,SAAS,eACP,KACA,OACA,WACmB;CACnB,OAAO,cAAc;EACnB,IAAI;EACJ,UAAU,kBAAkB;GAC1B,SAAS,IAAI;GAIb,iBAAiB,mBAAmB,KAAK,KAAK;EAChD,CAAC;EACD,WACE,aASA,gBAAgB,EAAE,QAAQ,EAAE,MAAM,OAAO,EAAE,CAAC;EAE9C,WAAW,EAAE,OAAO,SAAS;CAC/B,CAAC;AACH;;AAGA,SAAS,mBACP,KACA,UAC6C;CAC7C,OAAO,IAAI,gBAAgB,IAAI,IAAI,gBAAgB,WAAW,QAAQ,CAAC;AACzE;AAEA,SAAgB,6BAEd,QAA0E;CAC1E,MAAM,SAAS,yBAA+B,kBAAkB;CAEhE,IAAI,OAAO,SAAS,aAAa;EAC/B,MAAM,YAAY;EAClB,MAAM,uCAAuC,4BAAkC;GAC7E,OAA0B,OAA0C;IAClE,OAAO;KACL,WAAW,UAAU,QAAQ,OAAO,KAAK,GAAG,KAAK,CAAC;KAClD,WAAW,UAAU;KACrB,SAAS,UAAU;KACnB,OAAO,UAAU;IACnB;GACF;EACF;EACA,OAAO;GAAE,aAAa;GAAgC;GAAS;EAAO;CACxE;CAEA,MAAM,WAAW;CACjB,MAAM,kCAAkC,uBAA6B;EACnE,OAA0B,OAAqC;GAC7D,MAAM,QAAQ,SAAS,QAAQ,OAAO,KAAK,GAAG;GAC9C,OAAO;IACL,SAAS,SAAS,QAAQ,OAAO,KAAK,GAAG;IACzC,SACE,SAAS,UAAU,OAAO,KAAK,GAAG,KAClC,eAAe,KAAK,KAAK,OAAO,SAAS,SAAS;IACpD,GAAI,UAAU,KAAA,IAAY,EAAE,MAAM,IAAI,CAAC;IACvC,GAAI,SAAS,kBAAkB,KAAA,IAC3B,EAAE,eAAe,SAAS,cAAc,IACxC,CAAC;GACP;EACF;CACF;CACA,OAAO;EAAE,aAAa;EAA2B;EAAS;CAAO;AACnE"}
1
+ {"version":3,"file":"factory.js","names":[],"sources":["../../src/factory.ts"],"sourcesContent":["/**\n * `createCloudflareSandboxAgent` — the headline DX: one configured function call\n * returns the Durable Object coordinator, the Sandbox DO, and the Worker fetch\n * handler, so a Cloudflare app's whole `worker.ts` is just export wiring:\n *\n * ```ts\n * const agent = createCloudflareSandboxAgent({\n * adapter: () => claudeCodeText('sonnet'),\n * })\n * export const RunCoordinator = agent.Coordinator\n * export const Sandbox = agent.Sandbox\n * export default agent.worker\n * ```\n *\n * Two modes, switched by `config.mode`:\n * - `'do-drives'` (default) → a {@link ChatSandboxCoordinator}: the DO runs\n * `chat()` itself and hosts the MCP tool-bridge.\n * - `'colocated'` → a {@link ContainerSandboxCoordinator}: an in-container\n * runner runs `chat()`; the DO is a thin coordinator that executes host tools.\n *\n * Env bindings (set in `wrangler.jsonc`):\n * - `RUN_COORDINATOR` — this coordinator DO's own namespace (so the Worker can\n * address it by `threadId`). Class name: whatever you export `Coordinator` as.\n * - `Sandbox` — the `@cloudflare/sandbox` Sandbox DO namespace (the container\n * hosts). Bind the exported `Sandbox` class.\n * - `PUBLIC_HOSTNAME` — OPTIONAL. Hostname the CONTAINER uses to reach the Worker's\n * tool-bridge / tool-exec endpoint. Unset → request-derived (local dev →\n * `host.docker.internal`). See `resolveBridgeOrigin`.\n * - `PREVIEW_HOSTNAME` — OPTIONAL. Custom domain (with a `*.<domain>` route) for\n * browser-facing `exposePort` preview URLs. Unset → request-derived (local dev →\n * `localhost`); REQUIRED on a `*.workers.dev` deploy, which has no wildcard\n * subdomains. See `resolvePreviewHost`.\n * - The harness's API key (`ANTHROPIC_API_KEY` for Claude Code, `CODEX_API_KEY` for\n * codex, …) — supplied by YOUR app, never by the package. Declare it as a secret\n * on the run's workspace (via a `sandbox`/`workspace` resolver) and add the field\n * to your own env type; the coordinator injects each declared secret into the\n * sandbox env by name. The package itself is harness-agnostic and binds no key.\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 { defineSandbox, defineWorkspace } from '@tanstack/ai-sandbox'\nimport { Sandbox } from '@cloudflare/sandbox'\nimport { cloudflareSandbox } from './provider'\nimport { ChatSandboxCoordinator } from './chat-coordinator'\nimport { ContainerSandboxCoordinator } from './container-coordinator'\nimport { createSandboxAgentWorker } from './worker'\nimport { normalizeStallTimeoutMs, resolvePreviewHost } from './coordinator'\nimport type { ChatCoordinatorEnv, ChatRunConfig } from './chat-coordinator'\nimport type {\n ContainerCoordinatorEnv,\n ContainerRunConfig,\n} from './container-coordinator'\nimport type { HarnessId } from './protocol'\nimport type { SandboxCoordinator, StartRunInput } from './coordinator'\nimport type { AnyTextAdapter, AnyTool, SystemPrompt } from '@tanstack/ai'\nimport type {\n SandboxDefinition,\n WorkspaceDefinition,\n} from '@tanstack/ai-sandbox'\n\n/**\n * The base Env every generated app binds: the coordinator's own namespace, the\n * Sandbox namespace, the OPTIONAL bridge/preview hostnames (request-derived when\n * unset), and the Anthropic key. The two modes extend this with exactly the\n * coordinator base each one requires.\n */\nexport interface SandboxAgentEnv\n extends ChatCoordinatorEnv, ContainerCoordinatorEnv {\n /** This coordinator DO's own namespace (so the Worker can address it). */\n RUN_COORDINATOR: DurableObjectNamespace<SandboxCoordinator<SandboxAgentEnv>>\n /**\n * Custom domain (with a `*.<domain>` route) for browser-facing `exposePort`\n * preview URLs. Optional: unset → request-derived (local dev → `localhost`).\n * REQUIRED on a `*.workers.dev` deploy (no wildcard subdomains). Distinct from\n * `PUBLIC_HOSTNAME`, which is the CONTAINER→Worker bridge host. See\n * {@link resolvePreviewHost}.\n */\n PREVIEW_HOSTNAME?: string\n}\n\n/** Shared config across both modes. */\ninterface BaseAgentConfig<TEnv extends SandboxAgentEnv> {\n /** chat()-provided server tools, resolved per run (DO-drives: bridged over MCP). */\n tools?: (input: StartRunInput, env: TEnv) => Array<AnyTool>\n /**\n * Fail a run after this many milliseconds without persisted activity. Omitted\n * defaults to five minutes; `false` disables the watchdog.\n */\n stallTimeoutMs?: number | false\n}\n\n/** DO-drives config: the DO runs `chat()` with the given adapter. */\nexport interface DoDrivesAgentConfig<\n TEnv extends SandboxAgentEnv,\n> extends BaseAgentConfig<TEnv> {\n mode?: 'do-drives'\n /** The harness/text adapter `chat()` runs, resolved per run. */\n adapter: (input: StartRunInput, env: TEnv) => AnyTextAdapter\n /**\n * Base system prompts prepended to every run's `chat()` (DO-drives only — the DO\n * runs `chat()` itself). The natural home for transport-level guidance the agent\n * needs regardless of what it builds — e.g. `systemPrompts: [PREVIEW_GUIDANCE]`\n * so previews don't reload-loop. See {@link PREVIEW_GUIDANCE}.\n */\n systemPrompts?: Array<SystemPrompt>\n /**\n * The sandbox the agent runs in, resolved per run. When omitted, a default\n * Cloudflare sandbox (one per thread, no source clone, NO auth secrets) is built\n * from the `Sandbox` binding and the resolved preview host, optionally\n * bootstrapping `workspace`. Supply the harness's API key either here (a custom\n * `sandbox` resolver whose workspace declares the secret) or via `workspace`\n * below — the package binds no key of its own.\n */\n sandbox?: (input: StartRunInput, env: TEnv) => SandboxDefinition\n /**\n * Workspace for the default sandbox (ignored when `sandbox` is provided). This is\n * where a default-sandbox app declares its harness auth, e.g.\n * `defineWorkspace({ source: { type: 'none' }, secrets: createSecrets({ ANTHROPIC_API_KEY: env.ANTHROPIC_API_KEY }) })`.\n */\n workspace?: WorkspaceDefinition\n}\n\n/** Co-located config: an in-container runner runs `chat()`. */\nexport interface ColocatedAgentConfig<\n TEnv extends SandboxAgentEnv,\n> extends BaseAgentConfig<TEnv> {\n mode: 'colocated'\n /** Which in-sandbox harness the runner spawns. */\n harness: HarnessId\n /** Model id passed to that harness. */\n model: string\n /** Workspace the in-container runner bootstraps for the agent. */\n workspace: WorkspaceDefinition\n}\n\nexport type CloudflareSandboxAgentConfig<TEnv extends SandboxAgentEnv> =\n | DoDrivesAgentConfig<TEnv>\n | ColocatedAgentConfig<TEnv>\n\n/** What {@link createCloudflareSandboxAgent} returns: the app's whole worker. */\nexport interface CloudflareSandboxAgent<TEnv extends SandboxAgentEnv> {\n /** The coordinator Durable Object class — export as your `RUN_COORDINATOR` binding. */\n Coordinator: new (\n ctx: DurableObjectState,\n env: TEnv,\n ) => SandboxCoordinator<TEnv>\n /** The `@cloudflare/sandbox` Sandbox DO class — export for the `Sandbox` binding. */\n Sandbox: typeof Sandbox\n /** The Worker fetch handler — `export default` it. */\n worker: ExportedHandler<TEnv>\n}\n\n/** Build the default per-thread Cloudflare sandbox for the DO-drives mode. */\nfunction defaultSandbox<TEnv extends SandboxAgentEnv>(\n env: TEnv,\n input: StartRunInput,\n workspace: WorkspaceDefinition | undefined,\n): SandboxDefinition {\n return defineSandbox({\n id: 'cf-edge-agent',\n provider: cloudflareSandbox({\n binding: env.Sandbox,\n // Browser-facing preview host: `PREVIEW_HOSTNAME` if set, else derived from\n // the trigger request (local dev → `localhost`; deployed → a custom domain,\n // since `*.workers.dev` has no wildcard). See `resolvePreviewHost`.\n previewHostname: resolvePreviewHost(env, input),\n }),\n workspace:\n workspace ??\n // The container image ships the harness CLI; no source to clone, and NO auth\n // secrets — the package is harness-agnostic, so it can't know which key the\n // CLI needs. Supply the harness's API key via a `workspace` with `secrets`\n // (or a custom `sandbox` resolver), e.g.:\n // workspace: defineWorkspace({\n // source: { type: 'none' },\n // secrets: createSecrets({ ANTHROPIC_API_KEY: env.ANTHROPIC_API_KEY }),\n // })\n defineWorkspace({ source: { type: 'none' } }),\n // One sandbox per thread, so a follow-up run resumes the same workspace.\n lifecycle: { reuse: 'thread' },\n })\n}\n\n/** Resolve the coordinator DO that owns a thread's runs (`RUN_COORDINATOR`). */\nfunction resolveCoordinator<TEnv extends SandboxAgentEnv>(\n env: TEnv,\n threadId: string,\n): DurableObjectStub<SandboxCoordinator<TEnv>> {\n return env.RUN_COORDINATOR.get(env.RUN_COORDINATOR.idFromName(threadId))\n}\n\nexport function createCloudflareSandboxAgent<\n TEnv extends SandboxAgentEnv = SandboxAgentEnv,\n>(config: CloudflareSandboxAgentConfig<TEnv>): CloudflareSandboxAgent<TEnv> {\n const stallTimeoutMs = normalizeStallTimeoutMs(config.stallTimeoutMs)\n const worker = createSandboxAgentWorker<TEnv>(resolveCoordinator)\n\n if (config.mode === 'colocated') {\n const colocated = config\n class ConfiguredContainerCoordinator extends ContainerSandboxCoordinator<TEnv> {\n constructor(ctx: DurableObjectState, env: TEnv) {\n super(ctx, env, stallTimeoutMs)\n }\n\n protected override config(input: StartRunInput): ContainerRunConfig {\n return {\n hostTools: colocated.tools?.(input, this.env) ?? [],\n workspace: colocated.workspace,\n harness: colocated.harness,\n model: colocated.model,\n }\n }\n }\n return { Coordinator: ConfiguredContainerCoordinator, Sandbox, worker }\n }\n\n const doDrives = config\n class ConfiguredChatCoordinator extends ChatSandboxCoordinator<TEnv> {\n constructor(ctx: DurableObjectState, env: TEnv) {\n super(ctx, env, stallTimeoutMs)\n }\n\n protected override config(input: StartRunInput): ChatRunConfig {\n const tools = doDrives.tools?.(input, this.env)\n return {\n adapter: doDrives.adapter(input, this.env),\n sandbox:\n doDrives.sandbox?.(input, this.env) ??\n defaultSandbox(this.env, input, doDrives.workspace),\n ...(tools !== undefined ? { tools } : {}),\n ...(doDrives.systemPrompts !== undefined\n ? { systemPrompts: doDrives.systemPrompts }\n : {}),\n }\n }\n }\n return { Coordinator: ConfiguredChatCoordinator, Sandbox, worker }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0JA,SAAS,eACP,KACA,OACA,WACmB;CACnB,OAAO,cAAc;EACnB,IAAI;EACJ,UAAU,kBAAkB;GAC1B,SAAS,IAAI;GAIb,iBAAiB,mBAAmB,KAAK,KAAK;EAChD,CAAC;EACD,WACE,aASA,gBAAgB,EAAE,QAAQ,EAAE,MAAM,OAAO,EAAE,CAAC;EAE9C,WAAW,EAAE,OAAO,SAAS;CAC/B,CAAC;AACH;;AAGA,SAAS,mBACP,KACA,UAC6C;CAC7C,OAAO,IAAI,gBAAgB,IAAI,IAAI,gBAAgB,WAAW,QAAQ,CAAC;AACzE;AAEA,SAAgB,6BAEd,QAA0E;CAC1E,MAAM,iBAAiB,wBAAwB,OAAO,cAAc;CACpE,MAAM,SAAS,yBAA+B,kBAAkB;CAEhE,IAAI,OAAO,SAAS,aAAa;EAC/B,MAAM,YAAY;EAClB,MAAM,uCAAuC,4BAAkC;GAC7E,YAAY,KAAyB,KAAW;IAC9C,MAAM,KAAK,KAAK,cAAc;GAChC;GAEA,OAA0B,OAA0C;IAClE,OAAO;KACL,WAAW,UAAU,QAAQ,OAAO,KAAK,GAAG,KAAK,CAAC;KAClD,WAAW,UAAU;KACrB,SAAS,UAAU;KACnB,OAAO,UAAU;IACnB;GACF;EACF;EACA,OAAO;GAAE,aAAa;GAAgC;GAAS;EAAO;CACxE;CAEA,MAAM,WAAW;CACjB,MAAM,kCAAkC,uBAA6B;EACnE,YAAY,KAAyB,KAAW;GAC9C,MAAM,KAAK,KAAK,cAAc;EAChC;EAEA,OAA0B,OAAqC;GAC7D,MAAM,QAAQ,SAAS,QAAQ,OAAO,KAAK,GAAG;GAC9C,OAAO;IACL,SAAS,SAAS,QAAQ,OAAO,KAAK,GAAG;IACzC,SACE,SAAS,UAAU,OAAO,KAAK,GAAG,KAClC,eAAe,KAAK,KAAK,OAAO,SAAS,SAAS;IACpD,GAAI,UAAU,KAAA,IAAY,EAAE,MAAM,IAAI,CAAC;IACvC,GAAI,SAAS,kBAAkB,KAAA,IAC3B,EAAE,eAAe,SAAS,cAAc,IACxC,CAAC;GACP;EACF;CACF;CACA,OAAO;EAAE,aAAa;EAA2B;EAAS;CAAO;AACnE"}
@@ -21,6 +21,10 @@ export declare class DurableObjectRunEventLog implements RunEventLog {
21
21
  }): Promise<RunLogRecord>;
22
22
  append(runId: string, chunk: StreamChunk): Promise<number>;
23
23
  finish(runId: string, status: TerminalRunStatus, error?: RunError): Promise<void>;
24
+ touch(runId: string): Promise<void>;
25
+ finishIfStale(runId: string, cutoff: number, chunk: Extract<StreamChunk, {
26
+ type: 'RUN_ERROR';
27
+ }>): Promise<boolean>;
24
28
  update(runId: string, patch: RunRecordPatch): Promise<void>;
25
29
  get(runId: string): Promise<RunLogRecord | null>;
26
30
  /**
@@ -112,16 +112,66 @@ var DurableObjectRunEventLog = class {
112
112
  await this.storage.put(recKey(runId), next);
113
113
  this.wake(runId);
114
114
  }
115
+ async touch(runId) {
116
+ await this.storage.transaction(async (txn) => {
117
+ const stored = await txn.get(recKey(runId));
118
+ if (!stored) return;
119
+ const { record, migrated } = migrateStoredRunRecord(stored);
120
+ if (isTerminalRunStatus(record.status)) {
121
+ if (migrated) await txn.put(recKey(runId), record);
122
+ return;
123
+ }
124
+ await txn.put(recKey(runId), {
125
+ ...record,
126
+ updatedAt: Date.now()
127
+ });
128
+ });
129
+ }
130
+ async finishIfStale(runId, cutoff, chunk) {
131
+ const finished = await this.storage.transaction(async (txn) => {
132
+ const stored = await txn.get(recKey(runId));
133
+ if (!stored) return false;
134
+ const { record, migrated } = migrateStoredRunRecord(stored);
135
+ if (isTerminalRunStatus(record.status) || record.updatedAt >= cutoff) {
136
+ if (migrated) await txn.put(recKey(runId), record);
137
+ return false;
138
+ }
139
+ const now = Date.now();
140
+ const seq = record.lastSeq + 1;
141
+ const next = {
142
+ ...record,
143
+ status: "failed",
144
+ lastSeq: seq,
145
+ error: {
146
+ message: chunk.message,
147
+ ...chunk.code !== void 0 ? { code: chunk.code } : {}
148
+ },
149
+ finishedAt: now,
150
+ updatedAt: now
151
+ };
152
+ await txn.put(evtKey(runId, seq), chunk);
153
+ await txn.put(recKey(runId), next);
154
+ return true;
155
+ });
156
+ if (finished) this.wake(runId);
157
+ return finished;
158
+ }
115
159
  async update(runId, patch) {
116
- const record = await this.getRecord(runId);
117
- if (!record) return;
118
- const next = {
119
- ...record,
120
- ...patch,
121
- updatedAt: Date.now()
122
- };
123
- await this.storage.put(recKey(runId), next);
124
- this.wake(runId);
160
+ if (await this.storage.transaction(async (txn) => {
161
+ const stored = await txn.get(recKey(runId));
162
+ if (!stored) return false;
163
+ const { record, migrated } = migrateStoredRunRecord(stored);
164
+ if (isTerminalRunStatus(record.status)) {
165
+ if (migrated) await txn.put(recKey(runId), record);
166
+ return false;
167
+ }
168
+ await txn.put(recKey(runId), {
169
+ ...record,
170
+ ...patch,
171
+ updatedAt: Date.now()
172
+ });
173
+ return true;
174
+ })) this.wake(runId);
125
175
  }
126
176
  async get(runId) {
127
177
  return this.getRecord(runId);
@@ -1 +1 @@
1
- {"version":3,"file":"run-log-do.js","names":[],"sources":["../../src/run-log-do.ts"],"sourcesContent":["/**\n * A durable {@link RunEventLog} backed by Durable Object storage — the storage\n * half of the serverless/edge run model. The coordinator appends every\n * {@link StreamChunk} the agent emits under a monotonic `seq`; clients tail from\n * a cursor. Because events are PERSISTED (not held in a caller's open stream), a\n * reconnecting tab, a dropped WebSocket, or a coordinator that hibernated\n * between chunks all resume cleanly: replay everything after the client's\n * `lastSeq`, then live-tail to terminal.\n *\n * Mirrors {@link InMemoryRunEventLog} exactly.\n * Storage layout (keys scoped by `runId` so one DO can host many runs):\n * - `rec:<runId>` → the {@link RunLogRecord}\n * - `evt:<runId>:<seq8>` → the chunk for that seq (seq zero-padded to 8 digits\n * so `list({ prefix })` returns events in seq order).\n *\n * LIVE-DATA MIGRATION: `rec:` values written before the run vocabulary\n * converged on core's (see the module header in `./run-log`) are converted by\n * {@link migrateStoredRunRecord} on first read and written back immediately, so\n * each record pays the conversion exactly once and every read path — `get`,\n * `append`'s precondition check, the watchdog's {@link list} — observes only\n * the converged layout. Event values (`evt:`) are raw chunks and need no\n * migration.\n *\n * The live-tail wake-up (the in-memory waiter set) is per-INSTANCE; if the\n * instance is evicted mid-run, a reader re-reads the persisted backlog and the\n * `TAIL_POLL_MS` fallback poll keeps it progressing. No event is ever lost.\n *\n * NOTE: Workers-runtime code — compiles against `@cloudflare/workers-types`.\n */\nimport { isTerminalRunStatus } from '@tanstack/ai'\nimport { migrateStoredRunRecord } from './run-log'\nimport type {\n RunEvent,\n RunEventLog,\n RunEventLogReadOptions,\n RunLogRecord,\n RunRecordPatch,\n} from './run-log'\nimport type { RunError, StreamChunk, TerminalRunStatus } from '@tanstack/ai'\n\n/** How long a post-eviction reader waits before re-polling storage (ms). */\nconst TAIL_POLL_MS = 250\n\n/**\n * What a `rec:` key may hold: the converged layout, or the pre-convergence one\n * `migrateStoredRunRecord` still reads. Typed as the migration function's input\n * so a read is forced through it.\n */\ntype StoredRunRecord = Parameters<typeof migrateStoredRunRecord>[0]\n\nconst recKey = (runId: string): string => `rec:${runId}`\nconst evtKey = (runId: string, seq: number): string =>\n `evt:${runId}:${String(seq).padStart(8, '0')}`\nconst evtPrefix = (runId: string): string => `evt:${runId}:`\n\nexport class DurableObjectRunEventLog implements RunEventLog {\n /** Per-run wake-ups for live-tailing readers on THIS instance. */\n private readonly waiters = new Map<string, Set<() => void>>()\n\n constructor(private readonly storage: DurableObjectStorage) {}\n\n /**\n * Read (and, when needed, migrate + write back) the record under `rec:<runId>`.\n * The single storage read path — everything else goes through here so no\n * caller can observe the legacy layout.\n */\n private async getRecord(runId: string): Promise<RunLogRecord | null> {\n const stored = await this.storage.get<StoredRunRecord>(recKey(runId))\n if (!stored) return null\n const { record, migrated } = migrateStoredRunRecord(stored)\n if (migrated) await this.storage.put(recKey(runId), record)\n return record\n }\n\n private async require(runId: string): Promise<RunLogRecord> {\n const record = await this.getRecord(runId)\n if (!record) throw new Error(`run-log: unknown runId \"${runId}\"`)\n return record\n }\n\n /** Wake (and clear) every reader blocked on this run. */\n private wake(runId: string): void {\n const set = this.waiters.get(runId)\n if (!set) return\n const pending = [...set]\n set.clear()\n for (const resolve of pending) resolve()\n }\n\n async open(input: {\n runId: string\n threadId: string\n startedAt?: number\n }): Promise<RunLogRecord> {\n const existing = await this.getRecord(input.runId)\n if (existing) return existing\n const now = Date.now()\n const record: RunLogRecord = {\n runId: input.runId,\n threadId: input.threadId,\n status: 'running',\n lastSeq: -1,\n startedAt: input.startedAt ?? now,\n updatedAt: now,\n }\n await this.storage.put(recKey(input.runId), record)\n return record\n }\n\n async append(runId: string, chunk: StreamChunk): Promise<number> {\n const record = await this.require(runId)\n if (isTerminalRunStatus(record.status)) {\n throw new Error(\n `run-log: cannot append to terminal run \"${runId}\" (status=${record.status})`,\n )\n }\n const seq = record.lastSeq + 1\n const next: RunLogRecord = {\n ...record,\n lastSeq: seq,\n updatedAt: Date.now(),\n }\n // One transaction so the appended event and its bumped record commit\n // together — a reader never sees a lastSeq pointing at a missing event.\n await this.storage.transaction(async (txn) => {\n await txn.put(evtKey(runId, seq), chunk)\n await txn.put(recKey(runId), next)\n })\n this.wake(runId)\n return seq\n }\n\n async finish(\n runId: string,\n status: TerminalRunStatus,\n error?: RunError,\n ): Promise<void> {\n const record = await this.require(runId)\n if (isTerminalRunStatus(record.status)) return\n const now = Date.now()\n const next: RunLogRecord = {\n ...record,\n status,\n ...(error !== undefined ? { error } : {}),\n finishedAt: now,\n updatedAt: now,\n }\n await this.storage.put(recKey(runId), next)\n this.wake(runId)\n }\n\n async update(runId: string, patch: RunRecordPatch): Promise<void> {\n const record = await this.getRecord(runId)\n if (!record) return // unknown runId is a no-op\n const next: RunLogRecord = { ...record, ...patch, updatedAt: Date.now() }\n await this.storage.put(recKey(runId), next)\n // A patch may terminalize the shared status field (core's driver writes its\n // terminal status through `RunStore.update`) — parked readers must see it\n // now, not a TAIL_POLL_MS later.\n this.wake(runId)\n }\n\n async get(runId: string): Promise<RunLogRecord | null> {\n return this.getRecord(runId)\n }\n\n /**\n * Every run record this log holds, migrated. The coordinator's stall\n * watchdog iterates this instead of listing `rec:` keys itself, so the\n * storage layout (and its migration) stays this module's private concern.\n */\n async list(): Promise<Array<RunLogRecord>> {\n const stored = await this.storage.list<StoredRunRecord>({ prefix: 'rec:' })\n const records: Array<RunLogRecord> = []\n for (const [key, value] of stored) {\n const { record, migrated } = migrateStoredRunRecord(value)\n if (migrated) await this.storage.put(key, record)\n records.push(record)\n }\n return records\n }\n\n async *read(\n runId: string,\n options?: RunEventLogReadOptions,\n ): AsyncIterable<RunEvent> {\n await this.require(runId)\n const signal = options?.signal\n let cursor = options?.fromSeq ?? -1\n\n while (!signal?.aborted) {\n const record = await this.require(runId)\n // Drain the persisted backlog after the cursor in seq order. The\n // zero-padded keys make the prefix list naturally ordered.\n if (cursor < record.lastSeq) {\n const events = await this.storage.list<StreamChunk>({\n prefix: evtPrefix(runId),\n start: evtKey(runId, cursor + 1),\n })\n for (const [, chunk] of events) {\n cursor += 1\n yield { seq: cursor, chunk }\n if (signal?.aborted) return\n }\n continue\n }\n if (isTerminalRunStatus(record.status)) return\n await this.waitForChange(runId, signal)\n }\n }\n\n /**\n * Resolve when an append/finish wakes this run, the signal aborts, or the\n * fallback poll fires (the poll lets a reader that outlived its in-memory\n * waiter — e.g. after the appending instance was evicted — keep progressing).\n */\n private waitForChange(runId: string, signal?: AbortSignal): Promise<void> {\n return new Promise<void>((resolve) => {\n let set = this.waiters.get(runId)\n if (!set) {\n set = new Set()\n this.waiters.set(runId, set)\n }\n const localSet = set\n const wake = (): void => {\n localSet.delete(wake)\n clearTimeout(timer)\n if (signal) signal.removeEventListener('abort', wake)\n resolve()\n }\n const timer = setTimeout(wake, TAIL_POLL_MS)\n localSet.add(wake)\n if (signal) signal.addEventListener('abort', wake, { once: true })\n })\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"run-log-do.js","names":[],"sources":["../../src/run-log-do.ts"],"sourcesContent":["/**\n * A durable {@link RunEventLog} backed by Durable Object storage — the storage\n * half of the serverless/edge run model. The coordinator appends every\n * {@link StreamChunk} the agent emits under a monotonic `seq`; clients tail from\n * a cursor. Because events are PERSISTED (not held in a caller's open stream), a\n * reconnecting tab, a dropped WebSocket, or a coordinator that hibernated\n * between chunks all resume cleanly: replay everything after the client's\n * `lastSeq`, then live-tail to terminal.\n *\n * Mirrors {@link InMemoryRunEventLog} exactly.\n * Storage layout (keys scoped by `runId` so one DO can host many runs):\n * - `rec:<runId>` → the {@link RunLogRecord}\n * - `evt:<runId>:<seq8>` → the chunk for that seq (seq zero-padded to 8 digits\n * so `list({ prefix })` returns events in seq order).\n *\n * LIVE-DATA MIGRATION: `rec:` values written before the run vocabulary\n * converged on core's (see the module header in `./run-log`) are converted by\n * {@link migrateStoredRunRecord} on first read and written back immediately, so\n * each record pays the conversion exactly once and every read path — `get`,\n * `append`'s precondition check, the watchdog's {@link list} — observes only\n * the converged layout. Event values (`evt:`) are raw chunks and need no\n * migration.\n *\n * The live-tail wake-up (the in-memory waiter set) is per-INSTANCE; if the\n * instance is evicted mid-run, a reader re-reads the persisted backlog and the\n * `TAIL_POLL_MS` fallback poll keeps it progressing. No event is ever lost.\n *\n * NOTE: Workers-runtime code — compiles against `@cloudflare/workers-types`.\n */\nimport { isTerminalRunStatus } from '@tanstack/ai'\nimport { migrateStoredRunRecord } from './run-log'\nimport type {\n RunEvent,\n RunEventLog,\n RunEventLogReadOptions,\n RunLogRecord,\n RunRecordPatch,\n} from './run-log'\nimport type { RunError, StreamChunk, TerminalRunStatus } from '@tanstack/ai'\n\n/** How long a post-eviction reader waits before re-polling storage (ms). */\nconst TAIL_POLL_MS = 250\n\n/**\n * What a `rec:` key may hold: the converged layout, or the pre-convergence one\n * `migrateStoredRunRecord` still reads. Typed as the migration function's input\n * so a read is forced through it.\n */\ntype StoredRunRecord = Parameters<typeof migrateStoredRunRecord>[0]\n\nconst recKey = (runId: string): string => `rec:${runId}`\nconst evtKey = (runId: string, seq: number): string =>\n `evt:${runId}:${String(seq).padStart(8, '0')}`\nconst evtPrefix = (runId: string): string => `evt:${runId}:`\n\nexport class DurableObjectRunEventLog implements RunEventLog {\n /** Per-run wake-ups for live-tailing readers on THIS instance. */\n private readonly waiters = new Map<string, Set<() => void>>()\n\n constructor(private readonly storage: DurableObjectStorage) {}\n\n /**\n * Read (and, when needed, migrate + write back) the record under `rec:<runId>`.\n * The single storage read path — everything else goes through here so no\n * caller can observe the legacy layout.\n */\n private async getRecord(runId: string): Promise<RunLogRecord | null> {\n const stored = await this.storage.get<StoredRunRecord>(recKey(runId))\n if (!stored) return null\n const { record, migrated } = migrateStoredRunRecord(stored)\n if (migrated) await this.storage.put(recKey(runId), record)\n return record\n }\n\n private async require(runId: string): Promise<RunLogRecord> {\n const record = await this.getRecord(runId)\n if (!record) throw new Error(`run-log: unknown runId \"${runId}\"`)\n return record\n }\n\n /** Wake (and clear) every reader blocked on this run. */\n private wake(runId: string): void {\n const set = this.waiters.get(runId)\n if (!set) return\n const pending = [...set]\n set.clear()\n for (const resolve of pending) resolve()\n }\n\n async open(input: {\n runId: string\n threadId: string\n startedAt?: number\n }): Promise<RunLogRecord> {\n const existing = await this.getRecord(input.runId)\n if (existing) return existing\n const now = Date.now()\n const record: RunLogRecord = {\n runId: input.runId,\n threadId: input.threadId,\n status: 'running',\n lastSeq: -1,\n startedAt: input.startedAt ?? now,\n updatedAt: now,\n }\n await this.storage.put(recKey(input.runId), record)\n return record\n }\n\n async append(runId: string, chunk: StreamChunk): Promise<number> {\n const record = await this.require(runId)\n if (isTerminalRunStatus(record.status)) {\n throw new Error(\n `run-log: cannot append to terminal run \"${runId}\" (status=${record.status})`,\n )\n }\n const seq = record.lastSeq + 1\n const next: RunLogRecord = {\n ...record,\n lastSeq: seq,\n updatedAt: Date.now(),\n }\n // One transaction so the appended event and its bumped record commit\n // together — a reader never sees a lastSeq pointing at a missing event.\n await this.storage.transaction(async (txn) => {\n await txn.put(evtKey(runId, seq), chunk)\n await txn.put(recKey(runId), next)\n })\n this.wake(runId)\n return seq\n }\n\n async finish(\n runId: string,\n status: TerminalRunStatus,\n error?: RunError,\n ): Promise<void> {\n const record = await this.require(runId)\n if (isTerminalRunStatus(record.status)) return\n const now = Date.now()\n const next: RunLogRecord = {\n ...record,\n status,\n ...(error !== undefined ? { error } : {}),\n finishedAt: now,\n updatedAt: now,\n }\n await this.storage.put(recKey(runId), next)\n this.wake(runId)\n }\n\n async touch(runId: string): Promise<void> {\n await this.storage.transaction(async (txn) => {\n const stored = await txn.get<StoredRunRecord>(recKey(runId))\n if (!stored) return\n const { record, migrated } = migrateStoredRunRecord(stored)\n if (isTerminalRunStatus(record.status)) {\n if (migrated) await txn.put(recKey(runId), record)\n return\n }\n await txn.put(recKey(runId), { ...record, updatedAt: Date.now() })\n })\n // Activity without a new event or status transition gives a tailing reader\n // nothing to observe, so intentionally do not wake it.\n }\n\n async finishIfStale(\n runId: string,\n cutoff: number,\n chunk: Extract<StreamChunk, { type: 'RUN_ERROR' }>,\n ): Promise<boolean> {\n const finished = await this.storage.transaction(async (txn) => {\n const stored = await txn.get<StoredRunRecord>(recKey(runId))\n if (!stored) return false\n const { record, migrated } = migrateStoredRunRecord(stored)\n if (isTerminalRunStatus(record.status) || record.updatedAt >= cutoff) {\n if (migrated) await txn.put(recKey(runId), record)\n return false\n }\n\n const now = Date.now()\n const seq = record.lastSeq + 1\n const next: RunLogRecord = {\n ...record,\n status: 'failed',\n lastSeq: seq,\n error: {\n message: chunk.message,\n ...(chunk.code !== undefined ? { code: chunk.code } : {}),\n },\n finishedAt: now,\n updatedAt: now,\n }\n // Read the current activity clock and commit the terminal event + record\n // in this one transaction. Keep wake-ups outside: transaction callbacks\n // may be retried and must contain no external work.\n await txn.put(evtKey(runId, seq), chunk)\n await txn.put(recKey(runId), next)\n return true\n })\n if (finished) this.wake(runId)\n return finished\n }\n\n async update(runId: string, patch: RunRecordPatch): Promise<void> {\n const updated = await this.storage.transaction(async (txn) => {\n const stored = await txn.get<StoredRunRecord>(recKey(runId))\n if (!stored) return false\n const { record, migrated } = migrateStoredRunRecord(stored)\n if (isTerminalRunStatus(record.status)) {\n if (migrated) await txn.put(recKey(runId), record)\n return false\n }\n await txn.put(recKey(runId), {\n ...record,\n ...patch,\n updatedAt: Date.now(),\n })\n return true\n })\n // A patch may terminalize the shared status field (core's driver writes its\n // terminal status through `RunStore.update`) — wake only after that update\n // commits. A late update racing a terminal writer is an atomic no-op.\n if (updated) this.wake(runId)\n }\n\n async get(runId: string): Promise<RunLogRecord | null> {\n return this.getRecord(runId)\n }\n\n /**\n * Every run record this log holds, migrated. The coordinator's stall\n * watchdog iterates this instead of listing `rec:` keys itself, so the\n * storage layout (and its migration) stays this module's private concern.\n */\n async list(): Promise<Array<RunLogRecord>> {\n const stored = await this.storage.list<StoredRunRecord>({ prefix: 'rec:' })\n const records: Array<RunLogRecord> = []\n for (const [key, value] of stored) {\n const { record, migrated } = migrateStoredRunRecord(value)\n if (migrated) await this.storage.put(key, record)\n records.push(record)\n }\n return records\n }\n\n async *read(\n runId: string,\n options?: RunEventLogReadOptions,\n ): AsyncIterable<RunEvent> {\n await this.require(runId)\n const signal = options?.signal\n let cursor = options?.fromSeq ?? -1\n\n while (!signal?.aborted) {\n const record = await this.require(runId)\n // Drain the persisted backlog after the cursor in seq order. The\n // zero-padded keys make the prefix list naturally ordered.\n if (cursor < record.lastSeq) {\n const events = await this.storage.list<StreamChunk>({\n prefix: evtPrefix(runId),\n start: evtKey(runId, cursor + 1),\n })\n for (const [, chunk] of events) {\n cursor += 1\n yield { seq: cursor, chunk }\n if (signal?.aborted) return\n }\n continue\n }\n if (isTerminalRunStatus(record.status)) return\n await this.waitForChange(runId, signal)\n }\n }\n\n /**\n * Resolve when an append/finish wakes this run, the signal aborts, or the\n * fallback poll fires (the poll lets a reader that outlived its in-memory\n * waiter — e.g. after the appending instance was evicted — keep progressing).\n */\n private waitForChange(runId: string, signal?: AbortSignal): Promise<void> {\n return new Promise<void>((resolve) => {\n let set = this.waiters.get(runId)\n if (!set) {\n set = new Set()\n this.waiters.set(runId, set)\n }\n const localSet = set\n const wake = (): void => {\n localSet.delete(wake)\n clearTimeout(timer)\n if (signal) signal.removeEventListener('abort', wake)\n resolve()\n }\n const timer = setTimeout(wake, TAIL_POLL_MS)\n localSet.add(wake)\n if (signal) signal.addEventListener('abort', wake, { once: true })\n })\n }\n}\n"],"mappings":"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"}
@@ -38,7 +38,8 @@ export interface RunEventLogReadOptions {
38
38
  * - `append` assigns the next `seq` (0, 1, 2, …) and returns it.
39
39
  * - `read` yields the backlog after `fromSeq` in order, then live-tails new
40
40
  * events, and RETURNS once the run is terminal and the cursor has caught up.
41
- * - All methods reject for an unknown `runId` except `get`, which resolves null.
41
+ * - Unknown-run behavior is method-specific: `append`/`finish`/`read` reject,
42
+ * `get` resolves null, and `update` is a no-op.
42
43
  */
43
44
  export interface RunEventLog {
44
45
  /**
@@ -57,9 +58,9 @@ export interface RunEventLog {
57
58
  /** Move the run to a terminal status. Idempotent for the same status. */
58
59
  finish: (runId: string, status: TerminalRunStatus, error?: RunError) => Promise<void>;
59
60
  /**
60
- * Patch the record's mutable fields ({@link RunRecordPatch}). Unknown `runId`
61
- * is a NO-OP (never a throw, never a create) — core's `RunStore.update`
62
- * invariant, which `runLogStore` maps onto this method.
61
+ * Patch the record's mutable fields ({@link RunRecordPatch}). Unknown and
62
+ * already-terminal runs are NO-OPs (never a throw, never a create), preserving
63
+ * the first terminal status against late driver updates.
63
64
  *
64
65
  * MUST wake blocked readers, exactly like `append`/`finish`: the record and
65
66
  * the event log share one status field here, so a driver that terminalizes
@@ -118,6 +119,10 @@ export declare class InMemoryRunEventLog implements RunEventLog {
118
119
  }): Promise<RunLogRecord>;
119
120
  append(runId: string, chunk: StreamChunk): Promise<number>;
120
121
  finish(runId: string, status: TerminalRunStatus, error?: RunError): Promise<void>;
122
+ touch(runId: string): Promise<void>;
123
+ finishIfStale(runId: string, cutoff: number, chunk: Extract<StreamChunk, {
124
+ type: 'RUN_ERROR';
125
+ }>): Promise<boolean>;
121
126
  update(runId: string, patch: RunRecordPatch): Promise<void>;
122
127
  get(runId: string): Promise<RunLogRecord | null>;
123
128
  list(): Promise<Array<RunLogRecord>>;
@@ -145,9 +145,32 @@ var InMemoryRunEventLog = class {
145
145
  this.wake(state);
146
146
  return Promise.resolve();
147
147
  }
148
+ touch(runId) {
149
+ const state = this.runs.get(runId);
150
+ if (!state || isTerminalRunStatus(state.record.status)) return Promise.resolve();
151
+ state.record.updatedAt = this.now();
152
+ return Promise.resolve();
153
+ }
154
+ finishIfStale(runId, cutoff, chunk) {
155
+ const state = this.runs.get(runId);
156
+ if (!state || isTerminalRunStatus(state.record.status) || state.record.updatedAt >= cutoff) return Promise.resolve(false);
157
+ const now = this.now();
158
+ const seq = state.record.lastSeq + 1;
159
+ state.chunks.push(chunk);
160
+ state.record.lastSeq = seq;
161
+ state.record.status = "failed";
162
+ state.record.error = {
163
+ message: chunk.message,
164
+ ...chunk.code !== void 0 ? { code: chunk.code } : {}
165
+ };
166
+ state.record.finishedAt = now;
167
+ state.record.updatedAt = now;
168
+ this.wake(state);
169
+ return Promise.resolve(true);
170
+ }
148
171
  update(runId, patch) {
149
172
  const state = this.runs.get(runId);
150
- if (!state) return Promise.resolve();
173
+ if (!state || isTerminalRunStatus(state.record.status)) return Promise.resolve();
151
174
  state.record = {
152
175
  ...state.record,
153
176
  ...patch,
@@ -1 +1 @@
1
- {"version":3,"file":"run-log.js","names":[],"sources":["../../src/run-log.ts"],"sourcesContent":["/**\n * Resumable run event-log — the primitive that lets a trigger (e.g. a\n * Cloudflare Worker) start an agent run and return immediately while a durable\n * orchestrator (e.g. a Durable Object) drives the run and persists every\n * emitted {@link StreamChunk} under a monotonic `seq`.\n *\n * Clients tail the log from a cursor (`fromSeq`), so a dropped connection, a new\n * browser tab, or an orchestrator that hibernated between chunks all reconnect\n * cleanly: replay everything after the client's last-seen `seq`, then live-tail\n * until the run reaches a terminal status. The *run* never depends on any single\n * connection staying open — that is what makes the serverless/edge model work.\n *\n * This module is transport- and storage-agnostic. {@link InMemoryRunEventLog} is\n * the default (single-process / tests); a durable backend (DO storage, KV, SQL)\n * implements the same {@link RunEventLog} interface — see\n * {@link DurableObjectRunEventLog} in `./run-log-do`.\n *\n * ---\n *\n * CONVERGED VOCABULARY (v1) + LIVE-DATA MIGRATION:\n *\n * This module used to keep a legacy vocabulary distinct from core's\n * (`TerminalRunStatus = 'done' | 'error' | 'aborted'`, a `RunRecord` with\n * `lastSeq`/`createdAt`/`updatedAt` and an optional `threadId`), deferred\n * because adopting core's shape meant migrating the Durable Object's persisted\n * record layout. That migration is now done: run statuses, `RunError`, and the\n * record's lifecycle fields come from `@tanstack/ai` (`'completed' | 'failed'\n * | 'aborted'` terminal set, `startedAt`/`finishedAt`, required `threadId`),\n * and {@link RunLogRecord} is core's {@link RunRecord} plus the two fields only\n * an event log needs: the `lastSeq` cursor and the `updatedAt` activity clock.\n *\n * Records persisted under the legacy layout are migrated **in place, on first\n * read**, by {@link migrateStoredRunRecord}:\n *\n * - `status` `'done'` → `'completed'`, `'error'` → `'failed'`\n * (`'running'`/`'aborted'` are unchanged);\n * - `createdAt` → `startedAt`; a terminal record gains\n * `finishedAt = updatedAt` (the closest stored approximation);\n * - a record persisted without `threadId` gets `threadId = runId`. The log\n * performs no thread-scoped queries, so this self-reference can never leak\n * into thread history; it exists only to satisfy the converged shape.\n *\n * `DurableObjectRunEventLog` writes the migrated record back on the read that\n * migrated it, so each record pays the conversion exactly once. The migration\n * is client-visible where the record is: `GET /runs/:id` and the WebSocket\n * terminal `status` frame now carry core's status strings and field names.\n */\nimport { isTerminalRunStatus } from '@tanstack/ai'\nimport type {\n RunError,\n RunRecord,\n RunStore,\n StreamChunk,\n TerminalRunStatus,\n} from '@tanstack/ai'\n\n/**\n * The mutable-field patch a {@link RunStore.update} accepts, reused verbatim so\n * the log can back a `RunStore` without restating (and drifting from) the pick.\n */\nexport type RunRecordPatch = Parameters<RunStore['update']>[1]\n\n/**\n * Durable bookkeeping for one run in the event log: core's {@link RunRecord}\n * plus the two fields only an event log needs.\n */\nexport interface RunLogRecord extends RunRecord {\n /** Seq of the last appended event, or `-1` when no events yet. */\n lastSeq: number\n /**\n * Epoch ms of the last append or status change — the activity clock a stall\n * watchdog reads. Distinct from `finishedAt`, which is set once, at terminal.\n */\n updatedAt: number\n}\n\n/** One persisted event: a chunk plus its monotonic, gap-free sequence number. */\nexport interface RunEvent {\n seq: number\n chunk: StreamChunk\n}\n\nexport interface RunEventLogReadOptions {\n /**\n * Exclusive cursor: only events with `seq > fromSeq` are yielded. Pass the\n * client's last-seen `seq` to resume; omit (or `-1`) to replay from the start.\n */\n fromSeq?: number\n /** Stop tailing when this fires (e.g. the client disconnected). */\n signal?: AbortSignal\n}\n\n/**\n * Append-only, `seq`-indexed log of a run's stream, with resumable reads.\n *\n * Contract:\n * - `append` assigns the next `seq` (0, 1, 2, …) and returns it.\n * - `read` yields the backlog after `fromSeq` in order, then live-tails new\n * events, and RETURNS once the run is terminal and the cursor has caught up.\n * - All methods reject for an unknown `runId` except `get`, which resolves null.\n */\nexport interface RunEventLog {\n /**\n * Idempotently create (or return) the run record. An existing record is\n * returned unchanged; `startedAt` (default `Date.now()`) applies only on\n * first creation — matching core's `RunStore.createOrResume` invariant, which\n * `runLogStore` maps directly onto this method.\n */\n open: (input: {\n runId: string\n threadId: string\n startedAt?: number\n }) => Promise<RunLogRecord>\n /** Append one chunk; resolves with its assigned `seq`. */\n append: (runId: string, chunk: StreamChunk) => Promise<number>\n /** Move the run to a terminal status. Idempotent for the same status. */\n finish: (\n runId: string,\n status: TerminalRunStatus,\n error?: RunError,\n ) => Promise<void>\n /**\n * Patch the record's mutable fields ({@link RunRecordPatch}). Unknown `runId`\n * is a NO-OP (never a throw, never a create) — core's `RunStore.update`\n * invariant, which `runLogStore` maps onto this method.\n *\n * MUST wake blocked readers, exactly like `append`/`finish`: the record and\n * the event log share one status field here, so a driver that terminalizes\n * through its `RunStore` — core's `pipeToRunLog` writes its terminal status\n * via `runs.update`, not `finish` — is ending the log with this call.\n */\n update: (runId: string, patch: RunRecordPatch) => Promise<void>\n /** Current record, or null if the run is unknown. */\n get: (runId: string) => Promise<RunLogRecord | null>\n /** Every run record this log holds. Backs `RunStore.findActiveRun`. */\n list: () => Promise<Array<RunLogRecord>>\n /** Replay-then-tail events with `seq > fromSeq` until the run is terminal. */\n read: (\n runId: string,\n options?: RunEventLogReadOptions,\n ) => AsyncIterable<RunEvent>\n}\n\n/**\n * The record layout this log persisted before converging on core's run\n * vocabulary. Never constructed by current code — it exists so\n * {@link migrateStoredRunRecord} can name what it reads out of old storage.\n */\ninterface LegacyStoredRunRecord {\n runId: string\n threadId?: string\n status: 'running' | 'done' | 'error' | 'aborted'\n lastSeq: number\n error?: RunError\n createdAt: number\n updatedAt: number\n}\n\nconst LEGACY_STATUS_MAP = {\n done: 'completed',\n error: 'failed',\n} as const\n\nfunction isLegacyStoredRunRecord(\n value: RunLogRecord | LegacyStoredRunRecord,\n): value is LegacyStoredRunRecord {\n // `createdAt` is the discriminant: it exists on every legacy record and on no\n // converged one. Status alone would miss legacy `running`/`aborted` records.\n return 'createdAt' in value\n}\n\n/**\n * Convert a stored record to the converged {@link RunLogRecord} layout.\n *\n * Total over both layouts: a converged record passes through unchanged\n * (`migrated: false`), a legacy one is mapped as documented in the module\n * header (`migrated: true`) so a durable backend can write the result back and\n * pay the conversion exactly once.\n */\nexport function migrateStoredRunRecord(\n stored: RunLogRecord | LegacyStoredRunRecord,\n): { record: RunLogRecord; migrated: boolean } {\n if (!isLegacyStoredRunRecord(stored))\n return { record: stored, migrated: false }\n const status =\n stored.status === 'done' || stored.status === 'error'\n ? LEGACY_STATUS_MAP[stored.status]\n : stored.status\n const record: RunLogRecord = {\n runId: stored.runId,\n // See the module header: the log runs no thread-scoped queries, so a\n // legacy record without a thread gets a self-reference, never a fake one.\n threadId: stored.threadId ?? stored.runId,\n status,\n lastSeq: stored.lastSeq,\n startedAt: stored.createdAt,\n updatedAt: stored.updatedAt,\n ...(isTerminalRunStatus(status) ? { finishedAt: stored.updatedAt } : {}),\n ...(stored.error !== undefined ? { error: stored.error } : {}),\n }\n return { record, migrated: true }\n}\n\n/** Per-run state for the in-memory log. */\ninterface RunState {\n record: RunLogRecord\n chunks: Array<StreamChunk>\n /** Resolved (and cleared) whenever an event is appended or status changes. */\n waiters: Set<() => void>\n}\n\n/**\n * Single-process {@link RunEventLog}. Backs `read`'s live-tail with an internal\n * waiter set: `append`/`finish` wake every blocked reader. Suitable for a\n * long-running Node host, tests, and as the reference implementation a durable\n * backend mirrors.\n */\nexport class InMemoryRunEventLog implements RunEventLog {\n private readonly runs = new Map<string, RunState>()\n\n private now(): number {\n return Date.now()\n }\n\n private require(runId: string): RunState {\n const state = this.runs.get(runId)\n if (!state) throw new Error(`run-log: unknown runId \"${runId}\"`)\n return state\n }\n\n private wake(state: RunState): void {\n const waiters = [...state.waiters]\n state.waiters.clear()\n for (const resolve of waiters) resolve()\n }\n\n // Mutators return a Promise without `async` so contract violations REJECT\n // (rather than throwing synchronously from a Promise-typed method — a\n // `.catch()` footgun) without an `await`-less async body.\n open(input: {\n runId: string\n threadId: string\n startedAt?: number\n }): Promise<RunLogRecord> {\n const existing = this.runs.get(input.runId)\n if (existing) return Promise.resolve({ ...existing.record })\n const now = this.now()\n const record: RunLogRecord = {\n runId: input.runId,\n threadId: input.threadId,\n status: 'running',\n lastSeq: -1,\n startedAt: input.startedAt ?? now,\n updatedAt: now,\n }\n this.runs.set(input.runId, { record, chunks: [], waiters: new Set() })\n return Promise.resolve({ ...record })\n }\n\n append(runId: string, chunk: StreamChunk): Promise<number> {\n const state = this.runs.get(runId)\n if (!state) {\n return Promise.reject(new Error(`run-log: unknown runId \"${runId}\"`))\n }\n if (isTerminalRunStatus(state.record.status)) {\n return Promise.reject(\n new Error(\n `run-log: cannot append to terminal run \"${runId}\" (status=${state.record.status})`,\n ),\n )\n }\n // Derive seq from the record's cursor (not `chunks.length`) so the gap-free\n // invariant holds the same way the durable backend computes it, even if the\n // backlog is ever trimmed/compacted.\n const seq = state.record.lastSeq + 1\n state.chunks.push(chunk)\n state.record.lastSeq = seq\n state.record.updatedAt = this.now()\n this.wake(state)\n return Promise.resolve(seq)\n }\n\n finish(\n runId: string,\n status: TerminalRunStatus,\n error?: RunError,\n ): Promise<void> {\n const state = this.runs.get(runId)\n if (!state) {\n return Promise.reject(new Error(`run-log: unknown runId \"${runId}\"`))\n }\n if (isTerminalRunStatus(state.record.status)) return Promise.resolve()\n const now = this.now()\n state.record.status = status\n if (error !== undefined) state.record.error = error\n state.record.finishedAt = now\n state.record.updatedAt = now\n this.wake(state)\n return Promise.resolve()\n }\n\n update(runId: string, patch: RunRecordPatch): Promise<void> {\n const state = this.runs.get(runId)\n if (!state) return Promise.resolve() // unknown runId is a no-op\n state.record = { ...state.record, ...patch, updatedAt: this.now() }\n // A patch may terminalize the shared status field (core's driver writes its\n // terminal status through `RunStore.update`) — parked readers must see it.\n this.wake(state)\n return Promise.resolve()\n }\n\n get(runId: string): Promise<RunLogRecord | null> {\n const state = this.runs.get(runId)\n return Promise.resolve(state ? { ...state.record } : null)\n }\n\n list(): Promise<Array<RunLogRecord>> {\n return Promise.resolve(\n [...this.runs.values()].map((state) => ({ ...state.record })),\n )\n }\n\n async *read(\n runId: string,\n options?: RunEventLogReadOptions,\n ): AsyncIterable<RunEvent> {\n const state = this.require(runId)\n const signal = options?.signal\n let cursor = options?.fromSeq ?? -1\n while (!signal?.aborted) {\n while (cursor < state.record.lastSeq) {\n cursor += 1\n const chunk = state.chunks[cursor]\n if (chunk !== undefined) yield { seq: cursor, chunk }\n }\n if (isTerminalRunStatus(state.record.status)) return\n await this.waitForChange(state, signal)\n }\n }\n\n private waitForChange(state: RunState, signal?: AbortSignal): Promise<void> {\n return new Promise<void>((resolve) => {\n const wake = (): void => {\n state.waiters.delete(wake)\n if (signal) signal.removeEventListener('abort', wake)\n resolve()\n }\n state.waiters.add(wake)\n if (signal) signal.addEventListener('abort', wake, { once: true })\n })\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"run-log.js","names":[],"sources":["../../src/run-log.ts"],"sourcesContent":["/**\n * Resumable run event-log — the primitive that lets a trigger (e.g. a\n * Cloudflare Worker) start an agent run and return immediately while a durable\n * orchestrator (e.g. a Durable Object) drives the run and persists every\n * emitted {@link StreamChunk} under a monotonic `seq`.\n *\n * Clients tail the log from a cursor (`fromSeq`), so a dropped connection, a new\n * browser tab, or an orchestrator that hibernated between chunks all reconnect\n * cleanly: replay everything after the client's last-seen `seq`, then live-tail\n * until the run reaches a terminal status. The *run* never depends on any single\n * connection staying open — that is what makes the serverless/edge model work.\n *\n * This module is transport- and storage-agnostic. {@link InMemoryRunEventLog} is\n * the default (single-process / tests); a durable backend (DO storage, KV, SQL)\n * implements the same {@link RunEventLog} interface — see\n * {@link DurableObjectRunEventLog} in `./run-log-do`.\n *\n * ---\n *\n * CONVERGED VOCABULARY (v1) + LIVE-DATA MIGRATION:\n *\n * This module used to keep a legacy vocabulary distinct from core's\n * (`TerminalRunStatus = 'done' | 'error' | 'aborted'`, a `RunRecord` with\n * `lastSeq`/`createdAt`/`updatedAt` and an optional `threadId`), deferred\n * because adopting core's shape meant migrating the Durable Object's persisted\n * record layout. That migration is now done: run statuses, `RunError`, and the\n * record's lifecycle fields come from `@tanstack/ai` (`'completed' | 'failed'\n * | 'aborted'` terminal set, `startedAt`/`finishedAt`, required `threadId`),\n * and {@link RunLogRecord} is core's {@link RunRecord} plus the two fields only\n * an event log needs: the `lastSeq` cursor and the `updatedAt` activity clock.\n *\n * Records persisted under the legacy layout are migrated **in place, on first\n * read**, by {@link migrateStoredRunRecord}:\n *\n * - `status` `'done'` → `'completed'`, `'error'` → `'failed'`\n * (`'running'`/`'aborted'` are unchanged);\n * - `createdAt` → `startedAt`; a terminal record gains\n * `finishedAt = updatedAt` (the closest stored approximation);\n * - a record persisted without `threadId` gets `threadId = runId`. The log\n * performs no thread-scoped queries, so this self-reference can never leak\n * into thread history; it exists only to satisfy the converged shape.\n *\n * `DurableObjectRunEventLog` writes the migrated record back on the read that\n * migrated it, so each record pays the conversion exactly once. The migration\n * is client-visible where the record is: `GET /runs/:id` and the WebSocket\n * terminal `status` frame now carry core's status strings and field names.\n */\nimport { isTerminalRunStatus } from '@tanstack/ai'\nimport type {\n RunError,\n RunRecord,\n RunStore,\n StreamChunk,\n TerminalRunStatus,\n} from '@tanstack/ai'\n\n/**\n * The mutable-field patch a {@link RunStore.update} accepts, reused verbatim so\n * the log can back a `RunStore` without restating (and drifting from) the pick.\n */\nexport type RunRecordPatch = Parameters<RunStore['update']>[1]\n\n/**\n * Durable bookkeeping for one run in the event log: core's {@link RunRecord}\n * plus the two fields only an event log needs.\n */\nexport interface RunLogRecord extends RunRecord {\n /** Seq of the last appended event, or `-1` when no events yet. */\n lastSeq: number\n /**\n * Epoch ms of the last append or status change — the activity clock a stall\n * watchdog reads. Distinct from `finishedAt`, which is set once, at terminal.\n */\n updatedAt: number\n}\n\n/** One persisted event: a chunk plus its monotonic, gap-free sequence number. */\nexport interface RunEvent {\n seq: number\n chunk: StreamChunk\n}\n\nexport interface RunEventLogReadOptions {\n /**\n * Exclusive cursor: only events with `seq > fromSeq` are yielded. Pass the\n * client's last-seen `seq` to resume; omit (or `-1`) to replay from the start.\n */\n fromSeq?: number\n /** Stop tailing when this fires (e.g. the client disconnected). */\n signal?: AbortSignal\n}\n\n/**\n * Append-only, `seq`-indexed log of a run's stream, with resumable reads.\n *\n * Contract:\n * - `append` assigns the next `seq` (0, 1, 2, …) and returns it.\n * - `read` yields the backlog after `fromSeq` in order, then live-tails new\n * events, and RETURNS once the run is terminal and the cursor has caught up.\n * - Unknown-run behavior is method-specific: `append`/`finish`/`read` reject,\n * `get` resolves null, and `update` is a no-op.\n */\nexport interface RunEventLog {\n /**\n * Idempotently create (or return) the run record. An existing record is\n * returned unchanged; `startedAt` (default `Date.now()`) applies only on\n * first creation — matching core's `RunStore.createOrResume` invariant, which\n * `runLogStore` maps directly onto this method.\n */\n open: (input: {\n runId: string\n threadId: string\n startedAt?: number\n }) => Promise<RunLogRecord>\n /** Append one chunk; resolves with its assigned `seq`. */\n append: (runId: string, chunk: StreamChunk) => Promise<number>\n /** Move the run to a terminal status. Idempotent for the same status. */\n finish: (\n runId: string,\n status: TerminalRunStatus,\n error?: RunError,\n ) => Promise<void>\n /**\n * Patch the record's mutable fields ({@link RunRecordPatch}). Unknown and\n * already-terminal runs are NO-OPs (never a throw, never a create), preserving\n * the first terminal status against late driver updates.\n *\n * MUST wake blocked readers, exactly like `append`/`finish`: the record and\n * the event log share one status field here, so a driver that terminalizes\n * through its `RunStore` — core's `pipeToRunLog` writes its terminal status\n * via `runs.update`, not `finish` — is ending the log with this call.\n */\n update: (runId: string, patch: RunRecordPatch) => Promise<void>\n /** Current record, or null if the run is unknown. */\n get: (runId: string) => Promise<RunLogRecord | null>\n /** Every run record this log holds. Backs `RunStore.findActiveRun`. */\n list: () => Promise<Array<RunLogRecord>>\n /** Replay-then-tail events with `seq > fromSeq` until the run is terminal. */\n read: (\n runId: string,\n options?: RunEventLogReadOptions,\n ) => AsyncIterable<RunEvent>\n}\n\n/**\n * The record layout this log persisted before converging on core's run\n * vocabulary. Never constructed by current code — it exists so\n * {@link migrateStoredRunRecord} can name what it reads out of old storage.\n */\ninterface LegacyStoredRunRecord {\n runId: string\n threadId?: string\n status: 'running' | 'done' | 'error' | 'aborted'\n lastSeq: number\n error?: RunError\n createdAt: number\n updatedAt: number\n}\n\nconst LEGACY_STATUS_MAP = {\n done: 'completed',\n error: 'failed',\n} as const\n\nfunction isLegacyStoredRunRecord(\n value: RunLogRecord | LegacyStoredRunRecord,\n): value is LegacyStoredRunRecord {\n // `createdAt` is the discriminant: it exists on every legacy record and on no\n // converged one. Status alone would miss legacy `running`/`aborted` records.\n return 'createdAt' in value\n}\n\n/**\n * Convert a stored record to the converged {@link RunLogRecord} layout.\n *\n * Total over both layouts: a converged record passes through unchanged\n * (`migrated: false`), a legacy one is mapped as documented in the module\n * header (`migrated: true`) so a durable backend can write the result back and\n * pay the conversion exactly once.\n */\nexport function migrateStoredRunRecord(\n stored: RunLogRecord | LegacyStoredRunRecord,\n): { record: RunLogRecord; migrated: boolean } {\n if (!isLegacyStoredRunRecord(stored))\n return { record: stored, migrated: false }\n const status =\n stored.status === 'done' || stored.status === 'error'\n ? LEGACY_STATUS_MAP[stored.status]\n : stored.status\n const record: RunLogRecord = {\n runId: stored.runId,\n // See the module header: the log runs no thread-scoped queries, so a\n // legacy record without a thread gets a self-reference, never a fake one.\n threadId: stored.threadId ?? stored.runId,\n status,\n lastSeq: stored.lastSeq,\n startedAt: stored.createdAt,\n updatedAt: stored.updatedAt,\n ...(isTerminalRunStatus(status) ? { finishedAt: stored.updatedAt } : {}),\n ...(stored.error !== undefined ? { error: stored.error } : {}),\n }\n return { record, migrated: true }\n}\n\n/** Per-run state for the in-memory log. */\ninterface RunState {\n record: RunLogRecord\n chunks: Array<StreamChunk>\n /** Resolved (and cleared) whenever an event is appended or status changes. */\n waiters: Set<() => void>\n}\n\n/**\n * Single-process {@link RunEventLog}. Backs `read`'s live-tail with an internal\n * waiter set: `append`/`finish` wake every blocked reader. Suitable for a\n * long-running Node host, tests, and as the reference implementation a durable\n * backend mirrors.\n */\nexport class InMemoryRunEventLog implements RunEventLog {\n private readonly runs = new Map<string, RunState>()\n\n private now(): number {\n return Date.now()\n }\n\n private require(runId: string): RunState {\n const state = this.runs.get(runId)\n if (!state) throw new Error(`run-log: unknown runId \"${runId}\"`)\n return state\n }\n\n private wake(state: RunState): void {\n const waiters = [...state.waiters]\n state.waiters.clear()\n for (const resolve of waiters) resolve()\n }\n\n // Mutators return a Promise without `async` so contract violations REJECT\n // (rather than throwing synchronously from a Promise-typed method — a\n // `.catch()` footgun) without an `await`-less async body.\n open(input: {\n runId: string\n threadId: string\n startedAt?: number\n }): Promise<RunLogRecord> {\n const existing = this.runs.get(input.runId)\n if (existing) return Promise.resolve({ ...existing.record })\n const now = this.now()\n const record: RunLogRecord = {\n runId: input.runId,\n threadId: input.threadId,\n status: 'running',\n lastSeq: -1,\n startedAt: input.startedAt ?? now,\n updatedAt: now,\n }\n this.runs.set(input.runId, { record, chunks: [], waiters: new Set() })\n return Promise.resolve({ ...record })\n }\n\n append(runId: string, chunk: StreamChunk): Promise<number> {\n const state = this.runs.get(runId)\n if (!state) {\n return Promise.reject(new Error(`run-log: unknown runId \"${runId}\"`))\n }\n if (isTerminalRunStatus(state.record.status)) {\n return Promise.reject(\n new Error(\n `run-log: cannot append to terminal run \"${runId}\" (status=${state.record.status})`,\n ),\n )\n }\n // Derive seq from the record's cursor (not `chunks.length`) so the gap-free\n // invariant holds the same way the durable backend computes it, even if the\n // backlog is ever trimmed/compacted.\n const seq = state.record.lastSeq + 1\n state.chunks.push(chunk)\n state.record.lastSeq = seq\n state.record.updatedAt = this.now()\n this.wake(state)\n return Promise.resolve(seq)\n }\n\n finish(\n runId: string,\n status: TerminalRunStatus,\n error?: RunError,\n ): Promise<void> {\n const state = this.runs.get(runId)\n if (!state) {\n return Promise.reject(new Error(`run-log: unknown runId \"${runId}\"`))\n }\n if (isTerminalRunStatus(state.record.status)) return Promise.resolve()\n const now = this.now()\n state.record.status = status\n if (error !== undefined) state.record.error = error\n state.record.finishedAt = now\n state.record.updatedAt = now\n this.wake(state)\n return Promise.resolve()\n }\n\n touch(runId: string): Promise<void> {\n const state = this.runs.get(runId)\n if (!state || isTerminalRunStatus(state.record.status)) {\n return Promise.resolve()\n }\n state.record.updatedAt = this.now()\n return Promise.resolve()\n }\n\n finishIfStale(\n runId: string,\n cutoff: number,\n chunk: Extract<StreamChunk, { type: 'RUN_ERROR' }>,\n ): Promise<boolean> {\n const state = this.runs.get(runId)\n if (\n !state ||\n isTerminalRunStatus(state.record.status) ||\n state.record.updatedAt >= cutoff\n ) {\n return Promise.resolve(false)\n }\n\n const now = this.now()\n const seq = state.record.lastSeq + 1\n state.chunks.push(chunk)\n state.record.lastSeq = seq\n state.record.status = 'failed'\n state.record.error = {\n message: chunk.message,\n ...(chunk.code !== undefined ? { code: chunk.code } : {}),\n }\n state.record.finishedAt = now\n state.record.updatedAt = now\n this.wake(state)\n return Promise.resolve(true)\n }\n\n update(runId: string, patch: RunRecordPatch): Promise<void> {\n const state = this.runs.get(runId)\n if (!state || isTerminalRunStatus(state.record.status)) {\n return Promise.resolve()\n }\n state.record = { ...state.record, ...patch, updatedAt: this.now() }\n // A patch may terminalize the shared status field (core's driver writes its\n // terminal status through `RunStore.update`) — parked readers must see it.\n this.wake(state)\n return Promise.resolve()\n }\n\n get(runId: string): Promise<RunLogRecord | null> {\n const state = this.runs.get(runId)\n return Promise.resolve(state ? { ...state.record } : null)\n }\n\n list(): Promise<Array<RunLogRecord>> {\n return Promise.resolve(\n [...this.runs.values()].map((state) => ({ ...state.record })),\n )\n }\n\n async *read(\n runId: string,\n options?: RunEventLogReadOptions,\n ): AsyncIterable<RunEvent> {\n const state = this.require(runId)\n const signal = options?.signal\n let cursor = options?.fromSeq ?? -1\n while (!signal?.aborted) {\n while (cursor < state.record.lastSeq) {\n cursor += 1\n const chunk = state.chunks[cursor]\n if (chunk !== undefined) yield { seq: cursor, chunk }\n }\n if (isTerminalRunStatus(state.record.status)) return\n await this.waitForChange(state, signal)\n }\n }\n\n private waitForChange(state: RunState, signal?: AbortSignal): Promise<void> {\n return new Promise<void>((resolve) => {\n const wake = (): void => {\n state.waiters.delete(wake)\n if (signal) signal.removeEventListener('abort', wake)\n resolve()\n }\n state.waiters.add(wake)\n if (signal) signal.addEventListener('abort', wake, { once: true })\n })\n }\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/ai-sandbox-cloudflare",
3
- "version": "0.3.14",
3
+ "version": "0.4.2",
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,8 +45,8 @@
45
45
  },
46
46
  "peerDependencies": {
47
47
  "zod": "^4.0.0",
48
- "@tanstack/ai": "^0.55.0",
49
- "@tanstack/ai-sandbox": "^0.5.9",
48
+ "@tanstack/ai": "^0.57.0",
49
+ "@tanstack/ai-sandbox": "^0.5.11",
50
50
  "@tanstack/ai-sandbox-local-process": "^0.2.5"
51
51
  },
52
52
  "devDependencies": {
@@ -54,8 +54,8 @@
54
54
  "@types/node": "^24.10.1",
55
55
  "@vitest/coverage-v8": "4.1.10",
56
56
  "zod": "^4.2.0",
57
- "@tanstack/ai": "0.55.0",
58
- "@tanstack/ai-sandbox": "0.5.9",
57
+ "@tanstack/ai": "0.57.0",
58
+ "@tanstack/ai-sandbox": "0.5.11",
59
59
  "@tanstack/ai-sandbox-local-process": "0.2.5"
60
60
  },
61
61
  "scripts": {
@@ -30,6 +30,7 @@ import {
30
30
  withSandbox,
31
31
  } from '@tanstack/ai-sandbox'
32
32
  import { SandboxCoordinator, resolveBridgeOrigin } from './coordinator'
33
+ import { runWithCallbackActivity } from './coordinator-callbacks'
33
34
  import { timingSafeBearerEqualWeb } from './web-crypto'
34
35
  import type { StartRunInput } from './coordinator'
35
36
  import type {
@@ -233,21 +234,29 @@ export abstract class ChatSandboxCoordinator<
233
234
  ) {
234
235
  return new Response('unauthorized', { status: 401 })
235
236
  }
236
- let message: unknown
237
- try {
238
- message = await request.json()
239
- } catch {
240
- // A malformed body must still produce a valid JSON-RPC error so the agent's
241
- // MCP client can react, rather than an opaque DO 500 that can wedge the run.
242
- return this.jsonResponse({
243
- jsonrpc: '2.0',
244
- id: null,
245
- error: { code: -32700, message: 'Parse error' },
246
- })
247
- }
248
- const reply = await handleBridgeJsonRpc(bridge.core, message)
249
- // A notification (no id) yields null → MCP expects an empty 202 ack.
250
- if (reply === null) return new Response(null, { status: 202 })
251
- return this.jsonResponse(reply)
237
+ return runWithCallbackActivity(
238
+ this,
239
+ runId,
240
+ (id) => this.log.touch(id),
241
+ async () => {
242
+ let message: unknown
243
+ try {
244
+ message = await request.json()
245
+ } catch {
246
+ // A malformed body must still produce a valid JSON-RPC error so the
247
+ // agent's MCP client can react, rather than an opaque DO 500 that can
248
+ // wedge the run.
249
+ return this.jsonResponse({
250
+ jsonrpc: '2.0',
251
+ id: null,
252
+ error: { code: -32700, message: 'Parse error' },
253
+ })
254
+ }
255
+ const reply = await handleBridgeJsonRpc(bridge.core, message)
256
+ // A notification (no id) yields null → MCP expects an empty 202 ack.
257
+ if (reply === null) return new Response(null, { status: 202 })
258
+ return this.jsonResponse(reply)
259
+ },
260
+ )
252
261
  }
253
262
  }
@@ -34,6 +34,7 @@ import {
34
34
  } from '@tanstack/ai-sandbox'
35
35
  import { getSandbox } from '@cloudflare/sandbox'
36
36
  import { SandboxCoordinator, resolveBridgeOrigin } from './coordinator'
37
+ import { runWithCallbackActivity } from './coordinator-callbacks'
37
38
  import { timingSafeBearerEqualWeb } from './web-crypto'
38
39
  import type { StartRunInput } from './coordinator'
39
40
  import type { ContainerRunRequest, HarnessId } from './protocol'
@@ -409,29 +410,41 @@ export abstract class ContainerSandboxCoordinator<
409
410
  ) {
410
411
  return new Response('unauthorized', { status: 401 })
411
412
  }
412
- let payload: unknown
413
- try {
414
- payload = await request.json()
415
- } catch {
416
- return this.jsonResponse({ error: 'body must be valid JSON' }, 400)
417
- }
418
- if (!isToolExecRequest(payload)) {
419
- return this.jsonResponse({ error: 'body must be { name, args }' }, 400)
420
- }
421
- try {
422
- const result = await executeHostTool(
423
- state.hostTools,
424
- payload.name,
425
- payload.args,
426
- {
427
- ...(state.context !== undefined ? { context: state.context } : {}),
428
- signal: state.abort.signal,
429
- },
430
- )
431
- return this.jsonResponse({ result })
432
- } catch (error) {
433
- const message = error instanceof Error ? error.message : String(error)
434
- return this.jsonResponse({ error: message }, 500)
435
- }
413
+ return runWithCallbackActivity(
414
+ this,
415
+ runId,
416
+ (id) => this.log.touch(id),
417
+ async () => {
418
+ let payload: unknown
419
+ try {
420
+ payload = await request.json()
421
+ } catch {
422
+ return this.jsonResponse({ error: 'body must be valid JSON' }, 400)
423
+ }
424
+ if (!isToolExecRequest(payload)) {
425
+ return this.jsonResponse(
426
+ { error: 'body must be { name, args }' },
427
+ 400,
428
+ )
429
+ }
430
+ try {
431
+ const result = await executeHostTool(
432
+ state.hostTools,
433
+ payload.name,
434
+ payload.args,
435
+ {
436
+ ...(state.context !== undefined
437
+ ? { context: state.context }
438
+ : {}),
439
+ signal: state.abort.signal,
440
+ },
441
+ )
442
+ return this.jsonResponse({ result })
443
+ } catch (error) {
444
+ const message = error instanceof Error ? error.message : String(error)
445
+ return this.jsonResponse({ error: message }, 500)
446
+ }
447
+ },
448
+ )
436
449
  }
437
450
  }
@@ -0,0 +1,58 @@
1
+ type TouchRun = (runId: string) => Promise<void>
2
+
3
+ const inFlightCallbacks = new WeakMap<object, Map<string, number>>()
4
+
5
+ function increment(owner: object, runId: string): void {
6
+ let runs = inFlightCallbacks.get(owner)
7
+ if (!runs) {
8
+ runs = new Map()
9
+ inFlightCallbacks.set(owner, runs)
10
+ }
11
+ runs.set(runId, (runs.get(runId) ?? 0) + 1)
12
+ }
13
+
14
+ function decrement(owner: object, runId: string): void {
15
+ const runs = inFlightCallbacks.get(owner)
16
+ if (!runs) return
17
+ const count = runs.get(runId) ?? 0
18
+ if (count > 1) {
19
+ runs.set(runId, count - 1)
20
+ return
21
+ }
22
+ runs.delete(runId)
23
+ if (runs.size === 0) inFlightCallbacks.delete(owner)
24
+ }
25
+
26
+ export function hasInFlightCallback(owner: object, runId: string): boolean {
27
+ return (inFlightCallbacks.get(owner)?.get(runId) ?? 0) > 0
28
+ }
29
+
30
+ export async function runWithCallbackActivity<T>(
31
+ owner: object,
32
+ runId: string,
33
+ touch: TouchRun,
34
+ operation: () => Promise<T>,
35
+ ): Promise<T> {
36
+ increment(owner, runId)
37
+ try {
38
+ // Failure here proves liveness could not be persisted, so do not execute the
39
+ // callback operation.
40
+ await touch(runId)
41
+ try {
42
+ return await operation()
43
+ } finally {
44
+ // This is best-effort bookkeeping after an operation has produced its
45
+ // result or error. It must never replace that original outcome.
46
+ try {
47
+ await touch(runId)
48
+ } catch (error) {
49
+ console.error(
50
+ `[sandbox-coordinator] completion activity touch failed for run ${runId}:`,
51
+ error,
52
+ )
53
+ }
54
+ }
55
+ } finally {
56
+ decrement(owner, runId)
57
+ }
58
+ }