@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,665 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Tests for multi-agent capsule merge and conflict resolution
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import {
|
|
7
|
+
shardMerge,
|
|
8
|
+
mergeCapsules,
|
|
9
|
+
ConflictDetector,
|
|
10
|
+
ConflictResolver,
|
|
11
|
+
} from '../src/merge.mjs';
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// Test Data Fixtures
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
const createCapsule = (id, o_hash, file_edits) => ({
|
|
18
|
+
id,
|
|
19
|
+
o_hash,
|
|
20
|
+
file_edits,
|
|
21
|
+
metadata: { created_at: new Date().toISOString() },
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const createFileEdit = (file_path, line_start, line_end, content, operation = 'replace') => ({
|
|
25
|
+
file_path,
|
|
26
|
+
line_start,
|
|
27
|
+
line_end,
|
|
28
|
+
content,
|
|
29
|
+
operation,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const defaultTotalOrder = {
|
|
33
|
+
rules: [],
|
|
34
|
+
default_rule: {
|
|
35
|
+
strategy: 'earlier_wins',
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// ============================================================================
|
|
40
|
+
// ConflictDetector Tests
|
|
41
|
+
// ============================================================================
|
|
42
|
+
|
|
43
|
+
describe('ConflictDetector', () => {
|
|
44
|
+
describe('rangesOverlap', () => {
|
|
45
|
+
it('should detect overlapping ranges', () => {
|
|
46
|
+
expect(ConflictDetector.rangesOverlap(1, 5, 3, 7)).toBe(true);
|
|
47
|
+
expect(ConflictDetector.rangesOverlap(1, 5, 5, 10)).toBe(true);
|
|
48
|
+
expect(ConflictDetector.rangesOverlap(3, 7, 1, 5)).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should detect non-overlapping ranges', () => {
|
|
52
|
+
expect(ConflictDetector.rangesOverlap(1, 5, 6, 10)).toBe(false);
|
|
53
|
+
expect(ConflictDetector.rangesOverlap(6, 10, 1, 5)).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should handle edge cases', () => {
|
|
57
|
+
expect(ConflictDetector.rangesOverlap(1, 5, 1, 5)).toBe(true);
|
|
58
|
+
expect(ConflictDetector.rangesOverlap(1, 1, 1, 1)).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('detectConflicts', () => {
|
|
63
|
+
it('should detect no conflicts when ranges do not overlap', () => {
|
|
64
|
+
const capsules = [
|
|
65
|
+
createCapsule('c1', 'hash1', [
|
|
66
|
+
createFileEdit('file.js', 1, 5, 'content1'),
|
|
67
|
+
]),
|
|
68
|
+
createCapsule('c2', 'hash2', [
|
|
69
|
+
createFileEdit('file.js', 10, 15, 'content2'),
|
|
70
|
+
]),
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
const conflicts = ConflictDetector.detectConflicts(capsules);
|
|
74
|
+
expect(conflicts).toHaveLength(0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should detect conflict when two capsules edit same file region', () => {
|
|
78
|
+
const capsules = [
|
|
79
|
+
createCapsule('c1', 'hash1', [
|
|
80
|
+
createFileEdit('file.js', 1, 10, 'content1'),
|
|
81
|
+
]),
|
|
82
|
+
createCapsule('c2', 'hash2', [
|
|
83
|
+
createFileEdit('file.js', 5, 15, 'content2'),
|
|
84
|
+
]),
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
const conflicts = ConflictDetector.detectConflicts(capsules);
|
|
88
|
+
expect(conflicts).toHaveLength(1);
|
|
89
|
+
expect(conflicts[0].file_path).toBe('file.js');
|
|
90
|
+
expect(conflicts[0].overlapping_edits).toHaveLength(2);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should detect conflicts in different files independently', () => {
|
|
94
|
+
const capsules = [
|
|
95
|
+
createCapsule('c1', 'hash1', [
|
|
96
|
+
createFileEdit('file1.js', 1, 10, 'content1'),
|
|
97
|
+
createFileEdit('file2.js', 1, 10, 'content2'),
|
|
98
|
+
]),
|
|
99
|
+
createCapsule('c2', 'hash2', [
|
|
100
|
+
createFileEdit('file1.js', 5, 15, 'content3'),
|
|
101
|
+
createFileEdit('file3.js', 1, 10, 'content4'),
|
|
102
|
+
]),
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
const conflicts = ConflictDetector.detectConflicts(capsules);
|
|
106
|
+
expect(conflicts).toHaveLength(1);
|
|
107
|
+
expect(conflicts[0].file_path).toBe('file1.js');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('createReceipt', () => {
|
|
112
|
+
it('should create valid conflict receipt', () => {
|
|
113
|
+
const conflict = {
|
|
114
|
+
file_path: 'test.js',
|
|
115
|
+
line_start: 1,
|
|
116
|
+
line_end: 10,
|
|
117
|
+
overlapping_edits: [
|
|
118
|
+
{ capsule_id: 'c1', edit: createFileEdit('test.js', 1, 5, 'content1') },
|
|
119
|
+
{ capsule_id: 'c2', edit: createFileEdit('test.js', 3, 10, 'content2') },
|
|
120
|
+
],
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const receipt = ConflictDetector.createReceipt(
|
|
124
|
+
conflict,
|
|
125
|
+
'earlier_wins',
|
|
126
|
+
'c1',
|
|
127
|
+
['c2']
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
expect(receipt).toMatchObject({
|
|
131
|
+
file_path: 'test.js',
|
|
132
|
+
capsules_involved: ['c1', 'c2'],
|
|
133
|
+
resolution_rule: 'earlier_wins',
|
|
134
|
+
winner: 'c1',
|
|
135
|
+
denied: ['c2'],
|
|
136
|
+
});
|
|
137
|
+
expect(receipt.conflict_id).toBeDefined();
|
|
138
|
+
expect(receipt.timestamp).toBeDefined();
|
|
139
|
+
expect(receipt.line_ranges).toHaveLength(2);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// ============================================================================
|
|
145
|
+
// ConflictResolver Tests
|
|
146
|
+
// ============================================================================
|
|
147
|
+
|
|
148
|
+
describe('ConflictResolver', () => {
|
|
149
|
+
const createConflict = (edits) => ({
|
|
150
|
+
file_path: 'test.js',
|
|
151
|
+
line_start: 1,
|
|
152
|
+
line_end: 10,
|
|
153
|
+
overlapping_edits: edits,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('earlierWins', () => {
|
|
157
|
+
it('should select capsule with earliest o_hash', () => {
|
|
158
|
+
const capsules = [
|
|
159
|
+
createCapsule('c1', 'hash_c', []),
|
|
160
|
+
createCapsule('c2', 'hash_a', []),
|
|
161
|
+
createCapsule('c3', 'hash_b', []),
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
const conflict = createConflict([
|
|
165
|
+
{ capsule_id: 'c1', edit: createFileEdit('test.js', 1, 5, 'content1') },
|
|
166
|
+
{ capsule_id: 'c2', edit: createFileEdit('test.js', 3, 7, 'content2') },
|
|
167
|
+
{ capsule_id: 'c3', edit: createFileEdit('test.js', 5, 10, 'content3') },
|
|
168
|
+
]);
|
|
169
|
+
|
|
170
|
+
const result = ConflictResolver.earlierWins(conflict, capsules);
|
|
171
|
+
expect(result.winner).toBe('c2'); // hash_a is earliest
|
|
172
|
+
expect(result.denied).toContain('c1');
|
|
173
|
+
expect(result.denied).toContain('c3');
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe('laterWins', () => {
|
|
178
|
+
it('should select capsule with latest o_hash', () => {
|
|
179
|
+
const capsules = [
|
|
180
|
+
createCapsule('c1', 'hash_a', []),
|
|
181
|
+
createCapsule('c2', 'hash_c', []),
|
|
182
|
+
createCapsule('c3', 'hash_b', []),
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
const conflict = createConflict([
|
|
186
|
+
{ capsule_id: 'c1', edit: createFileEdit('test.js', 1, 5, 'content1') },
|
|
187
|
+
{ capsule_id: 'c2', edit: createFileEdit('test.js', 3, 7, 'content2') },
|
|
188
|
+
]);
|
|
189
|
+
|
|
190
|
+
const result = ConflictResolver.laterWins(conflict, capsules);
|
|
191
|
+
expect(result.winner).toBe('c2'); // hash_c is latest
|
|
192
|
+
expect(result.denied).toContain('c1');
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe('lexicographic', () => {
|
|
197
|
+
it('should select capsule with lexicographically first id', () => {
|
|
198
|
+
const conflict = createConflict([
|
|
199
|
+
{ capsule_id: 'capsule_3', edit: createFileEdit('test.js', 1, 5, 'content1') },
|
|
200
|
+
{ capsule_id: 'capsule_1', edit: createFileEdit('test.js', 3, 7, 'content2') },
|
|
201
|
+
{ capsule_id: 'capsule_2', edit: createFileEdit('test.js', 5, 10, 'content3') },
|
|
202
|
+
]);
|
|
203
|
+
|
|
204
|
+
const result = ConflictResolver.lexicographic(conflict);
|
|
205
|
+
expect(result.winner).toBe('capsule_1');
|
|
206
|
+
expect(result.denied).toEqual(['capsule_2', 'capsule_3']);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('resolveConflict', () => {
|
|
211
|
+
it('should apply specified strategy', () => {
|
|
212
|
+
const capsules = [
|
|
213
|
+
createCapsule('c1', 'hash_b', []),
|
|
214
|
+
createCapsule('c2', 'hash_a', []),
|
|
215
|
+
];
|
|
216
|
+
|
|
217
|
+
const conflict = createConflict([
|
|
218
|
+
{ capsule_id: 'c1', edit: createFileEdit('test.js', 1, 5, 'content1') },
|
|
219
|
+
{ capsule_id: 'c2', edit: createFileEdit('test.js', 3, 7, 'content2') },
|
|
220
|
+
]);
|
|
221
|
+
|
|
222
|
+
const rule = { strategy: 'earlier_wins' };
|
|
223
|
+
const result = ConflictResolver.resolveConflict(conflict, rule, capsules);
|
|
224
|
+
|
|
225
|
+
expect(result.winner).toBe('c2');
|
|
226
|
+
expect(result.rule).toBe('earlier_wins');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('should handle merge_all strategy', () => {
|
|
230
|
+
const capsules = [
|
|
231
|
+
createCapsule('c1', 'hash_a', []),
|
|
232
|
+
createCapsule('c2', 'hash_b', []),
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
const conflict = createConflict([
|
|
236
|
+
{ capsule_id: 'c1', edit: createFileEdit('test.js', 1, 5, 'content1') },
|
|
237
|
+
{ capsule_id: 'c2', edit: createFileEdit('test.js', 3, 7, 'content2') },
|
|
238
|
+
]);
|
|
239
|
+
|
|
240
|
+
const rule = { strategy: 'merge_all' };
|
|
241
|
+
const result = ConflictResolver.resolveConflict(conflict, rule, capsules);
|
|
242
|
+
|
|
243
|
+
expect(result.winner).toBeNull();
|
|
244
|
+
expect(result.denied).toEqual([]);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// ============================================================================
|
|
250
|
+
// shardMerge Tests
|
|
251
|
+
// ============================================================================
|
|
252
|
+
|
|
253
|
+
describe('shardMerge', () => {
|
|
254
|
+
it('should merge capsules with no conflicts', () => {
|
|
255
|
+
const capsules = [
|
|
256
|
+
createCapsule('c1', 'hash1', [
|
|
257
|
+
createFileEdit('file1.js', 1, 5, 'content1'),
|
|
258
|
+
]),
|
|
259
|
+
createCapsule('c2', 'hash2', [
|
|
260
|
+
createFileEdit('file2.js', 1, 5, 'content2'),
|
|
261
|
+
]),
|
|
262
|
+
];
|
|
263
|
+
|
|
264
|
+
const result = shardMerge(capsules, defaultTotalOrder);
|
|
265
|
+
|
|
266
|
+
expect(result.merged_state).toBeDefined();
|
|
267
|
+
expect(Object.keys(result.merged_state)).toHaveLength(2);
|
|
268
|
+
expect(result.conflict_receipts).toHaveLength(0);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('should handle two-capsule merge with conflict', () => {
|
|
272
|
+
const capsules = [
|
|
273
|
+
createCapsule('c1', 'hash_b', [
|
|
274
|
+
createFileEdit('file.js', 1, 10, 'content1'),
|
|
275
|
+
]),
|
|
276
|
+
createCapsule('c2', 'hash_a', [
|
|
277
|
+
createFileEdit('file.js', 5, 15, 'content2'),
|
|
278
|
+
]),
|
|
279
|
+
];
|
|
280
|
+
|
|
281
|
+
const result = shardMerge(capsules, defaultTotalOrder);
|
|
282
|
+
|
|
283
|
+
expect(result.conflict_receipts).toHaveLength(1);
|
|
284
|
+
expect(result.conflict_receipts[0].winner).toBe('c2'); // hash_a wins
|
|
285
|
+
expect(result.conflict_receipts[0].denied).toContain('c1');
|
|
286
|
+
expect(Object.keys(result.merged_state)).toHaveLength(1);
|
|
287
|
+
expect(result.merged_state.c2).toBeDefined();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('should handle three-capsule merge (multi-way conflict)', () => {
|
|
291
|
+
const capsules = [
|
|
292
|
+
createCapsule('c1', 'hash_c', [
|
|
293
|
+
createFileEdit('file.js', 1, 10, 'content1'),
|
|
294
|
+
]),
|
|
295
|
+
createCapsule('c2', 'hash_a', [
|
|
296
|
+
createFileEdit('file.js', 5, 15, 'content2'),
|
|
297
|
+
]),
|
|
298
|
+
createCapsule('c3', 'hash_b', [
|
|
299
|
+
createFileEdit('file.js', 7, 20, 'content3'),
|
|
300
|
+
]),
|
|
301
|
+
];
|
|
302
|
+
|
|
303
|
+
const result = shardMerge(capsules, defaultTotalOrder);
|
|
304
|
+
|
|
305
|
+
expect(result.conflict_receipts).toHaveLength(1);
|
|
306
|
+
expect(result.conflict_receipts[0].capsules_involved).toHaveLength(3);
|
|
307
|
+
expect(result.conflict_receipts[0].winner).toBe('c2'); // hash_a wins
|
|
308
|
+
expect(result.conflict_receipts[0].denied).toContain('c1');
|
|
309
|
+
expect(result.conflict_receipts[0].denied).toContain('c3');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('should produce deterministic ordering', () => {
|
|
313
|
+
const capsules = [
|
|
314
|
+
createCapsule('c1', 'hash_b', [
|
|
315
|
+
createFileEdit('file.js', 1, 10, 'content1'),
|
|
316
|
+
]),
|
|
317
|
+
createCapsule('c2', 'hash_a', [
|
|
318
|
+
createFileEdit('file.js', 5, 15, 'content2'),
|
|
319
|
+
]),
|
|
320
|
+
];
|
|
321
|
+
|
|
322
|
+
// Run multiple times
|
|
323
|
+
const result1 = shardMerge(capsules, defaultTotalOrder);
|
|
324
|
+
const result2 = shardMerge(capsules, defaultTotalOrder);
|
|
325
|
+
const result3 = shardMerge(capsules, defaultTotalOrder);
|
|
326
|
+
|
|
327
|
+
expect(result1.conflict_receipts[0].winner).toBe(result2.conflict_receipts[0].winner);
|
|
328
|
+
expect(result2.conflict_receipts[0].winner).toBe(result3.conflict_receipts[0].winner);
|
|
329
|
+
expect(result1.conflict_receipts[0].winner).toBe('c2');
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// ============================================================================
|
|
334
|
+
// mergeCapsules Tests
|
|
335
|
+
// ============================================================================
|
|
336
|
+
|
|
337
|
+
describe('mergeCapsules', () => {
|
|
338
|
+
it('should merge capsules with no conflicts', () => {
|
|
339
|
+
const capsules = [
|
|
340
|
+
createCapsule('c1', 'hash1', [
|
|
341
|
+
createFileEdit('file1.js', 1, 5, 'content1'),
|
|
342
|
+
]),
|
|
343
|
+
createCapsule('c2', 'hash2', [
|
|
344
|
+
createFileEdit('file2.js', 1, 5, 'content2'),
|
|
345
|
+
]),
|
|
346
|
+
];
|
|
347
|
+
|
|
348
|
+
const result = mergeCapsules(capsules, defaultTotalOrder);
|
|
349
|
+
|
|
350
|
+
expect(result.admitted).toEqual(['c1', 'c2']);
|
|
351
|
+
expect(result.denied).toEqual([]);
|
|
352
|
+
expect(result.conflict_receipts).toHaveLength(0);
|
|
353
|
+
expect(result.merged_state).toBeDefined();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('should handle two-capsule conflict', () => {
|
|
357
|
+
const capsules = [
|
|
358
|
+
createCapsule('c1', 'hash_b', [
|
|
359
|
+
createFileEdit('file.js', 1, 10, 'content1'),
|
|
360
|
+
]),
|
|
361
|
+
createCapsule('c2', 'hash_a', [
|
|
362
|
+
createFileEdit('file.js', 5, 15, 'content2'),
|
|
363
|
+
]),
|
|
364
|
+
];
|
|
365
|
+
|
|
366
|
+
const result = mergeCapsules(capsules, defaultTotalOrder);
|
|
367
|
+
|
|
368
|
+
expect(result.admitted).toEqual(['c2']);
|
|
369
|
+
expect(result.denied).toEqual(['c1']);
|
|
370
|
+
expect(result.conflict_receipts).toHaveLength(1);
|
|
371
|
+
expect(result.conflict_receipts[0].winner).toBe('c2');
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('should handle three-capsule multi-way conflict', () => {
|
|
375
|
+
const capsules = [
|
|
376
|
+
createCapsule('c1', 'hash_c', [
|
|
377
|
+
createFileEdit('file.js', 1, 10, 'content1'),
|
|
378
|
+
]),
|
|
379
|
+
createCapsule('c2', 'hash_a', [
|
|
380
|
+
createFileEdit('file.js', 5, 15, 'content2'),
|
|
381
|
+
]),
|
|
382
|
+
createCapsule('c3', 'hash_b', [
|
|
383
|
+
createFileEdit('file.js', 7, 20, 'content3'),
|
|
384
|
+
]),
|
|
385
|
+
];
|
|
386
|
+
|
|
387
|
+
const result = mergeCapsules(capsules, defaultTotalOrder);
|
|
388
|
+
|
|
389
|
+
expect(result.admitted).toEqual(['c2']);
|
|
390
|
+
expect(result.denied).toContain('c1');
|
|
391
|
+
expect(result.denied).toContain('c3');
|
|
392
|
+
expect(result.conflict_receipts).toHaveLength(1);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it('should generate conflict receipts for each conflict', () => {
|
|
396
|
+
const capsules = [
|
|
397
|
+
createCapsule('c1', 'hash_b', [
|
|
398
|
+
createFileEdit('file1.js', 1, 10, 'content1'),
|
|
399
|
+
createFileEdit('file2.js', 1, 10, 'content2'),
|
|
400
|
+
]),
|
|
401
|
+
createCapsule('c2', 'hash_a', [
|
|
402
|
+
createFileEdit('file1.js', 5, 15, 'content3'),
|
|
403
|
+
createFileEdit('file2.js', 5, 15, 'content4'),
|
|
404
|
+
]),
|
|
405
|
+
];
|
|
406
|
+
|
|
407
|
+
const result = mergeCapsules(capsules, defaultTotalOrder);
|
|
408
|
+
|
|
409
|
+
expect(result.conflict_receipts).toHaveLength(2);
|
|
410
|
+
expect(result.conflict_receipts[0].file_path).not.toBe(
|
|
411
|
+
result.conflict_receipts[1].file_path
|
|
412
|
+
);
|
|
413
|
+
expect(result.conflict_receipts.every((r) => r.winner === 'c2')).toBe(true);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it('should enforce deterministic ordering across multiple runs', () => {
|
|
417
|
+
const capsules = [
|
|
418
|
+
createCapsule('capsule_3', 'hash_c', [
|
|
419
|
+
createFileEdit('file.js', 1, 10, 'content1'),
|
|
420
|
+
]),
|
|
421
|
+
createCapsule('capsule_1', 'hash_a', [
|
|
422
|
+
createFileEdit('file.js', 5, 15, 'content2'),
|
|
423
|
+
]),
|
|
424
|
+
createCapsule('capsule_2', 'hash_b', [
|
|
425
|
+
createFileEdit('file.js', 7, 20, 'content3'),
|
|
426
|
+
]),
|
|
427
|
+
];
|
|
428
|
+
|
|
429
|
+
const result1 = mergeCapsules(capsules, defaultTotalOrder);
|
|
430
|
+
const result2 = mergeCapsules(capsules, defaultTotalOrder);
|
|
431
|
+
const result3 = mergeCapsules(capsules, defaultTotalOrder);
|
|
432
|
+
|
|
433
|
+
expect(result1.admitted).toEqual(result2.admitted);
|
|
434
|
+
expect(result2.admitted).toEqual(result3.admitted);
|
|
435
|
+
expect(result1.denied.sort()).toEqual(result2.denied.sort());
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('should use lexicographic strategy when specified', () => {
|
|
439
|
+
const totalOrder = {
|
|
440
|
+
rules: [],
|
|
441
|
+
default_rule: { strategy: 'lexicographic' },
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
const capsules = [
|
|
445
|
+
createCapsule('capsule_z', 'hash_a', [
|
|
446
|
+
createFileEdit('file.js', 1, 10, 'content1'),
|
|
447
|
+
]),
|
|
448
|
+
createCapsule('capsule_a', 'hash_z', [
|
|
449
|
+
createFileEdit('file.js', 5, 15, 'content2'),
|
|
450
|
+
]),
|
|
451
|
+
];
|
|
452
|
+
|
|
453
|
+
const result = mergeCapsules(capsules, totalOrder);
|
|
454
|
+
|
|
455
|
+
expect(result.admitted).toEqual(['capsule_a']);
|
|
456
|
+
expect(result.denied).toEqual(['capsule_z']);
|
|
457
|
+
expect(result.conflict_receipts[0].resolution_rule).toBe('lexicographic');
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('should handle validation errors gracefully', () => {
|
|
461
|
+
const invalidCapsules = [
|
|
462
|
+
{ id: 'c1' }, // Missing required fields
|
|
463
|
+
];
|
|
464
|
+
|
|
465
|
+
expect(() => {
|
|
466
|
+
mergeCapsules(invalidCapsules, defaultTotalOrder);
|
|
467
|
+
}).toThrow();
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('should preserve metadata in merged state', () => {
|
|
471
|
+
const capsules = [
|
|
472
|
+
createCapsule('c1', 'hash1', [
|
|
473
|
+
createFileEdit('file.js', 1, 5, 'content1'),
|
|
474
|
+
]),
|
|
475
|
+
];
|
|
476
|
+
capsules[0].metadata = { custom_field: 'value' };
|
|
477
|
+
|
|
478
|
+
const result = mergeCapsules(capsules, defaultTotalOrder);
|
|
479
|
+
|
|
480
|
+
expect(result.merged_state.c1.metadata).toEqual({ custom_field: 'value' });
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// ============================================================================
|
|
485
|
+
// Integration Tests
|
|
486
|
+
// ============================================================================
|
|
487
|
+
|
|
488
|
+
describe('Integration Tests', () => {
|
|
489
|
+
it('should handle complex multi-file, multi-capsule scenario', () => {
|
|
490
|
+
const capsules = [
|
|
491
|
+
createCapsule('agent1', 'hash_1', [
|
|
492
|
+
createFileEdit('src/main.js', 1, 10, 'agent1 main changes'),
|
|
493
|
+
createFileEdit('src/utils.js', 20, 30, 'agent1 utils changes'),
|
|
494
|
+
]),
|
|
495
|
+
createCapsule('agent2', 'hash_2', [
|
|
496
|
+
createFileEdit('src/main.js', 5, 15, 'agent2 main changes'),
|
|
497
|
+
createFileEdit('src/config.js', 1, 5, 'agent2 config changes'),
|
|
498
|
+
]),
|
|
499
|
+
createCapsule('agent3', 'hash_3', [
|
|
500
|
+
createFileEdit('src/utils.js', 25, 35, 'agent3 utils changes'),
|
|
501
|
+
createFileEdit('src/tests.js', 1, 20, 'agent3 test changes'),
|
|
502
|
+
]),
|
|
503
|
+
];
|
|
504
|
+
|
|
505
|
+
const result = mergeCapsules(capsules, defaultTotalOrder);
|
|
506
|
+
|
|
507
|
+
expect(result.admitted.length + result.denied.length).toBe(3);
|
|
508
|
+
expect(result.conflict_receipts.length).toBeGreaterThan(0);
|
|
509
|
+
expect(result.merged_state).toBeDefined();
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it('should handle empty capsule list', () => {
|
|
513
|
+
const result = mergeCapsules([], defaultTotalOrder);
|
|
514
|
+
|
|
515
|
+
expect(result.admitted).toEqual([]);
|
|
516
|
+
expect(result.denied).toEqual([]);
|
|
517
|
+
expect(result.conflict_receipts).toEqual([]);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it('should handle single capsule (no conflicts possible)', () => {
|
|
521
|
+
const capsules = [
|
|
522
|
+
createCapsule('c1', 'hash1', [
|
|
523
|
+
createFileEdit('file.js', 1, 10, 'content'),
|
|
524
|
+
]),
|
|
525
|
+
];
|
|
526
|
+
|
|
527
|
+
const result = mergeCapsules(capsules, defaultTotalOrder);
|
|
528
|
+
|
|
529
|
+
expect(result.admitted).toEqual(['c1']);
|
|
530
|
+
expect(result.denied).toEqual([]);
|
|
531
|
+
expect(result.conflict_receipts).toEqual([]);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
describe('merge_all strategy', () => {
|
|
535
|
+
it('should admit all capsules in 2-way conflict with merge_all', () => {
|
|
536
|
+
const totalOrder = {
|
|
537
|
+
rules: [],
|
|
538
|
+
default_rule: { strategy: 'merge_all' },
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
const capsules = [
|
|
542
|
+
createCapsule('c1', 'hash_b', [
|
|
543
|
+
createFileEdit('file.js', 1, 10, 'content1'),
|
|
544
|
+
]),
|
|
545
|
+
createCapsule('c2', 'hash_a', [
|
|
546
|
+
createFileEdit('file.js', 5, 15, 'content2'),
|
|
547
|
+
]),
|
|
548
|
+
];
|
|
549
|
+
|
|
550
|
+
const result = mergeCapsules(capsules, totalOrder);
|
|
551
|
+
|
|
552
|
+
// Both capsules should be admitted
|
|
553
|
+
expect(result.admitted).toEqual(['c1', 'c2']);
|
|
554
|
+
expect(result.denied).toEqual([]);
|
|
555
|
+
|
|
556
|
+
// Should still emit conflict receipt as notice
|
|
557
|
+
expect(result.conflict_receipts).toHaveLength(1);
|
|
558
|
+
expect(result.conflict_receipts[0].resolution_rule).toBe('merge_all');
|
|
559
|
+
|
|
560
|
+
// Merged state should contain both capsules
|
|
561
|
+
expect(Object.keys(result.merged_state)).toHaveLength(2);
|
|
562
|
+
expect(result.merged_state.c1).toBeDefined();
|
|
563
|
+
expect(result.merged_state.c2).toBeDefined();
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it('should admit all capsules in 3-way conflict with merge_all', () => {
|
|
567
|
+
const totalOrder = {
|
|
568
|
+
rules: [],
|
|
569
|
+
default_rule: { strategy: 'merge_all' },
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
const capsules = [
|
|
573
|
+
createCapsule('c1', 'hash_c', [
|
|
574
|
+
createFileEdit('file.js', 1, 10, 'content1'),
|
|
575
|
+
]),
|
|
576
|
+
createCapsule('c2', 'hash_a', [
|
|
577
|
+
createFileEdit('file.js', 5, 15, 'content2'),
|
|
578
|
+
]),
|
|
579
|
+
createCapsule('c3', 'hash_b', [
|
|
580
|
+
createFileEdit('file.js', 7, 20, 'content3'),
|
|
581
|
+
]),
|
|
582
|
+
];
|
|
583
|
+
|
|
584
|
+
const result = mergeCapsules(capsules, totalOrder);
|
|
585
|
+
|
|
586
|
+
// All three capsules should be admitted
|
|
587
|
+
expect(result.admitted).toEqual(['c1', 'c2', 'c3']);
|
|
588
|
+
expect(result.denied).toEqual([]);
|
|
589
|
+
|
|
590
|
+
// Should emit conflict receipt
|
|
591
|
+
expect(result.conflict_receipts).toHaveLength(1);
|
|
592
|
+
expect(result.conflict_receipts[0].capsules_involved).toHaveLength(3);
|
|
593
|
+
|
|
594
|
+
// All capsules in merged state
|
|
595
|
+
expect(Object.keys(result.merged_state)).toHaveLength(3);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it('should emit multiple conflict receipts for multiple conflicts with merge_all', () => {
|
|
599
|
+
const totalOrder = {
|
|
600
|
+
rules: [],
|
|
601
|
+
default_rule: { strategy: 'merge_all' },
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
const capsules = [
|
|
605
|
+
createCapsule('c1', 'hash_a', [
|
|
606
|
+
createFileEdit('file1.js', 1, 10, 'content1'),
|
|
607
|
+
createFileEdit('file2.js', 1, 10, 'content2'),
|
|
608
|
+
]),
|
|
609
|
+
createCapsule('c2', 'hash_b', [
|
|
610
|
+
createFileEdit('file1.js', 5, 15, 'content3'),
|
|
611
|
+
createFileEdit('file2.js', 5, 15, 'content4'),
|
|
612
|
+
]),
|
|
613
|
+
];
|
|
614
|
+
|
|
615
|
+
const result = mergeCapsules(capsules, totalOrder);
|
|
616
|
+
|
|
617
|
+
// Both admitted
|
|
618
|
+
expect(result.admitted).toEqual(['c1', 'c2']);
|
|
619
|
+
expect(result.denied).toEqual([]);
|
|
620
|
+
|
|
621
|
+
// Two conflict receipts (one per file)
|
|
622
|
+
expect(result.conflict_receipts).toHaveLength(2);
|
|
623
|
+
|
|
624
|
+
// All in merged state
|
|
625
|
+
expect(Object.keys(result.merged_state)).toHaveLength(2);
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it('should handle merge_all with partial conflicts (some files overlap, some don\'t)', () => {
|
|
629
|
+
const totalOrder = {
|
|
630
|
+
rules: [],
|
|
631
|
+
default_rule: { strategy: 'merge_all' },
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
const capsules = [
|
|
635
|
+
createCapsule('c1', 'hash_a', [
|
|
636
|
+
createFileEdit('file1.js', 1, 10, 'content1'),
|
|
637
|
+
createFileEdit('file2.js', 1, 10, 'content2'),
|
|
638
|
+
]),
|
|
639
|
+
createCapsule('c2', 'hash_b', [
|
|
640
|
+
createFileEdit('file1.js', 5, 15, 'content3'), // Conflicts with c1
|
|
641
|
+
createFileEdit('file3.js', 1, 10, 'content4'), // No conflict
|
|
642
|
+
]),
|
|
643
|
+
createCapsule('c3', 'hash_c', [
|
|
644
|
+
createFileEdit('file4.js', 1, 10, 'content5'), // No conflict
|
|
645
|
+
]),
|
|
646
|
+
];
|
|
647
|
+
|
|
648
|
+
const result = mergeCapsules(capsules, totalOrder);
|
|
649
|
+
|
|
650
|
+
// All admitted
|
|
651
|
+
expect(result.admitted).toEqual(['c1', 'c2', 'c3']);
|
|
652
|
+
expect(result.denied).toEqual([]);
|
|
653
|
+
|
|
654
|
+
// Only one conflict (file1.js between c1 and c2)
|
|
655
|
+
expect(result.conflict_receipts).toHaveLength(1);
|
|
656
|
+
expect(result.conflict_receipts[0].file_path).toBe('file1.js');
|
|
657
|
+
|
|
658
|
+
// All in merged state
|
|
659
|
+
expect(Object.keys(result.merged_state)).toHaveLength(3);
|
|
660
|
+
expect(result.merged_state.c1.file_edits).toHaveLength(2);
|
|
661
|
+
expect(result.merged_state.c2.file_edits).toHaveLength(2);
|
|
662
|
+
expect(result.merged_state.c3.file_edits).toHaveLength(1);
|
|
663
|
+
});
|
|
664
|
+
});
|
|
665
|
+
});
|