@tanstack/ai-sandbox 0.3.4 → 0.4.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/README.md +26 -0
- package/dist/esm/checkpoint-store.d.ts +147 -0
- package/dist/esm/checkpoint-store.js +267 -0
- package/dist/esm/checkpoint-store.js.map +1 -0
- package/dist/esm/contracts.d.ts +19 -0
- package/dist/esm/index.d.ts +11 -1
- package/dist/esm/index.js +12 -7
- package/dist/esm/memory-snapshot-types.d.ts +129 -0
- package/dist/esm/memory-snapshots.d.ts +6 -0
- package/dist/esm/memory-snapshots.js +490 -0
- package/dist/esm/memory-snapshots.js.map +1 -0
- package/dist/esm/middleware.d.ts +33 -1
- package/dist/esm/middleware.js +339 -94
- package/dist/esm/middleware.js.map +1 -1
- package/dist/esm/ngrok.d.ts +1 -1
- package/dist/esm/sandbox.d.ts +16 -0
- package/dist/esm/sandbox.js +62 -9
- package/dist/esm/sandbox.js.map +1 -1
- package/dist/esm/snapshot-operations.d.ts +65 -0
- package/dist/esm/snapshot-operations.js +317 -0
- package/dist/esm/snapshot-operations.js.map +1 -0
- package/dist/esm/snapshot-tools.d.ts +185 -0
- package/dist/esm/snapshot-tools.js +160 -0
- package/dist/esm/snapshot-tools.js.map +1 -0
- package/dist/esm/snapshots.d.ts +51 -0
- package/dist/esm/snapshots.js +350 -0
- package/dist/esm/snapshots.js.map +1 -0
- package/dist/esm/testkit/checkpoint-conformance.d.ts +2 -0
- package/dist/esm/testkit/checkpoint-conformance.js +453 -0
- package/dist/esm/testkit/checkpoint-conformance.js.map +1 -0
- package/dist/esm/testkit/checkpoint-fork-conformance.d.ts +18 -0
- package/dist/esm/testkit/checkpoint-fork-conformance.js +191 -0
- package/dist/esm/testkit/checkpoint-fork-conformance.js.map +1 -0
- package/dist/esm/testkit/conformance.d.ts +4 -0
- package/dist/esm/testkit/conformance.js +3 -1
- package/dist/esm/testkit/conformance.js.map +1 -1
- package/package.json +8 -3
- package/skills/ai-sandbox/SKILL.md +96 -8
- package/src/checkpoint-store.ts +652 -0
- package/src/contracts.ts +12 -0
- package/src/index.ts +56 -0
- package/src/memory-snapshot-types.ts +167 -0
- package/src/memory-snapshots.ts +936 -0
- package/src/middleware.ts +610 -160
- package/src/sandbox.ts +107 -6
- package/src/snapshot-operations.ts +540 -0
- package/src/snapshot-tools.ts +208 -0
- package/src/snapshots.ts +711 -0
- package/src/testkit/checkpoint-conformance.ts +472 -0
- package/src/testkit/checkpoint-fork-conformance.ts +299 -0
- package/src/testkit/conformance.ts +7 -0
|
@@ -0,0 +1,936 @@
|
|
|
1
|
+
import type { ModelMessage } from '@tanstack/ai'
|
|
2
|
+
import type {
|
|
3
|
+
MemoryArtifactRecord as ArtifactRecord,
|
|
4
|
+
MemoryBlobBody as BlobBody,
|
|
5
|
+
MemoryBlobRecord as BlobRecord,
|
|
6
|
+
MemoryGenerationRunRecord as GenerationRunRecord,
|
|
7
|
+
MemoryInterruptRecord as InterruptRecord,
|
|
8
|
+
MemoryRunRecord as RunRecord,
|
|
9
|
+
MemorySnapshotPersistence,
|
|
10
|
+
} from './memory-snapshot-types'
|
|
11
|
+
import {
|
|
12
|
+
SandboxCheckpointConflictError,
|
|
13
|
+
SandboxCheckpointDuplicateIdError,
|
|
14
|
+
SandboxCheckpointError,
|
|
15
|
+
SandboxCheckpointInvalidEntryError,
|
|
16
|
+
SandboxCheckpointInvalidIdError,
|
|
17
|
+
SandboxCheckpointNotHeadError,
|
|
18
|
+
SandboxCheckpointParentMismatchError,
|
|
19
|
+
SandboxCheckpointWriterConflictError,
|
|
20
|
+
SandboxCheckpointWriterLostError,
|
|
21
|
+
} from './checkpoint-store'
|
|
22
|
+
import type {
|
|
23
|
+
ForkCapableSandboxCheckpointStore,
|
|
24
|
+
SandboxCheckpoint,
|
|
25
|
+
SandboxCheckpointWriter,
|
|
26
|
+
SandboxCheckpointWriterLease,
|
|
27
|
+
SandboxCheckpointForkInput,
|
|
28
|
+
} from './checkpoint-store'
|
|
29
|
+
import { createSandboxSnapshots } from './snapshot-operations'
|
|
30
|
+
import type {
|
|
31
|
+
CreateSandboxSnapshotsInput,
|
|
32
|
+
SandboxSnapshots,
|
|
33
|
+
} from './snapshot-operations'
|
|
34
|
+
|
|
35
|
+
type BlobGetOptions = { range?: { offset: number; length?: number } }
|
|
36
|
+
|
|
37
|
+
function resolveBlobRange(
|
|
38
|
+
size: number,
|
|
39
|
+
range: { offset: number; length?: number },
|
|
40
|
+
): { offset: number; length: number } {
|
|
41
|
+
if (
|
|
42
|
+
!Number.isInteger(range.offset) ||
|
|
43
|
+
range.offset < 0 ||
|
|
44
|
+
range.offset >= size
|
|
45
|
+
) {
|
|
46
|
+
throw new RangeError(
|
|
47
|
+
`Blob range offset ${range.offset} is outside the object (size ${size}).`,
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
const remaining = size - range.offset
|
|
51
|
+
if (range.length === undefined) {
|
|
52
|
+
return { offset: range.offset, length: remaining }
|
|
53
|
+
}
|
|
54
|
+
if (!Number.isInteger(range.length) || range.length < 0) {
|
|
55
|
+
throw new RangeError(`Blob range length ${range.length} is not valid.`)
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
offset: range.offset,
|
|
59
|
+
length: Math.min(range.length, remaining),
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type MemorySandboxSnapshots = SandboxSnapshots<
|
|
64
|
+
MemorySnapshotPersistence,
|
|
65
|
+
ForkCapableSandboxCheckpointStore
|
|
66
|
+
>
|
|
67
|
+
|
|
68
|
+
export type MemorySandboxSnapshotsOptions = Omit<
|
|
69
|
+
CreateSandboxSnapshotsInput<
|
|
70
|
+
MemorySnapshotPersistence,
|
|
71
|
+
ForkCapableSandboxCheckpointStore
|
|
72
|
+
>,
|
|
73
|
+
'persistence' | 'checkpoints'
|
|
74
|
+
>
|
|
75
|
+
|
|
76
|
+
const encoder = new TextEncoder()
|
|
77
|
+
const compare = (a: string, b: string) => {
|
|
78
|
+
const left = encoder.encode(a)
|
|
79
|
+
const right = encoder.encode(b)
|
|
80
|
+
for (let i = 0; i < Math.min(left.length, right.length); i++) {
|
|
81
|
+
const leftByte = left[i]
|
|
82
|
+
const rightByte = right[i]
|
|
83
|
+
if (leftByte !== rightByte) return (leftByte ?? 0) - (rightByte ?? 0)
|
|
84
|
+
}
|
|
85
|
+
return left.length - right.length
|
|
86
|
+
}
|
|
87
|
+
const clone = <T>(value: T): T => structuredClone(value)
|
|
88
|
+
|
|
89
|
+
interface MemoryCheckpointState {
|
|
90
|
+
checkpoints: Map<string, SandboxCheckpoint>
|
|
91
|
+
heads: Map<string, string>
|
|
92
|
+
writers: Map<string, { ownerToken: string; fence: number; expiresAt: number }>
|
|
93
|
+
fences: Map<string, number>
|
|
94
|
+
references: Map<string, number>
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface MemorySnapshotState extends MemoryCheckpointState {
|
|
98
|
+
messages: Map<string, Array<ModelMessage>>
|
|
99
|
+
runs: Map<string, RunRecord>
|
|
100
|
+
generations: Map<string, GenerationRunRecord>
|
|
101
|
+
interrupts: Map<string, InterruptRecord>
|
|
102
|
+
metadata: Map<string, Map<string, unknown>>
|
|
103
|
+
artifacts: Map<string, ArtifactRecord>
|
|
104
|
+
blobs: Map<string, { record: BlobRecord; bytes: Uint8Array }>
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function hasUnpairedSurrogate(value: string): boolean {
|
|
108
|
+
for (let index = 0; index < value.length; index++) {
|
|
109
|
+
const code = value.charCodeAt(index)
|
|
110
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
111
|
+
const next = value.charCodeAt(index + 1)
|
|
112
|
+
if (Number.isNaN(next) || next < 0xdc00 || next > 0xdfff) return true
|
|
113
|
+
index++
|
|
114
|
+
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
|
115
|
+
return true
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return false
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function assertValidIdentifier(
|
|
122
|
+
value: unknown,
|
|
123
|
+
label: string,
|
|
124
|
+
): asserts value is string {
|
|
125
|
+
if (
|
|
126
|
+
typeof value !== 'string' ||
|
|
127
|
+
value.length === 0 ||
|
|
128
|
+
hasUnpairedSurrogate(value)
|
|
129
|
+
) {
|
|
130
|
+
throw new SandboxCheckpointInvalidIdError(
|
|
131
|
+
`${label} must be a non-empty well-formed Unicode string`,
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function hasOwn(value: object, key: string): boolean {
|
|
137
|
+
return Object.prototype.hasOwnProperty.call(value, key)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function validateEntries(checkpoint: SandboxCheckpoint): void {
|
|
141
|
+
if (!Array.isArray(checkpoint.files)) {
|
|
142
|
+
throw new SandboxCheckpointInvalidEntryError(
|
|
143
|
+
'Checkpoint files must be an array',
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
const paths = new Set<string>()
|
|
147
|
+
const kinds = new Map<string, 'file' | 'dir'>()
|
|
148
|
+
for (const entry of checkpoint.files as ReadonlyArray<unknown>) {
|
|
149
|
+
if (entry === null || typeof entry !== 'object') {
|
|
150
|
+
throw new SandboxCheckpointInvalidEntryError(
|
|
151
|
+
'Checkpoint entry must be an object',
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
const candidate = entry as Record<string, unknown>
|
|
155
|
+
if (
|
|
156
|
+
typeof candidate.path !== 'string' ||
|
|
157
|
+
candidate.path.length === 0 ||
|
|
158
|
+
candidate.path.includes('\0') ||
|
|
159
|
+
candidate.path.startsWith('/') ||
|
|
160
|
+
candidate.path.startsWith('\\') ||
|
|
161
|
+
/^[A-Za-z]:([\\/]|$)/.test(candidate.path) ||
|
|
162
|
+
candidate.path.includes('\\') ||
|
|
163
|
+
candidate.path
|
|
164
|
+
.split('/')
|
|
165
|
+
.some((part) => part.length === 0 || part === '.' || part === '..')
|
|
166
|
+
) {
|
|
167
|
+
throw new SandboxCheckpointInvalidEntryError(
|
|
168
|
+
'Checkpoint entry path must be a normalized workspace-relative path',
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
const path = candidate.path
|
|
172
|
+
if (paths.has(path)) {
|
|
173
|
+
throw new SandboxCheckpointInvalidEntryError(
|
|
174
|
+
`Checkpoint contains duplicate entry path '${path}'`,
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
for (
|
|
178
|
+
let separator = path.indexOf('/');
|
|
179
|
+
separator !== -1;
|
|
180
|
+
separator = path.indexOf('/', separator + 1)
|
|
181
|
+
) {
|
|
182
|
+
const ancestor = path.slice(0, separator)
|
|
183
|
+
if (kinds.get(ancestor) === 'file') {
|
|
184
|
+
throw new SandboxCheckpointInvalidEntryError(
|
|
185
|
+
`Checkpoint entry '${path}' is beneath file '${ancestor}'`,
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (
|
|
190
|
+
candidate.kind === 'file' &&
|
|
191
|
+
Array.from(kinds.keys()).some((other) => other.startsWith(`${path}/`))
|
|
192
|
+
) {
|
|
193
|
+
throw new SandboxCheckpointInvalidEntryError(
|
|
194
|
+
`Checkpoint file '${path}' is an ancestor of another entry`,
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
paths.add(path)
|
|
198
|
+
if (candidate.kind === 'file') {
|
|
199
|
+
if (
|
|
200
|
+
typeof candidate.blobKey !== 'string' ||
|
|
201
|
+
candidate.blobKey.length === 0 ||
|
|
202
|
+
hasUnpairedSurrogate(candidate.blobKey) ||
|
|
203
|
+
!/^sandbox-files\/sha256\/[0-9a-f]{64}$/.test(candidate.blobKey)
|
|
204
|
+
) {
|
|
205
|
+
throw new SandboxCheckpointInvalidEntryError(
|
|
206
|
+
'File entries require a valid content-addressed blobKey',
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
if (
|
|
210
|
+
!hasOwn(candidate, 'size') ||
|
|
211
|
+
typeof candidate.size !== 'number' ||
|
|
212
|
+
!Number.isSafeInteger(candidate.size) ||
|
|
213
|
+
candidate.size < 0
|
|
214
|
+
) {
|
|
215
|
+
throw new SandboxCheckpointInvalidEntryError(
|
|
216
|
+
'File entry size must be a non-negative safe integer',
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
} else if (candidate.kind === 'dir') {
|
|
220
|
+
if (hasOwn(candidate, 'blobKey') || hasOwn(candidate, 'size')) {
|
|
221
|
+
throw new SandboxCheckpointInvalidEntryError(
|
|
222
|
+
'Directory entries cannot contain file fields',
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
} else {
|
|
226
|
+
throw new SandboxCheckpointInvalidEntryError(
|
|
227
|
+
'Checkpoint entry kind must be file or dir',
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
kinds.set(path, candidate.kind)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function validateArtifacts(checkpoint: SandboxCheckpoint): void {
|
|
235
|
+
if (!Array.isArray(checkpoint.artifacts)) {
|
|
236
|
+
throw new SandboxCheckpointInvalidEntryError(
|
|
237
|
+
'Checkpoint artifacts must be an array',
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
for (const artifact of checkpoint.artifacts as ReadonlyArray<unknown>) {
|
|
241
|
+
if (artifact === null || typeof artifact !== 'object') {
|
|
242
|
+
throw new SandboxCheckpointInvalidEntryError(
|
|
243
|
+
'Checkpoint artifact must be an object',
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
const candidate = artifact as Record<string, unknown>
|
|
247
|
+
if (
|
|
248
|
+
typeof candidate.artifactId !== 'string' ||
|
|
249
|
+
candidate.artifactId.length === 0 ||
|
|
250
|
+
hasUnpairedSurrogate(candidate.artifactId) ||
|
|
251
|
+
typeof candidate.name !== 'string' ||
|
|
252
|
+
candidate.name.length === 0 ||
|
|
253
|
+
typeof candidate.mimeType !== 'string' ||
|
|
254
|
+
candidate.mimeType.length === 0 ||
|
|
255
|
+
typeof candidate.blobKey !== 'string' ||
|
|
256
|
+
candidate.blobKey.length === 0 ||
|
|
257
|
+
hasUnpairedSurrogate(candidate.blobKey) ||
|
|
258
|
+
!/^sandbox-artifacts\/sha256\/[0-9a-f]{64}$/.test(candidate.blobKey) ||
|
|
259
|
+
typeof candidate.size !== 'number' ||
|
|
260
|
+
!Number.isSafeInteger(candidate.size) ||
|
|
261
|
+
candidate.size < 0 ||
|
|
262
|
+
typeof candidate.createdAt !== 'number' ||
|
|
263
|
+
!Number.isFinite(candidate.createdAt)
|
|
264
|
+
) {
|
|
265
|
+
throw new SandboxCheckpointInvalidEntryError(
|
|
266
|
+
'Checkpoint artifact has invalid fields',
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function validateCheckpoint(checkpoint: SandboxCheckpoint): void {
|
|
273
|
+
assertValidIdentifier(checkpoint.id, 'Checkpoint id')
|
|
274
|
+
assertValidIdentifier(checkpoint.threadId, 'Checkpoint thread id')
|
|
275
|
+
if (checkpoint.parentCheckpointId !== null) {
|
|
276
|
+
assertValidIdentifier(checkpoint.parentCheckpointId, 'Parent checkpoint id')
|
|
277
|
+
}
|
|
278
|
+
if (!Number.isFinite(checkpoint.createdAt)) {
|
|
279
|
+
throw new SandboxCheckpointInvalidEntryError(
|
|
280
|
+
'Checkpoint createdAt must be a finite number',
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
validateEntries(checkpoint)
|
|
284
|
+
validateArtifacts(checkpoint)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function blobKeys(checkpoint: SandboxCheckpoint): Set<string> {
|
|
288
|
+
const keys = new Set<string>()
|
|
289
|
+
for (const entry of checkpoint.files) {
|
|
290
|
+
if (entry.kind === 'file') keys.add(entry.blobKey)
|
|
291
|
+
}
|
|
292
|
+
for (const artifact of checkpoint.artifacts) keys.add(artifact.blobKey)
|
|
293
|
+
return keys
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
class MemorySnapshotCheckpointStore implements ForkCapableSandboxCheckpointStore {
|
|
297
|
+
private readonly now = () => Date.now()
|
|
298
|
+
private readonly leaseDurationMs = 120_000
|
|
299
|
+
private readonly renewAfterMs = 45_000
|
|
300
|
+
|
|
301
|
+
constructor(private readonly state: MemorySnapshotState) {}
|
|
302
|
+
|
|
303
|
+
async get(id: string): Promise<SandboxCheckpoint | null> {
|
|
304
|
+
assertValidIdentifier(id, 'Checkpoint id')
|
|
305
|
+
const checkpoint = this.state.checkpoints.get(id)
|
|
306
|
+
return checkpoint ? clone(checkpoint) : null
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async list(threadId: string): Promise<Array<SandboxCheckpoint>> {
|
|
310
|
+
assertValidIdentifier(threadId, 'Thread id')
|
|
311
|
+
return [...this.state.checkpoints.values()]
|
|
312
|
+
.filter((checkpoint) => checkpoint.threadId === threadId)
|
|
313
|
+
.sort((a, b) => a.createdAt - b.createdAt || compare(a.id, b.id))
|
|
314
|
+
.map(clone)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async getHead(threadId: string): Promise<string | null> {
|
|
318
|
+
assertValidIdentifier(threadId, 'Thread id')
|
|
319
|
+
return this.state.heads.get(threadId) ?? null
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async append(input: {
|
|
323
|
+
checkpoint: SandboxCheckpoint
|
|
324
|
+
expectedHeadId: string | null
|
|
325
|
+
writer: SandboxCheckpointWriter
|
|
326
|
+
}): Promise<{ headId: string }> {
|
|
327
|
+
const checkpoint = clone(input.checkpoint)
|
|
328
|
+
const { expectedHeadId, writer } = input
|
|
329
|
+
assertValidIdentifier(checkpoint.id, 'Checkpoint id')
|
|
330
|
+
assertValidIdentifier(checkpoint.threadId, 'Checkpoint thread id')
|
|
331
|
+
assertValidIdentifier(writer.threadId, 'Writer thread id')
|
|
332
|
+
if (expectedHeadId !== null) {
|
|
333
|
+
assertValidIdentifier(expectedHeadId, 'Expected head id')
|
|
334
|
+
}
|
|
335
|
+
if (checkpoint.parentCheckpointId != null) {
|
|
336
|
+
assertValidIdentifier(
|
|
337
|
+
checkpoint.parentCheckpointId,
|
|
338
|
+
'Parent checkpoint id',
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
if (writer.threadId !== checkpoint.threadId) {
|
|
342
|
+
throw new SandboxCheckpointWriterLostError(
|
|
343
|
+
'Checkpoint writer thread does not match checkpoint thread',
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
validateCheckpoint(checkpoint)
|
|
347
|
+
|
|
348
|
+
// Re-check live state after staging because cloning caller data may
|
|
349
|
+
// re-enter append and advance this thread's head.
|
|
350
|
+
this.assertWriter(writer, checkpoint.threadId)
|
|
351
|
+
if (this.state.checkpoints.has(checkpoint.id)) {
|
|
352
|
+
throw new SandboxCheckpointDuplicateIdError(
|
|
353
|
+
`Checkpoint '${checkpoint.id}' already exists`,
|
|
354
|
+
)
|
|
355
|
+
}
|
|
356
|
+
const actualHeadId = this.state.heads.get(checkpoint.threadId) ?? null
|
|
357
|
+
if (actualHeadId !== expectedHeadId) {
|
|
358
|
+
throw new SandboxCheckpointConflictError(
|
|
359
|
+
`Expected head '${expectedHeadId}', but thread '${checkpoint.threadId}' is at '${actualHeadId}'`,
|
|
360
|
+
)
|
|
361
|
+
}
|
|
362
|
+
const parentCheckpointId = checkpoint.parentCheckpointId ?? null
|
|
363
|
+
if (parentCheckpointId !== expectedHeadId) {
|
|
364
|
+
throw new SandboxCheckpointParentMismatchError(
|
|
365
|
+
`Checkpoint '${checkpoint.id}' parent does not match expected head`,
|
|
366
|
+
)
|
|
367
|
+
}
|
|
368
|
+
const stored = { ...checkpoint, parentCheckpointId }
|
|
369
|
+
const keys = blobKeys(stored)
|
|
370
|
+
this.state.checkpoints.set(stored.id, stored)
|
|
371
|
+
this.state.heads.set(stored.threadId, stored.id)
|
|
372
|
+
for (const key of keys) {
|
|
373
|
+
this.state.references.set(key, (this.state.references.get(key) ?? 0) + 1)
|
|
374
|
+
}
|
|
375
|
+
return { headId: stored.id }
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async deleteHead(input: {
|
|
379
|
+
threadId: string
|
|
380
|
+
checkpointId: string
|
|
381
|
+
writer: SandboxCheckpointWriter
|
|
382
|
+
}): Promise<void> {
|
|
383
|
+
const { threadId, checkpointId, writer } = input
|
|
384
|
+
assertValidIdentifier(threadId, 'Thread id')
|
|
385
|
+
assertValidIdentifier(checkpointId, 'Checkpoint id')
|
|
386
|
+
assertValidIdentifier(writer.threadId, 'Writer thread id')
|
|
387
|
+
if (writer.threadId !== threadId) {
|
|
388
|
+
throw new SandboxCheckpointWriterLostError(
|
|
389
|
+
'Checkpoint writer thread does not match operation thread',
|
|
390
|
+
)
|
|
391
|
+
}
|
|
392
|
+
this.assertWriter(writer, threadId)
|
|
393
|
+
if ((this.state.heads.get(threadId) ?? null) !== checkpointId) {
|
|
394
|
+
throw new SandboxCheckpointNotHeadError(
|
|
395
|
+
`Checkpoint '${checkpointId}' is not the current head of thread '${threadId}'`,
|
|
396
|
+
)
|
|
397
|
+
}
|
|
398
|
+
const checkpoint = this.state.checkpoints.get(checkpointId)
|
|
399
|
+
if (!checkpoint) {
|
|
400
|
+
throw new SandboxCheckpointNotHeadError(
|
|
401
|
+
`Checkpoint '${checkpointId}' does not exist`,
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
this.state.checkpoints.delete(checkpointId)
|
|
405
|
+
if (checkpoint.parentCheckpointId) {
|
|
406
|
+
this.state.heads.set(threadId, checkpoint.parentCheckpointId)
|
|
407
|
+
} else {
|
|
408
|
+
this.state.heads.delete(threadId)
|
|
409
|
+
}
|
|
410
|
+
for (const key of blobKeys(checkpoint)) {
|
|
411
|
+
const references = (this.state.references.get(key) ?? 0) - 1
|
|
412
|
+
if (references > 0) this.state.references.set(key, references)
|
|
413
|
+
else this.state.references.delete(key)
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async acquireWriter(threadId: string): Promise<SandboxCheckpointWriterLease> {
|
|
418
|
+
assertValidIdentifier(threadId, 'Thread id')
|
|
419
|
+
const current = this.state.writers.get(threadId)
|
|
420
|
+
if (current && current.expiresAt > this.now()) {
|
|
421
|
+
throw new SandboxCheckpointWriterConflictError(
|
|
422
|
+
`Thread '${threadId}' already has an active checkpoint writer`,
|
|
423
|
+
)
|
|
424
|
+
}
|
|
425
|
+
const fence = (this.state.fences.get(threadId) ?? 0) + 1
|
|
426
|
+
this.state.fences.set(threadId, fence)
|
|
427
|
+
const ownerToken = globalThis.crypto.randomUUID()
|
|
428
|
+
const lease = {
|
|
429
|
+
threadId,
|
|
430
|
+
ownerToken,
|
|
431
|
+
fence,
|
|
432
|
+
expiresAt: this.now() + this.leaseDurationMs,
|
|
433
|
+
}
|
|
434
|
+
this.state.writers.set(threadId, lease)
|
|
435
|
+
return {
|
|
436
|
+
...lease,
|
|
437
|
+
get expiresAt() {
|
|
438
|
+
return lease.expiresAt
|
|
439
|
+
},
|
|
440
|
+
renewAfterMs: this.renewAfterMs,
|
|
441
|
+
renew: async () => {
|
|
442
|
+
this.assertWriter(lease, threadId)
|
|
443
|
+
lease.expiresAt = this.now() + this.leaseDurationMs
|
|
444
|
+
return { expiresAt: lease.expiresAt }
|
|
445
|
+
},
|
|
446
|
+
release: async () => {
|
|
447
|
+
const currentLease = this.state.writers.get(threadId)
|
|
448
|
+
if (
|
|
449
|
+
currentLease?.ownerToken === ownerToken &&
|
|
450
|
+
currentLease.fence === fence
|
|
451
|
+
) {
|
|
452
|
+
this.state.writers.delete(threadId)
|
|
453
|
+
}
|
|
454
|
+
},
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async listBlobReferences(): Promise<
|
|
459
|
+
Array<{ key: string; references: number }>
|
|
460
|
+
> {
|
|
461
|
+
return [...this.state.references.entries()]
|
|
462
|
+
.sort(([a], [b]) => compare(a, b))
|
|
463
|
+
.map(([key, references]) => ({ key, references }))
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async forkFromCheckpoint(
|
|
467
|
+
input: SandboxCheckpointForkInput,
|
|
468
|
+
): Promise<{ checkpoint: SandboxCheckpoint }> {
|
|
469
|
+
const sourceThreadId = input.sourceThreadId
|
|
470
|
+
const sourceCheckpointId = input.sourceCheckpointId
|
|
471
|
+
const destinationThreadId = input.destinationThreadId
|
|
472
|
+
const destinationCheckpointId = input.destinationCheckpointId
|
|
473
|
+
const createdAt = input.createdAt
|
|
474
|
+
const suppliedWriter = input.writer
|
|
475
|
+
const writer: SandboxCheckpointWriter = {
|
|
476
|
+
threadId: suppliedWriter.threadId,
|
|
477
|
+
ownerToken: suppliedWriter.ownerToken,
|
|
478
|
+
fence: suppliedWriter.fence,
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
assertValidIdentifier(sourceThreadId, 'Source thread id')
|
|
482
|
+
assertValidIdentifier(sourceCheckpointId, 'Source checkpoint id')
|
|
483
|
+
assertValidIdentifier(destinationThreadId, 'Destination thread id')
|
|
484
|
+
assertValidIdentifier(destinationCheckpointId, 'Destination checkpoint id')
|
|
485
|
+
assertValidIdentifier(writer.threadId, 'Writer thread id')
|
|
486
|
+
if (!Number.isFinite(createdAt)) {
|
|
487
|
+
throw new SandboxCheckpointInvalidEntryError(
|
|
488
|
+
'Fork checkpoint createdAt must be a finite number',
|
|
489
|
+
)
|
|
490
|
+
}
|
|
491
|
+
if (sourceThreadId === destinationThreadId) {
|
|
492
|
+
throw new SandboxCheckpointError(
|
|
493
|
+
'SANDBOX_SNAPSHOT_FORK_SOURCE_THREAD_MISMATCH',
|
|
494
|
+
'Source and destination threads must differ',
|
|
495
|
+
)
|
|
496
|
+
}
|
|
497
|
+
const source = this.state.checkpoints.get(sourceCheckpointId)
|
|
498
|
+
if (!source) {
|
|
499
|
+
throw new SandboxCheckpointError(
|
|
500
|
+
'SANDBOX_SNAPSHOT_FORK_SOURCE_NOT_FOUND',
|
|
501
|
+
'Source checkpoint was not found',
|
|
502
|
+
)
|
|
503
|
+
}
|
|
504
|
+
if (source.threadId !== sourceThreadId) {
|
|
505
|
+
throw new SandboxCheckpointError(
|
|
506
|
+
'SANDBOX_SNAPSHOT_FORK_SOURCE_THREAD_MISMATCH',
|
|
507
|
+
'Source checkpoint belongs to another thread',
|
|
508
|
+
)
|
|
509
|
+
}
|
|
510
|
+
if (writer.threadId !== destinationThreadId) {
|
|
511
|
+
throw new SandboxCheckpointWriterLostError(
|
|
512
|
+
'Checkpoint writer thread does not match destination thread',
|
|
513
|
+
)
|
|
514
|
+
}
|
|
515
|
+
this.assertWriter(writer, destinationThreadId)
|
|
516
|
+
this.assertDestinationEmpty(destinationThreadId, destinationCheckpointId)
|
|
517
|
+
|
|
518
|
+
const stagedCheckpoint: SandboxCheckpoint = clone({
|
|
519
|
+
id: destinationCheckpointId,
|
|
520
|
+
threadId: destinationThreadId,
|
|
521
|
+
parentCheckpointId: null,
|
|
522
|
+
createdAt,
|
|
523
|
+
reason: 'fork-root',
|
|
524
|
+
files: source.files,
|
|
525
|
+
conversation: source.conversation,
|
|
526
|
+
artifacts: source.artifacts,
|
|
527
|
+
})
|
|
528
|
+
validateCheckpoint(stagedCheckpoint)
|
|
529
|
+
const stagedTranscript = clone([...stagedCheckpoint.conversation])
|
|
530
|
+
const result = { checkpoint: clone(stagedCheckpoint) }
|
|
531
|
+
const stagedReferences = [...blobKeys(stagedCheckpoint)].map((key) => ({
|
|
532
|
+
key,
|
|
533
|
+
references: (this.state.references.get(key) ?? 0) + 1,
|
|
534
|
+
}))
|
|
535
|
+
|
|
536
|
+
// Cloning can invoke user-defined getters. Revalidate immediately before
|
|
537
|
+
// the synchronous publication block so a reentrant save is never lost.
|
|
538
|
+
this.assertWriter(writer, destinationThreadId)
|
|
539
|
+
this.assertDestinationEmpty(destinationThreadId, destinationCheckpointId)
|
|
540
|
+
|
|
541
|
+
this.state.messages.set(stagedCheckpoint.threadId, stagedTranscript)
|
|
542
|
+
this.state.checkpoints.set(stagedCheckpoint.id, stagedCheckpoint)
|
|
543
|
+
this.state.heads.set(stagedCheckpoint.threadId, stagedCheckpoint.id)
|
|
544
|
+
for (const reference of stagedReferences) {
|
|
545
|
+
this.state.references.set(reference.key, reference.references)
|
|
546
|
+
}
|
|
547
|
+
return result
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
private assertDestinationEmpty(
|
|
551
|
+
destinationThreadId: string,
|
|
552
|
+
destinationCheckpointId: string,
|
|
553
|
+
): void {
|
|
554
|
+
if (
|
|
555
|
+
this.state.messages.has(destinationThreadId) ||
|
|
556
|
+
[...this.state.runs.values()].some(
|
|
557
|
+
(value) => value.threadId === destinationThreadId,
|
|
558
|
+
) ||
|
|
559
|
+
[...this.state.generations.values()].some(
|
|
560
|
+
(value) => value.threadId === destinationThreadId,
|
|
561
|
+
) ||
|
|
562
|
+
[...this.state.interrupts.values()].some(
|
|
563
|
+
(value) => value.threadId === destinationThreadId,
|
|
564
|
+
) ||
|
|
565
|
+
[...this.state.artifacts.values()].some(
|
|
566
|
+
(value) => value.threadId === destinationThreadId,
|
|
567
|
+
) ||
|
|
568
|
+
[...this.state.checkpoints.values()].some(
|
|
569
|
+
(value) => value.threadId === destinationThreadId,
|
|
570
|
+
) ||
|
|
571
|
+
this.state.heads.has(destinationThreadId) ||
|
|
572
|
+
this.state.checkpoints.has(destinationCheckpointId)
|
|
573
|
+
) {
|
|
574
|
+
throw new SandboxCheckpointError(
|
|
575
|
+
'SANDBOX_SNAPSHOT_FORK_DESTINATION_NOT_EMPTY',
|
|
576
|
+
'Destination thread is not empty',
|
|
577
|
+
)
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
private assertWriter(
|
|
582
|
+
writer: SandboxCheckpointWriter,
|
|
583
|
+
threadId: string,
|
|
584
|
+
): void {
|
|
585
|
+
const current = this.state.writers.get(threadId)
|
|
586
|
+
if (
|
|
587
|
+
!current ||
|
|
588
|
+
current.ownerToken !== writer.ownerToken ||
|
|
589
|
+
current.fence !== writer.fence ||
|
|
590
|
+
current.expiresAt <= this.now()
|
|
591
|
+
) {
|
|
592
|
+
throw new SandboxCheckpointWriterLostError(
|
|
593
|
+
`Checkpoint writer lease for thread '${threadId}' is no longer current`,
|
|
594
|
+
)
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async function bodyBytes(body: BlobBody): Promise<Uint8Array> {
|
|
600
|
+
if (typeof body === 'string') return encoder.encode(body)
|
|
601
|
+
if (body instanceof ArrayBuffer) return new Uint8Array(body.slice(0))
|
|
602
|
+
if (ArrayBuffer.isView(body))
|
|
603
|
+
return new Uint8Array(
|
|
604
|
+
body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength),
|
|
605
|
+
)
|
|
606
|
+
if (typeof Blob !== 'undefined' && body instanceof Blob)
|
|
607
|
+
return new Uint8Array(await body.arrayBuffer())
|
|
608
|
+
if (typeof ReadableStream !== 'undefined' && body instanceof ReadableStream) {
|
|
609
|
+
const reader = body.getReader()
|
|
610
|
+
const parts: Array<Uint8Array> = []
|
|
611
|
+
try {
|
|
612
|
+
for (;;) {
|
|
613
|
+
const next = await reader.read()
|
|
614
|
+
if (next.done) break
|
|
615
|
+
parts.push(new Uint8Array(next.value))
|
|
616
|
+
}
|
|
617
|
+
} finally {
|
|
618
|
+
reader.releaseLock()
|
|
619
|
+
}
|
|
620
|
+
const result = new Uint8Array(
|
|
621
|
+
parts.reduce((total, part) => total + part.byteLength, 0),
|
|
622
|
+
)
|
|
623
|
+
let offset = 0
|
|
624
|
+
for (const part of parts) {
|
|
625
|
+
result.set(part, offset)
|
|
626
|
+
offset += part.byteLength
|
|
627
|
+
}
|
|
628
|
+
return result
|
|
629
|
+
}
|
|
630
|
+
throw new TypeError('Unsupported blob body.')
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
export async function memorySandboxSnapshots(
|
|
634
|
+
options: MemorySandboxSnapshotsOptions = {},
|
|
635
|
+
): Promise<MemorySandboxSnapshots> {
|
|
636
|
+
const { persistence, checkpoints } = await createMemorySandboxSnapshots()
|
|
637
|
+
return createSandboxSnapshots({
|
|
638
|
+
persistence,
|
|
639
|
+
checkpoints,
|
|
640
|
+
...options,
|
|
641
|
+
})
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async function createMemorySandboxSnapshots(): Promise<{
|
|
645
|
+
persistence: MemorySnapshotPersistence
|
|
646
|
+
checkpoints: ForkCapableSandboxCheckpointStore
|
|
647
|
+
}> {
|
|
648
|
+
const messages = new Map<string, Array<ModelMessage>>()
|
|
649
|
+
const runs = new Map<string, RunRecord>()
|
|
650
|
+
const generations = new Map<string, GenerationRunRecord>()
|
|
651
|
+
const interrupts = new Map<string, InterruptRecord>()
|
|
652
|
+
const metadata = new Map<string, Map<string, unknown>>()
|
|
653
|
+
const artifacts = new Map<string, ArtifactRecord>()
|
|
654
|
+
const blobs = new Map<string, { record: BlobRecord; bytes: Uint8Array }>()
|
|
655
|
+
const state: MemorySnapshotState = {
|
|
656
|
+
messages,
|
|
657
|
+
runs,
|
|
658
|
+
generations,
|
|
659
|
+
interrupts,
|
|
660
|
+
metadata,
|
|
661
|
+
artifacts,
|
|
662
|
+
blobs,
|
|
663
|
+
checkpoints: new Map(),
|
|
664
|
+
heads: new Map(),
|
|
665
|
+
writers: new Map(),
|
|
666
|
+
fences: new Map(),
|
|
667
|
+
references: new Map(),
|
|
668
|
+
}
|
|
669
|
+
let etag = 0
|
|
670
|
+
const persistence: MemorySnapshotPersistence = {
|
|
671
|
+
stores: {
|
|
672
|
+
messages: {
|
|
673
|
+
loadThread: async (threadId: string) =>
|
|
674
|
+
messages.get(threadId)?.slice() ?? [],
|
|
675
|
+
saveThread: async (threadId: string, value: Array<ModelMessage>) => {
|
|
676
|
+
messages.set(threadId, value.slice())
|
|
677
|
+
},
|
|
678
|
+
},
|
|
679
|
+
runs: {
|
|
680
|
+
createOrResume: async (input: {
|
|
681
|
+
runId: string
|
|
682
|
+
threadId: string
|
|
683
|
+
status?: RunRecord['status']
|
|
684
|
+
startedAt: number
|
|
685
|
+
}) => {
|
|
686
|
+
const existing = runs.get(input.runId)
|
|
687
|
+
if (existing) return existing
|
|
688
|
+
const record: RunRecord = {
|
|
689
|
+
...input,
|
|
690
|
+
status: input.status ?? 'running',
|
|
691
|
+
}
|
|
692
|
+
runs.set(record.runId, record)
|
|
693
|
+
return record
|
|
694
|
+
},
|
|
695
|
+
update: async (runId: string, patch: Partial<RunRecord>) => {
|
|
696
|
+
const value = runs.get(runId)
|
|
697
|
+
if (value) runs.set(runId, { ...value, ...patch })
|
|
698
|
+
},
|
|
699
|
+
get: async (runId: string) => runs.get(runId) ?? null,
|
|
700
|
+
findActiveRun: async (threadId: string) =>
|
|
701
|
+
[...runs.values()]
|
|
702
|
+
.filter(
|
|
703
|
+
(run) => run.threadId === threadId && run.status === 'running',
|
|
704
|
+
)
|
|
705
|
+
.sort((a, b) => b.startedAt - a.startedAt)[0] ?? null,
|
|
706
|
+
listByThread: async (threadId: string) =>
|
|
707
|
+
[...runs.values()]
|
|
708
|
+
.filter((run) => run.threadId === threadId)
|
|
709
|
+
.sort((a, b) => a.startedAt - b.startedAt),
|
|
710
|
+
listReclaimable: async (input: { now: number; ttlMs: number }) =>
|
|
711
|
+
[...runs.values()].filter(
|
|
712
|
+
(run) =>
|
|
713
|
+
run.status === 'running' &&
|
|
714
|
+
run.detachedSince !== undefined &&
|
|
715
|
+
run.detachedSince <= input.now - input.ttlMs,
|
|
716
|
+
),
|
|
717
|
+
},
|
|
718
|
+
generationRuns: {
|
|
719
|
+
createOrResume: async (
|
|
720
|
+
input: Pick<
|
|
721
|
+
GenerationRunRecord,
|
|
722
|
+
| 'runId'
|
|
723
|
+
| 'threadId'
|
|
724
|
+
| 'activity'
|
|
725
|
+
| 'provider'
|
|
726
|
+
| 'model'
|
|
727
|
+
| 'startedAt'
|
|
728
|
+
> & { status?: GenerationRunRecord['status'] },
|
|
729
|
+
) => {
|
|
730
|
+
const value = generations.get(input.runId) ?? {
|
|
731
|
+
...input,
|
|
732
|
+
status: input.status ?? 'running',
|
|
733
|
+
}
|
|
734
|
+
generations.set(input.runId, value)
|
|
735
|
+
return value
|
|
736
|
+
},
|
|
737
|
+
update: async (runId: string, patch: Partial<GenerationRunRecord>) => {
|
|
738
|
+
const value = generations.get(runId)
|
|
739
|
+
if (value) generations.set(runId, { ...value, ...patch })
|
|
740
|
+
},
|
|
741
|
+
get: async (runId: string) => generations.get(runId) ?? null,
|
|
742
|
+
findLatestForThread: async (threadId: string) =>
|
|
743
|
+
[...generations.values()]
|
|
744
|
+
.filter((run) => run.threadId === threadId)
|
|
745
|
+
.sort((a, b) => b.startedAt - a.startedAt)[0] ?? null,
|
|
746
|
+
},
|
|
747
|
+
interrupts: {
|
|
748
|
+
create: async (
|
|
749
|
+
record: Omit<InterruptRecord, 'status' | 'resolvedAt'>,
|
|
750
|
+
) => {
|
|
751
|
+
if (!interrupts.has(record.interruptId))
|
|
752
|
+
interrupts.set(record.interruptId, { ...record, status: 'pending' })
|
|
753
|
+
},
|
|
754
|
+
resolve: async (id: string, response?: unknown) => {
|
|
755
|
+
const value = interrupts.get(id)
|
|
756
|
+
if (value)
|
|
757
|
+
interrupts.set(id, {
|
|
758
|
+
...value,
|
|
759
|
+
status: 'resolved',
|
|
760
|
+
resolvedAt: Date.now(),
|
|
761
|
+
response,
|
|
762
|
+
})
|
|
763
|
+
},
|
|
764
|
+
cancel: async (id: string) => {
|
|
765
|
+
const value = interrupts.get(id)
|
|
766
|
+
if (value)
|
|
767
|
+
interrupts.set(id, {
|
|
768
|
+
...value,
|
|
769
|
+
status: 'cancelled',
|
|
770
|
+
resolvedAt: Date.now(),
|
|
771
|
+
})
|
|
772
|
+
},
|
|
773
|
+
get: async (id: string) => interrupts.get(id) ?? null,
|
|
774
|
+
list: async (threadId: string) =>
|
|
775
|
+
[...interrupts.values()]
|
|
776
|
+
.filter((value) => value.threadId === threadId)
|
|
777
|
+
.sort((a, b) => a.requestedAt - b.requestedAt),
|
|
778
|
+
listPending: async (threadId: string) =>
|
|
779
|
+
[...interrupts.values()]
|
|
780
|
+
.filter(
|
|
781
|
+
(value) =>
|
|
782
|
+
value.threadId === threadId && value.status === 'pending',
|
|
783
|
+
)
|
|
784
|
+
.sort((a, b) => a.requestedAt - b.requestedAt),
|
|
785
|
+
listByRun: async (runId: string) =>
|
|
786
|
+
[...interrupts.values()]
|
|
787
|
+
.filter((value) => value.runId === runId)
|
|
788
|
+
.sort((a, b) => a.requestedAt - b.requestedAt),
|
|
789
|
+
listPendingByRun: async (runId: string) =>
|
|
790
|
+
[...interrupts.values()]
|
|
791
|
+
.filter(
|
|
792
|
+
(value) => value.runId === runId && value.status === 'pending',
|
|
793
|
+
)
|
|
794
|
+
.sort((a, b) => a.requestedAt - b.requestedAt),
|
|
795
|
+
},
|
|
796
|
+
metadata: {
|
|
797
|
+
get: async (namespace: string, key: string) => {
|
|
798
|
+
const bucket = metadata.get(namespace)
|
|
799
|
+
return bucket?.has(key) ? bucket.get(key) : null
|
|
800
|
+
},
|
|
801
|
+
set: async (namespace: string, key: string, value: unknown) => {
|
|
802
|
+
let bucket = metadata.get(namespace)
|
|
803
|
+
if (!bucket) {
|
|
804
|
+
bucket = new Map()
|
|
805
|
+
metadata.set(namespace, bucket)
|
|
806
|
+
}
|
|
807
|
+
bucket.set(key, value)
|
|
808
|
+
},
|
|
809
|
+
delete: async (namespace: string, key: string) => {
|
|
810
|
+
metadata.get(namespace)?.delete(key)
|
|
811
|
+
},
|
|
812
|
+
},
|
|
813
|
+
artifacts: {
|
|
814
|
+
save: async (value: ArtifactRecord) => {
|
|
815
|
+
artifacts.set(value.artifactId, { ...value })
|
|
816
|
+
},
|
|
817
|
+
get: async (id: string) => artifacts.get(id) ?? null,
|
|
818
|
+
list: async (runId: string) =>
|
|
819
|
+
[...artifacts.values()]
|
|
820
|
+
.filter((value) => value.runId === runId)
|
|
821
|
+
.sort(
|
|
822
|
+
(a, b) =>
|
|
823
|
+
a.createdAt - b.createdAt ||
|
|
824
|
+
compare(a.artifactId, b.artifactId),
|
|
825
|
+
),
|
|
826
|
+
|
|
827
|
+
listForThread: async (threadId: string) =>
|
|
828
|
+
[...artifacts.values()]
|
|
829
|
+
.filter((value) => value.threadId === threadId)
|
|
830
|
+
.sort(
|
|
831
|
+
(a, b) =>
|
|
832
|
+
a.createdAt - b.createdAt ||
|
|
833
|
+
compare(a.artifactId, b.artifactId),
|
|
834
|
+
),
|
|
835
|
+
|
|
836
|
+
delete: async (id: string) => {
|
|
837
|
+
artifacts.delete(id)
|
|
838
|
+
},
|
|
839
|
+
deleteForRun: async (runId: string) => {
|
|
840
|
+
for (const [id, value] of artifacts)
|
|
841
|
+
if (value.runId === runId) artifacts.delete(id)
|
|
842
|
+
},
|
|
843
|
+
},
|
|
844
|
+
blobs: {
|
|
845
|
+
put: async (
|
|
846
|
+
key: string,
|
|
847
|
+
body: BlobBody,
|
|
848
|
+
putOptions?: {
|
|
849
|
+
contentType?: string
|
|
850
|
+
customMetadata?: Record<string, string>
|
|
851
|
+
},
|
|
852
|
+
) => {
|
|
853
|
+
const bytes = await bodyBytes(body)
|
|
854
|
+
const now = Date.now()
|
|
855
|
+
const record: BlobRecord = {
|
|
856
|
+
key,
|
|
857
|
+
size: bytes.byteLength,
|
|
858
|
+
etag: String(++etag),
|
|
859
|
+
contentType:
|
|
860
|
+
putOptions?.contentType ??
|
|
861
|
+
(typeof Blob !== 'undefined' && body instanceof Blob
|
|
862
|
+
? body.type || undefined
|
|
863
|
+
: undefined),
|
|
864
|
+
customMetadata: putOptions?.customMetadata
|
|
865
|
+
? { ...putOptions.customMetadata }
|
|
866
|
+
: undefined,
|
|
867
|
+
createdAt: blobs.get(key)?.record.createdAt ?? now,
|
|
868
|
+
updatedAt: now,
|
|
869
|
+
}
|
|
870
|
+
blobs.set(key, { record, bytes: new Uint8Array(bytes) })
|
|
871
|
+
return clone(record)
|
|
872
|
+
},
|
|
873
|
+
get: async (key: string, getOptions?: BlobGetOptions) => {
|
|
874
|
+
const value = blobs.get(key)
|
|
875
|
+
if (!value) return null
|
|
876
|
+
const range = getOptions?.range
|
|
877
|
+
? resolveBlobRange(value.bytes.byteLength, getOptions.range)
|
|
878
|
+
: { offset: 0, length: value.bytes.byteLength }
|
|
879
|
+
const bytes = value.bytes.slice(
|
|
880
|
+
range.offset,
|
|
881
|
+
range.offset + range.length,
|
|
882
|
+
)
|
|
883
|
+
return {
|
|
884
|
+
...clone(value.record),
|
|
885
|
+
...(getOptions?.range ? { range } : {}),
|
|
886
|
+
body: new Blob([bytes]).stream(),
|
|
887
|
+
arrayBuffer: async () =>
|
|
888
|
+
bytes.buffer.slice(
|
|
889
|
+
bytes.byteOffset,
|
|
890
|
+
bytes.byteOffset + bytes.byteLength,
|
|
891
|
+
),
|
|
892
|
+
text: async () => new TextDecoder().decode(bytes),
|
|
893
|
+
}
|
|
894
|
+
},
|
|
895
|
+
head: async (key: string) => clone(blobs.get(key)?.record ?? null),
|
|
896
|
+
delete: async (key: string) => {
|
|
897
|
+
blobs.delete(key)
|
|
898
|
+
},
|
|
899
|
+
list: async (listOptions?: {
|
|
900
|
+
prefix?: string
|
|
901
|
+
cursor?: string
|
|
902
|
+
limit?: number
|
|
903
|
+
}) => {
|
|
904
|
+
const keys = [...blobs.keys()]
|
|
905
|
+
.filter((key) => key.startsWith(listOptions?.prefix ?? ''))
|
|
906
|
+
.filter(
|
|
907
|
+
(key) =>
|
|
908
|
+
listOptions?.cursor === undefined || key > listOptions.cursor,
|
|
909
|
+
)
|
|
910
|
+
.sort()
|
|
911
|
+
if (listOptions?.limit === 0) return { objects: [], truncated: false }
|
|
912
|
+
const page =
|
|
913
|
+
listOptions?.limit === undefined
|
|
914
|
+
? keys
|
|
915
|
+
: keys.slice(0, listOptions.limit)
|
|
916
|
+
const truncated =
|
|
917
|
+
listOptions?.limit !== undefined && keys.length > page.length
|
|
918
|
+
const objects = page.map((key) => {
|
|
919
|
+
const value = blobs.get(key)
|
|
920
|
+
if (!value) throw new Error(`Missing blob for listed key: ${key}`)
|
|
921
|
+
return value.record
|
|
922
|
+
})
|
|
923
|
+
return {
|
|
924
|
+
objects: clone(objects),
|
|
925
|
+
...(truncated ? { cursor: page.at(-1), truncated: true } : {}),
|
|
926
|
+
}
|
|
927
|
+
},
|
|
928
|
+
},
|
|
929
|
+
},
|
|
930
|
+
}
|
|
931
|
+
const checkpointStore = new MemorySnapshotCheckpointStore(state)
|
|
932
|
+
return {
|
|
933
|
+
persistence,
|
|
934
|
+
checkpoints: checkpointStore,
|
|
935
|
+
}
|
|
936
|
+
}
|