@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,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}
|
|
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
|
|
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
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
} from '
|
|
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
|
-
|
|
48
|
-
|
|
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: {
|
|
63
|
-
|
|
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:
|
|
98
|
+
const record: RunLogRecord = {
|
|
67
99
|
runId: input.runId,
|
|
68
|
-
|
|
100
|
+
threadId: input.threadId,
|
|
69
101
|
status: 'running',
|
|
70
102
|
lastSeq: -1,
|
|
71
|
-
|
|
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:
|
|
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
|
|
140
|
+
const now = Date.now()
|
|
141
|
+
const next: RunLogRecord = {
|
|
105
142
|
...record,
|
|
106
143
|
status,
|
|
107
144
|
...(error !== undefined ? { error } : {}),
|
|
108
|
-
|
|
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
|
|
115
|
-
|
|
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(
|