@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.
Files changed (70) hide show
  1. package/IMPLEMENTATION_SUMMARY.json +150 -0
  2. package/PLUGIN_SYSTEM_SUMMARY.json +149 -0
  3. package/README.md +98 -0
  4. package/TRANSACTION_IMPLEMENTATION.json +119 -0
  5. package/capability-map.md +93 -0
  6. package/docs/api-stability.md +269 -0
  7. package/docs/extensions/plugin-development.md +382 -0
  8. package/package.json +40 -0
  9. package/plugins/registry.json +35 -0
  10. package/src/admission-gate.mjs +414 -0
  11. package/src/api-version.mjs +373 -0
  12. package/src/atomic-admission.mjs +310 -0
  13. package/src/bounds.mjs +289 -0
  14. package/src/bulkhead-manager.mjs +280 -0
  15. package/src/capsule.mjs +524 -0
  16. package/src/crdt.mjs +361 -0
  17. package/src/enhanced-bounds.mjs +614 -0
  18. package/src/executor.mjs +73 -0
  19. package/src/freeze-restore.mjs +521 -0
  20. package/src/index.mjs +62 -0
  21. package/src/materialized-views.mjs +371 -0
  22. package/src/merge.mjs +472 -0
  23. package/src/plugin-isolation.mjs +392 -0
  24. package/src/plugin-manager.mjs +441 -0
  25. package/src/projections-api.mjs +336 -0
  26. package/src/projections-cli.mjs +238 -0
  27. package/src/projections-docs.mjs +300 -0
  28. package/src/projections-ide.mjs +278 -0
  29. package/src/receipt.mjs +340 -0
  30. package/src/rollback.mjs +258 -0
  31. package/src/saga-orchestrator.mjs +355 -0
  32. package/src/schemas.mjs +1330 -0
  33. package/src/storage-optimization.mjs +359 -0
  34. package/src/tool-registry.mjs +272 -0
  35. package/src/transaction.mjs +466 -0
  36. package/src/validators.mjs +485 -0
  37. package/src/work-item.mjs +449 -0
  38. package/templates/plugin-template/README.md +58 -0
  39. package/templates/plugin-template/index.mjs +162 -0
  40. package/templates/plugin-template/plugin.json +19 -0
  41. package/test/admission-gate.test.mjs +583 -0
  42. package/test/api-version.test.mjs +74 -0
  43. package/test/atomic-admission.test.mjs +155 -0
  44. package/test/bounds.test.mjs +341 -0
  45. package/test/bulkhead-manager.test.mjs +236 -0
  46. package/test/capsule.test.mjs +625 -0
  47. package/test/crdt.test.mjs +215 -0
  48. package/test/enhanced-bounds.test.mjs +487 -0
  49. package/test/freeze-restore.test.mjs +472 -0
  50. package/test/materialized-views.test.mjs +243 -0
  51. package/test/merge.test.mjs +665 -0
  52. package/test/plugin-isolation.test.mjs +109 -0
  53. package/test/plugin-manager.test.mjs +208 -0
  54. package/test/projections-api.test.mjs +293 -0
  55. package/test/projections-cli.test.mjs +204 -0
  56. package/test/projections-docs.test.mjs +173 -0
  57. package/test/projections-ide.test.mjs +230 -0
  58. package/test/receipt.test.mjs +295 -0
  59. package/test/rollback.test.mjs +132 -0
  60. package/test/saga-orchestrator.test.mjs +279 -0
  61. package/test/schemas.test.mjs +716 -0
  62. package/test/storage-optimization.test.mjs +503 -0
  63. package/test/tool-registry.test.mjs +341 -0
  64. package/test/transaction.test.mjs +189 -0
  65. package/test/validators.test.mjs +463 -0
  66. package/test/work-item.test.mjs +548 -0
  67. package/test/work-item.test.mjs.bak +548 -0
  68. package/var/kgc/test-atomic-log.json +519 -0
  69. package/var/kgc/test-cascading-log.json +145 -0
  70. 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
+ });