@tanstack/ai-sandbox-cloudflare 0.2.3 → 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/coordinator.js
CHANGED
|
@@ -1,188 +1,208 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { EventType } from "@tanstack/ai";
|
|
3
|
-
import { RunController, isTerminalRunStatus } from "@tanstack/ai-sandbox";
|
|
1
|
+
import { runLogStore, runLogStream } from "./durability.js";
|
|
4
2
|
import { DurableObjectRunEventLog } from "./run-log-do.js";
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
import { resolveBridgeOrigin, resolvePreviewHost } from "./public-host.js";
|
|
4
|
+
import { RunController } from "@tanstack/ai-sandbox";
|
|
5
|
+
import { EventType, isTerminalRunStatus } from "@tanstack/ai";
|
|
6
|
+
import { DurableObject } from "cloudflare:workers";
|
|
7
|
+
//#region src/coordinator.ts
|
|
8
|
+
/**
|
|
9
|
+
* `SandboxCoordinator` — the abstract Durable Object base for the serverless/
|
|
10
|
+
* edge agent run model. It owns everything the two concrete models share:
|
|
11
|
+
*
|
|
12
|
+
* - a durable, resumable run-log ({@link DurableObjectRunEventLog});
|
|
13
|
+
* - `startRun`: open the run, kick off the model's chunk stream WITHOUT blocking
|
|
14
|
+
* the trigger, start piping it into the log via {@link RunController}, register
|
|
15
|
+
* the resulting `done` promise with `ctx.waitUntil` (keeping the instance alive
|
|
16
|
+
* until the run is terminal rather than letting it hibernate mid-run), and arm
|
|
17
|
+
* a watchdog alarm;
|
|
18
|
+
* - `status` (poll fallback) + a hibernatable WebSocket tail with a resumable
|
|
19
|
+
* cursor (replay after `lastSeq`, then live-tail, reconnect-safe);
|
|
20
|
+
* - routing for `GET /runs/:id` and `GET /runs/:id/stream`, delegating any other
|
|
21
|
+
* path to {@link handleRoute} (which a subclass overrides for e.g. `/_bridge`
|
|
22
|
+
* or `/tool-exec`).
|
|
23
|
+
*
|
|
24
|
+
* Subclasses implement {@link buildRunStream} — the ONE difference between the
|
|
25
|
+
* models: run `chat()` in the DO ({@link ChatSandboxCoordinator}) or drive an
|
|
26
|
+
* in-container runner ({@link ContainerSandboxCoordinator}).
|
|
27
|
+
*
|
|
28
|
+
* NOTE: Workers-runtime code — compiles against `@cloudflare/workers-types`; not
|
|
29
|
+
* runtime-verified in this repo.
|
|
30
|
+
*/
|
|
31
|
+
/** Re-arm window for the liveness watchdog while a run is in flight (ms). */
|
|
32
|
+
var WATCHDOG_MS = 3e4;
|
|
33
|
+
/**
|
|
34
|
+
* How long a non-terminal run may go without ANY new event before the watchdog
|
|
35
|
+
* presumes the orchestrator driving it is dead (eviction that lost the
|
|
36
|
+
* `waitUntil` promise, an uncaught fault, a hung container) and fails the run so
|
|
37
|
+
* tailing clients stop waiting forever. Generous so a legitimately slow agent
|
|
38
|
+
* step (a long tool call that emits no chunks) is not killed prematurely.
|
|
39
|
+
*/
|
|
40
|
+
var WATCHDOG_STALL_MS = 5 * 6e4;
|
|
7
41
|
function isSocketAttachment(value) {
|
|
8
|
-
|
|
9
|
-
}
|
|
10
|
-
class SandboxCoordinator extends DurableObject {
|
|
11
|
-
log;
|
|
12
|
-
controller;
|
|
13
|
-
/**
|
|
14
|
-
* Sockets with a live {@link pump} loop. Guards against a second concurrent
|
|
15
|
-
* pump on the same socket: `acceptStream` starts one, and `webSocketMessage`
|
|
16
|
-
* would start another on any inbound client message while the first is still
|
|
17
|
-
* running — double-delivering events and racing the persisted cursor.
|
|
18
|
-
*/
|
|
19
|
-
pumping = /* @__PURE__ */ new WeakSet();
|
|
20
|
-
constructor(ctx, env) {
|
|
21
|
-
super(ctx, env);
|
|
22
|
-
this.log = new DurableObjectRunEventLog(ctx.storage);
|
|
23
|
-
this.controller = new RunController(this.log);
|
|
24
|
-
}
|
|
25
|
-
/** Extra fetch routes a subclass serves (e.g. `/_bridge`, `/tool-exec`). */
|
|
26
|
-
handleRoute(_request, _parts) {
|
|
27
|
-
return new Response("not found", { status: 404 });
|
|
28
|
-
}
|
|
29
|
-
/** Called once a run reaches a terminal status (override to clean up state). */
|
|
30
|
-
onRunSettled(_runId) {
|
|
31
|
-
}
|
|
32
|
-
jsonResponse(body, status = 200) {
|
|
33
|
-
return new Response(JSON.stringify(body), {
|
|
34
|
-
status,
|
|
35
|
-
headers: { "content-type": "application/json" }
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
// ===========================================================================
|
|
39
|
-
// Trigger (called by the Worker; returns immediately)
|
|
40
|
-
// ===========================================================================
|
|
41
|
-
async startRun(input) {
|
|
42
|
-
const existing = await this.log.get(input.runId);
|
|
43
|
-
if (existing) return { runId: input.runId };
|
|
44
|
-
await this.log.open({ runId: input.runId, threadId: input.threadId });
|
|
45
|
-
let stream;
|
|
46
|
-
try {
|
|
47
|
-
stream = await this.buildRunStream(input);
|
|
48
|
-
} catch (error) {
|
|
49
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
50
|
-
await this.log.append(input.runId, {
|
|
51
|
-
type: EventType.RUN_ERROR,
|
|
52
|
-
message
|
|
53
|
-
});
|
|
54
|
-
await this.log.finish(input.runId, "error", { message });
|
|
55
|
-
this.onRunSettled(input.runId);
|
|
56
|
-
return { runId: input.runId };
|
|
57
|
-
}
|
|
58
|
-
const { done } = this.controller.start({
|
|
59
|
-
runId: input.runId,
|
|
60
|
-
threadId: input.threadId,
|
|
61
|
-
stream
|
|
62
|
-
});
|
|
63
|
-
this.ctx.waitUntil(done.finally(() => this.onRunSettled(input.runId)));
|
|
64
|
-
await this.ctx.storage.setAlarm(Date.now() + WATCHDOG_MS);
|
|
65
|
-
return { runId: input.runId };
|
|
66
|
-
}
|
|
67
|
-
async status(runId) {
|
|
68
|
-
return this.controller.status(runId);
|
|
69
|
-
}
|
|
70
|
-
// ===========================================================================
|
|
71
|
-
// HTTP surface
|
|
72
|
-
// ===========================================================================
|
|
73
|
-
async fetch(request) {
|
|
74
|
-
const url = new URL(request.url);
|
|
75
|
-
const parts = url.pathname.split("/").filter(Boolean);
|
|
76
|
-
if (parts[0] === "runs" && typeof parts[1] === "string") {
|
|
77
|
-
if (parts[2] === "stream") return this.acceptStream(parts[1], request);
|
|
78
|
-
if (parts.length === 2 && request.method === "GET") {
|
|
79
|
-
const record = await this.status(parts[1]);
|
|
80
|
-
return record ? this.jsonResponse(record) : this.jsonResponse({ error: "unknown run" }, 404);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
return this.handleRoute(request, parts);
|
|
84
|
-
}
|
|
85
|
-
// ===========================================================================
|
|
86
|
-
// WebSocket streaming with hibernation + resumable cursor
|
|
87
|
-
// ===========================================================================
|
|
88
|
-
async acceptStream(runId, request) {
|
|
89
|
-
if (request.headers.get("upgrade") !== "websocket") {
|
|
90
|
-
return new Response("expected websocket upgrade", { status: 426 });
|
|
91
|
-
}
|
|
92
|
-
const record = await this.log.get(runId);
|
|
93
|
-
if (!record) return new Response("unknown run", { status: 404 });
|
|
94
|
-
const url = new URL(request.url);
|
|
95
|
-
const lastSeqParam = url.searchParams.get("lastSeq");
|
|
96
|
-
const lastSeq = lastSeqParam !== null ? Number.parseInt(lastSeqParam, 10) : -1;
|
|
97
|
-
if (Number.isNaN(lastSeq)) {
|
|
98
|
-
return new Response("lastSeq must be an integer", { status: 400 });
|
|
99
|
-
}
|
|
100
|
-
const pair = new WebSocketPair();
|
|
101
|
-
const [client, server] = [pair[0], pair[1]];
|
|
102
|
-
server.serializeAttachment({ runId, lastSeq });
|
|
103
|
-
this.ctx.acceptWebSocket(server);
|
|
104
|
-
this.pump(server, runId, lastSeq);
|
|
105
|
-
return new Response(null, { status: 101, webSocket: client });
|
|
106
|
-
}
|
|
107
|
-
/**
|
|
108
|
-
* Replay-then-tail loop for one socket. Each delivered event advances the
|
|
109
|
-
* socket's persisted cursor so a mid-stream reconnect resumes exactly once.
|
|
110
|
-
* No-ops if a pump is already running for this socket (see {@link pumping}).
|
|
111
|
-
*/
|
|
112
|
-
pump(socket, runId, fromSeq) {
|
|
113
|
-
if (this.pumping.has(socket)) return;
|
|
114
|
-
this.pumping.add(socket);
|
|
115
|
-
const done = (async () => {
|
|
116
|
-
try {
|
|
117
|
-
for await (const event of this.controller.attach(runId, { fromSeq })) {
|
|
118
|
-
socket.send(JSON.stringify(event));
|
|
119
|
-
socket.serializeAttachment({
|
|
120
|
-
runId,
|
|
121
|
-
lastSeq: event.seq
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
|
-
const record = await this.log.get(runId);
|
|
125
|
-
if (socket.readyState === WebSocket.OPEN) {
|
|
126
|
-
socket.send(JSON.stringify({ type: "status", record }));
|
|
127
|
-
socket.close(1e3, "run complete");
|
|
128
|
-
}
|
|
129
|
-
} catch (error) {
|
|
130
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
131
|
-
console.error(
|
|
132
|
-
`[sandbox-coordinator] tail failed for run ${runId}:`,
|
|
133
|
-
error
|
|
134
|
-
);
|
|
135
|
-
if (socket.readyState === WebSocket.OPEN) {
|
|
136
|
-
socket.close(1011, message.slice(0, 120));
|
|
137
|
-
}
|
|
138
|
-
} finally {
|
|
139
|
-
this.pumping.delete(socket);
|
|
140
|
-
}
|
|
141
|
-
})();
|
|
142
|
-
this.ctx.waitUntil(done);
|
|
143
|
-
}
|
|
144
|
-
webSocketMessage(ws, _message) {
|
|
145
|
-
const attachment = ws.deserializeAttachment();
|
|
146
|
-
if (isSocketAttachment(attachment)) {
|
|
147
|
-
this.pump(ws, attachment.runId, attachment.lastSeq);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
webSocketClose(_ws, _code, _reason) {
|
|
151
|
-
}
|
|
152
|
-
// ===========================================================================
|
|
153
|
-
// Watchdog alarm — keeps a run observable across hibernation
|
|
154
|
-
// ===========================================================================
|
|
155
|
-
async alarm() {
|
|
156
|
-
try {
|
|
157
|
-
const runs = await this.ctx.storage.list({ prefix: "rec:" });
|
|
158
|
-
const now = Date.now();
|
|
159
|
-
let active = false;
|
|
160
|
-
for (const record of runs.values()) {
|
|
161
|
-
if (isTerminalRunStatus(record.status)) continue;
|
|
162
|
-
if (now - record.updatedAt > WATCHDOG_STALL_MS) {
|
|
163
|
-
await this.failStalledRun(record.runId);
|
|
164
|
-
} else {
|
|
165
|
-
active = true;
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
if (active) await this.ctx.storage.setAlarm(Date.now() + WATCHDOG_MS);
|
|
169
|
-
} catch (error) {
|
|
170
|
-
console.error("[sandbox-coordinator] watchdog alarm failed:", error);
|
|
171
|
-
await this.ctx.storage.setAlarm(Date.now() + WATCHDOG_MS);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
/** Mark a stalled (orchestrator-presumed-dead) run as a terminal error. */
|
|
175
|
-
async failStalledRun(runId) {
|
|
176
|
-
const message = "run watchdog: no progress; orchestrator presumed dead";
|
|
177
|
-
try {
|
|
178
|
-
await this.log.append(runId, { type: EventType.RUN_ERROR, message });
|
|
179
|
-
} catch {
|
|
180
|
-
}
|
|
181
|
-
await this.log.finish(runId, "error", { message });
|
|
182
|
-
this.onRunSettled(runId);
|
|
183
|
-
}
|
|
42
|
+
return value !== null && typeof value === "object" && "runId" in value && typeof value.runId === "string" && "lastSeq" in value && typeof value.lastSeq === "number";
|
|
184
43
|
}
|
|
185
|
-
|
|
186
|
-
|
|
44
|
+
var SandboxCoordinator = class extends DurableObject {
|
|
45
|
+
log;
|
|
46
|
+
controller;
|
|
47
|
+
/**
|
|
48
|
+
* Sockets with a live {@link pump} loop. Guards against a second concurrent
|
|
49
|
+
* pump on the same socket: `acceptStream` starts one, and `webSocketMessage`
|
|
50
|
+
* would start another on any inbound client message while the first is still
|
|
51
|
+
* running — double-delivering events and racing the persisted cursor.
|
|
52
|
+
*/
|
|
53
|
+
pumping = /* @__PURE__ */ new WeakSet();
|
|
54
|
+
constructor(ctx, env) {
|
|
55
|
+
super(ctx, env);
|
|
56
|
+
this.log = new DurableObjectRunEventLog(ctx.storage);
|
|
57
|
+
this.controller = new RunController({
|
|
58
|
+
runs: runLogStore(this.log),
|
|
59
|
+
durability: (runId) => runLogStream(this.log, { runId })
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/** Extra fetch routes a subclass serves (e.g. `/_bridge`, `/tool-exec`). */
|
|
63
|
+
handleRoute(_request, _parts) {
|
|
64
|
+
return new Response("not found", { status: 404 });
|
|
65
|
+
}
|
|
66
|
+
/** Called once a run reaches a terminal status (override to clean up state). */
|
|
67
|
+
onRunSettled(_runId) {}
|
|
68
|
+
jsonResponse(body, status = 200) {
|
|
69
|
+
return new Response(JSON.stringify(body), {
|
|
70
|
+
status,
|
|
71
|
+
headers: { "content-type": "application/json" }
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
async startRun(input) {
|
|
75
|
+
if (await this.log.get(input.runId)) return { runId: input.runId };
|
|
76
|
+
await this.log.open({
|
|
77
|
+
runId: input.runId,
|
|
78
|
+
threadId: input.threadId
|
|
79
|
+
});
|
|
80
|
+
let stream;
|
|
81
|
+
try {
|
|
82
|
+
stream = await this.buildRunStream(input);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
85
|
+
await this.log.append(input.runId, {
|
|
86
|
+
type: EventType.RUN_ERROR,
|
|
87
|
+
message
|
|
88
|
+
});
|
|
89
|
+
await this.log.finish(input.runId, "failed", { message });
|
|
90
|
+
this.onRunSettled(input.runId);
|
|
91
|
+
return { runId: input.runId };
|
|
92
|
+
}
|
|
93
|
+
const { done } = this.controller.start({
|
|
94
|
+
runId: input.runId,
|
|
95
|
+
threadId: input.threadId,
|
|
96
|
+
stream
|
|
97
|
+
});
|
|
98
|
+
const settle = () => this.onRunSettled(input.runId);
|
|
99
|
+
this.ctx.waitUntil(done.then(settle, settle));
|
|
100
|
+
await this.ctx.storage.setAlarm(Date.now() + WATCHDOG_MS);
|
|
101
|
+
return { runId: input.runId };
|
|
102
|
+
}
|
|
103
|
+
async status(runId) {
|
|
104
|
+
return this.log.get(runId);
|
|
105
|
+
}
|
|
106
|
+
async fetch(request) {
|
|
107
|
+
const parts = new URL(request.url).pathname.split("/").filter(Boolean);
|
|
108
|
+
if (parts[0] === "runs" && typeof parts[1] === "string") {
|
|
109
|
+
if (parts[2] === "stream") return this.acceptStream(parts[1], request);
|
|
110
|
+
if (parts.length === 2 && request.method === "GET") {
|
|
111
|
+
const record = await this.status(parts[1]);
|
|
112
|
+
return record ? this.jsonResponse(record) : this.jsonResponse({ error: "unknown run" }, 404);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return this.handleRoute(request, parts);
|
|
116
|
+
}
|
|
117
|
+
async acceptStream(runId, request) {
|
|
118
|
+
if (request.headers.get("upgrade") !== "websocket") return new Response("expected websocket upgrade", { status: 426 });
|
|
119
|
+
if (!await this.log.get(runId)) return new Response("unknown run", { status: 404 });
|
|
120
|
+
const lastSeqParam = new URL(request.url).searchParams.get("lastSeq");
|
|
121
|
+
const lastSeq = lastSeqParam !== null ? Number.parseInt(lastSeqParam, 10) : -1;
|
|
122
|
+
if (Number.isNaN(lastSeq)) return new Response("lastSeq must be an integer", { status: 400 });
|
|
123
|
+
const pair = new WebSocketPair();
|
|
124
|
+
const [client, server] = [pair[0], pair[1]];
|
|
125
|
+
server.serializeAttachment({
|
|
126
|
+
runId,
|
|
127
|
+
lastSeq
|
|
128
|
+
});
|
|
129
|
+
this.ctx.acceptWebSocket(server);
|
|
130
|
+
this.pump(server, runId, lastSeq);
|
|
131
|
+
return new Response(null, {
|
|
132
|
+
status: 101,
|
|
133
|
+
webSocket: client
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Replay-then-tail loop for one socket. Each delivered event advances the
|
|
138
|
+
* socket's persisted cursor so a mid-stream reconnect resumes exactly once.
|
|
139
|
+
* No-ops if a pump is already running for this socket (see {@link pumping}).
|
|
140
|
+
*/
|
|
141
|
+
pump(socket, runId, fromSeq) {
|
|
142
|
+
if (this.pumping.has(socket)) return;
|
|
143
|
+
this.pumping.add(socket);
|
|
144
|
+
const done = (async () => {
|
|
145
|
+
try {
|
|
146
|
+
for await (const event of this.log.read(runId, { fromSeq })) {
|
|
147
|
+
socket.send(JSON.stringify(event));
|
|
148
|
+
socket.serializeAttachment({
|
|
149
|
+
runId,
|
|
150
|
+
lastSeq: event.seq
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
const record = await this.log.get(runId);
|
|
154
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
155
|
+
socket.send(JSON.stringify({
|
|
156
|
+
type: "status",
|
|
157
|
+
record
|
|
158
|
+
}));
|
|
159
|
+
socket.close(1e3, "run complete");
|
|
160
|
+
}
|
|
161
|
+
} catch (error) {
|
|
162
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
163
|
+
console.error(`[sandbox-coordinator] tail failed for run ${runId}:`, error);
|
|
164
|
+
if (socket.readyState === WebSocket.OPEN) socket.close(1011, message.slice(0, 120));
|
|
165
|
+
} finally {
|
|
166
|
+
this.pumping.delete(socket);
|
|
167
|
+
}
|
|
168
|
+
})();
|
|
169
|
+
this.ctx.waitUntil(done);
|
|
170
|
+
}
|
|
171
|
+
webSocketMessage(ws, _message) {
|
|
172
|
+
const attachment = ws.deserializeAttachment();
|
|
173
|
+
if (isSocketAttachment(attachment)) this.pump(ws, attachment.runId, attachment.lastSeq);
|
|
174
|
+
}
|
|
175
|
+
webSocketClose(_ws, _code, _reason) {}
|
|
176
|
+
async alarm() {
|
|
177
|
+
try {
|
|
178
|
+
const runs = await this.log.list();
|
|
179
|
+
const now = Date.now();
|
|
180
|
+
let active = false;
|
|
181
|
+
for (const record of runs) {
|
|
182
|
+
if (isTerminalRunStatus(record.status)) continue;
|
|
183
|
+
if (now - record.updatedAt > WATCHDOG_STALL_MS) await this.failStalledRun(record.runId);
|
|
184
|
+
else active = true;
|
|
185
|
+
}
|
|
186
|
+
if (active) await this.ctx.storage.setAlarm(Date.now() + WATCHDOG_MS);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
console.error("[sandbox-coordinator] watchdog alarm failed:", error);
|
|
189
|
+
await this.ctx.storage.setAlarm(Date.now() + WATCHDOG_MS);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/** Mark a stalled (orchestrator-presumed-dead) run as a terminal error. */
|
|
193
|
+
async failStalledRun(runId) {
|
|
194
|
+
const message = "run watchdog: no progress; orchestrator presumed dead";
|
|
195
|
+
try {
|
|
196
|
+
await this.log.append(runId, {
|
|
197
|
+
type: EventType.RUN_ERROR,
|
|
198
|
+
message
|
|
199
|
+
});
|
|
200
|
+
} catch {}
|
|
201
|
+
await this.log.finish(runId, "failed", { message });
|
|
202
|
+
this.onRunSettled(runId);
|
|
203
|
+
}
|
|
187
204
|
};
|
|
188
|
-
//#
|
|
205
|
+
//#endregion
|
|
206
|
+
export { SandboxCoordinator, resolveBridgeOrigin, resolvePreviewHost };
|
|
207
|
+
|
|
208
|
+
//# sourceMappingURL=coordinator.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"coordinator.js","sources":["../../src/coordinator.ts"],"sourcesContent":["/**\n * `SandboxCoordinator` — the abstract Durable Object base for the serverless/\n * edge agent run model. It owns everything the two concrete models share:\n *\n * - a durable, resumable run-log ({@link DurableObjectRunEventLog});\n * - `startRun`: open the run, kick off the model's chunk stream WITHOUT blocking\n * the trigger, start piping it into the log via {@link RunController}, register\n * the resulting `done` promise with `ctx.waitUntil` (keeping the instance alive\n * until the run is terminal rather than letting it hibernate mid-run), and arm\n * a watchdog alarm;\n * - `status` (poll fallback) + a hibernatable WebSocket tail with a resumable\n * cursor (replay after `lastSeq`, then live-tail, reconnect-safe);\n * - routing for `GET /runs/:id` and `GET /runs/:id/stream`, delegating any other\n * path to {@link handleRoute} (which a subclass overrides for e.g. `/_bridge`\n * or `/tool-exec`).\n *\n * Subclasses implement {@link buildRunStream} — the ONE difference between the\n * models: run `chat()` in the DO ({@link ChatSandboxCoordinator}) or drive an\n * in-container runner ({@link ContainerSandboxCoordinator}).\n *\n * NOTE: Workers-runtime code — compiles against `@cloudflare/workers-types`; not\n * runtime-verified in this repo.\n */\nimport { DurableObject } from 'cloudflare:workers'\nimport { EventType } from '@tanstack/ai'\nimport { RunController, isTerminalRunStatus } from '@tanstack/ai-sandbox'\nimport { DurableObjectRunEventLog } from './run-log-do'\nimport type { ModelMessage, StreamChunk } from '@tanstack/ai'\nimport type { RunRecord } from '@tanstack/ai-sandbox'\n\n/** Re-arm window for the liveness watchdog while a run is in flight (ms). */\nconst WATCHDOG_MS = 30_000\n\n/**\n * How long a non-terminal run may go without ANY new event before the watchdog\n * presumes the orchestrator driving it is dead (eviction that lost the\n * `waitUntil` promise, an uncaught fault, a hung container) and fails the run so\n * tailing clients stop waiting forever. Generous so a legitimately slow agent\n * step (a long tool call that emits no chunks) is not killed prematurely.\n */\nconst WATCHDOG_STALL_MS = 5 * 60_000\n\n/** What the Worker hands the coordinator to start a run. */\nexport interface StartRunInput {\n runId: string\n threadId: string\n messages: Array<ModelMessage>\n /**\n * The host the `POST /runs` trigger request arrived on, captured by the Worker\n * (`new URL(request.url).host`). Used to derive the container's callback hosts\n * when `PUBLIC_HOSTNAME` / `PREVIEW_HOSTNAME` are not set — see\n * {@link resolveBridgeOrigin} / {@link resolvePreviewHost} for the rules (and the\n * Cloudflare-specific reason request-derivation is safe to trust).\n */\n publicHost?: string\n /**\n * Free-form per-run input forwarded verbatim from the trigger to the app's\n * `adapter` / `sandbox` / `tools` resolvers (it reaches them through `config`\n * unchanged; it is NOT persisted to the run-log). Use it to carry browser-chosen\n * run options the base trigger has no field for — e.g. which harness to run, or a\n * model id. The package never inspects it; the app validates whatever it reads.\n */\n metadata?: Record<string, unknown>\n}\n\n// Host resolvers live in their own (Workers-free) module so they stay pure and\n// unit-testable; re-exported here because the coordinators build their callback\n// URLs with them. `resolveBridgeOrigin` = container→Worker (/_bridge, /tool-exec);\n// `resolvePreviewHost` = browser→container previews. See their docstrings.\nexport { resolveBridgeOrigin, resolvePreviewHost } from './public-host'\n\n/** Cursor stashed on each hibernatable WebSocket so it survives eviction. */\ninterface SocketAttachment {\n runId: string\n lastSeq: number\n}\n\nfunction isSocketAttachment(value: unknown): value is SocketAttachment {\n return (\n value !== null &&\n typeof value === 'object' &&\n 'runId' in value &&\n typeof value.runId === 'string' &&\n 'lastSeq' in value &&\n typeof value.lastSeq === 'number'\n )\n}\n\nexport abstract class SandboxCoordinator<\n TEnv = unknown,\n> extends DurableObject<TEnv> {\n protected readonly log: DurableObjectRunEventLog\n protected readonly controller: RunController\n\n /**\n * Sockets with a live {@link pump} loop. Guards against a second concurrent\n * pump on the same socket: `acceptStream` starts one, and `webSocketMessage`\n * would start another on any inbound client message while the first is still\n * running — double-delivering events and racing the persisted cursor.\n */\n private readonly pumping = new WeakSet<WebSocket>()\n\n constructor(ctx: DurableObjectState, env: TEnv) {\n super(ctx, env)\n this.log = new DurableObjectRunEventLog(ctx.storage)\n this.controller = new RunController(this.log)\n }\n\n // ===========================================================================\n // Subclass seam\n // ===========================================================================\n\n /**\n * Produce the run's `StreamChunk` stream. The ONE model-specific method:\n * `ChatSandboxCoordinator` runs `chat()` here; `ContainerSandboxCoordinator`\n * drives the in-container runner. Lazily consumed by the run driver, so any\n * setup (mint a token, start a container) can happen at the top.\n */\n protected abstract buildRunStream(\n input: StartRunInput,\n ): AsyncIterable<StreamChunk> | Promise<AsyncIterable<StreamChunk>>\n\n /** Extra fetch routes a subclass serves (e.g. `/_bridge`, `/tool-exec`). */\n protected handleRoute(\n _request: Request,\n _parts: Array<string>,\n ): Promise<Response> | Response {\n return new Response('not found', { status: 404 })\n }\n\n /** Called once a run reaches a terminal status (override to clean up state). */\n protected onRunSettled(_runId: string): void {}\n\n protected jsonResponse(body: unknown, status = 200): Response {\n return new Response(JSON.stringify(body), {\n status,\n headers: { 'content-type': 'application/json' },\n })\n }\n\n // ===========================================================================\n // Trigger (called by the Worker; returns immediately)\n // ===========================================================================\n\n async startRun(input: StartRunInput): Promise<{ runId: string }> {\n const existing = await this.log.get(input.runId)\n if (existing) return { runId: input.runId } // idempotent re-trigger\n\n // Open the run BEFORE building the stream. `pipeToRunLog`'s never-rejects\n // guarantee only covers failures AFTER the stream is handed to it — a throw\n // while BUILDING the stream (config(), chat() validation, mint a token)\n // would otherwise leave no record and no terminal event, so a tailing client\n // would never see the failure. Opening here (idempotent with pipeToRunLog's\n // own open) lets us record it.\n await this.log.open({ runId: input.runId, threadId: input.threadId })\n let stream: AsyncIterable<StreamChunk>\n try {\n stream = await this.buildRunStream(input)\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n await this.log.append(input.runId, {\n type: EventType.RUN_ERROR,\n message,\n })\n await this.log.finish(input.runId, 'error', { message })\n this.onRunSettled(input.runId)\n return { runId: input.runId }\n }\n\n const { done } = this.controller.start({\n runId: input.runId,\n threadId: input.threadId,\n stream,\n })\n // Keep the instance alive until the run is terminal; `pipeToRunLog` never\n // rejects (failures land in the log), so no `.catch` is needed.\n this.ctx.waitUntil(done.finally(() => this.onRunSettled(input.runId)))\n await this.ctx.storage.setAlarm(Date.now() + WATCHDOG_MS)\n return { runId: input.runId }\n }\n\n async status(runId: string): Promise<RunRecord | null> {\n return this.controller.status(runId)\n }\n\n // ===========================================================================\n // HTTP surface\n // ===========================================================================\n\n override async fetch(request: Request): Promise<Response> {\n const url = new URL(request.url)\n const parts = url.pathname.split('/').filter(Boolean)\n\n if (parts[0] === 'runs' && typeof parts[1] === 'string') {\n if (parts[2] === 'stream') return this.acceptStream(parts[1], request)\n if (parts.length === 2 && request.method === 'GET') {\n const record = await this.status(parts[1])\n return record\n ? this.jsonResponse(record)\n : this.jsonResponse({ error: 'unknown run' }, 404)\n }\n }\n return this.handleRoute(request, parts)\n }\n\n // ===========================================================================\n // WebSocket streaming with hibernation + resumable cursor\n // ===========================================================================\n\n private async acceptStream(\n runId: string,\n request: Request,\n ): Promise<Response> {\n if (request.headers.get('upgrade') !== 'websocket') {\n return new Response('expected websocket upgrade', { status: 426 })\n }\n const record = await this.log.get(runId)\n if (!record) return new Response('unknown run', { status: 404 })\n\n const url = new URL(request.url)\n const lastSeqParam = url.searchParams.get('lastSeq')\n const lastSeq =\n lastSeqParam !== null ? Number.parseInt(lastSeqParam, 10) : -1\n if (Number.isNaN(lastSeq)) {\n return new Response('lastSeq must be an integer', { status: 400 })\n }\n\n const pair = new WebSocketPair()\n const [client, server] = [pair[0], pair[1]]\n server.serializeAttachment({ runId, lastSeq } satisfies SocketAttachment)\n this.ctx.acceptWebSocket(server)\n this.pump(server, runId, lastSeq)\n\n return new Response(null, { status: 101, webSocket: client })\n }\n\n /**\n * Replay-then-tail loop for one socket. Each delivered event advances the\n * socket's persisted cursor so a mid-stream reconnect resumes exactly once.\n * No-ops if a pump is already running for this socket (see {@link pumping}).\n */\n private pump(socket: WebSocket, runId: string, fromSeq: number): void {\n if (this.pumping.has(socket)) return\n this.pumping.add(socket)\n const done = (async () => {\n try {\n for await (const event of this.controller.attach(runId, { fromSeq })) {\n socket.send(JSON.stringify(event))\n socket.serializeAttachment({\n runId,\n lastSeq: event.seq,\n } satisfies SocketAttachment)\n }\n const record = await this.log.get(runId)\n if (socket.readyState === WebSocket.OPEN) {\n socket.send(JSON.stringify({ type: 'status', record }))\n socket.close(1000, 'run complete')\n }\n } catch (error) {\n // A tail loop throwing means a run-log read failed — an operator needs\n // the full error, but the client only gets a truncated close reason.\n const message = error instanceof Error ? error.message : String(error)\n console.error(\n `[sandbox-coordinator] tail failed for run ${runId}:`,\n error,\n )\n if (socket.readyState === WebSocket.OPEN) {\n socket.close(1011, message.slice(0, 120))\n }\n } finally {\n this.pumping.delete(socket)\n }\n })()\n this.ctx.waitUntil(done)\n }\n\n override webSocketMessage(\n ws: WebSocket,\n _message: string | ArrayBuffer,\n ): void {\n // Only meaningful as a post-hibernation resume nudge: restart the tail from\n // the persisted cursor IF no pump is live (the guard in `pump` enforces the\n // \"resume exactly once\" invariant when the original pump is still running).\n const attachment: unknown = ws.deserializeAttachment()\n if (isSocketAttachment(attachment)) {\n this.pump(ws, attachment.runId, attachment.lastSeq)\n }\n }\n\n override webSocketClose(\n _ws: WebSocket,\n _code: number,\n _reason: string,\n ): void {\n // Nothing to clean up: the run-log is durable and independent of any socket.\n }\n\n // ===========================================================================\n // Watchdog alarm — keeps a run observable across hibernation\n // ===========================================================================\n\n override async alarm(): Promise<void> {\n try {\n const runs = await this.ctx.storage.list<RunRecord>({ prefix: 'rec:' })\n const now = Date.now()\n let active = false\n for (const record of runs.values()) {\n if (isTerminalRunStatus(record.status)) continue\n if (now - record.updatedAt > WATCHDOG_STALL_MS) {\n // No progress for too long — the driver is presumed dead. Fail the run\n // so tailing clients stop waiting forever (the whole point of the\n // watchdog; without this a stuck run sits at `running` indefinitely).\n await this.failStalledRun(record.runId)\n } else {\n active = true\n }\n }\n if (active) await this.ctx.storage.setAlarm(Date.now() + WATCHDOG_MS)\n } catch (error) {\n // Never let the watchdog die silently: a transient storage error must not\n // permanently disable liveness detection. Re-arm and try again next tick.\n console.error('[sandbox-coordinator] watchdog alarm failed:', error)\n await this.ctx.storage.setAlarm(Date.now() + WATCHDOG_MS)\n }\n }\n\n /** Mark a stalled (orchestrator-presumed-dead) run as a terminal error. */\n private async failStalledRun(runId: string): Promise<void> {\n const message = 'run watchdog: no progress; orchestrator presumed dead'\n try {\n await this.log.append(runId, { type: EventType.RUN_ERROR, message })\n } catch {\n // The run may have just reached terminal concurrently; finish is idempotent.\n }\n await this.log.finish(runId, 'error', { message })\n this.onRunSettled(runId)\n }\n}\n"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"coordinator.js","names":[],"sources":["../../src/coordinator.ts"],"sourcesContent":["/**\n * `SandboxCoordinator` — the abstract Durable Object base for the serverless/\n * edge agent run model. It owns everything the two concrete models share:\n *\n * - a durable, resumable run-log ({@link DurableObjectRunEventLog});\n * - `startRun`: open the run, kick off the model's chunk stream WITHOUT blocking\n * the trigger, start piping it into the log via {@link RunController}, register\n * the resulting `done` promise with `ctx.waitUntil` (keeping the instance alive\n * until the run is terminal rather than letting it hibernate mid-run), and arm\n * a watchdog alarm;\n * - `status` (poll fallback) + a hibernatable WebSocket tail with a resumable\n * cursor (replay after `lastSeq`, then live-tail, reconnect-safe);\n * - routing for `GET /runs/:id` and `GET /runs/:id/stream`, delegating any other\n * path to {@link handleRoute} (which a subclass overrides for e.g. `/_bridge`\n * or `/tool-exec`).\n *\n * Subclasses implement {@link buildRunStream} — the ONE difference between the\n * models: run `chat()` in the DO ({@link ChatSandboxCoordinator}) or drive an\n * in-container runner ({@link ContainerSandboxCoordinator}).\n *\n * NOTE: Workers-runtime code — compiles against `@cloudflare/workers-types`; not\n * runtime-verified in this repo.\n */\nimport { DurableObject } from 'cloudflare:workers'\nimport { EventType, isTerminalRunStatus } from '@tanstack/ai'\n// The PORTABLE run driver: this coordinator is a platform binding of core's\n// `RunController`, not a driver of its own. The DO run log backs both of the\n// driver's seams through the adapters in './durability' — `runLogStore` for\n// the lifecycle record, `runLogStream` for the per-run event log — and the\n// vocabulary is core's throughout (historical `done`/`error` records are\n// migrated on read; see './run-log').\nimport { RunController } from '@tanstack/ai-sandbox'\nimport { runLogStore, runLogStream } from './durability'\nimport { DurableObjectRunEventLog } from './run-log-do'\nimport type { ModelMessage, StreamChunk } from '@tanstack/ai'\nimport type { RunLogRecord } from './run-log'\n\n/** Re-arm window for the liveness watchdog while a run is in flight (ms). */\nconst WATCHDOG_MS = 30_000\n\n/**\n * How long a non-terminal run may go without ANY new event before the watchdog\n * presumes the orchestrator driving it is dead (eviction that lost the\n * `waitUntil` promise, an uncaught fault, a hung container) and fails the run so\n * tailing clients stop waiting forever. Generous so a legitimately slow agent\n * step (a long tool call that emits no chunks) is not killed prematurely.\n */\nconst WATCHDOG_STALL_MS = 5 * 60_000\n\n/** What the Worker hands the coordinator to start a run. */\nexport interface StartRunInput {\n runId: string\n threadId: string\n messages: Array<ModelMessage>\n /**\n * The host the `POST /runs` trigger request arrived on, captured by the Worker\n * (`new URL(request.url).host`). Used to derive the container's callback hosts\n * when `PUBLIC_HOSTNAME` / `PREVIEW_HOSTNAME` are not set — see\n * {@link resolveBridgeOrigin} / {@link resolvePreviewHost} for the rules (and the\n * Cloudflare-specific reason request-derivation is safe to trust).\n */\n publicHost?: string\n /**\n * Free-form per-run input forwarded verbatim from the trigger to the app's\n * `adapter` / `sandbox` / `tools` resolvers (it reaches them through `config`\n * unchanged; it is NOT persisted to the run-log). Use it to carry browser-chosen\n * run options the base trigger has no field for — e.g. which harness to run, or a\n * model id. The package never inspects it; the app validates whatever it reads.\n */\n metadata?: Record<string, unknown>\n}\n\n// Host resolvers live in their own (Workers-free) module so they stay pure and\n// unit-testable; re-exported here because the coordinators build their callback\n// URLs with them. `resolveBridgeOrigin` = container→Worker (/_bridge, /tool-exec);\n// `resolvePreviewHost` = browser→container previews. See their docstrings.\nexport { resolveBridgeOrigin, resolvePreviewHost } from './public-host'\n\n/** Cursor stashed on each hibernatable WebSocket so it survives eviction. */\ninterface SocketAttachment {\n runId: string\n lastSeq: number\n}\n\nfunction isSocketAttachment(value: unknown): value is SocketAttachment {\n return (\n value !== null &&\n typeof value === 'object' &&\n 'runId' in value &&\n typeof value.runId === 'string' &&\n 'lastSeq' in value &&\n typeof value.lastSeq === 'number'\n )\n}\n\nexport abstract class SandboxCoordinator<\n TEnv = unknown,\n> extends DurableObject<TEnv> {\n protected readonly log: DurableObjectRunEventLog\n protected readonly controller: RunController\n\n /**\n * Sockets with a live {@link pump} loop. Guards against a second concurrent\n * pump on the same socket: `acceptStream` starts one, and `webSocketMessage`\n * would start another on any inbound client message while the first is still\n * running — double-delivering events and racing the persisted cursor.\n */\n private readonly pumping = new WeakSet<WebSocket>()\n\n constructor(ctx: DurableObjectState, env: TEnv) {\n super(ctx, env)\n this.log = new DurableObjectRunEventLog(ctx.storage)\n this.controller = new RunController({\n runs: runLogStore(this.log),\n durability: (runId) => runLogStream(this.log, { runId }),\n })\n }\n\n // ===========================================================================\n // Subclass seam\n // ===========================================================================\n\n /**\n * Produce the run's `StreamChunk` stream. The ONE model-specific method:\n * `ChatSandboxCoordinator` runs `chat()` here; `ContainerSandboxCoordinator`\n * drives the in-container runner. Lazily consumed by the run driver, so any\n * setup (mint a token, start a container) can happen at the top.\n */\n protected abstract buildRunStream(\n input: StartRunInput,\n ): AsyncIterable<StreamChunk> | Promise<AsyncIterable<StreamChunk>>\n\n /** Extra fetch routes a subclass serves (e.g. `/_bridge`, `/tool-exec`). */\n protected handleRoute(\n _request: Request,\n _parts: Array<string>,\n ): Promise<Response> | Response {\n return new Response('not found', { status: 404 })\n }\n\n /** Called once a run reaches a terminal status (override to clean up state). */\n protected onRunSettled(_runId: string): void {}\n\n protected jsonResponse(body: unknown, status = 200): Response {\n return new Response(JSON.stringify(body), {\n status,\n headers: { 'content-type': 'application/json' },\n })\n }\n\n // ===========================================================================\n // Trigger (called by the Worker; returns immediately)\n // ===========================================================================\n\n async startRun(input: StartRunInput): Promise<{ runId: string }> {\n const existing = await this.log.get(input.runId)\n if (existing) return { runId: input.runId } // idempotent re-trigger\n\n // Open the run BEFORE building the stream. `pipeToRunLog`'s never-rejects\n // guarantee only covers failures AFTER the stream is handed to it — a throw\n // while BUILDING the stream (config(), chat() validation, mint a token)\n // would otherwise leave no record and no terminal event, so a tailing client\n // would never see the failure. Opening here (idempotent with pipeToRunLog's\n // own open) lets us record it.\n await this.log.open({ runId: input.runId, threadId: input.threadId })\n let stream: AsyncIterable<StreamChunk>\n try {\n stream = await this.buildRunStream(input)\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n await this.log.append(input.runId, {\n type: EventType.RUN_ERROR,\n message,\n })\n await this.log.finish(input.runId, 'failed', { message })\n this.onRunSettled(input.runId)\n return { runId: input.runId }\n }\n\n const { done } = this.controller.start({\n runId: input.runId,\n threadId: input.threadId,\n stream,\n })\n // Keep the instance alive until the run is terminal. `pipeToRunLog` never\n // rejects (failures land in the log), but this must not DEPEND on that:\n // `.finally` adopts a rejection, which would hand `waitUntil` a rejected\n // promise. Two-argument `then` settles fulfilled either way while still\n // running the settle hook.\n const settle = (): void => this.onRunSettled(input.runId)\n this.ctx.waitUntil(done.then(settle, settle))\n await this.ctx.storage.setAlarm(Date.now() + WATCHDOG_MS)\n return { runId: input.runId }\n }\n\n async status(runId: string): Promise<RunLogRecord | null> {\n // Straight off the log (not `controller.status`) so the answer keeps the\n // log-level fields (`lastSeq`) a reconnecting client resumes from.\n return this.log.get(runId)\n }\n\n // ===========================================================================\n // HTTP surface\n // ===========================================================================\n\n override async fetch(request: Request): Promise<Response> {\n const url = new URL(request.url)\n const parts = url.pathname.split('/').filter(Boolean)\n\n if (parts[0] === 'runs' && typeof parts[1] === 'string') {\n if (parts[2] === 'stream') return this.acceptStream(parts[1], request)\n if (parts.length === 2 && request.method === 'GET') {\n const record = await this.status(parts[1])\n return record\n ? this.jsonResponse(record)\n : this.jsonResponse({ error: 'unknown run' }, 404)\n }\n }\n return this.handleRoute(request, parts)\n }\n\n // ===========================================================================\n // WebSocket streaming with hibernation + resumable cursor\n // ===========================================================================\n\n private async acceptStream(\n runId: string,\n request: Request,\n ): Promise<Response> {\n if (request.headers.get('upgrade') !== 'websocket') {\n return new Response('expected websocket upgrade', { status: 426 })\n }\n const record = await this.log.get(runId)\n if (!record) return new Response('unknown run', { status: 404 })\n\n const url = new URL(request.url)\n const lastSeqParam = url.searchParams.get('lastSeq')\n const lastSeq =\n lastSeqParam !== null ? Number.parseInt(lastSeqParam, 10) : -1\n if (Number.isNaN(lastSeq)) {\n return new Response('lastSeq must be an integer', { status: 400 })\n }\n\n const pair = new WebSocketPair()\n const [client, server] = [pair[0], pair[1]]\n server.serializeAttachment({ runId, lastSeq } satisfies SocketAttachment)\n this.ctx.acceptWebSocket(server)\n this.pump(server, runId, lastSeq)\n\n return new Response(null, { status: 101, webSocket: client })\n }\n\n /**\n * Replay-then-tail loop for one socket. Each delivered event advances the\n * socket's persisted cursor so a mid-stream reconnect resumes exactly once.\n * No-ops if a pump is already running for this socket (see {@link pumping}).\n */\n private pump(socket: WebSocket, runId: string, fromSeq: number): void {\n if (this.pumping.has(socket)) return\n this.pumping.add(socket)\n const done = (async () => {\n try {\n // The tail reads the log directly by seq — the client wire protocol\n // (`?lastSeq`, `{seq, chunk}` frames) is seq-based, and `log.read` is\n // the seq-cursor surface. Core's `controller.attach` serves consumers\n // that speak opaque `StreamDurability` offsets instead.\n for await (const event of this.log.read(runId, { fromSeq })) {\n socket.send(JSON.stringify(event))\n socket.serializeAttachment({\n runId,\n lastSeq: event.seq,\n } satisfies SocketAttachment)\n }\n const record = await this.log.get(runId)\n if (socket.readyState === WebSocket.OPEN) {\n socket.send(JSON.stringify({ type: 'status', record }))\n socket.close(1000, 'run complete')\n }\n } catch (error) {\n // A tail loop throwing means a run-log read failed — an operator needs\n // the full error, but the client only gets a truncated close reason.\n const message = error instanceof Error ? error.message : String(error)\n console.error(\n `[sandbox-coordinator] tail failed for run ${runId}:`,\n error,\n )\n if (socket.readyState === WebSocket.OPEN) {\n socket.close(1011, message.slice(0, 120))\n }\n } finally {\n this.pumping.delete(socket)\n }\n })()\n this.ctx.waitUntil(done)\n }\n\n override webSocketMessage(\n ws: WebSocket,\n _message: string | ArrayBuffer,\n ): void {\n // Only meaningful as a post-hibernation resume nudge: restart the tail from\n // the persisted cursor IF no pump is live (the guard in `pump` enforces the\n // \"resume exactly once\" invariant when the original pump is still running).\n const attachment: unknown = ws.deserializeAttachment()\n if (isSocketAttachment(attachment)) {\n this.pump(ws, attachment.runId, attachment.lastSeq)\n }\n }\n\n override webSocketClose(\n _ws: WebSocket,\n _code: number,\n _reason: string,\n ): void {\n // Nothing to clean up: the run-log is durable and independent of any socket.\n }\n\n // ===========================================================================\n // Watchdog alarm — keeps a run observable across hibernation\n // ===========================================================================\n\n override async alarm(): Promise<void> {\n try {\n // Through the log (not a raw `rec:` list) so legacy records are migrated\n // on the way out — the storage layout is the log's private concern.\n const runs = await this.log.list()\n const now = Date.now()\n let active = false\n for (const record of runs) {\n if (isTerminalRunStatus(record.status)) continue\n if (now - record.updatedAt > WATCHDOG_STALL_MS) {\n // No progress for too long — the driver is presumed dead. Fail the run\n // so tailing clients stop waiting forever (the whole point of the\n // watchdog; without this a stuck run sits at `running` indefinitely).\n await this.failStalledRun(record.runId)\n } else {\n active = true\n }\n }\n if (active) await this.ctx.storage.setAlarm(Date.now() + WATCHDOG_MS)\n } catch (error) {\n // Never let the watchdog die silently: a transient storage error must not\n // permanently disable liveness detection. Re-arm and try again next tick.\n console.error('[sandbox-coordinator] watchdog alarm failed:', error)\n await this.ctx.storage.setAlarm(Date.now() + WATCHDOG_MS)\n }\n }\n\n /** Mark a stalled (orchestrator-presumed-dead) run as a terminal error. */\n private async failStalledRun(runId: string): Promise<void> {\n const message = 'run watchdog: no progress; orchestrator presumed dead'\n try {\n await this.log.append(runId, { type: EventType.RUN_ERROR, message })\n } catch {\n // The run may have just reached terminal concurrently; finish is idempotent.\n }\n await this.log.finish(runId, 'failed', { message })\n this.onRunSettled(runId)\n }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { RunStore, StreamDurability } from '@tanstack/ai';
|
|
2
|
+
import { RunEventLog } from './run-log.js';
|
|
3
|
+
/**
|
|
4
|
+
* Expose a {@link RunEventLog} as core's `RunStore`, for the `runs` half of
|
|
5
|
+
* `RunDeps`. A pure rename layer — the invariants (idempotent `createOrResume`,
|
|
6
|
+
* no-op `update` on an unknown run) are the log's own.
|
|
7
|
+
*/
|
|
8
|
+
export declare function runLogStore(log: RunEventLog): RunStore;
|
|
9
|
+
/** Construction input for {@link runLogStream}. */
|
|
10
|
+
export interface RunLogStreamInit {
|
|
11
|
+
/** The run this durability adapter attaches to. */
|
|
12
|
+
runId: string;
|
|
13
|
+
/**
|
|
14
|
+
* Resume offset captured by the consumer (`resumeFrom()` returns it).
|
|
15
|
+
* Defaults to `null` (a producer / from-start reader).
|
|
16
|
+
*/
|
|
17
|
+
offset?: string | null;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Expose one run of a {@link RunEventLog} as core's `StreamDurability`, for the
|
|
21
|
+
* `durability` half of `RunDeps`: `(runId) => runLogStream(log, { runId })`.
|
|
22
|
+
*
|
|
23
|
+
* The run must already exist — core's driver guarantees it (`createOrResume`
|
|
24
|
+
* runs before the first `append`), and a standalone consumer opens it first.
|
|
25
|
+
* `append` and `read` on an unknown run reject, per the log's own contract;
|
|
26
|
+
* `snapshot` resolves `[]`, per `StreamDurability`'s.
|
|
27
|
+
*
|
|
28
|
+
* Offsets encode the log's monotonic `seq` (versioned, run-scoped, opaque to
|
|
29
|
+
* callers). The `'-1'` (from-start) and `'now'` (tail-only) read sentinels
|
|
30
|
+
* every shipped backend honors are supported.
|
|
31
|
+
*/
|
|
32
|
+
export declare function runLogStream(log: RunEventLog, init: RunLogStreamInit): StreamDurability;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
//#region src/durability.ts
|
|
2
|
+
/**
|
|
3
|
+
* Expose a {@link RunEventLog} as core's `RunStore`, for the `runs` half of
|
|
4
|
+
* `RunDeps`. A pure rename layer — the invariants (idempotent `createOrResume`,
|
|
5
|
+
* no-op `update` on an unknown run) are the log's own.
|
|
6
|
+
*/
|
|
7
|
+
function runLogStore(log) {
|
|
8
|
+
return {
|
|
9
|
+
createOrResume: ({ runId, threadId, startedAt }) => log.open({
|
|
10
|
+
runId,
|
|
11
|
+
threadId,
|
|
12
|
+
startedAt
|
|
13
|
+
}),
|
|
14
|
+
update: (runId, patch) => log.update(runId, patch),
|
|
15
|
+
get: (runId) => log.get(runId),
|
|
16
|
+
findActiveRun: async (threadId) => {
|
|
17
|
+
let active = null;
|
|
18
|
+
for (const record of await log.list()) {
|
|
19
|
+
if (record.threadId !== threadId || record.status !== "running") continue;
|
|
20
|
+
if (active === null || record.startedAt > active.startedAt) active = record;
|
|
21
|
+
}
|
|
22
|
+
return active;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
var RUN_LOG_OFFSET_PREFIX = "cfrunlog:v1:";
|
|
27
|
+
function encodeOffset(runId, seq) {
|
|
28
|
+
return `${RUN_LOG_OFFSET_PREFIX}${encodeURIComponent(runId)}:${seq}`;
|
|
29
|
+
}
|
|
30
|
+
function decodeOffset(offset) {
|
|
31
|
+
if (!offset.startsWith(RUN_LOG_OFFSET_PREFIX)) throw new Error(`Invalid run-log stream offset: ${offset}`);
|
|
32
|
+
const encoded = offset.slice(12);
|
|
33
|
+
const separator = encoded.lastIndexOf(":");
|
|
34
|
+
if (separator === -1) throw new Error(`Invalid run-log stream offset: ${offset}`);
|
|
35
|
+
const runId = decodeURIComponent(encoded.slice(0, separator));
|
|
36
|
+
const seq = Number(encoded.slice(separator + 1));
|
|
37
|
+
if (!Number.isSafeInteger(seq) || seq < 0) throw new Error(`Invalid run-log stream offset: ${offset}`);
|
|
38
|
+
return {
|
|
39
|
+
runId,
|
|
40
|
+
seq
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Expose one run of a {@link RunEventLog} as core's `StreamDurability`, for the
|
|
45
|
+
* `durability` half of `RunDeps`: `(runId) => runLogStream(log, { runId })`.
|
|
46
|
+
*
|
|
47
|
+
* The run must already exist — core's driver guarantees it (`createOrResume`
|
|
48
|
+
* runs before the first `append`), and a standalone consumer opens it first.
|
|
49
|
+
* `append` and `read` on an unknown run reject, per the log's own contract;
|
|
50
|
+
* `snapshot` resolves `[]`, per `StreamDurability`'s.
|
|
51
|
+
*
|
|
52
|
+
* Offsets encode the log's monotonic `seq` (versioned, run-scoped, opaque to
|
|
53
|
+
* callers). The `'-1'` (from-start) and `'now'` (tail-only) read sentinels
|
|
54
|
+
* every shipped backend honors are supported.
|
|
55
|
+
*/
|
|
56
|
+
function runLogStream(log, init) {
|
|
57
|
+
const { runId } = init;
|
|
58
|
+
const resumeOffset = init.offset ?? null;
|
|
59
|
+
const seqAfter = async (offset) => {
|
|
60
|
+
if (offset === "-1") return -1;
|
|
61
|
+
if (offset === "now") return (await log.get(runId))?.lastSeq ?? -1;
|
|
62
|
+
const decoded = decodeOffset(offset);
|
|
63
|
+
if (decoded.runId !== runId) throw new Error(`Run-log stream offset belongs to run ${JSON.stringify(decoded.runId)}, not ${JSON.stringify(runId)}`);
|
|
64
|
+
return decoded.seq;
|
|
65
|
+
};
|
|
66
|
+
return {
|
|
67
|
+
resumeFrom: () => resumeOffset,
|
|
68
|
+
append: async (chunks) => {
|
|
69
|
+
const offsets = [];
|
|
70
|
+
for (const chunk of chunks) offsets.push(encodeOffset(runId, await log.append(runId, chunk)));
|
|
71
|
+
return offsets;
|
|
72
|
+
},
|
|
73
|
+
read: async function* (offset, signal) {
|
|
74
|
+
const fromSeq = await seqAfter(offset);
|
|
75
|
+
const events = log.read(runId, {
|
|
76
|
+
fromSeq,
|
|
77
|
+
...signal !== void 0 ? { signal } : {}
|
|
78
|
+
});
|
|
79
|
+
for await (const event of events) yield {
|
|
80
|
+
offset: encodeOffset(runId, event.seq),
|
|
81
|
+
chunk: event.chunk
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
close: () => log.finish(runId, "completed"),
|
|
85
|
+
snapshot: async () => {
|
|
86
|
+
const record = await log.get(runId);
|
|
87
|
+
if (record === null || record.lastSeq < 0) return [];
|
|
88
|
+
const lastSeq = record.lastSeq;
|
|
89
|
+
const entries = [];
|
|
90
|
+
for await (const event of log.read(runId, { fromSeq: -1 })) {
|
|
91
|
+
entries.push({
|
|
92
|
+
offset: encodeOffset(runId, event.seq),
|
|
93
|
+
chunk: event.chunk
|
|
94
|
+
});
|
|
95
|
+
if (event.seq >= lastSeq) break;
|
|
96
|
+
}
|
|
97
|
+
return entries;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
//#endregion
|
|
102
|
+
export { runLogStore, runLogStream };
|
|
103
|
+
|
|
104
|
+
//# sourceMappingURL=durability.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"durability.js","names":[],"sources":["../../src/durability.ts"],"sourcesContent":["/**\n * The portable seams over a {@link RunEventLog} — what makes the coordinator a\n * platform *binding* of core's run driver rather than a parallel architecture.\n *\n * Core's `pipeToRunLog` / `RunController` (`@tanstack/ai-sandbox`) drive a run\n * through two seams: a `RunStore` for the lifecycle record and a per-run\n * `StreamDurability` for the event log. On Cloudflare both are backed by the\n * SAME Durable Object storage — the run log's `rec:` record is core's\n * {@link RunLogRecord} and its `evt:` rows are the chunks — so this module is\n * two thin views over one log:\n *\n * - {@link runLogStore} — the log as a `RunStore`;\n * - {@link runLogStream} — one run of the log as a `StreamDurability`.\n *\n * The fusion has one consequence worth naming: the record's `status` and the\n * log's terminal state are the same field. Core's driver terminalizes through\n * `runs.update(...)` and then calls `durability.close()`; on this backend the\n * `update` already ended the log (and woke its readers — see\n * {@link RunEventLog.update}), so `close()` is normally a no-op. It still maps\n * to `finish('completed')` for the one path where it isn't: an `update` that\n * failed would otherwise leave readers parked on a log nothing will ever end.\n */\nimport type { RunStore, StreamChunk, StreamDurability } from '@tanstack/ai'\nimport type { RunEventLog } from './run-log'\n\n/**\n * Expose a {@link RunEventLog} as core's `RunStore`, for the `runs` half of\n * `RunDeps`. A pure rename layer — the invariants (idempotent `createOrResume`,\n * no-op `update` on an unknown run) are the log's own.\n */\nexport function runLogStore(log: RunEventLog): RunStore {\n return {\n // `status` is accepted but ignored: a log run always opens `'running'`,\n // which is also `createOrResume`'s documented default, and core's driver\n // never passes anything else.\n createOrResume: ({ runId, threadId, startedAt }) =>\n log.open({ runId, threadId, startedAt }),\n update: (runId, patch) => log.update(runId, patch),\n get: (runId) => log.get(runId),\n findActiveRun: async (threadId) => {\n let active = null\n for (const record of await log.list()) {\n if (record.threadId !== threadId || record.status !== 'running') {\n continue\n }\n if (active === null || record.startedAt > active.startedAt) {\n active = record\n }\n }\n return active\n },\n }\n}\n\nconst RUN_LOG_OFFSET_PREFIX = 'cfrunlog:v1:'\n\nfunction encodeOffset(runId: string, seq: number): string {\n return `${RUN_LOG_OFFSET_PREFIX}${encodeURIComponent(runId)}:${seq}`\n}\n\nfunction decodeOffset(offset: string): { runId: string; seq: number } {\n if (!offset.startsWith(RUN_LOG_OFFSET_PREFIX)) {\n throw new Error(`Invalid run-log stream offset: ${offset}`)\n }\n const encoded = offset.slice(RUN_LOG_OFFSET_PREFIX.length)\n const separator = encoded.lastIndexOf(':')\n if (separator === -1) {\n throw new Error(`Invalid run-log stream offset: ${offset}`)\n }\n const runId = decodeURIComponent(encoded.slice(0, separator))\n const seq = Number(encoded.slice(separator + 1))\n if (!Number.isSafeInteger(seq) || seq < 0) {\n throw new Error(`Invalid run-log stream offset: ${offset}`)\n }\n return { runId, seq }\n}\n\n/** Construction input for {@link runLogStream}. */\nexport interface RunLogStreamInit {\n /** The run this durability adapter attaches to. */\n runId: string\n /**\n * Resume offset captured by the consumer (`resumeFrom()` returns it).\n * Defaults to `null` (a producer / from-start reader).\n */\n offset?: string | null\n}\n\n/**\n * Expose one run of a {@link RunEventLog} as core's `StreamDurability`, for the\n * `durability` half of `RunDeps`: `(runId) => runLogStream(log, { runId })`.\n *\n * The run must already exist — core's driver guarantees it (`createOrResume`\n * runs before the first `append`), and a standalone consumer opens it first.\n * `append` and `read` on an unknown run reject, per the log's own contract;\n * `snapshot` resolves `[]`, per `StreamDurability`'s.\n *\n * Offsets encode the log's monotonic `seq` (versioned, run-scoped, opaque to\n * callers). The `'-1'` (from-start) and `'now'` (tail-only) read sentinels\n * every shipped backend honors are supported.\n */\nexport function runLogStream(\n log: RunEventLog,\n init: RunLogStreamInit,\n): StreamDurability {\n const { runId } = init\n const resumeOffset = init.offset ?? null\n\n const seqAfter = async (offset: string): Promise<number> => {\n if (offset === '-1') return -1\n if (offset === 'now') return (await log.get(runId))?.lastSeq ?? -1\n const decoded = decodeOffset(offset)\n if (decoded.runId !== runId) {\n throw new Error(\n `Run-log stream offset belongs to run ${JSON.stringify(decoded.runId)}, not ${JSON.stringify(runId)}`,\n )\n }\n return decoded.seq\n }\n\n return {\n resumeFrom: () => resumeOffset,\n append: async (chunks) => {\n const offsets: Array<string> = []\n for (const chunk of chunks) {\n offsets.push(encodeOffset(runId, await log.append(runId, chunk)))\n }\n return offsets\n },\n read: async function* (offset, signal) {\n const fromSeq = await seqAfter(offset)\n const events = log.read(runId, {\n fromSeq,\n ...(signal !== undefined ? { signal } : {}),\n })\n for await (const event of events) {\n yield { offset: encodeOffset(runId, event.seq), chunk: event.chunk }\n }\n },\n // See the module header: normally a no-op (the driver's terminal\n // `runs.update` already ended the shared record); `'completed'` lands only\n // when that update failed, where unwedging parked readers beats leaving\n // them on a log nothing will ever end.\n close: () => log.finish(runId, 'completed'),\n snapshot: async () => {\n const record = await log.get(runId)\n // Unknown run resolves to [] — the contract forbids reusing the\n // unknown-run failure path a from-start `read` join takes.\n if (record === null || record.lastSeq < 0) return []\n const lastSeq = record.lastSeq\n const entries: Array<{ offset: string; chunk: StreamChunk }> = []\n for await (const event of log.read(runId, { fromSeq: -1 })) {\n entries.push({\n offset: encodeOffset(runId, event.seq),\n chunk: event.chunk,\n })\n // Stop at the lastSeq captured BEFORE the read: `read` live-tails an\n // open log, and a snapshot must return a point-in-time view instead.\n if (event.seq >= lastSeq) break\n }\n return entries\n },\n }\n}\n"],"mappings":";;;;;;AA8BA,SAAgB,YAAY,KAA4B;CACtD,OAAO;EAIL,iBAAiB,EAAE,OAAO,UAAU,gBAClC,IAAI,KAAK;GAAE;GAAO;GAAU;EAAU,CAAC;EACzC,SAAS,OAAO,UAAU,IAAI,OAAO,OAAO,KAAK;EACjD,MAAM,UAAU,IAAI,IAAI,KAAK;EAC7B,eAAe,OAAO,aAAa;GACjC,IAAI,SAAS;GACb,KAAK,MAAM,UAAU,MAAM,IAAI,KAAK,GAAG;IACrC,IAAI,OAAO,aAAa,YAAY,OAAO,WAAW,WACpD;IAEF,IAAI,WAAW,QAAQ,OAAO,YAAY,OAAO,WAC/C,SAAS;GAEb;GACA,OAAO;EACT;CACF;AACF;AAEA,IAAM,wBAAwB;AAE9B,SAAS,aAAa,OAAe,KAAqB;CACxD,OAAO,GAAG,wBAAwB,mBAAmB,KAAK,EAAE,GAAG;AACjE;AAEA,SAAS,aAAa,QAAgD;CACpE,IAAI,CAAC,OAAO,WAAW,qBAAqB,GAC1C,MAAM,IAAI,MAAM,kCAAkC,QAAQ;CAE5D,MAAM,UAAU,OAAO,MAAM,EAA4B;CACzD,MAAM,YAAY,QAAQ,YAAY,GAAG;CACzC,IAAI,cAAc,IAChB,MAAM,IAAI,MAAM,kCAAkC,QAAQ;CAE5D,MAAM,QAAQ,mBAAmB,QAAQ,MAAM,GAAG,SAAS,CAAC;CAC5D,MAAM,MAAM,OAAO,QAAQ,MAAM,YAAY,CAAC,CAAC;CAC/C,IAAI,CAAC,OAAO,cAAc,GAAG,KAAK,MAAM,GACtC,MAAM,IAAI,MAAM,kCAAkC,QAAQ;CAE5D,OAAO;EAAE;EAAO;CAAI;AACtB;;;;;;;;;;;;;;AA0BA,SAAgB,aACd,KACA,MACkB;CAClB,MAAM,EAAE,UAAU;CAClB,MAAM,eAAe,KAAK,UAAU;CAEpC,MAAM,WAAW,OAAO,WAAoC;EAC1D,IAAI,WAAW,MAAM,OAAO;EAC5B,IAAI,WAAW,OAAO,QAAQ,MAAM,IAAI,IAAI,KAAK,EAAA,EAAI,WAAW;EAChE,MAAM,UAAU,aAAa,MAAM;EACnC,IAAI,QAAQ,UAAU,OACpB,MAAM,IAAI,MACR,wCAAwC,KAAK,UAAU,QAAQ,KAAK,EAAE,QAAQ,KAAK,UAAU,KAAK,GACpG;EAEF,OAAO,QAAQ;CACjB;CAEA,OAAO;EACL,kBAAkB;EAClB,QAAQ,OAAO,WAAW;GACxB,MAAM,UAAyB,CAAC;GAChC,KAAK,MAAM,SAAS,QAClB,QAAQ,KAAK,aAAa,OAAO,MAAM,IAAI,OAAO,OAAO,KAAK,CAAC,CAAC;GAElE,OAAO;EACT;EACA,MAAM,iBAAiB,QAAQ,QAAQ;GACrC,MAAM,UAAU,MAAM,SAAS,MAAM;GACrC,MAAM,SAAS,IAAI,KAAK,OAAO;IAC7B;IACA,GAAI,WAAW,KAAA,IAAY,EAAE,OAAO,IAAI,CAAC;GAC3C,CAAC;GACD,WAAW,MAAM,SAAS,QACxB,MAAM;IAAE,QAAQ,aAAa,OAAO,MAAM,GAAG;IAAG,OAAO,MAAM;GAAM;EAEvE;EAKA,aAAa,IAAI,OAAO,OAAO,WAAW;EAC1C,UAAU,YAAY;GACpB,MAAM,SAAS,MAAM,IAAI,IAAI,KAAK;GAGlC,IAAI,WAAW,QAAQ,OAAO,UAAU,GAAG,OAAO,CAAC;GACnD,MAAM,UAAU,OAAO;GACvB,MAAM,UAAyD,CAAC;GAChE,WAAW,MAAM,SAAS,IAAI,KAAK,OAAO,EAAE,SAAS,GAAG,CAAC,GAAG;IAC1D,QAAQ,KAAK;KACX,QAAQ,aAAa,OAAO,MAAM,GAAG;KACrC,OAAO,MAAM;IACf,CAAC;IAGD,IAAI,MAAM,OAAO,SAAS;GAC5B;GACA,OAAO;EACT;CACF;AACF"}
|