@tanstack/ai-sandbox-cloudflare 0.2.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/agent.d.ts +4 -0
- package/dist/esm/agent.js +9 -21
- package/dist/esm/chat-coordinator.js +144 -132
- package/dist/esm/chat-coordinator.js.map +1 -1
- package/dist/esm/container-coordinator.js +251 -247
- package/dist/esm/container-coordinator.js.map +1 -1
- package/dist/esm/coordinator.d.ts +3 -2
- package/dist/esm/coordinator.js +204 -184
- package/dist/esm/coordinator.js.map +1 -1
- package/dist/esm/durability.d.ts +32 -0
- package/dist/esm/durability.js +104 -0
- package/dist/esm/durability.js.map +1 -0
- package/dist/esm/factory.js +98 -59
- package/dist/esm/factory.js.map +1 -1
- package/dist/esm/handle.js +205 -203
- package/dist/esm/handle.js.map +1 -1
- package/dist/esm/index.js +2 -8
- package/dist/esm/preview-tool.d.ts +7 -1
- package/dist/esm/preview-tool.js +75 -32
- package/dist/esm/preview-tool.js.map +1 -1
- package/dist/esm/protocol.js +61 -50
- package/dist/esm/protocol.js.map +1 -1
- package/dist/esm/provider.js +43 -62
- package/dist/esm/provider.js.map +1 -1
- package/dist/esm/public-host.js +84 -39
- package/dist/esm/public-host.js.map +1 -1
- package/dist/esm/run-log-do.d.ts +19 -5
- package/dist/esm/run-log-do.js +196 -121
- package/dist/esm/run-log-do.js.map +1 -1
- package/dist/esm/run-log.d.ts +127 -0
- package/dist/esm/run-log.js +198 -0
- package/dist/esm/run-log.js.map +1 -0
- package/dist/esm/runner.js +146 -95
- package/dist/esm/runner.js.map +1 -1
- package/dist/esm/web-crypto.js +27 -16
- package/dist/esm/web-crypto.js.map +1 -1
- package/dist/esm/worker.js +84 -72
- package/dist/esm/worker.js.map +1 -1
- package/package.json +9 -9
- package/src/agent.ts +26 -0
- package/src/coordinator.ts +36 -14
- package/src/durability.ts +164 -0
- package/src/handle.ts +5 -0
- package/src/run-log-do.ts +85 -20
- package/src/run-log.ts +352 -0
- package/dist/esm/agent.js.map +0 -1
- package/dist/esm/index.js.map +0 -1
package/dist/esm/public-host.js
CHANGED
|
@@ -1,49 +1,94 @@
|
|
|
1
|
+
//#region src/public-host.ts
|
|
2
|
+
/**
|
|
3
|
+
* Host resolution for the two DISTINCT public surfaces the sandbox layer exposes.
|
|
4
|
+
* Kept in its own (Workers-free) module so it stays pure and unit-testable.
|
|
5
|
+
*
|
|
6
|
+
* These were once a single `PUBLIC_HOSTNAME`, but they have different reachers and
|
|
7
|
+
* therefore different correct values:
|
|
8
|
+
*
|
|
9
|
+
* - **Bridge / tool-exec** — the off-isolate CONTAINER calls back into the Worker
|
|
10
|
+
* (`/_bridge`, `/tool-exec`). It must reach the Worker, so locally that's
|
|
11
|
+
* `host.docker.internal` (the container can't reach the host's `localhost`).
|
|
12
|
+
* - **Preview** — the BROWSER opens an `exposePort` URL that `proxyToSandbox`
|
|
13
|
+
* routes into the container. It needs WILDCARD DNS, so locally that's
|
|
14
|
+
* `*.localhost` (browsers resolve it to loopback with zero setup) and in
|
|
15
|
+
* production a CUSTOM DOMAIN (`*.workers.dev` has no wildcard subdomains).
|
|
16
|
+
*/
|
|
17
|
+
/** Hostnames that mean "this machine" (the loopback the container can't reach). */
|
|
1
18
|
function isLoopbackHost(host) {
|
|
2
|
-
|
|
3
|
-
|
|
19
|
+
const name = host.split(":")[0];
|
|
20
|
+
return name === "localhost" || name === "127.0.0.1" || name === "0.0.0.0";
|
|
4
21
|
}
|
|
22
|
+
/** The port portion of a `host[:port]`, or `fallback` when none is present. */
|
|
5
23
|
function portOf(host, fallback) {
|
|
6
|
-
|
|
7
|
-
|
|
24
|
+
const colon = host.indexOf(":");
|
|
25
|
+
return colon === -1 ? fallback : host.slice(colon + 1);
|
|
8
26
|
}
|
|
27
|
+
/** `http://` for local hosts (loopback / host.docker.internal), `https://` else. */
|
|
9
28
|
function originForHost(host) {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
return `${scheme}://${host}`;
|
|
29
|
+
const name = host.split(":")[0];
|
|
30
|
+
return `${isLoopbackHost(host) || name === "host.docker.internal" ? "http" : "https"}://${host}`;
|
|
13
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the ORIGIN the off-isolate sandbox CONTAINER uses to call back into the
|
|
34
|
+
* Worker — the MCP tool-bridge (`/_bridge`) and host-tool execution (`/tool-exec`).
|
|
35
|
+
* Returns a full origin (scheme + host + optional port), e.g.
|
|
36
|
+
* `http://host.docker.internal:3001` locally or `https://app.example.com` deployed.
|
|
37
|
+
*
|
|
38
|
+
* `PUBLIC_HOSTNAME` wins when set; otherwise we derive from the host the trigger
|
|
39
|
+
* request arrived on (`input.publicHost`).
|
|
40
|
+
*
|
|
41
|
+
* ── Why a callback hostname is unavoidable ──────────────────────────────────────
|
|
42
|
+
* The container is SEPARATE compute from the Worker isolate; it can only reach the
|
|
43
|
+
* Worker over the network, so the callback URL must be an absolute host.
|
|
44
|
+
*
|
|
45
|
+
* ── Why request-derivation is SAFE on Cloudflare ────────────────────────────────
|
|
46
|
+
* On a generic Node server the `Host` header is attacker-controlled and trusting it
|
|
47
|
+
* is a Host-injection / token-exfil vector (the per-run bearer token rides this
|
|
48
|
+
* URL). Not so behind Cloudflare: the edge dispatches a request to your Worker only
|
|
49
|
+
* when its hostname matches a route you OWN, so `input.publicHost` is always one of
|
|
50
|
+
* your own hostnames — never an attacker's.
|
|
51
|
+
*
|
|
52
|
+
* ── Local dev: localhost → host.docker.internal ─────────────────────────────────
|
|
53
|
+
* Locally the trigger arrives on `localhost`, which the container CANNOT reach
|
|
54
|
+
* (that's the container's own loopback). So we rewrite it to `host.docker.internal`
|
|
55
|
+
* (the Docker host gateway), keeping the port, over `http`. This removes the need
|
|
56
|
+
* for a dev tunnel for the bridge entirely.
|
|
57
|
+
*/
|
|
14
58
|
function resolveBridgeOrigin(env, input) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
);
|
|
22
|
-
}
|
|
23
|
-
if (isLoopbackHost(host)) {
|
|
24
|
-
return `http://host.docker.internal:${portOf(host, "3001")}`;
|
|
25
|
-
}
|
|
26
|
-
return originForHost(host);
|
|
59
|
+
const configured = env.PUBLIC_HOSTNAME?.trim();
|
|
60
|
+
if (configured) return originForHost(configured);
|
|
61
|
+
const host = input.publicHost;
|
|
62
|
+
if (!host) throw new Error("sandbox agent: no bridge host available — set PUBLIC_HOSTNAME, or run behind Cloudflare so the Worker can derive it from the trigger request.");
|
|
63
|
+
if (isLoopbackHost(host)) return `http://host.docker.internal:${portOf(host, "3001")}`;
|
|
64
|
+
return originForHost(host);
|
|
27
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Resolve the HOST passed to `exposePort` for browser-facing preview URLs (the app
|
|
68
|
+
* the agent builds). Returns a bare host (the `@cloudflare/sandbox` SDK builds the
|
|
69
|
+
* `<port>-<id>-<token>.<host>` URL + scheme itself).
|
|
70
|
+
*
|
|
71
|
+
* `PREVIEW_HOSTNAME` wins when set; otherwise we derive from the trigger request.
|
|
72
|
+
*
|
|
73
|
+
* Preview URLs require WILDCARD DNS, which constrains the value:
|
|
74
|
+
* - **Local** → `localhost:<port>`. The SDK's localhost path yields
|
|
75
|
+
* `http://<port>-<id>-<token>.localhost:<port>`, which browsers resolve to
|
|
76
|
+
* loopback with no DNS setup — so previews work locally with no tunnel.
|
|
77
|
+
* - **Deployed** → a CUSTOM DOMAIN with a `*.<domain>` route. `*.workers.dev` has
|
|
78
|
+
* no wildcard subdomains (the SDK's `exposePort` throws on it), so we throw a
|
|
79
|
+
* clear error pointing at `PREVIEW_HOSTNAME` rather than letting the run fail
|
|
80
|
+
* deep in the agent.
|
|
81
|
+
*/
|
|
28
82
|
function resolvePreviewHost(env, input) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
if (isLoopbackHost(host)) return host;
|
|
38
|
-
if (host.endsWith(".workers.dev")) {
|
|
39
|
-
throw new Error(
|
|
40
|
-
"sandbox agent: preview URLs need a custom domain with wildcard DNS — *.workers.dev has no wildcard subdomains. Set PREVIEW_HOSTNAME to your custom domain and add a `*.<domain>` route to the Worker."
|
|
41
|
-
);
|
|
42
|
-
}
|
|
43
|
-
return host;
|
|
83
|
+
const configured = env.PREVIEW_HOSTNAME?.trim();
|
|
84
|
+
if (configured) return configured;
|
|
85
|
+
const host = input.publicHost;
|
|
86
|
+
if (!host) throw new Error("sandbox agent: no preview host available — set PREVIEW_HOSTNAME to a custom domain with a wildcard route.");
|
|
87
|
+
if (isLoopbackHost(host)) return host;
|
|
88
|
+
if (host.endsWith(".workers.dev")) throw new Error("sandbox agent: preview URLs need a custom domain with wildcard DNS — *.workers.dev has no wildcard subdomains. Set PREVIEW_HOSTNAME to your custom domain and add a `*.<domain>` route to the Worker.");
|
|
89
|
+
return host;
|
|
44
90
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
//# sourceMappingURL=public-host.js.map
|
|
91
|
+
//#endregion
|
|
92
|
+
export { resolveBridgeOrigin, resolvePreviewHost };
|
|
93
|
+
|
|
94
|
+
//# sourceMappingURL=public-host.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"public-host.js","sources":["../../src/public-host.ts"],"sourcesContent":["/**\n * Host resolution for the two DISTINCT public surfaces the sandbox layer exposes.\n * Kept in its own (Workers-free) module so it stays pure and unit-testable.\n *\n * These were once a single `PUBLIC_HOSTNAME`, but they have different reachers and\n * therefore different correct values:\n *\n * - **Bridge / tool-exec** — the off-isolate CONTAINER calls back into the Worker\n * (`/_bridge`, `/tool-exec`). It must reach the Worker, so locally that's\n * `host.docker.internal` (the container can't reach the host's `localhost`).\n * - **Preview** — the BROWSER opens an `exposePort` URL that `proxyToSandbox`\n * routes into the container. It needs WILDCARD DNS, so locally that's\n * `*.localhost` (browsers resolve it to loopback with zero setup) and in\n * production a CUSTOM DOMAIN (`*.workers.dev` has no wildcard subdomains).\n */\n\n/** Hostnames that mean \"this machine\" (the loopback the container can't reach). */\nfunction isLoopbackHost(host: string): boolean {\n const name = host.split(':')[0]\n return name === 'localhost' || name === '127.0.0.1' || name === '0.0.0.0'\n}\n\n/** The port portion of a `host[:port]`, or `fallback` when none is present. */\nfunction portOf(host: string, fallback: string): string {\n const colon = host.indexOf(':')\n return colon === -1 ? fallback : host.slice(colon + 1)\n}\n\n/** `http://` for local hosts (loopback / host.docker.internal), `https://` else. */\nfunction originForHost(host: string): string {\n const name = host.split(':')[0]\n const scheme =\n isLoopbackHost(host) || name === 'host.docker.internal' ? 'http' : 'https'\n return `${scheme}://${host}`\n}\n\n/**\n * Resolve the ORIGIN the off-isolate sandbox CONTAINER uses to call back into the\n * Worker — the MCP tool-bridge (`/_bridge`) and host-tool execution (`/tool-exec`).\n * Returns a full origin (scheme + host + optional port), e.g.\n * `http://host.docker.internal:3001` locally or `https://app.example.com` deployed.\n *\n * `PUBLIC_HOSTNAME` wins when set; otherwise we derive from the host the trigger\n * request arrived on (`input.publicHost`).\n *\n * ── Why a callback hostname is unavoidable ──────────────────────────────────────\n * The container is SEPARATE compute from the Worker isolate; it can only reach the\n * Worker over the network, so the callback URL must be an absolute host.\n *\n * ── Why request-derivation is SAFE on Cloudflare ────────────────────────────────\n * On a generic Node server the `Host` header is attacker-controlled and trusting it\n * is a Host-injection / token-exfil vector (the per-run bearer token rides this\n * URL). Not so behind Cloudflare: the edge dispatches a request to your Worker only\n * when its hostname matches a route you OWN, so `input.publicHost` is always one of\n * your own hostnames — never an attacker's.\n *\n * ── Local dev: localhost → host.docker.internal ─────────────────────────────────\n * Locally the trigger arrives on `localhost`, which the container CANNOT reach\n * (that's the container's own loopback). So we rewrite it to `host.docker.internal`\n * (the Docker host gateway), keeping the port, over `http`. This removes the need\n * for a dev tunnel for the bridge entirely.\n */\nexport function resolveBridgeOrigin(\n env: { PUBLIC_HOSTNAME?: string },\n input: { publicHost?: string },\n): string {\n const configured = env.PUBLIC_HOSTNAME?.trim()\n if (configured) return originForHost(configured)\n const host = input.publicHost\n if (!host) {\n throw new Error(\n 'sandbox agent: no bridge host available — set PUBLIC_HOSTNAME, or run ' +\n 'behind Cloudflare so the Worker can derive it from the trigger request.',\n )\n }\n // Local dev: the container reaches the host machine via the Docker host gateway.\n if (isLoopbackHost(host)) {\n return `http://host.docker.internal:${portOf(host, '3001')}`\n }\n return originForHost(host)\n}\n\n/**\n * Resolve the HOST passed to `exposePort` for browser-facing preview URLs (the app\n * the agent builds). Returns a bare host (the `@cloudflare/sandbox` SDK builds the\n * `<port>-<id>-<token>.<host>` URL + scheme itself).\n *\n * `PREVIEW_HOSTNAME` wins when set; otherwise we derive from the trigger request.\n *\n * Preview URLs require WILDCARD DNS, which constrains the value:\n * - **Local** → `localhost:<port>`. The SDK's localhost path yields\n * `http://<port>-<id>-<token>.localhost:<port>`, which browsers resolve to\n * loopback with no DNS setup — so previews work locally with no tunnel.\n * - **Deployed** → a CUSTOM DOMAIN with a `*.<domain>` route. `*.workers.dev` has\n * no wildcard subdomains (the SDK's `exposePort` throws on it), so we throw a\n * clear error pointing at `PREVIEW_HOSTNAME` rather than letting the run fail\n * deep in the agent.\n */\nexport function resolvePreviewHost(\n env: { PREVIEW_HOSTNAME?: string },\n input: { publicHost?: string },\n): string {\n const configured = env.PREVIEW_HOSTNAME?.trim()\n if (configured) return configured\n const host = input.publicHost\n if (!host) {\n throw new Error(\n 'sandbox agent: no preview host available — set PREVIEW_HOSTNAME to a ' +\n 'custom domain with a wildcard route.',\n )\n }\n if (isLoopbackHost(host)) return host\n if (host.endsWith('.workers.dev')) {\n throw new Error(\n 'sandbox agent: preview URLs need a custom domain with wildcard DNS — ' +\n '*.workers.dev has no wildcard subdomains. Set PREVIEW_HOSTNAME to your ' +\n 'custom domain and add a `*.<domain>` route to the Worker.',\n )\n }\n return host\n}\n"],"
|
|
1
|
+
{"version":3,"file":"public-host.js","names":[],"sources":["../../src/public-host.ts"],"sourcesContent":["/**\n * Host resolution for the two DISTINCT public surfaces the sandbox layer exposes.\n * Kept in its own (Workers-free) module so it stays pure and unit-testable.\n *\n * These were once a single `PUBLIC_HOSTNAME`, but they have different reachers and\n * therefore different correct values:\n *\n * - **Bridge / tool-exec** — the off-isolate CONTAINER calls back into the Worker\n * (`/_bridge`, `/tool-exec`). It must reach the Worker, so locally that's\n * `host.docker.internal` (the container can't reach the host's `localhost`).\n * - **Preview** — the BROWSER opens an `exposePort` URL that `proxyToSandbox`\n * routes into the container. It needs WILDCARD DNS, so locally that's\n * `*.localhost` (browsers resolve it to loopback with zero setup) and in\n * production a CUSTOM DOMAIN (`*.workers.dev` has no wildcard subdomains).\n */\n\n/** Hostnames that mean \"this machine\" (the loopback the container can't reach). */\nfunction isLoopbackHost(host: string): boolean {\n const name = host.split(':')[0]\n return name === 'localhost' || name === '127.0.0.1' || name === '0.0.0.0'\n}\n\n/** The port portion of a `host[:port]`, or `fallback` when none is present. */\nfunction portOf(host: string, fallback: string): string {\n const colon = host.indexOf(':')\n return colon === -1 ? fallback : host.slice(colon + 1)\n}\n\n/** `http://` for local hosts (loopback / host.docker.internal), `https://` else. */\nfunction originForHost(host: string): string {\n const name = host.split(':')[0]\n const scheme =\n isLoopbackHost(host) || name === 'host.docker.internal' ? 'http' : 'https'\n return `${scheme}://${host}`\n}\n\n/**\n * Resolve the ORIGIN the off-isolate sandbox CONTAINER uses to call back into the\n * Worker — the MCP tool-bridge (`/_bridge`) and host-tool execution (`/tool-exec`).\n * Returns a full origin (scheme + host + optional port), e.g.\n * `http://host.docker.internal:3001` locally or `https://app.example.com` deployed.\n *\n * `PUBLIC_HOSTNAME` wins when set; otherwise we derive from the host the trigger\n * request arrived on (`input.publicHost`).\n *\n * ── Why a callback hostname is unavoidable ──────────────────────────────────────\n * The container is SEPARATE compute from the Worker isolate; it can only reach the\n * Worker over the network, so the callback URL must be an absolute host.\n *\n * ── Why request-derivation is SAFE on Cloudflare ────────────────────────────────\n * On a generic Node server the `Host` header is attacker-controlled and trusting it\n * is a Host-injection / token-exfil vector (the per-run bearer token rides this\n * URL). Not so behind Cloudflare: the edge dispatches a request to your Worker only\n * when its hostname matches a route you OWN, so `input.publicHost` is always one of\n * your own hostnames — never an attacker's.\n *\n * ── Local dev: localhost → host.docker.internal ─────────────────────────────────\n * Locally the trigger arrives on `localhost`, which the container CANNOT reach\n * (that's the container's own loopback). So we rewrite it to `host.docker.internal`\n * (the Docker host gateway), keeping the port, over `http`. This removes the need\n * for a dev tunnel for the bridge entirely.\n */\nexport function resolveBridgeOrigin(\n env: { PUBLIC_HOSTNAME?: string },\n input: { publicHost?: string },\n): string {\n const configured = env.PUBLIC_HOSTNAME?.trim()\n if (configured) return originForHost(configured)\n const host = input.publicHost\n if (!host) {\n throw new Error(\n 'sandbox agent: no bridge host available — set PUBLIC_HOSTNAME, or run ' +\n 'behind Cloudflare so the Worker can derive it from the trigger request.',\n )\n }\n // Local dev: the container reaches the host machine via the Docker host gateway.\n if (isLoopbackHost(host)) {\n return `http://host.docker.internal:${portOf(host, '3001')}`\n }\n return originForHost(host)\n}\n\n/**\n * Resolve the HOST passed to `exposePort` for browser-facing preview URLs (the app\n * the agent builds). Returns a bare host (the `@cloudflare/sandbox` SDK builds the\n * `<port>-<id>-<token>.<host>` URL + scheme itself).\n *\n * `PREVIEW_HOSTNAME` wins when set; otherwise we derive from the trigger request.\n *\n * Preview URLs require WILDCARD DNS, which constrains the value:\n * - **Local** → `localhost:<port>`. The SDK's localhost path yields\n * `http://<port>-<id>-<token>.localhost:<port>`, which browsers resolve to\n * loopback with no DNS setup — so previews work locally with no tunnel.\n * - **Deployed** → a CUSTOM DOMAIN with a `*.<domain>` route. `*.workers.dev` has\n * no wildcard subdomains (the SDK's `exposePort` throws on it), so we throw a\n * clear error pointing at `PREVIEW_HOSTNAME` rather than letting the run fail\n * deep in the agent.\n */\nexport function resolvePreviewHost(\n env: { PREVIEW_HOSTNAME?: string },\n input: { publicHost?: string },\n): string {\n const configured = env.PREVIEW_HOSTNAME?.trim()\n if (configured) return configured\n const host = input.publicHost\n if (!host) {\n throw new Error(\n 'sandbox agent: no preview host available — set PREVIEW_HOSTNAME to a ' +\n 'custom domain with a wildcard route.',\n )\n }\n if (isLoopbackHost(host)) return host\n if (host.endsWith('.workers.dev')) {\n throw new Error(\n 'sandbox agent: preview URLs need a custom domain with wildcard DNS — ' +\n '*.workers.dev has no wildcard subdomains. Set PREVIEW_HOSTNAME to your ' +\n 'custom domain and add a `*.<domain>` route to the Worker.',\n )\n }\n return host\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAiBA,SAAS,eAAe,MAAuB;CAC7C,MAAM,OAAO,KAAK,MAAM,GAAG,CAAC,CAAC;CAC7B,OAAO,SAAS,eAAe,SAAS,eAAe,SAAS;AAClE;;AAGA,SAAS,OAAO,MAAc,UAA0B;CACtD,MAAM,QAAQ,KAAK,QAAQ,GAAG;CAC9B,OAAO,UAAU,KAAK,WAAW,KAAK,MAAM,QAAQ,CAAC;AACvD;;AAGA,SAAS,cAAc,MAAsB;CAC3C,MAAM,OAAO,KAAK,MAAM,GAAG,CAAC,CAAC;CAG7B,OAAO,GADL,eAAe,IAAI,KAAK,SAAS,yBAAyB,SAAS,QACpD,KAAK;AACxB;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BA,SAAgB,oBACd,KACA,OACQ;CACR,MAAM,aAAa,IAAI,iBAAiB,KAAK;CAC7C,IAAI,YAAY,OAAO,cAAc,UAAU;CAC/C,MAAM,OAAO,MAAM;CACnB,IAAI,CAAC,MACH,MAAM,IAAI,MACR,+IAEF;CAGF,IAAI,eAAe,IAAI,GACrB,OAAO,+BAA+B,OAAO,MAAM,MAAM;CAE3D,OAAO,cAAc,IAAI;AAC3B;;;;;;;;;;;;;;;;;AAkBA,SAAgB,mBACd,KACA,OACQ;CACR,MAAM,aAAa,IAAI,kBAAkB,KAAK;CAC9C,IAAI,YAAY,OAAO;CACvB,MAAM,OAAO,MAAM;CACnB,IAAI,CAAC,MACH,MAAM,IAAI,MACR,2GAEF;CAEF,IAAI,eAAe,IAAI,GAAG,OAAO;CACjC,IAAI,KAAK,SAAS,cAAc,GAC9B,MAAM,IAAI,MACR,uMAGF;CAEF,OAAO;AACT"}
|
package/dist/esm/run-log-do.d.ts
CHANGED
|
@@ -1,20 +1,34 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { StreamChunk } from '@tanstack/ai';
|
|
1
|
+
import { RunEvent, RunEventLog, RunEventLogReadOptions, RunLogRecord, RunRecordPatch } from './run-log.js';
|
|
2
|
+
import { RunError, StreamChunk, TerminalRunStatus } from '@tanstack/ai';
|
|
3
3
|
export declare class DurableObjectRunEventLog implements RunEventLog {
|
|
4
4
|
private readonly storage;
|
|
5
5
|
/** Per-run wake-ups for live-tailing readers on THIS instance. */
|
|
6
6
|
private readonly waiters;
|
|
7
7
|
constructor(storage: DurableObjectStorage);
|
|
8
|
+
/**
|
|
9
|
+
* Read (and, when needed, migrate + write back) the record under `rec:<runId>`.
|
|
10
|
+
* The single storage read path — everything else goes through here so no
|
|
11
|
+
* caller can observe the legacy layout.
|
|
12
|
+
*/
|
|
13
|
+
private getRecord;
|
|
8
14
|
private require;
|
|
9
15
|
/** Wake (and clear) every reader blocked on this run. */
|
|
10
16
|
private wake;
|
|
11
17
|
open(input: {
|
|
12
18
|
runId: string;
|
|
13
|
-
threadId
|
|
14
|
-
|
|
19
|
+
threadId: string;
|
|
20
|
+
startedAt?: number;
|
|
21
|
+
}): Promise<RunLogRecord>;
|
|
15
22
|
append(runId: string, chunk: StreamChunk): Promise<number>;
|
|
16
23
|
finish(runId: string, status: TerminalRunStatus, error?: RunError): Promise<void>;
|
|
17
|
-
|
|
24
|
+
update(runId: string, patch: RunRecordPatch): Promise<void>;
|
|
25
|
+
get(runId: string): Promise<RunLogRecord | null>;
|
|
26
|
+
/**
|
|
27
|
+
* Every run record this log holds, migrated. The coordinator's stall
|
|
28
|
+
* watchdog iterates this instead of listing `rec:` keys itself, so the
|
|
29
|
+
* storage layout (and its migration) stays this module's private concern.
|
|
30
|
+
*/
|
|
31
|
+
list(): Promise<Array<RunLogRecord>>;
|
|
18
32
|
read(runId: string, options?: RunEventLogReadOptions): AsyncIterable<RunEvent>;
|
|
19
33
|
/**
|
|
20
34
|
* Resolve when an append/finish wakes this run, the signal aborts, or the
|
package/dist/esm/run-log-do.js
CHANGED
|
@@ -1,122 +1,197 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
1
|
+
import { migrateStoredRunRecord } from "./run-log.js";
|
|
2
|
+
import { isTerminalRunStatus } from "@tanstack/ai";
|
|
3
|
+
//#region src/run-log-do.ts
|
|
4
|
+
/**
|
|
5
|
+
* A durable {@link RunEventLog} backed by Durable Object storage — the storage
|
|
6
|
+
* half of the serverless/edge run model. The coordinator appends every
|
|
7
|
+
* {@link StreamChunk} the agent emits under a monotonic `seq`; clients tail from
|
|
8
|
+
* a cursor. Because events are PERSISTED (not held in a caller's open stream), a
|
|
9
|
+
* reconnecting tab, a dropped WebSocket, or a coordinator that hibernated
|
|
10
|
+
* between chunks all resume cleanly: replay everything after the client's
|
|
11
|
+
* `lastSeq`, then live-tail to terminal.
|
|
12
|
+
*
|
|
13
|
+
* Mirrors {@link InMemoryRunEventLog} exactly.
|
|
14
|
+
* Storage layout (keys scoped by `runId` so one DO can host many runs):
|
|
15
|
+
* - `rec:<runId>` → the {@link RunLogRecord}
|
|
16
|
+
* - `evt:<runId>:<seq8>` → the chunk for that seq (seq zero-padded to 8 digits
|
|
17
|
+
* so `list({ prefix })` returns events in seq order).
|
|
18
|
+
*
|
|
19
|
+
* LIVE-DATA MIGRATION: `rec:` values written before the run vocabulary
|
|
20
|
+
* converged on core's (see the module header in `./run-log`) are converted by
|
|
21
|
+
* {@link migrateStoredRunRecord} on first read and written back immediately, so
|
|
22
|
+
* each record pays the conversion exactly once and every read path — `get`,
|
|
23
|
+
* `append`'s precondition check, the watchdog's {@link list} — observes only
|
|
24
|
+
* the converged layout. Event values (`evt:`) are raw chunks and need no
|
|
25
|
+
* migration.
|
|
26
|
+
*
|
|
27
|
+
* The live-tail wake-up (the in-memory waiter set) is per-INSTANCE; if the
|
|
28
|
+
* instance is evicted mid-run, a reader re-reads the persisted backlog and the
|
|
29
|
+
* `TAIL_POLL_MS` fallback poll keeps it progressing. No event is ever lost.
|
|
30
|
+
*
|
|
31
|
+
* NOTE: Workers-runtime code — compiles against `@cloudflare/workers-types`.
|
|
32
|
+
*/
|
|
33
|
+
/** How long a post-eviction reader waits before re-polling storage (ms). */
|
|
34
|
+
var TAIL_POLL_MS = 250;
|
|
35
|
+
var recKey = (runId) => `rec:${runId}`;
|
|
36
|
+
var evtKey = (runId, seq) => `evt:${runId}:${String(seq).padStart(8, "0")}`;
|
|
37
|
+
var evtPrefix = (runId) => `evt:${runId}:`;
|
|
38
|
+
var DurableObjectRunEventLog = class {
|
|
39
|
+
storage;
|
|
40
|
+
/** Per-run wake-ups for live-tailing readers on THIS instance. */
|
|
41
|
+
waiters = /* @__PURE__ */ new Map();
|
|
42
|
+
constructor(storage) {
|
|
43
|
+
this.storage = storage;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Read (and, when needed, migrate + write back) the record under `rec:<runId>`.
|
|
47
|
+
* The single storage read path — everything else goes through here so no
|
|
48
|
+
* caller can observe the legacy layout.
|
|
49
|
+
*/
|
|
50
|
+
async getRecord(runId) {
|
|
51
|
+
const stored = await this.storage.get(recKey(runId));
|
|
52
|
+
if (!stored) return null;
|
|
53
|
+
const { record, migrated } = migrateStoredRunRecord(stored);
|
|
54
|
+
if (migrated) await this.storage.put(recKey(runId), record);
|
|
55
|
+
return record;
|
|
56
|
+
}
|
|
57
|
+
async require(runId) {
|
|
58
|
+
const record = await this.getRecord(runId);
|
|
59
|
+
if (!record) throw new Error(`run-log: unknown runId "${runId}"`);
|
|
60
|
+
return record;
|
|
61
|
+
}
|
|
62
|
+
/** Wake (and clear) every reader blocked on this run. */
|
|
63
|
+
wake(runId) {
|
|
64
|
+
const set = this.waiters.get(runId);
|
|
65
|
+
if (!set) return;
|
|
66
|
+
const pending = [...set];
|
|
67
|
+
set.clear();
|
|
68
|
+
for (const resolve of pending) resolve();
|
|
69
|
+
}
|
|
70
|
+
async open(input) {
|
|
71
|
+
const existing = await this.getRecord(input.runId);
|
|
72
|
+
if (existing) return existing;
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
const record = {
|
|
75
|
+
runId: input.runId,
|
|
76
|
+
threadId: input.threadId,
|
|
77
|
+
status: "running",
|
|
78
|
+
lastSeq: -1,
|
|
79
|
+
startedAt: input.startedAt ?? now,
|
|
80
|
+
updatedAt: now
|
|
81
|
+
};
|
|
82
|
+
await this.storage.put(recKey(input.runId), record);
|
|
83
|
+
return record;
|
|
84
|
+
}
|
|
85
|
+
async append(runId, chunk) {
|
|
86
|
+
const record = await this.require(runId);
|
|
87
|
+
if (isTerminalRunStatus(record.status)) throw new Error(`run-log: cannot append to terminal run "${runId}" (status=${record.status})`);
|
|
88
|
+
const seq = record.lastSeq + 1;
|
|
89
|
+
const next = {
|
|
90
|
+
...record,
|
|
91
|
+
lastSeq: seq,
|
|
92
|
+
updatedAt: Date.now()
|
|
93
|
+
};
|
|
94
|
+
await this.storage.transaction(async (txn) => {
|
|
95
|
+
await txn.put(evtKey(runId, seq), chunk);
|
|
96
|
+
await txn.put(recKey(runId), next);
|
|
97
|
+
});
|
|
98
|
+
this.wake(runId);
|
|
99
|
+
return seq;
|
|
100
|
+
}
|
|
101
|
+
async finish(runId, status, error) {
|
|
102
|
+
const record = await this.require(runId);
|
|
103
|
+
if (isTerminalRunStatus(record.status)) return;
|
|
104
|
+
const now = Date.now();
|
|
105
|
+
const next = {
|
|
106
|
+
...record,
|
|
107
|
+
status,
|
|
108
|
+
...error !== void 0 ? { error } : {},
|
|
109
|
+
finishedAt: now,
|
|
110
|
+
updatedAt: now
|
|
111
|
+
};
|
|
112
|
+
await this.storage.put(recKey(runId), next);
|
|
113
|
+
this.wake(runId);
|
|
114
|
+
}
|
|
115
|
+
async update(runId, patch) {
|
|
116
|
+
const record = await this.getRecord(runId);
|
|
117
|
+
if (!record) return;
|
|
118
|
+
const next = {
|
|
119
|
+
...record,
|
|
120
|
+
...patch,
|
|
121
|
+
updatedAt: Date.now()
|
|
122
|
+
};
|
|
123
|
+
await this.storage.put(recKey(runId), next);
|
|
124
|
+
this.wake(runId);
|
|
125
|
+
}
|
|
126
|
+
async get(runId) {
|
|
127
|
+
return this.getRecord(runId);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Every run record this log holds, migrated. The coordinator's stall
|
|
131
|
+
* watchdog iterates this instead of listing `rec:` keys itself, so the
|
|
132
|
+
* storage layout (and its migration) stays this module's private concern.
|
|
133
|
+
*/
|
|
134
|
+
async list() {
|
|
135
|
+
const stored = await this.storage.list({ prefix: "rec:" });
|
|
136
|
+
const records = [];
|
|
137
|
+
for (const [key, value] of stored) {
|
|
138
|
+
const { record, migrated } = migrateStoredRunRecord(value);
|
|
139
|
+
if (migrated) await this.storage.put(key, record);
|
|
140
|
+
records.push(record);
|
|
141
|
+
}
|
|
142
|
+
return records;
|
|
143
|
+
}
|
|
144
|
+
async *read(runId, options) {
|
|
145
|
+
await this.require(runId);
|
|
146
|
+
const signal = options?.signal;
|
|
147
|
+
let cursor = options?.fromSeq ?? -1;
|
|
148
|
+
while (!signal?.aborted) {
|
|
149
|
+
const record = await this.require(runId);
|
|
150
|
+
if (cursor < record.lastSeq) {
|
|
151
|
+
const events = await this.storage.list({
|
|
152
|
+
prefix: evtPrefix(runId),
|
|
153
|
+
start: evtKey(runId, cursor + 1)
|
|
154
|
+
});
|
|
155
|
+
for (const [, chunk] of events) {
|
|
156
|
+
cursor += 1;
|
|
157
|
+
yield {
|
|
158
|
+
seq: cursor,
|
|
159
|
+
chunk
|
|
160
|
+
};
|
|
161
|
+
if (signal?.aborted) return;
|
|
162
|
+
}
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (isTerminalRunStatus(record.status)) return;
|
|
166
|
+
await this.waitForChange(runId, signal);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Resolve when an append/finish wakes this run, the signal aborts, or the
|
|
171
|
+
* fallback poll fires (the poll lets a reader that outlived its in-memory
|
|
172
|
+
* waiter — e.g. after the appending instance was evicted — keep progressing).
|
|
173
|
+
*/
|
|
174
|
+
waitForChange(runId, signal) {
|
|
175
|
+
return new Promise((resolve) => {
|
|
176
|
+
let set = this.waiters.get(runId);
|
|
177
|
+
if (!set) {
|
|
178
|
+
set = /* @__PURE__ */ new Set();
|
|
179
|
+
this.waiters.set(runId, set);
|
|
180
|
+
}
|
|
181
|
+
const localSet = set;
|
|
182
|
+
const wake = () => {
|
|
183
|
+
localSet.delete(wake);
|
|
184
|
+
clearTimeout(timer);
|
|
185
|
+
if (signal) signal.removeEventListener("abort", wake);
|
|
186
|
+
resolve();
|
|
187
|
+
};
|
|
188
|
+
const timer = setTimeout(wake, TAIL_POLL_MS);
|
|
189
|
+
localSet.add(wake);
|
|
190
|
+
if (signal) signal.addEventListener("abort", wake, { once: true });
|
|
191
|
+
});
|
|
192
|
+
}
|
|
121
193
|
};
|
|
122
|
-
//#
|
|
194
|
+
//#endregion
|
|
195
|
+
export { DurableObjectRunEventLog };
|
|
196
|
+
|
|
197
|
+
//# sourceMappingURL=run-log-do.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"run-log-do.js","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} from `@tanstack/ai-sandbox` exactly.\n * Storage layout (keys scoped by `runId` so one DO can host many runs):\n * - `rec:<runId>` → the {@link RunRecord}\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 * 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-sandbox'\nimport type {\n RunError,\n RunEvent,\n RunEventLog,\n RunEventLogReadOptions,\n RunRecord,\n TerminalRunStatus,\n} from '@tanstack/ai-sandbox'\nimport type { StreamChunk } from '@tanstack/ai'\n\n/** How long a post-eviction reader waits before re-polling storage (ms). */\nconst TAIL_POLL_MS = 250\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 private async require(runId: string): Promise<RunRecord> {\n const record = await this.storage.get<RunRecord>(recKey(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: { runId: string; threadId?: string }): Promise<RunRecord> {\n const existing = await this.storage.get<RunRecord>(recKey(input.runId))\n if (existing) return existing\n const now = Date.now()\n const record: RunRecord = {\n runId: input.runId,\n ...(input.threadId !== undefined ? { threadId: input.threadId } : {}),\n status: 'running',\n lastSeq: -1,\n createdAt: 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: RunRecord = { ...record, lastSeq: seq, updatedAt: Date.now() }\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 next: RunRecord = {\n ...record,\n status,\n ...(error !== undefined ? { error } : {}),\n updatedAt: Date.now(),\n }\n await this.storage.put(recKey(runId), next)\n this.wake(runId)\n }\n\n async get(runId: string): Promise<RunRecord | null> {\n return (await this.storage.get<RunRecord>(recKey(runId))) ?? null\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"],"names":[],"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 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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"}
|