@stoneforge/quarry 1.10.2 → 1.13.0

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 (137) hide show
  1. package/README.md +3 -1
  2. package/dist/cli/commands/admin.d.ts.map +1 -1
  3. package/dist/cli/commands/admin.js +313 -3
  4. package/dist/cli/commands/admin.js.map +1 -1
  5. package/dist/cli/commands/auto-link-helper.d.ts +33 -0
  6. package/dist/cli/commands/auto-link-helper.d.ts.map +1 -0
  7. package/dist/cli/commands/auto-link-helper.js +73 -0
  8. package/dist/cli/commands/auto-link-helper.js.map +1 -0
  9. package/dist/cli/commands/crud.d.ts +1 -0
  10. package/dist/cli/commands/crud.d.ts.map +1 -1
  11. package/dist/cli/commands/crud.js +44 -5
  12. package/dist/cli/commands/crud.js.map +1 -1
  13. package/dist/cli/commands/docs.d.ts +1 -0
  14. package/dist/cli/commands/docs.d.ts.map +1 -1
  15. package/dist/cli/commands/docs.js +81 -1
  16. package/dist/cli/commands/docs.js.map +1 -1
  17. package/dist/cli/commands/external-sync.d.ts +17 -0
  18. package/dist/cli/commands/external-sync.d.ts.map +1 -0
  19. package/dist/cli/commands/external-sync.js +1647 -0
  20. package/dist/cli/commands/external-sync.js.map +1 -0
  21. package/dist/cli/commands/log.d.ts +18 -0
  22. package/dist/cli/commands/log.d.ts.map +1 -0
  23. package/dist/cli/commands/log.js +282 -0
  24. package/dist/cli/commands/log.js.map +1 -0
  25. package/dist/cli/commands/metrics.d.ts +9 -0
  26. package/dist/cli/commands/metrics.d.ts.map +1 -0
  27. package/dist/cli/commands/metrics.js +219 -0
  28. package/dist/cli/commands/metrics.js.map +1 -0
  29. package/dist/cli/runner.d.ts.map +1 -1
  30. package/dist/cli/runner.js +8 -0
  31. package/dist/cli/runner.js.map +1 -1
  32. package/dist/config/config.d.ts.map +1 -1
  33. package/dist/config/config.js +28 -0
  34. package/dist/config/config.js.map +1 -1
  35. package/dist/config/defaults.d.ts +13 -1
  36. package/dist/config/defaults.d.ts.map +1 -1
  37. package/dist/config/defaults.js +21 -0
  38. package/dist/config/defaults.js.map +1 -1
  39. package/dist/config/file.d.ts.map +1 -1
  40. package/dist/config/file.js +61 -0
  41. package/dist/config/file.js.map +1 -1
  42. package/dist/config/index.d.ts +3 -3
  43. package/dist/config/index.d.ts.map +1 -1
  44. package/dist/config/index.js +2 -2
  45. package/dist/config/index.js.map +1 -1
  46. package/dist/config/merge.d.ts.map +1 -1
  47. package/dist/config/merge.js +46 -1
  48. package/dist/config/merge.js.map +1 -1
  49. package/dist/config/types.d.ts +63 -1
  50. package/dist/config/types.d.ts.map +1 -1
  51. package/dist/config/types.js +30 -0
  52. package/dist/config/types.js.map +1 -1
  53. package/dist/config/validation.d.ts.map +1 -1
  54. package/dist/config/validation.js +51 -1
  55. package/dist/config/validation.js.map +1 -1
  56. package/dist/external-sync/adapters/task-sync-adapter.d.ts +177 -0
  57. package/dist/external-sync/adapters/task-sync-adapter.d.ts.map +1 -0
  58. package/dist/external-sync/adapters/task-sync-adapter.js +353 -0
  59. package/dist/external-sync/adapters/task-sync-adapter.js.map +1 -0
  60. package/dist/external-sync/auto-link.d.ts +66 -0
  61. package/dist/external-sync/auto-link.d.ts.map +1 -0
  62. package/dist/external-sync/auto-link.js +98 -0
  63. package/dist/external-sync/auto-link.js.map +1 -0
  64. package/dist/external-sync/conflict-resolver.d.ts +170 -0
  65. package/dist/external-sync/conflict-resolver.d.ts.map +1 -0
  66. package/dist/external-sync/conflict-resolver.js +580 -0
  67. package/dist/external-sync/conflict-resolver.js.map +1 -0
  68. package/dist/external-sync/index.d.ts +20 -0
  69. package/dist/external-sync/index.d.ts.map +1 -0
  70. package/dist/external-sync/index.js +20 -0
  71. package/dist/external-sync/index.js.map +1 -0
  72. package/dist/external-sync/provider-registry.d.ts +109 -0
  73. package/dist/external-sync/provider-registry.d.ts.map +1 -0
  74. package/dist/external-sync/provider-registry.js +188 -0
  75. package/dist/external-sync/provider-registry.js.map +1 -0
  76. package/dist/external-sync/providers/github/github-api.d.ts +271 -0
  77. package/dist/external-sync/providers/github/github-api.d.ts.map +1 -0
  78. package/dist/external-sync/providers/github/github-api.js +366 -0
  79. package/dist/external-sync/providers/github/github-api.js.map +1 -0
  80. package/dist/external-sync/providers/github/github-field-map.d.ts +76 -0
  81. package/dist/external-sync/providers/github/github-field-map.d.ts.map +1 -0
  82. package/dist/external-sync/providers/github/github-field-map.js +157 -0
  83. package/dist/external-sync/providers/github/github-field-map.js.map +1 -0
  84. package/dist/external-sync/providers/github/github-provider.d.ts +36 -0
  85. package/dist/external-sync/providers/github/github-provider.d.ts.map +1 -0
  86. package/dist/external-sync/providers/github/github-provider.js +212 -0
  87. package/dist/external-sync/providers/github/github-provider.js.map +1 -0
  88. package/dist/external-sync/providers/github/github-task-adapter.d.ts +135 -0
  89. package/dist/external-sync/providers/github/github-task-adapter.d.ts.map +1 -0
  90. package/dist/external-sync/providers/github/github-task-adapter.js +374 -0
  91. package/dist/external-sync/providers/github/github-task-adapter.js.map +1 -0
  92. package/dist/external-sync/providers/github/index.d.ts +12 -0
  93. package/dist/external-sync/providers/github/index.d.ts.map +1 -0
  94. package/dist/external-sync/providers/github/index.js +15 -0
  95. package/dist/external-sync/providers/github/index.js.map +1 -0
  96. package/dist/external-sync/providers/index.d.ts +9 -0
  97. package/dist/external-sync/providers/index.d.ts.map +1 -0
  98. package/dist/external-sync/providers/index.js +10 -0
  99. package/dist/external-sync/providers/index.js.map +1 -0
  100. package/dist/external-sync/providers/linear/index.d.ts +19 -0
  101. package/dist/external-sync/providers/linear/index.d.ts.map +1 -0
  102. package/dist/external-sync/providers/linear/index.js +19 -0
  103. package/dist/external-sync/providers/linear/index.js.map +1 -0
  104. package/dist/external-sync/providers/linear/linear-api.d.ts +252 -0
  105. package/dist/external-sync/providers/linear/linear-api.d.ts.map +1 -0
  106. package/dist/external-sync/providers/linear/linear-api.js +522 -0
  107. package/dist/external-sync/providers/linear/linear-api.js.map +1 -0
  108. package/dist/external-sync/providers/linear/linear-field-map.d.ts +135 -0
  109. package/dist/external-sync/providers/linear/linear-field-map.d.ts.map +1 -0
  110. package/dist/external-sync/providers/linear/linear-field-map.js +338 -0
  111. package/dist/external-sync/providers/linear/linear-field-map.js.map +1 -0
  112. package/dist/external-sync/providers/linear/linear-provider.d.ts +52 -0
  113. package/dist/external-sync/providers/linear/linear-provider.d.ts.map +1 -0
  114. package/dist/external-sync/providers/linear/linear-provider.js +169 -0
  115. package/dist/external-sync/providers/linear/linear-provider.js.map +1 -0
  116. package/dist/external-sync/providers/linear/linear-task-adapter.d.ts +190 -0
  117. package/dist/external-sync/providers/linear/linear-task-adapter.d.ts.map +1 -0
  118. package/dist/external-sync/providers/linear/linear-task-adapter.js +521 -0
  119. package/dist/external-sync/providers/linear/linear-task-adapter.js.map +1 -0
  120. package/dist/external-sync/providers/linear/linear-types.d.ts +114 -0
  121. package/dist/external-sync/providers/linear/linear-types.d.ts.map +1 -0
  122. package/dist/external-sync/providers/linear/linear-types.js +10 -0
  123. package/dist/external-sync/providers/linear/linear-types.js.map +1 -0
  124. package/dist/external-sync/sync-engine.d.ts +298 -0
  125. package/dist/external-sync/sync-engine.d.ts.map +1 -0
  126. package/dist/external-sync/sync-engine.js +785 -0
  127. package/dist/external-sync/sync-engine.js.map +1 -0
  128. package/dist/index.d.ts +1 -0
  129. package/dist/index.d.ts.map +1 -1
  130. package/dist/index.js +2 -0
  131. package/dist/index.js.map +1 -1
  132. package/dist/services/inbox.js +1 -1
  133. package/dist/sync/hash.d.ts +5 -0
  134. package/dist/sync/hash.d.ts.map +1 -1
  135. package/dist/sync/hash.js +21 -2
  136. package/dist/sync/hash.js.map +1 -1
  137. package/package.json +11 -5
@@ -0,0 +1,785 @@
1
+ /**
2
+ * Sync Engine — orchestrates push/pull operations between Stoneforge and external services
3
+ *
4
+ * The sync engine coordinates bidirectional synchronization between Stoneforge elements
5
+ * and external services (GitHub, Linear, etc.) using the provider registry and adapters.
6
+ *
7
+ * Core operations:
8
+ * - push(): Push locally-changed linked elements to external services
9
+ * - pull(): Pull externally-changed items into Stoneforge
10
+ * - sync(): Bidirectional sync (push then pull)
11
+ *
12
+ * Change detection:
13
+ * - Push: Query events since lastPushedAt, filter to elements with _externalSync metadata,
14
+ * compare content hash against lastPushedHash
15
+ * - Pull: Call adapter.listIssuesSince() using global sync cursor from settings,
16
+ * compare against lastPulledHash
17
+ *
18
+ * Usage:
19
+ * ```typescript
20
+ * import { createSyncEngine } from '@stoneforge/quarry';
21
+ *
22
+ * const engine = createSyncEngine({ api, registry, settings });
23
+ * const result = await engine.push({ all: true });
24
+ * const result = await engine.pull();
25
+ * const result = await engine.sync({ dryRun: true });
26
+ * ```
27
+ */
28
+ import { getExternalSyncState, setExternalSyncState, } from '@stoneforge/core';
29
+ import { createTimestamp } from '@stoneforge/core';
30
+ import { createHash } from 'crypto';
31
+ import { computeContentHashSync } from '../sync/hash.js';
32
+ import { taskToExternalTask, externalTaskToTaskUpdates, getFieldMapConfigForProvider } from './adapters/task-sync-adapter.js';
33
+ // ============================================================================
34
+ // Settings Keys
35
+ // ============================================================================
36
+ /** Key prefix for sync cursor settings */
37
+ const SYNC_CURSOR_KEY_PREFIX = 'external_sync.cursor';
38
+ /**
39
+ * Build a settings key for a sync cursor.
40
+ * Format: external_sync.cursor.<provider>.<project>.<adapterType>
41
+ */
42
+ function buildCursorKey(provider, project, adapterType) {
43
+ return `${SYNC_CURSOR_KEY_PREFIX}.${provider}.${project}.${adapterType}`;
44
+ }
45
+ // ============================================================================
46
+ // Default Conflict Resolver
47
+ // ============================================================================
48
+ /**
49
+ * Default conflict resolver — implements last-write-wins strategy.
50
+ * Compares updatedAt timestamps from local element and remote item.
51
+ */
52
+ const defaultConflictResolver = {
53
+ resolve(localElement, remoteItem, strategy) {
54
+ switch (strategy) {
55
+ case 'local_wins':
56
+ return { winner: 'local', resolved: true };
57
+ case 'remote_wins':
58
+ return { winner: 'remote', resolved: true };
59
+ case 'manual':
60
+ return { winner: 'manual', resolved: false };
61
+ case 'last_write_wins':
62
+ default: {
63
+ const localTime = new Date(localElement.updatedAt).getTime();
64
+ const remoteTime = new Date(remoteItem.updatedAt).getTime();
65
+ if (remoteTime >= localTime) {
66
+ return { winner: 'remote', resolved: true };
67
+ }
68
+ return { winner: 'local', resolved: true };
69
+ }
70
+ }
71
+ },
72
+ };
73
+ // ============================================================================
74
+ // SyncEngine
75
+ // ============================================================================
76
+ /**
77
+ * Sync Engine — coordinates push/pull operations between Stoneforge and external services.
78
+ *
79
+ * The engine is stateless — all state is stored in element metadata (_externalSync)
80
+ * and settings (sync cursors). Each operation reads current state, computes changes,
81
+ * and writes updated state.
82
+ */
83
+ export class SyncEngine {
84
+ api;
85
+ registry;
86
+ settings;
87
+ conflictResolver;
88
+ defaultStrategy;
89
+ providerConfigs;
90
+ constructor(config) {
91
+ this.api = config.api;
92
+ this.registry = config.registry;
93
+ this.settings = config.settings;
94
+ this.conflictResolver = config.conflictResolver ?? defaultConflictResolver;
95
+ this.defaultStrategy = config.defaultConflictStrategy ?? 'last_write_wins';
96
+ this.providerConfigs = config.providerConfigs ?? [];
97
+ }
98
+ // --------------------------------------------------------------------------
99
+ // Push — push locally-changed linked elements to external services
100
+ // --------------------------------------------------------------------------
101
+ /**
102
+ * Push locally-changed linked elements to external services.
103
+ *
104
+ * Algorithm:
105
+ * 1. Find elements with _externalSync metadata
106
+ * 2. For each element, query events since lastPushedAt
107
+ * 3. Compare current content hash against lastPushedHash
108
+ * 4. If hash differs, push changes to external service via adapter
109
+ * 5. Update _externalSync metadata with new timestamp and hash
110
+ *
111
+ * @param options - Push options (dryRun, taskIds, all)
112
+ * @returns Aggregated sync result across all providers
113
+ */
114
+ async push(options = {}) {
115
+ const pushed = [];
116
+ const skipped = [];
117
+ const conflicts = [];
118
+ const errors = [];
119
+ const now = createTimestamp();
120
+ // Step 1: Find elements to push
121
+ const elements = await this.findLinkedElements(options);
122
+ // Step 2: Process each element
123
+ for (const element of elements) {
124
+ const syncState = getExternalSyncState(element.metadata);
125
+ if (!syncState) {
126
+ // No sync state — shouldn't happen since we filtered for it, but guard
127
+ skipped.push(1);
128
+ continue;
129
+ }
130
+ // Skip if direction is pull-only
131
+ if (syncState.direction === 'pull') {
132
+ skipped.push(1);
133
+ continue;
134
+ }
135
+ // Skip closed/tombstone tasks — they're done and shouldn't sync.
136
+ // If a task is later reopened (status changes away from closed/tombstone),
137
+ // it will be picked up again naturally since the filter no longer applies.
138
+ const elementStatus = element.status;
139
+ if (elementStatus === 'closed' || elementStatus === 'tombstone') {
140
+ skipped.push(1);
141
+ continue;
142
+ }
143
+ try {
144
+ const result = await this.pushElement(element, syncState, options, now);
145
+ if (result === 'pushed') {
146
+ pushed.push(1);
147
+ }
148
+ else if (result === 'skipped') {
149
+ skipped.push(1);
150
+ }
151
+ }
152
+ catch (err) {
153
+ errors.push({
154
+ elementId: element.id,
155
+ externalId: syncState.externalId,
156
+ provider: syncState.provider,
157
+ project: syncState.project,
158
+ message: err instanceof Error ? err.message : String(err),
159
+ retryable: isRetryableError(err),
160
+ });
161
+ }
162
+ }
163
+ return buildResult({
164
+ pushed: pushed.length,
165
+ pulled: 0,
166
+ skipped: skipped.length,
167
+ conflicts,
168
+ errors,
169
+ provider: this.getPrimaryProvider(),
170
+ project: this.getPrimaryProject(),
171
+ });
172
+ }
173
+ /**
174
+ * Push a single element to its external service.
175
+ *
176
+ * @returns 'pushed' if changes were sent, 'skipped' if no changes detected
177
+ */
178
+ async pushElement(element, syncState, options, now) {
179
+ // Check for actual content change via hash (skip when force is true)
180
+ const currentHash = computeContentHashSync(element).hash;
181
+ if (!options.force && syncState.lastPushedHash && currentHash === syncState.lastPushedHash) {
182
+ return 'skipped';
183
+ }
184
+ // Verify events have occurred since last push (skip when force is true)
185
+ if (!options.force && syncState.lastPushedAt) {
186
+ const events = await this.api.listEvents({
187
+ elementId: element.id,
188
+ eventType: ['updated', 'closed', 'reopened'],
189
+ after: syncState.lastPushedAt,
190
+ });
191
+ if (events.length === 0) {
192
+ return 'skipped';
193
+ }
194
+ }
195
+ // Dry run — report but don't actually push
196
+ if (options.dryRun) {
197
+ return 'pushed';
198
+ }
199
+ // Get the adapter for this element's provider
200
+ const adapter = this.getTaskAdapter(syncState.provider);
201
+ if (!adapter) {
202
+ throw new Error(`No task adapter found for provider '${syncState.provider}'`);
203
+ }
204
+ // Build external task input using the shared field mapping utilities.
205
+ // This properly converts priority → sf:priority:* labels, taskType → sf:type:* labels,
206
+ // status → open/closed state, hydrates description, and resolves assignees.
207
+ const fieldMapConfig = getFieldMapConfigForProvider(syncState.provider);
208
+ const taskInput = await taskToExternalTask(element, fieldMapConfig, this.api);
209
+ // Push to external service
210
+ await adapter.updateIssue(syncState.project, syncState.externalId, taskInput);
211
+ // Update sync state on element
212
+ const updatedSyncState = {
213
+ ...syncState,
214
+ lastPushedAt: now,
215
+ lastPushedHash: currentHash,
216
+ };
217
+ await this.api.update(element.id, {
218
+ metadata: setExternalSyncState(element.metadata, updatedSyncState),
219
+ });
220
+ return 'pushed';
221
+ }
222
+ // --------------------------------------------------------------------------
223
+ // Pull — pull externally-changed items into Stoneforge
224
+ // --------------------------------------------------------------------------
225
+ /**
226
+ * Pull externally-changed items into Stoneforge.
227
+ *
228
+ * Algorithm:
229
+ * 1. For each configured provider, get the sync cursor (last poll timestamp)
230
+ * 2. Call adapter.listIssuesSince(project, cursor) to find changed items
231
+ * 3. For each changed item:
232
+ * a. If linked to a local element, compare against lastPulledHash
233
+ * b. If unlinked and options.all is set, create a new Stoneforge task
234
+ * c. If both local and remote changed, use conflict resolver
235
+ * 4. Update sync cursors and element metadata
236
+ *
237
+ * @param options - Pull options (dryRun, taskIds, all)
238
+ * @returns Aggregated sync result
239
+ */
240
+ async pull(options = {}) {
241
+ const pulled = [];
242
+ const skipped = [];
243
+ const conflicts = [];
244
+ const errors = [];
245
+ const now = createTimestamp();
246
+ // Get all task adapters from the registry
247
+ const adapterEntries = this.registry.getAdaptersOfType('task');
248
+ for (const { provider, adapter } of adapterEntries) {
249
+ const taskAdapter = adapter;
250
+ const providerConfig = this.getProviderConfig(provider.name);
251
+ const project = providerConfig?.defaultProject;
252
+ if (!project) {
253
+ // No project configured for this provider — skip
254
+ continue;
255
+ }
256
+ try {
257
+ const result = await this.pullFromProvider(provider, taskAdapter, project, options, now, conflicts, errors);
258
+ pulled.push(result.pulled);
259
+ skipped.push(result.skipped);
260
+ }
261
+ catch (err) {
262
+ errors.push({
263
+ provider: provider.name,
264
+ project,
265
+ message: err instanceof Error ? err.message : String(err),
266
+ retryable: isRetryableError(err),
267
+ });
268
+ }
269
+ }
270
+ return buildResult({
271
+ pushed: 0,
272
+ pulled: pulled.reduce((a, b) => a + b, 0),
273
+ skipped: skipped.reduce((a, b) => a + b, 0),
274
+ conflicts,
275
+ errors,
276
+ provider: this.getPrimaryProvider(),
277
+ project: this.getPrimaryProject(),
278
+ });
279
+ }
280
+ /**
281
+ * Pull changes from a specific provider.
282
+ */
283
+ async pullFromProvider(provider, adapter, project, options, now, conflicts, errors) {
284
+ let pulledCount = 0;
285
+ let skippedCount = 0;
286
+ // Get the sync cursor for this provider+project
287
+ const syncCursor = this.getSyncCursor(provider.name, project, 'task');
288
+ // Fetch changed items since cursor
289
+ const externalItems = await adapter.listIssuesSince(project, syncCursor);
290
+ // Get all locally-linked elements for matching
291
+ const linkedElements = await this.findLinkedElementsForProvider(provider.name, project);
292
+ // Build a map of externalId → local element for fast lookup
293
+ const linkedByExternalId = new Map();
294
+ for (const el of linkedElements) {
295
+ const state = getExternalSyncState(el.metadata);
296
+ if (state) {
297
+ linkedByExternalId.set(state.externalId, el);
298
+ }
299
+ }
300
+ for (const externalItem of externalItems) {
301
+ // If taskIds filter is set, check if this external item matches
302
+ if (options.taskIds) {
303
+ const localEl = linkedByExternalId.get(externalItem.externalId);
304
+ if (!localEl || !options.taskIds.includes(localEl.id)) {
305
+ skippedCount++;
306
+ continue;
307
+ }
308
+ }
309
+ try {
310
+ const result = await this.pullItem(provider, project, externalItem, linkedByExternalId, options, now, conflicts);
311
+ if (result === 'pulled') {
312
+ pulledCount++;
313
+ }
314
+ else if (result === 'skipped') {
315
+ skippedCount++;
316
+ }
317
+ else if (result === 'created') {
318
+ pulledCount++;
319
+ }
320
+ }
321
+ catch (err) {
322
+ errors.push({
323
+ externalId: externalItem.externalId,
324
+ provider: provider.name,
325
+ project,
326
+ message: err instanceof Error ? err.message : String(err),
327
+ retryable: isRetryableError(err),
328
+ });
329
+ }
330
+ }
331
+ // Update sync cursor (unless dry run)
332
+ if (!options.dryRun && externalItems.length > 0) {
333
+ this.setSyncCursor(provider.name, project, 'task', now);
334
+ }
335
+ return { pulled: pulledCount, skipped: skippedCount };
336
+ }
337
+ /**
338
+ * Pull a single external item into Stoneforge.
339
+ *
340
+ * @returns 'pulled' if local element was updated, 'skipped' if no changes,
341
+ * 'created' if a new task was created
342
+ */
343
+ async pullItem(provider, project, externalItem, linkedByExternalId, options, now, conflicts) {
344
+ const localElement = linkedByExternalId.get(externalItem.externalId);
345
+ if (!localElement) {
346
+ // Unlinked external item — create new task if --all flag is set
347
+ if (options.all) {
348
+ if (options.dryRun) {
349
+ return 'created';
350
+ }
351
+ await this.createTaskFromExternal(provider, project, externalItem, now);
352
+ return 'created';
353
+ }
354
+ return 'skipped';
355
+ }
356
+ // Linked element — check for actual change via hash
357
+ const syncState = getExternalSyncState(localElement.metadata);
358
+ // Skip if direction is push-only
359
+ if (syncState.direction === 'push') {
360
+ return 'skipped';
361
+ }
362
+ // Skip updates to closed/tombstone tasks UNLESS the external item is open
363
+ // (which means someone reopened the issue externally — we should sync that).
364
+ const localStatus = localElement.status;
365
+ if ((localStatus === 'closed' || localStatus === 'tombstone') && externalItem.state !== 'open') {
366
+ return 'skipped';
367
+ }
368
+ // Compute a hash of the external item content to detect real changes
369
+ const remoteContentKey = computeExternalItemHash(externalItem);
370
+ if (syncState.lastPulledHash && remoteContentKey === syncState.lastPulledHash) {
371
+ return 'skipped';
372
+ }
373
+ // Check for conflict: has local also changed since last pull?
374
+ const localHash = computeContentHashSync(localElement).hash;
375
+ const localChanged = syncState.lastPushedHash !== undefined && localHash !== syncState.lastPushedHash;
376
+ if (localChanged) {
377
+ // Both sides changed — use conflict resolver
378
+ const resolution = this.conflictResolver.resolve(localElement, externalItem, this.defaultStrategy);
379
+ conflicts.push({
380
+ elementId: localElement.id,
381
+ externalId: externalItem.externalId,
382
+ provider: provider.name,
383
+ project,
384
+ localUpdatedAt: localElement.updatedAt,
385
+ remoteUpdatedAt: externalItem.updatedAt,
386
+ strategy: this.defaultStrategy,
387
+ resolved: resolution.resolved,
388
+ winner: resolution.resolved ? resolution.winner : undefined,
389
+ });
390
+ if (!resolution.resolved) {
391
+ // Manual resolution needed — tag the element
392
+ if (!options.dryRun) {
393
+ const tags = localElement.tags.includes('sync-conflict')
394
+ ? localElement.tags
395
+ : [...localElement.tags, 'sync-conflict'];
396
+ await this.api.update(localElement.id, { tags });
397
+ }
398
+ return 'skipped';
399
+ }
400
+ if (resolution.winner === 'local') {
401
+ // Local wins — skip the pull, but update the pulled hash
402
+ if (!options.dryRun) {
403
+ const updatedSyncState = {
404
+ ...syncState,
405
+ lastPulledAt: now,
406
+ lastPulledHash: remoteContentKey,
407
+ };
408
+ await this.api.update(localElement.id, {
409
+ metadata: setExternalSyncState(localElement.metadata, updatedSyncState),
410
+ });
411
+ }
412
+ return 'skipped';
413
+ }
414
+ // Remote wins — fall through to apply remote changes
415
+ }
416
+ // Dry run — report but don't actually apply
417
+ if (options.dryRun) {
418
+ return 'pulled';
419
+ }
420
+ // Apply remote changes to local element using provider field map config.
421
+ // Pass the existing task for diff mode (only changed fields are returned).
422
+ const updates = this.externalItemToUpdates(externalItem, localElement);
423
+ const updatedSyncState = {
424
+ ...syncState,
425
+ lastPulledAt: now,
426
+ lastPulledHash: remoteContentKey,
427
+ };
428
+ // Merge metadata: preserve existing metadata, add any from updates
429
+ // (e.g., _pendingAssignee), and set the updated sync state.
430
+ const updatesMetadata = (updates.metadata ?? {});
431
+ const mergedMetadata = setExternalSyncState({ ...localElement.metadata, ...updatesMetadata }, updatedSyncState);
432
+ // Handle description (body) sync from external item.
433
+ // The body is not included in externalItemToUpdates() because it requires
434
+ // separate document creation/update operations.
435
+ const descriptionRefUpdate = await this.syncDescriptionFromExternal(localElement, externalItem);
436
+ await this.api.update(localElement.id, {
437
+ ...updates,
438
+ ...descriptionRefUpdate,
439
+ metadata: mergedMetadata,
440
+ });
441
+ return 'pulled';
442
+ }
443
+ // --------------------------------------------------------------------------
444
+ // Sync — bidirectional (push then pull)
445
+ // --------------------------------------------------------------------------
446
+ /**
447
+ * Bidirectional sync — push then pull.
448
+ *
449
+ * Runs push first to send local changes, then pull to receive remote changes.
450
+ * Results are merged from both operations.
451
+ *
452
+ * @param options - Sync options (dryRun, taskIds, all)
453
+ * @returns Merged sync result
454
+ */
455
+ async sync(options = {}) {
456
+ const pushResult = await this.push(options);
457
+ const pullResult = await this.pull(options);
458
+ return {
459
+ success: pushResult.success && pullResult.success,
460
+ provider: pushResult.provider || pullResult.provider,
461
+ project: pushResult.project || pullResult.project,
462
+ adapterType: 'task',
463
+ pushed: pushResult.pushed,
464
+ pulled: pullResult.pulled,
465
+ skipped: pushResult.skipped + pullResult.skipped,
466
+ conflicts: [...pushResult.conflicts, ...pullResult.conflicts],
467
+ errors: [...pushResult.errors, ...pullResult.errors],
468
+ };
469
+ }
470
+ // --------------------------------------------------------------------------
471
+ // Element Discovery
472
+ // --------------------------------------------------------------------------
473
+ /**
474
+ * Find elements that are linked to external services and match the given options.
475
+ *
476
+ * - If taskIds is specified, returns only those tasks (that have _externalSync)
477
+ * - If all is true, returns all tasks with _externalSync metadata
478
+ * - Otherwise, returns tasks with _externalSync that have been updated
479
+ */
480
+ async findLinkedElements(options) {
481
+ if (options.taskIds && options.taskIds.length > 0) {
482
+ // Fetch specific tasks
483
+ const elements = [];
484
+ for (const id of options.taskIds) {
485
+ const element = await this.api.get(id);
486
+ if (element && getExternalSyncState(element.metadata)) {
487
+ elements.push(element);
488
+ }
489
+ }
490
+ return elements;
491
+ }
492
+ // Fetch all tasks, then filter to those with _externalSync metadata
493
+ const allTasks = await this.api.list({ type: 'task' });
494
+ return allTasks.filter((el) => getExternalSyncState(el.metadata) !== undefined);
495
+ }
496
+ /**
497
+ * Find elements linked to a specific provider and project.
498
+ */
499
+ async findLinkedElementsForProvider(providerName, project) {
500
+ const allTasks = await this.api.list({ type: 'task' });
501
+ return allTasks.filter((el) => {
502
+ const state = getExternalSyncState(el.metadata);
503
+ return state && state.provider === providerName && state.project === project;
504
+ });
505
+ }
506
+ // --------------------------------------------------------------------------
507
+ // Field Mapping Utilities
508
+ // --------------------------------------------------------------------------
509
+ /**
510
+ * Convert an ExternalTask to partial updates for applying to a local element.
511
+ *
512
+ * Delegates to externalTaskToTaskUpdates() from task-sync-adapter.ts, which
513
+ * uses the provider's TaskSyncFieldMapConfig for correct status, priority,
514
+ * taskType, and tag mapping. The provider is looked up from item.provider.
515
+ *
516
+ * @param item - The external task to convert
517
+ * @param existingTask - The existing local task (for diff mode), or undefined
518
+ * @returns Partial<Task> with the mapped fields
519
+ */
520
+ externalItemToUpdates(item, existingTask) {
521
+ const config = getFieldMapConfigForProvider(item.provider);
522
+ return externalTaskToTaskUpdates(item, existingTask, config);
523
+ }
524
+ // --------------------------------------------------------------------------
525
+ // Task Creation (for pull with --all)
526
+ // --------------------------------------------------------------------------
527
+ /**
528
+ * Create a new Stoneforge task from an unlinked external item.
529
+ */
530
+ async createTaskFromExternal(provider, project, item, now) {
531
+ const syncState = {
532
+ provider: provider.name,
533
+ project,
534
+ externalId: item.externalId,
535
+ url: item.url,
536
+ lastPulledAt: now,
537
+ lastPulledHash: computeExternalItemHash(item),
538
+ direction: 'bidirectional',
539
+ adapterType: 'task',
540
+ };
541
+ // Use the provider's field map config for correct status, priority,
542
+ // taskType, and tag mapping — instead of hardcoded open/closed.
543
+ const taskUpdates = this.externalItemToUpdates(item);
544
+ // Merge metadata from field mapping (e.g., _pendingAssignee) with sync state
545
+ const taskMetadata = (taskUpdates.metadata ?? {});
546
+ // Create a description document if the external item has a body
547
+ let descriptionRef;
548
+ if (item.body && item.body.trim().length > 0) {
549
+ descriptionRef = await this.createDescriptionDocument(item.body, taskUpdates.title ?? item.title);
550
+ }
551
+ const createInput = {
552
+ type: 'task',
553
+ title: taskUpdates.title ?? item.title,
554
+ status: taskUpdates.status ?? 'open',
555
+ priority: taskUpdates.priority,
556
+ taskType: taskUpdates.taskType,
557
+ tags: taskUpdates.tags ?? [...item.labels],
558
+ externalRef: taskUpdates.externalRef ?? item.url,
559
+ createdBy: 'system',
560
+ metadata: { ...taskMetadata, _externalSync: syncState },
561
+ ...(descriptionRef !== undefined && { descriptionRef }),
562
+ };
563
+ const element = await this.api.create(createInput);
564
+ return element;
565
+ }
566
+ // --------------------------------------------------------------------------
567
+ // Description Sync Helpers
568
+ // --------------------------------------------------------------------------
569
+ /**
570
+ * Sync the description (body) from an external item to the local task's
571
+ * description document.
572
+ *
573
+ * - If the task already has a descriptionRef, compare the body against the
574
+ * existing document content and update only if changed.
575
+ * - If the task has no descriptionRef and the body is non-empty, create a
576
+ * new document and return the descriptionRef to link.
577
+ * - If the body is empty/undefined, skip (don't delete existing descriptions).
578
+ *
579
+ * @returns Partial update with descriptionRef if a new document was created,
580
+ * or empty object if no descriptionRef change is needed.
581
+ */
582
+ async syncDescriptionFromExternal(localTask, externalItem) {
583
+ const body = externalItem.body;
584
+ // If body is empty/undefined, skip — don't delete existing descriptions
585
+ if (!body || body.trim().length === 0) {
586
+ return {};
587
+ }
588
+ if (localTask.descriptionRef) {
589
+ // Task already has a description document — update if content changed
590
+ const existingDoc = await this.api.get(localTask.descriptionRef);
591
+ if (existingDoc && existingDoc.type === 'document') {
592
+ if (existingDoc.content !== body) {
593
+ await this.api.update(localTask.descriptionRef, {
594
+ content: body,
595
+ });
596
+ }
597
+ // No descriptionRef change needed
598
+ return {};
599
+ }
600
+ // Document not found — fall through to create a new one
601
+ }
602
+ // No existing description document — create a new one and link it
603
+ const newDescRef = await this.createDescriptionDocument(body, localTask.title);
604
+ return { descriptionRef: newDescRef };
605
+ }
606
+ /**
607
+ * Create a new description document for a task.
608
+ *
609
+ * Uses the same pattern as the server-side task creation:
610
+ * - contentType: 'markdown'
611
+ * - category: 'task-description' tag
612
+ * - createdBy: 'system'
613
+ *
614
+ * @param body - The description content
615
+ * @param taskTitle - The task title (used in the document title)
616
+ * @returns The DocumentId of the created document
617
+ */
618
+ async createDescriptionDocument(body, taskTitle) {
619
+ const docInput = {
620
+ type: 'document',
621
+ contentType: 'markdown',
622
+ content: body,
623
+ createdBy: 'system',
624
+ tags: ['task-description'],
625
+ title: `Description for task ${taskTitle}`,
626
+ metadata: {},
627
+ version: 1,
628
+ previousVersionId: null,
629
+ category: 'task-description',
630
+ status: 'active',
631
+ immutable: false,
632
+ };
633
+ const createdDoc = await this.api.create(docInput);
634
+ return createdDoc.id;
635
+ }
636
+ // --------------------------------------------------------------------------
637
+ // Sync Cursor Management
638
+ // --------------------------------------------------------------------------
639
+ /**
640
+ * Get the sync cursor for a provider+project+adapterType.
641
+ * Returns a timestamp indicating the last time we polled this combination.
642
+ */
643
+ getSyncCursor(provider, project, adapterType) {
644
+ if (!this.settings) {
645
+ // No settings service — return epoch
646
+ return '1970-01-01T00:00:00.000Z';
647
+ }
648
+ const key = buildCursorKey(provider, project, adapterType);
649
+ const setting = this.settings.getSetting(key);
650
+ if (setting && typeof setting.value === 'string') {
651
+ return setting.value;
652
+ }
653
+ return '1970-01-01T00:00:00.000Z';
654
+ }
655
+ /**
656
+ * Update the sync cursor for a provider+project+adapterType.
657
+ */
658
+ setSyncCursor(provider, project, adapterType, cursor) {
659
+ if (!this.settings) {
660
+ return;
661
+ }
662
+ const key = buildCursorKey(provider, project, adapterType);
663
+ this.settings.setSetting(key, cursor);
664
+ }
665
+ // --------------------------------------------------------------------------
666
+ // Provider/Adapter Lookup
667
+ // --------------------------------------------------------------------------
668
+ /**
669
+ * Get a TaskSyncAdapter for a given provider name.
670
+ */
671
+ getTaskAdapter(providerName) {
672
+ const provider = this.registry.get(providerName);
673
+ if (!provider)
674
+ return undefined;
675
+ return provider.getTaskAdapter?.();
676
+ }
677
+ /**
678
+ * Get the ProviderConfig for a given provider name.
679
+ */
680
+ getProviderConfig(providerName) {
681
+ return this.providerConfigs.find((c) => c.provider === providerName);
682
+ }
683
+ /**
684
+ * Get the primary provider name (for result reporting).
685
+ */
686
+ getPrimaryProvider() {
687
+ if (this.providerConfigs.length > 0) {
688
+ return this.providerConfigs[0].provider;
689
+ }
690
+ const providers = this.registry.list();
691
+ return providers.length > 0 ? providers[0].name : 'unknown';
692
+ }
693
+ /**
694
+ * Get the primary project (for result reporting).
695
+ */
696
+ getPrimaryProject() {
697
+ if (this.providerConfigs.length > 0 && this.providerConfigs[0].defaultProject) {
698
+ return this.providerConfigs[0].defaultProject;
699
+ }
700
+ return '';
701
+ }
702
+ }
703
+ // ============================================================================
704
+ // Helper Functions
705
+ // ============================================================================
706
+ /**
707
+ * Compute a content hash for an external item.
708
+ * Used to detect real changes from the remote side.
709
+ * Hashes key semantic fields in a deterministic order.
710
+ */
711
+ function computeExternalItemHash(item) {
712
+ // Build deterministic content representation with sorted keys
713
+ const contentFields = {
714
+ assignees: [...item.assignees].sort(),
715
+ body: item.body ?? '',
716
+ labels: [...item.labels].sort(),
717
+ priority: item.priority,
718
+ state: item.state,
719
+ title: item.title,
720
+ };
721
+ const hashInput = `external:${JSON.stringify(contentFields)}`;
722
+ return createHash('sha256').update(hashInput).digest('hex');
723
+ }
724
+ /**
725
+ * Check if an error is likely retryable (network issues, rate limits, etc.)
726
+ */
727
+ function isRetryableError(err) {
728
+ if (!(err instanceof Error))
729
+ return false;
730
+ const message = err.message.toLowerCase();
731
+ return (message.includes('rate limit') ||
732
+ message.includes('timeout') ||
733
+ message.includes('econnrefused') ||
734
+ message.includes('enotfound') ||
735
+ message.includes('network') ||
736
+ message.includes('503') ||
737
+ message.includes('429'));
738
+ }
739
+ /**
740
+ * Build a standardized ExternalSyncResult.
741
+ */
742
+ function buildResult(params) {
743
+ return {
744
+ success: params.errors.length === 0,
745
+ provider: params.provider,
746
+ project: params.project,
747
+ adapterType: 'task',
748
+ pushed: params.pushed,
749
+ pulled: params.pulled,
750
+ skipped: params.skipped,
751
+ conflicts: params.conflicts,
752
+ errors: params.errors,
753
+ };
754
+ }
755
+ // ============================================================================
756
+ // Factory
757
+ // ============================================================================
758
+ /**
759
+ * Create a new SyncEngine instance.
760
+ *
761
+ * @param config - Sync engine configuration
762
+ * @returns A new SyncEngine instance
763
+ *
764
+ * @example
765
+ * ```typescript
766
+ * const engine = createSyncEngine({
767
+ * api: quarryApi,
768
+ * registry: providerRegistry,
769
+ * settings: settingsService,
770
+ * });
771
+ *
772
+ * // Push locally-changed tasks
773
+ * const pushResult = await engine.push({ all: true });
774
+ *
775
+ * // Pull externally-changed items
776
+ * const pullResult = await engine.pull();
777
+ *
778
+ * // Bidirectional sync
779
+ * const syncResult = await engine.sync({ dryRun: true });
780
+ * ```
781
+ */
782
+ export function createSyncEngine(config) {
783
+ return new SyncEngine(config);
784
+ }
785
+ //# sourceMappingURL=sync-engine.js.map