@tanstack/ai-sandbox-cloudflare 0.3.12 → 0.4.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.
- package/dist/esm/chat-coordinator.js +19 -16
- package/dist/esm/chat-coordinator.js.map +1 -1
- package/dist/esm/container-coordinator.js +20 -17
- package/dist/esm/container-coordinator.js.map +1 -1
- package/dist/esm/coordinator-callbacks.d.ts +4 -0
- package/dist/esm/coordinator-callbacks.js +45 -0
- package/dist/esm/coordinator-callbacks.js.map +1 -0
- package/dist/esm/coordinator.d.ts +7 -2
- package/dist/esm/coordinator.js +43 -28
- package/dist/esm/coordinator.js.map +1 -1
- package/dist/esm/factory.d.ts +5 -0
- package/dist/esm/factory.js +8 -1
- package/dist/esm/factory.js.map +1 -1
- package/dist/esm/preview-tool.js +77 -1
- package/dist/esm/preview-tool.js.map +1 -1
- package/dist/esm/run-log-do.d.ts +4 -0
- package/dist/esm/run-log-do.js +59 -9
- package/dist/esm/run-log-do.js.map +1 -1
- package/dist/esm/run-log.d.ts +9 -4
- package/dist/esm/run-log.js +24 -1
- package/dist/esm/run-log.js.map +1 -1
- package/package.json +5 -5
- package/src/chat-coordinator.ts +25 -16
- package/src/container-coordinator.ts +37 -24
- package/src/coordinator-callbacks.ts +58 -0
- package/src/coordinator.ts +66 -30
- package/src/factory.ts +15 -1
- package/src/preview-tool.ts +133 -1
- package/src/run-log-do.ts +71 -7
- package/src/run-log.ts +46 -5
package/dist/esm/factory.js
CHANGED
|
@@ -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 {
|
package/dist/esm/factory.js.map
CHANGED
|
@@ -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"}
|
package/dist/esm/preview-tool.js
CHANGED
|
@@ -56,6 +56,66 @@ var PREVIEW_GUIDANCE = [
|
|
|
56
56
|
"• Other dev servers — bind 0.0.0.0 and allow all hosts equivalently.",
|
|
57
57
|
"Once it is listening, call `exposePreview` with that port, then share the URL."
|
|
58
58
|
].join("\n");
|
|
59
|
+
var LOCAL_PROBE_TIMEOUT_MS = 5e3;
|
|
60
|
+
var EDGE_PROBE_ATTEMPTS = 5;
|
|
61
|
+
var EDGE_PROBE_BASE_DELAY_MS = 250;
|
|
62
|
+
var EDGE_PROBE_FETCH_TIMEOUT_MS = 3e3;
|
|
63
|
+
/**
|
|
64
|
+
* Probe the port INSIDE the sandbox via `containerFetch`. Returns the HTTP
|
|
65
|
+
* status when a listener answered (any response — 4xx/5xx included — proves one
|
|
66
|
+
* exists), or the failure symptom (a string) when nothing did.
|
|
67
|
+
*/
|
|
68
|
+
async function localProbe(sandbox, port) {
|
|
69
|
+
let timeoutId;
|
|
70
|
+
const timeout = new Promise((_, reject) => {
|
|
71
|
+
timeoutId = setTimeout(() => reject(/* @__PURE__ */ new Error(`no response within ${LOCAL_PROBE_TIMEOUT_MS}ms`)), LOCAL_PROBE_TIMEOUT_MS);
|
|
72
|
+
});
|
|
73
|
+
try {
|
|
74
|
+
return (await Promise.race([sandbox.containerFetch("http://preview/", { method: "HEAD" }, port), timeout])).status;
|
|
75
|
+
} catch (error) {
|
|
76
|
+
return error instanceof Error ? error.message : String(error);
|
|
77
|
+
} finally {
|
|
78
|
+
clearTimeout(timeoutId);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Probe a tunnel URL through the public edge with bounded retries. Only 502/530
|
|
83
|
+
* — Cloudflare's tunnel/origin-unreachable signatures — mark the URL as
|
|
84
|
+
* unreachable (401/403/404 prove the server answered, and `redirect: 'manual'`
|
|
85
|
+
* keeps a login redirect from probing some other site), and even those are
|
|
86
|
+
* trusted when the app answered the SAME status locally, so an app's own
|
|
87
|
+
* 502/530 never gets its healthy tunnel destroyed. The verdict split exists
|
|
88
|
+
* because only an OBSERVED non-matching 502/530 ('stale') is evidence that
|
|
89
|
+
* justifies destroying the tunnel; fetch exceptions ('unverified') prove
|
|
90
|
+
* nothing about it — the probe path itself may be what failed. A throw
|
|
91
|
+
* anywhere in the retry window therefore wins over an earlier 502/530: the
|
|
92
|
+
* window did not finish as HTTP probes, so we must not destroy.
|
|
93
|
+
*/
|
|
94
|
+
async function edgeProbeFailure(url, localStatus) {
|
|
95
|
+
let lastFailure = "no response";
|
|
96
|
+
let verdict = "unverified";
|
|
97
|
+
let sawFetchException = false;
|
|
98
|
+
for (let attempt = 0; attempt < EDGE_PROBE_ATTEMPTS; attempt += 1) {
|
|
99
|
+
if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, EDGE_PROBE_BASE_DELAY_MS * 2 ** (attempt - 1)));
|
|
100
|
+
try {
|
|
101
|
+
const res = await fetch(url, {
|
|
102
|
+
method: "HEAD",
|
|
103
|
+
redirect: "manual",
|
|
104
|
+
signal: AbortSignal.timeout(EDGE_PROBE_FETCH_TIMEOUT_MS)
|
|
105
|
+
});
|
|
106
|
+
if (res.status !== 502 && res.status !== 530 || res.status === localStatus) return null;
|
|
107
|
+
lastFailure = `HTTP ${res.status}`;
|
|
108
|
+
verdict = "stale";
|
|
109
|
+
} catch (error) {
|
|
110
|
+
sawFetchException = true;
|
|
111
|
+
lastFailure = error instanceof Error ? error.message : String(error);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
verdict: sawFetchException ? "unverified" : verdict,
|
|
116
|
+
symptom: lastFailure
|
|
117
|
+
};
|
|
118
|
+
}
|
|
59
119
|
/**
|
|
60
120
|
* Build the `exposePreview` server tool for one run. Starting a tunnel is a
|
|
61
121
|
* HOST-side call on the Sandbox DO stub, so an in-sandbox agent cannot make it from
|
|
@@ -71,7 +131,23 @@ function exposePreviewTool(input, env) {
|
|
|
71
131
|
description: "Expose a port a dev server is listening on inside the sandbox and return a public preview URL (a Cloudflare quick tunnel) to show the user. Call this AFTER the server is up. The dev server must allow all hosts (e.g. Vite `server.allowedHosts: true`) so it accepts the tunnel hostname.",
|
|
72
132
|
inputSchema: z.object({ port: z.number().int().min(1024).max(65535).describe("The port the dev server is listening on, e.g. 5173.") })
|
|
73
133
|
}).server(async ({ port }) => {
|
|
74
|
-
|
|
134
|
+
const sandbox = getSandbox(env.Sandbox, input.threadId, { transport: "rpc" });
|
|
135
|
+
const local = await localProbe(sandbox, port);
|
|
136
|
+
if (typeof local === "string") throw new Error(`No server is listening on port ${port} inside the sandbox (${local}). Start the dev server (bound to 0.0.0.0:${port}) first, then retry exposePreview.`);
|
|
137
|
+
const tunnel = await sandbox.tunnels.get(port);
|
|
138
|
+
const edgeFailure = await edgeProbeFailure(tunnel.url, local);
|
|
139
|
+
if (edgeFailure === null) return { url: tunnel.url };
|
|
140
|
+
if (edgeFailure.verdict === "unverified") throw new Error(`Port ${port} is serving inside the sandbox, but the preview tunnel could not be verified from the edge (${edgeFailure.symptom}). The tunnel was left in place — retry exposePreview in a few seconds.`);
|
|
141
|
+
await sandbox.tunnels.destroy(port);
|
|
142
|
+
const fresh = await sandbox.tunnels.get(port);
|
|
143
|
+
const freshFailure = await edgeProbeFailure(fresh.url, local);
|
|
144
|
+
if (freshFailure === null) return {
|
|
145
|
+
url: fresh.url,
|
|
146
|
+
note: `The tunnel for port ${port} was stale, so it was replaced. Any previously shared preview URL for this port is dead — share this new URL instead.`
|
|
147
|
+
};
|
|
148
|
+
if (freshFailure.verdict === "stale") await sandbox.tunnels.destroy(port);
|
|
149
|
+
const [diagnosis, hint] = freshFailure.verdict === "stale" ? ["its preview tunnel never became reachable", "Retry exposePreview, and if it keeps failing, restart the dev server and try again."] : ["the replacement preview tunnel could not be verified from the edge", "Retry exposePreview in a few seconds."];
|
|
150
|
+
throw new Error(`Port ${port} is serving inside the sandbox, but ${diagnosis} (old tunnel: ${edgeFailure.symptom}; replacement tunnel: ${freshFailure.symptom}). ${hint}`);
|
|
75
151
|
});
|
|
76
152
|
}
|
|
77
153
|
//#endregion
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"preview-tool.js","names":[],"sources":["../../src/preview-tool.ts"],"sourcesContent":["/**\n * The browser-preview capability, as reusable building blocks rather than\n * per-app glue: a `chat()` server tool that mints a preview URL for a dev server\n * running inside the sandbox, plus the system-prompt guidance an agent needs to\n * produce a preview that works.\n *\n * Previews go over a **Cloudflare quick tunnel** (`sandbox.tunnels.get(port)` →\n * `https://<name>.trycloudflare.com`), served by `cloudflared` INSIDE the sandbox.\n * We deliberately do NOT use `exposePort` + `proxyToSandbox` here: that routes the\n * preview through the Worker's own origin, which in local dev is the example's Vite\n * dev server — and Vite's middleware then serves the preview's module/asset\n * requests (`/@vite/client`, `/src/*`, `/@fs/*`) from the HOST instead of the\n * container, breaking the page. A tunnel bypasses the Vite port entirely, needs no\n * custom domain on a deploy, and forwards WebSockets (so the app's HMR works).\n *\n * Both exports belong to THIS package because the transport is its concern, not any\n * particular app's. Wire them explicitly into your agent:\n *\n * ```ts\n * import {\n * exposePreviewTool,\n * PREVIEW_GUIDANCE,\n * } from '@tanstack/ai-sandbox-cloudflare/agent'\n *\n * createCloudflareSandboxAgent({\n * adapter: () => claudeCodeText('sonnet'),\n * tools: (input, env) => [exposePreviewTool(input, env)],\n * systemPrompts: [PREVIEW_GUIDANCE],\n * })\n * ```\n *\n * Workers-only (imports `@cloudflare/sandbox`) — exported from the `/agent` entry.\n */\nimport { toolDefinition } from '@tanstack/ai'\nimport { z } from 'zod'\nimport { getSandbox } from '@cloudflare/sandbox'\nimport type { Sandbox } from '@cloudflare/sandbox'\nimport type { StartRunInput } from './coordinator'\n\n/**\n * The minimum env an {@link exposePreviewTool} needs: the Sandbox namespace it\n * addresses the run's container in. `SandboxAgentEnv` satisfies this structurally,\n * so the factory's `tools` resolver passes its env straight in.\n */\nexport interface PreviewToolEnv {\n Sandbox: DurableObjectNamespace<Sandbox>\n}\n\n/**\n * System-prompt guidance for any agent that exposes a dev server as a browser\n * preview. App-agnostic: the only requirement a quick tunnel imposes is that the\n * dev server accept the tunnel hostname (Vite/webpack reject unknown hosts by\n * default), so the rule is \"bind wide + allow all hosts\", not \"disable HMR\" — the\n * tunnel forwards WebSockets, so HMR works.\n */\nexport const PREVIEW_GUIDANCE: string = [\n 'PREVIEW SERVERS: to show the user a running web app, start its dev server bound',\n 'to 0.0.0.0 on a port OTHER than 3000 (3000 is reserved by the sandbox control',\n 'plane), then call the `exposePreview` tool with that port. It returns a public',\n 'Cloudflare quick-tunnel URL (https://<name>.trycloudflare.com) served straight',\n 'from the sandbox — no custom domain needed, and HMR / live-reload WebSockets',\n 'work through the tunnel (you do NOT need to disable HMR). The ONE requirement:',\n 'the dev server must ACCEPT the tunnel hostname, which servers reject by default,',\n 'so allow all hosts in its config before starting:',\n '• Vite — `server: { host: true, allowedHosts: true }` in vite.config.',\n \"• webpack-dev-server — `allowedHosts: 'all'` (and `host: '0.0.0.0'`).\",\n '• Other dev servers — bind 0.0.0.0 and allow all hosts equivalently.',\n 'Once it is listening, call `exposePreview` with that port, then share the URL.',\n].join('\\n')\n\n/**\n * Build the `exposePreview` server tool for one run. Starting a tunnel is a\n * HOST-side call on the Sandbox DO stub, so an in-sandbox agent cannot make it from\n * bash — it calls this bridged tool instead. We address the run's container by\n * `threadId` and open (or reuse) a quick tunnel to the given port.\n *\n * Closes over the run's `input` + `env`, so build it inside the `tools` resolver\n * (`tools: (input, env) => [exposePreviewTool(input, env)]`).\n */\nexport function exposePreviewTool(input: StartRunInput, env: PreviewToolEnv) {\n return toolDefinition({\n name: 'exposePreview',\n description:\n 'Expose a port a dev server is listening on inside the sandbox and return a public preview URL (a Cloudflare quick tunnel) to show the user. Call this AFTER the server is up. The dev server must allow all hosts (e.g. Vite `server.allowedHosts: true`) so it accepts the tunnel hostname.',\n inputSchema: z.object({\n port: z\n .number()\n .int()\n .min(1024)\n .max(65535)\n .describe('The port the dev server is listening on, e.g. 5173.'),\n }),\n }).server(async ({ port }) => {\n // `sandbox.tunnels` only exists on the RPC transport (on HTTP/WebSocket it's a\n // stub that throws \"requires the RPC transport\"), so we must obtain the stub\n // with `transport: 'rpc'`. IMPORTANT: this must MATCH how the sandbox was\n // created — pass `transport: 'rpc'` on EVERY `getSandbox()` for this id (in your\n // sandbox provider too), or the differing transport disconnects the run's active\n // client. See the SDK `SandboxOptions.transport` note.\n const sandbox = getSandbox(env.Sandbox, input.threadId, {\n transport: 'rpc',\n })\n // A Cloudflare quick tunnel (`*.trycloudflare.com`) run by `cloudflared` INSIDE\n // the sandbox: it bypasses the local Vite dev server's port entirely (so Vite\n // can't hijack the preview's asset requests) and needs no custom domain on a\n // deploy. `get(port)` is idempotent per port. See the Sandbox SDK `tunnels` API.\n const tunnel = await sandbox.tunnels.get(port)\n return { url: tunnel.url }\n })\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAuDA,IAAa,mBAA2B;CACtC;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF,CAAC,CAAC,KAAK,IAAI;;;;;;;;;;AAWX,SAAgB,kBAAkB,OAAsB,KAAqB;CAC3E,OAAO,eAAe;EACpB,MAAM;EACN,aACE;EACF,aAAa,EAAE,OAAO,EACpB,MAAM,EACH,OAAO,CAAC,CACR,IAAI,CAAC,CACL,IAAI,IAAI,CAAC,CACT,IAAI,KAAK,CAAC,CACV,SAAS,qDAAqD,EACnE,CAAC;CACH,CAAC,CAAC,CAAC,OAAO,OAAO,EAAE,WAAW;EAe5B,OAAO,EAAE,MAAK,MARE,WAAW,IAAI,SAAS,MAAM,UAAU,EACtD,WAAW,MACb,CAKqB,CAAA,CAAQ,QAAQ,IAAI,IAAI,EAAA,CACxB,IAAI;CAC3B,CAAC;AACH"}
|
|
1
|
+
{"version":3,"file":"preview-tool.js","names":[],"sources":["../../src/preview-tool.ts"],"sourcesContent":["/**\n * The browser-preview capability, as reusable building blocks rather than\n * per-app glue: a `chat()` server tool that mints a preview URL for a dev server\n * running inside the sandbox, plus the system-prompt guidance an agent needs to\n * produce a preview that works.\n *\n * Previews go over a **Cloudflare quick tunnel** (`sandbox.tunnels.get(port)` →\n * `https://<name>.trycloudflare.com`), served by `cloudflared` INSIDE the sandbox.\n * We deliberately do NOT use `exposePort` + `proxyToSandbox` here: that routes the\n * preview through the Worker's own origin, which in local dev is the example's Vite\n * dev server — and Vite's middleware then serves the preview's module/asset\n * requests (`/@vite/client`, `/src/*`, `/@fs/*`) from the HOST instead of the\n * container, breaking the page. A tunnel bypasses the Vite port entirely, needs no\n * custom domain on a deploy, and forwards WebSockets (so the app's HMR works).\n *\n * Both exports belong to THIS package because the transport is its concern, not any\n * particular app's. Wire them explicitly into your agent:\n *\n * ```ts\n * import {\n * exposePreviewTool,\n * PREVIEW_GUIDANCE,\n * } from '@tanstack/ai-sandbox-cloudflare/agent'\n *\n * createCloudflareSandboxAgent({\n * adapter: () => claudeCodeText('sonnet'),\n * tools: (input, env) => [exposePreviewTool(input, env)],\n * systemPrompts: [PREVIEW_GUIDANCE],\n * })\n * ```\n *\n * Workers-only (imports `@cloudflare/sandbox`) — exported from the `/agent` entry.\n */\nimport { toolDefinition } from '@tanstack/ai'\nimport { z } from 'zod'\nimport { getSandbox } from '@cloudflare/sandbox'\nimport type { Sandbox } from '@cloudflare/sandbox'\nimport type { StartRunInput } from './coordinator'\n\n/**\n * The minimum env an {@link exposePreviewTool} needs: the Sandbox namespace it\n * addresses the run's container in. `SandboxAgentEnv` satisfies this structurally,\n * so the factory's `tools` resolver passes its env straight in.\n */\nexport interface PreviewToolEnv {\n Sandbox: DurableObjectNamespace<Sandbox>\n}\n\n/**\n * System-prompt guidance for any agent that exposes a dev server as a browser\n * preview. App-agnostic: the only requirement a quick tunnel imposes is that the\n * dev server accept the tunnel hostname (Vite/webpack reject unknown hosts by\n * default), so the rule is \"bind wide + allow all hosts\", not \"disable HMR\" — the\n * tunnel forwards WebSockets, so HMR works.\n */\nexport const PREVIEW_GUIDANCE: string = [\n 'PREVIEW SERVERS: to show the user a running web app, start its dev server bound',\n 'to 0.0.0.0 on a port OTHER than 3000 (3000 is reserved by the sandbox control',\n 'plane), then call the `exposePreview` tool with that port. It returns a public',\n 'Cloudflare quick-tunnel URL (https://<name>.trycloudflare.com) served straight',\n 'from the sandbox — no custom domain needed, and HMR / live-reload WebSockets',\n 'work through the tunnel (you do NOT need to disable HMR). The ONE requirement:',\n 'the dev server must ACCEPT the tunnel hostname, which servers reject by default,',\n 'so allow all hosts in its config before starting:',\n '• Vite — `server: { host: true, allowedHosts: true }` in vite.config.',\n \"• webpack-dev-server — `allowedHosts: 'all'` (and `host: '0.0.0.0'`).\",\n '• Other dev servers — bind 0.0.0.0 and allow all hosts equivalently.',\n 'Once it is listening, call `exposePreview` with that port, then share the URL.',\n].join('\\n')\n\nconst LOCAL_PROBE_TIMEOUT_MS = 5_000\n// Fresh quick tunnels need a few seconds of DNS/edge propagation, hence retries.\nconst EDGE_PROBE_ATTEMPTS = 5\nconst EDGE_PROBE_BASE_DELAY_MS = 250\nconst EDGE_PROBE_FETCH_TIMEOUT_MS = 3_000\n\n/**\n * Probe the port INSIDE the sandbox via `containerFetch`. Returns the HTTP\n * status when a listener answered (any response — 4xx/5xx included — proves one\n * exists), or the failure symptom (a string) when nothing did.\n */\nasync function localProbe(\n sandbox: Sandbox,\n port: number,\n): Promise<number | string> {\n // A race instead of AbortSignal: signals don't serialize across the sandbox\n // RPC boundary, and a lost in-flight probe response is harmless.\n let timeoutId: ReturnType<typeof setTimeout> | undefined\n const timeout = new Promise<never>((_, reject) => {\n timeoutId = setTimeout(\n () => reject(new Error(`no response within ${LOCAL_PROBE_TIMEOUT_MS}ms`)),\n LOCAL_PROBE_TIMEOUT_MS,\n )\n })\n try {\n const res = await Promise.race([\n sandbox.containerFetch('http://preview/', { method: 'HEAD' }, port),\n timeout,\n ])\n return res.status\n } catch (error) {\n return error instanceof Error ? error.message : String(error)\n } finally {\n clearTimeout(timeoutId)\n }\n}\n\n/**\n * Probe a tunnel URL through the public edge with bounded retries. Only 502/530\n * — Cloudflare's tunnel/origin-unreachable signatures — mark the URL as\n * unreachable (401/403/404 prove the server answered, and `redirect: 'manual'`\n * keeps a login redirect from probing some other site), and even those are\n * trusted when the app answered the SAME status locally, so an app's own\n * 502/530 never gets its healthy tunnel destroyed. The verdict split exists\n * because only an OBSERVED non-matching 502/530 ('stale') is evidence that\n * justifies destroying the tunnel; fetch exceptions ('unverified') prove\n * nothing about it — the probe path itself may be what failed. A throw\n * anywhere in the retry window therefore wins over an earlier 502/530: the\n * window did not finish as HTTP probes, so we must not destroy.\n */\nasync function edgeProbeFailure(\n url: string,\n localStatus: number,\n): Promise<{ verdict: 'stale' | 'unverified'; symptom: string } | null> {\n let lastFailure = 'no response'\n let verdict: 'stale' | 'unverified' = 'unverified'\n let sawFetchException = false\n for (let attempt = 0; attempt < EDGE_PROBE_ATTEMPTS; attempt += 1) {\n if (attempt > 0) {\n await new Promise((resolve) =>\n setTimeout(resolve, EDGE_PROBE_BASE_DELAY_MS * 2 ** (attempt - 1)),\n )\n }\n try {\n const res = await fetch(url, {\n method: 'HEAD',\n redirect: 'manual',\n signal: AbortSignal.timeout(EDGE_PROBE_FETCH_TIMEOUT_MS),\n })\n if (\n (res.status !== 502 && res.status !== 530) ||\n res.status === localStatus\n ) {\n return null\n }\n lastFailure = `HTTP ${res.status}`\n verdict = 'stale'\n } catch (error) {\n sawFetchException = true\n lastFailure = error instanceof Error ? error.message : String(error)\n }\n }\n return {\n verdict: sawFetchException ? 'unverified' : verdict,\n symptom: lastFailure,\n }\n}\n\n/**\n * Build the `exposePreview` server tool for one run. Starting a tunnel is a\n * HOST-side call on the Sandbox DO stub, so an in-sandbox agent cannot make it from\n * bash — it calls this bridged tool instead. We address the run's container by\n * `threadId` and open (or reuse) a quick tunnel to the given port.\n *\n * Closes over the run's `input` + `env`, so build it inside the `tools` resolver\n * (`tools: (input, env) => [exposePreviewTool(input, env)]`).\n */\nexport function exposePreviewTool(input: StartRunInput, env: PreviewToolEnv) {\n return toolDefinition({\n name: 'exposePreview',\n description:\n 'Expose a port a dev server is listening on inside the sandbox and return a public preview URL (a Cloudflare quick tunnel) to show the user. Call this AFTER the server is up. The dev server must allow all hosts (e.g. Vite `server.allowedHosts: true`) so it accepts the tunnel hostname.',\n inputSchema: z.object({\n port: z\n .number()\n .int()\n .min(1024)\n .max(65535)\n .describe('The port the dev server is listening on, e.g. 5173.'),\n }),\n }).server(async ({ port }) => {\n // `sandbox.tunnels` only exists on the RPC transport (on HTTP/WebSocket it's a\n // stub that throws \"requires the RPC transport\"), so we must obtain the stub\n // with `transport: 'rpc'`. IMPORTANT: this must MATCH how the sandbox was\n // created — pass `transport: 'rpc'` on EVERY `getSandbox()` for this id (in your\n // sandbox provider too), or the differing transport disconnects the run's active\n // client. See the SDK `SandboxOptions.transport` note.\n const sandbox = getSandbox(env.Sandbox, input.threadId, {\n transport: 'rpc',\n })\n // Gate tunnel work on a live listener: a fresh tunnel to a dead port is still\n // a dead preview, and the failure the agent can FIX is \"start the server\".\n const local = await localProbe(sandbox, port)\n if (typeof local === 'string') {\n throw new Error(\n `No server is listening on port ${port} inside the sandbox (${local}). Start the dev server (bound to 0.0.0.0:${port}) first, then retry exposePreview.`,\n )\n }\n // A Cloudflare quick tunnel (`*.trycloudflare.com`) run by `cloudflared` INSIDE\n // the sandbox: it bypasses the local Vite dev server's port entirely (so Vite\n // can't hijack the preview's asset requests) and needs no custom domain on a\n // deploy. `get(port)` is idempotent per port. See the Sandbox SDK `tunnels` API.\n const tunnel = await sandbox.tunnels.get(port)\n const edgeFailure = await edgeProbeFailure(tunnel.url, local)\n if (edgeFailure === null) return { url: tunnel.url }\n // Never destroy on 'unverified': the tunnel may be healthy with only the\n // probe path broken, so destroying it could kill a working preview.\n if (edgeFailure.verdict === 'unverified') {\n throw new Error(\n `Port ${port} is serving inside the sandbox, but the preview tunnel could not be verified from the edge (${edgeFailure.symptom}). The tunnel was left in place — retry exposePreview in a few seconds.`,\n )\n }\n // Local server healthy but the edge kept answering 502/530: the cached tunnel\n // record is suspect. Refresh, bounded to ONE so we never churn tunnels.\n await sandbox.tunnels.destroy(port)\n const fresh = await sandbox.tunnels.get(port)\n const freshFailure = await edgeProbeFailure(fresh.url, local)\n if (freshFailure === null) {\n return {\n url: fresh.url,\n note: `The tunnel for port ${port} was stale, so it was replaced. Any previously shared preview URL for this port is dead — share this new URL instead.`,\n }\n }\n // Don't leave a known-dead record in DO storage (the #992 failure mode).\n if (freshFailure.verdict === 'stale') {\n await sandbox.tunnels.destroy(port)\n }\n const [diagnosis, hint] =\n freshFailure.verdict === 'stale'\n ? [\n 'its preview tunnel never became reachable',\n 'Retry exposePreview, and if it keeps failing, restart the dev server and try again.',\n ]\n : [\n 'the replacement preview tunnel could not be verified from the edge',\n 'Retry exposePreview in a few seconds.',\n ]\n throw new Error(\n `Port ${port} is serving inside the sandbox, but ${diagnosis} (old tunnel: ${edgeFailure.symptom}; replacement tunnel: ${freshFailure.symptom}). ${hint}`,\n )\n })\n}\n"],"mappings":"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"}
|
package/dist/esm/run-log-do.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/esm/run-log-do.js
CHANGED
|
@@ -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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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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"}
|
package/dist/esm/run-log.d.ts
CHANGED
|
@@ -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
|
-
* -
|
|
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
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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>>;
|
package/dist/esm/run-log.js
CHANGED
|
@@ -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,
|
package/dist/esm/run-log.js.map
CHANGED
|
@@ -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":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8JA,IAAM,oBAAoB;CACxB,MAAM;CACN,OAAO;AACT;AAEA,SAAS,wBACP,OACgC;CAGhC,OAAO,eAAe;AACxB;;;;;;;;;AAUA,SAAgB,uBACd,QAC6C;CAC7C,IAAI,CAAC,wBAAwB,MAAM,GACjC,OAAO;EAAE,QAAQ;EAAQ,UAAU;CAAM;CAC3C,MAAM,SACJ,OAAO,WAAW,UAAU,OAAO,WAAW,UAC1C,kBAAkB,OAAO,UACzB,OAAO;CAab,OAAO;EAAE,QAAA;GAXP,OAAO,OAAO;GAGd,UAAU,OAAO,YAAY,OAAO;GACpC;GACA,SAAS,OAAO;GAChB,WAAW,OAAO;GAClB,WAAW,OAAO;GAClB,GAAI,oBAAoB,MAAM,IAAI,EAAE,YAAY,OAAO,UAAU,IAAI,CAAC;GACtE,GAAI,OAAO,UAAU,KAAA,IAAY,EAAE,OAAO,OAAO,MAAM,IAAI,CAAC;EAErD;EAAQ,UAAU;CAAK;AAClC;;;;;;;AAgBA,IAAa,sBAAb,MAAwD;CACtD,uBAAwB,IAAI,IAAsB;CAElD,MAAsB;EACpB,OAAO,KAAK,IAAI;CAClB;CAEA,QAAgB,OAAyB;EACvC,MAAM,QAAQ,KAAK,KAAK,IAAI,KAAK;EACjC,IAAI,CAAC,OAAO,MAAM,IAAI,MAAM,2BAA2B,MAAM,EAAE;EAC/D,OAAO;CACT;CAEA,KAAa,OAAuB;EAClC,MAAM,UAAU,CAAC,GAAG,MAAM,OAAO;EACjC,MAAM,QAAQ,MAAM;EACpB,KAAK,MAAM,WAAW,SAAS,QAAQ;CACzC;CAKA,KAAK,OAIqB;EACxB,MAAM,WAAW,KAAK,KAAK,IAAI,MAAM,KAAK;EAC1C,IAAI,UAAU,OAAO,QAAQ,QAAQ,EAAE,GAAG,SAAS,OAAO,CAAC;EAC3D,MAAM,MAAM,KAAK,IAAI;EACrB,MAAM,SAAuB;GAC3B,OAAO,MAAM;GACb,UAAU,MAAM;GAChB,QAAQ;GACR,SAAS;GACT,WAAW,MAAM,aAAa;GAC9B,WAAW;EACb;EACA,KAAK,KAAK,IAAI,MAAM,OAAO;GAAE;GAAQ,QAAQ,CAAC;GAAG,yBAAS,IAAI,IAAI;EAAE,CAAC;EACrE,OAAO,QAAQ,QAAQ,EAAE,GAAG,OAAO,CAAC;CACtC;CAEA,OAAO,OAAe,OAAqC;EACzD,MAAM,QAAQ,KAAK,KAAK,IAAI,KAAK;EACjC,IAAI,CAAC,OACH,OAAO,QAAQ,uBAAO,IAAI,MAAM,2BAA2B,MAAM,EAAE,CAAC;EAEtE,IAAI,oBAAoB,MAAM,OAAO,MAAM,GACzC,OAAO,QAAQ,uBACb,IAAI,MACF,2CAA2C,MAAM,YAAY,MAAM,OAAO,OAAO,EACnF,CACF;EAKF,MAAM,MAAM,MAAM,OAAO,UAAU;EACnC,MAAM,OAAO,KAAK,KAAK;EACvB,MAAM,OAAO,UAAU;EACvB,MAAM,OAAO,YAAY,KAAK,IAAI;EAClC,KAAK,KAAK,KAAK;EACf,OAAO,QAAQ,QAAQ,GAAG;CAC5B;CAEA,OACE,OACA,QACA,OACe;EACf,MAAM,QAAQ,KAAK,KAAK,IAAI,KAAK;EACjC,IAAI,CAAC,OACH,OAAO,QAAQ,uBAAO,IAAI,MAAM,2BAA2B,MAAM,EAAE,CAAC;EAEtE,IAAI,oBAAoB,MAAM,OAAO,MAAM,GAAG,OAAO,QAAQ,QAAQ;EACrE,MAAM,MAAM,KAAK,IAAI;EACrB,MAAM,OAAO,SAAS;EACtB,IAAI,UAAU,KAAA,GAAW,MAAM,OAAO,QAAQ;EAC9C,MAAM,OAAO,aAAa;EAC1B,MAAM,OAAO,YAAY;EACzB,KAAK,KAAK,KAAK;EACf,OAAO,QAAQ,QAAQ;CACzB;CAEA,OAAO,OAAe,OAAsC;EAC1D,MAAM,QAAQ,KAAK,KAAK,IAAI,KAAK;EACjC,IAAI,CAAC,OAAO,OAAO,QAAQ,QAAQ;EACnC,MAAM,SAAS;GAAE,GAAG,MAAM;GAAQ,GAAG;GAAO,WAAW,KAAK,IAAI;EAAE;EAGlE,KAAK,KAAK,KAAK;EACf,OAAO,QAAQ,QAAQ;CACzB;CAEA,IAAI,OAA6C;EAC/C,MAAM,QAAQ,KAAK,KAAK,IAAI,KAAK;EACjC,OAAO,QAAQ,QAAQ,QAAQ,EAAE,GAAG,MAAM,OAAO,IAAI,IAAI;CAC3D;CAEA,OAAqC;EACnC,OAAO,QAAQ,QACb,CAAC,GAAG,KAAK,KAAK,OAAO,CAAC,CAAC,CAAC,KAAK,WAAW,EAAE,GAAG,MAAM,OAAO,EAAE,CAC9D;CACF;CAEA,OAAO,KACL,OACA,SACyB;EACzB,MAAM,QAAQ,KAAK,QAAQ,KAAK;EAChC,MAAM,SAAS,SAAS;EACxB,IAAI,SAAS,SAAS,WAAW;EACjC,OAAO,CAAC,QAAQ,SAAS;GACvB,OAAO,SAAS,MAAM,OAAO,SAAS;IACpC,UAAU;IACV,MAAM,QAAQ,MAAM,OAAO;IAC3B,IAAI,UAAU,KAAA,GAAW,MAAM;KAAE,KAAK;KAAQ;IAAM;GACtD;GACA,IAAI,oBAAoB,MAAM,OAAO,MAAM,GAAG;GAC9C,MAAM,KAAK,cAAc,OAAO,MAAM;EACxC;CACF;CAEA,cAAsB,OAAiB,QAAqC;EAC1E,OAAO,IAAI,SAAe,YAAY;GACpC,MAAM,aAAmB;IACvB,MAAM,QAAQ,OAAO,IAAI;IACzB,IAAI,QAAQ,OAAO,oBAAoB,SAAS,IAAI;IACpD,QAAQ;GACV;GACA,MAAM,QAAQ,IAAI,IAAI;GACtB,IAAI,QAAQ,OAAO,iBAAiB,SAAS,MAAM,EAAE,MAAM,KAAK,CAAC;EACnE,CAAC;CACH;AACF"}
|
|
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
|
+
"version": "0.4.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,8 +45,8 @@
|
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
47
|
"zod": "^4.0.0",
|
|
48
|
-
"@tanstack/ai": "^0.
|
|
49
|
-
"@tanstack/ai-sandbox": "^0.5.
|
|
48
|
+
"@tanstack/ai": "^0.55.0",
|
|
49
|
+
"@tanstack/ai-sandbox": "^0.5.9",
|
|
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.
|
|
58
|
-
"@tanstack/ai-sandbox": "0.5.
|
|
57
|
+
"@tanstack/ai": "0.55.0",
|
|
58
|
+
"@tanstack/ai-sandbox": "0.5.9",
|
|
59
59
|
"@tanstack/ai-sandbox-local-process": "0.2.5"
|
|
60
60
|
},
|
|
61
61
|
"scripts": {
|