@unrdf/kgc-runtime 26.4.2
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/IMPLEMENTATION_SUMMARY.json +150 -0
- package/PLUGIN_SYSTEM_SUMMARY.json +149 -0
- package/README.md +98 -0
- package/TRANSACTION_IMPLEMENTATION.json +119 -0
- package/capability-map.md +93 -0
- package/docs/api-stability.md +269 -0
- package/docs/extensions/plugin-development.md +382 -0
- package/package.json +40 -0
- package/plugins/registry.json +35 -0
- package/src/admission-gate.mjs +414 -0
- package/src/api-version.mjs +373 -0
- package/src/atomic-admission.mjs +310 -0
- package/src/bounds.mjs +289 -0
- package/src/bulkhead-manager.mjs +280 -0
- package/src/capsule.mjs +524 -0
- package/src/crdt.mjs +361 -0
- package/src/enhanced-bounds.mjs +614 -0
- package/src/executor.mjs +73 -0
- package/src/freeze-restore.mjs +521 -0
- package/src/index.mjs +62 -0
- package/src/materialized-views.mjs +371 -0
- package/src/merge.mjs +472 -0
- package/src/plugin-isolation.mjs +392 -0
- package/src/plugin-manager.mjs +441 -0
- package/src/projections-api.mjs +336 -0
- package/src/projections-cli.mjs +238 -0
- package/src/projections-docs.mjs +300 -0
- package/src/projections-ide.mjs +278 -0
- package/src/receipt.mjs +340 -0
- package/src/rollback.mjs +258 -0
- package/src/saga-orchestrator.mjs +355 -0
- package/src/schemas.mjs +1330 -0
- package/src/storage-optimization.mjs +359 -0
- package/src/tool-registry.mjs +272 -0
- package/src/transaction.mjs +466 -0
- package/src/validators.mjs +485 -0
- package/src/work-item.mjs +449 -0
- package/templates/plugin-template/README.md +58 -0
- package/templates/plugin-template/index.mjs +162 -0
- package/templates/plugin-template/plugin.json +19 -0
- package/test/admission-gate.test.mjs +583 -0
- package/test/api-version.test.mjs +74 -0
- package/test/atomic-admission.test.mjs +155 -0
- package/test/bounds.test.mjs +341 -0
- package/test/bulkhead-manager.test.mjs +236 -0
- package/test/capsule.test.mjs +625 -0
- package/test/crdt.test.mjs +215 -0
- package/test/enhanced-bounds.test.mjs +487 -0
- package/test/freeze-restore.test.mjs +472 -0
- package/test/materialized-views.test.mjs +243 -0
- package/test/merge.test.mjs +665 -0
- package/test/plugin-isolation.test.mjs +109 -0
- package/test/plugin-manager.test.mjs +208 -0
- package/test/projections-api.test.mjs +293 -0
- package/test/projections-cli.test.mjs +204 -0
- package/test/projections-docs.test.mjs +173 -0
- package/test/projections-ide.test.mjs +230 -0
- package/test/receipt.test.mjs +295 -0
- package/test/rollback.test.mjs +132 -0
- package/test/saga-orchestrator.test.mjs +279 -0
- package/test/schemas.test.mjs +716 -0
- package/test/storage-optimization.test.mjs +503 -0
- package/test/tool-registry.test.mjs +341 -0
- package/test/transaction.test.mjs +189 -0
- package/test/validators.test.mjs +463 -0
- package/test/work-item.test.mjs +548 -0
- package/test/work-item.test.mjs.bak +548 -0
- package/var/kgc/test-atomic-log.json +519 -0
- package/var/kgc/test-cascading-log.json +145 -0
- package/vitest.config.mjs +18 -0
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Tests for RunCapsule canonicalization and replay
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
6
|
+
import { mkdirSync, rmSync, existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import {
|
|
9
|
+
RunCapsule,
|
|
10
|
+
storeCapsule,
|
|
11
|
+
replayCapsule,
|
|
12
|
+
listCapsules,
|
|
13
|
+
} from '../src/capsule.mjs';
|
|
14
|
+
|
|
15
|
+
const TEST_CAPSULE_DIR = './var/kgc/capsules-test';
|
|
16
|
+
|
|
17
|
+
describe('RunCapsule', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
// Clean up test directory
|
|
20
|
+
if (existsSync(TEST_CAPSULE_DIR)) {
|
|
21
|
+
rmSync(TEST_CAPSULE_DIR, { recursive: true, force: true });
|
|
22
|
+
}
|
|
23
|
+
mkdirSync(TEST_CAPSULE_DIR, { recursive: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
// Clean up after tests
|
|
28
|
+
if (existsSync(TEST_CAPSULE_DIR)) {
|
|
29
|
+
rmSync(TEST_CAPSULE_DIR, { recursive: true, force: true });
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('Canonicalization', () => {
|
|
34
|
+
it('should produce deterministic hash for same inputs', () => {
|
|
35
|
+
const data1 = {
|
|
36
|
+
inputs: { prompt: 'test', model: 'gpt-4' },
|
|
37
|
+
tool_trace: [
|
|
38
|
+
{ tool: 'bash', args: ['ls'], output: 'file1.txt\nfile2.txt' },
|
|
39
|
+
],
|
|
40
|
+
edits: [{ file: 'test.mjs', old: 'foo', new: 'bar' }],
|
|
41
|
+
artifacts: ['output.json'],
|
|
42
|
+
bounds: { start: 1000, end: 2000 },
|
|
43
|
+
o_hash_before: 'abc123',
|
|
44
|
+
o_hash_after: 'def456',
|
|
45
|
+
receipts: [],
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const data2 = {
|
|
49
|
+
inputs: { model: 'gpt-4', prompt: 'test' }, // Different order
|
|
50
|
+
tool_trace: [
|
|
51
|
+
{ tool: 'bash', args: ['ls'], output: 'file1.txt\nfile2.txt' },
|
|
52
|
+
],
|
|
53
|
+
edits: [{ file: 'test.mjs', old: 'foo', new: 'bar' }],
|
|
54
|
+
artifacts: ['output.json'],
|
|
55
|
+
bounds: { start: 1000, end: 2000 },
|
|
56
|
+
o_hash_before: 'abc123',
|
|
57
|
+
o_hash_after: 'def456',
|
|
58
|
+
receipts: [],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const capsule1 = new RunCapsule(data1);
|
|
62
|
+
const capsule2 = new RunCapsule(data2);
|
|
63
|
+
|
|
64
|
+
expect(capsule1.capsule_hash).toBe(capsule2.capsule_hash);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should produce different hash for different inputs', () => {
|
|
68
|
+
const data1 = {
|
|
69
|
+
inputs: { prompt: 'test1' },
|
|
70
|
+
tool_trace: [],
|
|
71
|
+
edits: [],
|
|
72
|
+
artifacts: [],
|
|
73
|
+
bounds: { start: 1000, end: 2000 },
|
|
74
|
+
o_hash_before: 'abc123',
|
|
75
|
+
o_hash_after: 'def456',
|
|
76
|
+
receipts: [],
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const data2 = {
|
|
80
|
+
inputs: { prompt: 'test2' }, // Different prompt
|
|
81
|
+
tool_trace: [],
|
|
82
|
+
edits: [],
|
|
83
|
+
artifacts: [],
|
|
84
|
+
bounds: { start: 1000, end: 2000 },
|
|
85
|
+
o_hash_before: 'abc123',
|
|
86
|
+
o_hash_after: 'def456',
|
|
87
|
+
receipts: [],
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const capsule1 = new RunCapsule(data1);
|
|
91
|
+
const capsule2 = new RunCapsule(data2);
|
|
92
|
+
|
|
93
|
+
expect(capsule1.capsule_hash).not.toBe(capsule2.capsule_hash);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should handle Unicode normalization in canonicalization', () => {
|
|
97
|
+
// Unicode café: é can be represented as single char (U+00E9) or e + combining accent (U+0065 + U+0301)
|
|
98
|
+
const data1 = {
|
|
99
|
+
inputs: { text: 'café' }, // Single character é
|
|
100
|
+
tool_trace: [],
|
|
101
|
+
edits: [],
|
|
102
|
+
artifacts: [],
|
|
103
|
+
bounds: { start: 1000, end: 2000 },
|
|
104
|
+
o_hash_before: 'abc',
|
|
105
|
+
o_hash_after: 'def',
|
|
106
|
+
receipts: [],
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const data2 = {
|
|
110
|
+
inputs: { text: 'café' }, // Composed é (should normalize to same)
|
|
111
|
+
tool_trace: [],
|
|
112
|
+
edits: [],
|
|
113
|
+
artifacts: [],
|
|
114
|
+
bounds: { start: 1000, end: 2000 },
|
|
115
|
+
o_hash_before: 'abc',
|
|
116
|
+
o_hash_after: 'def',
|
|
117
|
+
receipts: [],
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const capsule1 = new RunCapsule(data1);
|
|
121
|
+
const capsule2 = new RunCapsule(data2);
|
|
122
|
+
|
|
123
|
+
// Should produce same hash after normalization
|
|
124
|
+
expect(capsule1.capsule_hash).toBe(capsule2.capsule_hash);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should handle nested object canonicalization', () => {
|
|
128
|
+
const data = {
|
|
129
|
+
inputs: {
|
|
130
|
+
nested: {
|
|
131
|
+
z: 'last',
|
|
132
|
+
a: 'first',
|
|
133
|
+
m: { x: 1, b: 2 },
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
tool_trace: [],
|
|
137
|
+
edits: [],
|
|
138
|
+
artifacts: [],
|
|
139
|
+
bounds: { start: 1000, end: 2000 },
|
|
140
|
+
o_hash_before: 'abc',
|
|
141
|
+
o_hash_after: 'def',
|
|
142
|
+
receipts: [],
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const capsule = new RunCapsule(data);
|
|
146
|
+
|
|
147
|
+
// Should have valid hash
|
|
148
|
+
expect(capsule.capsule_hash).toMatch(/^[a-f0-9]{64}$/);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('Hash Consistency', () => {
|
|
153
|
+
it('should produce BLAKE3 hash of 64 hex characters', () => {
|
|
154
|
+
const data = {
|
|
155
|
+
inputs: { test: 'value' },
|
|
156
|
+
tool_trace: [],
|
|
157
|
+
edits: [],
|
|
158
|
+
artifacts: [],
|
|
159
|
+
bounds: { start: 1000, end: 2000 },
|
|
160
|
+
o_hash_before: 'before',
|
|
161
|
+
o_hash_after: 'after',
|
|
162
|
+
receipts: [],
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const capsule = new RunCapsule(data);
|
|
166
|
+
|
|
167
|
+
expect(capsule.capsule_hash).toMatch(/^[a-f0-9]{64}$/);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should maintain hash stability across multiple instantiations', () => {
|
|
171
|
+
const data = {
|
|
172
|
+
inputs: { stable: 'test' },
|
|
173
|
+
tool_trace: [{ tool: 'read', file: 'test.txt' }],
|
|
174
|
+
edits: [],
|
|
175
|
+
artifacts: [],
|
|
176
|
+
bounds: { start: 5000, end: 6000 },
|
|
177
|
+
o_hash_before: 'hash1',
|
|
178
|
+
o_hash_after: 'hash2',
|
|
179
|
+
receipts: [],
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const hashes = Array.from({ length: 10 }, () => {
|
|
183
|
+
const capsule = new RunCapsule(data);
|
|
184
|
+
return capsule.capsule_hash;
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// All hashes should be identical
|
|
188
|
+
expect(new Set(hashes).size).toBe(1);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('should handle empty arrays consistently', () => {
|
|
192
|
+
const data1 = {
|
|
193
|
+
inputs: {},
|
|
194
|
+
tool_trace: [],
|
|
195
|
+
edits: [],
|
|
196
|
+
artifacts: [],
|
|
197
|
+
bounds: { start: 0, end: 0 },
|
|
198
|
+
o_hash_before: '',
|
|
199
|
+
o_hash_after: '',
|
|
200
|
+
receipts: [],
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const data2 = {
|
|
204
|
+
inputs: {},
|
|
205
|
+
tool_trace: [],
|
|
206
|
+
edits: [],
|
|
207
|
+
artifacts: [],
|
|
208
|
+
bounds: { start: 0, end: 0 },
|
|
209
|
+
o_hash_before: '',
|
|
210
|
+
o_hash_after: '',
|
|
211
|
+
receipts: [],
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const capsule1 = new RunCapsule(data1);
|
|
215
|
+
const capsule2 = new RunCapsule(data2);
|
|
216
|
+
|
|
217
|
+
expect(capsule1.capsule_hash).toBe(capsule2.capsule_hash);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe('storeCapsule', () => {
|
|
222
|
+
it('should store capsule to correct location', async () => {
|
|
223
|
+
const data = {
|
|
224
|
+
inputs: { action: 'store_test' },
|
|
225
|
+
tool_trace: [],
|
|
226
|
+
edits: [],
|
|
227
|
+
artifacts: [],
|
|
228
|
+
bounds: { start: 1000, end: 2000 },
|
|
229
|
+
o_hash_before: 'before',
|
|
230
|
+
o_hash_after: 'after',
|
|
231
|
+
receipts: [],
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const capsule = new RunCapsule(data);
|
|
235
|
+
const result = await storeCapsule(capsule, TEST_CAPSULE_DIR);
|
|
236
|
+
|
|
237
|
+
const expectedPath = join(
|
|
238
|
+
TEST_CAPSULE_DIR,
|
|
239
|
+
`${capsule.capsule_hash}.json`
|
|
240
|
+
);
|
|
241
|
+
expect(result.path).toBe(expectedPath);
|
|
242
|
+
expect(result.deduplicated).toBe(false);
|
|
243
|
+
expect(existsSync(result.path)).toBe(true);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('should create manifest entry', async () => {
|
|
247
|
+
const data = {
|
|
248
|
+
inputs: { action: 'manifest_test' },
|
|
249
|
+
tool_trace: [],
|
|
250
|
+
edits: [],
|
|
251
|
+
artifacts: [],
|
|
252
|
+
bounds: { start: 1000, end: 2000 },
|
|
253
|
+
o_hash_before: 'before',
|
|
254
|
+
o_hash_after: 'after',
|
|
255
|
+
receipts: [],
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const capsule = new RunCapsule(data);
|
|
259
|
+
await storeCapsule(capsule, TEST_CAPSULE_DIR);
|
|
260
|
+
|
|
261
|
+
const manifestPath = join(TEST_CAPSULE_DIR, 'manifest.json');
|
|
262
|
+
expect(existsSync(manifestPath)).toBe(true);
|
|
263
|
+
|
|
264
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
265
|
+
expect(manifest.capsules).toHaveLength(1);
|
|
266
|
+
expect(manifest.capsules[0].hash).toBe(capsule.capsule_hash);
|
|
267
|
+
expect(manifest.capsules[0]).toHaveProperty('stored_at');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('should handle multiple capsule storage', async () => {
|
|
271
|
+
const capsules = [
|
|
272
|
+
new RunCapsule({
|
|
273
|
+
inputs: { id: 1 },
|
|
274
|
+
tool_trace: [],
|
|
275
|
+
edits: [],
|
|
276
|
+
artifacts: [],
|
|
277
|
+
bounds: { start: 1000, end: 2000 },
|
|
278
|
+
o_hash_before: 'a',
|
|
279
|
+
o_hash_after: 'b',
|
|
280
|
+
receipts: [],
|
|
281
|
+
}),
|
|
282
|
+
new RunCapsule({
|
|
283
|
+
inputs: { id: 2 },
|
|
284
|
+
tool_trace: [],
|
|
285
|
+
edits: [],
|
|
286
|
+
artifacts: [],
|
|
287
|
+
bounds: { start: 2000, end: 3000 },
|
|
288
|
+
o_hash_before: 'c',
|
|
289
|
+
o_hash_after: 'd',
|
|
290
|
+
receipts: [],
|
|
291
|
+
}),
|
|
292
|
+
];
|
|
293
|
+
|
|
294
|
+
for (const capsule of capsules) {
|
|
295
|
+
await storeCapsule(capsule, TEST_CAPSULE_DIR);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const files = readdirSync(TEST_CAPSULE_DIR).filter((f) =>
|
|
299
|
+
f.endsWith('.json')
|
|
300
|
+
);
|
|
301
|
+
expect(files.length).toBeGreaterThanOrEqual(3); // 2 capsules + manifest + index
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
describe('Replay', () => {
|
|
306
|
+
it('should replay capsule with matching output', async () => {
|
|
307
|
+
const data = {
|
|
308
|
+
inputs: { command: 'echo test' },
|
|
309
|
+
tool_trace: [
|
|
310
|
+
{ tool: 'bash', command: 'echo test', output: 'test\n' },
|
|
311
|
+
],
|
|
312
|
+
edits: [{ file: 'output.txt', old: '', new: 'test\n' }],
|
|
313
|
+
artifacts: ['output.txt'],
|
|
314
|
+
bounds: { start: 1000, end: 2000 },
|
|
315
|
+
o_hash_before: 'initial',
|
|
316
|
+
o_hash_after: 'final_hash',
|
|
317
|
+
receipts: [],
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const capsule = new RunCapsule(data);
|
|
321
|
+
const o_snapshot = { state: 'initial' };
|
|
322
|
+
|
|
323
|
+
const { result, receipt } = await replayCapsule(capsule, o_snapshot);
|
|
324
|
+
|
|
325
|
+
expect(result).toBe('admit');
|
|
326
|
+
expect(receipt.status).toBe('admit');
|
|
327
|
+
expect(receipt.capsule_hash).toBe(capsule.capsule_hash);
|
|
328
|
+
expect(receipt.output_hash).toBe('final_hash');
|
|
329
|
+
expect(receipt.verified).toBe(true);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('should deny replay with divergent state', async () => {
|
|
333
|
+
const data = {
|
|
334
|
+
inputs: { command: 'test' },
|
|
335
|
+
tool_trace: [],
|
|
336
|
+
edits: [],
|
|
337
|
+
artifacts: [],
|
|
338
|
+
bounds: { start: 1000, end: 2000 },
|
|
339
|
+
o_hash_before: 'expected_initial',
|
|
340
|
+
o_hash_after: 'expected_final',
|
|
341
|
+
receipts: [],
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const capsule = new RunCapsule(data);
|
|
345
|
+
// Simulate divergent state with different hash
|
|
346
|
+
const o_snapshot = { state: 'divergent', hash: 'different_hash' };
|
|
347
|
+
|
|
348
|
+
const { result, receipt } = await replayCapsule(capsule, o_snapshot);
|
|
349
|
+
|
|
350
|
+
expect(result).toBe('deny');
|
|
351
|
+
expect(receipt.status).toBe('deny');
|
|
352
|
+
expect(receipt.capsule_hash).toBe(capsule.capsule_hash);
|
|
353
|
+
expect(receipt.error).toContain('Output hash mismatch');
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('should apply edits during replay', async () => {
|
|
357
|
+
const data = {
|
|
358
|
+
inputs: { task: 'edit_file' },
|
|
359
|
+
tool_trace: [],
|
|
360
|
+
edits: [
|
|
361
|
+
{ file: 'test.mjs', old: 'const x = 1;', new: 'const x = 2;' },
|
|
362
|
+
{ file: 'test.mjs', old: 'const y = 3;', new: 'const y = 4;' },
|
|
363
|
+
],
|
|
364
|
+
artifacts: [],
|
|
365
|
+
bounds: { start: 1000, end: 2000 },
|
|
366
|
+
o_hash_before: 'before_edits',
|
|
367
|
+
o_hash_after: 'after_edits',
|
|
368
|
+
receipts: [],
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const capsule = new RunCapsule(data);
|
|
372
|
+
const o_snapshot = { files: { 'test.mjs': 'const x = 1;\nconst y = 3;' } };
|
|
373
|
+
|
|
374
|
+
const { result, receipt } = await replayCapsule(capsule, o_snapshot);
|
|
375
|
+
|
|
376
|
+
expect(receipt.edits_applied).toBe(2);
|
|
377
|
+
expect(receipt.tool_traces_executed).toBe(0);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('should execute tool traces during replay', async () => {
|
|
381
|
+
const data = {
|
|
382
|
+
inputs: { task: 'run_tools' },
|
|
383
|
+
tool_trace: [
|
|
384
|
+
{ tool: 'read', file: 'input.txt', output: 'data' },
|
|
385
|
+
{ tool: 'write', file: 'output.txt', content: 'processed data' },
|
|
386
|
+
],
|
|
387
|
+
edits: [],
|
|
388
|
+
artifacts: ['output.txt'],
|
|
389
|
+
bounds: { start: 1000, end: 2000 },
|
|
390
|
+
o_hash_before: 'before_tools',
|
|
391
|
+
o_hash_after: 'after_tools',
|
|
392
|
+
receipts: [],
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
const capsule = new RunCapsule(data);
|
|
396
|
+
const o_snapshot = { state: 'ready' };
|
|
397
|
+
|
|
398
|
+
const { result, receipt } = await replayCapsule(capsule, o_snapshot);
|
|
399
|
+
|
|
400
|
+
expect(receipt.tool_traces_executed).toBe(2);
|
|
401
|
+
expect(receipt.edits_applied).toBe(0);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('should compute proper output hash from snapshot', async () => {
|
|
405
|
+
const data = {
|
|
406
|
+
inputs: { task: 'hash_test' },
|
|
407
|
+
tool_trace: [],
|
|
408
|
+
edits: [
|
|
409
|
+
{ file: 'test.txt', old: 'a', new: 'b' },
|
|
410
|
+
],
|
|
411
|
+
artifacts: [],
|
|
412
|
+
bounds: { start: 1000, end: 2000 },
|
|
413
|
+
o_hash_before: 'before',
|
|
414
|
+
o_hash_after: 'after',
|
|
415
|
+
receipts: [],
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const capsule = new RunCapsule(data);
|
|
419
|
+
const o_snapshot = { files: { 'test.txt': 'abc' } };
|
|
420
|
+
|
|
421
|
+
const { result, receipt } = await replayCapsule(capsule, o_snapshot);
|
|
422
|
+
|
|
423
|
+
expect(receipt.output_hash).toBeDefined();
|
|
424
|
+
expect(receipt.output_hash).toMatch(/^[a-f0-9]{64}$/);
|
|
425
|
+
expect(receipt.expected_hash).toBe('after');
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('should handle snapshot with files correctly', async () => {
|
|
429
|
+
const data = {
|
|
430
|
+
inputs: { task: 'file_edit' },
|
|
431
|
+
tool_trace: [],
|
|
432
|
+
edits: [
|
|
433
|
+
{ file: 'code.js', old: 'foo', new: 'bar' },
|
|
434
|
+
],
|
|
435
|
+
artifacts: [],
|
|
436
|
+
bounds: { start: 1000, end: 2000 },
|
|
437
|
+
o_hash_before: 'start',
|
|
438
|
+
o_hash_after: 'end',
|
|
439
|
+
receipts: [],
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
const capsule = new RunCapsule(data);
|
|
443
|
+
const o_snapshot = { files: { 'code.js': 'function foo() {}' } };
|
|
444
|
+
|
|
445
|
+
const { result, receipt } = await replayCapsule(capsule, o_snapshot);
|
|
446
|
+
|
|
447
|
+
expect(receipt.edits_applied).toBe(1);
|
|
448
|
+
expect(receipt.verified).toBeDefined();
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('should continue on edit errors', async () => {
|
|
452
|
+
const data = {
|
|
453
|
+
inputs: { task: 'partial_edits' },
|
|
454
|
+
tool_trace: [],
|
|
455
|
+
edits: [
|
|
456
|
+
{ invalid: 'edit1' }, // Missing required fields
|
|
457
|
+
{ file: 'valid.txt', old: 'x', new: 'y' },
|
|
458
|
+
],
|
|
459
|
+
artifacts: [],
|
|
460
|
+
bounds: { start: 1000, end: 2000 },
|
|
461
|
+
o_hash_before: 'start',
|
|
462
|
+
o_hash_after: 'end',
|
|
463
|
+
receipts: [],
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
const capsule = new RunCapsule(data);
|
|
467
|
+
const o_snapshot = { files: { 'valid.txt': 'xyz' } };
|
|
468
|
+
|
|
469
|
+
const { result, receipt } = await replayCapsule(capsule, o_snapshot);
|
|
470
|
+
|
|
471
|
+
// Should apply the valid edit
|
|
472
|
+
expect(receipt.edits_applied).toBeGreaterThanOrEqual(1);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('should handle tool traces with validation', async () => {
|
|
476
|
+
const data = {
|
|
477
|
+
inputs: { task: 'tool_validation' },
|
|
478
|
+
tool_trace: [
|
|
479
|
+
{ tool: 'bash', command: 'ls', output: 'file.txt' },
|
|
480
|
+
{ tool: 'read', file: 'data.json' },
|
|
481
|
+
{ invalid_trace: true }, // Invalid trace
|
|
482
|
+
],
|
|
483
|
+
edits: [],
|
|
484
|
+
artifacts: [],
|
|
485
|
+
bounds: { start: 1000, end: 2000 },
|
|
486
|
+
o_hash_before: 'initial',
|
|
487
|
+
o_hash_after: 'final',
|
|
488
|
+
receipts: [],
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
const capsule = new RunCapsule(data);
|
|
492
|
+
const o_snapshot = { state: 'ready' };
|
|
493
|
+
|
|
494
|
+
const { result, receipt } = await replayCapsule(capsule, o_snapshot);
|
|
495
|
+
|
|
496
|
+
// Should execute valid tool traces
|
|
497
|
+
expect(receipt.tool_traces_executed).toBe(2);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it('should clone snapshot without mutation', async () => {
|
|
501
|
+
const data = {
|
|
502
|
+
inputs: { task: 'immutable_test' },
|
|
503
|
+
tool_trace: [],
|
|
504
|
+
edits: [
|
|
505
|
+
{ file: 'test.txt', old: 'original', new: 'modified' },
|
|
506
|
+
],
|
|
507
|
+
artifacts: [],
|
|
508
|
+
bounds: { start: 1000, end: 2000 },
|
|
509
|
+
o_hash_before: 'before',
|
|
510
|
+
o_hash_after: 'after',
|
|
511
|
+
receipts: [],
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
const capsule = new RunCapsule(data);
|
|
515
|
+
const originalSnapshot = { files: { 'test.txt': 'original content' } };
|
|
516
|
+
const snapshotCopy = JSON.parse(JSON.stringify(originalSnapshot));
|
|
517
|
+
|
|
518
|
+
await replayCapsule(capsule, originalSnapshot);
|
|
519
|
+
|
|
520
|
+
// Original snapshot should be unchanged
|
|
521
|
+
expect(originalSnapshot).toEqual(snapshotCopy);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it('should include replay duration in receipt', async () => {
|
|
525
|
+
const data = {
|
|
526
|
+
inputs: { task: 'timing_test' },
|
|
527
|
+
tool_trace: [],
|
|
528
|
+
edits: [],
|
|
529
|
+
artifacts: [],
|
|
530
|
+
bounds: { start: 1000, end: 2000 },
|
|
531
|
+
o_hash_before: 'start',
|
|
532
|
+
o_hash_after: 'end',
|
|
533
|
+
receipts: [],
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
const capsule = new RunCapsule(data);
|
|
537
|
+
const o_snapshot = {};
|
|
538
|
+
|
|
539
|
+
const { result, receipt } = await replayCapsule(capsule, o_snapshot);
|
|
540
|
+
|
|
541
|
+
expect(receipt.replay_duration_ms).toBeDefined();
|
|
542
|
+
expect(receipt.replay_duration_ms).toBeGreaterThanOrEqual(0);
|
|
543
|
+
});
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
describe('listCapsules', () => {
|
|
547
|
+
it('should return empty array when no capsules exist', async () => {
|
|
548
|
+
const capsules = await listCapsules(TEST_CAPSULE_DIR);
|
|
549
|
+
expect(capsules).toEqual([]);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it('should list all stored capsules with hashes', async () => {
|
|
553
|
+
const testData = {
|
|
554
|
+
inputs: { test: 'capsule1' },
|
|
555
|
+
tool_trace: [],
|
|
556
|
+
edits: [],
|
|
557
|
+
artifacts: [],
|
|
558
|
+
bounds: { start: 1000, end: 2000 },
|
|
559
|
+
o_hash_before: 'a',
|
|
560
|
+
o_hash_after: 'b',
|
|
561
|
+
receipts: [],
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
const testCapsules = [
|
|
565
|
+
new RunCapsule(testData),
|
|
566
|
+
new RunCapsule({
|
|
567
|
+
inputs: { test: 'capsule2' },
|
|
568
|
+
tool_trace: [],
|
|
569
|
+
edits: [],
|
|
570
|
+
artifacts: [],
|
|
571
|
+
bounds: { start: 2000, end: 3000 },
|
|
572
|
+
o_hash_before: 'c',
|
|
573
|
+
o_hash_after: 'd',
|
|
574
|
+
receipts: [],
|
|
575
|
+
}),
|
|
576
|
+
];
|
|
577
|
+
|
|
578
|
+
for (const capsule of testCapsules) {
|
|
579
|
+
await storeCapsule(capsule, TEST_CAPSULE_DIR);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const capsules = await listCapsules(TEST_CAPSULE_DIR);
|
|
583
|
+
|
|
584
|
+
expect(capsules).toHaveLength(2);
|
|
585
|
+
expect(capsules[0]).toHaveProperty('hash');
|
|
586
|
+
expect(capsules[0]).toHaveProperty('stored_at');
|
|
587
|
+
expect(capsules[0]).toHaveProperty('inputs');
|
|
588
|
+
expect(capsules[0]).toHaveProperty('bounds');
|
|
589
|
+
|
|
590
|
+
const hashes = capsules.map((c) => c.hash);
|
|
591
|
+
expect(hashes).toContain(testCapsules[0].capsule_hash);
|
|
592
|
+
expect(hashes).toContain(testCapsules[1].capsule_hash);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it('should handle corrupt capsule files gracefully', async () => {
|
|
596
|
+
// Create a valid capsule first
|
|
597
|
+
const validCapsule = new RunCapsule({
|
|
598
|
+
inputs: { valid: true },
|
|
599
|
+
tool_trace: [],
|
|
600
|
+
edits: [],
|
|
601
|
+
artifacts: [],
|
|
602
|
+
bounds: { start: 1000, end: 2000 },
|
|
603
|
+
o_hash_before: 'a',
|
|
604
|
+
o_hash_after: 'b',
|
|
605
|
+
receipts: [],
|
|
606
|
+
});
|
|
607
|
+
await storeCapsule(validCapsule, TEST_CAPSULE_DIR);
|
|
608
|
+
|
|
609
|
+
// Create a corrupt file
|
|
610
|
+
const { writeFileSync } = await import('node:fs');
|
|
611
|
+
writeFileSync(
|
|
612
|
+
join(TEST_CAPSULE_DIR, 'corrupt.json'),
|
|
613
|
+
'invalid json {'
|
|
614
|
+
);
|
|
615
|
+
|
|
616
|
+
const capsules = await listCapsules(TEST_CAPSULE_DIR);
|
|
617
|
+
|
|
618
|
+
// Should still return valid capsule, skipping corrupt one
|
|
619
|
+
expect(capsules.length).toBeGreaterThanOrEqual(1);
|
|
620
|
+
expect(capsules.some((c) => c.hash === validCapsule.capsule_hash)).toBe(
|
|
621
|
+
true
|
|
622
|
+
);
|
|
623
|
+
});
|
|
624
|
+
});
|
|
625
|
+
});
|