@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.
Files changed (51) hide show
  1. package/README.md +26 -0
  2. package/dist/esm/checkpoint-store.d.ts +147 -0
  3. package/dist/esm/checkpoint-store.js +267 -0
  4. package/dist/esm/checkpoint-store.js.map +1 -0
  5. package/dist/esm/contracts.d.ts +19 -0
  6. package/dist/esm/index.d.ts +11 -1
  7. package/dist/esm/index.js +12 -7
  8. package/dist/esm/memory-snapshot-types.d.ts +129 -0
  9. package/dist/esm/memory-snapshots.d.ts +6 -0
  10. package/dist/esm/memory-snapshots.js +490 -0
  11. package/dist/esm/memory-snapshots.js.map +1 -0
  12. package/dist/esm/middleware.d.ts +33 -1
  13. package/dist/esm/middleware.js +339 -94
  14. package/dist/esm/middleware.js.map +1 -1
  15. package/dist/esm/ngrok.d.ts +1 -1
  16. package/dist/esm/sandbox.d.ts +16 -0
  17. package/dist/esm/sandbox.js +62 -9
  18. package/dist/esm/sandbox.js.map +1 -1
  19. package/dist/esm/snapshot-operations.d.ts +65 -0
  20. package/dist/esm/snapshot-operations.js +317 -0
  21. package/dist/esm/snapshot-operations.js.map +1 -0
  22. package/dist/esm/snapshot-tools.d.ts +185 -0
  23. package/dist/esm/snapshot-tools.js +160 -0
  24. package/dist/esm/snapshot-tools.js.map +1 -0
  25. package/dist/esm/snapshots.d.ts +51 -0
  26. package/dist/esm/snapshots.js +350 -0
  27. package/dist/esm/snapshots.js.map +1 -0
  28. package/dist/esm/testkit/checkpoint-conformance.d.ts +2 -0
  29. package/dist/esm/testkit/checkpoint-conformance.js +453 -0
  30. package/dist/esm/testkit/checkpoint-conformance.js.map +1 -0
  31. package/dist/esm/testkit/checkpoint-fork-conformance.d.ts +18 -0
  32. package/dist/esm/testkit/checkpoint-fork-conformance.js +191 -0
  33. package/dist/esm/testkit/checkpoint-fork-conformance.js.map +1 -0
  34. package/dist/esm/testkit/conformance.d.ts +4 -0
  35. package/dist/esm/testkit/conformance.js +3 -1
  36. package/dist/esm/testkit/conformance.js.map +1 -1
  37. package/package.json +8 -3
  38. package/skills/ai-sandbox/SKILL.md +96 -8
  39. package/src/checkpoint-store.ts +652 -0
  40. package/src/contracts.ts +12 -0
  41. package/src/index.ts +56 -0
  42. package/src/memory-snapshot-types.ts +167 -0
  43. package/src/memory-snapshots.ts +936 -0
  44. package/src/middleware.ts +610 -160
  45. package/src/sandbox.ts +107 -6
  46. package/src/snapshot-operations.ts +540 -0
  47. package/src/snapshot-tools.ts +208 -0
  48. package/src/snapshots.ts +711 -0
  49. package/src/testkit/checkpoint-conformance.ts +472 -0
  50. package/src/testkit/checkpoint-fork-conformance.ts +299 -0
  51. 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