@tanstack/ai-sandbox 0.3.3 → 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,652 @@
|
|
|
1
|
+
import type { ModelMessage } from '@tanstack/ai'
|
|
2
|
+
|
|
3
|
+
// Compare strings by their UTF-8 bytes so ordering does not depend on locale.
|
|
4
|
+
const utf8Encoder = new TextEncoder()
|
|
5
|
+
|
|
6
|
+
const compareUtf8Bytes = (left: string, right: string): number => {
|
|
7
|
+
const leftBytes = utf8Encoder.encode(left)
|
|
8
|
+
const rightBytes = utf8Encoder.encode(right)
|
|
9
|
+
const length = Math.min(leftBytes.length, rightBytes.length)
|
|
10
|
+
for (let index = 0; index < length; index++) {
|
|
11
|
+
const leftByte = leftBytes[index]
|
|
12
|
+
const rightByte = rightBytes[index]
|
|
13
|
+
if (leftByte !== rightByte) {
|
|
14
|
+
return (leftByte ?? 0) - (rightByte ?? 0)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return leftBytes.length - rightBytes.length
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SandboxSnapshotFileEntry {
|
|
21
|
+
path: string
|
|
22
|
+
kind: 'file'
|
|
23
|
+
blobKey: string
|
|
24
|
+
size: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SandboxSnapshotDirectoryEntry {
|
|
28
|
+
path: string
|
|
29
|
+
kind: 'dir'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type SandboxSnapshotEntry =
|
|
33
|
+
| SandboxSnapshotFileEntry
|
|
34
|
+
| SandboxSnapshotDirectoryEntry
|
|
35
|
+
|
|
36
|
+
export interface SandboxSnapshotArtifact {
|
|
37
|
+
artifactId: string
|
|
38
|
+
name: string
|
|
39
|
+
mimeType: string
|
|
40
|
+
size: number
|
|
41
|
+
blobKey: string
|
|
42
|
+
createdAt: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SandboxCheckpoint {
|
|
46
|
+
id: string
|
|
47
|
+
threadId: string
|
|
48
|
+
parentCheckpointId: string | null
|
|
49
|
+
createdAt: number
|
|
50
|
+
reason: 'automatic' | 'named' | 'fork-root'
|
|
51
|
+
label?: string
|
|
52
|
+
sourceRunId?: string
|
|
53
|
+
files: ReadonlyArray<SandboxSnapshotEntry>
|
|
54
|
+
conversation: ReadonlyArray<ModelMessage>
|
|
55
|
+
artifacts: ReadonlyArray<SandboxSnapshotArtifact>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface SandboxCheckpointStore {
|
|
59
|
+
get: (id: string) => Promise<SandboxCheckpoint | null>
|
|
60
|
+
list: (threadId: string) => Promise<Array<SandboxCheckpoint>>
|
|
61
|
+
getHead: (threadId: string) => Promise<string | null>
|
|
62
|
+
append: (input: {
|
|
63
|
+
checkpoint: SandboxCheckpoint
|
|
64
|
+
expectedHeadId: string | null
|
|
65
|
+
writer: SandboxCheckpointWriter
|
|
66
|
+
}) => Promise<{ headId: string }>
|
|
67
|
+
deleteHead: (input: {
|
|
68
|
+
threadId: string
|
|
69
|
+
checkpointId: string
|
|
70
|
+
writer: SandboxCheckpointWriter
|
|
71
|
+
}) => Promise<void>
|
|
72
|
+
acquireWriter: (threadId: string) => Promise<SandboxCheckpointWriterLease>
|
|
73
|
+
listBlobReferences: () => Promise<Array<{ key: string; references: number }>>
|
|
74
|
+
/** Optional atomic fork capability. Stores without this method cannot fork. */
|
|
75
|
+
forkFromCheckpoint?: SandboxCheckpointForkCapability['forkFromCheckpoint']
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface SandboxCheckpointForkInput {
|
|
79
|
+
sourceThreadId: string
|
|
80
|
+
sourceCheckpointId: string
|
|
81
|
+
destinationThreadId: string
|
|
82
|
+
destinationCheckpointId: string
|
|
83
|
+
createdAt: number
|
|
84
|
+
writer: SandboxCheckpointWriter
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface SandboxCheckpointForkCapability {
|
|
88
|
+
forkFromCheckpoint: (input: SandboxCheckpointForkInput) => Promise<{
|
|
89
|
+
checkpoint: SandboxCheckpoint
|
|
90
|
+
}>
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type ForkCapableSandboxCheckpointStore = SandboxCheckpointStore &
|
|
94
|
+
SandboxCheckpointForkCapability
|
|
95
|
+
|
|
96
|
+
export function isForkCapableSandboxCheckpointStore(
|
|
97
|
+
store: SandboxCheckpointStore,
|
|
98
|
+
): store is ForkCapableSandboxCheckpointStore {
|
|
99
|
+
return typeof store.forkFromCheckpoint === 'function'
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface SandboxCheckpointWriter {
|
|
103
|
+
threadId: string
|
|
104
|
+
ownerToken: string
|
|
105
|
+
fence: number
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface SandboxCheckpointWriterLease extends SandboxCheckpointWriter {
|
|
109
|
+
expiresAt: number
|
|
110
|
+
renewAfterMs: number
|
|
111
|
+
renew: () => Promise<{ expiresAt: number }>
|
|
112
|
+
release: () => Promise<void>
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface SandboxCheckpointStoreOptions {
|
|
116
|
+
now?: () => number
|
|
117
|
+
leaseDurationMs?: number
|
|
118
|
+
renewAfterMs?: number
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface InMemoryCheckpointState {
|
|
122
|
+
checkpoints: Map<string, SandboxCheckpoint>
|
|
123
|
+
heads: Map<string, string>
|
|
124
|
+
writers: Map<string, { ownerToken: string; fence: number; expiresAt: number }>
|
|
125
|
+
fences: Map<string, number>
|
|
126
|
+
references: Map<string, number>
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export type SandboxCheckpointErrorCode =
|
|
130
|
+
| 'SANDBOX_SNAPSHOT_STALE_HEAD'
|
|
131
|
+
| 'SANDBOX_SNAPSHOT_PARENT_MISMATCH'
|
|
132
|
+
| 'SANDBOX_SNAPSHOT_DUPLICATE_ID'
|
|
133
|
+
| 'SANDBOX_SNAPSHOT_NOT_HEAD'
|
|
134
|
+
| 'SANDBOX_SNAPSHOT_WRITER_CONFLICT'
|
|
135
|
+
| 'SANDBOX_SNAPSHOT_WRITER_LOST'
|
|
136
|
+
| 'SANDBOX_SNAPSHOT_INVALID_ID'
|
|
137
|
+
| 'SANDBOX_SNAPSHOT_INVALID_ENTRY'
|
|
138
|
+
| 'SANDBOX_SNAPSHOT_CHECKPOINT_NOT_FOUND'
|
|
139
|
+
| 'SANDBOX_SNAPSHOT_ATOMIC_FORK_REQUIRED'
|
|
140
|
+
| 'SANDBOX_SNAPSHOT_FORK_SOURCE_NOT_FOUND'
|
|
141
|
+
| 'SANDBOX_SNAPSHOT_FORK_SOURCE_THREAD_MISMATCH'
|
|
142
|
+
| 'SANDBOX_SNAPSHOT_FORK_DESTINATION_NOT_EMPTY'
|
|
143
|
+
|
|
144
|
+
export class SandboxCheckpointError extends Error {
|
|
145
|
+
readonly code: SandboxCheckpointErrorCode
|
|
146
|
+
|
|
147
|
+
constructor(code: SandboxCheckpointErrorCode, message: string) {
|
|
148
|
+
super(message)
|
|
149
|
+
this.name = 'SandboxCheckpointError'
|
|
150
|
+
this.code = code
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export class SandboxCheckpointConflictError extends SandboxCheckpointError {
|
|
155
|
+
constructor(message: string) {
|
|
156
|
+
super('SANDBOX_SNAPSHOT_STALE_HEAD', message)
|
|
157
|
+
this.name = 'SandboxCheckpointConflictError'
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export class SandboxCheckpointDuplicateIdError extends SandboxCheckpointError {
|
|
162
|
+
constructor(message: string) {
|
|
163
|
+
super('SANDBOX_SNAPSHOT_DUPLICATE_ID', message)
|
|
164
|
+
this.name = 'SandboxCheckpointDuplicateIdError'
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export class SandboxCheckpointInvalidIdError extends SandboxCheckpointError {
|
|
169
|
+
constructor(message: string) {
|
|
170
|
+
super('SANDBOX_SNAPSHOT_INVALID_ID', message)
|
|
171
|
+
this.name = 'SandboxCheckpointInvalidIdError'
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export class SandboxCheckpointInvalidEntryError extends SandboxCheckpointError {
|
|
176
|
+
constructor(message: string) {
|
|
177
|
+
super('SANDBOX_SNAPSHOT_INVALID_ENTRY', message)
|
|
178
|
+
this.name = 'SandboxCheckpointInvalidEntryError'
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export class SandboxCheckpointParentMismatchError extends SandboxCheckpointError {
|
|
183
|
+
constructor(message: string) {
|
|
184
|
+
super('SANDBOX_SNAPSHOT_PARENT_MISMATCH', message)
|
|
185
|
+
this.name = 'SandboxCheckpointParentMismatchError'
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export class SandboxCheckpointNotHeadError extends SandboxCheckpointError {
|
|
190
|
+
constructor(message: string) {
|
|
191
|
+
super('SANDBOX_SNAPSHOT_NOT_HEAD', message)
|
|
192
|
+
this.name = 'SandboxCheckpointNotHeadError'
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export class SandboxCheckpointWriterConflictError extends SandboxCheckpointError {
|
|
197
|
+
constructor(message: string) {
|
|
198
|
+
super('SANDBOX_SNAPSHOT_WRITER_CONFLICT', message)
|
|
199
|
+
this.name = 'SandboxCheckpointWriterConflictError'
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export class SandboxCheckpointWriterLostError extends SandboxCheckpointError {
|
|
204
|
+
constructor(message: string) {
|
|
205
|
+
super('SANDBOX_SNAPSHOT_WRITER_LOST', message)
|
|
206
|
+
this.name = 'SandboxCheckpointWriterLostError'
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function defineSandboxCheckpointStore(
|
|
211
|
+
store: SandboxCheckpointStore,
|
|
212
|
+
): SandboxCheckpointStore {
|
|
213
|
+
return store
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function copy<T>(value: T): T {
|
|
217
|
+
return structuredClone(value)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function blobKeys(checkpoint: SandboxCheckpoint): Set<string> {
|
|
221
|
+
const keys = new Set<string>()
|
|
222
|
+
for (const entry of checkpoint.files) {
|
|
223
|
+
if (entry.kind === 'file') keys.add(entry.blobKey)
|
|
224
|
+
}
|
|
225
|
+
for (const artifact of checkpoint.artifacts) keys.add(artifact.blobKey)
|
|
226
|
+
return keys
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function hasUnpairedSurrogate(value: string): boolean {
|
|
230
|
+
for (let index = 0; index < value.length; index++) {
|
|
231
|
+
const code = value.charCodeAt(index)
|
|
232
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
233
|
+
const next = value.charCodeAt(index + 1)
|
|
234
|
+
if (Number.isNaN(next) || next < 0xdc00 || next > 0xdfff) return true
|
|
235
|
+
index++
|
|
236
|
+
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
|
237
|
+
return true
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return false
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function assertValidIdentifier(
|
|
244
|
+
value: unknown,
|
|
245
|
+
label: string,
|
|
246
|
+
): asserts value is string {
|
|
247
|
+
if (
|
|
248
|
+
typeof value !== 'string' ||
|
|
249
|
+
value.length === 0 ||
|
|
250
|
+
hasUnpairedSurrogate(value)
|
|
251
|
+
) {
|
|
252
|
+
throw new SandboxCheckpointInvalidIdError(
|
|
253
|
+
`${label} must be a non-empty well-formed Unicode string`,
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function hasOwn(value: object, key: string): boolean {
|
|
259
|
+
return Object.prototype.hasOwnProperty.call(value, key)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function validateEntries(checkpoint: SandboxCheckpoint): void {
|
|
263
|
+
if (!Array.isArray(checkpoint.files)) {
|
|
264
|
+
throw new SandboxCheckpointInvalidEntryError(
|
|
265
|
+
'Checkpoint files must be an array',
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
const paths = new Set<string>()
|
|
269
|
+
const kinds = new Map<string, 'file' | 'dir'>()
|
|
270
|
+
for (const entry of checkpoint.files as ReadonlyArray<unknown>) {
|
|
271
|
+
if (entry === null || typeof entry !== 'object') {
|
|
272
|
+
throw new SandboxCheckpointInvalidEntryError(
|
|
273
|
+
'Checkpoint entry must be an object',
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
const candidate = entry as Record<string, unknown>
|
|
277
|
+
if (
|
|
278
|
+
typeof candidate.path !== 'string' ||
|
|
279
|
+
candidate.path.length === 0 ||
|
|
280
|
+
candidate.path.includes('\0') ||
|
|
281
|
+
candidate.path.startsWith('/') ||
|
|
282
|
+
candidate.path.startsWith('\\') ||
|
|
283
|
+
/^[A-Za-z]:([\\/]|$)/.test(candidate.path) ||
|
|
284
|
+
candidate.path.includes('\\') ||
|
|
285
|
+
candidate.path
|
|
286
|
+
.split('/')
|
|
287
|
+
.some((part) => part.length === 0 || part === '.' || part === '..')
|
|
288
|
+
) {
|
|
289
|
+
throw new SandboxCheckpointInvalidEntryError(
|
|
290
|
+
'Checkpoint entry path must be a normalized workspace-relative path',
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
const path = candidate.path
|
|
294
|
+
if (paths.has(path)) {
|
|
295
|
+
throw new SandboxCheckpointInvalidEntryError(
|
|
296
|
+
`Checkpoint contains duplicate entry path '${path}'`,
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
for (
|
|
300
|
+
let separator = path.indexOf('/');
|
|
301
|
+
separator !== -1;
|
|
302
|
+
separator = path.indexOf('/', separator + 1)
|
|
303
|
+
) {
|
|
304
|
+
const ancestor = path.slice(0, separator)
|
|
305
|
+
if (kinds.get(ancestor) === 'file') {
|
|
306
|
+
throw new SandboxCheckpointInvalidEntryError(
|
|
307
|
+
`Checkpoint entry '${path}' is beneath file '${ancestor}'`,
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (
|
|
312
|
+
candidate.kind === 'file' &&
|
|
313
|
+
Array.from(kinds.keys()).some((other) => other.startsWith(`${path}/`))
|
|
314
|
+
) {
|
|
315
|
+
throw new SandboxCheckpointInvalidEntryError(
|
|
316
|
+
`Checkpoint file '${path}' is an ancestor of another entry`,
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
paths.add(path)
|
|
320
|
+
if (candidate.kind === 'file') {
|
|
321
|
+
if (
|
|
322
|
+
typeof candidate.blobKey !== 'string' ||
|
|
323
|
+
candidate.blobKey.length === 0 ||
|
|
324
|
+
hasUnpairedSurrogate(candidate.blobKey) ||
|
|
325
|
+
!/^sandbox-files\/sha256\/[0-9a-f]{64}$/.test(candidate.blobKey)
|
|
326
|
+
) {
|
|
327
|
+
throw new SandboxCheckpointInvalidEntryError(
|
|
328
|
+
'File entries require a non-empty blobKey',
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
if (
|
|
332
|
+
!hasOwn(candidate, 'size') ||
|
|
333
|
+
typeof candidate.size !== 'number' ||
|
|
334
|
+
!Number.isSafeInteger(candidate.size) ||
|
|
335
|
+
candidate.size < 0
|
|
336
|
+
) {
|
|
337
|
+
throw new SandboxCheckpointInvalidEntryError(
|
|
338
|
+
'File entry size must be a non-negative safe integer',
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
} else if (candidate.kind === 'dir') {
|
|
342
|
+
if (hasOwn(candidate, 'blobKey') || hasOwn(candidate, 'size')) {
|
|
343
|
+
throw new SandboxCheckpointInvalidEntryError(
|
|
344
|
+
'Directory entries cannot contain file fields',
|
|
345
|
+
)
|
|
346
|
+
}
|
|
347
|
+
} else {
|
|
348
|
+
throw new SandboxCheckpointInvalidEntryError(
|
|
349
|
+
'Checkpoint entry kind must be file or dir',
|
|
350
|
+
)
|
|
351
|
+
}
|
|
352
|
+
kinds.set(path, candidate.kind)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function validateArtifacts(checkpoint: SandboxCheckpoint): void {
|
|
357
|
+
if (!Array.isArray(checkpoint.artifacts)) {
|
|
358
|
+
throw new SandboxCheckpointInvalidEntryError(
|
|
359
|
+
'Checkpoint artifacts must be an array',
|
|
360
|
+
)
|
|
361
|
+
}
|
|
362
|
+
for (const artifact of checkpoint.artifacts as ReadonlyArray<unknown>) {
|
|
363
|
+
if (artifact === null || typeof artifact !== 'object') {
|
|
364
|
+
throw new SandboxCheckpointInvalidEntryError(
|
|
365
|
+
'Checkpoint artifact must be an object',
|
|
366
|
+
)
|
|
367
|
+
}
|
|
368
|
+
const candidate = artifact as Record<string, unknown>
|
|
369
|
+
if (
|
|
370
|
+
typeof candidate.artifactId !== 'string' ||
|
|
371
|
+
candidate.artifactId.length === 0 ||
|
|
372
|
+
hasUnpairedSurrogate(candidate.artifactId) ||
|
|
373
|
+
typeof candidate.name !== 'string' ||
|
|
374
|
+
candidate.name.length === 0 ||
|
|
375
|
+
typeof candidate.mimeType !== 'string' ||
|
|
376
|
+
candidate.mimeType.length === 0 ||
|
|
377
|
+
typeof candidate.blobKey !== 'string' ||
|
|
378
|
+
candidate.blobKey.length === 0 ||
|
|
379
|
+
hasUnpairedSurrogate(candidate.blobKey) ||
|
|
380
|
+
!/^sandbox-artifacts\/sha256\/[0-9a-f]{64}$/.test(candidate.blobKey) ||
|
|
381
|
+
typeof candidate.size !== 'number' ||
|
|
382
|
+
!Number.isSafeInteger(candidate.size) ||
|
|
383
|
+
candidate.size < 0 ||
|
|
384
|
+
typeof candidate.createdAt !== 'number' ||
|
|
385
|
+
!Number.isFinite(candidate.createdAt)
|
|
386
|
+
) {
|
|
387
|
+
throw new SandboxCheckpointInvalidEntryError(
|
|
388
|
+
'Checkpoint artifact has invalid fields',
|
|
389
|
+
)
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export class InMemorySandboxCheckpointStore implements SandboxCheckpointStore {
|
|
395
|
+
private readonly state: InMemoryCheckpointState
|
|
396
|
+
private readonly now: () => number
|
|
397
|
+
private readonly leaseDurationMs: number
|
|
398
|
+
private readonly renewAfterMs: number
|
|
399
|
+
|
|
400
|
+
constructor(options: SandboxCheckpointStoreOptions = {}) {
|
|
401
|
+
const state: InMemoryCheckpointState = {
|
|
402
|
+
checkpoints: new Map(),
|
|
403
|
+
heads: new Map(),
|
|
404
|
+
writers: new Map(),
|
|
405
|
+
fences: new Map(),
|
|
406
|
+
references: new Map(),
|
|
407
|
+
}
|
|
408
|
+
this.state = state
|
|
409
|
+
this.now = options.now ?? (() => Date.now())
|
|
410
|
+
this.leaseDurationMs = options.leaseDurationMs ?? 120_000
|
|
411
|
+
this.renewAfterMs = options.renewAfterMs ?? 45_000
|
|
412
|
+
if (!Number.isFinite(this.leaseDurationMs) || this.leaseDurationMs <= 0) {
|
|
413
|
+
throw new Error('leaseDurationMs must be finite and positive')
|
|
414
|
+
}
|
|
415
|
+
if (
|
|
416
|
+
!Number.isFinite(this.renewAfterMs) ||
|
|
417
|
+
this.renewAfterMs <= 0 ||
|
|
418
|
+
this.renewAfterMs >= this.leaseDurationMs
|
|
419
|
+
) {
|
|
420
|
+
throw new Error(
|
|
421
|
+
'renewAfterMs must be finite, positive, and less than leaseDurationMs',
|
|
422
|
+
)
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async get(id: string): Promise<SandboxCheckpoint | null> {
|
|
427
|
+
assertValidIdentifier(id, 'Checkpoint id')
|
|
428
|
+
const checkpoint = this.state.checkpoints.get(id)
|
|
429
|
+
return checkpoint ? copy(checkpoint) : null
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async list(threadId: string): Promise<Array<SandboxCheckpoint>> {
|
|
433
|
+
assertValidIdentifier(threadId, 'Thread id')
|
|
434
|
+
return Array.from(this.state.checkpoints.values())
|
|
435
|
+
.filter((checkpoint) => checkpoint.threadId === threadId)
|
|
436
|
+
.sort((a, b) => a.createdAt - b.createdAt || compareUtf8Bytes(a.id, b.id))
|
|
437
|
+
.map(copy)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async getHead(threadId: string): Promise<string | null> {
|
|
441
|
+
assertValidIdentifier(threadId, 'Thread id')
|
|
442
|
+
return this.state.heads.get(threadId) ?? null
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async append(input: {
|
|
446
|
+
checkpoint: SandboxCheckpoint
|
|
447
|
+
expectedHeadId: string | null
|
|
448
|
+
writer: SandboxCheckpointWriter
|
|
449
|
+
}): Promise<{ headId: string }> {
|
|
450
|
+
const checkpoint = copy(input.checkpoint)
|
|
451
|
+
const { expectedHeadId, writer } = input
|
|
452
|
+
assertValidIdentifier(checkpoint.threadId, 'Checkpoint thread id')
|
|
453
|
+
assertValidIdentifier(writer.threadId, 'Writer thread id')
|
|
454
|
+
assertValidIdentifier(checkpoint.id, 'Checkpoint id')
|
|
455
|
+
if (expectedHeadId !== null) {
|
|
456
|
+
assertValidIdentifier(expectedHeadId, 'Expected head id')
|
|
457
|
+
}
|
|
458
|
+
if (checkpoint.parentCheckpointId != null) {
|
|
459
|
+
assertValidIdentifier(
|
|
460
|
+
checkpoint.parentCheckpointId,
|
|
461
|
+
'Parent checkpoint id',
|
|
462
|
+
)
|
|
463
|
+
}
|
|
464
|
+
if (writer.threadId !== checkpoint.threadId) {
|
|
465
|
+
throw new SandboxCheckpointWriterLostError(
|
|
466
|
+
'Checkpoint writer thread does not match checkpoint thread',
|
|
467
|
+
)
|
|
468
|
+
}
|
|
469
|
+
if (typeof checkpoint.id !== 'string' || checkpoint.id.length === 0) {
|
|
470
|
+
throw new SandboxCheckpointInvalidIdError(
|
|
471
|
+
'Checkpoint id must be non-empty',
|
|
472
|
+
)
|
|
473
|
+
}
|
|
474
|
+
if (hasUnpairedSurrogate(checkpoint.id)) {
|
|
475
|
+
throw new SandboxCheckpointInvalidIdError(
|
|
476
|
+
'Checkpoint id must contain valid Unicode',
|
|
477
|
+
)
|
|
478
|
+
}
|
|
479
|
+
if (hasUnpairedSurrogate(checkpoint.threadId)) {
|
|
480
|
+
throw new SandboxCheckpointInvalidIdError(
|
|
481
|
+
'Checkpoint thread id must contain valid Unicode',
|
|
482
|
+
)
|
|
483
|
+
}
|
|
484
|
+
if (
|
|
485
|
+
typeof checkpoint.createdAt !== 'number' ||
|
|
486
|
+
!Number.isFinite(checkpoint.createdAt)
|
|
487
|
+
) {
|
|
488
|
+
throw new SandboxCheckpointInvalidEntryError(
|
|
489
|
+
'Checkpoint createdAt must be a finite number',
|
|
490
|
+
)
|
|
491
|
+
}
|
|
492
|
+
if (expectedHeadId === '') {
|
|
493
|
+
throw new SandboxCheckpointInvalidIdError(
|
|
494
|
+
'Expected head id must be null or non-empty',
|
|
495
|
+
)
|
|
496
|
+
}
|
|
497
|
+
const parentCheckpointId = checkpoint.parentCheckpointId ?? null
|
|
498
|
+
if (parentCheckpointId === '') {
|
|
499
|
+
throw new SandboxCheckpointInvalidIdError(
|
|
500
|
+
'Parent checkpoint id must be null or non-empty',
|
|
501
|
+
)
|
|
502
|
+
}
|
|
503
|
+
if (expectedHeadId !== null && hasUnpairedSurrogate(expectedHeadId)) {
|
|
504
|
+
throw new SandboxCheckpointInvalidIdError(
|
|
505
|
+
'Expected head id must contain valid Unicode',
|
|
506
|
+
)
|
|
507
|
+
}
|
|
508
|
+
if (
|
|
509
|
+
parentCheckpointId !== null &&
|
|
510
|
+
hasUnpairedSurrogate(parentCheckpointId)
|
|
511
|
+
) {
|
|
512
|
+
throw new SandboxCheckpointInvalidIdError(
|
|
513
|
+
'Parent checkpoint id must contain valid Unicode',
|
|
514
|
+
)
|
|
515
|
+
}
|
|
516
|
+
validateEntries(checkpoint)
|
|
517
|
+
validateArtifacts(checkpoint)
|
|
518
|
+
|
|
519
|
+
// The caller can re-enter append while its checkpoint is staged. Recheck
|
|
520
|
+
// live writer and CAS state immediately before publishing.
|
|
521
|
+
this.assertWriter(writer, checkpoint.threadId)
|
|
522
|
+
if (this.state.checkpoints.has(checkpoint.id)) {
|
|
523
|
+
throw new SandboxCheckpointDuplicateIdError(
|
|
524
|
+
`Checkpoint '${checkpoint.id}' already exists`,
|
|
525
|
+
)
|
|
526
|
+
}
|
|
527
|
+
const actualHeadId = this.state.heads.get(checkpoint.threadId) ?? null
|
|
528
|
+
if (actualHeadId !== expectedHeadId) {
|
|
529
|
+
throw new SandboxCheckpointConflictError(
|
|
530
|
+
`Expected head '${expectedHeadId}', but thread '${checkpoint.threadId}' is at '${actualHeadId}'`,
|
|
531
|
+
)
|
|
532
|
+
}
|
|
533
|
+
if (parentCheckpointId !== expectedHeadId) {
|
|
534
|
+
throw new SandboxCheckpointParentMismatchError(
|
|
535
|
+
`Checkpoint '${checkpoint.id}' parent does not match expected head`,
|
|
536
|
+
)
|
|
537
|
+
}
|
|
538
|
+
const stored = { ...checkpoint, parentCheckpointId }
|
|
539
|
+
const keys = blobKeys(stored)
|
|
540
|
+
this.state.checkpoints.set(stored.id, stored)
|
|
541
|
+
this.state.heads.set(stored.threadId, stored.id)
|
|
542
|
+
for (const key of keys) {
|
|
543
|
+
this.state.references.set(key, (this.state.references.get(key) ?? 0) + 1)
|
|
544
|
+
}
|
|
545
|
+
return { headId: stored.id }
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async deleteHead(input: {
|
|
549
|
+
threadId: string
|
|
550
|
+
checkpointId: string
|
|
551
|
+
writer: SandboxCheckpointWriter
|
|
552
|
+
}): Promise<void> {
|
|
553
|
+
const { threadId, checkpointId, writer } = input
|
|
554
|
+
assertValidIdentifier(threadId, 'Thread id')
|
|
555
|
+
assertValidIdentifier(checkpointId, 'Checkpoint id')
|
|
556
|
+
assertValidIdentifier(writer.threadId, 'Writer thread id')
|
|
557
|
+
if (writer.threadId !== threadId) {
|
|
558
|
+
throw new SandboxCheckpointWriterLostError(
|
|
559
|
+
'Checkpoint writer thread does not match operation thread',
|
|
560
|
+
)
|
|
561
|
+
}
|
|
562
|
+
this.assertWriter(writer, threadId)
|
|
563
|
+
const headId = this.state.heads.get(threadId) ?? null
|
|
564
|
+
if (headId !== checkpointId) {
|
|
565
|
+
throw new SandboxCheckpointNotHeadError(
|
|
566
|
+
`Checkpoint '${checkpointId}' is not the current head of thread '${threadId}'`,
|
|
567
|
+
)
|
|
568
|
+
}
|
|
569
|
+
const checkpoint = this.state.checkpoints.get(checkpointId)
|
|
570
|
+
if (!checkpoint) {
|
|
571
|
+
throw new SandboxCheckpointNotHeadError(
|
|
572
|
+
`Checkpoint '${checkpointId}' does not exist`,
|
|
573
|
+
)
|
|
574
|
+
}
|
|
575
|
+
this.state.checkpoints.delete(checkpointId)
|
|
576
|
+
if (checkpoint.parentCheckpointId) {
|
|
577
|
+
this.state.heads.set(threadId, checkpoint.parentCheckpointId)
|
|
578
|
+
} else {
|
|
579
|
+
this.state.heads.delete(threadId)
|
|
580
|
+
}
|
|
581
|
+
for (const key of blobKeys(checkpoint)) {
|
|
582
|
+
const references = (this.state.references.get(key) ?? 0) - 1
|
|
583
|
+
if (references > 0) this.state.references.set(key, references)
|
|
584
|
+
else this.state.references.delete(key)
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async acquireWriter(threadId: string): Promise<SandboxCheckpointWriterLease> {
|
|
589
|
+
assertValidIdentifier(threadId, 'Thread id')
|
|
590
|
+
const current = this.state.writers.get(threadId)
|
|
591
|
+
if (current && current.expiresAt > this.now()) {
|
|
592
|
+
throw new SandboxCheckpointWriterConflictError(
|
|
593
|
+
`Thread '${threadId}' already has an active checkpoint writer`,
|
|
594
|
+
)
|
|
595
|
+
}
|
|
596
|
+
const fence = (this.state.fences.get(threadId) ?? 0) + 1
|
|
597
|
+
this.state.fences.set(threadId, fence)
|
|
598
|
+
const ownerToken = globalThis.crypto.randomUUID()
|
|
599
|
+
const lease = {
|
|
600
|
+
threadId,
|
|
601
|
+
ownerToken,
|
|
602
|
+
fence,
|
|
603
|
+
expiresAt: this.now() + this.leaseDurationMs,
|
|
604
|
+
}
|
|
605
|
+
this.state.writers.set(threadId, lease)
|
|
606
|
+
return {
|
|
607
|
+
...lease,
|
|
608
|
+
get expiresAt() {
|
|
609
|
+
return lease.expiresAt
|
|
610
|
+
},
|
|
611
|
+
renewAfterMs: this.renewAfterMs,
|
|
612
|
+
renew: async () => {
|
|
613
|
+
this.assertWriter(lease, threadId)
|
|
614
|
+
lease.expiresAt = this.now() + this.leaseDurationMs
|
|
615
|
+
return { expiresAt: lease.expiresAt }
|
|
616
|
+
},
|
|
617
|
+
release: async () => {
|
|
618
|
+
const currentLease = this.state.writers.get(threadId)
|
|
619
|
+
if (
|
|
620
|
+
currentLease?.ownerToken === ownerToken &&
|
|
621
|
+
currentLease.fence === fence
|
|
622
|
+
)
|
|
623
|
+
this.state.writers.delete(threadId)
|
|
624
|
+
},
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
private assertWriter(
|
|
629
|
+
writer: SandboxCheckpointWriter,
|
|
630
|
+
threadId: string,
|
|
631
|
+
): void {
|
|
632
|
+
const current = this.state.writers.get(threadId)
|
|
633
|
+
if (
|
|
634
|
+
!current ||
|
|
635
|
+
current.ownerToken !== writer.ownerToken ||
|
|
636
|
+
current.fence !== writer.fence ||
|
|
637
|
+
current.expiresAt <= this.now()
|
|
638
|
+
) {
|
|
639
|
+
throw new SandboxCheckpointWriterLostError(
|
|
640
|
+
`Checkpoint writer lease for thread '${threadId}' is no longer current`,
|
|
641
|
+
)
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
async listBlobReferences(): Promise<
|
|
646
|
+
Array<{ key: string; references: number }>
|
|
647
|
+
> {
|
|
648
|
+
return Array.from(this.state.references.entries())
|
|
649
|
+
.sort(([a], [b]) => compareUtf8Bytes(a, b))
|
|
650
|
+
.map(([key, references]) => ({ key, references }))
|
|
651
|
+
}
|
|
652
|
+
}
|
package/src/contracts.ts
CHANGED
|
@@ -117,6 +117,11 @@ export interface SandboxFs {
|
|
|
117
117
|
remove: (path: string) => Promise<void>
|
|
118
118
|
rename: (from: string, to: string) => Promise<void>
|
|
119
119
|
exists: (path: string) => Promise<boolean>
|
|
120
|
+
/**
|
|
121
|
+
* Optional metadata lookup. Implementations must not follow symlinks.
|
|
122
|
+
* Returns undefined only for a confirmed missing path. All other errors reject.
|
|
123
|
+
*/
|
|
124
|
+
lstat?: (path: string) => Promise<SandboxFsStat | undefined>
|
|
120
125
|
/** Optional — present only when `capabilities.fs` providers advertise watch. */
|
|
121
126
|
watch?: (
|
|
122
127
|
path: string,
|
|
@@ -124,6 +129,13 @@ export interface SandboxFs {
|
|
|
124
129
|
) => Promise<{ stop: () => Promise<void> }>
|
|
125
130
|
}
|
|
126
131
|
|
|
132
|
+
export type SandboxFsStat =
|
|
133
|
+
// `mode` is the complete POSIX mode value, including the file-type bits.
|
|
134
|
+
| { type: 'file'; mode: number; size: number }
|
|
135
|
+
| { type: 'dir'; mode: number }
|
|
136
|
+
| { type: 'symlink'; mode: number }
|
|
137
|
+
| { type: 'other'; mode: number }
|
|
138
|
+
|
|
127
139
|
/**
|
|
128
140
|
* Uniform git surface. Implementations either delegate to the provider's
|
|
129
141
|
* native git (when advertised) or desugar to `process.exec("git …")`, so the
|