@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
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { RunError, RunRecord, RunStore, StreamChunk, TerminalRunStatus } from '@tanstack/ai';
|
|
2
|
+
/**
|
|
3
|
+
* The mutable-field patch a {@link RunStore.update} accepts, reused verbatim so
|
|
4
|
+
* the log can back a `RunStore` without restating (and drifting from) the pick.
|
|
5
|
+
*/
|
|
6
|
+
export type RunRecordPatch = Parameters<RunStore['update']>[1];
|
|
7
|
+
/**
|
|
8
|
+
* Durable bookkeeping for one run in the event log: core's {@link RunRecord}
|
|
9
|
+
* plus the two fields only an event log needs.
|
|
10
|
+
*/
|
|
11
|
+
export interface RunLogRecord extends RunRecord {
|
|
12
|
+
/** Seq of the last appended event, or `-1` when no events yet. */
|
|
13
|
+
lastSeq: number;
|
|
14
|
+
/**
|
|
15
|
+
* Epoch ms of the last append or status change — the activity clock a stall
|
|
16
|
+
* watchdog reads. Distinct from `finishedAt`, which is set once, at terminal.
|
|
17
|
+
*/
|
|
18
|
+
updatedAt: number;
|
|
19
|
+
}
|
|
20
|
+
/** One persisted event: a chunk plus its monotonic, gap-free sequence number. */
|
|
21
|
+
export interface RunEvent {
|
|
22
|
+
seq: number;
|
|
23
|
+
chunk: StreamChunk;
|
|
24
|
+
}
|
|
25
|
+
export interface RunEventLogReadOptions {
|
|
26
|
+
/**
|
|
27
|
+
* Exclusive cursor: only events with `seq > fromSeq` are yielded. Pass the
|
|
28
|
+
* client's last-seen `seq` to resume; omit (or `-1`) to replay from the start.
|
|
29
|
+
*/
|
|
30
|
+
fromSeq?: number;
|
|
31
|
+
/** Stop tailing when this fires (e.g. the client disconnected). */
|
|
32
|
+
signal?: AbortSignal;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Append-only, `seq`-indexed log of a run's stream, with resumable reads.
|
|
36
|
+
*
|
|
37
|
+
* Contract:
|
|
38
|
+
* - `append` assigns the next `seq` (0, 1, 2, …) and returns it.
|
|
39
|
+
* - `read` yields the backlog after `fromSeq` in order, then live-tails new
|
|
40
|
+
* events, and RETURNS once the run is terminal and the cursor has caught up.
|
|
41
|
+
* - All methods reject for an unknown `runId` except `get`, which resolves null.
|
|
42
|
+
*/
|
|
43
|
+
export interface RunEventLog {
|
|
44
|
+
/**
|
|
45
|
+
* Idempotently create (or return) the run record. An existing record is
|
|
46
|
+
* returned unchanged; `startedAt` (default `Date.now()`) applies only on
|
|
47
|
+
* first creation — matching core's `RunStore.createOrResume` invariant, which
|
|
48
|
+
* `runLogStore` maps directly onto this method.
|
|
49
|
+
*/
|
|
50
|
+
open: (input: {
|
|
51
|
+
runId: string;
|
|
52
|
+
threadId: string;
|
|
53
|
+
startedAt?: number;
|
|
54
|
+
}) => Promise<RunLogRecord>;
|
|
55
|
+
/** Append one chunk; resolves with its assigned `seq`. */
|
|
56
|
+
append: (runId: string, chunk: StreamChunk) => Promise<number>;
|
|
57
|
+
/** Move the run to a terminal status. Idempotent for the same status. */
|
|
58
|
+
finish: (runId: string, status: TerminalRunStatus, error?: RunError) => Promise<void>;
|
|
59
|
+
/**
|
|
60
|
+
* Patch the record's mutable fields ({@link RunRecordPatch}). Unknown `runId`
|
|
61
|
+
* is a NO-OP (never a throw, never a create) — core's `RunStore.update`
|
|
62
|
+
* invariant, which `runLogStore` maps onto this method.
|
|
63
|
+
*
|
|
64
|
+
* MUST wake blocked readers, exactly like `append`/`finish`: the record and
|
|
65
|
+
* the event log share one status field here, so a driver that terminalizes
|
|
66
|
+
* through its `RunStore` — core's `pipeToRunLog` writes its terminal status
|
|
67
|
+
* via `runs.update`, not `finish` — is ending the log with this call.
|
|
68
|
+
*/
|
|
69
|
+
update: (runId: string, patch: RunRecordPatch) => Promise<void>;
|
|
70
|
+
/** Current record, or null if the run is unknown. */
|
|
71
|
+
get: (runId: string) => Promise<RunLogRecord | null>;
|
|
72
|
+
/** Every run record this log holds. Backs `RunStore.findActiveRun`. */
|
|
73
|
+
list: () => Promise<Array<RunLogRecord>>;
|
|
74
|
+
/** Replay-then-tail events with `seq > fromSeq` until the run is terminal. */
|
|
75
|
+
read: (runId: string, options?: RunEventLogReadOptions) => AsyncIterable<RunEvent>;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The record layout this log persisted before converging on core's run
|
|
79
|
+
* vocabulary. Never constructed by current code — it exists so
|
|
80
|
+
* {@link migrateStoredRunRecord} can name what it reads out of old storage.
|
|
81
|
+
*/
|
|
82
|
+
interface LegacyStoredRunRecord {
|
|
83
|
+
runId: string;
|
|
84
|
+
threadId?: string;
|
|
85
|
+
status: 'running' | 'done' | 'error' | 'aborted';
|
|
86
|
+
lastSeq: number;
|
|
87
|
+
error?: RunError;
|
|
88
|
+
createdAt: number;
|
|
89
|
+
updatedAt: number;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Convert a stored record to the converged {@link RunLogRecord} layout.
|
|
93
|
+
*
|
|
94
|
+
* Total over both layouts: a converged record passes through unchanged
|
|
95
|
+
* (`migrated: false`), a legacy one is mapped as documented in the module
|
|
96
|
+
* header (`migrated: true`) so a durable backend can write the result back and
|
|
97
|
+
* pay the conversion exactly once.
|
|
98
|
+
*/
|
|
99
|
+
export declare function migrateStoredRunRecord(stored: RunLogRecord | LegacyStoredRunRecord): {
|
|
100
|
+
record: RunLogRecord;
|
|
101
|
+
migrated: boolean;
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* Single-process {@link RunEventLog}. Backs `read`'s live-tail with an internal
|
|
105
|
+
* waiter set: `append`/`finish` wake every blocked reader. Suitable for a
|
|
106
|
+
* long-running Node host, tests, and as the reference implementation a durable
|
|
107
|
+
* backend mirrors.
|
|
108
|
+
*/
|
|
109
|
+
export declare class InMemoryRunEventLog implements RunEventLog {
|
|
110
|
+
private readonly runs;
|
|
111
|
+
private now;
|
|
112
|
+
private require;
|
|
113
|
+
private wake;
|
|
114
|
+
open(input: {
|
|
115
|
+
runId: string;
|
|
116
|
+
threadId: string;
|
|
117
|
+
startedAt?: number;
|
|
118
|
+
}): Promise<RunLogRecord>;
|
|
119
|
+
append(runId: string, chunk: StreamChunk): Promise<number>;
|
|
120
|
+
finish(runId: string, status: TerminalRunStatus, error?: RunError): Promise<void>;
|
|
121
|
+
update(runId: string, patch: RunRecordPatch): Promise<void>;
|
|
122
|
+
get(runId: string): Promise<RunLogRecord | null>;
|
|
123
|
+
list(): Promise<Array<RunLogRecord>>;
|
|
124
|
+
read(runId: string, options?: RunEventLogReadOptions): AsyncIterable<RunEvent>;
|
|
125
|
+
private waitForChange;
|
|
126
|
+
}
|
|
127
|
+
export {};
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { isTerminalRunStatus } from "@tanstack/ai";
|
|
2
|
+
//#region src/run-log.ts
|
|
3
|
+
/**
|
|
4
|
+
* Resumable run event-log — the primitive that lets a trigger (e.g. a
|
|
5
|
+
* Cloudflare Worker) start an agent run and return immediately while a durable
|
|
6
|
+
* orchestrator (e.g. a Durable Object) drives the run and persists every
|
|
7
|
+
* emitted {@link StreamChunk} under a monotonic `seq`.
|
|
8
|
+
*
|
|
9
|
+
* Clients tail the log from a cursor (`fromSeq`), so a dropped connection, a new
|
|
10
|
+
* browser tab, or an orchestrator that hibernated between chunks all reconnect
|
|
11
|
+
* cleanly: replay everything after the client's last-seen `seq`, then live-tail
|
|
12
|
+
* until the run reaches a terminal status. The *run* never depends on any single
|
|
13
|
+
* connection staying open — that is what makes the serverless/edge model work.
|
|
14
|
+
*
|
|
15
|
+
* This module is transport- and storage-agnostic. {@link InMemoryRunEventLog} is
|
|
16
|
+
* the default (single-process / tests); a durable backend (DO storage, KV, SQL)
|
|
17
|
+
* implements the same {@link RunEventLog} interface — see
|
|
18
|
+
* {@link DurableObjectRunEventLog} in `./run-log-do`.
|
|
19
|
+
*
|
|
20
|
+
* ---
|
|
21
|
+
*
|
|
22
|
+
* CONVERGED VOCABULARY (v1) + LIVE-DATA MIGRATION:
|
|
23
|
+
*
|
|
24
|
+
* This module used to keep a legacy vocabulary distinct from core's
|
|
25
|
+
* (`TerminalRunStatus = 'done' | 'error' | 'aborted'`, a `RunRecord` with
|
|
26
|
+
* `lastSeq`/`createdAt`/`updatedAt` and an optional `threadId`), deferred
|
|
27
|
+
* because adopting core's shape meant migrating the Durable Object's persisted
|
|
28
|
+
* record layout. That migration is now done: run statuses, `RunError`, and the
|
|
29
|
+
* record's lifecycle fields come from `@tanstack/ai` (`'completed' | 'failed'
|
|
30
|
+
* | 'aborted'` terminal set, `startedAt`/`finishedAt`, required `threadId`),
|
|
31
|
+
* and {@link RunLogRecord} is core's {@link RunRecord} plus the two fields only
|
|
32
|
+
* an event log needs: the `lastSeq` cursor and the `updatedAt` activity clock.
|
|
33
|
+
*
|
|
34
|
+
* Records persisted under the legacy layout are migrated **in place, on first
|
|
35
|
+
* read**, by {@link migrateStoredRunRecord}:
|
|
36
|
+
*
|
|
37
|
+
* - `status` `'done'` → `'completed'`, `'error'` → `'failed'`
|
|
38
|
+
* (`'running'`/`'aborted'` are unchanged);
|
|
39
|
+
* - `createdAt` → `startedAt`; a terminal record gains
|
|
40
|
+
* `finishedAt = updatedAt` (the closest stored approximation);
|
|
41
|
+
* - a record persisted without `threadId` gets `threadId = runId`. The log
|
|
42
|
+
* performs no thread-scoped queries, so this self-reference can never leak
|
|
43
|
+
* into thread history; it exists only to satisfy the converged shape.
|
|
44
|
+
*
|
|
45
|
+
* `DurableObjectRunEventLog` writes the migrated record back on the read that
|
|
46
|
+
* migrated it, so each record pays the conversion exactly once. The migration
|
|
47
|
+
* is client-visible where the record is: `GET /runs/:id` and the WebSocket
|
|
48
|
+
* terminal `status` frame now carry core's status strings and field names.
|
|
49
|
+
*/
|
|
50
|
+
var LEGACY_STATUS_MAP = {
|
|
51
|
+
done: "completed",
|
|
52
|
+
error: "failed"
|
|
53
|
+
};
|
|
54
|
+
function isLegacyStoredRunRecord(value) {
|
|
55
|
+
return "createdAt" in value;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Convert a stored record to the converged {@link RunLogRecord} layout.
|
|
59
|
+
*
|
|
60
|
+
* Total over both layouts: a converged record passes through unchanged
|
|
61
|
+
* (`migrated: false`), a legacy one is mapped as documented in the module
|
|
62
|
+
* header (`migrated: true`) so a durable backend can write the result back and
|
|
63
|
+
* pay the conversion exactly once.
|
|
64
|
+
*/
|
|
65
|
+
function migrateStoredRunRecord(stored) {
|
|
66
|
+
if (!isLegacyStoredRunRecord(stored)) return {
|
|
67
|
+
record: stored,
|
|
68
|
+
migrated: false
|
|
69
|
+
};
|
|
70
|
+
const status = stored.status === "done" || stored.status === "error" ? LEGACY_STATUS_MAP[stored.status] : stored.status;
|
|
71
|
+
return {
|
|
72
|
+
record: {
|
|
73
|
+
runId: stored.runId,
|
|
74
|
+
threadId: stored.threadId ?? stored.runId,
|
|
75
|
+
status,
|
|
76
|
+
lastSeq: stored.lastSeq,
|
|
77
|
+
startedAt: stored.createdAt,
|
|
78
|
+
updatedAt: stored.updatedAt,
|
|
79
|
+
...isTerminalRunStatus(status) ? { finishedAt: stored.updatedAt } : {},
|
|
80
|
+
...stored.error !== void 0 ? { error: stored.error } : {}
|
|
81
|
+
},
|
|
82
|
+
migrated: true
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Single-process {@link RunEventLog}. Backs `read`'s live-tail with an internal
|
|
87
|
+
* waiter set: `append`/`finish` wake every blocked reader. Suitable for a
|
|
88
|
+
* long-running Node host, tests, and as the reference implementation a durable
|
|
89
|
+
* backend mirrors.
|
|
90
|
+
*/
|
|
91
|
+
var InMemoryRunEventLog = class {
|
|
92
|
+
runs = /* @__PURE__ */ new Map();
|
|
93
|
+
now() {
|
|
94
|
+
return Date.now();
|
|
95
|
+
}
|
|
96
|
+
require(runId) {
|
|
97
|
+
const state = this.runs.get(runId);
|
|
98
|
+
if (!state) throw new Error(`run-log: unknown runId "${runId}"`);
|
|
99
|
+
return state;
|
|
100
|
+
}
|
|
101
|
+
wake(state) {
|
|
102
|
+
const waiters = [...state.waiters];
|
|
103
|
+
state.waiters.clear();
|
|
104
|
+
for (const resolve of waiters) resolve();
|
|
105
|
+
}
|
|
106
|
+
open(input) {
|
|
107
|
+
const existing = this.runs.get(input.runId);
|
|
108
|
+
if (existing) return Promise.resolve({ ...existing.record });
|
|
109
|
+
const now = this.now();
|
|
110
|
+
const record = {
|
|
111
|
+
runId: input.runId,
|
|
112
|
+
threadId: input.threadId,
|
|
113
|
+
status: "running",
|
|
114
|
+
lastSeq: -1,
|
|
115
|
+
startedAt: input.startedAt ?? now,
|
|
116
|
+
updatedAt: now
|
|
117
|
+
};
|
|
118
|
+
this.runs.set(input.runId, {
|
|
119
|
+
record,
|
|
120
|
+
chunks: [],
|
|
121
|
+
waiters: /* @__PURE__ */ new Set()
|
|
122
|
+
});
|
|
123
|
+
return Promise.resolve({ ...record });
|
|
124
|
+
}
|
|
125
|
+
append(runId, chunk) {
|
|
126
|
+
const state = this.runs.get(runId);
|
|
127
|
+
if (!state) return Promise.reject(/* @__PURE__ */ new Error(`run-log: unknown runId "${runId}"`));
|
|
128
|
+
if (isTerminalRunStatus(state.record.status)) return Promise.reject(/* @__PURE__ */ new Error(`run-log: cannot append to terminal run "${runId}" (status=${state.record.status})`));
|
|
129
|
+
const seq = state.record.lastSeq + 1;
|
|
130
|
+
state.chunks.push(chunk);
|
|
131
|
+
state.record.lastSeq = seq;
|
|
132
|
+
state.record.updatedAt = this.now();
|
|
133
|
+
this.wake(state);
|
|
134
|
+
return Promise.resolve(seq);
|
|
135
|
+
}
|
|
136
|
+
finish(runId, status, error) {
|
|
137
|
+
const state = this.runs.get(runId);
|
|
138
|
+
if (!state) return Promise.reject(/* @__PURE__ */ new Error(`run-log: unknown runId "${runId}"`));
|
|
139
|
+
if (isTerminalRunStatus(state.record.status)) return Promise.resolve();
|
|
140
|
+
const now = this.now();
|
|
141
|
+
state.record.status = status;
|
|
142
|
+
if (error !== void 0) state.record.error = error;
|
|
143
|
+
state.record.finishedAt = now;
|
|
144
|
+
state.record.updatedAt = now;
|
|
145
|
+
this.wake(state);
|
|
146
|
+
return Promise.resolve();
|
|
147
|
+
}
|
|
148
|
+
update(runId, patch) {
|
|
149
|
+
const state = this.runs.get(runId);
|
|
150
|
+
if (!state) return Promise.resolve();
|
|
151
|
+
state.record = {
|
|
152
|
+
...state.record,
|
|
153
|
+
...patch,
|
|
154
|
+
updatedAt: this.now()
|
|
155
|
+
};
|
|
156
|
+
this.wake(state);
|
|
157
|
+
return Promise.resolve();
|
|
158
|
+
}
|
|
159
|
+
get(runId) {
|
|
160
|
+
const state = this.runs.get(runId);
|
|
161
|
+
return Promise.resolve(state ? { ...state.record } : null);
|
|
162
|
+
}
|
|
163
|
+
list() {
|
|
164
|
+
return Promise.resolve([...this.runs.values()].map((state) => ({ ...state.record })));
|
|
165
|
+
}
|
|
166
|
+
async *read(runId, options) {
|
|
167
|
+
const state = this.require(runId);
|
|
168
|
+
const signal = options?.signal;
|
|
169
|
+
let cursor = options?.fromSeq ?? -1;
|
|
170
|
+
while (!signal?.aborted) {
|
|
171
|
+
while (cursor < state.record.lastSeq) {
|
|
172
|
+
cursor += 1;
|
|
173
|
+
const chunk = state.chunks[cursor];
|
|
174
|
+
if (chunk !== void 0) yield {
|
|
175
|
+
seq: cursor,
|
|
176
|
+
chunk
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
if (isTerminalRunStatus(state.record.status)) return;
|
|
180
|
+
await this.waitForChange(state, signal);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
waitForChange(state, signal) {
|
|
184
|
+
return new Promise((resolve) => {
|
|
185
|
+
const wake = () => {
|
|
186
|
+
state.waiters.delete(wake);
|
|
187
|
+
if (signal) signal.removeEventListener("abort", wake);
|
|
188
|
+
resolve();
|
|
189
|
+
};
|
|
190
|
+
state.waiters.add(wake);
|
|
191
|
+
if (signal) signal.addEventListener("abort", wake, { once: true });
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
//#endregion
|
|
196
|
+
export { InMemoryRunEventLog, migrateStoredRunRecord };
|
|
197
|
+
|
|
198
|
+
//# sourceMappingURL=run-log.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"run-log.js","names":[],"sources":["../../src/run-log.ts"],"sourcesContent":["/**\n * Resumable run event-log — the primitive that lets a trigger (e.g. a\n * Cloudflare Worker) start an agent run and return immediately while a durable\n * orchestrator (e.g. a Durable Object) drives the run and persists every\n * emitted {@link StreamChunk} under a monotonic `seq`.\n *\n * Clients tail the log from a cursor (`fromSeq`), so a dropped connection, a new\n * browser tab, or an orchestrator that hibernated between chunks all reconnect\n * cleanly: replay everything after the client's last-seen `seq`, then live-tail\n * until the run reaches a terminal status. The *run* never depends on any single\n * connection staying open — that is what makes the serverless/edge model work.\n *\n * This module is transport- and storage-agnostic. {@link InMemoryRunEventLog} is\n * the default (single-process / tests); a durable backend (DO storage, KV, SQL)\n * implements the same {@link RunEventLog} interface — see\n * {@link DurableObjectRunEventLog} in `./run-log-do`.\n *\n * ---\n *\n * CONVERGED VOCABULARY (v1) + LIVE-DATA MIGRATION:\n *\n * This module used to keep a legacy vocabulary distinct from core's\n * (`TerminalRunStatus = 'done' | 'error' | 'aborted'`, a `RunRecord` with\n * `lastSeq`/`createdAt`/`updatedAt` and an optional `threadId`), deferred\n * because adopting core's shape meant migrating the Durable Object's persisted\n * record layout. That migration is now done: run statuses, `RunError`, and the\n * record's lifecycle fields come from `@tanstack/ai` (`'completed' | 'failed'\n * | 'aborted'` terminal set, `startedAt`/`finishedAt`, required `threadId`),\n * and {@link RunLogRecord} is core's {@link RunRecord} plus the two fields only\n * an event log needs: the `lastSeq` cursor and the `updatedAt` activity clock.\n *\n * Records persisted under the legacy layout are migrated **in place, on first\n * read**, by {@link migrateStoredRunRecord}:\n *\n * - `status` `'done'` → `'completed'`, `'error'` → `'failed'`\n * (`'running'`/`'aborted'` are unchanged);\n * - `createdAt` → `startedAt`; a terminal record gains\n * `finishedAt = updatedAt` (the closest stored approximation);\n * - a record persisted without `threadId` gets `threadId = runId`. The log\n * performs no thread-scoped queries, so this self-reference can never leak\n * into thread history; it exists only to satisfy the converged shape.\n *\n * `DurableObjectRunEventLog` writes the migrated record back on the read that\n * migrated it, so each record pays the conversion exactly once. The migration\n * is client-visible where the record is: `GET /runs/:id` and the WebSocket\n * terminal `status` frame now carry core's status strings and field names.\n */\nimport { isTerminalRunStatus } from '@tanstack/ai'\nimport type {\n RunError,\n RunRecord,\n RunStore,\n StreamChunk,\n TerminalRunStatus,\n} from '@tanstack/ai'\n\n/**\n * The mutable-field patch a {@link RunStore.update} accepts, reused verbatim so\n * the log can back a `RunStore` without restating (and drifting from) the pick.\n */\nexport type RunRecordPatch = Parameters<RunStore['update']>[1]\n\n/**\n * Durable bookkeeping for one run in the event log: core's {@link RunRecord}\n * plus the two fields only an event log needs.\n */\nexport interface RunLogRecord extends RunRecord {\n /** Seq of the last appended event, or `-1` when no events yet. */\n lastSeq: number\n /**\n * Epoch ms of the last append or status change — the activity clock a stall\n * watchdog reads. Distinct from `finishedAt`, which is set once, at terminal.\n */\n updatedAt: number\n}\n\n/** One persisted event: a chunk plus its monotonic, gap-free sequence number. */\nexport interface RunEvent {\n seq: number\n chunk: StreamChunk\n}\n\nexport interface RunEventLogReadOptions {\n /**\n * Exclusive cursor: only events with `seq > fromSeq` are yielded. Pass the\n * client's last-seen `seq` to resume; omit (or `-1`) to replay from the start.\n */\n fromSeq?: number\n /** Stop tailing when this fires (e.g. the client disconnected). */\n signal?: AbortSignal\n}\n\n/**\n * Append-only, `seq`-indexed log of a run's stream, with resumable reads.\n *\n * Contract:\n * - `append` assigns the next `seq` (0, 1, 2, …) and returns it.\n * - `read` yields the backlog after `fromSeq` in order, then live-tails new\n * events, and RETURNS once the run is terminal and the cursor has caught up.\n * - All methods reject for an unknown `runId` except `get`, which resolves null.\n */\nexport interface RunEventLog {\n /**\n * Idempotently create (or return) the run record. An existing record is\n * returned unchanged; `startedAt` (default `Date.now()`) applies only on\n * first creation — matching core's `RunStore.createOrResume` invariant, which\n * `runLogStore` maps directly onto this method.\n */\n open: (input: {\n runId: string\n threadId: string\n startedAt?: number\n }) => Promise<RunLogRecord>\n /** Append one chunk; resolves with its assigned `seq`. */\n append: (runId: string, chunk: StreamChunk) => Promise<number>\n /** Move the run to a terminal status. Idempotent for the same status. */\n finish: (\n runId: string,\n status: TerminalRunStatus,\n error?: RunError,\n ) => Promise<void>\n /**\n * Patch the record's mutable fields ({@link RunRecordPatch}). Unknown `runId`\n * is a NO-OP (never a throw, never a create) — core's `RunStore.update`\n * invariant, which `runLogStore` maps onto this method.\n *\n * MUST wake blocked readers, exactly like `append`/`finish`: the record and\n * the event log share one status field here, so a driver that terminalizes\n * through its `RunStore` — core's `pipeToRunLog` writes its terminal status\n * via `runs.update`, not `finish` — is ending the log with this call.\n */\n update: (runId: string, patch: RunRecordPatch) => Promise<void>\n /** Current record, or null if the run is unknown. */\n get: (runId: string) => Promise<RunLogRecord | null>\n /** Every run record this log holds. Backs `RunStore.findActiveRun`. */\n list: () => Promise<Array<RunLogRecord>>\n /** Replay-then-tail events with `seq > fromSeq` until the run is terminal. */\n read: (\n runId: string,\n options?: RunEventLogReadOptions,\n ) => AsyncIterable<RunEvent>\n}\n\n/**\n * The record layout this log persisted before converging on core's run\n * vocabulary. Never constructed by current code — it exists so\n * {@link migrateStoredRunRecord} can name what it reads out of old storage.\n */\ninterface LegacyStoredRunRecord {\n runId: string\n threadId?: string\n status: 'running' | 'done' | 'error' | 'aborted'\n lastSeq: number\n error?: RunError\n createdAt: number\n updatedAt: number\n}\n\nconst LEGACY_STATUS_MAP = {\n done: 'completed',\n error: 'failed',\n} as const\n\nfunction isLegacyStoredRunRecord(\n value: RunLogRecord | LegacyStoredRunRecord,\n): value is LegacyStoredRunRecord {\n // `createdAt` is the discriminant: it exists on every legacy record and on no\n // converged one. Status alone would miss legacy `running`/`aborted` records.\n return 'createdAt' in value\n}\n\n/**\n * Convert a stored record to the converged {@link RunLogRecord} layout.\n *\n * Total over both layouts: a converged record passes through unchanged\n * (`migrated: false`), a legacy one is mapped as documented in the module\n * header (`migrated: true`) so a durable backend can write the result back and\n * pay the conversion exactly once.\n */\nexport function migrateStoredRunRecord(\n stored: RunLogRecord | LegacyStoredRunRecord,\n): { record: RunLogRecord; migrated: boolean } {\n if (!isLegacyStoredRunRecord(stored))\n return { record: stored, migrated: false }\n const status =\n stored.status === 'done' || stored.status === 'error'\n ? LEGACY_STATUS_MAP[stored.status]\n : stored.status\n const record: RunLogRecord = {\n runId: stored.runId,\n // See the module header: the log runs no thread-scoped queries, so a\n // legacy record without a thread gets a self-reference, never a fake one.\n threadId: stored.threadId ?? stored.runId,\n status,\n lastSeq: stored.lastSeq,\n startedAt: stored.createdAt,\n updatedAt: stored.updatedAt,\n ...(isTerminalRunStatus(status) ? { finishedAt: stored.updatedAt } : {}),\n ...(stored.error !== undefined ? { error: stored.error } : {}),\n }\n return { record, migrated: true }\n}\n\n/** Per-run state for the in-memory log. */\ninterface RunState {\n record: RunLogRecord\n chunks: Array<StreamChunk>\n /** Resolved (and cleared) whenever an event is appended or status changes. */\n waiters: Set<() => void>\n}\n\n/**\n * Single-process {@link RunEventLog}. Backs `read`'s live-tail with an internal\n * waiter set: `append`/`finish` wake every blocked reader. Suitable for a\n * long-running Node host, tests, and as the reference implementation a durable\n * backend mirrors.\n */\nexport class InMemoryRunEventLog implements RunEventLog {\n private readonly runs = new Map<string, RunState>()\n\n private now(): number {\n return Date.now()\n }\n\n private require(runId: string): RunState {\n const state = this.runs.get(runId)\n if (!state) throw new Error(`run-log: unknown runId \"${runId}\"`)\n return state\n }\n\n private wake(state: RunState): void {\n const waiters = [...state.waiters]\n state.waiters.clear()\n for (const resolve of waiters) resolve()\n }\n\n // Mutators return a Promise without `async` so contract violations REJECT\n // (rather than throwing synchronously from a Promise-typed method — a\n // `.catch()` footgun) without an `await`-less async body.\n open(input: {\n runId: string\n threadId: string\n startedAt?: number\n }): Promise<RunLogRecord> {\n const existing = this.runs.get(input.runId)\n if (existing) return Promise.resolve({ ...existing.record })\n const now = this.now()\n const record: RunLogRecord = {\n runId: input.runId,\n threadId: input.threadId,\n status: 'running',\n lastSeq: -1,\n startedAt: input.startedAt ?? now,\n updatedAt: now,\n }\n this.runs.set(input.runId, { record, chunks: [], waiters: new Set() })\n return Promise.resolve({ ...record })\n }\n\n append(runId: string, chunk: StreamChunk): Promise<number> {\n const state = this.runs.get(runId)\n if (!state) {\n return Promise.reject(new Error(`run-log: unknown runId \"${runId}\"`))\n }\n if (isTerminalRunStatus(state.record.status)) {\n return Promise.reject(\n new Error(\n `run-log: cannot append to terminal run \"${runId}\" (status=${state.record.status})`,\n ),\n )\n }\n // Derive seq from the record's cursor (not `chunks.length`) so the gap-free\n // invariant holds the same way the durable backend computes it, even if the\n // backlog is ever trimmed/compacted.\n const seq = state.record.lastSeq + 1\n state.chunks.push(chunk)\n state.record.lastSeq = seq\n state.record.updatedAt = this.now()\n this.wake(state)\n return Promise.resolve(seq)\n }\n\n finish(\n runId: string,\n status: TerminalRunStatus,\n error?: RunError,\n ): Promise<void> {\n const state = this.runs.get(runId)\n if (!state) {\n return Promise.reject(new Error(`run-log: unknown runId \"${runId}\"`))\n }\n if (isTerminalRunStatus(state.record.status)) return Promise.resolve()\n const now = this.now()\n state.record.status = status\n if (error !== undefined) state.record.error = error\n state.record.finishedAt = now\n state.record.updatedAt = now\n this.wake(state)\n return Promise.resolve()\n }\n\n update(runId: string, patch: RunRecordPatch): Promise<void> {\n const state = this.runs.get(runId)\n if (!state) return Promise.resolve() // unknown runId is a no-op\n state.record = { ...state.record, ...patch, updatedAt: this.now() }\n // A patch may terminalize the shared status field (core's driver writes its\n // terminal status through `RunStore.update`) — parked readers must see it.\n this.wake(state)\n return Promise.resolve()\n }\n\n get(runId: string): Promise<RunLogRecord | null> {\n const state = this.runs.get(runId)\n return Promise.resolve(state ? { ...state.record } : null)\n }\n\n list(): Promise<Array<RunLogRecord>> {\n return Promise.resolve(\n [...this.runs.values()].map((state) => ({ ...state.record })),\n )\n }\n\n async *read(\n runId: string,\n options?: RunEventLogReadOptions,\n ): AsyncIterable<RunEvent> {\n const state = this.require(runId)\n const signal = options?.signal\n let cursor = options?.fromSeq ?? -1\n while (!signal?.aborted) {\n while (cursor < state.record.lastSeq) {\n cursor += 1\n const chunk = state.chunks[cursor]\n if (chunk !== undefined) yield { seq: cursor, chunk }\n }\n if (isTerminalRunStatus(state.record.status)) return\n await this.waitForChange(state, signal)\n }\n }\n\n private waitForChange(state: RunState, signal?: AbortSignal): Promise<void> {\n return new Promise<void>((resolve) => {\n const wake = (): void => {\n state.waiters.delete(wake)\n if (signal) signal.removeEventListener('abort', wake)\n resolve()\n }\n state.waiters.add(wake)\n if (signal) signal.addEventListener('abort', wake, { once: true })\n })\n }\n}\n"],"mappings":"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"}
|
package/dist/esm/runner.js
CHANGED
|
@@ -1,107 +1,158 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { parseContainerRunRequest } from "./protocol.js";
|
|
2
|
+
import { createSecrets, defineSandbox, defineWorkspace, httpRemoteToolExecutor, remoteToolStubs, withSandbox } from "@tanstack/ai-sandbox";
|
|
2
3
|
import { EventType, chat } from "@tanstack/ai";
|
|
3
|
-
import {
|
|
4
|
+
import { createServer } from "node:http";
|
|
4
5
|
import { localProcessSandbox } from "@tanstack/ai-sandbox-local-process";
|
|
5
|
-
|
|
6
|
+
//#region src/runner.ts
|
|
7
|
+
/**
|
|
8
|
+
* `runInContainerHarness` — the IN-CONTAINER harness runner, shipped from the
|
|
9
|
+
* package so a co-located app's container program is a single function call.
|
|
10
|
+
*
|
|
11
|
+
* This is the heart of the CO-LOCATED model: the agent harness loop AND its MCP
|
|
12
|
+
* tool-bridge run HERE, on the container's own localhost. The Durable Object
|
|
13
|
+
* outside never calls `chat()`; it POSTs `/run` to this server and reads the
|
|
14
|
+
* NDJSON stream back.
|
|
15
|
+
*
|
|
16
|
+
* DO ── POST /run {messages, harness, model, workspace, toolDescriptors,
|
|
17
|
+
* toolExecUrl, toolExecToken} ──▶ THIS
|
|
18
|
+
* THIS ── NDJSON stream of StreamChunk ──────────────────────────────────▶ DO
|
|
19
|
+
*
|
|
20
|
+
* It is a tiny `node:http` server (NODE/container side — NOT Workers; it uses
|
|
21
|
+
* `localProcessSandbox`). On `POST /run` it validates the {@link
|
|
22
|
+
* ContainerRunRequest}, builds `chat()` with the in-container `local-process`
|
|
23
|
+
* sandbox and the adapter the CALLER resolves, and streams each {@link
|
|
24
|
+
* StreamChunk} back as NDJSON (one JSON object per line).
|
|
25
|
+
*
|
|
26
|
+
* Why the MCP bridge is genuinely in-container: the in-container sandbox is
|
|
27
|
+
* `localProcessSandbox()` — the container IS the host — so the harness adapter
|
|
28
|
+
* serves its tool-bridge over the container's own `localhost` and feeds the
|
|
29
|
+
* prompt over NATIVE writable stdin (no file-redirect; the bridge URL/token
|
|
30
|
+
* never leave the container). The MCP protocol never crosses the network.
|
|
31
|
+
*
|
|
32
|
+
* The ONE thing that still crosses back to the DO is host-tool EXECUTION: each
|
|
33
|
+
* tool rebuilt by {@link remoteToolStubs} delegates its `execute()` to {@link
|
|
34
|
+
* httpRemoteToolExecutor}, which POSTs `{ name, args }` (bearer-gated) to the
|
|
35
|
+
* DO's `toolExecUrl`:
|
|
36
|
+
*
|
|
37
|
+
* agent → in-container MCP bridge → stub.execute → httpRemoteToolExecutor → DO
|
|
38
|
+
*
|
|
39
|
+
* The app supplies only `resolveAdapter` — which `*Text` adapter to build for a
|
|
40
|
+
* given `{ harness, model }`. The server + `chat()` wiring lives here, so the
|
|
41
|
+
* package doesn't depend on every adapter package.
|
|
42
|
+
*
|
|
43
|
+
* NOTE: container-side Node code — compiles against the real TanStack AI types;
|
|
44
|
+
* not runtime-verified in this repo (no container build in CI).
|
|
45
|
+
*/
|
|
46
|
+
/** Read a request body fully into a string (small JSON payloads only). */
|
|
6
47
|
function readBody(req) {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
let body = "";
|
|
50
|
+
req.setEncoding("utf8");
|
|
51
|
+
req.on("data", (chunk) => {
|
|
52
|
+
body += chunk;
|
|
53
|
+
});
|
|
54
|
+
req.on("end", () => resolve(body));
|
|
55
|
+
req.on("error", reject);
|
|
56
|
+
});
|
|
16
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Rebuild the request's workspace with a real `createSecrets`, pulling each
|
|
60
|
+
* referenced secret's VALUE from the container env. Secret values never cross
|
|
61
|
+
* the `POST /run` boundary (`createSecrets` stores them under a non-enumerable
|
|
62
|
+
* symbol, so serializing the workspace carries only the names) — the DO injects
|
|
63
|
+
* them into the container env via `sandbox.setEnvVars`, and we reconstitute them
|
|
64
|
+
* here. A referenced secret missing from the env is a hard error, never a silent
|
|
65
|
+
* keyless run.
|
|
66
|
+
*/
|
|
17
67
|
function reconstituteWorkspace(workspace) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
return defineWorkspace({ ...workspace, secrets: createSecrets(values) });
|
|
68
|
+
if (workspace.secrets === void 0) return workspace;
|
|
69
|
+
const names = Object.keys(workspace.secrets);
|
|
70
|
+
if (names.length === 0) return workspace;
|
|
71
|
+
const values = {};
|
|
72
|
+
for (const name of names) {
|
|
73
|
+
const value = process.env[name];
|
|
74
|
+
if (value === void 0 || value === "") throw new Error(`runInContainerHarness: secret "${name}" is not set in the container env`);
|
|
75
|
+
values[name] = value;
|
|
76
|
+
}
|
|
77
|
+
return defineWorkspace({
|
|
78
|
+
...workspace,
|
|
79
|
+
secrets: createSecrets(values)
|
|
80
|
+
});
|
|
32
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Build the `chat()` stream that runs the harness on THIS container via the
|
|
84
|
+
* `local-process` sandbox. The agent's `chat()` tools are stubs that delegate
|
|
85
|
+
* back to the DO; everything else (the harness loop, the MCP bridge, stdin)
|
|
86
|
+
* stays on localhost.
|
|
87
|
+
*/
|
|
33
88
|
function runAgent(request, resolveAdapter) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
// Rebuild the DO's host tools as stubs whose execute() POSTs back to the DO.
|
|
51
|
-
// The adapter bridges them over the in-container localhost MCP transport.
|
|
52
|
-
tools: remoteToolStubs(
|
|
53
|
-
request.toolDescriptors,
|
|
54
|
-
httpRemoteToolExecutor(request.toolExecUrl, request.toolExecToken)
|
|
55
|
-
),
|
|
56
|
-
// Provide the in-container local-process sandbox handle the adapter needs.
|
|
57
|
-
middleware: [withSandbox(sandbox)]
|
|
58
|
-
});
|
|
89
|
+
const sandbox = defineSandbox({
|
|
90
|
+
id: "colocated-in-container",
|
|
91
|
+
provider: localProcessSandbox(),
|
|
92
|
+
workspace: reconstituteWorkspace(request.workspace)
|
|
93
|
+
});
|
|
94
|
+
return chat({
|
|
95
|
+
threadId: request.threadId,
|
|
96
|
+
adapter: resolveAdapter({
|
|
97
|
+
harness: request.harness,
|
|
98
|
+
model: request.model
|
|
99
|
+
}),
|
|
100
|
+
messages: request.messages,
|
|
101
|
+
stream: true,
|
|
102
|
+
tools: remoteToolStubs(request.toolDescriptors, httpRemoteToolExecutor(request.toolExecUrl, request.toolExecToken)),
|
|
103
|
+
middleware: [withSandbox(sandbox)]
|
|
104
|
+
});
|
|
59
105
|
}
|
|
106
|
+
/** Stream the agent's chunks to the response as NDJSON, one object per line. */
|
|
60
107
|
async function handleRun(req, res, resolveAdapter) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
res.end();
|
|
78
|
-
}
|
|
108
|
+
const request = parseContainerRunRequest(JSON.parse(await readBody(req)));
|
|
109
|
+
res.writeHead(200, {
|
|
110
|
+
"content-type": "application/x-ndjson",
|
|
111
|
+
"cache-control": "no-cache"
|
|
112
|
+
});
|
|
113
|
+
try {
|
|
114
|
+
for await (const chunk of runAgent(request, resolveAdapter)) res.write(`${JSON.stringify(chunk)}\n`);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
117
|
+
res.write(`${JSON.stringify({
|
|
118
|
+
type: EventType.RUN_ERROR,
|
|
119
|
+
message
|
|
120
|
+
})}\n`);
|
|
121
|
+
} finally {
|
|
122
|
+
res.end();
|
|
123
|
+
}
|
|
79
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* Start the in-container harness runner: a `node:http` server with `GET /health`
|
|
127
|
+
* and `POST /run`. Call this as the container's program; the app supplies only
|
|
128
|
+
* `resolveAdapter`.
|
|
129
|
+
*/
|
|
80
130
|
function runInContainerHarness(options) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
131
|
+
const port = options.port ?? Number.parseInt(process.env.RUNNER_PORT ?? "8080", 10);
|
|
132
|
+
const server = createServer((req, res) => {
|
|
133
|
+
if (req.method === "POST" && req.url === "/run") {
|
|
134
|
+
handleRun(req, res, options.resolveAdapter).catch((error) => {
|
|
135
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
136
|
+
if (!res.headersSent) res.writeHead(400, { "content-type": "text/plain" });
|
|
137
|
+
res.end(message);
|
|
138
|
+
});
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (req.method === "GET" && req.url === "/health") {
|
|
142
|
+
res.writeHead(200).end("ok");
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
res.writeHead(404).end("not found");
|
|
146
|
+
});
|
|
147
|
+
server.listen(port, () => {
|
|
148
|
+
console.log(`[container-runner] listening on :${port}`);
|
|
149
|
+
});
|
|
150
|
+
return {
|
|
151
|
+
server,
|
|
152
|
+
port
|
|
153
|
+
};
|
|
103
154
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
//# sourceMappingURL=runner.js.map
|
|
155
|
+
//#endregion
|
|
156
|
+
export { runInContainerHarness };
|
|
157
|
+
|
|
158
|
+
//# sourceMappingURL=runner.js.map
|