@n8n-as-code/sync 0.5.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 (50) hide show
  1. package/README.md +22 -0
  2. package/dist/index.d.ts +10 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +10 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/services/directory-utils.d.ts +35 -0
  7. package/dist/services/directory-utils.d.ts.map +1 -0
  8. package/dist/services/directory-utils.js +75 -0
  9. package/dist/services/directory-utils.js.map +1 -0
  10. package/dist/services/hash-utils.d.ts +22 -0
  11. package/dist/services/hash-utils.d.ts.map +1 -0
  12. package/dist/services/hash-utils.js +31 -0
  13. package/dist/services/hash-utils.js.map +1 -0
  14. package/dist/services/n8n-api-client.d.ts +23 -0
  15. package/dist/services/n8n-api-client.d.ts.map +1 -0
  16. package/dist/services/n8n-api-client.js +193 -0
  17. package/dist/services/n8n-api-client.js.map +1 -0
  18. package/dist/services/resolution-manager.d.ts +73 -0
  19. package/dist/services/resolution-manager.d.ts.map +1 -0
  20. package/dist/services/resolution-manager.js +149 -0
  21. package/dist/services/resolution-manager.js.map +1 -0
  22. package/dist/services/state-manager.d.ts +43 -0
  23. package/dist/services/state-manager.d.ts.map +1 -0
  24. package/dist/services/state-manager.js +68 -0
  25. package/dist/services/state-manager.js.map +1 -0
  26. package/dist/services/sync-engine.d.ts +56 -0
  27. package/dist/services/sync-engine.d.ts.map +1 -0
  28. package/dist/services/sync-engine.js +312 -0
  29. package/dist/services/sync-engine.js.map +1 -0
  30. package/dist/services/sync-manager.d.ts +37 -0
  31. package/dist/services/sync-manager.d.ts.map +1 -0
  32. package/dist/services/sync-manager.js +280 -0
  33. package/dist/services/sync-manager.js.map +1 -0
  34. package/dist/services/trash-service.d.ts +17 -0
  35. package/dist/services/trash-service.d.ts.map +1 -0
  36. package/dist/services/trash-service.js +41 -0
  37. package/dist/services/trash-service.js.map +1 -0
  38. package/dist/services/watcher.d.ts +135 -0
  39. package/dist/services/watcher.d.ts.map +1 -0
  40. package/dist/services/watcher.js +655 -0
  41. package/dist/services/watcher.js.map +1 -0
  42. package/dist/services/workflow-sanitizer.d.ts +23 -0
  43. package/dist/services/workflow-sanitizer.d.ts.map +1 -0
  44. package/dist/services/workflow-sanitizer.js +100 -0
  45. package/dist/services/workflow-sanitizer.js.map +1 -0
  46. package/dist/types.d.ts +46 -0
  47. package/dist/types.d.ts.map +1 -0
  48. package/dist/types.js +12 -0
  49. package/dist/types.js.map +1 -0
  50. package/package.json +43 -0
@@ -0,0 +1,655 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import EventEmitter from 'events';
4
+ import * as chokidar from 'chokidar';
5
+ import { WorkflowSanitizer } from './workflow-sanitizer.js';
6
+ import { HashUtils } from './hash-utils.js';
7
+ import { WorkflowSyncStatus } from '../types.js';
8
+ /**
9
+ * Watcher - State Observation Component
10
+ *
11
+ * Responsibilities:
12
+ * 1. File System Watch with debounce
13
+ * 2. Remote Polling with lightweight strategy
14
+ * 3. Canonical Hashing (SHA-256 of sorted JSON)
15
+ * 4. Status Matrix Calculation (3-way comparison)
16
+ * 5. State Persistence (only component that writes to .n8n-state.json)
17
+ *
18
+ * Never performs synchronization actions - only observes reality.
19
+ */
20
+ export class Watcher extends EventEmitter {
21
+ watcher = null;
22
+ pollInterval = null;
23
+ client;
24
+ directory;
25
+ pollIntervalMs;
26
+ syncInactive;
27
+ ignoredTags;
28
+ stateFilePath;
29
+ isConnected = true;
30
+ isInitializing = false;
31
+ // Internal state tracking
32
+ localHashes = new Map(); // filename -> hash
33
+ remoteHashes = new Map(); // workflowId -> hash
34
+ fileToIdMap = new Map(); // filename -> workflowId
35
+ idToFileMap = new Map(); // workflowId -> filename
36
+ lastKnownStatuses = new Map(); // workflowId or filename -> status
37
+ // Concurrency control
38
+ isPaused = new Set(); // IDs for which observation is paused
39
+ syncInProgress = new Set(); // IDs currently being synced
40
+ pausedFilenames = new Set(); // Filenames for which observation is paused (for workflows without ID yet)
41
+ // Lightweight polling cache
42
+ remoteTimestamps = new Map(); // workflowId -> updatedAt
43
+ constructor(client, options) {
44
+ super();
45
+ this.client = client;
46
+ this.directory = options.directory;
47
+ this.pollIntervalMs = options.pollIntervalMs;
48
+ this.syncInactive = options.syncInactive;
49
+ this.ignoredTags = options.ignoredTags;
50
+ this.stateFilePath = path.join(this.directory, '.n8n-state.json');
51
+ }
52
+ async start() {
53
+ if (this.watcher || this.pollInterval)
54
+ return;
55
+ this.isInitializing = true;
56
+ // Initial scan - throw error if connection fails on startup
57
+ try {
58
+ await this.refreshRemoteState();
59
+ }
60
+ catch (error) {
61
+ // Check if it's a connection error
62
+ const isConnectionError = error.code === 'ECONNREFUSED' ||
63
+ error.code === 'ENOTFOUND' ||
64
+ error.code === 'ETIMEDOUT' ||
65
+ error.message?.includes('fetch failed') ||
66
+ error.message?.includes('ECONNREFUSED') ||
67
+ error.message?.includes('ENOTFOUND') ||
68
+ error.cause?.code === 'ECONNREFUSED';
69
+ if (isConnectionError) {
70
+ this.isInitializing = false;
71
+ // On startup, throw the error to prevent initialization
72
+ throw new Error('Cannot connect to n8n instance. Please check if n8n is running and the host URL is correct.');
73
+ }
74
+ // For other errors, re-throw
75
+ this.isInitializing = false;
76
+ throw error;
77
+ }
78
+ await this.refreshLocalState();
79
+ this.isInitializing = false;
80
+ // Local Watch with debounce
81
+ this.watcher = chokidar.watch(this.directory, {
82
+ ignored: [
83
+ /(^|[\/\\])\../, // Hidden files
84
+ '**/_archive/**', // Archive folder (strictly ignored)
85
+ '**/.n8n-state.json' // State file
86
+ ],
87
+ persistent: true,
88
+ ignoreInitial: true,
89
+ awaitWriteFinish: { stabilityThreshold: 500, pollInterval: 100 } // 500ms debounce
90
+ });
91
+ this.watcher
92
+ .on('add', (p) => this.onLocalChange(p))
93
+ .on('change', (p) => this.onLocalChange(p))
94
+ .on('unlink', (p) => this.onLocalDelete(p));
95
+ // Remote Poll
96
+ if (this.pollIntervalMs > 0) {
97
+ this.pollInterval = setInterval(() => this.refreshRemoteState(), this.pollIntervalMs);
98
+ }
99
+ this.emit('ready');
100
+ }
101
+ stop() {
102
+ if (this.watcher) {
103
+ this.watcher.close();
104
+ this.watcher = null;
105
+ }
106
+ if (this.pollInterval) {
107
+ clearInterval(this.pollInterval);
108
+ this.pollInterval = null;
109
+ }
110
+ }
111
+ /**
112
+ * Pause observation for a workflow during sync operations
113
+ */
114
+ pauseObservation(workflowId) {
115
+ this.isPaused.add(workflowId);
116
+ }
117
+ /**
118
+ * Resume observation after sync operations
119
+ */
120
+ resumeObservation(workflowId) {
121
+ this.isPaused.delete(workflowId);
122
+ // Don't force refresh here - let the normal polling cycle handle it
123
+ // Forcing a refresh after every sync can cause loops in auto-sync mode
124
+ // because the remote state might not be immediately consistent with local state
125
+ }
126
+ /**
127
+ * Pause observation for a filename (for workflows without ID yet)
128
+ */
129
+ pauseObservationByFilename(filename) {
130
+ this.pausedFilenames.add(filename);
131
+ }
132
+ /**
133
+ * Resume observation for a filename
134
+ */
135
+ resumeObservationByFilename(filename) {
136
+ this.pausedFilenames.delete(filename);
137
+ }
138
+ /**
139
+ * Mark a workflow as being synced (prevents race conditions)
140
+ */
141
+ markSyncInProgress(workflowId) {
142
+ this.syncInProgress.add(workflowId);
143
+ }
144
+ /**
145
+ * Mark a workflow as no longer being synced
146
+ */
147
+ markSyncComplete(workflowId) {
148
+ this.syncInProgress.delete(workflowId);
149
+ }
150
+ async onLocalChange(filePath) {
151
+ const filename = path.basename(filePath);
152
+ if (!filename.endsWith('.json'))
153
+ return;
154
+ const content = this.readJsonFile(filePath);
155
+ if (!content)
156
+ return;
157
+ // Check if filename is paused (for workflows without ID)
158
+ if (this.pausedFilenames.has(filename)) {
159
+ return;
160
+ }
161
+ const workflowId = content.id || this.fileToIdMap.get(filename);
162
+ if (workflowId && (this.isPaused.has(workflowId) || this.syncInProgress.has(workflowId))) {
163
+ return;
164
+ }
165
+ // IMPORTANT: Hash is calculated on the SANITIZED version
166
+ // This means versionId, versionCounter, pinData, etc. are ignored
167
+ // The file on disk can contain these fields, but they won't affect the hash
168
+ const clean = WorkflowSanitizer.cleanForStorage(content);
169
+ const hash = this.computeHash(clean);
170
+ this.localHashes.set(filename, hash);
171
+ if (workflowId) {
172
+ this.fileToIdMap.set(filename, workflowId);
173
+ this.idToFileMap.set(workflowId, filename);
174
+ }
175
+ this.broadcastStatus(filename, workflowId);
176
+ }
177
+ async onLocalDelete(filePath) {
178
+ const filename = path.basename(filePath);
179
+ let workflowId = this.fileToIdMap.get(filename);
180
+ // If workflowId not found via filename mapping, try to find it via state
181
+ if (!workflowId) {
182
+ const state = this.loadState();
183
+ for (const [id, stateData] of Object.entries(state.workflows)) {
184
+ const mappedFilename = this.idToFileMap.get(id);
185
+ if (mappedFilename === filename) {
186
+ workflowId = id;
187
+ break;
188
+ }
189
+ }
190
+ }
191
+ if (workflowId && (this.isPaused.has(workflowId) || this.syncInProgress.has(workflowId))) {
192
+ return;
193
+ }
194
+ // CRITICAL: Per spec 5.3 DELETED_LOCALLY - Archive Remote to _archive/ IMMEDIATELY
195
+ // This happens BEFORE user confirmation, to ensure we have a backup
196
+ if (workflowId) {
197
+ const remoteHash = this.remoteHashes.get(workflowId);
198
+ const lastSyncedHash = this.getLastSyncedHash(workflowId);
199
+ // Only archive if remote exists and matches last synced (true local deletion)
200
+ if (remoteHash && remoteHash === lastSyncedHash) {
201
+ try {
202
+ // Fetch remote workflow content
203
+ const remoteWorkflow = await this.client.getWorkflow(workflowId);
204
+ if (remoteWorkflow) {
205
+ // Create archive directory if it doesn't exist
206
+ const archiveDir = path.join(this.directory, '.archive');
207
+ if (!fs.existsSync(archiveDir)) {
208
+ fs.mkdirSync(archiveDir, { recursive: true });
209
+ }
210
+ // Save to archive with timestamp
211
+ const clean = WorkflowSanitizer.cleanForStorage(remoteWorkflow);
212
+ const archivePath = path.join(archiveDir, `${Date.now()}_${filename}`);
213
+ fs.writeFileSync(archivePath, JSON.stringify(clean, null, 2));
214
+ console.log(`[Watcher] Archived remote workflow to: ${archivePath}`);
215
+ }
216
+ }
217
+ catch (error) {
218
+ console.warn(`[Watcher] Failed to archive remote workflow ${workflowId}:`, error);
219
+ // Continue anyway - deletion detection should still work
220
+ }
221
+ }
222
+ }
223
+ this.localHashes.delete(filename);
224
+ this.broadcastStatus(filename, workflowId);
225
+ }
226
+ async refreshLocalState() {
227
+ if (!fs.existsSync(this.directory)) {
228
+ console.log(`[DEBUG] refreshLocalState: Directory missing: ${this.directory}`);
229
+ // Clear all local hashes since directory doesn't exist
230
+ this.localHashes.clear();
231
+ return;
232
+ }
233
+ const files = fs.readdirSync(this.directory).filter(f => f.endsWith('.json') && !f.startsWith('.'));
234
+ const currentFiles = new Set(files);
235
+ // Remove entries for files that no longer exist
236
+ for (const filename of this.localHashes.keys()) {
237
+ if (!currentFiles.has(filename)) {
238
+ this.localHashes.delete(filename);
239
+ const workflowId = this.fileToIdMap.get(filename);
240
+ if (workflowId) {
241
+ // Broadcast status change for deleted file
242
+ this.broadcastStatus(filename, workflowId);
243
+ }
244
+ }
245
+ }
246
+ // Add/update entries for existing files
247
+ for (const filename of files) {
248
+ const filePath = path.join(this.directory, filename);
249
+ const content = this.readJsonFile(filePath);
250
+ if (content) {
251
+ const clean = WorkflowSanitizer.cleanForStorage(content);
252
+ const hash = this.computeHash(clean);
253
+ this.localHashes.set(filename, hash);
254
+ if (content.id) {
255
+ this.fileToIdMap.set(filename, content.id);
256
+ this.idToFileMap.set(content.id, filename);
257
+ }
258
+ }
259
+ }
260
+ }
261
+ /**
262
+ * Lightweight polling strategy:
263
+ * 1. Fetch only IDs and updatedAt timestamps
264
+ * 2. Compare with cached timestamps
265
+ * 3. Fetch full content only if timestamp changed
266
+ */
267
+ async refreshRemoteState() {
268
+ try {
269
+ const remoteWorkflows = await this.client.getAllWorkflows();
270
+ this.isConnected = true;
271
+ const currentRemoteIds = new Set();
272
+ for (const wf of remoteWorkflows) {
273
+ if (this.shouldIgnore(wf))
274
+ continue;
275
+ if (this.isPaused.has(wf.id) || this.syncInProgress.has(wf.id))
276
+ continue;
277
+ currentRemoteIds.add(wf.id);
278
+ // CRITICAL: Use ID-based mapping instead of name-based filename generation
279
+ // This prevents issues when filename != workflow.name
280
+ let filename = this.idToFileMap.get(wf.id);
281
+ // If no mapping exists, try to find the file by scanning local files
282
+ if (!filename) {
283
+ filename = this.findFilenameByWorkflowId(wf.id);
284
+ }
285
+ // If still not found, generate filename from name (new remote workflow)
286
+ if (!filename) {
287
+ filename = `${this.safeName(wf.name)}.json`;
288
+ }
289
+ this.idToFileMap.set(wf.id, filename);
290
+ this.fileToIdMap.set(filename, wf.id);
291
+ // Check if we need to fetch full content
292
+ const cachedTimestamp = this.remoteTimestamps.get(wf.id);
293
+ const needsFullFetch = !cachedTimestamp ||
294
+ (wf.updatedAt && wf.updatedAt !== cachedTimestamp);
295
+ if (needsFullFetch) {
296
+ try {
297
+ const fullWf = await this.client.getWorkflow(wf.id);
298
+ if (fullWf) {
299
+ const clean = WorkflowSanitizer.cleanForStorage(fullWf);
300
+ const hash = this.computeHash(clean);
301
+ this.remoteHashes.set(wf.id, hash);
302
+ if (wf.updatedAt) {
303
+ this.remoteTimestamps.set(wf.id, wf.updatedAt);
304
+ }
305
+ this.broadcastStatus(filename, wf.id);
306
+ }
307
+ }
308
+ catch (e) {
309
+ console.warn(`[Watcher] Could not fetch workflow ${wf.id}:`, e);
310
+ }
311
+ }
312
+ else {
313
+ // Timestamp unchanged, use cached hash
314
+ const cachedHash = this.remoteHashes.get(wf.id);
315
+ if (cachedHash) {
316
+ this.broadcastStatus(filename, wf.id);
317
+ }
318
+ }
319
+ }
320
+ // Prune remoteHashes for deleted workflows
321
+ for (const id of this.remoteHashes.keys()) {
322
+ if (!currentRemoteIds.has(id)) {
323
+ this.remoteHashes.delete(id);
324
+ this.remoteTimestamps.delete(id);
325
+ const filename = this.idToFileMap.get(id);
326
+ if (filename)
327
+ this.broadcastStatus(filename, id);
328
+ }
329
+ }
330
+ }
331
+ catch (error) {
332
+ // Check if it's a connection error
333
+ const isConnectionError = error.code === 'ECONNREFUSED' ||
334
+ error.code === 'ENOTFOUND' ||
335
+ error.code === 'ETIMEDOUT' ||
336
+ error.message?.includes('fetch failed') ||
337
+ error.message?.includes('ECONNREFUSED') ||
338
+ error.message?.includes('ENOTFOUND') ||
339
+ error.cause?.code === 'ECONNREFUSED';
340
+ if (isConnectionError) {
341
+ this.isConnected = false;
342
+ // Stop polling to avoid spamming errors
343
+ if (this.pollInterval) {
344
+ clearInterval(this.pollInterval);
345
+ this.pollInterval = null;
346
+ }
347
+ // Emit a specific connection error
348
+ this.emit('connection-lost', new Error('Lost connection to n8n instance. Please check if n8n is running.'));
349
+ }
350
+ else {
351
+ // For other errors, just emit the error
352
+ this.emit('error', error);
353
+ }
354
+ // Re-throw so that start() can catch it on initial call
355
+ throw error;
356
+ }
357
+ }
358
+ /**
359
+ * Finalize sync - update base state after successful sync operation
360
+ * Called by SyncEngine after PULL/PUSH completes
361
+ */
362
+ async finalizeSync(workflowId) {
363
+ let filename = this.idToFileMap.get(workflowId);
364
+ // If workflow not tracked yet (first sync of local-only workflow),
365
+ // scan directory to find the file with this ID
366
+ if (!filename) {
367
+ const files = fs.readdirSync(this.directory).filter(f => f.endsWith('.json') && !f.startsWith('.'));
368
+ for (const file of files) {
369
+ const filePath = path.join(this.directory, file);
370
+ const content = this.readJsonFile(filePath);
371
+ if (content?.id === workflowId) {
372
+ filename = file;
373
+ // Initialize tracking for this workflow
374
+ this.fileToIdMap.set(filename, workflowId);
375
+ this.idToFileMap.set(workflowId, filename);
376
+ break;
377
+ }
378
+ }
379
+ if (!filename) {
380
+ throw new Error(`Cannot finalize sync: workflow ${workflowId} not found in directory`);
381
+ }
382
+ }
383
+ // Get current reality
384
+ const filePath = path.join(this.directory, filename);
385
+ const content = this.readJsonFile(filePath);
386
+ if (!content) {
387
+ throw new Error(`Cannot finalize sync: local file not found for ${workflowId}`);
388
+ }
389
+ const clean = WorkflowSanitizer.cleanForStorage(content);
390
+ const computedHash = this.computeHash(clean);
391
+ // After a successful sync, local and remote should be identical
392
+ // Use the computed hash for both
393
+ const localHash = computedHash;
394
+ const remoteHash = computedHash;
395
+ // Update caches
396
+ this.localHashes.set(filename, localHash);
397
+ this.remoteHashes.set(workflowId, remoteHash);
398
+ // Update base state
399
+ await this.updateWorkflowState(workflowId, localHash);
400
+ // Broadcast new IN_SYNC status
401
+ this.broadcastStatus(filename, workflowId);
402
+ }
403
+ /**
404
+ * Update workflow state in .n8n-state.json
405
+ * Only this component writes to the state file
406
+ */
407
+ async updateWorkflowState(id, hash) {
408
+ const state = this.loadState();
409
+ state.workflows[id] = {
410
+ lastSyncedHash: hash,
411
+ lastSyncedAt: new Date().toISOString()
412
+ };
413
+ this.saveState(state);
414
+ }
415
+ /**
416
+ * Remove workflow from state file
417
+ * Called after deletion confirmation
418
+ */
419
+ async removeWorkflowState(id) {
420
+ const state = this.loadState();
421
+ delete state.workflows[id];
422
+ this.saveState(state);
423
+ // Clean up internal tracking
424
+ const filename = this.idToFileMap.get(id);
425
+ if (filename) {
426
+ this.fileToIdMap.delete(filename);
427
+ }
428
+ this.idToFileMap.delete(id);
429
+ this.remoteHashes.delete(id);
430
+ this.remoteTimestamps.delete(id);
431
+ }
432
+ /**
433
+ * Load state from .n8n-state.json
434
+ */
435
+ loadState() {
436
+ if (fs.existsSync(this.stateFilePath)) {
437
+ try {
438
+ const data = JSON.parse(fs.readFileSync(this.stateFilePath, 'utf-8'));
439
+ if (!data.workflows) {
440
+ data.workflows = {};
441
+ }
442
+ return data;
443
+ }
444
+ catch (e) {
445
+ console.warn('Could not read state file, using empty state');
446
+ }
447
+ }
448
+ return { workflows: {} };
449
+ }
450
+ /**
451
+ * Save state to .n8n-state.json
452
+ */
453
+ saveState(state) {
454
+ fs.writeFileSync(this.stateFilePath, JSON.stringify(state, null, 2));
455
+ }
456
+ /**
457
+ * Compute canonical hash for content
458
+ */
459
+ computeHash(content) {
460
+ return HashUtils.computeHash(content);
461
+ }
462
+ broadcastStatus(filename, workflowId) {
463
+ if (this.isInitializing)
464
+ return;
465
+ const status = this.calculateStatus(filename, workflowId);
466
+ const key = workflowId || filename;
467
+ const lastStatus = this.lastKnownStatuses.get(key);
468
+ if (status !== lastStatus) {
469
+ this.lastKnownStatuses.set(key, status);
470
+ this.emit('statusChange', {
471
+ filename,
472
+ workflowId,
473
+ status
474
+ });
475
+ }
476
+ }
477
+ calculateStatus(filename, workflowId) {
478
+ if (!workflowId)
479
+ workflowId = this.fileToIdMap.get(filename);
480
+ const localHash = this.localHashes.get(filename);
481
+ const remoteHash = workflowId ? this.remoteHashes.get(workflowId) : undefined;
482
+ // If we are disconnected and don't have a remote hash, don't claim it's deleted
483
+ if (!this.isConnected && !remoteHash && workflowId) {
484
+ return WorkflowSyncStatus.IN_SYNC; // Treat as in-sync or unknown to avoid "deleted" panic
485
+ }
486
+ // Get base state
487
+ const state = this.loadState();
488
+ const baseState = workflowId ? state.workflows[workflowId] : undefined;
489
+ const lastSyncedHash = baseState?.lastSyncedHash;
490
+ // Implementation of 4.2 Status Logic Matrix
491
+ if (localHash && !lastSyncedHash && !remoteHash)
492
+ return WorkflowSyncStatus.EXIST_ONLY_LOCALLY;
493
+ if (remoteHash && !lastSyncedHash && !localHash)
494
+ return WorkflowSyncStatus.EXIST_ONLY_REMOTELY;
495
+ if (localHash && remoteHash && localHash === remoteHash)
496
+ return WorkflowSyncStatus.IN_SYNC;
497
+ if (lastSyncedHash) {
498
+ // Check deletions first (they take precedence over modifications)
499
+ if (!localHash && remoteHash === lastSyncedHash)
500
+ return WorkflowSyncStatus.DELETED_LOCALLY;
501
+ if (!remoteHash && localHash === lastSyncedHash)
502
+ return WorkflowSyncStatus.DELETED_REMOTELY;
503
+ // Then check modifications
504
+ const localModified = localHash !== lastSyncedHash;
505
+ const remoteModified = remoteHash && remoteHash !== lastSyncedHash;
506
+ if (localModified && remoteModified)
507
+ return WorkflowSyncStatus.CONFLICT;
508
+ if (localModified && remoteHash === lastSyncedHash)
509
+ return WorkflowSyncStatus.MODIFIED_LOCALLY;
510
+ if (remoteModified && localHash === lastSyncedHash)
511
+ return WorkflowSyncStatus.MODIFIED_REMOTELY;
512
+ }
513
+ // Fallback for edge cases
514
+ return WorkflowSyncStatus.CONFLICT;
515
+ }
516
+ shouldIgnore(wf) {
517
+ if (!this.syncInactive && !wf.active)
518
+ return true;
519
+ if (wf.tags) {
520
+ const hasIgnoredTag = wf.tags.some(t => this.ignoredTags.includes(t.name.toLowerCase()));
521
+ if (hasIgnoredTag)
522
+ return true;
523
+ }
524
+ return false;
525
+ }
526
+ safeName(name) {
527
+ return name.replace(/[\/\\:]/g, '_').replace(/\s+/g, ' ').trim();
528
+ }
529
+ /**
530
+ * Find local file that contains a specific workflow ID
531
+ * Used when we have an ID but no filename mapping yet (e.g., after file rename)
532
+ */
533
+ findFilenameByWorkflowId(workflowId) {
534
+ if (!fs.existsSync(this.directory)) {
535
+ return undefined;
536
+ }
537
+ const files = fs.readdirSync(this.directory)
538
+ .filter(f => f.endsWith('.json') && !f.startsWith('.'));
539
+ for (const file of files) {
540
+ const content = this.readJsonFile(path.join(this.directory, file));
541
+ if (content?.id === workflowId) {
542
+ return file;
543
+ }
544
+ }
545
+ return undefined;
546
+ }
547
+ readJsonFile(filePath) {
548
+ try {
549
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
550
+ }
551
+ catch {
552
+ return null;
553
+ }
554
+ }
555
+ getFileToIdMap() {
556
+ return this.fileToIdMap;
557
+ }
558
+ getStatusMatrix() {
559
+ const results = new Map();
560
+ const state = this.loadState();
561
+ // 1. Process all local files
562
+ for (const [filename, hash] of this.localHashes.entries()) {
563
+ const workflowId = this.fileToIdMap.get(filename);
564
+ const status = this.calculateStatus(filename, workflowId);
565
+ results.set(filename, {
566
+ id: workflowId || '',
567
+ name: filename.replace('.json', ''),
568
+ filename: filename,
569
+ status: status,
570
+ active: true
571
+ });
572
+ }
573
+ // 2. Process all remote workflows not yet in results
574
+ for (const [workflowId, remoteHash] of this.remoteHashes.entries()) {
575
+ const filename = this.idToFileMap.get(workflowId) || `${workflowId}.json`;
576
+ if (!results.has(filename)) {
577
+ const status = this.calculateStatus(filename, workflowId);
578
+ results.set(filename, {
579
+ id: workflowId,
580
+ name: filename.replace('.json', ''),
581
+ filename: filename,
582
+ status: status,
583
+ active: true
584
+ });
585
+ }
586
+ }
587
+ // 3. Process tracked but deleted workflows
588
+ for (const id of Object.keys(state.workflows)) {
589
+ const filename = this.idToFileMap.get(id) || `${id}.json`;
590
+ if (!results.has(filename)) {
591
+ const status = this.calculateStatus(filename, id);
592
+ results.set(filename, {
593
+ id,
594
+ name: filename.replace('.json', ''),
595
+ filename,
596
+ status,
597
+ active: true
598
+ });
599
+ }
600
+ }
601
+ return Array.from(results.values()).sort((a, b) => a.name.localeCompare(b.name));
602
+ }
603
+ /**
604
+ * Get last synced hash for a workflow
605
+ */
606
+ getLastSyncedHash(workflowId) {
607
+ const state = this.loadState();
608
+ return state.workflows[workflowId]?.lastSyncedHash;
609
+ }
610
+ /**
611
+ * Update remote hash cache (for SyncEngine use)
612
+ * @internal
613
+ */
614
+ setRemoteHash(workflowId, hash) {
615
+ this.remoteHashes.set(workflowId, hash);
616
+ }
617
+ /**
618
+ * Get all tracked workflow IDs
619
+ */
620
+ getTrackedWorkflowIds() {
621
+ const state = this.loadState();
622
+ return Object.keys(state.workflows);
623
+ }
624
+ /**
625
+ * Update workflow ID in state (when a workflow is re-created with a new ID)
626
+ */
627
+ async updateWorkflowId(oldId, newId) {
628
+ const state = this.loadState();
629
+ // Migrate state from old ID to new ID
630
+ if (state.workflows[oldId]) {
631
+ state.workflows[newId] = state.workflows[oldId];
632
+ delete state.workflows[oldId];
633
+ this.saveState(state);
634
+ }
635
+ // Update internal mappings
636
+ const filename = this.idToFileMap.get(oldId);
637
+ if (filename) {
638
+ this.idToFileMap.delete(oldId);
639
+ this.idToFileMap.set(newId, filename);
640
+ this.fileToIdMap.set(filename, newId);
641
+ }
642
+ // Update hash maps
643
+ const remoteHash = this.remoteHashes.get(oldId);
644
+ if (remoteHash) {
645
+ this.remoteHashes.delete(oldId);
646
+ this.remoteHashes.set(newId, remoteHash);
647
+ }
648
+ const timestamp = this.remoteTimestamps.get(oldId);
649
+ if (timestamp) {
650
+ this.remoteTimestamps.delete(oldId);
651
+ this.remoteTimestamps.set(newId, timestamp);
652
+ }
653
+ }
654
+ }
655
+ //# sourceMappingURL=watcher.js.map