@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
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type {
|
|
3
|
+
SandboxCheckpoint,
|
|
4
|
+
SandboxCheckpointStore,
|
|
5
|
+
SandboxCheckpointStoreOptions,
|
|
6
|
+
SandboxCheckpointWriter,
|
|
7
|
+
} from '../checkpoint-store'
|
|
8
|
+
|
|
9
|
+
const writers = new WeakMap<
|
|
10
|
+
SandboxCheckpointStore,
|
|
11
|
+
Map<string, Promise<SandboxCheckpointWriter>>
|
|
12
|
+
>()
|
|
13
|
+
async function writerFor(store: SandboxCheckpointStore, threadId: string) {
|
|
14
|
+
let storeWriters = writers.get(store)
|
|
15
|
+
if (!storeWriters) {
|
|
16
|
+
storeWriters = new Map()
|
|
17
|
+
writers.set(store, storeWriters)
|
|
18
|
+
}
|
|
19
|
+
let writer = storeWriters.get(threadId)
|
|
20
|
+
if (!writer) {
|
|
21
|
+
writer = store.acquireWriter(threadId)
|
|
22
|
+
storeWriters.set(threadId, writer)
|
|
23
|
+
}
|
|
24
|
+
return writer
|
|
25
|
+
}
|
|
26
|
+
async function append(
|
|
27
|
+
store: SandboxCheckpointStore,
|
|
28
|
+
input: { checkpoint: SandboxCheckpoint; expectedHeadId: string | null },
|
|
29
|
+
) {
|
|
30
|
+
return store.append({
|
|
31
|
+
...input,
|
|
32
|
+
writer: await writerFor(store, input.checkpoint.threadId),
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
async function deleteHead(
|
|
36
|
+
store: SandboxCheckpointStore,
|
|
37
|
+
input: { threadId: string; checkpointId: string },
|
|
38
|
+
) {
|
|
39
|
+
return store.deleteHead({
|
|
40
|
+
...input,
|
|
41
|
+
writer: await writerFor(store, input.threadId),
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function expectThreadState(
|
|
46
|
+
store: SandboxCheckpointStore,
|
|
47
|
+
staleCheckpointId: string,
|
|
48
|
+
expected: {
|
|
49
|
+
head: string | null
|
|
50
|
+
list: Array<SandboxCheckpoint>
|
|
51
|
+
checkpoint: SandboxCheckpoint | null
|
|
52
|
+
references: Array<{ key: string; references: number }>
|
|
53
|
+
},
|
|
54
|
+
) {
|
|
55
|
+
expect(await store.getHead('thread-a')).toBe(expected.head)
|
|
56
|
+
expect(await store.list('thread-a')).toEqual(expected.list)
|
|
57
|
+
expect(await store.get(staleCheckpointId)).toEqual(expected.checkpoint)
|
|
58
|
+
expect(await store.listBlobReferences()).toEqual(expected.references)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function checkpoint(
|
|
62
|
+
id: string,
|
|
63
|
+
parentCheckpointId: string | null = null,
|
|
64
|
+
threadId = 'thread-a',
|
|
65
|
+
): SandboxCheckpoint {
|
|
66
|
+
const hash = (Number(id.replace(/\D/g, '')) || 1)
|
|
67
|
+
.toString(16)
|
|
68
|
+
.padStart(64, '0')
|
|
69
|
+
return {
|
|
70
|
+
id,
|
|
71
|
+
threadId,
|
|
72
|
+
parentCheckpointId,
|
|
73
|
+
createdAt: Number(id.replace(/\D/g, '')) || 1,
|
|
74
|
+
reason: 'automatic',
|
|
75
|
+
files: [
|
|
76
|
+
{
|
|
77
|
+
path: `${id}.txt`,
|
|
78
|
+
kind: 'file',
|
|
79
|
+
blobKey: `sandbox-files/sha256/${hash}`,
|
|
80
|
+
size: 1,
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
conversation: [{ role: 'user', content: id }],
|
|
84
|
+
artifacts: [],
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function runSandboxCheckpointStoreConformance(
|
|
89
|
+
name: string,
|
|
90
|
+
makeStore: (
|
|
91
|
+
options?: SandboxCheckpointStoreOptions,
|
|
92
|
+
) => SandboxCheckpointStore | Promise<SandboxCheckpointStore>,
|
|
93
|
+
options?: SandboxCheckpointStoreOptions,
|
|
94
|
+
): void {
|
|
95
|
+
describe(`SandboxCheckpointStore conformance: ${name}`, () => {
|
|
96
|
+
it('returns null and an empty list for an unknown thread', async () => {
|
|
97
|
+
const store = await makeStore(options)
|
|
98
|
+
expect(await store.get('missing')).toBeNull()
|
|
99
|
+
expect(await store.getHead('missing-thread')).toBeNull()
|
|
100
|
+
expect(await store.list('missing-thread')).toEqual([])
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('appends a root and then a parent-linked checkpoint', async () => {
|
|
104
|
+
const store = await makeStore(options)
|
|
105
|
+
await expect(
|
|
106
|
+
append(store, { checkpoint: checkpoint('1'), expectedHeadId: null }),
|
|
107
|
+
).resolves.toEqual({ headId: '1' })
|
|
108
|
+
await expect(
|
|
109
|
+
append(store, {
|
|
110
|
+
checkpoint: checkpoint('2', '1'),
|
|
111
|
+
expectedHeadId: '1',
|
|
112
|
+
}),
|
|
113
|
+
).resolves.toEqual({ headId: '2' })
|
|
114
|
+
expect(await store.getHead('thread-a')).toBe('2')
|
|
115
|
+
expect((await store.list('thread-a')).map((entry) => entry.id)).toEqual([
|
|
116
|
+
'1',
|
|
117
|
+
'2',
|
|
118
|
+
])
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('enforces expected-head and parent compare-and-swap rules', async () => {
|
|
122
|
+
const store = await makeStore(options)
|
|
123
|
+
await append(store, { checkpoint: checkpoint('1'), expectedHeadId: null })
|
|
124
|
+
await expect(
|
|
125
|
+
append(store, { checkpoint: checkpoint('2'), expectedHeadId: null }),
|
|
126
|
+
).rejects.toMatchObject({ code: 'SANDBOX_SNAPSHOT_STALE_HEAD' })
|
|
127
|
+
await expect(
|
|
128
|
+
append(store, { checkpoint: checkpoint('3'), expectedHeadId: '1' }),
|
|
129
|
+
).rejects.toMatchObject({ code: 'SANDBOX_SNAPSHOT_PARENT_MISMATCH' })
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('rejects duplicate checkpoint ids', async () => {
|
|
133
|
+
const store = await makeStore(options)
|
|
134
|
+
await append(store, { checkpoint: checkpoint('1'), expectedHeadId: null })
|
|
135
|
+
await expect(
|
|
136
|
+
append(store, { checkpoint: checkpoint('1'), expectedHeadId: '1' }),
|
|
137
|
+
).rejects.toMatchObject({ code: 'SANDBOX_SNAPSHOT_DUPLICATE_ID' })
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('isolates checkpoints and heads by thread', async () => {
|
|
141
|
+
const store = await makeStore(options)
|
|
142
|
+
await append(store, {
|
|
143
|
+
checkpoint: checkpoint('a', null, 'thread-a'),
|
|
144
|
+
expectedHeadId: null,
|
|
145
|
+
})
|
|
146
|
+
await append(store, {
|
|
147
|
+
checkpoint: checkpoint('b', null, 'thread-b'),
|
|
148
|
+
expectedHeadId: null,
|
|
149
|
+
})
|
|
150
|
+
expect(await store.getHead('thread-a')).toBe('a')
|
|
151
|
+
expect(await store.getHead('thread-b')).toBe('b')
|
|
152
|
+
expect((await store.list('thread-a')).map((entry) => entry.id)).toEqual([
|
|
153
|
+
'a',
|
|
154
|
+
])
|
|
155
|
+
expect((await store.list('thread-b')).map((entry) => entry.id)).toEqual([
|
|
156
|
+
'b',
|
|
157
|
+
])
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('lists checkpoints by createdAt and then checkpoint id', async () => {
|
|
161
|
+
const store = await makeStore(options)
|
|
162
|
+
const first = { ...checkpoint('z'), createdAt: 2 }
|
|
163
|
+
const second = { ...checkpoint('a', 'z'), createdAt: 1 }
|
|
164
|
+
const third = { ...checkpoint('b', 'a'), createdAt: 1 }
|
|
165
|
+
await append(store, { checkpoint: first, expectedHeadId: null })
|
|
166
|
+
await append(store, { checkpoint: second, expectedHeadId: 'z' })
|
|
167
|
+
await append(store, { checkpoint: third, expectedHeadId: 'a' })
|
|
168
|
+
expect((await store.list('thread-a')).map((entry) => entry.id)).toEqual([
|
|
169
|
+
'a',
|
|
170
|
+
'b',
|
|
171
|
+
'z',
|
|
172
|
+
])
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('orders checkpoint ids and blob keys by UTF-8 bytes', async () => {
|
|
176
|
+
const store = await makeStore(options)
|
|
177
|
+
const ids = ['é', 'a', 'B', '😀']
|
|
178
|
+
const keys = ['1', '2', '3', '4'].map(
|
|
179
|
+
(value) => `sandbox-files/sha256/${value.padStart(64, '0')}`,
|
|
180
|
+
)
|
|
181
|
+
let parent: string | null = null
|
|
182
|
+
for (const [index, id] of ids.entries()) {
|
|
183
|
+
const blobKey = keys[index]
|
|
184
|
+
if (!blobKey) throw new Error(`Missing blob key for '${id}'`)
|
|
185
|
+
await append(store, {
|
|
186
|
+
checkpoint: {
|
|
187
|
+
...checkpoint(id, parent),
|
|
188
|
+
files: [
|
|
189
|
+
{
|
|
190
|
+
path: `${id}.txt`,
|
|
191
|
+
kind: 'file',
|
|
192
|
+
blobKey,
|
|
193
|
+
size: 1,
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
},
|
|
197
|
+
expectedHeadId: parent,
|
|
198
|
+
})
|
|
199
|
+
parent = id
|
|
200
|
+
}
|
|
201
|
+
expect((await store.list('thread-a')).map((entry) => entry.id)).toEqual([
|
|
202
|
+
'B',
|
|
203
|
+
'a',
|
|
204
|
+
'é',
|
|
205
|
+
'😀',
|
|
206
|
+
])
|
|
207
|
+
expect(
|
|
208
|
+
(await store.listBlobReferences()).map((entry) => entry.key),
|
|
209
|
+
).toEqual([
|
|
210
|
+
`sandbox-files/sha256/${'0'.repeat(63)}1`,
|
|
211
|
+
`sandbox-files/sha256/${'0'.repeat(63)}2`,
|
|
212
|
+
`sandbox-files/sha256/${'0'.repeat(63)}3`,
|
|
213
|
+
`sandbox-files/sha256/${'0'.repeat(63)}4`,
|
|
214
|
+
])
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('rejects malformed Unicode checkpoint ids and blob keys', async () => {
|
|
218
|
+
const store = await makeStore(options)
|
|
219
|
+
const malformed = '\uD800'
|
|
220
|
+
await expect(
|
|
221
|
+
append(store, {
|
|
222
|
+
checkpoint: checkpoint(malformed),
|
|
223
|
+
expectedHeadId: null,
|
|
224
|
+
}),
|
|
225
|
+
).rejects.toMatchObject({ code: 'SANDBOX_SNAPSHOT_INVALID_ID' })
|
|
226
|
+
await expect(
|
|
227
|
+
append(store, {
|
|
228
|
+
checkpoint: {
|
|
229
|
+
...checkpoint('root'),
|
|
230
|
+
files: [
|
|
231
|
+
{
|
|
232
|
+
path: 'root.txt',
|
|
233
|
+
kind: 'file',
|
|
234
|
+
blobKey: `sandbox-files/sha256/${malformed}${'0'.repeat(63)}`,
|
|
235
|
+
size: 1,
|
|
236
|
+
},
|
|
237
|
+
],
|
|
238
|
+
},
|
|
239
|
+
expectedHeadId: null,
|
|
240
|
+
}),
|
|
241
|
+
).rejects.toMatchObject({ code: 'SANDBOX_SNAPSHOT_INVALID_ENTRY' })
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it.each([Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])(
|
|
245
|
+
'rejects non-finite createdAt without changing state: %s',
|
|
246
|
+
async (createdAt) => {
|
|
247
|
+
const store = await makeStore(options)
|
|
248
|
+
const before = {
|
|
249
|
+
head: await store.getHead('thread-a'),
|
|
250
|
+
list: await store.list('thread-a'),
|
|
251
|
+
references: await store.listBlobReferences(),
|
|
252
|
+
}
|
|
253
|
+
await expect(
|
|
254
|
+
append(store, {
|
|
255
|
+
checkpoint: { ...checkpoint('invalid'), createdAt },
|
|
256
|
+
expectedHeadId: null,
|
|
257
|
+
}),
|
|
258
|
+
).rejects.toMatchObject({ code: 'SANDBOX_SNAPSHOT_INVALID_ENTRY' })
|
|
259
|
+
expect(await store.getHead('thread-a')).toBe(before.head)
|
|
260
|
+
expect(await store.list('thread-a')).toEqual(before.list)
|
|
261
|
+
expect(await store.listBlobReferences()).toEqual(before.references)
|
|
262
|
+
},
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
it('keeps checkpoints immutable and reference counts distinct blob keys', async () => {
|
|
266
|
+
const store = await makeStore(options)
|
|
267
|
+
const value = {
|
|
268
|
+
...checkpoint('1'),
|
|
269
|
+
files: [
|
|
270
|
+
...checkpoint('1').files,
|
|
271
|
+
{
|
|
272
|
+
path: 'same.txt',
|
|
273
|
+
kind: 'file' as const,
|
|
274
|
+
blobKey: `sandbox-files/sha256/${'0'.repeat(63)}1`,
|
|
275
|
+
size: 1,
|
|
276
|
+
},
|
|
277
|
+
],
|
|
278
|
+
}
|
|
279
|
+
await append(store, { checkpoint: value, expectedHeadId: null })
|
|
280
|
+
const inputFile = value.files[0]
|
|
281
|
+
if (!inputFile) throw new Error('Expected input file')
|
|
282
|
+
inputFile.path = 'input-mutated'
|
|
283
|
+
const loaded = await store.get('1')
|
|
284
|
+
expect(loaded).not.toBe(value)
|
|
285
|
+
if (!loaded) throw new Error('Expected stored checkpoint')
|
|
286
|
+
const loadedFile = loaded.files[0]
|
|
287
|
+
if (!loadedFile) throw new Error('Expected stored file')
|
|
288
|
+
expect(loadedFile.path).toBe('1.txt')
|
|
289
|
+
loadedFile.path = 'changed'
|
|
290
|
+
expect((await store.get('1'))?.files[0]?.path).toBe('1.txt')
|
|
291
|
+
const listed = await store.list('thread-a')
|
|
292
|
+
const listedCheckpoint = listed[0]
|
|
293
|
+
const listedFile = listedCheckpoint?.files[0]
|
|
294
|
+
if (!listedFile) throw new Error('Expected listed file')
|
|
295
|
+
listedFile.path = 'list-mutated'
|
|
296
|
+
expect((await store.list('thread-a'))[0]?.files[0]?.path).toBe('1.txt')
|
|
297
|
+
expect(await store.listBlobReferences()).toEqual([
|
|
298
|
+
{ key: `sandbox-files/sha256/${'0'.repeat(63)}1`, references: 1 },
|
|
299
|
+
])
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('counts one reference per checkpoint for shared blob keys', async () => {
|
|
303
|
+
const store = await makeStore(options)
|
|
304
|
+
const shared = `sandbox-files/sha256/${'f'.repeat(64)}`
|
|
305
|
+
await append(store, {
|
|
306
|
+
checkpoint: {
|
|
307
|
+
...checkpoint('1'),
|
|
308
|
+
files: [{ path: 'a', kind: 'file', blobKey: shared, size: 1 }],
|
|
309
|
+
},
|
|
310
|
+
expectedHeadId: null,
|
|
311
|
+
})
|
|
312
|
+
await append(store, {
|
|
313
|
+
checkpoint: {
|
|
314
|
+
...checkpoint('2', '1'),
|
|
315
|
+
files: [{ path: 'b', kind: 'file', blobKey: shared, size: 1 }],
|
|
316
|
+
},
|
|
317
|
+
expectedHeadId: '1',
|
|
318
|
+
})
|
|
319
|
+
expect(await store.listBlobReferences()).toEqual([
|
|
320
|
+
{ key: shared, references: 2 },
|
|
321
|
+
])
|
|
322
|
+
await deleteHead(store, { threadId: 'thread-a', checkpointId: '2' })
|
|
323
|
+
expect(await store.listBlobReferences()).toEqual([
|
|
324
|
+
{ key: shared, references: 1 },
|
|
325
|
+
])
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
it('allows one non-waiting writer lease per thread', async () => {
|
|
329
|
+
const store = await makeStore(options)
|
|
330
|
+
const lease = await store.acquireWriter('thread-a')
|
|
331
|
+
try {
|
|
332
|
+
await expect(store.acquireWriter('thread-a')).rejects.toMatchObject({
|
|
333
|
+
code: 'SANDBOX_SNAPSHOT_WRITER_CONFLICT',
|
|
334
|
+
})
|
|
335
|
+
const secondLease = await store.acquireWriter('thread-b')
|
|
336
|
+
try {
|
|
337
|
+
expect(secondLease).toBeDefined()
|
|
338
|
+
} finally {
|
|
339
|
+
await secondLease.release()
|
|
340
|
+
}
|
|
341
|
+
} finally {
|
|
342
|
+
await lease.release()
|
|
343
|
+
}
|
|
344
|
+
const nextLease = await store.acquireWriter('thread-a')
|
|
345
|
+
await nextLease.release()
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
it('extends a lease when renew succeeds', async () => {
|
|
349
|
+
const config = options ?? {}
|
|
350
|
+
let current = config.now?.() ?? 1_000
|
|
351
|
+
const store = await makeStore({ ...config, now: () => current })
|
|
352
|
+
const lease = await store.acquireWriter('thread-a')
|
|
353
|
+
const originalExpiry = lease.expiresAt
|
|
354
|
+
|
|
355
|
+
current += lease.renewAfterMs
|
|
356
|
+
await expect(lease.renew()).resolves.toEqual({
|
|
357
|
+
expiresAt: current + (config.leaseDurationMs ?? 120_000),
|
|
358
|
+
})
|
|
359
|
+
expect(lease.expiresAt).toBeGreaterThan(originalExpiry)
|
|
360
|
+
await lease.release()
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
it('does not let a stale lease release a replacement lease', async () => {
|
|
364
|
+
const config = options ?? {}
|
|
365
|
+
let current = config.now?.() ?? 1_000
|
|
366
|
+
const store = await makeStore({ ...config, now: () => current })
|
|
367
|
+
const old = await store.acquireWriter('thread-a')
|
|
368
|
+
current += (config.leaseDurationMs ?? 120_000) + 1
|
|
369
|
+
const replacement = await store.acquireWriter('thread-a')
|
|
370
|
+
|
|
371
|
+
await old.release()
|
|
372
|
+
await expect(store.acquireWriter('thread-a')).rejects.toMatchObject({
|
|
373
|
+
code: 'SANDBOX_SNAPSHOT_WRITER_CONFLICT',
|
|
374
|
+
})
|
|
375
|
+
await replacement.release()
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
it('rejects stale leases after expiry takeover without changing state', async () => {
|
|
379
|
+
const config = options ?? {}
|
|
380
|
+
let current = config.now?.() ?? Date.now()
|
|
381
|
+
const takeoverStore = await makeStore({ ...config, now: () => current })
|
|
382
|
+
const old = await takeoverStore.acquireWriter('thread-a')
|
|
383
|
+
await takeoverStore.append({
|
|
384
|
+
checkpoint: checkpoint('root'),
|
|
385
|
+
expectedHeadId: null,
|
|
386
|
+
writer: old,
|
|
387
|
+
})
|
|
388
|
+
current += (config.leaseDurationMs ?? 120_000) + 1
|
|
389
|
+
const beforeExpiry = {
|
|
390
|
+
head: await takeoverStore.getHead('thread-a'),
|
|
391
|
+
list: await takeoverStore.list('thread-a'),
|
|
392
|
+
checkpoint: await takeoverStore.get('root'),
|
|
393
|
+
references: await takeoverStore.listBlobReferences(),
|
|
394
|
+
}
|
|
395
|
+
await expect(old.renew()).rejects.toMatchObject({
|
|
396
|
+
code: 'SANDBOX_SNAPSHOT_WRITER_LOST',
|
|
397
|
+
})
|
|
398
|
+
await expect(
|
|
399
|
+
takeoverStore.append({
|
|
400
|
+
checkpoint: checkpoint('stale', 'root'),
|
|
401
|
+
expectedHeadId: 'root',
|
|
402
|
+
writer: old,
|
|
403
|
+
}),
|
|
404
|
+
).rejects.toMatchObject({ code: 'SANDBOX_SNAPSHOT_WRITER_LOST' })
|
|
405
|
+
await expectThreadState(takeoverStore, 'stale', {
|
|
406
|
+
...beforeExpiry,
|
|
407
|
+
checkpoint: null,
|
|
408
|
+
})
|
|
409
|
+
await expect(
|
|
410
|
+
takeoverStore.deleteHead({
|
|
411
|
+
threadId: 'thread-a',
|
|
412
|
+
checkpointId: 'root',
|
|
413
|
+
writer: old,
|
|
414
|
+
}),
|
|
415
|
+
).rejects.toMatchObject({ code: 'SANDBOX_SNAPSHOT_WRITER_LOST' })
|
|
416
|
+
await expectThreadState(takeoverStore, 'root', beforeExpiry)
|
|
417
|
+
const next = await takeoverStore.acquireWriter('thread-a')
|
|
418
|
+
expect(next.fence).toBeGreaterThan(old.fence)
|
|
419
|
+
const before = {
|
|
420
|
+
head: await takeoverStore.getHead('thread-a'),
|
|
421
|
+
list: await takeoverStore.list('thread-a'),
|
|
422
|
+
checkpoint: await takeoverStore.get('root'),
|
|
423
|
+
references: await takeoverStore.listBlobReferences(),
|
|
424
|
+
}
|
|
425
|
+
await expect(old.renew()).rejects.toMatchObject({
|
|
426
|
+
code: 'SANDBOX_SNAPSHOT_WRITER_LOST',
|
|
427
|
+
})
|
|
428
|
+
await expect(
|
|
429
|
+
takeoverStore.append({
|
|
430
|
+
checkpoint: checkpoint('stale', 'root'),
|
|
431
|
+
expectedHeadId: 'root',
|
|
432
|
+
writer: old,
|
|
433
|
+
}),
|
|
434
|
+
).rejects.toMatchObject({ code: 'SANDBOX_SNAPSHOT_WRITER_LOST' })
|
|
435
|
+
await expectThreadState(takeoverStore, 'stale', {
|
|
436
|
+
...before,
|
|
437
|
+
checkpoint: null,
|
|
438
|
+
})
|
|
439
|
+
await expect(
|
|
440
|
+
takeoverStore.deleteHead({
|
|
441
|
+
threadId: 'thread-a',
|
|
442
|
+
checkpointId: 'root',
|
|
443
|
+
writer: old,
|
|
444
|
+
}),
|
|
445
|
+
).rejects.toMatchObject({ code: 'SANDBOX_SNAPSHOT_WRITER_LOST' })
|
|
446
|
+
await expectThreadState(takeoverStore, 'root', before)
|
|
447
|
+
await old.release()
|
|
448
|
+
await expectThreadState(takeoverStore, 'stale', {
|
|
449
|
+
...before,
|
|
450
|
+
checkpoint: null,
|
|
451
|
+
})
|
|
452
|
+
await next.release()
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
it('deletes only the current head and transitions to its parent', async () => {
|
|
456
|
+
const store = await makeStore(options)
|
|
457
|
+
await append(store, { checkpoint: checkpoint('1'), expectedHeadId: null })
|
|
458
|
+
await append(store, {
|
|
459
|
+
checkpoint: checkpoint('2', '1'),
|
|
460
|
+
expectedHeadId: '1',
|
|
461
|
+
})
|
|
462
|
+
await expect(
|
|
463
|
+
deleteHead(store, { threadId: 'thread-a', checkpointId: '1' }),
|
|
464
|
+
).rejects.toMatchObject({ code: 'SANDBOX_SNAPSHOT_NOT_HEAD' })
|
|
465
|
+
await deleteHead(store, { threadId: 'thread-a', checkpointId: '2' })
|
|
466
|
+
expect(await store.getHead('thread-a')).toBe('1')
|
|
467
|
+
await deleteHead(store, { threadId: 'thread-a', checkpointId: '1' })
|
|
468
|
+
expect(await store.getHead('thread-a')).toBeNull()
|
|
469
|
+
expect(await store.listBlobReferences()).toEqual([])
|
|
470
|
+
})
|
|
471
|
+
})
|
|
472
|
+
}
|