@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.
- package/README.md +26 -0
- package/dist/esm/approvals.js +5 -4
- package/dist/esm/approvals.js.map +1 -1
- package/dist/esm/bridge-events.js +7 -6
- package/dist/esm/bridge-events.js.map +1 -1
- 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/chunk-identity.d.ts +0 -17
- package/dist/esm/chunk-identity.js +28 -5
- package/dist/esm/chunk-identity.js.map +1 -1
- 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/dist/esm/tool-history.js +4 -5
- package/dist/esm/tool-history.js.map +1 -1
- package/package.json +8 -3
- package/skills/ai-sandbox/SKILL.md +96 -8
- package/src/approvals.ts +13 -12
- package/src/bridge-events.ts +16 -10
- package/src/checkpoint-store.ts +652 -0
- package/src/chunk-identity.ts +36 -5
- 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
- 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
|
|
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
|
-
|
|
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
|
+
}
|