@stoneforge/quarry 1.12.0 → 1.14.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.
- package/README.md +2 -0
- package/dist/api/quarry-api.d.ts +9 -1
- package/dist/api/quarry-api.d.ts.map +1 -1
- package/dist/api/quarry-api.js +21 -2
- package/dist/api/quarry-api.js.map +1 -1
- package/dist/api/types.d.ts +8 -1
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js.map +1 -1
- package/dist/cli/commands/auto-link-helper.d.ts +33 -0
- package/dist/cli/commands/auto-link-helper.d.ts.map +1 -0
- package/dist/cli/commands/auto-link-helper.js +74 -0
- package/dist/cli/commands/auto-link-helper.js.map +1 -0
- package/dist/cli/commands/crud.d.ts +3 -0
- package/dist/cli/commands/crud.d.ts.map +1 -1
- package/dist/cli/commands/crud.js +144 -15
- package/dist/cli/commands/crud.js.map +1 -1
- package/dist/cli/commands/docs.js +2 -2
- package/dist/cli/commands/docs.js.map +1 -1
- package/dist/cli/commands/document.js +1 -1
- package/dist/cli/commands/document.js.map +1 -1
- package/dist/cli/commands/entity.js +1 -1
- package/dist/cli/commands/entity.js.map +1 -1
- package/dist/cli/commands/external-sync.d.ts +18 -0
- package/dist/cli/commands/external-sync.d.ts.map +1 -0
- package/dist/cli/commands/external-sync.js +2499 -0
- package/dist/cli/commands/external-sync.js.map +1 -0
- package/dist/cli/commands/library.js +1 -1
- package/dist/cli/commands/library.js.map +1 -1
- package/dist/cli/commands/message.js +2 -2
- package/dist/cli/commands/message.js.map +1 -1
- package/dist/cli/commands/serve.d.ts.map +1 -1
- package/dist/cli/commands/serve.js +2 -0
- package/dist/cli/commands/serve.js.map +1 -1
- package/dist/cli/commands/task.d.ts.map +1 -1
- package/dist/cli/commands/task.js +7 -4
- package/dist/cli/commands/task.js.map +1 -1
- package/dist/cli/commands/team.js +1 -1
- package/dist/cli/commands/team.js.map +1 -1
- package/dist/cli/commands/workflow.js +1 -1
- package/dist/cli/commands/workflow.js.map +1 -1
- package/dist/cli/runner.d.ts.map +1 -1
- package/dist/cli/runner.js +3 -0
- package/dist/cli/runner.js.map +1 -1
- package/dist/cli/utils/progress.d.ts +30 -0
- package/dist/cli/utils/progress.d.ts.map +1 -0
- package/dist/cli/utils/progress.js +47 -0
- package/dist/cli/utils/progress.js.map +1 -0
- package/dist/config/config.d.ts.map +1 -1
- package/dist/config/config.js +34 -0
- package/dist/config/config.js.map +1 -1
- package/dist/config/defaults.d.ts +13 -1
- package/dist/config/defaults.d.ts.map +1 -1
- package/dist/config/defaults.js +22 -0
- package/dist/config/defaults.js.map +1 -1
- package/dist/config/file.d.ts.map +1 -1
- package/dist/config/file.js +71 -0
- package/dist/config/file.js.map +1 -1
- package/dist/config/index.d.ts +3 -3
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +2 -2
- package/dist/config/index.js.map +1 -1
- package/dist/config/merge.d.ts.map +1 -1
- package/dist/config/merge.js +52 -1
- package/dist/config/merge.js.map +1 -1
- package/dist/config/types.d.ts +68 -1
- package/dist/config/types.d.ts.map +1 -1
- package/dist/config/types.js +33 -0
- package/dist/config/types.js.map +1 -1
- package/dist/config/validation.d.ts.map +1 -1
- package/dist/config/validation.js +64 -1
- package/dist/config/validation.js.map +1 -1
- package/dist/external-sync/adapters/document-sync-adapter.d.ts +150 -0
- package/dist/external-sync/adapters/document-sync-adapter.d.ts.map +1 -0
- package/dist/external-sync/adapters/document-sync-adapter.js +325 -0
- package/dist/external-sync/adapters/document-sync-adapter.js.map +1 -0
- package/dist/external-sync/adapters/task-sync-adapter.d.ts +177 -0
- package/dist/external-sync/adapters/task-sync-adapter.d.ts.map +1 -0
- package/dist/external-sync/adapters/task-sync-adapter.js +353 -0
- package/dist/external-sync/adapters/task-sync-adapter.js.map +1 -0
- package/dist/external-sync/auto-link.d.ts +66 -0
- package/dist/external-sync/auto-link.d.ts.map +1 -0
- package/dist/external-sync/auto-link.js +98 -0
- package/dist/external-sync/auto-link.js.map +1 -0
- package/dist/external-sync/conflict-resolver.d.ts +170 -0
- package/dist/external-sync/conflict-resolver.d.ts.map +1 -0
- package/dist/external-sync/conflict-resolver.js +580 -0
- package/dist/external-sync/conflict-resolver.js.map +1 -0
- package/dist/external-sync/index.d.ts +23 -0
- package/dist/external-sync/index.d.ts.map +1 -0
- package/dist/external-sync/index.js +24 -0
- package/dist/external-sync/index.js.map +1 -0
- package/dist/external-sync/provider-registry.d.ts +113 -0
- package/dist/external-sync/provider-registry.d.ts.map +1 -0
- package/dist/external-sync/provider-registry.js +205 -0
- package/dist/external-sync/provider-registry.js.map +1 -0
- package/dist/external-sync/providers/folder/folder-document-adapter.d.ts +97 -0
- package/dist/external-sync/providers/folder/folder-document-adapter.d.ts.map +1 -0
- package/dist/external-sync/providers/folder/folder-document-adapter.js +261 -0
- package/dist/external-sync/providers/folder/folder-document-adapter.js.map +1 -0
- package/dist/external-sync/providers/folder/folder-fs.d.ts +146 -0
- package/dist/external-sync/providers/folder/folder-fs.d.ts.map +1 -0
- package/dist/external-sync/providers/folder/folder-fs.js +300 -0
- package/dist/external-sync/providers/folder/folder-fs.js.map +1 -0
- package/dist/external-sync/providers/folder/folder-provider.d.ts +28 -0
- package/dist/external-sync/providers/folder/folder-provider.d.ts.map +1 -0
- package/dist/external-sync/providers/folder/folder-provider.js +87 -0
- package/dist/external-sync/providers/folder/folder-provider.js.map +1 -0
- package/dist/external-sync/providers/folder/index.d.ts +11 -0
- package/dist/external-sync/providers/folder/index.d.ts.map +1 -0
- package/dist/external-sync/providers/folder/index.js +13 -0
- package/dist/external-sync/providers/folder/index.js.map +1 -0
- package/dist/external-sync/providers/github/github-api.d.ts +271 -0
- package/dist/external-sync/providers/github/github-api.d.ts.map +1 -0
- package/dist/external-sync/providers/github/github-api.js +366 -0
- package/dist/external-sync/providers/github/github-api.js.map +1 -0
- package/dist/external-sync/providers/github/github-field-map.d.ts +76 -0
- package/dist/external-sync/providers/github/github-field-map.d.ts.map +1 -0
- package/dist/external-sync/providers/github/github-field-map.js +157 -0
- package/dist/external-sync/providers/github/github-field-map.js.map +1 -0
- package/dist/external-sync/providers/github/github-provider.d.ts +36 -0
- package/dist/external-sync/providers/github/github-provider.d.ts.map +1 -0
- package/dist/external-sync/providers/github/github-provider.js +212 -0
- package/dist/external-sync/providers/github/github-provider.js.map +1 -0
- package/dist/external-sync/providers/github/github-task-adapter.d.ts +135 -0
- package/dist/external-sync/providers/github/github-task-adapter.d.ts.map +1 -0
- package/dist/external-sync/providers/github/github-task-adapter.js +374 -0
- package/dist/external-sync/providers/github/github-task-adapter.js.map +1 -0
- package/dist/external-sync/providers/github/index.d.ts +12 -0
- package/dist/external-sync/providers/github/index.d.ts.map +1 -0
- package/dist/external-sync/providers/github/index.js +15 -0
- package/dist/external-sync/providers/github/index.js.map +1 -0
- package/dist/external-sync/providers/index.d.ts +13 -0
- package/dist/external-sync/providers/index.d.ts.map +1 -0
- package/dist/external-sync/providers/index.js +15 -0
- package/dist/external-sync/providers/index.js.map +1 -0
- package/dist/external-sync/providers/linear/index.d.ts +19 -0
- package/dist/external-sync/providers/linear/index.d.ts.map +1 -0
- package/dist/external-sync/providers/linear/index.js +19 -0
- package/dist/external-sync/providers/linear/index.js.map +1 -0
- package/dist/external-sync/providers/linear/linear-api.d.ts +252 -0
- package/dist/external-sync/providers/linear/linear-api.d.ts.map +1 -0
- package/dist/external-sync/providers/linear/linear-api.js +522 -0
- package/dist/external-sync/providers/linear/linear-api.js.map +1 -0
- package/dist/external-sync/providers/linear/linear-field-map.d.ts +135 -0
- package/dist/external-sync/providers/linear/linear-field-map.d.ts.map +1 -0
- package/dist/external-sync/providers/linear/linear-field-map.js +338 -0
- package/dist/external-sync/providers/linear/linear-field-map.js.map +1 -0
- package/dist/external-sync/providers/linear/linear-provider.d.ts +52 -0
- package/dist/external-sync/providers/linear/linear-provider.d.ts.map +1 -0
- package/dist/external-sync/providers/linear/linear-provider.js +169 -0
- package/dist/external-sync/providers/linear/linear-provider.js.map +1 -0
- package/dist/external-sync/providers/linear/linear-task-adapter.d.ts +190 -0
- package/dist/external-sync/providers/linear/linear-task-adapter.d.ts.map +1 -0
- package/dist/external-sync/providers/linear/linear-task-adapter.js +521 -0
- package/dist/external-sync/providers/linear/linear-task-adapter.js.map +1 -0
- package/dist/external-sync/providers/linear/linear-types.d.ts +114 -0
- package/dist/external-sync/providers/linear/linear-types.d.ts.map +1 -0
- package/dist/external-sync/providers/linear/linear-types.js +10 -0
- package/dist/external-sync/providers/linear/linear-types.js.map +1 -0
- package/dist/external-sync/providers/notion/index.d.ts +19 -0
- package/dist/external-sync/providers/notion/index.d.ts.map +1 -0
- package/dist/external-sync/providers/notion/index.js +20 -0
- package/dist/external-sync/providers/notion/index.js.map +1 -0
- package/dist/external-sync/providers/notion/notion-api.d.ts +253 -0
- package/dist/external-sync/providers/notion/notion-api.d.ts.map +1 -0
- package/dist/external-sync/providers/notion/notion-api.js +492 -0
- package/dist/external-sync/providers/notion/notion-api.js.map +1 -0
- package/dist/external-sync/providers/notion/notion-blocks.d.ts +93 -0
- package/dist/external-sync/providers/notion/notion-blocks.d.ts.map +1 -0
- package/dist/external-sync/providers/notion/notion-blocks.js +773 -0
- package/dist/external-sync/providers/notion/notion-blocks.js.map +1 -0
- package/dist/external-sync/providers/notion/notion-document-adapter.d.ts +176 -0
- package/dist/external-sync/providers/notion/notion-document-adapter.d.ts.map +1 -0
- package/dist/external-sync/providers/notion/notion-document-adapter.js +413 -0
- package/dist/external-sync/providers/notion/notion-document-adapter.js.map +1 -0
- package/dist/external-sync/providers/notion/notion-provider.d.ts +57 -0
- package/dist/external-sync/providers/notion/notion-provider.d.ts.map +1 -0
- package/dist/external-sync/providers/notion/notion-provider.js +159 -0
- package/dist/external-sync/providers/notion/notion-provider.js.map +1 -0
- package/dist/external-sync/providers/notion/notion-types.d.ts +388 -0
- package/dist/external-sync/providers/notion/notion-types.d.ts.map +1 -0
- package/dist/external-sync/providers/notion/notion-types.js +47 -0
- package/dist/external-sync/providers/notion/notion-types.js.map +1 -0
- package/dist/external-sync/sync-engine.d.ts +364 -0
- package/dist/external-sync/sync-engine.d.ts.map +1 -0
- package/dist/external-sync/sync-engine.js +1154 -0
- package/dist/external-sync/sync-engine.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/server/index.js +8 -8
- package/dist/server/index.js.map +1 -1
- package/dist/services/inbox.js +1 -1
- package/dist/sync/hash.d.ts +5 -0
- package/dist/sync/hash.d.ts.map +1 -1
- package/dist/sync/hash.js +21 -2
- package/dist/sync/hash.js.map +1 -1
- package/package.json +10 -12
|
@@ -0,0 +1,1154 @@
|
|
|
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
|
+
import { documentToExternalDocumentInput, externalDocumentToDocumentUpdates, computeExternalDocumentHash, resolveDocumentLibraryPaths } from './adapters/document-sync-adapter.js';
|
|
34
|
+
// ============================================================================
|
|
35
|
+
// Settings Keys
|
|
36
|
+
// ============================================================================
|
|
37
|
+
/** Key prefix for sync cursor settings */
|
|
38
|
+
const SYNC_CURSOR_KEY_PREFIX = 'external_sync.cursor';
|
|
39
|
+
/** Number of documents to push concurrently (balances throughput vs API rate limits) */
|
|
40
|
+
const PUSH_CONCURRENCY = 3;
|
|
41
|
+
/**
|
|
42
|
+
* Build a settings key for a sync cursor.
|
|
43
|
+
* Format: external_sync.cursor.<provider>.<project>.<adapterType>
|
|
44
|
+
*/
|
|
45
|
+
function buildCursorKey(provider, project, adapterType) {
|
|
46
|
+
return `${SYNC_CURSOR_KEY_PREFIX}.${provider}.${project}.${adapterType}`;
|
|
47
|
+
}
|
|
48
|
+
// ============================================================================
|
|
49
|
+
// Default Conflict Resolver
|
|
50
|
+
// ============================================================================
|
|
51
|
+
/**
|
|
52
|
+
* Default conflict resolver — implements last-write-wins strategy.
|
|
53
|
+
* Compares updatedAt timestamps from local element and remote item.
|
|
54
|
+
*/
|
|
55
|
+
const defaultConflictResolver = {
|
|
56
|
+
resolve(localElement, remoteItem, strategy) {
|
|
57
|
+
switch (strategy) {
|
|
58
|
+
case 'local_wins':
|
|
59
|
+
return { winner: 'local', resolved: true };
|
|
60
|
+
case 'remote_wins':
|
|
61
|
+
return { winner: 'remote', resolved: true };
|
|
62
|
+
case 'manual':
|
|
63
|
+
return { winner: 'manual', resolved: false };
|
|
64
|
+
case 'last_write_wins':
|
|
65
|
+
default: {
|
|
66
|
+
const localTime = new Date(localElement.updatedAt).getTime();
|
|
67
|
+
const remoteTime = new Date(remoteItem.updatedAt).getTime();
|
|
68
|
+
if (remoteTime >= localTime) {
|
|
69
|
+
return { winner: 'remote', resolved: true };
|
|
70
|
+
}
|
|
71
|
+
return { winner: 'local', resolved: true };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
// ============================================================================
|
|
77
|
+
// SyncEngine
|
|
78
|
+
// ============================================================================
|
|
79
|
+
/**
|
|
80
|
+
* Sync Engine — coordinates push/pull operations between Stoneforge and external services.
|
|
81
|
+
*
|
|
82
|
+
* The engine is stateless — all state is stored in element metadata (_externalSync)
|
|
83
|
+
* and settings (sync cursors). Each operation reads current state, computes changes,
|
|
84
|
+
* and writes updated state.
|
|
85
|
+
*/
|
|
86
|
+
export class SyncEngine {
|
|
87
|
+
api;
|
|
88
|
+
registry;
|
|
89
|
+
settings;
|
|
90
|
+
conflictResolver;
|
|
91
|
+
defaultStrategy;
|
|
92
|
+
providerConfigs;
|
|
93
|
+
constructor(config) {
|
|
94
|
+
this.api = config.api;
|
|
95
|
+
this.registry = config.registry;
|
|
96
|
+
this.settings = config.settings;
|
|
97
|
+
this.conflictResolver = config.conflictResolver ?? defaultConflictResolver;
|
|
98
|
+
this.defaultStrategy = config.defaultConflictStrategy ?? 'last_write_wins';
|
|
99
|
+
this.providerConfigs = config.providerConfigs ?? [];
|
|
100
|
+
}
|
|
101
|
+
// --------------------------------------------------------------------------
|
|
102
|
+
// Push — push locally-changed linked elements to external services
|
|
103
|
+
// --------------------------------------------------------------------------
|
|
104
|
+
/**
|
|
105
|
+
* Push locally-changed linked elements to external services.
|
|
106
|
+
*
|
|
107
|
+
* Algorithm:
|
|
108
|
+
* 1. Find elements with _externalSync metadata
|
|
109
|
+
* 2. For each element, query events since lastPushedAt
|
|
110
|
+
* 3. Compare current content hash against lastPushedHash
|
|
111
|
+
* 4. If hash differs, push changes to external service via adapter
|
|
112
|
+
* 5. Update _externalSync metadata with new timestamp and hash
|
|
113
|
+
*
|
|
114
|
+
* @param options - Push options (dryRun, taskIds, all)
|
|
115
|
+
* @returns Aggregated sync result across all providers
|
|
116
|
+
*/
|
|
117
|
+
async push(options = {}) {
|
|
118
|
+
const pushed = [];
|
|
119
|
+
const skipped = [];
|
|
120
|
+
const conflicts = [];
|
|
121
|
+
const errors = [];
|
|
122
|
+
const now = createTimestamp();
|
|
123
|
+
// Step 1: Find elements to push
|
|
124
|
+
let elements = await this.findLinkedElements(options);
|
|
125
|
+
// Step 1.5: Batch-resolve library paths for all document elements
|
|
126
|
+
// This avoids N+1 queries by resolving all paths upfront
|
|
127
|
+
const documentElements = elements.filter(el => {
|
|
128
|
+
const syncState = getExternalSyncState(el.metadata);
|
|
129
|
+
return syncState?.adapterType === 'document';
|
|
130
|
+
});
|
|
131
|
+
const libraryPaths = documentElements.length > 0
|
|
132
|
+
? await resolveDocumentLibraryPaths(this.api, documentElements.map(el => el.id))
|
|
133
|
+
: new Map();
|
|
134
|
+
// Step 1.6: Filter out documents without a library unless includeNoLibrary is set
|
|
135
|
+
let noLibrarySkipped = 0;
|
|
136
|
+
if (!options.includeNoLibrary && documentElements.length > 0) {
|
|
137
|
+
const beforeCount = elements.length;
|
|
138
|
+
elements = elements.filter(el => {
|
|
139
|
+
const syncState = getExternalSyncState(el.metadata);
|
|
140
|
+
if (syncState?.adapterType !== 'document')
|
|
141
|
+
return true; // keep non-documents
|
|
142
|
+
return libraryPaths.has(el.id);
|
|
143
|
+
});
|
|
144
|
+
noLibrarySkipped = beforeCount - elements.length;
|
|
145
|
+
}
|
|
146
|
+
// Notify caller of total count before processing begins
|
|
147
|
+
options.onBeforeProcess?.(elements.length);
|
|
148
|
+
// Step 2: Process elements concurrently in batches
|
|
149
|
+
let processedCount = 0;
|
|
150
|
+
// Notify caller of total element count on first progress callback
|
|
151
|
+
options.onProgress?.(0, elements.length);
|
|
152
|
+
for (let i = 0; i < elements.length; i += PUSH_CONCURRENCY) {
|
|
153
|
+
const batch = elements.slice(i, i + PUSH_CONCURRENCY);
|
|
154
|
+
const results = await Promise.allSettled(batch.map(element => this.pushSingleElement(element, options, now, libraryPaths)));
|
|
155
|
+
for (let j = 0; j < results.length; j++) {
|
|
156
|
+
const result = results[j];
|
|
157
|
+
if (result.status === 'fulfilled') {
|
|
158
|
+
const outcome = result.value;
|
|
159
|
+
if (outcome === 'pushed') {
|
|
160
|
+
pushed.push(1);
|
|
161
|
+
}
|
|
162
|
+
else if (outcome === 'skipped') {
|
|
163
|
+
skipped.push(1);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
// rejected — extract error info
|
|
168
|
+
const element = batch[j];
|
|
169
|
+
const syncState = getExternalSyncState(element.metadata);
|
|
170
|
+
const err = result.reason;
|
|
171
|
+
errors.push({
|
|
172
|
+
elementId: element.id,
|
|
173
|
+
externalId: syncState?.externalId ?? '',
|
|
174
|
+
provider: syncState?.provider ?? 'unknown',
|
|
175
|
+
project: syncState?.project ?? 'unknown',
|
|
176
|
+
message: err instanceof Error ? err.message : String(err),
|
|
177
|
+
retryable: isRetryableError(err),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
processedCount += batch.length;
|
|
182
|
+
options.onProgress?.(processedCount, elements.length);
|
|
183
|
+
}
|
|
184
|
+
return buildResult({
|
|
185
|
+
pushed: pushed.length,
|
|
186
|
+
pulled: 0,
|
|
187
|
+
skipped: skipped.length,
|
|
188
|
+
conflicts,
|
|
189
|
+
errors,
|
|
190
|
+
provider: this.getPrimaryProvider(),
|
|
191
|
+
project: this.getPrimaryProject(),
|
|
192
|
+
noLibrarySkipped,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Process a single element for push — validates sync state, checks skip conditions,
|
|
197
|
+
* and routes to the appropriate push method (pushElement or pushDocument).
|
|
198
|
+
*
|
|
199
|
+
* Extracted from the push() loop body to enable concurrent execution via Promise.allSettled.
|
|
200
|
+
*
|
|
201
|
+
* @returns 'pushed' if changes were sent, 'skipped' if element was skipped
|
|
202
|
+
*/
|
|
203
|
+
async pushSingleElement(element, options, now, libraryPaths = new Map()) {
|
|
204
|
+
const syncState = getExternalSyncState(element.metadata);
|
|
205
|
+
if (!syncState) {
|
|
206
|
+
// No sync state — shouldn't happen since we filtered for it, but guard
|
|
207
|
+
return 'skipped';
|
|
208
|
+
}
|
|
209
|
+
// Skip if direction is pull-only
|
|
210
|
+
if (syncState.direction === 'pull') {
|
|
211
|
+
return 'skipped';
|
|
212
|
+
}
|
|
213
|
+
// Skip closed/tombstone tasks and archived documents — they're done
|
|
214
|
+
// and shouldn't sync. If an element is later reopened/reactivated,
|
|
215
|
+
// it will be picked up again naturally since the filter no longer applies.
|
|
216
|
+
const elementStatus = element.status;
|
|
217
|
+
if (elementStatus === 'closed' || elementStatus === 'tombstone' || elementStatus === 'archived') {
|
|
218
|
+
return 'skipped';
|
|
219
|
+
}
|
|
220
|
+
// Route by adapter type: tasks → pushElement(), documents → pushDocument()
|
|
221
|
+
if (syncState.adapterType === 'document') {
|
|
222
|
+
const libraryPath = libraryPaths.get(element.id);
|
|
223
|
+
return this.pushDocument(element, syncState, options, now, libraryPath);
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
return this.pushElement(element, syncState, options, now);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Push a single element to its external service.
|
|
231
|
+
*
|
|
232
|
+
* @returns 'pushed' if changes were sent, 'skipped' if no changes detected
|
|
233
|
+
*/
|
|
234
|
+
async pushElement(element, syncState, options, now) {
|
|
235
|
+
// Check for actual content change via hash (skip when force is true)
|
|
236
|
+
const currentHash = computeContentHashSync(element).hash;
|
|
237
|
+
if (!options.force && syncState.lastPushedHash && currentHash === syncState.lastPushedHash) {
|
|
238
|
+
return 'skipped';
|
|
239
|
+
}
|
|
240
|
+
// Verify events have occurred since last push (skip when force is true)
|
|
241
|
+
if (!options.force && syncState.lastPushedAt) {
|
|
242
|
+
const events = await this.api.listEvents({
|
|
243
|
+
elementId: element.id,
|
|
244
|
+
eventType: ['updated', 'closed', 'reopened'],
|
|
245
|
+
after: syncState.lastPushedAt,
|
|
246
|
+
});
|
|
247
|
+
if (events.length === 0) {
|
|
248
|
+
return 'skipped';
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
// Dry run — report but don't actually push
|
|
252
|
+
if (options.dryRun) {
|
|
253
|
+
return 'pushed';
|
|
254
|
+
}
|
|
255
|
+
// Get the adapter for this element's provider
|
|
256
|
+
const adapter = this.getTaskAdapter(syncState.provider);
|
|
257
|
+
if (!adapter) {
|
|
258
|
+
throw new Error(`No task adapter found for provider '${syncState.provider}'`);
|
|
259
|
+
}
|
|
260
|
+
// Build external task input using the shared field mapping utilities.
|
|
261
|
+
// This properly converts priority → sf:priority:* labels, taskType → sf:type:* labels,
|
|
262
|
+
// status → open/closed state, hydrates description, and resolves assignees.
|
|
263
|
+
const fieldMapConfig = getFieldMapConfigForProvider(syncState.provider);
|
|
264
|
+
const taskInput = await taskToExternalTask(element, fieldMapConfig, this.api);
|
|
265
|
+
// Push to external service
|
|
266
|
+
await adapter.updateIssue(syncState.project, syncState.externalId, taskInput);
|
|
267
|
+
// Update sync state on element
|
|
268
|
+
const updatedSyncState = {
|
|
269
|
+
...syncState,
|
|
270
|
+
lastPushedAt: now,
|
|
271
|
+
lastPushedHash: currentHash,
|
|
272
|
+
};
|
|
273
|
+
await this.api.update(element.id, {
|
|
274
|
+
metadata: setExternalSyncState(element.metadata, updatedSyncState),
|
|
275
|
+
});
|
|
276
|
+
return 'pushed';
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Push a single document to its external service.
|
|
280
|
+
*
|
|
281
|
+
* Parallel to pushElement(), but uses document-specific conversion
|
|
282
|
+
* (documentToExternalDocumentInput) and the DocumentSyncAdapter.
|
|
283
|
+
*
|
|
284
|
+
* @returns 'pushed' if changes were sent, 'skipped' if no changes detected
|
|
285
|
+
*/
|
|
286
|
+
async pushDocument(element, syncState, options, now, libraryPath) {
|
|
287
|
+
// Check for actual content change via hash (skip when force is true)
|
|
288
|
+
const currentHash = computeContentHashSync(element).hash;
|
|
289
|
+
if (!options.force && syncState.lastPushedHash && currentHash === syncState.lastPushedHash) {
|
|
290
|
+
return 'skipped';
|
|
291
|
+
}
|
|
292
|
+
// Verify events have occurred since last push (skip when force is true)
|
|
293
|
+
if (!options.force && syncState.lastPushedAt) {
|
|
294
|
+
const events = await this.api.listEvents({
|
|
295
|
+
elementId: element.id,
|
|
296
|
+
eventType: ['updated'],
|
|
297
|
+
after: syncState.lastPushedAt,
|
|
298
|
+
});
|
|
299
|
+
if (events.length === 0) {
|
|
300
|
+
return 'skipped';
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// Dry run — report but don't actually push
|
|
304
|
+
if (options.dryRun) {
|
|
305
|
+
return 'pushed';
|
|
306
|
+
}
|
|
307
|
+
// Get the document adapter for this element's provider
|
|
308
|
+
const adapter = this.getDocumentAdapter(syncState.provider);
|
|
309
|
+
if (!adapter) {
|
|
310
|
+
throw new Error(`No document adapter found for provider '${syncState.provider}'`);
|
|
311
|
+
}
|
|
312
|
+
// Build external document input using the shared field mapping utilities
|
|
313
|
+
const docInput = documentToExternalDocumentInput(element, libraryPath);
|
|
314
|
+
// Push to external service
|
|
315
|
+
await adapter.updatePage(syncState.project, syncState.externalId, docInput);
|
|
316
|
+
// Update sync state on element
|
|
317
|
+
const updatedSyncState = {
|
|
318
|
+
...syncState,
|
|
319
|
+
lastPushedAt: now,
|
|
320
|
+
lastPushedHash: currentHash,
|
|
321
|
+
};
|
|
322
|
+
await this.api.update(element.id, {
|
|
323
|
+
metadata: setExternalSyncState(element.metadata, updatedSyncState),
|
|
324
|
+
});
|
|
325
|
+
return 'pushed';
|
|
326
|
+
}
|
|
327
|
+
// --------------------------------------------------------------------------
|
|
328
|
+
// Pull — pull externally-changed items into Stoneforge
|
|
329
|
+
// --------------------------------------------------------------------------
|
|
330
|
+
/**
|
|
331
|
+
* Pull externally-changed items into Stoneforge.
|
|
332
|
+
*
|
|
333
|
+
* Algorithm:
|
|
334
|
+
* 1. For each configured provider, get the sync cursor (last poll timestamp)
|
|
335
|
+
* 2. Call adapter.listIssuesSince(project, cursor) to find changed items
|
|
336
|
+
* 3. For each changed item:
|
|
337
|
+
* a. If linked to a local element, compare against lastPulledHash
|
|
338
|
+
* b. If unlinked and options.all is set, create a new Stoneforge task
|
|
339
|
+
* c. If both local and remote changed, use conflict resolver
|
|
340
|
+
* 4. Update sync cursors and element metadata
|
|
341
|
+
*
|
|
342
|
+
* @param options - Pull options (dryRun, taskIds, all)
|
|
343
|
+
* @returns Aggregated sync result
|
|
344
|
+
*/
|
|
345
|
+
async pull(options = {}) {
|
|
346
|
+
const pulled = [];
|
|
347
|
+
const skipped = [];
|
|
348
|
+
const conflicts = [];
|
|
349
|
+
const errors = [];
|
|
350
|
+
const now = createTimestamp();
|
|
351
|
+
const adapterFilter = options.adapterTypes && options.adapterTypes.length > 0
|
|
352
|
+
? new Set(options.adapterTypes)
|
|
353
|
+
: null;
|
|
354
|
+
// Get all task adapters from the registry (skip if filtered out)
|
|
355
|
+
if (!adapterFilter || adapterFilter.has('task')) {
|
|
356
|
+
const taskAdapterEntries = this.registry.getAdaptersOfType('task');
|
|
357
|
+
for (const { provider, adapter } of taskAdapterEntries) {
|
|
358
|
+
const taskAdapter = adapter;
|
|
359
|
+
const providerConfig = this.getProviderConfig(provider.name);
|
|
360
|
+
const project = providerConfig?.defaultProject;
|
|
361
|
+
if (!project) {
|
|
362
|
+
// No project configured for this provider — skip
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
const result = await this.pullFromProvider(provider, taskAdapter, project, options, now, conflicts, errors);
|
|
367
|
+
pulled.push(result.pulled);
|
|
368
|
+
skipped.push(result.skipped);
|
|
369
|
+
}
|
|
370
|
+
catch (err) {
|
|
371
|
+
errors.push({
|
|
372
|
+
provider: provider.name,
|
|
373
|
+
project,
|
|
374
|
+
message: err instanceof Error ? err.message : String(err),
|
|
375
|
+
retryable: isRetryableError(err),
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
// Get all document adapters from the registry (skip if filtered out)
|
|
381
|
+
if (!adapterFilter || adapterFilter.has('document')) {
|
|
382
|
+
const docAdapterEntries = this.registry.getAdaptersOfType('document');
|
|
383
|
+
for (const { provider, adapter } of docAdapterEntries) {
|
|
384
|
+
const docAdapter = adapter;
|
|
385
|
+
const providerConfig = this.getProviderConfig(provider.name);
|
|
386
|
+
const project = providerConfig?.defaultProject;
|
|
387
|
+
if (!project) {
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
try {
|
|
391
|
+
const result = await this.pullDocumentsFromProvider(provider, docAdapter, project, options, now, conflicts, errors);
|
|
392
|
+
pulled.push(result.pulled);
|
|
393
|
+
skipped.push(result.skipped);
|
|
394
|
+
}
|
|
395
|
+
catch (err) {
|
|
396
|
+
errors.push({
|
|
397
|
+
provider: provider.name,
|
|
398
|
+
project,
|
|
399
|
+
message: err instanceof Error ? err.message : String(err),
|
|
400
|
+
retryable: isRetryableError(err),
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return buildResult({
|
|
406
|
+
pushed: 0,
|
|
407
|
+
pulled: pulled.reduce((a, b) => a + b, 0),
|
|
408
|
+
skipped: skipped.reduce((a, b) => a + b, 0),
|
|
409
|
+
conflicts,
|
|
410
|
+
errors,
|
|
411
|
+
provider: this.getPrimaryProvider(),
|
|
412
|
+
project: this.getPrimaryProject(),
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Pull changes from a specific provider.
|
|
417
|
+
*/
|
|
418
|
+
async pullFromProvider(provider, adapter, project, options, now, conflicts, errors) {
|
|
419
|
+
let pulledCount = 0;
|
|
420
|
+
let skippedCount = 0;
|
|
421
|
+
// Get the sync cursor for this provider+project
|
|
422
|
+
const syncCursor = this.getSyncCursor(provider.name, project, 'task');
|
|
423
|
+
// Fetch changed items since cursor
|
|
424
|
+
const externalItems = await adapter.listIssuesSince(project, syncCursor);
|
|
425
|
+
// Get all locally-linked elements for matching
|
|
426
|
+
const linkedElements = await this.findLinkedElementsForProvider(provider.name, project);
|
|
427
|
+
// Build a map of externalId → local element for fast lookup
|
|
428
|
+
const linkedByExternalId = new Map();
|
|
429
|
+
for (const el of linkedElements) {
|
|
430
|
+
const state = getExternalSyncState(el.metadata);
|
|
431
|
+
if (state) {
|
|
432
|
+
linkedByExternalId.set(state.externalId, el);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
for (const externalItem of externalItems) {
|
|
436
|
+
// If taskIds filter is set, check if this external item matches
|
|
437
|
+
if (options.taskIds) {
|
|
438
|
+
const localEl = linkedByExternalId.get(externalItem.externalId);
|
|
439
|
+
if (!localEl || !options.taskIds.includes(localEl.id)) {
|
|
440
|
+
skippedCount++;
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
try {
|
|
445
|
+
const result = await this.pullItem(provider, project, externalItem, linkedByExternalId, options, now, conflicts);
|
|
446
|
+
if (result === 'pulled') {
|
|
447
|
+
pulledCount++;
|
|
448
|
+
}
|
|
449
|
+
else if (result === 'skipped') {
|
|
450
|
+
skippedCount++;
|
|
451
|
+
}
|
|
452
|
+
else if (result === 'created') {
|
|
453
|
+
pulledCount++;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
catch (err) {
|
|
457
|
+
errors.push({
|
|
458
|
+
externalId: externalItem.externalId,
|
|
459
|
+
provider: provider.name,
|
|
460
|
+
project,
|
|
461
|
+
message: err instanceof Error ? err.message : String(err),
|
|
462
|
+
retryable: isRetryableError(err),
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
// Update sync cursor (unless dry run)
|
|
467
|
+
if (!options.dryRun && externalItems.length > 0) {
|
|
468
|
+
this.setSyncCursor(provider.name, project, 'task', now);
|
|
469
|
+
}
|
|
470
|
+
return { pulled: pulledCount, skipped: skippedCount };
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Pull a single external item into Stoneforge.
|
|
474
|
+
*
|
|
475
|
+
* @returns 'pulled' if local element was updated, 'skipped' if no changes,
|
|
476
|
+
* 'created' if a new task was created
|
|
477
|
+
*/
|
|
478
|
+
async pullItem(provider, project, externalItem, linkedByExternalId, options, now, conflicts) {
|
|
479
|
+
const localElement = linkedByExternalId.get(externalItem.externalId);
|
|
480
|
+
if (!localElement) {
|
|
481
|
+
// Unlinked external item — create new task if --all flag is set
|
|
482
|
+
if (options.all) {
|
|
483
|
+
if (options.dryRun) {
|
|
484
|
+
return 'created';
|
|
485
|
+
}
|
|
486
|
+
await this.createTaskFromExternal(provider, project, externalItem, now);
|
|
487
|
+
return 'created';
|
|
488
|
+
}
|
|
489
|
+
return 'skipped';
|
|
490
|
+
}
|
|
491
|
+
// Linked element — check for actual change via hash
|
|
492
|
+
const syncState = getExternalSyncState(localElement.metadata);
|
|
493
|
+
// Skip if direction is push-only
|
|
494
|
+
if (syncState.direction === 'push') {
|
|
495
|
+
return 'skipped';
|
|
496
|
+
}
|
|
497
|
+
// Skip updates to closed/tombstone tasks UNLESS the external item is open
|
|
498
|
+
// (which means someone reopened the issue externally — we should sync that).
|
|
499
|
+
const localStatus = localElement.status;
|
|
500
|
+
if ((localStatus === 'closed' || localStatus === 'tombstone') && externalItem.state !== 'open') {
|
|
501
|
+
return 'skipped';
|
|
502
|
+
}
|
|
503
|
+
// Compute a hash of the external item content to detect real changes
|
|
504
|
+
const remoteContentKey = computeExternalItemHash(externalItem);
|
|
505
|
+
if (syncState.lastPulledHash && remoteContentKey === syncState.lastPulledHash) {
|
|
506
|
+
return 'skipped';
|
|
507
|
+
}
|
|
508
|
+
// Check for conflict: has local also changed since last pull?
|
|
509
|
+
const localHash = computeContentHashSync(localElement).hash;
|
|
510
|
+
const localChanged = syncState.lastPushedHash !== undefined && localHash !== syncState.lastPushedHash;
|
|
511
|
+
if (localChanged) {
|
|
512
|
+
// Both sides changed — use conflict resolver
|
|
513
|
+
const resolution = this.conflictResolver.resolve(localElement, externalItem, this.defaultStrategy);
|
|
514
|
+
conflicts.push({
|
|
515
|
+
elementId: localElement.id,
|
|
516
|
+
externalId: externalItem.externalId,
|
|
517
|
+
provider: provider.name,
|
|
518
|
+
project,
|
|
519
|
+
localUpdatedAt: localElement.updatedAt,
|
|
520
|
+
remoteUpdatedAt: externalItem.updatedAt,
|
|
521
|
+
strategy: this.defaultStrategy,
|
|
522
|
+
resolved: resolution.resolved,
|
|
523
|
+
winner: resolution.resolved ? resolution.winner : undefined,
|
|
524
|
+
});
|
|
525
|
+
if (!resolution.resolved) {
|
|
526
|
+
// Manual resolution needed — tag the element
|
|
527
|
+
if (!options.dryRun) {
|
|
528
|
+
const tags = localElement.tags.includes('sync-conflict')
|
|
529
|
+
? localElement.tags
|
|
530
|
+
: [...localElement.tags, 'sync-conflict'];
|
|
531
|
+
await this.api.update(localElement.id, { tags });
|
|
532
|
+
}
|
|
533
|
+
return 'skipped';
|
|
534
|
+
}
|
|
535
|
+
if (resolution.winner === 'local') {
|
|
536
|
+
// Local wins — skip the pull, but update the pulled hash
|
|
537
|
+
if (!options.dryRun) {
|
|
538
|
+
const updatedSyncState = {
|
|
539
|
+
...syncState,
|
|
540
|
+
lastPulledAt: now,
|
|
541
|
+
lastPulledHash: remoteContentKey,
|
|
542
|
+
};
|
|
543
|
+
await this.api.update(localElement.id, {
|
|
544
|
+
metadata: setExternalSyncState(localElement.metadata, updatedSyncState),
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
return 'skipped';
|
|
548
|
+
}
|
|
549
|
+
// Remote wins — fall through to apply remote changes
|
|
550
|
+
}
|
|
551
|
+
// Dry run — report but don't actually apply
|
|
552
|
+
if (options.dryRun) {
|
|
553
|
+
return 'pulled';
|
|
554
|
+
}
|
|
555
|
+
// Apply remote changes to local element using provider field map config.
|
|
556
|
+
// Pass the existing task for diff mode (only changed fields are returned).
|
|
557
|
+
const updates = this.externalItemToUpdates(externalItem, localElement);
|
|
558
|
+
const updatedSyncState = {
|
|
559
|
+
...syncState,
|
|
560
|
+
lastPulledAt: now,
|
|
561
|
+
lastPulledHash: remoteContentKey,
|
|
562
|
+
};
|
|
563
|
+
// Merge metadata: preserve existing metadata, add any from updates
|
|
564
|
+
// (e.g., _pendingAssignee), and set the updated sync state.
|
|
565
|
+
const updatesMetadata = (updates.metadata ?? {});
|
|
566
|
+
const mergedMetadata = setExternalSyncState({ ...localElement.metadata, ...updatesMetadata }, updatedSyncState);
|
|
567
|
+
// Handle description (body) sync from external item.
|
|
568
|
+
// The body is not included in externalItemToUpdates() because it requires
|
|
569
|
+
// separate document creation/update operations.
|
|
570
|
+
const descriptionRefUpdate = await this.syncDescriptionFromExternal(localElement, externalItem);
|
|
571
|
+
await this.api.update(localElement.id, {
|
|
572
|
+
...updates,
|
|
573
|
+
...descriptionRefUpdate,
|
|
574
|
+
metadata: mergedMetadata,
|
|
575
|
+
});
|
|
576
|
+
return 'pulled';
|
|
577
|
+
}
|
|
578
|
+
// --------------------------------------------------------------------------
|
|
579
|
+
// Pull — document-specific helpers
|
|
580
|
+
// --------------------------------------------------------------------------
|
|
581
|
+
/**
|
|
582
|
+
* Pull document changes from a specific provider.
|
|
583
|
+
* Parallel to pullFromProvider(), but uses the DocumentSyncAdapter.
|
|
584
|
+
*/
|
|
585
|
+
async pullDocumentsFromProvider(provider, adapter, project, options, now, conflicts, errors) {
|
|
586
|
+
let pulledCount = 0;
|
|
587
|
+
let skippedCount = 0;
|
|
588
|
+
// Get the sync cursor for this provider+project for documents
|
|
589
|
+
const syncCursor = this.getSyncCursor(provider.name, project, 'document');
|
|
590
|
+
// Fetch changed documents since cursor
|
|
591
|
+
const allExternalDocs = await adapter.listPagesSince(project, syncCursor);
|
|
592
|
+
// Skip external documents with empty or whitespace-only titles
|
|
593
|
+
const externalDocs = allExternalDocs.filter((doc) => doc.title && doc.title.trim().length > 0);
|
|
594
|
+
// Get all locally-linked document elements for matching
|
|
595
|
+
const linkedElements = await this.findLinkedElementsForProvider(provider.name, project);
|
|
596
|
+
// Filter to only document-type linked elements
|
|
597
|
+
const linkedDocuments = linkedElements.filter((el) => {
|
|
598
|
+
const state = getExternalSyncState(el.metadata);
|
|
599
|
+
return state && state.adapterType === 'document';
|
|
600
|
+
});
|
|
601
|
+
// Build a map of externalId → local element for fast lookup
|
|
602
|
+
const linkedByExternalId = new Map();
|
|
603
|
+
for (const el of linkedDocuments) {
|
|
604
|
+
const state = getExternalSyncState(el.metadata);
|
|
605
|
+
if (state) {
|
|
606
|
+
linkedByExternalId.set(state.externalId, el);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
for (const externalDoc of externalDocs) {
|
|
610
|
+
// If taskIds filter is set (used generically for element IDs), check if this external doc matches
|
|
611
|
+
if (options.taskIds) {
|
|
612
|
+
const localEl = linkedByExternalId.get(externalDoc.externalId);
|
|
613
|
+
if (!localEl || !options.taskIds.includes(localEl.id)) {
|
|
614
|
+
skippedCount++;
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
try {
|
|
619
|
+
const result = await this.pullDocumentItem(provider, project, externalDoc, linkedByExternalId, options, now, conflicts);
|
|
620
|
+
if (result === 'pulled') {
|
|
621
|
+
pulledCount++;
|
|
622
|
+
}
|
|
623
|
+
else if (result === 'skipped') {
|
|
624
|
+
skippedCount++;
|
|
625
|
+
}
|
|
626
|
+
else if (result === 'created') {
|
|
627
|
+
pulledCount++;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
catch (err) {
|
|
631
|
+
errors.push({
|
|
632
|
+
externalId: externalDoc.externalId,
|
|
633
|
+
provider: provider.name,
|
|
634
|
+
project,
|
|
635
|
+
message: err instanceof Error ? err.message : String(err),
|
|
636
|
+
retryable: isRetryableError(err),
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
// Update sync cursor (unless dry run)
|
|
641
|
+
if (!options.dryRun && externalDocs.length > 0) {
|
|
642
|
+
this.setSyncCursor(provider.name, project, 'document', now);
|
|
643
|
+
}
|
|
644
|
+
return { pulled: pulledCount, skipped: skippedCount };
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Pull a single external document into Stoneforge.
|
|
648
|
+
* Parallel to pullItem(), but uses document-specific conversion.
|
|
649
|
+
*
|
|
650
|
+
* @returns 'pulled' if local element was updated, 'skipped' if no changes,
|
|
651
|
+
* 'created' if a new document was created
|
|
652
|
+
*/
|
|
653
|
+
async pullDocumentItem(provider, project, externalDoc, linkedByExternalId, options, now, conflicts) {
|
|
654
|
+
const localElement = linkedByExternalId.get(externalDoc.externalId);
|
|
655
|
+
if (!localElement) {
|
|
656
|
+
// Unlinked external document — create new document if --all flag is set
|
|
657
|
+
if (options.all) {
|
|
658
|
+
if (options.dryRun) {
|
|
659
|
+
return 'created';
|
|
660
|
+
}
|
|
661
|
+
await this.createDocumentFromExternal(provider, project, externalDoc, now);
|
|
662
|
+
return 'created';
|
|
663
|
+
}
|
|
664
|
+
return 'skipped';
|
|
665
|
+
}
|
|
666
|
+
// Linked element — check for actual change via hash
|
|
667
|
+
const syncState = getExternalSyncState(localElement.metadata);
|
|
668
|
+
// Skip if direction is push-only
|
|
669
|
+
if (syncState.direction === 'push') {
|
|
670
|
+
return 'skipped';
|
|
671
|
+
}
|
|
672
|
+
// Skip updates to archived documents
|
|
673
|
+
const localStatus = localElement.status;
|
|
674
|
+
if (localStatus === 'archived') {
|
|
675
|
+
return 'skipped';
|
|
676
|
+
}
|
|
677
|
+
// Compute a hash of the external document content to detect real changes
|
|
678
|
+
const remoteContentKey = computeExternalDocumentHash(externalDoc);
|
|
679
|
+
if (syncState.lastPulledHash && remoteContentKey === syncState.lastPulledHash) {
|
|
680
|
+
return 'skipped';
|
|
681
|
+
}
|
|
682
|
+
// Check for conflict: has local also changed since last pull?
|
|
683
|
+
const localHash = computeContentHashSync(localElement).hash;
|
|
684
|
+
const localChanged = syncState.lastPushedHash !== undefined && localHash !== syncState.lastPushedHash;
|
|
685
|
+
if (localChanged) {
|
|
686
|
+
// Both sides changed — use conflict resolver
|
|
687
|
+
const resolution = this.conflictResolver.resolve(localElement, externalDoc, this.defaultStrategy);
|
|
688
|
+
conflicts.push({
|
|
689
|
+
elementId: localElement.id,
|
|
690
|
+
externalId: externalDoc.externalId,
|
|
691
|
+
provider: provider.name,
|
|
692
|
+
project,
|
|
693
|
+
localUpdatedAt: localElement.updatedAt,
|
|
694
|
+
remoteUpdatedAt: externalDoc.updatedAt,
|
|
695
|
+
strategy: this.defaultStrategy,
|
|
696
|
+
resolved: resolution.resolved,
|
|
697
|
+
winner: resolution.resolved ? resolution.winner : undefined,
|
|
698
|
+
});
|
|
699
|
+
if (!resolution.resolved) {
|
|
700
|
+
// Manual resolution needed — tag the element
|
|
701
|
+
if (!options.dryRun) {
|
|
702
|
+
const tags = localElement.tags.includes('sync-conflict')
|
|
703
|
+
? localElement.tags
|
|
704
|
+
: [...localElement.tags, 'sync-conflict'];
|
|
705
|
+
await this.api.update(localElement.id, { tags });
|
|
706
|
+
}
|
|
707
|
+
return 'skipped';
|
|
708
|
+
}
|
|
709
|
+
if (resolution.winner === 'local') {
|
|
710
|
+
// Local wins — skip the pull, but update the pulled hash
|
|
711
|
+
if (!options.dryRun) {
|
|
712
|
+
const updatedSyncState = {
|
|
713
|
+
...syncState,
|
|
714
|
+
lastPulledAt: now,
|
|
715
|
+
lastPulledHash: remoteContentKey,
|
|
716
|
+
};
|
|
717
|
+
await this.api.update(localElement.id, {
|
|
718
|
+
metadata: setExternalSyncState(localElement.metadata, updatedSyncState),
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
return 'skipped';
|
|
722
|
+
}
|
|
723
|
+
// Remote wins — fall through to apply remote changes
|
|
724
|
+
}
|
|
725
|
+
// Dry run — report but don't actually apply
|
|
726
|
+
if (options.dryRun) {
|
|
727
|
+
return 'pulled';
|
|
728
|
+
}
|
|
729
|
+
// Apply remote changes to local document using document field mapping.
|
|
730
|
+
// Pass the existing document for diff mode (only changed fields are returned).
|
|
731
|
+
const updates = externalDocumentToDocumentUpdates(externalDoc, localElement);
|
|
732
|
+
const updatedSyncState = {
|
|
733
|
+
...syncState,
|
|
734
|
+
lastPulledAt: now,
|
|
735
|
+
lastPulledHash: remoteContentKey,
|
|
736
|
+
};
|
|
737
|
+
const mergedMetadata = setExternalSyncState({ ...localElement.metadata }, updatedSyncState);
|
|
738
|
+
await this.api.update(localElement.id, {
|
|
739
|
+
...updates,
|
|
740
|
+
metadata: mergedMetadata,
|
|
741
|
+
});
|
|
742
|
+
return 'pulled';
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* Create a new Stoneforge document from an unlinked external document.
|
|
746
|
+
*/
|
|
747
|
+
async createDocumentFromExternal(provider, project, externalDoc, now) {
|
|
748
|
+
const syncState = {
|
|
749
|
+
provider: provider.name,
|
|
750
|
+
project,
|
|
751
|
+
externalId: externalDoc.externalId,
|
|
752
|
+
url: externalDoc.url,
|
|
753
|
+
lastPulledAt: now,
|
|
754
|
+
lastPulledHash: computeExternalDocumentHash(externalDoc),
|
|
755
|
+
direction: 'bidirectional',
|
|
756
|
+
adapterType: 'document',
|
|
757
|
+
};
|
|
758
|
+
// Use the document field mapping for conversion
|
|
759
|
+
const docUpdates = externalDocumentToDocumentUpdates(externalDoc);
|
|
760
|
+
const createInput = {
|
|
761
|
+
type: 'document',
|
|
762
|
+
title: docUpdates.title ?? externalDoc.title,
|
|
763
|
+
content: docUpdates.content ?? externalDoc.content,
|
|
764
|
+
contentType: docUpdates.contentType ?? 'markdown',
|
|
765
|
+
status: 'active',
|
|
766
|
+
category: 'other',
|
|
767
|
+
version: 1,
|
|
768
|
+
previousVersionId: null,
|
|
769
|
+
immutable: false,
|
|
770
|
+
tags: [],
|
|
771
|
+
externalRef: externalDoc.url,
|
|
772
|
+
createdBy: 'system',
|
|
773
|
+
metadata: { _externalSync: syncState },
|
|
774
|
+
};
|
|
775
|
+
const element = await this.api.create(createInput);
|
|
776
|
+
return element;
|
|
777
|
+
}
|
|
778
|
+
// --------------------------------------------------------------------------
|
|
779
|
+
// Sync — bidirectional (push then pull)
|
|
780
|
+
// --------------------------------------------------------------------------
|
|
781
|
+
/**
|
|
782
|
+
* Bidirectional sync — push then pull.
|
|
783
|
+
*
|
|
784
|
+
* Runs push first to send local changes, then pull to receive remote changes.
|
|
785
|
+
* Results are merged from both operations.
|
|
786
|
+
*
|
|
787
|
+
* @param options - Sync options (dryRun, taskIds, all)
|
|
788
|
+
* @returns Merged sync result
|
|
789
|
+
*/
|
|
790
|
+
async sync(options = {}) {
|
|
791
|
+
const pushResult = await this.push(options);
|
|
792
|
+
const pullResult = await this.pull(options);
|
|
793
|
+
return {
|
|
794
|
+
success: pushResult.success && pullResult.success,
|
|
795
|
+
provider: pushResult.provider || pullResult.provider,
|
|
796
|
+
project: pushResult.project || pullResult.project,
|
|
797
|
+
adapterType: pushResult.adapterType || pullResult.adapterType,
|
|
798
|
+
pushed: pushResult.pushed,
|
|
799
|
+
pulled: pullResult.pulled,
|
|
800
|
+
skipped: pushResult.skipped + pullResult.skipped,
|
|
801
|
+
conflicts: [...pushResult.conflicts, ...pullResult.conflicts],
|
|
802
|
+
errors: [...pushResult.errors, ...pullResult.errors],
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
// --------------------------------------------------------------------------
|
|
806
|
+
// Element Discovery
|
|
807
|
+
// --------------------------------------------------------------------------
|
|
808
|
+
/**
|
|
809
|
+
* Find elements that are linked to external services and match the given options.
|
|
810
|
+
*
|
|
811
|
+
* - If taskIds is specified, returns only those tasks (that have _externalSync)
|
|
812
|
+
* - If all is true, returns all tasks with _externalSync metadata
|
|
813
|
+
* - Otherwise, returns tasks with _externalSync that have been updated
|
|
814
|
+
*/
|
|
815
|
+
async findLinkedElements(options) {
|
|
816
|
+
const adapterFilter = options.adapterTypes && options.adapterTypes.length > 0
|
|
817
|
+
? new Set(options.adapterTypes)
|
|
818
|
+
: null;
|
|
819
|
+
if (options.taskIds && options.taskIds.length > 0) {
|
|
820
|
+
// Fetch specific elements (tasks or documents)
|
|
821
|
+
const elements = [];
|
|
822
|
+
for (const id of options.taskIds) {
|
|
823
|
+
const element = await this.api.get(id);
|
|
824
|
+
if (element) {
|
|
825
|
+
const state = getExternalSyncState(element.metadata);
|
|
826
|
+
if (state && (!adapterFilter || adapterFilter.has(state.adapterType))) {
|
|
827
|
+
elements.push(element);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
return elements;
|
|
832
|
+
}
|
|
833
|
+
// Fetch elements based on adapter type filter
|
|
834
|
+
const collections = [];
|
|
835
|
+
if (!adapterFilter || adapterFilter.has('task')) {
|
|
836
|
+
collections.push(await this.api.list({ type: 'task' }));
|
|
837
|
+
}
|
|
838
|
+
if (!adapterFilter || adapterFilter.has('document')) {
|
|
839
|
+
collections.push(await this.api.list({ type: 'document' }));
|
|
840
|
+
}
|
|
841
|
+
return collections.flat().filter((el) => {
|
|
842
|
+
const state = getExternalSyncState(el.metadata);
|
|
843
|
+
return state !== undefined && (!adapterFilter || adapterFilter.has(state.adapterType));
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Find elements linked to a specific provider and project.
|
|
848
|
+
*/
|
|
849
|
+
async findLinkedElementsForProvider(providerName, project) {
|
|
850
|
+
const allTasks = await this.api.list({ type: 'task' });
|
|
851
|
+
const allDocs = await this.api.list({ type: 'document' });
|
|
852
|
+
return [...allTasks, ...allDocs].filter((el) => {
|
|
853
|
+
const state = getExternalSyncState(el.metadata);
|
|
854
|
+
return state && state.provider === providerName && state.project === project;
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
// --------------------------------------------------------------------------
|
|
858
|
+
// Field Mapping Utilities
|
|
859
|
+
// --------------------------------------------------------------------------
|
|
860
|
+
/**
|
|
861
|
+
* Convert an ExternalTask to partial updates for applying to a local element.
|
|
862
|
+
*
|
|
863
|
+
* Delegates to externalTaskToTaskUpdates() from task-sync-adapter.ts, which
|
|
864
|
+
* uses the provider's TaskSyncFieldMapConfig for correct status, priority,
|
|
865
|
+
* taskType, and tag mapping. The provider is looked up from item.provider.
|
|
866
|
+
*
|
|
867
|
+
* @param item - The external task to convert
|
|
868
|
+
* @param existingTask - The existing local task (for diff mode), or undefined
|
|
869
|
+
* @returns Partial<Task> with the mapped fields
|
|
870
|
+
*/
|
|
871
|
+
externalItemToUpdates(item, existingTask) {
|
|
872
|
+
const config = getFieldMapConfigForProvider(item.provider);
|
|
873
|
+
return externalTaskToTaskUpdates(item, existingTask, config);
|
|
874
|
+
}
|
|
875
|
+
// --------------------------------------------------------------------------
|
|
876
|
+
// Task Creation (for pull with --all)
|
|
877
|
+
// --------------------------------------------------------------------------
|
|
878
|
+
/**
|
|
879
|
+
* Create a new Stoneforge task from an unlinked external item.
|
|
880
|
+
*/
|
|
881
|
+
async createTaskFromExternal(provider, project, item, now) {
|
|
882
|
+
const syncState = {
|
|
883
|
+
provider: provider.name,
|
|
884
|
+
project,
|
|
885
|
+
externalId: item.externalId,
|
|
886
|
+
url: item.url,
|
|
887
|
+
lastPulledAt: now,
|
|
888
|
+
lastPulledHash: computeExternalItemHash(item),
|
|
889
|
+
direction: 'bidirectional',
|
|
890
|
+
adapterType: 'task',
|
|
891
|
+
};
|
|
892
|
+
// Use the provider's field map config for correct status, priority,
|
|
893
|
+
// taskType, and tag mapping — instead of hardcoded open/closed.
|
|
894
|
+
const taskUpdates = this.externalItemToUpdates(item);
|
|
895
|
+
// Merge metadata from field mapping (e.g., _pendingAssignee) with sync state
|
|
896
|
+
const taskMetadata = (taskUpdates.metadata ?? {});
|
|
897
|
+
// Create a description document if the external item has a body
|
|
898
|
+
let descriptionRef;
|
|
899
|
+
if (item.body && item.body.trim().length > 0) {
|
|
900
|
+
descriptionRef = await this.createDescriptionDocument(item.body, taskUpdates.title ?? item.title);
|
|
901
|
+
}
|
|
902
|
+
const createInput = {
|
|
903
|
+
type: 'task',
|
|
904
|
+
title: taskUpdates.title ?? item.title,
|
|
905
|
+
status: taskUpdates.status ?? 'open',
|
|
906
|
+
priority: taskUpdates.priority,
|
|
907
|
+
taskType: taskUpdates.taskType,
|
|
908
|
+
tags: taskUpdates.tags ?? [...item.labels],
|
|
909
|
+
externalRef: taskUpdates.externalRef ?? item.url,
|
|
910
|
+
createdBy: 'system',
|
|
911
|
+
metadata: { ...taskMetadata, _externalSync: syncState },
|
|
912
|
+
...(descriptionRef !== undefined && { descriptionRef }),
|
|
913
|
+
};
|
|
914
|
+
const element = await this.api.create(createInput);
|
|
915
|
+
return element;
|
|
916
|
+
}
|
|
917
|
+
// --------------------------------------------------------------------------
|
|
918
|
+
// Description Sync Helpers
|
|
919
|
+
// --------------------------------------------------------------------------
|
|
920
|
+
/**
|
|
921
|
+
* Sync the description (body) from an external item to the local task's
|
|
922
|
+
* description document.
|
|
923
|
+
*
|
|
924
|
+
* - If the task already has a descriptionRef, compare the body against the
|
|
925
|
+
* existing document content and update only if changed.
|
|
926
|
+
* - If the task has no descriptionRef and the body is non-empty, create a
|
|
927
|
+
* new document and return the descriptionRef to link.
|
|
928
|
+
* - If the body is empty/undefined, skip (don't delete existing descriptions).
|
|
929
|
+
*
|
|
930
|
+
* @returns Partial update with descriptionRef if a new document was created,
|
|
931
|
+
* or empty object if no descriptionRef change is needed.
|
|
932
|
+
*/
|
|
933
|
+
async syncDescriptionFromExternal(localTask, externalItem) {
|
|
934
|
+
const body = externalItem.body;
|
|
935
|
+
// If body is empty/undefined, skip — don't delete existing descriptions
|
|
936
|
+
if (!body || body.trim().length === 0) {
|
|
937
|
+
return {};
|
|
938
|
+
}
|
|
939
|
+
if (localTask.descriptionRef) {
|
|
940
|
+
// Task already has a description document — update if content changed
|
|
941
|
+
const existingDoc = await this.api.get(localTask.descriptionRef);
|
|
942
|
+
if (existingDoc && existingDoc.type === 'document') {
|
|
943
|
+
if (existingDoc.content !== body) {
|
|
944
|
+
await this.api.update(localTask.descriptionRef, {
|
|
945
|
+
content: body,
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
// No descriptionRef change needed
|
|
949
|
+
return {};
|
|
950
|
+
}
|
|
951
|
+
// Document not found — fall through to create a new one
|
|
952
|
+
}
|
|
953
|
+
// No existing description document — create a new one and link it
|
|
954
|
+
const newDescRef = await this.createDescriptionDocument(body, localTask.title);
|
|
955
|
+
return { descriptionRef: newDescRef };
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* Create a new description document for a task.
|
|
959
|
+
*
|
|
960
|
+
* Uses the same pattern as the server-side task creation:
|
|
961
|
+
* - contentType: 'markdown'
|
|
962
|
+
* - category: 'task-description' tag
|
|
963
|
+
* - createdBy: 'system'
|
|
964
|
+
*
|
|
965
|
+
* @param body - The description content
|
|
966
|
+
* @param taskTitle - The task title (used in the document title)
|
|
967
|
+
* @returns The DocumentId of the created document
|
|
968
|
+
*/
|
|
969
|
+
async createDescriptionDocument(body, taskTitle) {
|
|
970
|
+
const docInput = {
|
|
971
|
+
type: 'document',
|
|
972
|
+
contentType: 'markdown',
|
|
973
|
+
content: body,
|
|
974
|
+
createdBy: 'system',
|
|
975
|
+
tags: ['task-description'],
|
|
976
|
+
title: `Description for task ${taskTitle}`,
|
|
977
|
+
metadata: {},
|
|
978
|
+
version: 1,
|
|
979
|
+
previousVersionId: null,
|
|
980
|
+
category: 'task-description',
|
|
981
|
+
status: 'active',
|
|
982
|
+
immutable: false,
|
|
983
|
+
};
|
|
984
|
+
const createdDoc = await this.api.create(docInput);
|
|
985
|
+
return createdDoc.id;
|
|
986
|
+
}
|
|
987
|
+
// --------------------------------------------------------------------------
|
|
988
|
+
// Sync Cursor Management
|
|
989
|
+
// --------------------------------------------------------------------------
|
|
990
|
+
/**
|
|
991
|
+
* Get the sync cursor for a provider+project+adapterType.
|
|
992
|
+
* Returns a timestamp indicating the last time we polled this combination.
|
|
993
|
+
*/
|
|
994
|
+
getSyncCursor(provider, project, adapterType) {
|
|
995
|
+
if (!this.settings) {
|
|
996
|
+
// No settings service — return epoch
|
|
997
|
+
return '1970-01-01T00:00:00.000Z';
|
|
998
|
+
}
|
|
999
|
+
const key = buildCursorKey(provider, project, adapterType);
|
|
1000
|
+
const setting = this.settings.getSetting(key);
|
|
1001
|
+
if (setting && typeof setting.value === 'string') {
|
|
1002
|
+
return setting.value;
|
|
1003
|
+
}
|
|
1004
|
+
return '1970-01-01T00:00:00.000Z';
|
|
1005
|
+
}
|
|
1006
|
+
/**
|
|
1007
|
+
* Update the sync cursor for a provider+project+adapterType.
|
|
1008
|
+
*/
|
|
1009
|
+
setSyncCursor(provider, project, adapterType, cursor) {
|
|
1010
|
+
if (!this.settings) {
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
const key = buildCursorKey(provider, project, adapterType);
|
|
1014
|
+
this.settings.setSetting(key, cursor);
|
|
1015
|
+
}
|
|
1016
|
+
// --------------------------------------------------------------------------
|
|
1017
|
+
// Provider/Adapter Lookup
|
|
1018
|
+
// --------------------------------------------------------------------------
|
|
1019
|
+
/**
|
|
1020
|
+
* Get a TaskSyncAdapter for a given provider name.
|
|
1021
|
+
*/
|
|
1022
|
+
getTaskAdapter(providerName) {
|
|
1023
|
+
const provider = this.registry.get(providerName);
|
|
1024
|
+
if (!provider)
|
|
1025
|
+
return undefined;
|
|
1026
|
+
return provider.getTaskAdapter?.();
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* Get a DocumentSyncAdapter for a given provider name.
|
|
1030
|
+
*/
|
|
1031
|
+
getDocumentAdapter(providerName) {
|
|
1032
|
+
const provider = this.registry.get(providerName);
|
|
1033
|
+
if (!provider)
|
|
1034
|
+
return undefined;
|
|
1035
|
+
return provider.getDocumentAdapter?.();
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* Get the ProviderConfig for a given provider name.
|
|
1039
|
+
*/
|
|
1040
|
+
getProviderConfig(providerName) {
|
|
1041
|
+
return this.providerConfigs.find((c) => c.provider === providerName);
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* Get the primary provider name (for result reporting).
|
|
1045
|
+
*/
|
|
1046
|
+
getPrimaryProvider() {
|
|
1047
|
+
if (this.providerConfigs.length > 0) {
|
|
1048
|
+
return this.providerConfigs[0].provider;
|
|
1049
|
+
}
|
|
1050
|
+
const providers = this.registry.list();
|
|
1051
|
+
return providers.length > 0 ? providers[0].name : 'unknown';
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* Get the primary project (for result reporting).
|
|
1055
|
+
*/
|
|
1056
|
+
getPrimaryProject() {
|
|
1057
|
+
if (this.providerConfigs.length > 0 && this.providerConfigs[0].defaultProject) {
|
|
1058
|
+
return this.providerConfigs[0].defaultProject;
|
|
1059
|
+
}
|
|
1060
|
+
return '';
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
// ============================================================================
|
|
1064
|
+
// Helper Functions
|
|
1065
|
+
// ============================================================================
|
|
1066
|
+
/**
|
|
1067
|
+
* Compute a content hash for an external item.
|
|
1068
|
+
* Used to detect real changes from the remote side.
|
|
1069
|
+
* Hashes key semantic fields in a deterministic order.
|
|
1070
|
+
*/
|
|
1071
|
+
function computeExternalItemHash(item) {
|
|
1072
|
+
// Build deterministic content representation with sorted keys
|
|
1073
|
+
const contentFields = {
|
|
1074
|
+
assignees: [...item.assignees].sort(),
|
|
1075
|
+
body: item.body ?? '',
|
|
1076
|
+
labels: [...item.labels].sort(),
|
|
1077
|
+
priority: item.priority,
|
|
1078
|
+
state: item.state,
|
|
1079
|
+
title: item.title,
|
|
1080
|
+
};
|
|
1081
|
+
const hashInput = `external:${JSON.stringify(contentFields)}`;
|
|
1082
|
+
return createHash('sha256').update(hashInput).digest('hex');
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Check if an error is likely retryable (network issues, rate limits, etc.)
|
|
1086
|
+
*/
|
|
1087
|
+
function isRetryableError(err) {
|
|
1088
|
+
if (!(err instanceof Error))
|
|
1089
|
+
return false;
|
|
1090
|
+
const message = err.message.toLowerCase();
|
|
1091
|
+
return (message.includes('rate limit') ||
|
|
1092
|
+
message.includes('timeout') ||
|
|
1093
|
+
message.includes('gateway timeout') ||
|
|
1094
|
+
message.includes('bad gateway') ||
|
|
1095
|
+
message.includes('econnrefused') ||
|
|
1096
|
+
message.includes('enotfound') ||
|
|
1097
|
+
message.includes('network') ||
|
|
1098
|
+
message.includes('502') ||
|
|
1099
|
+
message.includes('503') ||
|
|
1100
|
+
message.includes('504') ||
|
|
1101
|
+
message.includes('429'));
|
|
1102
|
+
}
|
|
1103
|
+
/**
|
|
1104
|
+
* Build a standardized ExternalSyncResult.
|
|
1105
|
+
*/
|
|
1106
|
+
function buildResult(params) {
|
|
1107
|
+
return {
|
|
1108
|
+
success: params.errors.length === 0,
|
|
1109
|
+
provider: params.provider,
|
|
1110
|
+
project: params.project,
|
|
1111
|
+
adapterType: 'task',
|
|
1112
|
+
pushed: params.pushed,
|
|
1113
|
+
pulled: params.pulled,
|
|
1114
|
+
skipped: params.skipped,
|
|
1115
|
+
conflicts: params.conflicts,
|
|
1116
|
+
errors: params.errors,
|
|
1117
|
+
...(params.noLibrarySkipped !== undefined && params.noLibrarySkipped > 0
|
|
1118
|
+
? { noLibrarySkipped: params.noLibrarySkipped }
|
|
1119
|
+
: {}),
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
// ============================================================================
|
|
1123
|
+
// Factory
|
|
1124
|
+
// ============================================================================
|
|
1125
|
+
/**
|
|
1126
|
+
* Create a new SyncEngine instance.
|
|
1127
|
+
*
|
|
1128
|
+
* @param config - Sync engine configuration
|
|
1129
|
+
* @returns A new SyncEngine instance
|
|
1130
|
+
*
|
|
1131
|
+
* @example
|
|
1132
|
+
* ```typescript
|
|
1133
|
+
* const engine = createSyncEngine({
|
|
1134
|
+
* api: quarryApi,
|
|
1135
|
+
* registry: providerRegistry,
|
|
1136
|
+
* settings: settingsService,
|
|
1137
|
+
* });
|
|
1138
|
+
*
|
|
1139
|
+
* // Push locally-changed tasks
|
|
1140
|
+
* const pushResult = await engine.push({ all: true });
|
|
1141
|
+
*
|
|
1142
|
+
* // Pull externally-changed items
|
|
1143
|
+
* const pullResult = await engine.pull();
|
|
1144
|
+
*
|
|
1145
|
+
* // Bidirectional sync
|
|
1146
|
+
* const syncResult = await engine.sync({ dryRun: true });
|
|
1147
|
+
* ```
|
|
1148
|
+
*/
|
|
1149
|
+
export function createSyncEngine(config) {
|
|
1150
|
+
return new SyncEngine(config);
|
|
1151
|
+
}
|
|
1152
|
+
// Export constants for testing
|
|
1153
|
+
export { PUSH_CONCURRENCY };
|
|
1154
|
+
//# sourceMappingURL=sync-engine.js.map
|