@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
package/src/merge.mjs ADDED
@@ -0,0 +1,472 @@
1
+ /**
2
+ * @fileoverview Multi-agent capsule merge and conflict resolution system
3
+ * Implements deterministic merge strategies for concurrent agent operations
4
+ */
5
+
6
+ import { z } from 'zod';
7
+
8
+ // ============================================================================
9
+ // Schemas
10
+ // ============================================================================
11
+
12
+ const CapsuleSchema = z.object({
13
+ id: z.string(),
14
+ o_hash: z.string(),
15
+ file_edits: z.array(
16
+ z.object({
17
+ file_path: z.string(),
18
+ line_start: z.number(),
19
+ line_end: z.number(),
20
+ content: z.string(),
21
+ operation: z.enum(['insert', 'replace', 'delete']).default('replace'),
22
+ })
23
+ ),
24
+ metadata: z.object({}).passthrough().optional(),
25
+ });
26
+
27
+ const ConflictRuleSchema = z.object({
28
+ strategy: z.enum(['earlier_wins', 'later_wins', 'lexicographic', 'merge_all']),
29
+ priority_field: z.string().optional(),
30
+ });
31
+
32
+ const TotalOrderSchema = z.object({
33
+ rules: z.array(ConflictRuleSchema),
34
+ default_rule: ConflictRuleSchema,
35
+ });
36
+
37
+ const ConflictReceiptSchema = z.object({
38
+ conflict_id: z.string(),
39
+ file_path: z.string(),
40
+ capsules_involved: z.array(z.string()),
41
+ line_ranges: z.array(
42
+ z.object({
43
+ capsule_id: z.string(),
44
+ line_start: z.number(),
45
+ line_end: z.number(),
46
+ })
47
+ ),
48
+ resolution_rule: z.string(),
49
+ winner: z.string().optional(),
50
+ denied: z.array(z.string()).optional(),
51
+ timestamp: z.string(),
52
+ });
53
+
54
+ const MergeResultSchema = z.object({
55
+ admitted: z.array(z.string()),
56
+ denied: z.array(z.string()),
57
+ conflict_receipts: z.array(ConflictReceiptSchema),
58
+ merged_state: z.record(z.string(), z.any()).optional(),
59
+ });
60
+
61
+ // ============================================================================
62
+ // Type definitions (JSDoc)
63
+ // ============================================================================
64
+
65
+ /**
66
+ * @typedef {Object} FileEdit
67
+ * @property {string} file_path - Path to the file
68
+ * @property {number} line_start - Starting line number
69
+ * @property {number} line_end - Ending line number
70
+ * @property {string} content - Content to apply
71
+ * @property {'insert'|'replace'|'delete'} operation - Type of edit operation
72
+ */
73
+
74
+ /**
75
+ * @typedef {Object} Capsule
76
+ * @property {string} id - Unique capsule identifier
77
+ * @property {string} o_hash - Ordering hash for deterministic resolution
78
+ * @property {FileEdit[]} file_edits - Array of file edits
79
+ * @property {Object} [metadata] - Optional metadata
80
+ */
81
+
82
+ /**
83
+ * @typedef {Object} ConflictRule
84
+ * @property {'earlier_wins'|'later_wins'|'lexicographic'|'merge_all'} strategy - Resolution strategy
85
+ * @property {string} [priority_field] - Field to use for priority
86
+ */
87
+
88
+ /**
89
+ * @typedef {Object} TotalOrder
90
+ * @property {ConflictRule[]} rules - Array of resolution rules
91
+ * @property {ConflictRule} default_rule - Default resolution rule
92
+ */
93
+
94
+ /**
95
+ * @typedef {Object} ConflictRegion
96
+ * @property {string} file_path - Path to the file
97
+ * @property {number} line_start - Starting line of conflict
98
+ * @property {number} line_end - Ending line of conflict
99
+ * @property {Array<{capsule_id: string, edit: FileEdit}>} overlapping_edits - Conflicting edits
100
+ */
101
+
102
+ /**
103
+ * @typedef {Object} ConflictReceipt
104
+ * @property {string} conflict_id - Unique conflict identifier
105
+ * @property {string} file_path - Path to the conflicting file
106
+ * @property {string[]} capsules_involved - IDs of capsules in conflict
107
+ * @property {Array<{capsule_id: string, line_start: number, line_end: number}>} line_ranges - Line ranges for each capsule
108
+ * @property {string} resolution_rule - Rule applied to resolve
109
+ * @property {string} [winner] - ID of winning capsule
110
+ * @property {string[]} [denied] - IDs of denied capsules
111
+ * @property {string} timestamp - ISO timestamp of resolution
112
+ */
113
+
114
+ /**
115
+ * @typedef {Object} MergeResult
116
+ * @property {string[]} admitted - IDs of admitted capsules
117
+ * @property {string[]} denied - IDs of denied capsules
118
+ * @property {ConflictReceipt[]} conflict_receipts - Array of conflict receipts
119
+ * @property {Object} [merged_state] - Optional merged state object
120
+ */
121
+
122
+ // ============================================================================
123
+ // ConflictDetector
124
+ // ============================================================================
125
+
126
+ /**
127
+ * Detects conflicts between capsule file edits
128
+ */
129
+ export class ConflictDetector {
130
+ /**
131
+ * Check if two line ranges overlap
132
+ * @param {number} start1 - Start of first range
133
+ * @param {number} end1 - End of first range
134
+ * @param {number} start2 - Start of second range
135
+ * @param {number} end2 - End of second range
136
+ * @returns {boolean} True if ranges overlap
137
+ */
138
+ static rangesOverlap(start1, end1, start2, end2) {
139
+ return start1 <= end2 && start2 <= end1;
140
+ }
141
+
142
+ /**
143
+ * Detect conflicts in an array of capsules
144
+ * @param {Capsule[]} capsules - Array of capsules to check
145
+ * @returns {ConflictRegion[]} Array of detected conflicts
146
+ */
147
+ static detectConflicts(capsules) {
148
+ const conflicts = [];
149
+ const fileEditMap = new Map();
150
+
151
+ // Group edits by file path
152
+ for (const capsule of capsules) {
153
+ for (const edit of capsule.file_edits) {
154
+ if (!fileEditMap.has(edit.file_path)) {
155
+ fileEditMap.set(edit.file_path, []);
156
+ }
157
+ fileEditMap.get(edit.file_path).push({
158
+ capsule_id: capsule.id,
159
+ edit,
160
+ });
161
+ }
162
+ }
163
+
164
+ // Check for overlaps within each file
165
+ for (const [file_path, edits] of fileEditMap.entries()) {
166
+ if (edits.length < 2) continue;
167
+
168
+ for (let i = 0; i < edits.length; i++) {
169
+ const overlapping = [edits[i]];
170
+
171
+ for (let j = i + 1; j < edits.length; j++) {
172
+ if (
173
+ this.rangesOverlap(
174
+ edits[i].edit.line_start,
175
+ edits[i].edit.line_end,
176
+ edits[j].edit.line_start,
177
+ edits[j].edit.line_end
178
+ )
179
+ ) {
180
+ overlapping.push(edits[j]);
181
+ }
182
+ }
183
+
184
+ if (overlapping.length > 1) {
185
+ // Create conflict region
186
+ const line_start = Math.min(...overlapping.map((e) => e.edit.line_start));
187
+ const line_end = Math.max(...overlapping.map((e) => e.edit.line_end));
188
+
189
+ conflicts.push({
190
+ file_path,
191
+ line_start,
192
+ line_end,
193
+ overlapping_edits: overlapping,
194
+ });
195
+
196
+ // Skip already processed edits
197
+ i += overlapping.length - 1;
198
+ }
199
+ }
200
+ }
201
+
202
+ return conflicts;
203
+ }
204
+
205
+ /**
206
+ * Create a conflict receipt for a detected conflict
207
+ * @param {ConflictRegion} conflict - Detected conflict region
208
+ * @param {string} resolution_rule - Rule applied to resolve
209
+ * @param {string} [winner] - ID of winning capsule
210
+ * @param {string[]} [denied] - IDs of denied capsules
211
+ * @returns {ConflictReceipt} Conflict receipt
212
+ */
213
+ static createReceipt(conflict, resolution_rule, winner = null, denied = []) {
214
+ const capsules_involved = conflict.overlapping_edits.map((e) => e.capsule_id);
215
+
216
+ return {
217
+ conflict_id: `conflict_${conflict.file_path}_${conflict.line_start}_${Date.now()}`,
218
+ file_path: conflict.file_path,
219
+ capsules_involved,
220
+ line_ranges: conflict.overlapping_edits.map((e) => ({
221
+ capsule_id: e.capsule_id,
222
+ line_start: e.edit.line_start,
223
+ line_end: e.edit.line_end,
224
+ })),
225
+ resolution_rule,
226
+ winner,
227
+ denied,
228
+ timestamp: new Date().toISOString(),
229
+ };
230
+ }
231
+ }
232
+
233
+ // ============================================================================
234
+ // Resolution Strategies
235
+ // ============================================================================
236
+
237
+ /**
238
+ * Resolves conflicts using deterministic rules
239
+ */
240
+ export class ConflictResolver {
241
+ /**
242
+ * Apply earlier_wins strategy - earlier o_hash wins
243
+ * @param {ConflictRegion} conflict - Conflict to resolve
244
+ * @param {Capsule[]} capsules - All capsules
245
+ * @returns {{winner: string, denied: string[]}} Resolution result
246
+ */
247
+ static earlierWins(conflict, capsules) {
248
+ const capsuleMap = new Map(capsules.map((c) => [c.id, c]));
249
+ const involved = conflict.overlapping_edits
250
+ .map((e) => ({
251
+ id: e.capsule_id,
252
+ hash: capsuleMap.get(e.capsule_id)?.o_hash || '',
253
+ }))
254
+ .sort((a, b) => a.hash.localeCompare(b.hash));
255
+
256
+ return {
257
+ winner: involved[0].id,
258
+ denied: involved.slice(1).map((c) => c.id),
259
+ };
260
+ }
261
+
262
+ /**
263
+ * Apply lexicographic strategy - lexicographically first capsule_id wins
264
+ * @param {ConflictRegion} conflict - Conflict to resolve
265
+ * @returns {{winner: string, denied: string[]}} Resolution result
266
+ */
267
+ static lexicographic(conflict) {
268
+ const involved = conflict.overlapping_edits
269
+ .map((e) => e.capsule_id)
270
+ .sort((a, b) => a.localeCompare(b));
271
+
272
+ return {
273
+ winner: involved[0],
274
+ denied: involved.slice(1),
275
+ };
276
+ }
277
+
278
+ /**
279
+ * Apply later_wins strategy - later o_hash wins
280
+ * @param {ConflictRegion} conflict - Conflict to resolve
281
+ * @param {Capsule[]} capsules - All capsules
282
+ * @returns {{winner: string, denied: string[]}} Resolution result
283
+ */
284
+ static laterWins(conflict, capsules) {
285
+ const capsuleMap = new Map(capsules.map((c) => [c.id, c]));
286
+ const involved = conflict.overlapping_edits
287
+ .map((e) => ({
288
+ id: e.capsule_id,
289
+ hash: capsuleMap.get(e.capsule_id)?.o_hash || '',
290
+ }))
291
+ .sort((a, b) => b.hash.localeCompare(a.hash));
292
+
293
+ return {
294
+ winner: involved[0].id,
295
+ denied: involved.slice(1).map((c) => c.id),
296
+ };
297
+ }
298
+
299
+ /**
300
+ * Resolve conflict using specified rule
301
+ * @param {ConflictRegion} conflict - Conflict to resolve
302
+ * @param {ConflictRule} rule - Resolution rule
303
+ * @param {Capsule[]} capsules - All capsules
304
+ * @returns {{winner: string, denied: string[], rule: string}} Resolution result
305
+ */
306
+ static resolveConflict(conflict, rule, capsules) {
307
+ let result;
308
+
309
+ switch (rule.strategy) {
310
+ case 'earlier_wins':
311
+ result = this.earlierWins(conflict, capsules);
312
+ break;
313
+ case 'later_wins':
314
+ result = this.laterWins(conflict, capsules);
315
+ break;
316
+ case 'lexicographic':
317
+ result = this.lexicographic(conflict);
318
+ break;
319
+ case 'merge_all':
320
+ // Admit all capsules, no winner
321
+ return {
322
+ winner: null,
323
+ denied: [],
324
+ rule: rule.strategy,
325
+ };
326
+ default:
327
+ // Default to earlier_wins
328
+ result = this.earlierWins(conflict, capsules);
329
+ }
330
+
331
+ return {
332
+ ...result,
333
+ rule: rule.strategy,
334
+ };
335
+ }
336
+ }
337
+
338
+ // ============================================================================
339
+ // ShardMerge
340
+ // ============================================================================
341
+
342
+ /**
343
+ * Performs shard merge with deterministic conflict resolution
344
+ * @param {Capsule[]} capsules - Array of capsules to merge
345
+ * @param {TotalOrder} totalOrder - Ordering rules for resolution
346
+ * @returns {{merged_state: Object|null, conflict_receipts: ConflictReceipt[]}} Merge result
347
+ */
348
+ export function shardMerge(capsules, totalOrder) {
349
+ // Validate inputs
350
+ const validatedCapsules = z.array(CapsuleSchema).parse(capsules);
351
+ const validatedOrder = TotalOrderSchema.parse(totalOrder);
352
+
353
+ // Detect conflicts
354
+ const conflicts = ConflictDetector.detectConflicts(validatedCapsules);
355
+
356
+ if (conflicts.length === 0) {
357
+ // No conflicts - simple merge
358
+ const merged_state = {};
359
+ for (const capsule of validatedCapsules) {
360
+ merged_state[capsule.id] = {
361
+ file_edits: capsule.file_edits,
362
+ metadata: capsule.metadata,
363
+ };
364
+ }
365
+ return { merged_state, conflict_receipts: [] };
366
+ }
367
+
368
+ // Resolve conflicts
369
+ const conflict_receipts = [];
370
+ const denied_capsules = new Set();
371
+
372
+ for (const conflict of conflicts) {
373
+ const rule = validatedOrder.default_rule;
374
+ const resolution = ConflictResolver.resolveConflict(
375
+ conflict,
376
+ rule,
377
+ validatedCapsules
378
+ );
379
+
380
+ // For merge_all strategy, don't deny any capsules - just emit receipts
381
+ if (rule.strategy !== 'merge_all' && resolution.denied) {
382
+ resolution.denied.forEach((id) => denied_capsules.add(id));
383
+ }
384
+
385
+ // Create receipt
386
+ const receipt = ConflictDetector.createReceipt(
387
+ conflict,
388
+ resolution.rule,
389
+ resolution.winner,
390
+ resolution.denied
391
+ );
392
+ conflict_receipts.push(receipt);
393
+ }
394
+
395
+ // Build merged state
396
+ const merged_state = {};
397
+ for (const capsule of validatedCapsules) {
398
+ // For merge_all, include all capsules; otherwise only admitted
399
+ if (validatedOrder.default_rule.strategy === 'merge_all' || !denied_capsules.has(capsule.id)) {
400
+ merged_state[capsule.id] = {
401
+ file_edits: capsule.file_edits,
402
+ metadata: capsule.metadata,
403
+ };
404
+ }
405
+ }
406
+
407
+ return { merged_state, conflict_receipts };
408
+ }
409
+
410
+ // ============================================================================
411
+ // mergeCapsules - Main API
412
+ // ============================================================================
413
+
414
+ /**
415
+ * Merge multiple capsules with conflict resolution
416
+ * @param {Capsule[]} capsules - Array of capsules to merge
417
+ * @param {TotalOrder} totalOrder - Ordering rules for resolution
418
+ * @returns {MergeResult} Merge result with admitted/denied capsules and receipts
419
+ */
420
+ export function mergeCapsules(capsules, totalOrder) {
421
+ try {
422
+ // Validate inputs
423
+ const validatedCapsules = z.array(CapsuleSchema).parse(capsules);
424
+ const validatedOrder = TotalOrderSchema.parse(totalOrder);
425
+
426
+ // Perform shard merge
427
+ const { merged_state, conflict_receipts } = shardMerge(
428
+ validatedCapsules,
429
+ validatedOrder
430
+ );
431
+
432
+ // Determine admitted and denied
433
+ const admitted = [];
434
+ const denied = [];
435
+
436
+ const deniedSet = new Set();
437
+ for (const receipt of conflict_receipts) {
438
+ if (receipt.denied) {
439
+ receipt.denied.forEach((id) => deniedSet.add(id));
440
+ }
441
+ }
442
+
443
+ for (const capsule of validatedCapsules) {
444
+ if (deniedSet.has(capsule.id)) {
445
+ denied.push(capsule.id);
446
+ } else {
447
+ admitted.push(capsule.id);
448
+ }
449
+ }
450
+
451
+ return {
452
+ admitted,
453
+ denied,
454
+ conflict_receipts,
455
+ merged_state,
456
+ };
457
+ } catch (error) {
458
+ // Handle validation errors
459
+ throw new Error(`Merge failed: ${error.message}`);
460
+ }
461
+ }
462
+
463
+ // ============================================================================
464
+ // Exports
465
+ // ============================================================================
466
+
467
+ export default {
468
+ shardMerge,
469
+ mergeCapsules,
470
+ ConflictDetector,
471
+ ConflictResolver,
472
+ };