@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.
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
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The portable seams over a {@link RunEventLog} — what makes the coordinator a
3
+ * platform *binding* of core's run driver rather than a parallel architecture.
4
+ *
5
+ * Core's `pipeToRunLog` / `RunController` (`@tanstack/ai-sandbox`) drive a run
6
+ * through two seams: a `RunStore` for the lifecycle record and a per-run
7
+ * `StreamDurability` for the event log. On Cloudflare both are backed by the
8
+ * SAME Durable Object storage — the run log's `rec:` record is core's
9
+ * {@link RunLogRecord} and its `evt:` rows are the chunks — so this module is
10
+ * two thin views over one log:
11
+ *
12
+ * - {@link runLogStore} — the log as a `RunStore`;
13
+ * - {@link runLogStream} — one run of the log as a `StreamDurability`.
14
+ *
15
+ * The fusion has one consequence worth naming: the record's `status` and the
16
+ * log's terminal state are the same field. Core's driver terminalizes through
17
+ * `runs.update(...)` and then calls `durability.close()`; on this backend the
18
+ * `update` already ended the log (and woke its readers — see
19
+ * {@link RunEventLog.update}), so `close()` is normally a no-op. It still maps
20
+ * to `finish('completed')` for the one path where it isn't: an `update` that
21
+ * failed would otherwise leave readers parked on a log nothing will ever end.
22
+ */
23
+ import type { RunStore, StreamChunk, StreamDurability } from '@tanstack/ai'
24
+ import type { RunEventLog } from './run-log'
25
+
26
+ /**
27
+ * Expose a {@link RunEventLog} as core's `RunStore`, for the `runs` half of
28
+ * `RunDeps`. A pure rename layer — the invariants (idempotent `createOrResume`,
29
+ * no-op `update` on an unknown run) are the log's own.
30
+ */
31
+ export function runLogStore(log: RunEventLog): RunStore {
32
+ return {
33
+ // `status` is accepted but ignored: a log run always opens `'running'`,
34
+ // which is also `createOrResume`'s documented default, and core's driver
35
+ // never passes anything else.
36
+ createOrResume: ({ runId, threadId, startedAt }) =>
37
+ log.open({ runId, threadId, startedAt }),
38
+ update: (runId, patch) => log.update(runId, patch),
39
+ get: (runId) => log.get(runId),
40
+ findActiveRun: async (threadId) => {
41
+ let active = null
42
+ for (const record of await log.list()) {
43
+ if (record.threadId !== threadId || record.status !== 'running') {
44
+ continue
45
+ }
46
+ if (active === null || record.startedAt > active.startedAt) {
47
+ active = record
48
+ }
49
+ }
50
+ return active
51
+ },
52
+ }
53
+ }
54
+
55
+ const RUN_LOG_OFFSET_PREFIX = 'cfrunlog:v1:'
56
+
57
+ function encodeOffset(runId: string, seq: number): string {
58
+ return `${RUN_LOG_OFFSET_PREFIX}${encodeURIComponent(runId)}:${seq}`
59
+ }
60
+
61
+ function decodeOffset(offset: string): { runId: string; seq: number } {
62
+ if (!offset.startsWith(RUN_LOG_OFFSET_PREFIX)) {
63
+ throw new Error(`Invalid run-log stream offset: ${offset}`)
64
+ }
65
+ const encoded = offset.slice(RUN_LOG_OFFSET_PREFIX.length)
66
+ const separator = encoded.lastIndexOf(':')
67
+ if (separator === -1) {
68
+ throw new Error(`Invalid run-log stream offset: ${offset}`)
69
+ }
70
+ const runId = decodeURIComponent(encoded.slice(0, separator))
71
+ const seq = Number(encoded.slice(separator + 1))
72
+ if (!Number.isSafeInteger(seq) || seq < 0) {
73
+ throw new Error(`Invalid run-log stream offset: ${offset}`)
74
+ }
75
+ return { runId, seq }
76
+ }
77
+
78
+ /** Construction input for {@link runLogStream}. */
79
+ export interface RunLogStreamInit {
80
+ /** The run this durability adapter attaches to. */
81
+ runId: string
82
+ /**
83
+ * Resume offset captured by the consumer (`resumeFrom()` returns it).
84
+ * Defaults to `null` (a producer / from-start reader).
85
+ */
86
+ offset?: string | null
87
+ }
88
+
89
+ /**
90
+ * Expose one run of a {@link RunEventLog} as core's `StreamDurability`, for the
91
+ * `durability` half of `RunDeps`: `(runId) => runLogStream(log, { runId })`.
92
+ *
93
+ * The run must already exist — core's driver guarantees it (`createOrResume`
94
+ * runs before the first `append`), and a standalone consumer opens it first.
95
+ * `append` and `read` on an unknown run reject, per the log's own contract;
96
+ * `snapshot` resolves `[]`, per `StreamDurability`'s.
97
+ *
98
+ * Offsets encode the log's monotonic `seq` (versioned, run-scoped, opaque to
99
+ * callers). The `'-1'` (from-start) and `'now'` (tail-only) read sentinels
100
+ * every shipped backend honors are supported.
101
+ */
102
+ export function runLogStream(
103
+ log: RunEventLog,
104
+ init: RunLogStreamInit,
105
+ ): StreamDurability {
106
+ const { runId } = init
107
+ const resumeOffset = init.offset ?? null
108
+
109
+ const seqAfter = async (offset: string): Promise<number> => {
110
+ if (offset === '-1') return -1
111
+ if (offset === 'now') return (await log.get(runId))?.lastSeq ?? -1
112
+ const decoded = decodeOffset(offset)
113
+ if (decoded.runId !== runId) {
114
+ throw new Error(
115
+ `Run-log stream offset belongs to run ${JSON.stringify(decoded.runId)}, not ${JSON.stringify(runId)}`,
116
+ )
117
+ }
118
+ return decoded.seq
119
+ }
120
+
121
+ return {
122
+ resumeFrom: () => resumeOffset,
123
+ append: async (chunks) => {
124
+ const offsets: Array<string> = []
125
+ for (const chunk of chunks) {
126
+ offsets.push(encodeOffset(runId, await log.append(runId, chunk)))
127
+ }
128
+ return offsets
129
+ },
130
+ read: async function* (offset, signal) {
131
+ const fromSeq = await seqAfter(offset)
132
+ const events = log.read(runId, {
133
+ fromSeq,
134
+ ...(signal !== undefined ? { signal } : {}),
135
+ })
136
+ for await (const event of events) {
137
+ yield { offset: encodeOffset(runId, event.seq), chunk: event.chunk }
138
+ }
139
+ },
140
+ // See the module header: normally a no-op (the driver's terminal
141
+ // `runs.update` already ended the shared record); `'completed'` lands only
142
+ // when that update failed, where unwedging parked readers beats leaving
143
+ // them on a log nothing will ever end.
144
+ close: () => log.finish(runId, 'completed'),
145
+ snapshot: async () => {
146
+ const record = await log.get(runId)
147
+ // Unknown run resolves to [] — the contract forbids reusing the
148
+ // unknown-run failure path a from-start `read` join takes.
149
+ if (record === null || record.lastSeq < 0) return []
150
+ const lastSeq = record.lastSeq
151
+ const entries: Array<{ offset: string; chunk: StreamChunk }> = []
152
+ for await (const event of log.read(runId, { fromSeq: -1 })) {
153
+ entries.push({
154
+ offset: encodeOffset(runId, event.seq),
155
+ chunk: event.chunk,
156
+ })
157
+ // Stop at the lastSeq captured BEFORE the read: `read` live-tails an
158
+ // open log, and a snapshot must return a point-in-time view instead.
159
+ if (event.seq >= lastSeq) break
160
+ }
161
+ return entries
162
+ },
163
+ }
164
+ }
package/src/handle.ts CHANGED
@@ -39,6 +39,11 @@ export const CLOUDFLARE_CAPS: SandboxCapabilities = {
39
39
  backgroundProcesses: true,
40
40
  // No writable host→process stdin; stdin-fed harnesses use file-redirection.
41
41
  writableStdin: false,
42
+ // `spawnProcess.kill()` is a no-op (see the comment in `spawnProcess`) and
43
+ // the caller's abort signal is never forwarded to `sandbox.exec` — on
44
+ // `exec` as well as `spawn` — so a spawned follower process (e.g. `tail -f`)
45
+ // can never be stopped by the caller here.
46
+ killableProcesses: false,
42
47
  snapshots: false,
43
48
  networkPolicy: false,
44
49
  durableFilesystem: false,
package/src/run-log-do.ts CHANGED
@@ -7,32 +7,47 @@
7
7
  * between chunks all resume cleanly: replay everything after the client's
8
8
  * `lastSeq`, then live-tail to terminal.
9
9
  *
10
- * Mirrors {@link InMemoryRunEventLog} from `@tanstack/ai-sandbox` exactly.
10
+ * Mirrors {@link InMemoryRunEventLog} exactly.
11
11
  * Storage layout (keys scoped by `runId` so one DO can host many runs):
12
- * - `rec:<runId>` → the {@link RunRecord}
12
+ * - `rec:<runId>` → the {@link RunLogRecord}
13
13
  * - `evt:<runId>:<seq8>` → the chunk for that seq (seq zero-padded to 8 digits
14
14
  * so `list({ prefix })` returns events in seq order).
15
15
  *
16
+ * LIVE-DATA MIGRATION: `rec:` values written before the run vocabulary
17
+ * converged on core's (see the module header in `./run-log`) are converted by
18
+ * {@link migrateStoredRunRecord} on first read and written back immediately, so
19
+ * each record pays the conversion exactly once and every read path — `get`,
20
+ * `append`'s precondition check, the watchdog's {@link list} — observes only
21
+ * the converged layout. Event values (`evt:`) are raw chunks and need no
22
+ * migration.
23
+ *
16
24
  * The live-tail wake-up (the in-memory waiter set) is per-INSTANCE; if the
17
25
  * instance is evicted mid-run, a reader re-reads the persisted backlog and the
18
26
  * `TAIL_POLL_MS` fallback poll keeps it progressing. No event is ever lost.
19
27
  *
20
28
  * NOTE: Workers-runtime code — compiles against `@cloudflare/workers-types`.
21
29
  */
22
- import { isTerminalRunStatus } from '@tanstack/ai-sandbox'
30
+ import { isTerminalRunStatus } from '@tanstack/ai'
31
+ import { migrateStoredRunRecord } from './run-log'
23
32
  import type {
24
- RunError,
25
33
  RunEvent,
26
34
  RunEventLog,
27
35
  RunEventLogReadOptions,
28
- RunRecord,
29
- TerminalRunStatus,
30
- } from '@tanstack/ai-sandbox'
31
- import type { StreamChunk } from '@tanstack/ai'
36
+ RunLogRecord,
37
+ RunRecordPatch,
38
+ } from './run-log'
39
+ import type { RunError, StreamChunk, TerminalRunStatus } from '@tanstack/ai'
32
40
 
33
41
  /** How long a post-eviction reader waits before re-polling storage (ms). */
34
42
  const TAIL_POLL_MS = 250
35
43
 
44
+ /**
45
+ * What a `rec:` key may hold: the converged layout, or the pre-convergence one
46
+ * `migrateStoredRunRecord` still reads. Typed as the migration function's input
47
+ * so a read is forced through it.
48
+ */
49
+ type StoredRunRecord = Parameters<typeof migrateStoredRunRecord>[0]
50
+
36
51
  const recKey = (runId: string): string => `rec:${runId}`
37
52
  const evtKey = (runId: string, seq: number): string =>
38
53
  `evt:${runId}:${String(seq).padStart(8, '0')}`
@@ -44,8 +59,21 @@ export class DurableObjectRunEventLog implements RunEventLog {
44
59
 
45
60
  constructor(private readonly storage: DurableObjectStorage) {}
46
61
 
47
- private async require(runId: string): Promise<RunRecord> {
48
- const record = await this.storage.get<RunRecord>(recKey(runId))
62
+ /**
63
+ * Read (and, when needed, migrate + write back) the record under `rec:<runId>`.
64
+ * The single storage read path — everything else goes through here so no
65
+ * caller can observe the legacy layout.
66
+ */
67
+ private async getRecord(runId: string): Promise<RunLogRecord | null> {
68
+ const stored = await this.storage.get<StoredRunRecord>(recKey(runId))
69
+ if (!stored) return null
70
+ const { record, migrated } = migrateStoredRunRecord(stored)
71
+ if (migrated) await this.storage.put(recKey(runId), record)
72
+ return record
73
+ }
74
+
75
+ private async require(runId: string): Promise<RunLogRecord> {
76
+ const record = await this.getRecord(runId)
49
77
  if (!record) throw new Error(`run-log: unknown runId "${runId}"`)
50
78
  return record
51
79
  }
@@ -59,16 +87,20 @@ export class DurableObjectRunEventLog implements RunEventLog {
59
87
  for (const resolve of pending) resolve()
60
88
  }
61
89
 
62
- async open(input: { runId: string; threadId?: string }): Promise<RunRecord> {
63
- const existing = await this.storage.get<RunRecord>(recKey(input.runId))
90
+ async open(input: {
91
+ runId: string
92
+ threadId: string
93
+ startedAt?: number
94
+ }): Promise<RunLogRecord> {
95
+ const existing = await this.getRecord(input.runId)
64
96
  if (existing) return existing
65
97
  const now = Date.now()
66
- const record: RunRecord = {
98
+ const record: RunLogRecord = {
67
99
  runId: input.runId,
68
- ...(input.threadId !== undefined ? { threadId: input.threadId } : {}),
100
+ threadId: input.threadId,
69
101
  status: 'running',
70
102
  lastSeq: -1,
71
- createdAt: now,
103
+ startedAt: input.startedAt ?? now,
72
104
  updatedAt: now,
73
105
  }
74
106
  await this.storage.put(recKey(input.runId), record)
@@ -83,7 +115,11 @@ export class DurableObjectRunEventLog implements RunEventLog {
83
115
  )
84
116
  }
85
117
  const seq = record.lastSeq + 1
86
- const next: RunRecord = { ...record, lastSeq: seq, updatedAt: Date.now() }
118
+ const next: RunLogRecord = {
119
+ ...record,
120
+ lastSeq: seq,
121
+ updatedAt: Date.now(),
122
+ }
87
123
  // One transaction so the appended event and its bumped record commit
88
124
  // together — a reader never sees a lastSeq pointing at a missing event.
89
125
  await this.storage.transaction(async (txn) => {
@@ -101,18 +137,47 @@ export class DurableObjectRunEventLog implements RunEventLog {
101
137
  ): Promise<void> {
102
138
  const record = await this.require(runId)
103
139
  if (isTerminalRunStatus(record.status)) return
104
- const next: RunRecord = {
140
+ const now = Date.now()
141
+ const next: RunLogRecord = {
105
142
  ...record,
106
143
  status,
107
144
  ...(error !== undefined ? { error } : {}),
108
- updatedAt: Date.now(),
145
+ finishedAt: now,
146
+ updatedAt: now,
109
147
  }
110
148
  await this.storage.put(recKey(runId), next)
111
149
  this.wake(runId)
112
150
  }
113
151
 
114
- async get(runId: string): Promise<RunRecord | null> {
115
- return (await this.storage.get<RunRecord>(recKey(runId))) ?? null
152
+ async update(runId: string, patch: RunRecordPatch): Promise<void> {
153
+ const record = await this.getRecord(runId)
154
+ if (!record) return // unknown runId is a no-op
155
+ const next: RunLogRecord = { ...record, ...patch, updatedAt: Date.now() }
156
+ await this.storage.put(recKey(runId), next)
157
+ // A patch may terminalize the shared status field (core's driver writes its
158
+ // terminal status through `RunStore.update`) — parked readers must see it
159
+ // now, not a TAIL_POLL_MS later.
160
+ this.wake(runId)
161
+ }
162
+
163
+ async get(runId: string): Promise<RunLogRecord | null> {
164
+ return this.getRecord(runId)
165
+ }
166
+
167
+ /**
168
+ * Every run record this log holds, migrated. The coordinator's stall
169
+ * watchdog iterates this instead of listing `rec:` keys itself, so the
170
+ * storage layout (and its migration) stays this module's private concern.
171
+ */
172
+ async list(): Promise<Array<RunLogRecord>> {
173
+ const stored = await this.storage.list<StoredRunRecord>({ prefix: 'rec:' })
174
+ const records: Array<RunLogRecord> = []
175
+ for (const [key, value] of stored) {
176
+ const { record, migrated } = migrateStoredRunRecord(value)
177
+ if (migrated) await this.storage.put(key, record)
178
+ records.push(record)
179
+ }
180
+ return records
116
181
  }
117
182
 
118
183
  async *read(