@n8n-as-code/sync 0.5.0 → 0.7.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 (51) hide show
  1. package/README.md +2 -2
  2. package/dist/helpers/index.d.ts +8 -0
  3. package/dist/helpers/index.d.ts.map +1 -0
  4. package/dist/helpers/index.js +8 -0
  5. package/dist/helpers/index.js.map +1 -0
  6. package/dist/helpers/project-helpers.d.ts +93 -0
  7. package/dist/helpers/project-helpers.d.ts.map +1 -0
  8. package/dist/helpers/project-helpers.js +168 -0
  9. package/dist/helpers/project-helpers.js.map +1 -0
  10. package/dist/index.d.ts +1 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/services/directory-utils.d.ts +6 -0
  15. package/dist/services/directory-utils.d.ts.map +1 -1
  16. package/dist/services/directory-utils.js +12 -0
  17. package/dist/services/directory-utils.js.map +1 -1
  18. package/dist/services/n8n-api-client.d.ts +28 -2
  19. package/dist/services/n8n-api-client.d.ts.map +1 -1
  20. package/dist/services/n8n-api-client.js +113 -3
  21. package/dist/services/n8n-api-client.js.map +1 -1
  22. package/dist/services/resolution-manager.d.ts +2 -2
  23. package/dist/services/resolution-manager.js +7 -7
  24. package/dist/services/resolution-manager.js.map +1 -1
  25. package/dist/services/state-manager.d.ts +1 -0
  26. package/dist/services/state-manager.d.ts.map +1 -1
  27. package/dist/services/state-manager.js.map +1 -1
  28. package/dist/services/sync-engine.d.ts +1 -1
  29. package/dist/services/sync-engine.d.ts.map +1 -1
  30. package/dist/services/sync-engine.js +10 -10
  31. package/dist/services/sync-engine.js.map +1 -1
  32. package/dist/services/sync-manager.d.ts +6 -1
  33. package/dist/services/sync-manager.d.ts.map +1 -1
  34. package/dist/services/sync-manager.js +18 -12
  35. package/dist/services/sync-manager.js.map +1 -1
  36. package/dist/services/trash-service.d.ts +1 -1
  37. package/dist/services/trash-service.d.ts.map +1 -1
  38. package/dist/services/trash-service.js +8 -8
  39. package/dist/services/trash-service.js.map +1 -1
  40. package/dist/services/watcher.d.ts +28 -2
  41. package/dist/services/watcher.d.ts.map +1 -1
  42. package/dist/services/watcher.js +466 -50
  43. package/dist/services/watcher.js.map +1 -1
  44. package/dist/services/workflow-sanitizer.d.ts +11 -0
  45. package/dist/services/workflow-sanitizer.d.ts.map +1 -1
  46. package/dist/services/workflow-sanitizer.js +31 -1
  47. package/dist/services/workflow-sanitizer.js.map +1 -1
  48. package/dist/types.d.ts +17 -0
  49. package/dist/types.d.ts.map +1 -1
  50. package/dist/types.js.map +1 -1
  51. package/package.json +2 -2
@@ -1,7 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import EventEmitter from 'events';
4
- import * as chokidar from 'chokidar';
4
+ import * as watcher from '@parcel/watcher';
5
5
  import { WorkflowSanitizer } from './workflow-sanitizer.js';
6
6
  import { HashUtils } from './hash-utils.js';
7
7
  import { WorkflowSyncStatus } from '../types.js';
@@ -18,13 +18,14 @@ import { WorkflowSyncStatus } from '../types.js';
18
18
  * Never performs synchronization actions - only observes reality.
19
19
  */
20
20
  export class Watcher extends EventEmitter {
21
- watcher = null;
21
+ watcherSubscription = null;
22
22
  pollInterval = null;
23
23
  client;
24
24
  directory;
25
25
  pollIntervalMs;
26
26
  syncInactive;
27
27
  ignoredTags;
28
+ projectId;
28
29
  stateFilePath;
29
30
  isConnected = true;
30
31
  isInitializing = false;
@@ -38,6 +39,11 @@ export class Watcher extends EventEmitter {
38
39
  isPaused = new Set(); // IDs for which observation is paused
39
40
  syncInProgress = new Set(); // IDs currently being synced
40
41
  pausedFilenames = new Set(); // Filenames for which observation is paused (for workflows without ID yet)
42
+ // Pending operations for rename detection
43
+ pendingOperations = new Map();
44
+ // Potential renames: when we see an add event for a workflow ID that already exists,
45
+ // we track it here to match with subsequent unlink events
46
+ potentialRenames = new Map();
41
47
  // Lightweight polling cache
42
48
  remoteTimestamps = new Map(); // workflowId -> updatedAt
43
49
  constructor(client, options) {
@@ -47,10 +53,11 @@ export class Watcher extends EventEmitter {
47
53
  this.pollIntervalMs = options.pollIntervalMs;
48
54
  this.syncInactive = options.syncInactive;
49
55
  this.ignoredTags = options.ignoredTags;
56
+ this.projectId = options.projectId;
50
57
  this.stateFilePath = path.join(this.directory, '.n8n-state.json');
51
58
  }
52
59
  async start() {
53
- if (this.watcher || this.pollInterval)
60
+ if (this.watcherSubscription || this.pollInterval)
54
61
  return;
55
62
  this.isInitializing = true;
56
63
  // Initial scan - throw error if connection fails on startup
@@ -76,22 +83,39 @@ export class Watcher extends EventEmitter {
76
83
  throw error;
77
84
  }
78
85
  await this.refreshLocalState();
86
+ // Restore persisted ID → filename mappings from state
87
+ // This ensures stable filename assignment even when remote workflows have duplicate names
88
+ this.restoreMappingsFromState();
79
89
  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
+ // Local Watch with @parcel/watcher
91
+ this.watcherSubscription = await watcher.subscribe(this.directory, (err, events) => {
92
+ if (err) {
93
+ this.emit('error', err);
94
+ return;
95
+ }
96
+ for (const event of events) {
97
+ const filename = path.basename(event.path);
98
+ // Ignore hidden files, trash, and state file
99
+ if (filename.startsWith('.') || event.path.includes('.trash')) {
100
+ continue;
101
+ }
102
+ switch (event.type) {
103
+ case 'create':
104
+ case 'update':
105
+ this.onLocalChange(event.path);
106
+ break;
107
+ case 'delete':
108
+ this.onLocalDelete(event.path);
109
+ break;
110
+ }
111
+ }
112
+ }, {
113
+ ignore: [
114
+ '**/.trash/**',
115
+ '**/.n8n-state.json',
116
+ '**/.git/**'
117
+ ]
90
118
  });
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
119
  // Remote Poll
96
120
  if (this.pollIntervalMs > 0) {
97
121
  this.pollInterval = setInterval(() => this.refreshRemoteState(), this.pollIntervalMs);
@@ -99,14 +123,19 @@ export class Watcher extends EventEmitter {
99
123
  this.emit('ready');
100
124
  }
101
125
  stop() {
102
- if (this.watcher) {
103
- this.watcher.close();
104
- this.watcher = null;
126
+ if (this.watcherSubscription) {
127
+ this.watcherSubscription.unsubscribe();
128
+ this.watcherSubscription = null;
105
129
  }
106
130
  if (this.pollInterval) {
107
131
  clearInterval(this.pollInterval);
108
132
  this.pollInterval = null;
109
133
  }
134
+ // Clean up pending operations
135
+ for (const op of this.pendingOperations.values()) {
136
+ clearTimeout(op.timeout);
137
+ }
138
+ this.pendingOperations.clear();
110
139
  }
111
140
  /**
112
141
  * Pause observation for a workflow during sync operations
@@ -152,20 +181,95 @@ export class Watcher extends EventEmitter {
152
181
  if (!filename.endsWith('.json'))
153
182
  return;
154
183
  const content = this.readJsonFile(filePath);
155
- if (!content)
184
+ if (!content) {
156
185
  return;
186
+ }
187
+ // Check if this is a rename operation (following architectural plan)
188
+ const detectedWorkflowId = content.id || this.fileToIdMap.get(filename);
189
+ const pendingOpKey = `unlink:${detectedWorkflowId || filename}`;
190
+ const pendingOp = this.pendingOperations.get(pendingOpKey);
191
+ if (pendingOp) {
192
+ // Check if this is a rename based on workflow ID or filename
193
+ const isRenameByWorkflowId = detectedWorkflowId && pendingOp.workflowId === detectedWorkflowId;
194
+ const isRenameByFilename = !detectedWorkflowId && pendingOp.filename === filename;
195
+ if (isRenameByWorkflowId || isRenameByFilename) {
196
+ // This is a rename! Handle it
197
+ clearTimeout(pendingOp.timeout); // Cancel the deletion timeout
198
+ this.handleRename(pendingOp.workflowId || detectedWorkflowId || '', pendingOp.filename, filename);
199
+ this.pendingOperations.delete(pendingOpKey);
200
+ return;
201
+ }
202
+ }
157
203
  // Check if filename is paused (for workflows without ID)
158
204
  if (this.pausedFilenames.has(filename)) {
159
205
  return;
160
206
  }
161
- const workflowId = content.id || this.fileToIdMap.get(filename);
207
+ let workflowId = content.id || this.fileToIdMap.get(filename);
162
208
  if (workflowId && (this.isPaused.has(workflowId) || this.syncInProgress.has(workflowId))) {
163
209
  return;
164
210
  }
211
+ // Check for duplicate ID (following architectural plan)
212
+ if (content.id) {
213
+ const existingFilename = this.idToFileMap.get(content.id);
214
+ if (existingFilename && existingFilename !== filename) {
215
+ // Check if the existing file still exists on disk
216
+ const existingFilePath = path.join(this.directory, existingFilename);
217
+ const fileExists = fs.existsSync(existingFilePath);
218
+ if (!fileExists) {
219
+ // The existing file doesn't exist - this is likely a rename
220
+ // Update mappings to point to the new filename
221
+ this.fileToIdMap.delete(existingFilename);
222
+ this.fileToIdMap.set(filename, content.id);
223
+ this.idToFileMap.set(content.id, filename);
224
+ // Emit rename event
225
+ this.emit('fileRenamed', {
226
+ workflowId: content.id,
227
+ oldFilename: existingFilename,
228
+ newFilename: filename
229
+ });
230
+ // Also check if there's a pending deletion for this workflow ID
231
+ const pendingOpKey = `unlink:${content.id}`;
232
+ const pendingOp = this.pendingOperations.get(pendingOpKey);
233
+ if (pendingOp) {
234
+ clearTimeout(pendingOp.timeout);
235
+ this.pendingOperations.delete(pendingOpKey);
236
+ }
237
+ }
238
+ else {
239
+ // File exists - this could be a rename where add happened before unlink
240
+ // Track as potential rename and wait for unlink event
241
+ this.potentialRenames.set(content.id, {
242
+ newFilename: filename,
243
+ timestamp: Date.now()
244
+ });
245
+ // File exists - this is a DUPLICATE ID (copy-paste)
246
+ // Principle: Keep ID only in the oldest file, remove from the new one
247
+ // DUPLICAT DÉTECTÉ pendant le watch → supprimer l'ID du nouveau fichier
248
+ // Remove ID from the new file
249
+ const currentContent = this.readJsonFile(filePath);
250
+ if (currentContent && currentContent.id === content.id) {
251
+ delete currentContent.id;
252
+ this.writeWorkflowFile(filename, currentContent);
253
+ // Re-read the content without ID
254
+ const newContent = this.readJsonFile(filePath);
255
+ if (newContent) {
256
+ const workflowId = this.fileToIdMap.get(filename);
257
+ const clean = WorkflowSanitizer.cleanForHash(newContent);
258
+ const hash = this.computeHash(clean);
259
+ this.localHashes.set(filename, hash);
260
+ this.broadcastStatus(filename, workflowId);
261
+ }
262
+ }
263
+ return; // Stop processing this file as it's being modified
264
+ // Don't return - continue processing as normal
265
+ // The unlink event should come soon and trigger rename detection
266
+ }
267
+ }
268
+ }
165
269
  // IMPORTANT: Hash is calculated on the SANITIZED version
166
270
  // This means versionId, versionCounter, pinData, etc. are ignored
167
271
  // The file on disk can contain these fields, but they won't affect the hash
168
- const clean = WorkflowSanitizer.cleanForStorage(content);
272
+ const clean = WorkflowSanitizer.cleanForHash(content);
169
273
  const hash = this.computeHash(clean);
170
274
  this.localHashes.set(filename, hash);
171
275
  if (workflowId) {
@@ -188,10 +292,90 @@ export class Watcher extends EventEmitter {
188
292
  }
189
293
  }
190
294
  }
295
+ // Check if this is a potential rename (add happened before unlink)
296
+ if (workflowId) {
297
+ const potentialRename = this.potentialRenames.get(workflowId);
298
+ if (potentialRename) {
299
+ this.potentialRenames.delete(workflowId);
300
+ // Handle as rename
301
+ this.handleRename(workflowId, filename, potentialRename.newFilename);
302
+ return;
303
+ }
304
+ }
191
305
  if (workflowId && (this.isPaused.has(workflowId) || this.syncInProgress.has(workflowId))) {
192
306
  return;
193
307
  }
194
- // CRITICAL: Per spec 5.3 DELETED_LOCALLY - Archive Remote to _archive/ IMMEDIATELY
308
+ // Schedule deletion check to detect renames (following architectural plan)
309
+ this.scheduleDeletionCheck(filename, workflowId);
310
+ }
311
+ scheduleDeletionCheck(filename, workflowId) {
312
+ const key = `unlink:${workflowId || filename}`;
313
+ const timeout = setTimeout(() => {
314
+ // After 1000ms, confirm that it's really a deletion (increased for better rename detection)
315
+ this.confirmDeletion(filename, workflowId);
316
+ this.pendingOperations.delete(key);
317
+ }, 1000);
318
+ this.pendingOperations.set(key, {
319
+ type: 'unlink',
320
+ filename,
321
+ workflowId,
322
+ timeout
323
+ });
324
+ }
325
+ async onLocalRename(oldPath, newPath) {
326
+ const oldFilename = path.basename(oldPath);
327
+ const newFilename = path.basename(newPath);
328
+ if (!oldFilename.endsWith('.json') || !newFilename.endsWith('.json')) {
329
+ return;
330
+ }
331
+ // Try to get workflow ID from old filename mapping
332
+ let workflowId = this.fileToIdMap.get(oldFilename);
333
+ // If not found, try to read the new file to get the workflow ID
334
+ if (!workflowId) {
335
+ const content = this.readJsonFile(newPath);
336
+ if (content?.id) {
337
+ workflowId = content.id;
338
+ }
339
+ }
340
+ if (!workflowId) {
341
+ // No workflow ID found - this is a rename of a file without ID
342
+ // Just update filename mappings if they exist
343
+ const oldHash = this.localHashes.get(oldFilename);
344
+ if (oldHash) {
345
+ this.localHashes.delete(oldFilename);
346
+ this.localHashes.set(newFilename, oldHash);
347
+ }
348
+ // Update fileToIdMap if old filename had a mapping
349
+ const mappedWorkflowId = this.fileToIdMap.get(oldFilename);
350
+ if (mappedWorkflowId) {
351
+ this.fileToIdMap.delete(oldFilename);
352
+ this.fileToIdMap.set(newFilename, mappedWorkflowId);
353
+ this.idToFileMap.set(mappedWorkflowId, newFilename);
354
+ }
355
+ // Emit rename event even without workflow ID
356
+ this.emit('fileRenamed', {
357
+ workflowId: '',
358
+ oldFilename,
359
+ newFilename
360
+ });
361
+ this.broadcastStatus(newFilename, workflowId);
362
+ return;
363
+ }
364
+ // We have a workflow ID - handle as a proper rename
365
+ this.handleRename(workflowId, oldFilename, newFilename);
366
+ }
367
+ async confirmDeletion(filename, workflowId) {
368
+ // Final check: is this actually a rename?
369
+ if (workflowId) {
370
+ // Check if the workflow ID appears in another file
371
+ const otherFilename = this.findFilenameByWorkflowId(workflowId);
372
+ if (otherFilename && otherFilename !== filename) {
373
+ // This is a rename, not a deletion!
374
+ this.handleRename(workflowId, filename, otherFilename);
375
+ return;
376
+ }
377
+ }
378
+ // CRITICAL: Per spec 5.3 DELETED_LOCALLY - Archive Remote to .trash/ IMMEDIATELY
195
379
  // This happens BEFORE user confirmation, to ensure we have a backup
196
380
  if (workflowId) {
197
381
  const remoteHash = this.remoteHashes.get(workflowId);
@@ -203,15 +387,14 @@ export class Watcher extends EventEmitter {
203
387
  const remoteWorkflow = await this.client.getWorkflow(workflowId);
204
388
  if (remoteWorkflow) {
205
389
  // 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 });
390
+ const trashDir = path.join(this.directory, '.trash');
391
+ if (!fs.existsSync(trashDir)) {
392
+ fs.mkdirSync(trashDir, { recursive: true });
209
393
  }
210
394
  // Save to archive with timestamp
211
- const clean = WorkflowSanitizer.cleanForStorage(remoteWorkflow);
212
- const archivePath = path.join(archiveDir, `${Date.now()}_${filename}`);
395
+ const clean = WorkflowSanitizer.cleanForHash(remoteWorkflow);
396
+ const archivePath = path.join(trashDir, `${Date.now()}_${filename}`);
213
397
  fs.writeFileSync(archivePath, JSON.stringify(clean, null, 2));
214
- console.log(`[Watcher] Archived remote workflow to: ${archivePath}`);
215
398
  }
216
399
  }
217
400
  catch (error) {
@@ -220,8 +403,36 @@ export class Watcher extends EventEmitter {
220
403
  }
221
404
  }
222
405
  }
223
- this.localHashes.delete(filename);
406
+ // IMPORTANT: Broadcast status BEFORE cleaning up mappings
407
+ // This ensures the UI receives the DELETED_LOCALLY status with the correct workflowId
224
408
  this.broadcastStatus(filename, workflowId);
409
+ // Clean up local hash for deleted file
410
+ this.localHashes.delete(filename);
411
+ // CRITICAL: DO NOT delete ID→filename mappings for DELETED_LOCALLY workflows
412
+ // Mappings must persist to:
413
+ // 1. Allow file restoration with the same filename
414
+ // 2. Prevent other remote workflows with the same name from taking this filename
415
+ // Mappings are only deleted when the workflow is completely removed via removeWorkflowState()
416
+ }
417
+ handleRename(workflowId, oldFilename, newFilename) {
418
+ // Update mappings
419
+ this.fileToIdMap.delete(oldFilename);
420
+ this.fileToIdMap.set(newFilename, workflowId);
421
+ this.idToFileMap.set(workflowId, newFilename);
422
+ // Update local hash mapping
423
+ const oldHash = this.localHashes.get(oldFilename);
424
+ if (oldHash) {
425
+ this.localHashes.delete(oldFilename);
426
+ this.localHashes.set(newFilename, oldHash);
427
+ }
428
+ // Emit rename event
429
+ this.emit('fileRenamed', {
430
+ workflowId,
431
+ oldFilename,
432
+ newFilename
433
+ });
434
+ // Broadcast status with new filename
435
+ this.broadcastStatus(newFilename, workflowId);
225
436
  }
226
437
  async refreshLocalState() {
227
438
  if (!fs.existsSync(this.directory)) {
@@ -243,18 +454,87 @@ export class Watcher extends EventEmitter {
243
454
  }
244
455
  }
245
456
  }
246
- // Add/update entries for existing files
457
+ // First pass: collect all files and their content
458
+ const fileContents = [];
247
459
  for (const filename of files) {
248
460
  const filePath = path.join(this.directory, filename);
249
461
  const content = this.readJsonFile(filePath);
250
462
  if (content) {
251
- const clean = WorkflowSanitizer.cleanForStorage(content);
463
+ const stat = fs.statSync(filePath);
464
+ fileContents.push({ filename, content, mtime: stat.mtimeMs });
465
+ const clean = WorkflowSanitizer.cleanForHash(content);
252
466
  const hash = this.computeHash(clean);
253
467
  this.localHashes.set(filename, hash);
254
- if (content.id) {
468
+ }
469
+ }
470
+ // Detect and resolve duplicate IDs (following architectural plan)
471
+ this.resolveDuplicateIds(fileContents);
472
+ // Second pass: update mappings after duplicate resolution
473
+ // CRITICAL: Only update mappings if not already set from persisted state
474
+ // This prevents ID alternation when remote workflows have duplicate names
475
+ for (const { filename, content } of fileContents) {
476
+ if (content?.id) {
477
+ // Only update if we don't have a persisted mapping for this ID
478
+ if (!this.idToFileMap.has(content.id)) {
255
479
  this.fileToIdMap.set(filename, content.id);
256
480
  this.idToFileMap.set(content.id, filename);
257
481
  }
482
+ else {
483
+ // We have a persisted mapping - verify it matches the file
484
+ const persistedFilename = this.idToFileMap.get(content.id);
485
+ if (persistedFilename !== filename) {
486
+ // The ID is in a different file than expected
487
+ // This can happen if a file was renamed or copied
488
+ // We need to decide: keep persisted mapping or update to new file?
489
+ // For now, update the reverse mapping but keep ID mapping stable
490
+ this.fileToIdMap.set(filename, content.id);
491
+ }
492
+ }
493
+ }
494
+ }
495
+ }
496
+ /**
497
+ * Resolve duplicate IDs in local files (following architectural plan)
498
+ * Principle: Keep ID only in the oldest file, remove from others
499
+ */
500
+ resolveDuplicateIds(fileContents) {
501
+ // Group files by workflow ID
502
+ const filesById = new Map();
503
+ for (const { filename, content, mtime } of fileContents) {
504
+ if (content?.id) {
505
+ const workflowId = content.id;
506
+ if (!filesById.has(workflowId)) {
507
+ filesById.set(workflowId, []);
508
+ }
509
+ filesById.get(workflowId).push({ filename, mtime });
510
+ }
511
+ }
512
+ // For each duplicate ID, keep only in oldest file
513
+ for (const [workflowId, fileList] of filesById.entries()) {
514
+ if (fileList.length > 1) {
515
+ // Sort by modification time (oldest first)
516
+ fileList.sort((a, b) => a.mtime - b.mtime);
517
+ const oldestFile = fileList[0].filename;
518
+ const duplicates = fileList.slice(1);
519
+ // Remove ID from duplicate files
520
+ for (const { filename: dupFilename } of duplicates) {
521
+ const filePath = path.join(this.directory, dupFilename);
522
+ const content = this.readJsonFile(filePath);
523
+ if (content) {
524
+ delete content.id;
525
+ fs.writeFileSync(filePath, JSON.stringify(content, null, 2));
526
+ // Update local hash for the modified file
527
+ const clean = WorkflowSanitizer.cleanForHash(content);
528
+ const hash = this.computeHash(clean);
529
+ this.localHashes.set(dupFilename, hash);
530
+ }
531
+ }
532
+ // Emit event for UI (if needed)
533
+ this.emit('duplicateIdResolved', {
534
+ workflowId,
535
+ keptInFilename: oldestFile,
536
+ removedFromFilenames: duplicates.map(d => d.filename)
537
+ });
258
538
  }
259
539
  }
260
540
  }
@@ -266,28 +546,67 @@ export class Watcher extends EventEmitter {
266
546
  */
267
547
  async refreshRemoteState() {
268
548
  try {
269
- const remoteWorkflows = await this.client.getAllWorkflows();
549
+ const remoteWorkflows = await this.client.getAllWorkflows(this.projectId);
270
550
  this.isConnected = true;
271
551
  const currentRemoteIds = new Set();
552
+ // Build set of already-assigned filenames to prevent collisions
553
+ // A filename is "assigned" if:
554
+ // 1. It exists physically on disk, OR
555
+ // 2. It's mapped to a workflow that still exists remotely (even if DELETED_LOCALLY)
556
+ const assignedFilenames = new Set();
272
557
  for (const wf of remoteWorkflows) {
273
558
  if (this.shouldIgnore(wf))
274
559
  continue;
275
560
  if (this.isPaused.has(wf.id) || this.syncInProgress.has(wf.id))
276
561
  continue;
277
562
  currentRemoteIds.add(wf.id);
278
- // CRITICAL: Use ID-based mapping instead of name-based filename generation
279
- // This prevents issues when filename != workflow.name
563
+ // CRITICAL: Use ID-based mapping with PERSISTED state as source of truth
564
+ // Priority order for finding filename:
565
+ // 1. Persisted mapping from state (most reliable for stability)
566
+ // 2. Memory mapping (may differ if file was renamed locally)
567
+ // 3. Scan local files by ID
568
+ // 4. Generate from name (new workflow)
280
569
  let filename = this.idToFileMap.get(wf.id);
281
- // If no mapping exists, try to find the file by scanning local files
570
+ // If no valid mapping, scan local files to discover/rediscover the workflow
282
571
  if (!filename) {
283
572
  filename = this.findFilenameByWorkflowId(wf.id);
284
573
  }
285
- // If still not found, generate filename from name (new remote workflow)
574
+ // Reserve this filename BEFORE checking for newworkflows
575
+ if (filename) {
576
+ assignedFilenames.add(filename);
577
+ }
578
+ // If still not found, this is a NEW remote workflow - generate filename
286
579
  if (!filename) {
287
- filename = `${this.safeName(wf.name)}.json`;
580
+ const baseName = `${this.safeName(wf.name)}.json`;
581
+ // Check if this base name is already assigned to another workflow
582
+ if (assignedFilenames.has(baseName)) {
583
+ // Name collision - generate unique filename with ID suffix
584
+ const idSuffix = wf.id.substring(0, 8);
585
+ filename = `${this.safeName(wf.name)}_${idSuffix}.json`;
586
+ }
587
+ else {
588
+ // Name is free - use it
589
+ filename = baseName;
590
+ }
591
+ // Mark this filename as assigned
592
+ assignedFilenames.add(filename);
593
+ }
594
+ // Update mappings ONLY if this is a new workflow or filename hasn't changed
595
+ const previousFilename = this.idToFileMap.get(wf.id);
596
+ if (!previousFilename) {
597
+ // New workflow - establish mapping
598
+ this.idToFileMap.set(wf.id, filename);
599
+ this.fileToIdMap.set(filename, wf.id);
600
+ }
601
+ else if (previousFilename !== filename) {
602
+ // Filename changed - this should only happen during explicit rename
603
+ // For duplicate name scenarios, we should have generated a unique name above
604
+ // Update mappings
605
+ this.fileToIdMap.delete(previousFilename);
606
+ this.idToFileMap.set(wf.id, filename);
607
+ this.fileToIdMap.set(filename, wf.id);
288
608
  }
289
- this.idToFileMap.set(wf.id, filename);
290
- this.fileToIdMap.set(filename, wf.id);
609
+ // If previousFilename === filename, mappings are already correct - don't touch them
291
610
  // Check if we need to fetch full content
292
611
  const cachedTimestamp = this.remoteTimestamps.get(wf.id);
293
612
  const needsFullFetch = !cachedTimestamp ||
@@ -296,7 +615,7 @@ export class Watcher extends EventEmitter {
296
615
  try {
297
616
  const fullWf = await this.client.getWorkflow(wf.id);
298
617
  if (fullWf) {
299
- const clean = WorkflowSanitizer.cleanForStorage(fullWf);
618
+ const clean = WorkflowSanitizer.cleanForHash(fullWf);
300
619
  const hash = this.computeHash(clean);
301
620
  this.remoteHashes.set(wf.id, hash);
302
621
  if (wf.updatedAt) {
@@ -386,7 +705,7 @@ export class Watcher extends EventEmitter {
386
705
  if (!content) {
387
706
  throw new Error(`Cannot finalize sync: local file not found for ${workflowId}`);
388
707
  }
389
- const clean = WorkflowSanitizer.cleanForStorage(content);
708
+ const clean = WorkflowSanitizer.cleanForHash(content);
390
709
  const computedHash = this.computeHash(clean);
391
710
  // After a successful sync, local and remote should be identical
392
711
  // Use the computed hash for both
@@ -406,9 +725,11 @@ export class Watcher extends EventEmitter {
406
725
  */
407
726
  async updateWorkflowState(id, hash) {
408
727
  const state = this.loadState();
728
+ const filename = this.idToFileMap.get(id) || '';
409
729
  state.workflows[id] = {
410
730
  lastSyncedHash: hash,
411
- lastSyncedAt: new Date().toISOString()
731
+ lastSyncedAt: new Date().toISOString(),
732
+ filename: filename
412
733
  };
413
734
  this.saveState(state);
414
735
  }
@@ -431,6 +752,7 @@ export class Watcher extends EventEmitter {
431
752
  }
432
753
  /**
433
754
  * Load state from .n8n-state.json
755
+ * Does NOT restore mappings - use restoreMappingsFromState() for that
434
756
  */
435
757
  loadState() {
436
758
  if (fs.existsSync(this.stateFilePath)) {
@@ -447,6 +769,23 @@ export class Watcher extends EventEmitter {
447
769
  }
448
770
  return { workflows: {} };
449
771
  }
772
+ /**
773
+ * Restore ID→filename mappings from persisted state
774
+ * Should only be called once at startup and after state changes
775
+ */
776
+ restoreMappingsFromState() {
777
+ const state = this.loadState();
778
+ for (const [id, workflowState] of Object.entries(state.workflows)) {
779
+ const ws = workflowState;
780
+ if (ws.filename) {
781
+ // Only set if not already mapped (current session takes precedence)
782
+ if (!this.idToFileMap.has(id)) {
783
+ this.idToFileMap.set(id, ws.filename);
784
+ this.fileToIdMap.set(ws.filename, id);
785
+ }
786
+ }
787
+ }
788
+ }
450
789
  /**
451
790
  * Save state to .n8n-state.json
452
791
  */
@@ -552,49 +891,90 @@ export class Watcher extends EventEmitter {
552
891
  return null;
553
892
  }
554
893
  }
894
+ writeWorkflowFile(filename, content) {
895
+ const filePath = path.join(this.directory, filename);
896
+ fs.writeFileSync(filePath, JSON.stringify(content, null, 2));
897
+ }
555
898
  getFileToIdMap() {
556
899
  return this.fileToIdMap;
557
900
  }
558
901
  getStatusMatrix() {
559
902
  const results = new Map();
560
903
  const state = this.loadState();
904
+ // Get workflows with metadata for project info
905
+ const workflowsMap = new Map();
906
+ try {
907
+ // Read local workflows
908
+ for (const [filename] of this.localHashes.entries()) {
909
+ const filePath = path.join(this.directory, filename);
910
+ if (fs.existsSync(filePath)) {
911
+ const content = fs.readFileSync(filePath, 'utf8');
912
+ const workflow = JSON.parse(content);
913
+ if (workflow.id) {
914
+ workflowsMap.set(workflow.id, workflow);
915
+ }
916
+ }
917
+ }
918
+ }
919
+ catch (error) {
920
+ console.debug('[Watcher] Failed to load workflow metadata for status matrix:', error);
921
+ }
561
922
  // 1. Process all local files
562
923
  for (const [filename, hash] of this.localHashes.entries()) {
563
924
  const workflowId = this.fileToIdMap.get(filename);
564
925
  const status = this.calculateStatus(filename, workflowId);
926
+ const workflow = workflowId ? workflowsMap.get(workflowId) : undefined;
565
927
  results.set(filename, {
566
928
  id: workflowId || '',
567
929
  name: filename.replace('.json', ''),
568
930
  filename: filename,
569
931
  status: status,
570
- active: true
932
+ active: workflow?.active ?? true,
933
+ projectId: workflow?.projectId,
934
+ projectName: workflow?.projectName,
935
+ homeProject: workflow?.homeProject,
936
+ isArchived: workflow?.isArchived ?? false
571
937
  });
572
938
  }
573
939
  // 2. Process all remote workflows not yet in results
574
940
  for (const [workflowId, remoteHash] of this.remoteHashes.entries()) {
575
- const filename = this.idToFileMap.get(workflowId) || `${workflowId}.json`;
941
+ // Use persisted filename from state for stability
942
+ const persistedFilename = state.workflows[workflowId]?.filename;
943
+ const filename = persistedFilename || this.idToFileMap.get(workflowId) || `${workflowId}.json`;
576
944
  if (!results.has(filename)) {
577
945
  const status = this.calculateStatus(filename, workflowId);
946
+ const workflow = workflowsMap.get(workflowId);
578
947
  results.set(filename, {
579
948
  id: workflowId,
580
949
  name: filename.replace('.json', ''),
581
950
  filename: filename,
582
951
  status: status,
583
- active: true
952
+ active: workflow?.active ?? true,
953
+ projectId: workflow?.projectId,
954
+ projectName: workflow?.projectName,
955
+ homeProject: workflow?.homeProject,
956
+ isArchived: workflow?.isArchived ?? false
584
957
  });
585
958
  }
586
959
  }
587
960
  // 3. Process tracked but deleted workflows
588
961
  for (const id of Object.keys(state.workflows)) {
589
- const filename = this.idToFileMap.get(id) || `${id}.json`;
962
+ // Use persisted filename from state for stability
963
+ const persistedFilename = state.workflows[id].filename;
964
+ const filename = persistedFilename || this.idToFileMap.get(id) || `${id}.json`;
590
965
  if (!results.has(filename)) {
591
966
  const status = this.calculateStatus(filename, id);
967
+ const workflow = workflowsMap.get(id);
592
968
  results.set(filename, {
593
969
  id,
594
970
  name: filename.replace('.json', ''),
595
971
  filename,
596
972
  status,
597
- active: true
973
+ active: workflow?.active ?? true,
974
+ projectId: workflow?.projectId,
975
+ projectName: workflow?.projectName,
976
+ homeProject: workflow?.homeProject,
977
+ isArchived: workflow?.isArchived ?? false
598
978
  });
599
979
  }
600
980
  }
@@ -621,6 +1001,42 @@ export class Watcher extends EventEmitter {
621
1001
  const state = this.loadState();
622
1002
  return Object.keys(state.workflows);
623
1003
  }
1004
+ /**
1005
+ * Get all workflows with their full content including organization metadata.
1006
+ * This reads from local files first, falls back to remote for remote-only workflows.
1007
+ * Useful for display purposes where we need project info, archived status, etc.
1008
+ */
1009
+ async getAllWorkflows() {
1010
+ const workflows = [];
1011
+ // 1. Get all local workflows
1012
+ for (const [filename, _] of this.localHashes.entries()) {
1013
+ const filepath = path.join(this.directory, filename);
1014
+ try {
1015
+ const content = fs.readFileSync(filepath, 'utf-8');
1016
+ const workflow = JSON.parse(content);
1017
+ workflows.push(workflow);
1018
+ }
1019
+ catch (error) {
1020
+ console.warn(`[Watcher] Failed to read local workflow ${filename}:`, error);
1021
+ }
1022
+ }
1023
+ // 2. For remote-only workflows, fetch from API
1024
+ const localIds = new Set(workflows.map(w => w.id));
1025
+ for (const [workflowId, _] of this.remoteHashes.entries()) {
1026
+ if (!localIds.has(workflowId)) {
1027
+ try {
1028
+ const workflow = await this.client.getWorkflow(workflowId);
1029
+ if (workflow) {
1030
+ workflows.push(workflow);
1031
+ }
1032
+ }
1033
+ catch (error) {
1034
+ console.warn(`[Watcher] Failed to fetch remote workflow ${workflowId}:`, error);
1035
+ }
1036
+ }
1037
+ }
1038
+ return workflows;
1039
+ }
624
1040
  /**
625
1041
  * Update workflow ID in state (when a workflow is re-created with a new ID)
626
1042
  */