@tanstack/ai-sandbox 0.3.4 → 0.5.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 (64) hide show
  1. package/README.md +26 -0
  2. package/dist/esm/approvals.js +5 -4
  3. package/dist/esm/approvals.js.map +1 -1
  4. package/dist/esm/bridge-events.js +7 -6
  5. package/dist/esm/bridge-events.js.map +1 -1
  6. package/dist/esm/checkpoint-store.d.ts +147 -0
  7. package/dist/esm/checkpoint-store.js +267 -0
  8. package/dist/esm/checkpoint-store.js.map +1 -0
  9. package/dist/esm/chunk-identity.d.ts +0 -17
  10. package/dist/esm/chunk-identity.js +28 -5
  11. package/dist/esm/chunk-identity.js.map +1 -1
  12. package/dist/esm/contracts.d.ts +19 -0
  13. package/dist/esm/index.d.ts +11 -1
  14. package/dist/esm/index.js +12 -7
  15. package/dist/esm/memory-snapshot-types.d.ts +129 -0
  16. package/dist/esm/memory-snapshots.d.ts +6 -0
  17. package/dist/esm/memory-snapshots.js +490 -0
  18. package/dist/esm/memory-snapshots.js.map +1 -0
  19. package/dist/esm/middleware.d.ts +33 -1
  20. package/dist/esm/middleware.js +339 -94
  21. package/dist/esm/middleware.js.map +1 -1
  22. package/dist/esm/ngrok.d.ts +1 -1
  23. package/dist/esm/sandbox.d.ts +16 -0
  24. package/dist/esm/sandbox.js +62 -9
  25. package/dist/esm/sandbox.js.map +1 -1
  26. package/dist/esm/snapshot-operations.d.ts +65 -0
  27. package/dist/esm/snapshot-operations.js +317 -0
  28. package/dist/esm/snapshot-operations.js.map +1 -0
  29. package/dist/esm/snapshot-tools.d.ts +185 -0
  30. package/dist/esm/snapshot-tools.js +160 -0
  31. package/dist/esm/snapshot-tools.js.map +1 -0
  32. package/dist/esm/snapshots.d.ts +51 -0
  33. package/dist/esm/snapshots.js +350 -0
  34. package/dist/esm/snapshots.js.map +1 -0
  35. package/dist/esm/testkit/checkpoint-conformance.d.ts +2 -0
  36. package/dist/esm/testkit/checkpoint-conformance.js +453 -0
  37. package/dist/esm/testkit/checkpoint-conformance.js.map +1 -0
  38. package/dist/esm/testkit/checkpoint-fork-conformance.d.ts +18 -0
  39. package/dist/esm/testkit/checkpoint-fork-conformance.js +191 -0
  40. package/dist/esm/testkit/checkpoint-fork-conformance.js.map +1 -0
  41. package/dist/esm/testkit/conformance.d.ts +4 -0
  42. package/dist/esm/testkit/conformance.js +3 -1
  43. package/dist/esm/testkit/conformance.js.map +1 -1
  44. package/dist/esm/tool-history.js +4 -5
  45. package/dist/esm/tool-history.js.map +1 -1
  46. package/package.json +8 -3
  47. package/skills/ai-sandbox/SKILL.md +96 -8
  48. package/src/approvals.ts +13 -12
  49. package/src/bridge-events.ts +16 -10
  50. package/src/checkpoint-store.ts +652 -0
  51. package/src/chunk-identity.ts +36 -5
  52. package/src/contracts.ts +12 -0
  53. package/src/index.ts +56 -0
  54. package/src/memory-snapshot-types.ts +167 -0
  55. package/src/memory-snapshots.ts +936 -0
  56. package/src/middleware.ts +610 -160
  57. package/src/sandbox.ts +107 -6
  58. package/src/snapshot-operations.ts +540 -0
  59. package/src/snapshot-tools.ts +208 -0
  60. package/src/snapshots.ts +711 -0
  61. package/src/testkit/checkpoint-conformance.ts +472 -0
  62. package/src/testkit/checkpoint-fork-conformance.ts +299 -0
  63. package/src/testkit/conformance.ts +7 -0
  64. package/src/tool-history.ts +9 -13
package/src/sandbox.ts CHANGED
@@ -95,10 +95,62 @@ export interface SandboxDefinition {
95
95
  key: (ctx: SandboxEnsureContext) => string
96
96
  /** Resume-or-create the sandbox for this thread/run. */
97
97
  ensure: (ctx: SandboxEnsureContext) => Promise<SandboxHandle>
98
+ /** Resume an existing sandbox only. Never creates or restores a sandbox. */
99
+ ensureExisting: (ctx: SandboxEnsureContext) => Promise<SandboxHandle | null>
98
100
  /** Tear down the sandbox recorded for this key. */
99
101
  destroy: (ctx: SandboxEnsureContext) => Promise<void>
100
102
  }
101
103
 
104
+ export type SandboxEnsureOutcome = {
105
+ handle: SandboxHandle
106
+ outcome: 'resumed' | 'native-restored' | 'created'
107
+ }
108
+
109
+ const outcomeEnsure = new WeakMap<
110
+ object,
111
+ (ctx: SandboxEnsureContext) => Promise<SandboxEnsureOutcome>
112
+ >()
113
+
114
+ interface SandboxEnsureExistingStage {
115
+ key: string
116
+ workspace: WorkspaceDefinition | undefined
117
+ resolvedSecrets: Readonly<Record<string, string>> | undefined
118
+ snapshotMaxAge: string | undefined
119
+ resume: SandboxProvider['resume']
120
+ }
121
+
122
+ const existingEnsure = new WeakMap<
123
+ object,
124
+ (
125
+ ctx: SandboxEnsureContext,
126
+ stage?: SandboxEnsureExistingStage,
127
+ ) => Promise<SandboxHandle | null>
128
+ >()
129
+
130
+ export function stageEnsureExistingSandbox(
131
+ definition: SandboxDefinition,
132
+ ): (
133
+ ctx: SandboxEnsureContext,
134
+ stage: SandboxEnsureExistingStage,
135
+ ) => Promise<SandboxHandle | null> {
136
+ const fn = existingEnsure.get(definition)
137
+ if (fn) return (ctx, stage) => fn(ctx, stage)
138
+ const ensureExisting = definition.ensureExisting.bind(definition)
139
+ return (ctx) => ensureExisting(ctx)
140
+ }
141
+
142
+ export function ensureSandboxWithOutcome(
143
+ definition: SandboxDefinition,
144
+ ctx: SandboxEnsureContext,
145
+ ) {
146
+ const fn = outcomeEnsure.get(definition)
147
+ if (!fn)
148
+ throw new Error(
149
+ 'Sandbox snapshot mode requires a definition created by defineSandbox()',
150
+ )
151
+ return fn(ctx)
152
+ }
153
+
102
154
  /**
103
155
  * Parse a human-readable duration string into milliseconds.
104
156
  * Supports `'<n>h'` (hours) and `'<n>m'` (minutes).
@@ -136,9 +188,10 @@ const fallbackLocks = new InMemoryLockStore()
136
188
  async function applyWorkspaceSecrets(
137
189
  handle: SandboxHandle,
138
190
  workspace: WorkspaceDefinition | undefined,
191
+ stagedSecrets?: Readonly<Record<string, string>>,
139
192
  ): Promise<void> {
140
193
  if (workspace?.secrets === undefined) return
141
- const resolved = resolveAllSecrets(workspace.secrets)
194
+ const resolved = stagedSecrets ?? resolveAllSecrets(workspace.secrets)
142
195
  if (Object.keys(resolved).length === 0) return
143
196
  await handle.env.set(resolved)
144
197
  }
@@ -155,7 +208,9 @@ export function defineSandbox(config: SandboxConfig): SandboxDefinition {
155
208
  tenant: ctx.tenant,
156
209
  })
157
210
 
158
- const ensure = async (ctx: SandboxEnsureContext): Promise<SandboxHandle> => {
211
+ const ensureWithOutcome = async (
212
+ ctx: SandboxEnsureContext,
213
+ ): Promise<SandboxEnsureOutcome> => {
159
214
  const store = ctx.store ?? fallbackStore
160
215
  const locks = ctx.locks ?? fallbackLocks
161
216
  const key = computeSandboxKey(keyInputFor(ctx))
@@ -186,7 +241,7 @@ export function defineSandbox(config: SandboxConfig): SandboxDefinition {
186
241
  latestRunId: ctx.runId,
187
242
  updatedAt: Date.now(),
188
243
  })
189
- return resumed
244
+ return { handle: resumed, outcome: 'resumed' }
190
245
  }
191
246
  // 2) Else restore from the latest snapshot, if supported.
192
247
  if (
@@ -211,7 +266,7 @@ export function defineSandbox(config: SandboxConfig): SandboxDefinition {
211
266
  latestRunId: ctx.runId,
212
267
  updatedAt: Date.now(),
213
268
  })
214
- return restored
269
+ return { handle: restored, outcome: 'native-restored' }
215
270
  }
216
271
  }
217
272
  // 3) Else fall through and re-create under the same identity
@@ -265,10 +320,52 @@ export function defineSandbox(config: SandboxConfig): SandboxDefinition {
265
320
  latestRunId: ctx.runId,
266
321
  updatedAt: Date.now(),
267
322
  })
268
- return created
323
+ return { handle: created, outcome: 'created' }
269
324
  })
270
325
  }
271
326
 
327
+ const ensure = async (ctx: SandboxEnsureContext): Promise<SandboxHandle> =>
328
+ (await ensureWithOutcome(ctx)).handle
329
+
330
+ const ensureExistingWithStage = async (
331
+ ctx: SandboxEnsureContext,
332
+ stage?: SandboxEnsureExistingStage,
333
+ ): Promise<SandboxHandle | null> => {
334
+ const store = ctx.store ?? fallbackStore
335
+ const locks = ctx.locks ?? fallbackLocks
336
+ const key = stage?.key ?? computeSandboxKey(keyInputFor(ctx))
337
+ const workspace = stage?.workspace ?? config.workspace
338
+ const snapshotMaxAge = stage
339
+ ? stage.snapshotMaxAge
340
+ : config.lifecycle?.snapshotMaxAge
341
+ const resume = stage?.resume ?? config.provider.resume.bind(config.provider)
342
+ return locks.withLock(`sandbox:${key}`, async () => {
343
+ const existing = await store.get(key)
344
+ const maxAgeMs = parseMaxAgeMs(snapshotMaxAge)
345
+ if (
346
+ !existing ||
347
+ (maxAgeMs !== undefined && Date.now() - existing.updatedAt > maxAgeMs)
348
+ )
349
+ return null
350
+ const resumed = await resume({
351
+ id: existing.providerSandboxId,
352
+ signal: ctx.signal,
353
+ })
354
+ if (!resumed) return null
355
+ await applyWorkspaceSecrets(resumed, workspace, stage?.resolvedSecrets)
356
+ await store.upsert({
357
+ ...existing,
358
+ latestRunId: ctx.runId,
359
+ updatedAt: Date.now(),
360
+ })
361
+ return resumed
362
+ })
363
+ }
364
+
365
+ const ensureExisting = (
366
+ ctx: SandboxEnsureContext,
367
+ ): Promise<SandboxHandle | null> => ensureExistingWithStage(ctx)
368
+
272
369
  const destroy = async (ctx: SandboxEnsureContext): Promise<void> => {
273
370
  const store = ctx.store ?? fallbackStore
274
371
  const key = computeSandboxKey(keyInputFor(ctx))
@@ -302,7 +399,7 @@ export function defineSandbox(config: SandboxConfig): SandboxDefinition {
302
399
  await store.delete(key)
303
400
  }
304
401
 
305
- return {
402
+ const definition: SandboxDefinition = {
306
403
  id: config.id,
307
404
  provider: config.provider,
308
405
  workspace: config.workspace,
@@ -312,6 +409,10 @@ export function defineSandbox(config: SandboxConfig): SandboxDefinition {
312
409
  fileEvents: config.fileEvents,
313
410
  key: (ctx) => computeSandboxKey(keyInputFor(ctx)),
314
411
  ensure,
412
+ ensureExisting,
315
413
  destroy,
316
414
  }
415
+ outcomeEnsure.set(definition, ensureWithOutcome)
416
+ existingEnsure.set(definition, ensureExistingWithStage)
417
+ return definition
317
418
  }
@@ -0,0 +1,540 @@
1
+ import {
2
+ captureSandboxArtifacts,
3
+ captureSandboxFiles,
4
+ resolveSandboxSnapshotPolicy,
5
+ SandboxSnapshotError,
6
+ } from './snapshots'
7
+ import { resolveAllSecrets } from './secrets'
8
+ import { computeSandboxKey, computeWorkspaceHash } from './key'
9
+ import { stageEnsureExistingSandbox } from './sandbox'
10
+ import type { ModelMessage } from '@tanstack/ai'
11
+ import type { LockStore } from '@tanstack/ai/locks'
12
+ import type {
13
+ SandboxCheckpoint,
14
+ SandboxCheckpointStore,
15
+ SandboxCheckpointWriterLease,
16
+ } from './checkpoint-store'
17
+ import type { SandboxInstanceStore } from './instance-store'
18
+ import type { SandboxDefinition } from './sandbox'
19
+ import type { SandboxSnapshotBundle, SandboxSnapshotPolicy } from './snapshots'
20
+ import type { WorkspaceDefinition } from './workspace'
21
+
22
+ export interface SnapshotPersistence {
23
+ stores: {
24
+ messages: {
25
+ loadThread: (threadId: string) => Promise<ReadonlyArray<ModelMessage>>
26
+ }
27
+ artifacts: NonNullable<SandboxSnapshotBundle['artifacts']>
28
+ blobs: SandboxSnapshotBundle['blobs']
29
+ }
30
+ }
31
+
32
+ export interface CreateSandboxSnapshotsInput<
33
+ TPersistence extends SnapshotPersistence = SnapshotPersistence,
34
+ TCheckpoints extends SandboxCheckpointStore = SandboxCheckpointStore,
35
+ > {
36
+ persistence: TPersistence
37
+ checkpoints: TCheckpoints
38
+ policy?: SandboxSnapshotPolicy
39
+ sandbox?: SandboxDefinition
40
+ instances?: SandboxInstanceStore
41
+ tenant?: { userId?: string; orgId?: string }
42
+ locks?: LockStore
43
+ }
44
+
45
+ export interface SaveSandboxSnapshotInput {
46
+ threadId: string
47
+ runId: string
48
+ label: string
49
+ sandbox?: SandboxDefinition
50
+ instances?: SandboxInstanceStore
51
+ tenant?: { userId?: string; orgId?: string }
52
+ locks?: LockStore
53
+ signal?: AbortSignal
54
+ adapterName?: string
55
+ }
56
+
57
+ export interface ForkSandboxSnapshotInput {
58
+ threadId: string
59
+ checkpointId: string
60
+ destinationThreadId: string
61
+ destinationCheckpointId?: string
62
+ createdAt?: number
63
+ }
64
+
65
+ export interface ReadSandboxSnapshotArtifactInput {
66
+ threadId: string
67
+ checkpointId: string
68
+ artifactId: string
69
+ }
70
+
71
+ export interface SandboxSnapshots<
72
+ TPersistence extends SnapshotPersistence = SnapshotPersistence,
73
+ TCheckpoints extends SandboxCheckpointStore = SandboxCheckpointStore,
74
+ > {
75
+ persistence: TPersistence
76
+ checkpoints: TCheckpoints
77
+ policy?: SandboxSnapshotPolicy
78
+ save: (input: SaveSandboxSnapshotInput) => Promise<SandboxCheckpoint>
79
+ fork: (input: ForkSandboxSnapshotInput) => Promise<SandboxCheckpoint>
80
+ readArtifact: (input: ReadSandboxSnapshotArtifactInput) => Promise<{
81
+ artifact: SandboxCheckpoint['artifacts'][number]
82
+ bytes: Uint8Array
83
+ }>
84
+ }
85
+
86
+ type Failure = { error: unknown }
87
+
88
+ async function withWriterLease<T>(
89
+ acquire: () => Promise<SandboxCheckpointWriterLease>,
90
+ renew: boolean,
91
+ operation: (
92
+ writer: SandboxCheckpointWriterLease,
93
+ throwIfLost: () => Promise<void>,
94
+ ) => Promise<T>,
95
+ ): Promise<T> {
96
+ const writer = await acquire()
97
+ const release = writer.release.bind(writer)
98
+ const renewWriter = renew ? writer.renew.bind(writer) : undefined
99
+ const renewAfterMs = renew ? writer.renewAfterMs : undefined
100
+ let renewalTimer: ReturnType<typeof setTimeout> | undefined
101
+ let renewalTask: Promise<void> | undefined
102
+ let renewalFailure: Failure | undefined
103
+ let stopped = false
104
+
105
+ const scheduleRenewal = (): void => {
106
+ if (renewWriter === undefined || renewAfterMs === undefined) return
107
+ renewalTimer = setTimeout(() => {
108
+ renewalTimer = undefined
109
+ renewalTask = (async () => {
110
+ try {
111
+ await renewWriter()
112
+ } catch (error) {
113
+ renewalFailure = { error }
114
+ } finally {
115
+ renewalTask = undefined
116
+ }
117
+ if (!stopped && renewalFailure === undefined) scheduleRenewal()
118
+ })()
119
+ }, renewAfterMs)
120
+ }
121
+ if (renew) scheduleRenewal()
122
+
123
+ const throwIfLost = async (): Promise<void> => {
124
+ await renewalTask
125
+ if (renewalFailure !== undefined) throw renewalFailure.error
126
+ }
127
+
128
+ let outcome: { value: T } | undefined
129
+ let operationFailure: Failure | undefined
130
+ try {
131
+ outcome = { value: await operation(writer, throwIfLost) }
132
+ } catch (error) {
133
+ operationFailure = { error }
134
+ }
135
+
136
+ stopped = true
137
+ if (renewalTimer !== undefined) clearTimeout(renewalTimer)
138
+ await renewalTask
139
+ let releaseFailure: Failure | undefined
140
+ try {
141
+ await release()
142
+ } catch (error) {
143
+ releaseFailure = { error }
144
+ }
145
+
146
+ if (renewalFailure !== undefined) throw renewalFailure.error
147
+ if (operationFailure !== undefined) throw operationFailure.error
148
+ if (releaseFailure !== undefined) throw releaseFailure.error
149
+ if (outcome === undefined) throw new Error('Writer operation had no outcome')
150
+ return outcome.value
151
+ }
152
+
153
+ function stageWorkspace(
154
+ workspace: WorkspaceDefinition | undefined,
155
+ ): WorkspaceDefinition | undefined {
156
+ if (workspace === undefined) return undefined
157
+ const source = workspace.source
158
+ const packageManager = workspace.packageManager
159
+ const setup = workspace.setup
160
+ const scripts = workspace.scripts
161
+ const skills = workspace.skills
162
+ const instructions = workspace.instructions
163
+ const plugins = workspace.plugins
164
+ const secrets = workspace.secrets
165
+ const root = workspace.root
166
+ return {
167
+ source,
168
+ ...(Object.hasOwn(workspace, 'packageManager') ? { packageManager } : {}),
169
+ ...(Object.hasOwn(workspace, 'setup') ? { setup } : {}),
170
+ ...(Object.hasOwn(workspace, 'scripts') ? { scripts } : {}),
171
+ ...(Object.hasOwn(workspace, 'skills') ? { skills } : {}),
172
+ ...(Object.hasOwn(workspace, 'instructions') ? { instructions } : {}),
173
+ ...(Object.hasOwn(workspace, 'plugins') ? { plugins } : {}),
174
+ ...(Object.hasOwn(workspace, 'secrets') ? { secrets } : {}),
175
+ ...(Object.hasOwn(workspace, 'root') ? { root } : {}),
176
+ }
177
+ }
178
+
179
+ function effectivePolicy(
180
+ supplied: SandboxSnapshotPolicy | undefined,
181
+ workspaceHash: string | undefined,
182
+ ): SandboxSnapshotPolicy {
183
+ return resolveSandboxSnapshotPolicy(supplied, workspaceHash)
184
+ }
185
+
186
+ function stageInstanceStore(store: SandboxInstanceStore): SandboxInstanceStore {
187
+ const get = store.get.bind(store)
188
+ const upsert = store.upsert.bind(store)
189
+ const deleteRecord = store.delete.bind(store)
190
+ return { get, upsert, delete: deleteRecord }
191
+ }
192
+
193
+ function stageLockStore(locks: LockStore | undefined): LockStore | undefined {
194
+ if (locks === undefined) return undefined
195
+ const withLock = locks.withLock.bind(locks)
196
+ return { withLock }
197
+ }
198
+
199
+ function requireSnapshotPersistence<TPersistence extends SnapshotPersistence>(
200
+ persistence: TPersistence,
201
+ ): TPersistence {
202
+ const stores = persistence.stores
203
+ if (!stores?.messages || !stores.artifacts || !stores.blobs) {
204
+ throw new SandboxSnapshotError(
205
+ 'SANDBOX_SNAPSHOT_MISSING_PERSISTENCE_STORES',
206
+ 'Sandbox snapshots require persistence stores.messages, stores.artifacts, and stores.blobs',
207
+ )
208
+ }
209
+ return persistence
210
+ }
211
+
212
+ export function createSandboxSnapshots<
213
+ TPersistence extends SnapshotPersistence,
214
+ TCheckpoints extends SandboxCheckpointStore,
215
+ >(
216
+ input: CreateSandboxSnapshotsInput<TPersistence, TCheckpoints>,
217
+ ): SandboxSnapshots<TPersistence, TCheckpoints> {
218
+ const persistence = requireSnapshotPersistence(input.persistence)
219
+ const checkpoints = input.checkpoints
220
+ const policy = input.policy
221
+ const boundSandbox = input.sandbox
222
+ const boundInstances = input.instances
223
+ const boundTenant = input.tenant
224
+ const boundLocks = input.locks
225
+
226
+ return {
227
+ persistence,
228
+ checkpoints,
229
+ ...(policy === undefined ? {} : { policy }),
230
+ async save(saveInput) {
231
+ const sandbox = saveInput.sandbox ?? boundSandbox
232
+ const instances = saveInput.instances ?? boundInstances
233
+ if (sandbox === undefined)
234
+ throw new SandboxSnapshotError(
235
+ 'SANDBOX_SNAPSHOT_MISSING_SANDBOX',
236
+ 'Named snapshots require a sandbox at create time or on save',
237
+ )
238
+ if (instances === undefined)
239
+ throw new SandboxSnapshotError(
240
+ 'SANDBOX_SNAPSHOT_MISSING_INSTANCES',
241
+ 'Named snapshots require instances at create time or on save',
242
+ )
243
+ return saveNamedSandboxSnapshot({
244
+ definition: sandbox,
245
+ threadId: saveInput.threadId,
246
+ runId: saveInput.runId,
247
+ instances,
248
+ persistence,
249
+ checkpoints,
250
+ policy,
251
+ label: saveInput.label,
252
+ tenant: saveInput.tenant ?? boundTenant,
253
+ locks: saveInput.locks ?? boundLocks,
254
+ signal: saveInput.signal,
255
+ adapterName: saveInput.adapterName,
256
+ })
257
+ },
258
+ fork(forkInput) {
259
+ return forkFromSandboxSnapshot({
260
+ threadId: forkInput.threadId,
261
+ checkpointId: forkInput.checkpointId,
262
+ destinationThreadId: forkInput.destinationThreadId,
263
+ checkpoints,
264
+ destinationCheckpointId: forkInput.destinationCheckpointId,
265
+ createdAt: forkInput.createdAt,
266
+ })
267
+ },
268
+ readArtifact(readInput) {
269
+ return resolveSnapshotArtifact({
270
+ threadId: readInput.threadId,
271
+ checkpointId: readInput.checkpointId,
272
+ artifactId: readInput.artifactId,
273
+ persistence,
274
+ checkpoints,
275
+ })
276
+ },
277
+ }
278
+ }
279
+
280
+ async function saveNamedSandboxSnapshot(input: {
281
+ definition: SandboxDefinition
282
+ threadId: string
283
+ runId: string
284
+ instances: SandboxInstanceStore
285
+ persistence: SnapshotPersistence
286
+ checkpoints: SandboxCheckpointStore
287
+ policy?: SandboxSnapshotPolicy
288
+ label: string
289
+ tenant?: { userId?: string; orgId?: string }
290
+ locks?: LockStore
291
+ signal?: AbortSignal
292
+ adapterName?: string
293
+ }): Promise<SandboxCheckpoint> {
294
+ const definition = input.definition
295
+ const threadId = input.threadId
296
+ const runId = input.runId
297
+ const instances = stageInstanceStore(input.instances)
298
+ const label = input.label
299
+ const suppliedTenant = input.tenant
300
+ const tenantUserId = suppliedTenant?.userId
301
+ const tenantOrgId = suppliedTenant?.orgId
302
+ const tenant = suppliedTenant
303
+ ? {
304
+ ...(tenantUserId === undefined ? {} : { userId: tenantUserId }),
305
+ ...(tenantOrgId === undefined ? {} : { orgId: tenantOrgId }),
306
+ }
307
+ : undefined
308
+ const locks = stageLockStore(input.locks)
309
+ const signal = input.signal
310
+ const adapterName = input.adapterName
311
+ const lifecycle = definition.lifecycle
312
+ const reuse = lifecycle?.reuse
313
+ const snapshotMaxAge = lifecycle?.snapshotMaxAge
314
+ const workspace = stageWorkspace(definition.workspace)
315
+ const sandboxId = definition.id
316
+ const provider = definition.provider
317
+ const providerName = provider.name
318
+ const resume = provider.resume.bind(provider)
319
+ const ensureExisting = stageEnsureExistingSandbox(definition)
320
+ const persistence = input.persistence
321
+ const stores = persistence.stores
322
+ const messages = stores.messages
323
+ const loadThread = messages.loadThread.bind(messages)
324
+ const artifactStore = stores.artifacts
325
+ const listForThread = artifactStore.listForThread.bind(artifactStore)
326
+ const suppliedBlobs = stores.blobs
327
+ const getBlob = suppliedBlobs.get.bind(suppliedBlobs)
328
+ const headBlob = suppliedBlobs.head.bind(suppliedBlobs)
329
+ const putBlob = suppliedBlobs.put.bind(suppliedBlobs)
330
+ const blobs: SandboxSnapshotBundle['blobs'] = {
331
+ get: getBlob,
332
+ head: headBlob,
333
+ put: putBlob,
334
+ }
335
+ const checkpoints = input.checkpoints
336
+ const acquireWriter = checkpoints.acquireWriter.bind(checkpoints)
337
+ const getHead = checkpoints.getHead.bind(checkpoints)
338
+ const append = checkpoints.append.bind(checkpoints)
339
+ const policy = effectivePolicy(
340
+ input.policy,
341
+ workspace === undefined ? undefined : computeWorkspaceHash(workspace),
342
+ )
343
+ const workspaceSecrets = workspace?.secrets
344
+ const secrets = workspaceSecrets ? resolveAllSecrets(workspaceSecrets) : {}
345
+ const workspaceRoot = workspace?.root
346
+ const key = computeSandboxKey({
347
+ threadId,
348
+ sandboxId,
349
+ providerName,
350
+ workspace,
351
+ tenant,
352
+ })
353
+
354
+ return withWriterLease(
355
+ () => acquireWriter(threadId),
356
+ true,
357
+ async (writer, throwIfLost) => {
358
+ if (reuse === 'none')
359
+ throw new SandboxSnapshotError(
360
+ 'SANDBOX_SNAPSHOT_REUSE_NONE',
361
+ 'Named snapshots require a reusable sandbox lifecycle',
362
+ )
363
+ const handle = await ensureExisting(
364
+ {
365
+ threadId,
366
+ runId,
367
+ store: instances,
368
+ locks,
369
+ tenant,
370
+ signal,
371
+ adapterName,
372
+ },
373
+ {
374
+ key,
375
+ workspace,
376
+ resolvedSecrets: workspaceSecrets ? secrets : undefined,
377
+ snapshotMaxAge,
378
+ resume,
379
+ },
380
+ )
381
+ if (!handle)
382
+ throw new SandboxSnapshotError(
383
+ 'SANDBOX_SNAPSHOT_MISSING_REUSABLE_SANDBOX',
384
+ 'Named snapshots require an existing resumable sandbox',
385
+ )
386
+ const conversation = await loadThread(threadId)
387
+ const files = await captureSandboxFiles(
388
+ handle,
389
+ { blobs, workspaceRoot },
390
+ policy,
391
+ secrets,
392
+ )
393
+ const artifacts = await captureSandboxArtifacts(
394
+ {
395
+ blobs,
396
+ artifacts: { listForThread },
397
+ },
398
+ threadId,
399
+ secrets,
400
+ )
401
+ const parentCheckpointId = await getHead(threadId)
402
+ await throwIfLost()
403
+ const checkpoint: SandboxCheckpoint = {
404
+ id: crypto.randomUUID(),
405
+ threadId,
406
+ parentCheckpointId,
407
+ createdAt: Date.now(),
408
+ reason: 'named',
409
+ label,
410
+ sourceRunId: runId,
411
+ files: files.files,
412
+ conversation,
413
+ artifacts,
414
+ }
415
+ await append({
416
+ checkpoint,
417
+ expectedHeadId: parentCheckpointId,
418
+ writer,
419
+ })
420
+ await throwIfLost()
421
+ return checkpoint
422
+ },
423
+ )
424
+ }
425
+
426
+ async function forkFromSandboxSnapshot(input: {
427
+ threadId: string
428
+ checkpointId: string
429
+ destinationThreadId: string
430
+ checkpoints: SandboxCheckpointStore
431
+ destinationCheckpointId?: string
432
+ createdAt?: number
433
+ }): Promise<SandboxCheckpoint> {
434
+ const sourceThreadId = input.threadId
435
+ const sourceCheckpointId = input.checkpointId
436
+ const destinationThreadId = input.destinationThreadId
437
+ const suppliedDestinationCheckpointId = input.destinationCheckpointId
438
+ const suppliedCreatedAt = input.createdAt
439
+ const destinationCheckpointId =
440
+ suppliedDestinationCheckpointId ?? crypto.randomUUID()
441
+ const createdAt = suppliedCreatedAt ?? Date.now()
442
+ const checkpoints = input.checkpoints
443
+ const acquireWriter = checkpoints.acquireWriter.bind(checkpoints)
444
+ const forkFromCheckpoint = checkpoints.forkFromCheckpoint?.bind(checkpoints)
445
+
446
+ return withWriterLease(
447
+ () => acquireWriter(destinationThreadId),
448
+ false,
449
+ async (writer) => {
450
+ if (forkFromCheckpoint === undefined)
451
+ throw new SandboxSnapshotError(
452
+ 'SANDBOX_SNAPSHOT_FORK_UNAVAILABLE',
453
+ 'The checkpoint store does not support atomic forks',
454
+ )
455
+ const result = await forkFromCheckpoint({
456
+ sourceThreadId,
457
+ sourceCheckpointId,
458
+ destinationThreadId,
459
+ destinationCheckpointId,
460
+ createdAt,
461
+ writer,
462
+ })
463
+ return result.checkpoint
464
+ },
465
+ )
466
+ }
467
+
468
+ async function sha256(bytes: Uint8Array): Promise<string> {
469
+ const digest = await crypto.subtle.digest('SHA-256', new Uint8Array(bytes))
470
+ return Array.from(new Uint8Array(digest), (byte) =>
471
+ byte.toString(16).padStart(2, '0'),
472
+ ).join('')
473
+ }
474
+
475
+ async function resolveSnapshotArtifact(input: {
476
+ threadId: string
477
+ checkpointId: string
478
+ artifactId: string
479
+ persistence: SnapshotPersistence
480
+ checkpoints: SandboxCheckpointStore
481
+ }): Promise<{
482
+ artifact: SandboxCheckpoint['artifacts'][number]
483
+ bytes: Uint8Array
484
+ }> {
485
+ const threadId = input.threadId
486
+ const checkpointId = input.checkpointId
487
+ const artifactId = input.artifactId
488
+ const checkpoints = input.checkpoints
489
+ const getCheckpoint = checkpoints.get.bind(checkpoints)
490
+ const persistence = input.persistence
491
+ const stores = persistence.stores
492
+ const blobs = stores.blobs
493
+ const getBlob = blobs.get.bind(blobs)
494
+ const checkpoint = await getCheckpoint(checkpointId)
495
+ if (!checkpoint)
496
+ throw new SandboxSnapshotError(
497
+ 'SANDBOX_SNAPSHOT_MISSING_CHECKPOINT_ARTIFACT',
498
+ 'Snapshot checkpoint does not exist',
499
+ )
500
+ const checkpointThreadId = checkpoint.threadId
501
+ const checkpointArtifacts = checkpoint.artifacts
502
+ if (checkpointThreadId !== threadId)
503
+ throw new SandboxSnapshotError(
504
+ 'SANDBOX_SNAPSHOT_FOREIGN_CHECKPOINT_ARTIFACT',
505
+ 'Snapshot checkpoint belongs to another thread',
506
+ )
507
+ const foundArtifact = checkpointArtifacts.find(
508
+ (value) => value.artifactId === artifactId,
509
+ )
510
+ if (!foundArtifact)
511
+ throw new SandboxSnapshotError(
512
+ 'SANDBOX_SNAPSHOT_MISSING_CHECKPOINT_ARTIFACT',
513
+ 'Snapshot artifact does not exist',
514
+ )
515
+ const artifact = {
516
+ artifactId: foundArtifact.artifactId,
517
+ name: foundArtifact.name,
518
+ mimeType: foundArtifact.mimeType,
519
+ size: foundArtifact.size,
520
+ blobKey: foundArtifact.blobKey,
521
+ createdAt: foundArtifact.createdAt,
522
+ }
523
+ const blob = await getBlob(artifact.blobKey)
524
+ if (!blob)
525
+ throw new SandboxSnapshotError(
526
+ 'SANDBOX_SNAPSHOT_INVALID_ARTIFACT_BYTES',
527
+ 'Snapshot artifact blob does not exist',
528
+ )
529
+ const arrayBuffer = blob.arrayBuffer.bind(blob)
530
+ const bytes = new Uint8Array(await arrayBuffer())
531
+ if (
532
+ bytes.byteLength !== artifact.size ||
533
+ artifact.blobKey !== `sandbox-artifacts/sha256/${await sha256(bytes)}`
534
+ )
535
+ throw new SandboxSnapshotError(
536
+ 'SANDBOX_SNAPSHOT_INVALID_ARTIFACT_BYTES',
537
+ 'Snapshot artifact bytes do not match metadata',
538
+ )
539
+ return { artifact: { ...artifact }, bytes: bytes.slice() }
540
+ }