@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.
- package/README.md +2 -2
- package/dist/helpers/index.d.ts +8 -0
- package/dist/helpers/index.d.ts.map +1 -0
- package/dist/helpers/index.js +8 -0
- package/dist/helpers/index.js.map +1 -0
- package/dist/helpers/project-helpers.d.ts +93 -0
- package/dist/helpers/project-helpers.d.ts.map +1 -0
- package/dist/helpers/project-helpers.js +168 -0
- package/dist/helpers/project-helpers.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/services/directory-utils.d.ts +6 -0
- package/dist/services/directory-utils.d.ts.map +1 -1
- package/dist/services/directory-utils.js +12 -0
- package/dist/services/directory-utils.js.map +1 -1
- package/dist/services/n8n-api-client.d.ts +28 -2
- package/dist/services/n8n-api-client.d.ts.map +1 -1
- package/dist/services/n8n-api-client.js +113 -3
- package/dist/services/n8n-api-client.js.map +1 -1
- package/dist/services/resolution-manager.d.ts +2 -2
- package/dist/services/resolution-manager.js +7 -7
- package/dist/services/resolution-manager.js.map +1 -1
- package/dist/services/state-manager.d.ts +1 -0
- package/dist/services/state-manager.d.ts.map +1 -1
- package/dist/services/state-manager.js.map +1 -1
- package/dist/services/sync-engine.d.ts +1 -1
- package/dist/services/sync-engine.d.ts.map +1 -1
- package/dist/services/sync-engine.js +10 -10
- package/dist/services/sync-engine.js.map +1 -1
- package/dist/services/sync-manager.d.ts +6 -1
- package/dist/services/sync-manager.d.ts.map +1 -1
- package/dist/services/sync-manager.js +18 -12
- package/dist/services/sync-manager.js.map +1 -1
- package/dist/services/trash-service.d.ts +1 -1
- package/dist/services/trash-service.d.ts.map +1 -1
- package/dist/services/trash-service.js +8 -8
- package/dist/services/trash-service.js.map +1 -1
- package/dist/services/watcher.d.ts +28 -2
- package/dist/services/watcher.d.ts.map +1 -1
- package/dist/services/watcher.js +466 -50
- package/dist/services/watcher.js.map +1 -1
- package/dist/services/workflow-sanitizer.d.ts +11 -0
- package/dist/services/workflow-sanitizer.d.ts.map +1 -1
- package/dist/services/workflow-sanitizer.js +31 -1
- package/dist/services/workflow-sanitizer.js.map +1 -1
- package/dist/types.d.ts +17 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
package/dist/services/watcher.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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
|
|
81
|
-
this.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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.
|
|
103
|
-
this.
|
|
104
|
-
this.
|
|
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
|
-
|
|
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.
|
|
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
|
-
//
|
|
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
|
|
207
|
-
if (!fs.existsSync(
|
|
208
|
-
fs.mkdirSync(
|
|
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.
|
|
212
|
-
const archivePath = path.join(
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
279
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
*/
|