@sudocode-ai/cli 0.1.12 → 0.1.14

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 (88) hide show
  1. package/dist/cli/init-commands.d.ts.map +1 -1
  2. package/dist/cli/init-commands.js +6 -2
  3. package/dist/cli/init-commands.js.map +1 -1
  4. package/dist/cli/plugin-commands.d.ts +62 -0
  5. package/dist/cli/plugin-commands.d.ts.map +1 -0
  6. package/dist/cli/plugin-commands.js +595 -0
  7. package/dist/cli/plugin-commands.js.map +1 -0
  8. package/dist/cli.js +73 -1
  9. package/dist/cli.js.map +1 -1
  10. package/dist/config.d.ts +40 -0
  11. package/dist/config.d.ts.map +1 -1
  12. package/dist/config.js +35 -0
  13. package/dist/config.js.map +1 -1
  14. package/dist/export.d.ts +10 -5
  15. package/dist/export.d.ts.map +1 -1
  16. package/dist/export.js +88 -18
  17. package/dist/export.js.map +1 -1
  18. package/dist/import.d.ts.map +1 -1
  19. package/dist/import.js +20 -0
  20. package/dist/import.js.map +1 -1
  21. package/dist/integrations/base-provider.d.ts +108 -0
  22. package/dist/integrations/base-provider.d.ts.map +1 -0
  23. package/dist/integrations/base-provider.js +80 -0
  24. package/dist/integrations/base-provider.js.map +1 -0
  25. package/dist/integrations/config-resolver.d.ts +62 -0
  26. package/dist/integrations/config-resolver.d.ts.map +1 -0
  27. package/dist/integrations/config-resolver.js +69 -0
  28. package/dist/integrations/config-resolver.js.map +1 -0
  29. package/dist/integrations/config-validator.d.ts +75 -0
  30. package/dist/integrations/config-validator.d.ts.map +1 -0
  31. package/dist/integrations/config-validator.js +129 -0
  32. package/dist/integrations/config-validator.js.map +1 -0
  33. package/dist/integrations/index.d.ts +14 -0
  34. package/dist/integrations/index.d.ts.map +1 -0
  35. package/dist/integrations/index.js +20 -0
  36. package/dist/integrations/index.js.map +1 -0
  37. package/dist/integrations/plugin-loader.d.ts +69 -0
  38. package/dist/integrations/plugin-loader.d.ts.map +1 -0
  39. package/dist/integrations/plugin-loader.js +172 -0
  40. package/dist/integrations/plugin-loader.js.map +1 -0
  41. package/dist/integrations/registry.d.ts +67 -0
  42. package/dist/integrations/registry.d.ts.map +1 -0
  43. package/dist/integrations/registry.js +77 -0
  44. package/dist/integrations/registry.js.map +1 -0
  45. package/dist/integrations/sync-coordinator.d.ts +186 -0
  46. package/dist/integrations/sync-coordinator.d.ts.map +1 -0
  47. package/dist/integrations/sync-coordinator.js +776 -0
  48. package/dist/integrations/sync-coordinator.js.map +1 -0
  49. package/dist/integrations/types.d.ts +142 -0
  50. package/dist/integrations/types.d.ts.map +1 -0
  51. package/dist/integrations/types.js +6 -0
  52. package/dist/integrations/types.js.map +1 -0
  53. package/dist/integrations/utils/conflict-resolver.d.ts +79 -0
  54. package/dist/integrations/utils/conflict-resolver.d.ts.map +1 -0
  55. package/dist/integrations/utils/conflict-resolver.js +106 -0
  56. package/dist/integrations/utils/conflict-resolver.js.map +1 -0
  57. package/dist/operations/external-links.d.ts +106 -0
  58. package/dist/operations/external-links.d.ts.map +1 -0
  59. package/dist/operations/external-links.js +300 -0
  60. package/dist/operations/external-links.js.map +1 -0
  61. package/dist/operations/feedback.d.ts.map +1 -1
  62. package/dist/operations/feedback.js +3 -1
  63. package/dist/operations/feedback.js.map +1 -1
  64. package/dist/operations/index.d.ts +1 -0
  65. package/dist/operations/index.d.ts.map +1 -1
  66. package/dist/operations/index.js +1 -0
  67. package/dist/operations/index.js.map +1 -1
  68. package/dist/operations/issues.d.ts +2 -0
  69. package/dist/operations/issues.d.ts.map +1 -1
  70. package/dist/operations/issues.js +34 -5
  71. package/dist/operations/issues.js.map +1 -1
  72. package/dist/operations/relationships.d.ts.map +1 -1
  73. package/dist/operations/relationships.js +6 -2
  74. package/dist/operations/relationships.js.map +1 -1
  75. package/dist/operations/specs.d.ts +2 -0
  76. package/dist/operations/specs.d.ts.map +1 -1
  77. package/dist/operations/specs.js +32 -4
  78. package/dist/operations/specs.js.map +1 -1
  79. package/dist/sync.d.ts +3 -3
  80. package/dist/sync.d.ts.map +1 -1
  81. package/dist/sync.js +2 -0
  82. package/dist/sync.js.map +1 -1
  83. package/dist/types.d.ts +1 -1
  84. package/dist/types.d.ts.map +1 -1
  85. package/dist/watcher.d.ts.map +1 -1
  86. package/dist/watcher.js +11 -6
  87. package/dist/watcher.js.map +1 -1
  88. package/package.json +2 -2
@@ -0,0 +1,776 @@
1
+ /**
2
+ * SyncCoordinator - Orchestrates sync between sudocode and external integration providers
3
+ *
4
+ * Manages provider lifecycle, handles change detection, and resolves conflicts
5
+ * during bidirectional synchronization.
6
+ */
7
+ import { resolveByStrategy, logConflict } from "./utils/conflict-resolver.js";
8
+ import { readJSONLSync, writeJSONLSync } from "../jsonl.js";
9
+ import { findSpecsByExternalLink, findIssuesByExternalLink, getSpecFromJsonl, getIssueFromJsonl, updateSpecExternalLinkSync, updateIssueExternalLinkSync, createIssueFromExternal, deleteIssueFromJsonl, closeIssueInJsonl, removeExternalLinkFromIssue, } from "../operations/external-links.js";
10
+ import * as path from "path";
11
+ /**
12
+ * SyncCoordinator manages integration providers and synchronization
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * const coordinator = new SyncCoordinator({
17
+ * projectPath: '/path/to/project',
18
+ * config: { jira: { enabled: true, ... } },
19
+ * });
20
+ *
21
+ * coordinator.registerProvider(new JiraProvider());
22
+ * await coordinator.start();
23
+ *
24
+ * // Sync all providers
25
+ * const results = await coordinator.syncAll();
26
+ *
27
+ * // Link a sudocode entity to an external entity
28
+ * await coordinator.linkEntity('i-abc', 'PROJ-123', 'jira');
29
+ *
30
+ * await coordinator.stop();
31
+ * ```
32
+ */
33
+ export class SyncCoordinator {
34
+ options;
35
+ providers = new Map();
36
+ lastSyncTimes = new Map();
37
+ constructor(options) {
38
+ this.options = options;
39
+ }
40
+ // ==========================================================================
41
+ // Provider Management
42
+ // ==========================================================================
43
+ /**
44
+ * Register an integration provider
45
+ * @param provider - The provider to register
46
+ */
47
+ registerProvider(provider) {
48
+ this.providers.set(provider.name, provider);
49
+ }
50
+ /**
51
+ * Get a registered provider by name
52
+ * @param name - Provider name
53
+ * @returns The provider, or undefined if not found
54
+ */
55
+ getProvider(name) {
56
+ return this.providers.get(name);
57
+ }
58
+ /**
59
+ * Get all registered provider names
60
+ */
61
+ getProviderNames() {
62
+ return Array.from(this.providers.keys());
63
+ }
64
+ // ==========================================================================
65
+ // Lifecycle
66
+ // ==========================================================================
67
+ /**
68
+ * Start the coordinator and initialize all enabled providers
69
+ *
70
+ * For each registered provider:
71
+ * 1. Skip if not enabled in config
72
+ * 2. Initialize with config
73
+ * 3. Validate connection
74
+ * 4. Start watching if auto_sync is enabled and provider supports it
75
+ */
76
+ async start() {
77
+ for (const [name, provider] of this.providers) {
78
+ const config = this.getProviderConfig(name);
79
+ if (!config?.enabled)
80
+ continue;
81
+ try {
82
+ await provider.initialize(config);
83
+ const validation = await provider.validate();
84
+ if (!validation.valid) {
85
+ console.warn(`[sync-coordinator] Provider ${name} validation failed:`, validation.errors);
86
+ continue;
87
+ }
88
+ // Start watching if auto_sync is enabled and provider supports it
89
+ if (config.auto_sync &&
90
+ provider.supportsWatch &&
91
+ provider.startWatching) {
92
+ provider.startWatching((changes) => {
93
+ this.handleInboundChanges(name, changes);
94
+ });
95
+ }
96
+ this.lastSyncTimes.set(name, new Date());
97
+ }
98
+ catch (error) {
99
+ console.error(`[sync-coordinator] Failed to initialize provider ${name}:`, error);
100
+ }
101
+ }
102
+ }
103
+ /**
104
+ * Stop the coordinator and dispose all providers
105
+ */
106
+ async stop() {
107
+ for (const [_name, provider] of this.providers) {
108
+ try {
109
+ provider.stopWatching?.();
110
+ await provider.dispose();
111
+ }
112
+ catch (error) {
113
+ console.error(`Error disposing provider ${_name}:`, error);
114
+ }
115
+ }
116
+ }
117
+ // ==========================================================================
118
+ // Sync Operations
119
+ // ==========================================================================
120
+ /**
121
+ * Sync all registered and enabled providers
122
+ * @returns Array of sync results from all providers
123
+ */
124
+ async syncAll() {
125
+ const results = [];
126
+ for (const name of this.providers.keys()) {
127
+ const config = this.getProviderConfig(name);
128
+ if (!config?.enabled)
129
+ continue;
130
+ try {
131
+ const providerResults = await this.syncProvider(name);
132
+ results.push(...providerResults);
133
+ }
134
+ catch (error) {
135
+ results.push({
136
+ success: false,
137
+ entity_id: "",
138
+ external_id: "",
139
+ action: "skipped",
140
+ error: `Provider ${name} sync failed: ${String(error)}`,
141
+ });
142
+ }
143
+ }
144
+ return results;
145
+ }
146
+ /**
147
+ * Sync a specific provider
148
+ * @param providerName - Name of the provider to sync
149
+ * @returns Array of sync results
150
+ */
151
+ async syncProvider(providerName) {
152
+ const provider = this.providers.get(providerName);
153
+ if (!provider) {
154
+ throw new Error(`Unknown provider: ${providerName}`);
155
+ }
156
+ const lastSync = this.lastSyncTimes.get(providerName) || new Date(0);
157
+ const changes = await provider.getChangesSince(lastSync);
158
+ const results = await this.handleInboundChanges(providerName, changes);
159
+ this.lastSyncTimes.set(providerName, new Date());
160
+ return results;
161
+ }
162
+ /**
163
+ * Sync a specific entity's external links
164
+ * @param entityId - Sudocode entity ID (s-xxxx or i-xxxx)
165
+ * @returns Array of sync results for each link
166
+ */
167
+ async syncEntity(entityId) {
168
+ const entity = this.loadEntity(entityId);
169
+ if (!entity?.external_links?.length) {
170
+ return [
171
+ {
172
+ success: true,
173
+ entity_id: entityId,
174
+ external_id: "",
175
+ action: "skipped",
176
+ },
177
+ ];
178
+ }
179
+ const results = [];
180
+ for (const link of entity.external_links) {
181
+ if (!link.sync_enabled)
182
+ continue;
183
+ try {
184
+ const result = await this.syncSingleLink(entity, link);
185
+ results.push(result);
186
+ }
187
+ catch (error) {
188
+ results.push({
189
+ success: false,
190
+ entity_id: entityId,
191
+ external_id: link.external_id,
192
+ action: "skipped",
193
+ error: String(error),
194
+ });
195
+ }
196
+ }
197
+ return results;
198
+ }
199
+ // ==========================================================================
200
+ // Link Management
201
+ // ==========================================================================
202
+ /**
203
+ * Link a sudocode entity to an external entity
204
+ *
205
+ * @param entityId - Sudocode entity ID
206
+ * @param externalId - External system entity ID
207
+ * @param provider - Provider name
208
+ * @param options - Link options (sync direction, enabled)
209
+ */
210
+ async linkEntity(entityId, externalId, provider, options) {
211
+ const entity = this.loadEntity(entityId);
212
+ if (!entity) {
213
+ throw new Error(`Entity not found: ${entityId}`);
214
+ }
215
+ const link = {
216
+ provider: provider,
217
+ external_id: externalId,
218
+ sync_enabled: options?.sync_enabled ?? true,
219
+ sync_direction: options?.sync_direction ?? "bidirectional",
220
+ last_synced_at: new Date().toISOString(),
221
+ };
222
+ const links = entity.external_links || [];
223
+ // Check if link already exists
224
+ const existingIndex = links.findIndex((l) => l.provider === provider && l.external_id === externalId);
225
+ if (existingIndex >= 0) {
226
+ // Update existing link
227
+ links[existingIndex] = { ...links[existingIndex], ...link };
228
+ }
229
+ else {
230
+ // Add new link
231
+ links.push(link);
232
+ }
233
+ this.updateEntityLinks(entityId, links);
234
+ }
235
+ /**
236
+ * Remove a link between a sudocode entity and an external entity
237
+ *
238
+ * @param entityId - Sudocode entity ID
239
+ * @param externalId - External system entity ID
240
+ */
241
+ async unlinkEntity(entityId, externalId) {
242
+ const entity = this.loadEntity(entityId);
243
+ if (!entity?.external_links)
244
+ return;
245
+ const links = entity.external_links.filter((l) => l.external_id !== externalId);
246
+ this.updateEntityLinks(entityId, links);
247
+ }
248
+ /**
249
+ * Handle deletion of a sudocode entity - propagate to external systems
250
+ *
251
+ * Call this when a sudocode entity is deleted to propagate the deletion
252
+ * to all linked external systems.
253
+ *
254
+ * @param entityId - Sudocode entity ID that was deleted
255
+ * @param externalLinks - The external_links from the deleted entity
256
+ * @returns Array of sync results
257
+ */
258
+ async handleEntityDeleted(entityId, externalLinks) {
259
+ const results = [];
260
+ if (!externalLinks || externalLinks.length === 0) {
261
+ return results;
262
+ }
263
+ for (const link of externalLinks) {
264
+ // Skip if sync is disabled or inbound-only
265
+ if (!link.sync_enabled)
266
+ continue;
267
+ if (link.sync_direction === "inbound")
268
+ continue;
269
+ const provider = this.providers.get(link.provider);
270
+ if (!provider)
271
+ continue;
272
+ const config = this.getProviderConfig(link.provider);
273
+ const deleteBehavior = config?.delete_behavior || "close";
274
+ try {
275
+ if (deleteBehavior === "delete" && provider.deleteEntity) {
276
+ await provider.deleteEntity(link.external_id);
277
+ results.push({
278
+ success: true,
279
+ entity_id: entityId,
280
+ external_id: link.external_id,
281
+ action: "updated",
282
+ });
283
+ }
284
+ else if (deleteBehavior === "close" && provider.updateEntity) {
285
+ await provider.updateEntity(link.external_id, { status: "closed" });
286
+ results.push({
287
+ success: true,
288
+ entity_id: entityId,
289
+ external_id: link.external_id,
290
+ action: "updated",
291
+ });
292
+ }
293
+ else {
294
+ results.push({
295
+ success: true,
296
+ entity_id: entityId,
297
+ external_id: link.external_id,
298
+ action: "skipped",
299
+ });
300
+ }
301
+ }
302
+ catch (error) {
303
+ results.push({
304
+ success: false,
305
+ entity_id: entityId,
306
+ external_id: link.external_id,
307
+ action: "skipped",
308
+ error: String(error),
309
+ });
310
+ }
311
+ }
312
+ return results;
313
+ }
314
+ // ==========================================================================
315
+ // Internal Methods - Change Handling
316
+ // ==========================================================================
317
+ /**
318
+ * Handle inbound changes from an external provider
319
+ */
320
+ async handleInboundChanges(providerName, changes) {
321
+ const results = [];
322
+ const provider = this.providers.get(providerName);
323
+ if (!provider)
324
+ return results;
325
+ for (const change of changes) {
326
+ try {
327
+ const result = await this.processInboundChange(providerName, provider, change);
328
+ results.push(result);
329
+ }
330
+ catch (error) {
331
+ results.push({
332
+ success: false,
333
+ entity_id: "",
334
+ external_id: change.entity_id,
335
+ action: "skipped",
336
+ error: String(error),
337
+ });
338
+ }
339
+ }
340
+ return results;
341
+ }
342
+ /**
343
+ * Process a single inbound change
344
+ */
345
+ async processInboundChange(providerName, provider, change) {
346
+ const config = this.getProviderConfig(providerName);
347
+ const sudocodeDir = path.join(this.options.projectPath, ".sudocode");
348
+ // Find linked sudocode entity
349
+ const linkedEntity = this.findLinkedEntity(providerName, change.entity_id);
350
+ // Handle NEW entities (auto-import)
351
+ if (!linkedEntity && change.change_type === "created") {
352
+ // Auto-import if enabled (defaults to true)
353
+ const autoImport = config?.auto_import !== false;
354
+ if (autoImport && change.data) {
355
+ // Map external entity to sudocode format
356
+ const mapped = provider.mapToSudocode(change.data);
357
+ const issueData = mapped.issue;
358
+ if (issueData && change.entity_type === "issue") {
359
+ // Create sudocode issue with auto-link
360
+ const newIssue = createIssueFromExternal(sudocodeDir, {
361
+ title: issueData.title || change.data.title,
362
+ content: issueData.content || change.data.description,
363
+ status: issueData.status || "open",
364
+ priority: issueData.priority ?? change.data.priority ?? 2,
365
+ external: {
366
+ provider: providerName,
367
+ external_id: change.entity_id,
368
+ sync_direction: config?.default_sync_direction || "bidirectional",
369
+ },
370
+ });
371
+ return {
372
+ success: true,
373
+ entity_id: newIssue.id,
374
+ external_id: change.entity_id,
375
+ action: "created",
376
+ };
377
+ }
378
+ }
379
+ return {
380
+ success: true,
381
+ entity_id: "",
382
+ external_id: change.entity_id,
383
+ action: "skipped",
384
+ };
385
+ }
386
+ // Handle UPDATED entities without link (skip)
387
+ if (!linkedEntity && change.change_type === "updated") {
388
+ return {
389
+ success: true,
390
+ entity_id: "",
391
+ external_id: change.entity_id,
392
+ action: "skipped",
393
+ };
394
+ }
395
+ // Handle DELETED entities
396
+ if (change.change_type === "deleted") {
397
+ if (!linkedEntity) {
398
+ return {
399
+ success: true,
400
+ entity_id: "",
401
+ external_id: change.entity_id,
402
+ action: "skipped",
403
+ };
404
+ }
405
+ // Get delete behavior from config (defaults to 'close')
406
+ const deleteBehavior = config?.delete_behavior || "close";
407
+ if (deleteBehavior === "ignore") {
408
+ // Disable sync on the stale link to avoid future sync errors
409
+ updateIssueExternalLinkSync(sudocodeDir, linkedEntity.id, change.entity_id, {
410
+ sync_enabled: false,
411
+ });
412
+ return {
413
+ success: true,
414
+ entity_id: linkedEntity.id,
415
+ external_id: change.entity_id,
416
+ action: "skipped",
417
+ };
418
+ }
419
+ if (deleteBehavior === "delete") {
420
+ deleteIssueFromJsonl(sudocodeDir, linkedEntity.id);
421
+ return {
422
+ success: true,
423
+ entity_id: linkedEntity.id,
424
+ external_id: change.entity_id,
425
+ action: "updated", // Using 'updated' as there's no 'deleted' action type
426
+ };
427
+ }
428
+ // Default: close the issue AND remove the stale external link
429
+ closeIssueInJsonl(sudocodeDir, linkedEntity.id);
430
+ // Clean up the external link to avoid orphaned reference
431
+ removeExternalLinkFromIssue(sudocodeDir, linkedEntity.id, change.entity_id);
432
+ return {
433
+ success: true,
434
+ entity_id: linkedEntity.id,
435
+ external_id: change.entity_id,
436
+ action: "updated",
437
+ };
438
+ }
439
+ // Check for conflicts
440
+ const link = linkedEntity?.external_links?.find((l) => l.external_id === change.entity_id);
441
+ if (link && change.data?.updated_at && linkedEntity) {
442
+ const conflict = this.detectConflict(linkedEntity, change, link);
443
+ if (conflict) {
444
+ const resolution = await this.resolveConflict(conflict);
445
+ if (resolution === "skip") {
446
+ return {
447
+ success: true,
448
+ entity_id: linkedEntity.id,
449
+ external_id: change.entity_id,
450
+ action: "skipped",
451
+ };
452
+ }
453
+ if (resolution === "sudocode") {
454
+ // Push sudocode version to external
455
+ await provider.updateEntity(change.entity_id, linkedEntity);
456
+ this.updateLinkSyncTime(linkedEntity.id, link.external_id);
457
+ return {
458
+ success: true,
459
+ entity_id: linkedEntity.id,
460
+ external_id: change.entity_id,
461
+ action: "updated",
462
+ };
463
+ }
464
+ }
465
+ }
466
+ // Apply inbound change (external wins or no conflict)
467
+ if (change.data && linkedEntity) {
468
+ const mapped = provider.mapToSudocode(change.data);
469
+ const updates = mapped.spec || mapped.issue;
470
+ if (updates) {
471
+ this.applyUpdatesToEntity(linkedEntity.id, updates);
472
+ if (link) {
473
+ this.updateLinkSyncTime(linkedEntity.id, link.external_id);
474
+ }
475
+ return {
476
+ success: true,
477
+ entity_id: linkedEntity.id,
478
+ external_id: change.entity_id,
479
+ action: "updated",
480
+ };
481
+ }
482
+ }
483
+ return {
484
+ success: true,
485
+ entity_id: linkedEntity?.id || "",
486
+ external_id: change.entity_id,
487
+ action: "skipped",
488
+ };
489
+ }
490
+ /**
491
+ * Sync a single external link
492
+ */
493
+ async syncSingleLink(entity, link) {
494
+ console.log(`[sync-coordinator] syncSingleLink: entity=${entity.id}, external=${link.external_id}, direction=${link.sync_direction}`);
495
+ const provider = this.providers.get(link.provider);
496
+ if (!provider) {
497
+ console.log(`[sync-coordinator] Provider not registered: ${link.provider}`);
498
+ return {
499
+ success: false,
500
+ entity_id: entity.id,
501
+ external_id: link.external_id,
502
+ action: "skipped",
503
+ error: `Provider not registered: ${link.provider}`,
504
+ };
505
+ }
506
+ try {
507
+ // Fetch current external state
508
+ console.log(`[sync-coordinator] Fetching external entity ${link.external_id}`);
509
+ const externalEntity = await provider.fetchEntity(link.external_id);
510
+ if (!externalEntity) {
511
+ console.log(`[sync-coordinator] External entity not found: ${link.external_id}`);
512
+ return {
513
+ success: false,
514
+ entity_id: entity.id,
515
+ external_id: link.external_id,
516
+ action: "skipped",
517
+ error: "External entity not found",
518
+ };
519
+ }
520
+ console.log(`[sync-coordinator] External entity found, status=${externalEntity.status}`);
521
+ // Check for conflicts
522
+ const change = {
523
+ entity_id: link.external_id,
524
+ entity_type: externalEntity.type,
525
+ change_type: "updated",
526
+ timestamp: externalEntity.updated_at || new Date().toISOString(),
527
+ data: externalEntity,
528
+ };
529
+ const conflict = this.detectConflict(entity, change, link);
530
+ console.log(`[sync-coordinator] Conflict detected: ${conflict !== null}`);
531
+ if (conflict) {
532
+ const resolution = await this.resolveConflict(conflict);
533
+ console.log(`[sync-coordinator] Conflict resolution: ${resolution}`);
534
+ if (resolution === "skip") {
535
+ return {
536
+ success: true,
537
+ entity_id: entity.id,
538
+ external_id: link.external_id,
539
+ action: "skipped",
540
+ };
541
+ }
542
+ if (resolution === "sudocode") {
543
+ // Push sudocode to external
544
+ console.log(`[sync-coordinator] Pushing sudocode to external (conflict resolution), entity:`, JSON.stringify(entity));
545
+ await provider.updateEntity(link.external_id, entity);
546
+ this.updateLinkSyncTime(entity.id, link.external_id);
547
+ return {
548
+ success: true,
549
+ entity_id: entity.id,
550
+ external_id: link.external_id,
551
+ action: "updated",
552
+ };
553
+ }
554
+ // External wins - apply to sudocode
555
+ const mapped = provider.mapToSudocode(externalEntity);
556
+ const updates = mapped.spec || mapped.issue;
557
+ if (updates) {
558
+ this.applyUpdatesToEntity(entity.id, updates);
559
+ }
560
+ this.updateLinkSyncTime(entity.id, link.external_id);
561
+ return {
562
+ success: true,
563
+ entity_id: entity.id,
564
+ external_id: link.external_id,
565
+ action: "updated",
566
+ };
567
+ }
568
+ // No conflict - sync based on direction
569
+ console.log(`[sync-coordinator] No conflict, checking direction: ${link.sync_direction}`);
570
+ if (link.sync_direction === "outbound" ||
571
+ link.sync_direction === "bidirectional") {
572
+ console.log(`[sync-coordinator] Calling provider.updateEntity with:`, JSON.stringify(entity));
573
+ await provider.updateEntity(link.external_id, entity);
574
+ console.log(`[sync-coordinator] provider.updateEntity completed`);
575
+ }
576
+ else {
577
+ console.log(`[sync-coordinator] Skipping updateEntity - direction is ${link.sync_direction}`);
578
+ }
579
+ this.updateLinkSyncTime(entity.id, link.external_id);
580
+ return {
581
+ success: true,
582
+ entity_id: entity.id,
583
+ external_id: link.external_id,
584
+ action: "updated",
585
+ };
586
+ }
587
+ catch (error) {
588
+ console.error(`[sync-coordinator] syncSingleLink error:`, error);
589
+ return {
590
+ success: false,
591
+ entity_id: entity.id,
592
+ external_id: link.external_id,
593
+ action: "skipped",
594
+ error: String(error),
595
+ };
596
+ }
597
+ }
598
+ // ==========================================================================
599
+ // Internal Methods - Conflict Detection and Resolution
600
+ // ==========================================================================
601
+ /**
602
+ * Detect if there's a conflict between sudocode and external versions
603
+ */
604
+ detectConflict(entity, change, link) {
605
+ const sudocodeUpdated = new Date(entity.updated_at);
606
+ const externalUpdated = new Date(change.data?.updated_at || 0);
607
+ const lastSynced = link.last_synced_at
608
+ ? new Date(link.last_synced_at)
609
+ : new Date(0);
610
+ // Conflict if both updated since last sync
611
+ if (sudocodeUpdated > lastSynced && externalUpdated > lastSynced) {
612
+ return {
613
+ sudocode_entity_id: entity.id,
614
+ external_id: change.entity_id,
615
+ provider: link.provider,
616
+ sudocode_updated_at: entity.updated_at,
617
+ external_updated_at: change.data?.updated_at || "",
618
+ };
619
+ }
620
+ return null;
621
+ }
622
+ /**
623
+ * Resolve a sync conflict based on configured strategy
624
+ */
625
+ async resolveConflict(conflict) {
626
+ const config = this.getProviderConfig(conflict.provider);
627
+ const strategy = config?.conflict_resolution || "newest-wins";
628
+ if (strategy === "manual" && this.options.onConflict) {
629
+ const resolution = await this.options.onConflict(conflict);
630
+ logConflict({
631
+ timestamp: new Date().toISOString(),
632
+ conflict,
633
+ resolution,
634
+ strategy,
635
+ });
636
+ return resolution;
637
+ }
638
+ const resolution = resolveByStrategy(conflict, strategy);
639
+ logConflict({
640
+ timestamp: new Date().toISOString(),
641
+ conflict,
642
+ resolution,
643
+ strategy,
644
+ });
645
+ return resolution;
646
+ }
647
+ // ==========================================================================
648
+ // Internal Methods - Entity Operations
649
+ // ==========================================================================
650
+ /**
651
+ * Get provider config from options
652
+ */
653
+ getProviderConfig(name) {
654
+ return this.options.config[name];
655
+ }
656
+ /**
657
+ * Determine if an ID is for a spec or issue
658
+ */
659
+ getEntityType(id) {
660
+ return id.startsWith("s-") ? "spec" : "issue";
661
+ }
662
+ /**
663
+ * Get the JSONL file path for an entity type
664
+ */
665
+ getEntityFilePath(type) {
666
+ const sudocodePath = path.join(this.options.projectPath, ".sudocode");
667
+ return type === "spec"
668
+ ? path.join(sudocodePath, "specs.jsonl")
669
+ : path.join(sudocodePath, "issues.jsonl");
670
+ }
671
+ /**
672
+ * Load an entity from JSONL storage
673
+ */
674
+ /**
675
+ * Load an entity from JSONL storage
676
+ */
677
+ loadEntity(id) {
678
+ const sudocodeDir = path.join(this.options.projectPath, ".sudocode");
679
+ let jsonlEntity = null;
680
+ if (id.startsWith("s-")) {
681
+ jsonlEntity = getSpecFromJsonl(sudocodeDir, id);
682
+ }
683
+ else {
684
+ jsonlEntity = getIssueFromJsonl(sudocodeDir, id);
685
+ }
686
+ if (!jsonlEntity)
687
+ return null;
688
+ return this.toEntity(jsonlEntity);
689
+ }
690
+ /**
691
+ * Convert JSONL entity to domain Entity
692
+ */
693
+ toEntity(jsonl) {
694
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
695
+ const { relationships, tags, feedback, ...entityData } = jsonl;
696
+ return entityData;
697
+ }
698
+ /**
699
+ * Find a sudocode entity linked to an external entity
700
+ */
701
+ findLinkedEntity(provider, externalId) {
702
+ const sudocodeDir = path.join(this.options.projectPath, ".sudocode");
703
+ const providerName = provider;
704
+ console.log(`[sync-coordinator] findLinkedEntity: looking for provider=${providerName}, external_id=${externalId}`);
705
+ // Search specs
706
+ const specs = findSpecsByExternalLink(sudocodeDir, providerName, externalId);
707
+ if (specs.length > 0) {
708
+ console.log(`[sync-coordinator] findLinkedEntity: found spec ${specs[0].id}`);
709
+ return this.toEntity(specs[0]);
710
+ }
711
+ // Search issues
712
+ const issues = findIssuesByExternalLink(sudocodeDir, providerName, externalId);
713
+ if (issues.length > 0) {
714
+ console.log(`[sync-coordinator] findLinkedEntity: found issue ${issues[0].id}`);
715
+ return this.toEntity(issues[0]);
716
+ }
717
+ console.log(`[sync-coordinator] findLinkedEntity: no linked entity found`);
718
+ return null;
719
+ }
720
+ /**
721
+ * Update external_links on an entity
722
+ */
723
+ updateEntityLinks(id, links) {
724
+ const type = this.getEntityType(id);
725
+ const filePath = this.getEntityFilePath(type);
726
+ const entities = readJSONLSync(filePath);
727
+ const index = entities.findIndex((e) => e.id === id);
728
+ if (index >= 0) {
729
+ entities[index].external_links = links;
730
+ entities[index].updated_at = new Date().toISOString();
731
+ writeJSONLSync(filePath, entities);
732
+ }
733
+ }
734
+ /**
735
+ * Update the last_synced_at timestamp on a link
736
+ */
737
+ /**
738
+ * Update the last_synced_at timestamp on a link
739
+ */
740
+ updateLinkSyncTime(entityId, externalId) {
741
+ const sudocodeDir = path.join(this.options.projectPath, ".sudocode");
742
+ const updates = { last_synced_at: new Date().toISOString() };
743
+ try {
744
+ if (entityId.startsWith("s-")) {
745
+ updateSpecExternalLinkSync(sudocodeDir, entityId, externalId, updates);
746
+ }
747
+ else {
748
+ updateIssueExternalLinkSync(sudocodeDir, entityId, externalId, updates);
749
+ }
750
+ }
751
+ catch (error) {
752
+ // Ignore if entity/link not found (race condition)
753
+ }
754
+ }
755
+ /**
756
+ * Apply partial updates to an entity
757
+ */
758
+ applyUpdatesToEntity(id, updates) {
759
+ const type = this.getEntityType(id);
760
+ const filePath = this.getEntityFilePath(type);
761
+ const entities = readJSONLSync(filePath);
762
+ const index = entities.findIndex((e) => e.id === id);
763
+ if (index >= 0) {
764
+ const current = entities[index];
765
+ // Apply updates while preserving relationships, tags, etc.
766
+ const { id: _id, uuid: _uuid, ...safeUpdates } = updates;
767
+ entities[index] = {
768
+ ...current,
769
+ ...safeUpdates,
770
+ updated_at: new Date().toISOString(),
771
+ };
772
+ writeJSONLSync(filePath, entities);
773
+ }
774
+ }
775
+ }
776
+ //# sourceMappingURL=sync-coordinator.js.map