@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.
Files changed (47) hide show
  1. package/dist/esm/agent.d.ts +4 -0
  2. package/dist/esm/agent.js +9 -21
  3. package/dist/esm/chat-coordinator.js +144 -132
  4. package/dist/esm/chat-coordinator.js.map +1 -1
  5. package/dist/esm/container-coordinator.js +251 -247
  6. package/dist/esm/container-coordinator.js.map +1 -1
  7. package/dist/esm/coordinator.d.ts +3 -2
  8. package/dist/esm/coordinator.js +204 -184
  9. package/dist/esm/coordinator.js.map +1 -1
  10. package/dist/esm/durability.d.ts +32 -0
  11. package/dist/esm/durability.js +104 -0
  12. package/dist/esm/durability.js.map +1 -0
  13. package/dist/esm/factory.js +98 -59
  14. package/dist/esm/factory.js.map +1 -1
  15. package/dist/esm/handle.js +205 -203
  16. package/dist/esm/handle.js.map +1 -1
  17. package/dist/esm/index.js +2 -8
  18. package/dist/esm/preview-tool.d.ts +7 -1
  19. package/dist/esm/preview-tool.js +75 -32
  20. package/dist/esm/preview-tool.js.map +1 -1
  21. package/dist/esm/protocol.js +61 -50
  22. package/dist/esm/protocol.js.map +1 -1
  23. package/dist/esm/provider.js +43 -62
  24. package/dist/esm/provider.js.map +1 -1
  25. package/dist/esm/public-host.js +84 -39
  26. package/dist/esm/public-host.js.map +1 -1
  27. package/dist/esm/run-log-do.d.ts +19 -5
  28. package/dist/esm/run-log-do.js +196 -121
  29. package/dist/esm/run-log-do.js.map +1 -1
  30. package/dist/esm/run-log.d.ts +127 -0
  31. package/dist/esm/run-log.js +198 -0
  32. package/dist/esm/run-log.js.map +1 -0
  33. package/dist/esm/runner.js +146 -95
  34. package/dist/esm/runner.js.map +1 -1
  35. package/dist/esm/web-crypto.js +27 -16
  36. package/dist/esm/web-crypto.js.map +1 -1
  37. package/dist/esm/worker.js +84 -72
  38. package/dist/esm/worker.js.map +1 -1
  39. package/package.json +9 -9
  40. package/src/agent.ts +26 -0
  41. package/src/coordinator.ts +36 -14
  42. package/src/durability.ts +164 -0
  43. package/src/handle.ts +5 -0
  44. package/src/run-log-do.ts +85 -20
  45. package/src/run-log.ts +352 -0
  46. package/dist/esm/agent.js.map +0 -1
  47. package/dist/esm/index.js.map +0 -1
package/src/run-log.ts ADDED
@@ -0,0 +1,352 @@
1
+ /**
2
+ * Resumable run event-log — the primitive that lets a trigger (e.g. a
3
+ * Cloudflare Worker) start an agent run and return immediately while a durable
4
+ * orchestrator (e.g. a Durable Object) drives the run and persists every
5
+ * emitted {@link StreamChunk} under a monotonic `seq`.
6
+ *
7
+ * Clients tail the log from a cursor (`fromSeq`), so a dropped connection, a new
8
+ * browser tab, or an orchestrator that hibernated between chunks all reconnect
9
+ * cleanly: replay everything after the client's last-seen `seq`, then live-tail
10
+ * until the run reaches a terminal status. The *run* never depends on any single
11
+ * connection staying open — that is what makes the serverless/edge model work.
12
+ *
13
+ * This module is transport- and storage-agnostic. {@link InMemoryRunEventLog} is
14
+ * the default (single-process / tests); a durable backend (DO storage, KV, SQL)
15
+ * implements the same {@link RunEventLog} interface — see
16
+ * {@link DurableObjectRunEventLog} in `./run-log-do`.
17
+ *
18
+ * ---
19
+ *
20
+ * CONVERGED VOCABULARY (v1) + LIVE-DATA MIGRATION:
21
+ *
22
+ * This module used to keep a legacy vocabulary distinct from core's
23
+ * (`TerminalRunStatus = 'done' | 'error' | 'aborted'`, a `RunRecord` with
24
+ * `lastSeq`/`createdAt`/`updatedAt` and an optional `threadId`), deferred
25
+ * because adopting core's shape meant migrating the Durable Object's persisted
26
+ * record layout. That migration is now done: run statuses, `RunError`, and the
27
+ * record's lifecycle fields come from `@tanstack/ai` (`'completed' | 'failed'
28
+ * | 'aborted'` terminal set, `startedAt`/`finishedAt`, required `threadId`),
29
+ * and {@link RunLogRecord} is core's {@link RunRecord} plus the two fields only
30
+ * an event log needs: the `lastSeq` cursor and the `updatedAt` activity clock.
31
+ *
32
+ * Records persisted under the legacy layout are migrated **in place, on first
33
+ * read**, by {@link migrateStoredRunRecord}:
34
+ *
35
+ * - `status` `'done'` → `'completed'`, `'error'` → `'failed'`
36
+ * (`'running'`/`'aborted'` are unchanged);
37
+ * - `createdAt` → `startedAt`; a terminal record gains
38
+ * `finishedAt = updatedAt` (the closest stored approximation);
39
+ * - a record persisted without `threadId` gets `threadId = runId`. The log
40
+ * performs no thread-scoped queries, so this self-reference can never leak
41
+ * into thread history; it exists only to satisfy the converged shape.
42
+ *
43
+ * `DurableObjectRunEventLog` writes the migrated record back on the read that
44
+ * migrated it, so each record pays the conversion exactly once. The migration
45
+ * is client-visible where the record is: `GET /runs/:id` and the WebSocket
46
+ * terminal `status` frame now carry core's status strings and field names.
47
+ */
48
+ import { isTerminalRunStatus } from '@tanstack/ai'
49
+ import type {
50
+ RunError,
51
+ RunRecord,
52
+ RunStore,
53
+ StreamChunk,
54
+ TerminalRunStatus,
55
+ } from '@tanstack/ai'
56
+
57
+ /**
58
+ * The mutable-field patch a {@link RunStore.update} accepts, reused verbatim so
59
+ * the log can back a `RunStore` without restating (and drifting from) the pick.
60
+ */
61
+ export type RunRecordPatch = Parameters<RunStore['update']>[1]
62
+
63
+ /**
64
+ * Durable bookkeeping for one run in the event log: core's {@link RunRecord}
65
+ * plus the two fields only an event log needs.
66
+ */
67
+ export interface RunLogRecord extends RunRecord {
68
+ /** Seq of the last appended event, or `-1` when no events yet. */
69
+ lastSeq: number
70
+ /**
71
+ * Epoch ms of the last append or status change — the activity clock a stall
72
+ * watchdog reads. Distinct from `finishedAt`, which is set once, at terminal.
73
+ */
74
+ updatedAt: number
75
+ }
76
+
77
+ /** One persisted event: a chunk plus its monotonic, gap-free sequence number. */
78
+ export interface RunEvent {
79
+ seq: number
80
+ chunk: StreamChunk
81
+ }
82
+
83
+ export interface RunEventLogReadOptions {
84
+ /**
85
+ * Exclusive cursor: only events with `seq > fromSeq` are yielded. Pass the
86
+ * client's last-seen `seq` to resume; omit (or `-1`) to replay from the start.
87
+ */
88
+ fromSeq?: number
89
+ /** Stop tailing when this fires (e.g. the client disconnected). */
90
+ signal?: AbortSignal
91
+ }
92
+
93
+ /**
94
+ * Append-only, `seq`-indexed log of a run's stream, with resumable reads.
95
+ *
96
+ * Contract:
97
+ * - `append` assigns the next `seq` (0, 1, 2, …) and returns it.
98
+ * - `read` yields the backlog after `fromSeq` in order, then live-tails new
99
+ * events, and RETURNS once the run is terminal and the cursor has caught up.
100
+ * - All methods reject for an unknown `runId` except `get`, which resolves null.
101
+ */
102
+ export interface RunEventLog {
103
+ /**
104
+ * Idempotently create (or return) the run record. An existing record is
105
+ * returned unchanged; `startedAt` (default `Date.now()`) applies only on
106
+ * first creation — matching core's `RunStore.createOrResume` invariant, which
107
+ * `runLogStore` maps directly onto this method.
108
+ */
109
+ open: (input: {
110
+ runId: string
111
+ threadId: string
112
+ startedAt?: number
113
+ }) => Promise<RunLogRecord>
114
+ /** Append one chunk; resolves with its assigned `seq`. */
115
+ append: (runId: string, chunk: StreamChunk) => Promise<number>
116
+ /** Move the run to a terminal status. Idempotent for the same status. */
117
+ finish: (
118
+ runId: string,
119
+ status: TerminalRunStatus,
120
+ error?: RunError,
121
+ ) => Promise<void>
122
+ /**
123
+ * Patch the record's mutable fields ({@link RunRecordPatch}). Unknown `runId`
124
+ * is a NO-OP (never a throw, never a create) — core's `RunStore.update`
125
+ * invariant, which `runLogStore` maps onto this method.
126
+ *
127
+ * MUST wake blocked readers, exactly like `append`/`finish`: the record and
128
+ * the event log share one status field here, so a driver that terminalizes
129
+ * through its `RunStore` — core's `pipeToRunLog` writes its terminal status
130
+ * via `runs.update`, not `finish` — is ending the log with this call.
131
+ */
132
+ update: (runId: string, patch: RunRecordPatch) => Promise<void>
133
+ /** Current record, or null if the run is unknown. */
134
+ get: (runId: string) => Promise<RunLogRecord | null>
135
+ /** Every run record this log holds. Backs `RunStore.findActiveRun`. */
136
+ list: () => Promise<Array<RunLogRecord>>
137
+ /** Replay-then-tail events with `seq > fromSeq` until the run is terminal. */
138
+ read: (
139
+ runId: string,
140
+ options?: RunEventLogReadOptions,
141
+ ) => AsyncIterable<RunEvent>
142
+ }
143
+
144
+ /**
145
+ * The record layout this log persisted before converging on core's run
146
+ * vocabulary. Never constructed by current code — it exists so
147
+ * {@link migrateStoredRunRecord} can name what it reads out of old storage.
148
+ */
149
+ interface LegacyStoredRunRecord {
150
+ runId: string
151
+ threadId?: string
152
+ status: 'running' | 'done' | 'error' | 'aborted'
153
+ lastSeq: number
154
+ error?: RunError
155
+ createdAt: number
156
+ updatedAt: number
157
+ }
158
+
159
+ const LEGACY_STATUS_MAP = {
160
+ done: 'completed',
161
+ error: 'failed',
162
+ } as const
163
+
164
+ function isLegacyStoredRunRecord(
165
+ value: RunLogRecord | LegacyStoredRunRecord,
166
+ ): value is LegacyStoredRunRecord {
167
+ // `createdAt` is the discriminant: it exists on every legacy record and on no
168
+ // converged one. Status alone would miss legacy `running`/`aborted` records.
169
+ return 'createdAt' in value
170
+ }
171
+
172
+ /**
173
+ * Convert a stored record to the converged {@link RunLogRecord} layout.
174
+ *
175
+ * Total over both layouts: a converged record passes through unchanged
176
+ * (`migrated: false`), a legacy one is mapped as documented in the module
177
+ * header (`migrated: true`) so a durable backend can write the result back and
178
+ * pay the conversion exactly once.
179
+ */
180
+ export function migrateStoredRunRecord(
181
+ stored: RunLogRecord | LegacyStoredRunRecord,
182
+ ): { record: RunLogRecord; migrated: boolean } {
183
+ if (!isLegacyStoredRunRecord(stored))
184
+ return { record: stored, migrated: false }
185
+ const status =
186
+ stored.status === 'done' || stored.status === 'error'
187
+ ? LEGACY_STATUS_MAP[stored.status]
188
+ : stored.status
189
+ const record: RunLogRecord = {
190
+ runId: stored.runId,
191
+ // See the module header: the log runs no thread-scoped queries, so a
192
+ // legacy record without a thread gets a self-reference, never a fake one.
193
+ threadId: stored.threadId ?? stored.runId,
194
+ status,
195
+ lastSeq: stored.lastSeq,
196
+ startedAt: stored.createdAt,
197
+ updatedAt: stored.updatedAt,
198
+ ...(isTerminalRunStatus(status) ? { finishedAt: stored.updatedAt } : {}),
199
+ ...(stored.error !== undefined ? { error: stored.error } : {}),
200
+ }
201
+ return { record, migrated: true }
202
+ }
203
+
204
+ /** Per-run state for the in-memory log. */
205
+ interface RunState {
206
+ record: RunLogRecord
207
+ chunks: Array<StreamChunk>
208
+ /** Resolved (and cleared) whenever an event is appended or status changes. */
209
+ waiters: Set<() => void>
210
+ }
211
+
212
+ /**
213
+ * Single-process {@link RunEventLog}. Backs `read`'s live-tail with an internal
214
+ * waiter set: `append`/`finish` wake every blocked reader. Suitable for a
215
+ * long-running Node host, tests, and as the reference implementation a durable
216
+ * backend mirrors.
217
+ */
218
+ export class InMemoryRunEventLog implements RunEventLog {
219
+ private readonly runs = new Map<string, RunState>()
220
+
221
+ private now(): number {
222
+ return Date.now()
223
+ }
224
+
225
+ private require(runId: string): RunState {
226
+ const state = this.runs.get(runId)
227
+ if (!state) throw new Error(`run-log: unknown runId "${runId}"`)
228
+ return state
229
+ }
230
+
231
+ private wake(state: RunState): void {
232
+ const waiters = [...state.waiters]
233
+ state.waiters.clear()
234
+ for (const resolve of waiters) resolve()
235
+ }
236
+
237
+ // Mutators return a Promise without `async` so contract violations REJECT
238
+ // (rather than throwing synchronously from a Promise-typed method — a
239
+ // `.catch()` footgun) without an `await`-less async body.
240
+ open(input: {
241
+ runId: string
242
+ threadId: string
243
+ startedAt?: number
244
+ }): Promise<RunLogRecord> {
245
+ const existing = this.runs.get(input.runId)
246
+ if (existing) return Promise.resolve({ ...existing.record })
247
+ const now = this.now()
248
+ const record: RunLogRecord = {
249
+ runId: input.runId,
250
+ threadId: input.threadId,
251
+ status: 'running',
252
+ lastSeq: -1,
253
+ startedAt: input.startedAt ?? now,
254
+ updatedAt: now,
255
+ }
256
+ this.runs.set(input.runId, { record, chunks: [], waiters: new Set() })
257
+ return Promise.resolve({ ...record })
258
+ }
259
+
260
+ append(runId: string, chunk: StreamChunk): Promise<number> {
261
+ const state = this.runs.get(runId)
262
+ if (!state) {
263
+ return Promise.reject(new Error(`run-log: unknown runId "${runId}"`))
264
+ }
265
+ if (isTerminalRunStatus(state.record.status)) {
266
+ return Promise.reject(
267
+ new Error(
268
+ `run-log: cannot append to terminal run "${runId}" (status=${state.record.status})`,
269
+ ),
270
+ )
271
+ }
272
+ // Derive seq from the record's cursor (not `chunks.length`) so the gap-free
273
+ // invariant holds the same way the durable backend computes it, even if the
274
+ // backlog is ever trimmed/compacted.
275
+ const seq = state.record.lastSeq + 1
276
+ state.chunks.push(chunk)
277
+ state.record.lastSeq = seq
278
+ state.record.updatedAt = this.now()
279
+ this.wake(state)
280
+ return Promise.resolve(seq)
281
+ }
282
+
283
+ finish(
284
+ runId: string,
285
+ status: TerminalRunStatus,
286
+ error?: RunError,
287
+ ): Promise<void> {
288
+ const state = this.runs.get(runId)
289
+ if (!state) {
290
+ return Promise.reject(new Error(`run-log: unknown runId "${runId}"`))
291
+ }
292
+ if (isTerminalRunStatus(state.record.status)) return Promise.resolve()
293
+ const now = this.now()
294
+ state.record.status = status
295
+ if (error !== undefined) state.record.error = error
296
+ state.record.finishedAt = now
297
+ state.record.updatedAt = now
298
+ this.wake(state)
299
+ return Promise.resolve()
300
+ }
301
+
302
+ update(runId: string, patch: RunRecordPatch): Promise<void> {
303
+ const state = this.runs.get(runId)
304
+ if (!state) return Promise.resolve() // unknown runId is a no-op
305
+ state.record = { ...state.record, ...patch, updatedAt: this.now() }
306
+ // A patch may terminalize the shared status field (core's driver writes its
307
+ // terminal status through `RunStore.update`) — parked readers must see it.
308
+ this.wake(state)
309
+ return Promise.resolve()
310
+ }
311
+
312
+ get(runId: string): Promise<RunLogRecord | null> {
313
+ const state = this.runs.get(runId)
314
+ return Promise.resolve(state ? { ...state.record } : null)
315
+ }
316
+
317
+ list(): Promise<Array<RunLogRecord>> {
318
+ return Promise.resolve(
319
+ [...this.runs.values()].map((state) => ({ ...state.record })),
320
+ )
321
+ }
322
+
323
+ async *read(
324
+ runId: string,
325
+ options?: RunEventLogReadOptions,
326
+ ): AsyncIterable<RunEvent> {
327
+ const state = this.require(runId)
328
+ const signal = options?.signal
329
+ let cursor = options?.fromSeq ?? -1
330
+ while (!signal?.aborted) {
331
+ while (cursor < state.record.lastSeq) {
332
+ cursor += 1
333
+ const chunk = state.chunks[cursor]
334
+ if (chunk !== undefined) yield { seq: cursor, chunk }
335
+ }
336
+ if (isTerminalRunStatus(state.record.status)) return
337
+ await this.waitForChange(state, signal)
338
+ }
339
+ }
340
+
341
+ private waitForChange(state: RunState, signal?: AbortSignal): Promise<void> {
342
+ return new Promise<void>((resolve) => {
343
+ const wake = (): void => {
344
+ state.waiters.delete(wake)
345
+ if (signal) signal.removeEventListener('abort', wake)
346
+ resolve()
347
+ }
348
+ state.waiters.add(wake)
349
+ if (signal) signal.addEventListener('abort', wake, { once: true })
350
+ })
351
+ }
352
+ }
@@ -1 +0,0 @@
1
- {"version":3,"file":"agent.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;"}