n8nac 0.9.9-next.df19681 → 0.9.9-next.e754dc0

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 (45) hide show
  1. package/README.md +12 -6
  2. package/dist/commands/list.d.ts.map +1 -1
  3. package/dist/commands/list.js +3 -9
  4. package/dist/commands/list.js.map +1 -1
  5. package/dist/commands/sync.d.ts +1 -1
  6. package/dist/commands/sync.d.ts.map +1 -1
  7. package/dist/commands/sync.js +22 -11
  8. package/dist/commands/sync.js.map +1 -1
  9. package/dist/core/index.d.ts +1 -1
  10. package/dist/core/index.d.ts.map +1 -1
  11. package/dist/core/index.js +1 -1
  12. package/dist/core/index.js.map +1 -1
  13. package/dist/core/services/cli-api.d.ts +2 -1
  14. package/dist/core/services/cli-api.d.ts.map +1 -1
  15. package/dist/core/services/cli-api.js +2 -1
  16. package/dist/core/services/cli-api.js.map +1 -1
  17. package/dist/core/services/resolution-manager.d.ts +2 -2
  18. package/dist/core/services/resolution-manager.d.ts.map +1 -1
  19. package/dist/core/services/resolution-manager.js +16 -2
  20. package/dist/core/services/resolution-manager.js.map +1 -1
  21. package/dist/core/services/state-manager.d.ts +4 -3
  22. package/dist/core/services/state-manager.d.ts.map +1 -1
  23. package/dist/core/services/state-manager.js +2 -2
  24. package/dist/core/services/state-manager.js.map +1 -1
  25. package/dist/core/services/sync-engine.d.ts +2 -2
  26. package/dist/core/services/sync-engine.d.ts.map +1 -1
  27. package/dist/core/services/sync-engine.js +60 -116
  28. package/dist/core/services/sync-engine.js.map +1 -1
  29. package/dist/core/services/sync-manager.d.ts +0 -3
  30. package/dist/core/services/sync-manager.d.ts.map +1 -1
  31. package/dist/core/services/sync-manager.js +7 -19
  32. package/dist/core/services/sync-manager.js.map +1 -1
  33. package/dist/core/services/{watcher.d.ts → workflow-state-tracker.d.ts} +6 -47
  34. package/dist/core/services/workflow-state-tracker.d.ts.map +1 -0
  35. package/dist/core/services/{watcher.js → workflow-state-tracker.js} +90 -468
  36. package/dist/core/services/workflow-state-tracker.js.map +1 -0
  37. package/dist/core/types.d.ts +0 -1
  38. package/dist/core/types.d.ts.map +1 -1
  39. package/dist/core/types.js +0 -1
  40. package/dist/core/types.js.map +1 -1
  41. package/dist/index.js +9 -4
  42. package/dist/index.js.map +1 -1
  43. package/package.json +3 -3
  44. package/dist/core/services/watcher.d.ts.map +0 -1
  45. package/dist/core/services/watcher.js.map +0 -1
@@ -1,7 +1,6 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import EventEmitter from 'events';
4
- import chokidar from 'chokidar';
5
4
  import { WorkflowTransformerAdapter } from './workflow-transformer-adapter.js';
6
5
  import { HashUtils } from './hash-utils.js';
7
6
  import { WorkflowSyncStatus } from '../types.js';
@@ -17,8 +16,7 @@ import { WorkflowSyncStatus } from '../types.js';
17
16
  *
18
17
  * Never performs synchronization actions - only observes reality.
19
18
  */
20
- export class Watcher extends EventEmitter {
21
- watcherSubscription = null;
19
+ export class WorkflowStateTracker extends EventEmitter {
22
20
  client;
23
21
  directory;
24
22
  syncInactive;
@@ -26,7 +24,6 @@ export class Watcher extends EventEmitter {
26
24
  projectId;
27
25
  stateFilePath;
28
26
  isConnected = true;
29
- isInitializing = false;
30
27
  /** True during the first refreshRemoteState() call — suppresses status broadcasts */
31
28
  isInitialRemoteLoad = false;
32
29
  // Internal state tracking
@@ -36,13 +33,6 @@ export class Watcher extends EventEmitter {
36
33
  idToFileMap = new Map(); // workflowId -> filename
37
34
  lastKnownStatuses = new Map(); // workflowId or filename -> status
38
35
  remoteIds = new Set(); // workflowId
39
- // Concurrency control
40
- isPaused = new Set(); // IDs for which observation is paused
41
- syncInProgress = new Set(); // IDs currently being synced
42
- pausedFilenames = new Set(); // Filenames for which observation is paused (for workflows without ID yet)
43
- // Potential renames: when we see an add event for a workflow ID that already exists,
44
- // we track it here to match with subsequent unlink events
45
- potentialRenames = new Map();
46
36
  // Lightweight remote state cache
47
37
  remoteTimestamps = new Map(); // workflowId -> updatedAt
48
38
  /** Canonical display name for each remote workflow (id is the unique key, NOT the name). */
@@ -58,309 +48,12 @@ export class Watcher extends EventEmitter {
58
48
  // Restore persisted mappings immediately so 'pull' and other commands can find workflows
59
49
  this.restoreMappingsFromState();
60
50
  }
61
- async start() {
62
- if (this.watcherSubscription)
63
- return;
64
- this.isInitializing = true;
65
- // Don't fetch remote state on startup (no batch operations)
66
- // Remote state will be populated incrementally through single-workflow fetch operations
67
- // Skip connection test - assume connected, will fail on first fetch if not
68
- await this.refreshLocalState();
69
- // Restore persisted ID → filename mappings from state
70
- // This ensures stable filename assignment even when remote workflows have duplicate names
71
- this.restoreMappingsFromState();
72
- this.isInitializing = false;
73
- // Local Watch with Chokidar
74
- this.watcherSubscription = chokidar.watch(this.directory, {
75
- ignored: [
76
- '**/.n8n-state.json',
77
- '**/.git/**',
78
- /(^|[\/\\])\../
79
- ],
80
- ignoreInitial: true,
81
- persistent: true,
82
- awaitWriteFinish: {
83
- stabilityThreshold: 100,
84
- pollInterval: 50
85
- }
86
- });
87
- // Wait for watcher to be ready
88
- await new Promise((resolve) => {
89
- this.watcherSubscription?.once('ready', resolve);
90
- });
91
- this.watcherSubscription
92
- .on('add', (filePath) => {
93
- const filename = path.basename(filePath);
94
- if (filename.startsWith('.'))
95
- return;
96
- this.onLocalChange(filePath);
97
- })
98
- .on('change', (filePath) => {
99
- const filename = path.basename(filePath);
100
- if (filename.startsWith('.'))
101
- return;
102
- this.onLocalChange(filePath);
103
- })
104
- .on('unlink', (filePath) => {
105
- const filename = path.basename(filePath);
106
- if (filename.startsWith('.'))
107
- return;
108
- this.onLocalDelete(filePath);
109
- })
110
- .on('error', (error) => {
111
- this.emit('error', error);
112
- });
113
- this.emit('ready');
114
- }
115
- async stop() {
116
- if (this.watcherSubscription) {
117
- await this.watcherSubscription.close();
118
- this.watcherSubscription = null;
119
- }
120
- }
121
51
  getDirectory() {
122
52
  return this.directory;
123
53
  }
124
54
  getFilenameForId(id) {
125
55
  return this.idToFileMap.get(id);
126
56
  }
127
- /**
128
- * Pause observation for a workflow during sync operations
129
- */
130
- pauseObservation(workflowId) {
131
- this.isPaused.add(workflowId);
132
- }
133
- /**
134
- * Resume observation after sync operations
135
- */
136
- resumeObservation(workflowId) {
137
- this.isPaused.delete(workflowId);
138
- // Don't force refresh here to avoid unnecessary API calls
139
- // In git-like sync, remote state is updated explicitly via fetch command
140
- }
141
- /**
142
- * Pause observation for a filename (for workflows without ID yet)
143
- */
144
- pauseObservationByFilename(filename) {
145
- this.pausedFilenames.add(filename);
146
- }
147
- /**
148
- * Resume observation for a filename
149
- */
150
- resumeObservationByFilename(filename) {
151
- this.pausedFilenames.delete(filename);
152
- }
153
- /**
154
- * Mark a workflow as being synced (prevents race conditions)
155
- */
156
- markSyncInProgress(workflowId) {
157
- this.syncInProgress.add(workflowId);
158
- }
159
- /**
160
- * Mark a workflow as no longer being synced
161
- */
162
- markSyncComplete(workflowId) {
163
- this.syncInProgress.delete(workflowId);
164
- }
165
- async onLocalChange(filePath) {
166
- const filename = path.basename(filePath);
167
- console.log(`[Watcher] onLocalChange: ${filename}`);
168
- if (!filename.endsWith('.workflow.ts'))
169
- return;
170
- const content = this.readJsonFile(filePath);
171
- if (!content) {
172
- console.log(`[Watcher] ❌ Cannot read file content for ${filename} - readJsonFile returned null`);
173
- return;
174
- }
175
- console.log(`[Watcher] ✅ File content read for ${filename}, ID=${content.id}`);
176
- // Check if filename is paused (for workflows without ID)
177
- if (this.pausedFilenames.has(filename)) {
178
- console.log(`[Watcher] ⏸️ Filename ${filename} is paused, ignoring change`);
179
- return;
180
- }
181
- let workflowId = content.id || this.fileToIdMap.get(filename);
182
- if (workflowId && (this.isPaused.has(workflowId) || this.syncInProgress.has(workflowId))) {
183
- console.log(`[Watcher] ⏸️ Workflow ${workflowId} is paused or sync in progress, ignoring change`);
184
- return;
185
- }
186
- // Check for duplicate ID (following architectural plan)
187
- if (content.id) {
188
- const existingFilename = this.idToFileMap.get(content.id);
189
- if (existingFilename && existingFilename !== filename) {
190
- // Check if the existing file still exists on disk
191
- const existingFilePath = path.join(this.directory, existingFilename);
192
- const fileExists = fs.existsSync(existingFilePath);
193
- if (!fileExists) {
194
- // The existing file doesn't exist - this is likely a rename
195
- // Update mappings to point to the new filename
196
- this.fileToIdMap.delete(existingFilename);
197
- this.fileToIdMap.set(filename, content.id);
198
- this.idToFileMap.set(content.id, filename);
199
- // PERSIST: Update filename in state to prevent "ghost" workflows after restart
200
- const state = this.loadState();
201
- if (state.workflows[content.id]) {
202
- state.workflows[content.id].filename = filename;
203
- this.saveState(state);
204
- }
205
- // Emit rename event
206
- this.emit('fileRenamed', {
207
- workflowId: content.id,
208
- oldFilename: existingFilename,
209
- newFilename: filename
210
- });
211
- }
212
- else {
213
- // File exists - this could be a rename where add happened before unlink
214
- // Track as potential rename and wait for unlink event
215
- this.potentialRenames.set(content.id, {
216
- newFilename: filename,
217
- timestamp: Date.now()
218
- });
219
- // File exists - this is a DUPLICATE ID (copy-paste)
220
- // Principle: Keep ID only in the oldest file, remove from the new one
221
- // DUPLICAT DÉTECTÉ pendant le watch → supprimer l'ID du nouveau fichier
222
- // Remove ID from the new file
223
- const currentContent = this.readJsonFile(filePath);
224
- if (currentContent && currentContent.id === content.id) {
225
- delete currentContent.id;
226
- await this.writeWorkflowFile(filename, currentContent);
227
- // Re-read the TypeScript content and compute hash
228
- const tsContent = fs.readFileSync(filePath, 'utf-8');
229
- try {
230
- const hash = await WorkflowTransformerAdapter.hashWorkflow(tsContent);
231
- const workflowId = this.fileToIdMap.get(filename);
232
- this.localHashes.set(filename, hash);
233
- this.broadcastStatus(filename, workflowId);
234
- }
235
- catch (parseErr) {
236
- console.error(`[Watcher] ❌ Cannot parse "${filename}" after duplicate-ID removal: ${parseErr.message}`);
237
- }
238
- }
239
- return; // Stop processing this file as it's being modified
240
- // Don't return - continue processing as normal
241
- // The unlink event should come soon and trigger rename detection
242
- }
243
- }
244
- }
245
- // IMPORTANT: Hash is calculated on the SANITIZED version
246
- // This means versionId, versionCounter, pinData, etc. are ignored
247
- // The file on disk can contain these fields, but they won't affect the hash
248
- const tsContent = fs.readFileSync(filePath, 'utf-8');
249
- let hash;
250
- try {
251
- hash = await WorkflowTransformerAdapter.hashWorkflow(tsContent);
252
- }
253
- catch (parseErr) {
254
- // Parsing failed (e.g. invalid identifier characters like → in the class name
255
- // cause ts-morph to silently drop the class body, resulting in a 0-node compile
256
- // which would be mistaken as a local modification and pushed, wiping the remote).
257
- // Log the problem and abort – do NOT update localHashes so the file is not pushed.
258
- console.error(`[Watcher] ❌ Cannot parse "${filename}" – skipping hash/status update to prevent data loss.\n` +
259
- ` Cause: ${parseErr.message}\n` +
260
- ` Tip: Make sure the class name contains only valid ASCII/identifier characters ` +
261
- `(→ U+2192 and similar symbols are not allowed in TypeScript identifiers).`);
262
- return;
263
- }
264
- console.log(`[Watcher] 🔢 Hash computed for ${filename}: ${hash.substring(0, 8)}...`);
265
- this.localHashes.set(filename, hash);
266
- if (workflowId) {
267
- this.fileToIdMap.set(filename, workflowId);
268
- this.idToFileMap.set(workflowId, filename);
269
- }
270
- console.log(`[Watcher] 📡 Broadcasting status for ${filename}...`);
271
- this.broadcastStatus(filename, workflowId);
272
- }
273
- async onLocalDelete(filePath) {
274
- const filename = path.basename(filePath);
275
- let workflowId = this.fileToIdMap.get(filename);
276
- // If workflowId not found via filename mapping, try to find it via state
277
- if (!workflowId) {
278
- const state = this.loadState();
279
- for (const [id, stateData] of Object.entries(state.workflows)) {
280
- const mappedFilename = this.idToFileMap.get(id);
281
- if (mappedFilename === filename) {
282
- workflowId = id;
283
- break;
284
- }
285
- }
286
- }
287
- // Check if this is a potential rename (add happened before unlink)
288
- if (workflowId) {
289
- const potentialRename = this.potentialRenames.get(workflowId);
290
- if (potentialRename) {
291
- this.potentialRenames.delete(workflowId);
292
- // Handle as rename
293
- this.handleRename(workflowId, filename, potentialRename.newFilename);
294
- return;
295
- }
296
- }
297
- if (workflowId && (this.isPaused.has(workflowId) || this.syncInProgress.has(workflowId))) {
298
- return;
299
- }
300
- // Handle deletion directly
301
- await this.handleLocalDelete(filename, workflowId);
302
- }
303
- async handleLocalDelete(filename, workflowId) {
304
- // Final check: is this actually a rename?
305
- if (workflowId) {
306
- // Check if the workflow ID appears in another file
307
- const otherFilename = this.findFilenameByWorkflowId(workflowId);
308
- if (otherFilename && otherFilename !== filename) {
309
- // This is a rename, not a deletion!
310
- this.handleRename(workflowId, filename, otherFilename);
311
- return;
312
- }
313
- }
314
- // When a local file is deleted we simply clear the lastSyncedHash from state
315
- // so that calculateStatus() naturally returns EXIST_ONLY_REMOTELY
316
- // (remoteHash present, no lastSyncedHash, no localHash).
317
- // No archiving is needed here – the remote copy is untouched.
318
- if (workflowId) {
319
- const state = this.loadState();
320
- if (state.workflows[workflowId]) {
321
- state.workflows[workflowId].lastSyncedHash = undefined;
322
- this.saveState(state);
323
- }
324
- }
325
- // Clean up local hash for deleted file
326
- this.localHashes.delete(filename);
327
- // Broadcast the new status (EXIST_ONLY_REMOTELY if remote exists, or gone entirely)
328
- this.broadcastStatus(filename, workflowId);
329
- // CRITICAL: DO NOT delete ID→filename mappings
330
- // Mappings must persist so the workflow re-appears with the same filename
331
- // when it is pulled again. Mappings are only cleaned up via removeWorkflowState().
332
- }
333
- handleRename(workflowId, oldFilename, newFilename) {
334
- // Update mappings
335
- this.fileToIdMap.delete(oldFilename);
336
- this.fileToIdMap.set(newFilename, workflowId);
337
- this.idToFileMap.set(workflowId, newFilename);
338
- // Update local hash mapping
339
- const oldHash = this.localHashes.get(oldFilename);
340
- if (oldHash) {
341
- this.localHashes.delete(oldFilename);
342
- this.localHashes.set(newFilename, oldHash);
343
- }
344
- // PERSIST: Update filename in state to prevent "ghost" workflows after restart
345
- if (workflowId) {
346
- const state = this.loadState();
347
- if (state.workflows[workflowId]) {
348
- state.workflows[workflowId].filename = newFilename;
349
- this.saveState(state);
350
- }
351
- }
352
- // Emit rename event
353
- this.emit('fileRenamed', {
354
- workflowId,
355
- oldFilename,
356
- newFilename
357
- });
358
- // Broadcast status with new filename
359
- this.broadcastStatus(newFilename, workflowId);
360
- // Also broadcast status for old filename to clear it from UI
361
- // Since it's no longer in localHashes or mappings, it will be handled correctly
362
- this.broadcastStatus(oldFilename, undefined);
363
- }
364
57
  async refreshLocalState() {
365
58
  if (!fs.existsSync(this.directory)) {
366
59
  console.log(`[DEBUG] refreshLocalState: Directory missing: ${this.directory}`);
@@ -388,8 +81,7 @@ export class Watcher extends EventEmitter {
388
81
  const filePath = path.join(this.directory, filename);
389
82
  const content = this.readJsonFile(filePath); // Quick ID extraction
390
83
  if (content) {
391
- const stat = fs.statSync(filePath);
392
- fileContents.push({ filename, content, mtime: stat.mtimeMs });
84
+ fileContents.push({ filename, content });
393
85
  // Compute hash from TypeScript file directly
394
86
  const tsContent = fs.readFileSync(filePath, 'utf-8');
395
87
  try {
@@ -400,7 +92,7 @@ export class Watcher extends EventEmitter {
400
92
  newlyTracked.push(filename);
401
93
  }
402
94
  catch (parseErr) {
403
- console.error(`[Watcher] ❌ Cannot parse "${filename}" during local scan – skipping.\n` +
95
+ console.error(`[WorkflowStateTracker] ❌ Cannot parse "${filename}" during local scan – skipping.\n` +
404
96
  ` Cause: ${parseErr.message}\n` +
405
97
  ` Tip: Make sure the class name contains only valid ASCII/identifier characters ` +
406
98
  `(→ U+2192 and similar symbols are not allowed in TypeScript identifiers).`);
@@ -408,29 +100,44 @@ export class Watcher extends EventEmitter {
408
100
  }
409
101
  }
410
102
  }
411
- // Detect and resolve duplicate IDs (following architectural plan)
412
- await this.resolveDuplicateIds(fileContents);
413
- // Second pass: update mappings after duplicate resolution
414
- // CRITICAL: Only update mappings if not already set from persisted state
415
- // This prevents ID alternation when remote workflows have duplicate names
103
+ // Second pass: build file→ID mappings from actual file content (scan-wins).
104
+ //
105
+ // For IDs that exist on disk, the scan result is authoritative — this correctly
106
+ // handles renames (new filename contains the same @workflow({ id: "..." }) decorator).
107
+ // Mappings for remote-only workflows (set by fetch/updateSingleRemoteState and not
108
+ // present in local files) are left untouched.
109
+ //
110
+ // Duplicate ID handling (copy-paste, option A — no file modification):
111
+ // Sort claimants alphabetically → first file wins, others get no mapping
112
+ // → treated as EXIST_ONLY_LOCALLY, resolved by pushing (gets a new id)
113
+ const idClaims = new Map();
416
114
  for (const { filename, content } of fileContents) {
417
115
  if (content?.id) {
418
- // Only update if we don't have a persisted mapping for this ID
419
- if (!this.idToFileMap.has(content.id)) {
420
- this.fileToIdMap.set(filename, content.id);
421
- this.idToFileMap.set(content.id, filename);
422
- }
423
- else {
424
- // We have a persisted mapping - verify it matches the file
425
- const persistedFilename = this.idToFileMap.get(content.id);
426
- if (persistedFilename !== filename) {
427
- // The ID is in a different file than expected
428
- // This can happen if a file was renamed or copied
429
- // We need to decide: keep persisted mapping or update to new file?
430
- // For now, update the reverse mapping but keep ID mapping stable
431
- this.fileToIdMap.set(filename, content.id);
432
- }
433
- }
116
+ if (!idClaims.has(content.id))
117
+ idClaims.set(content.id, []);
118
+ idClaims.get(content.id).push(filename);
119
+ }
120
+ }
121
+ for (const [id, claimants] of idClaims) {
122
+ // Remove the stale filename entry for this ID before setting the scan result
123
+ const staleFilename = this.idToFileMap.get(id);
124
+ if (staleFilename) {
125
+ this.fileToIdMap.delete(staleFilename);
126
+ }
127
+ const sorted = [...claimants].sort();
128
+ const winner = sorted[0];
129
+ if (sorted.length > 1) {
130
+ console.warn(`[WorkflowStateTracker] ⚠️ Duplicate ID "${id}" in [${sorted.join(', ')}]` +
131
+ ` → "${winner}" wins (alphabetical). Others treated as new workflows.`);
132
+ }
133
+ this.fileToIdMap.set(winner, id);
134
+ this.idToFileMap.set(id, winner);
135
+ }
136
+ // Clean up fileToIdMap entries for files that no longer exist on disk.
137
+ // (idToFileMap for deleted-locally workflows is intentionally kept for EXIST_ONLY_REMOTELY.)
138
+ for (const existingFilename of Array.from(this.fileToIdMap.keys())) {
139
+ if (!currentFiles.has(existingFilename)) {
140
+ this.fileToIdMap.delete(existingFilename);
434
141
  }
435
142
  }
436
143
  // Broadcast status for newly-tracked files (includes ID-less local-only files)
@@ -441,51 +148,6 @@ export class Watcher extends EventEmitter {
441
148
  this.broadcastStatus(filename, workflowId);
442
149
  }
443
150
  }
444
- /**
445
- * Resolve duplicate IDs in local files (following architectural plan)
446
- * Principle: Keep ID only in the oldest file, remove from others
447
- */
448
- async resolveDuplicateIds(fileContents) {
449
- // Group files by workflow ID
450
- const filesById = new Map();
451
- for (const { filename, content, mtime } of fileContents) {
452
- if (content?.id) {
453
- const workflowId = content.id;
454
- if (!filesById.has(workflowId)) {
455
- filesById.set(workflowId, []);
456
- }
457
- filesById.get(workflowId).push({ filename, mtime });
458
- }
459
- }
460
- // For each duplicate ID, keep only in oldest file
461
- for (const [workflowId, fileList] of filesById.entries()) {
462
- if (fileList.length > 1) {
463
- // Sort by modification time (oldest first)
464
- fileList.sort((a, b) => a.mtime - b.mtime);
465
- const oldestFile = fileList[0].filename;
466
- const duplicates = fileList.slice(1);
467
- // Remove ID from duplicate files
468
- for (const { filename: dupFilename } of duplicates) {
469
- const filePath = path.join(this.directory, dupFilename);
470
- const content = this.readJsonFile(filePath);
471
- if (content) {
472
- delete content.id;
473
- await this.writeWorkflowFile(dupFilename, content);
474
- // Update local hash for the modified file
475
- const tsContent = fs.readFileSync(filePath, 'utf-8');
476
- const hash = await WorkflowTransformerAdapter.hashWorkflow(tsContent);
477
- this.localHashes.set(dupFilename, hash);
478
- }
479
- }
480
- // Emit event for UI (if needed)
481
- this.emit('duplicateIdResolved', {
482
- workflowId,
483
- keptInFilename: oldestFile,
484
- removedFromFilenames: duplicates.map(d => d.filename)
485
- });
486
- }
487
- }
488
- }
489
151
  /**
490
152
  * Lightweight fetch strategy:
491
153
  * 1. Fetch only IDs and updatedAt timestamps
@@ -512,8 +174,6 @@ export class Watcher extends EventEmitter {
512
174
  for (const wf of remoteWorkflows) {
513
175
  if (this.shouldIgnore(wf))
514
176
  continue;
515
- if (this.isPaused.has(wf.id) || this.syncInProgress.has(wf.id))
516
- continue;
517
177
  this.remoteIds.add(wf.id);
518
178
  // Store canonical name keyed by ID (names are NOT unique in n8n)
519
179
  if (wf.name)
@@ -550,16 +210,7 @@ export class Watcher extends EventEmitter {
550
210
  // New workflow - establish mapping
551
211
  this.idToFileMap.set(wf.id, filename);
552
212
  this.fileToIdMap.set(filename, wf.id);
553
- // PERSIST: Save mapping to state so subsequent 'pull' commands can find it
554
- const state = this.loadState();
555
- if (!state.workflows[wf.id]) {
556
- state.workflows[wf.id] = {
557
- filename: filename,
558
- lastSyncedHash: undefined,
559
- lastSyncedAt: undefined
560
- };
561
- this.saveState(state);
562
- }
213
+ // No longer persist filename to state (mappings are rebuilt from file scan).
563
214
  }
564
215
  else if (previousFilename !== filename) {
565
216
  // Filename changed
@@ -665,11 +316,9 @@ export class Watcher extends EventEmitter {
665
316
  */
666
317
  async updateWorkflowState(id, hash, remoteUpdatedAt) {
667
318
  const state = this.loadState();
668
- const filename = this.idToFileMap.get(id) || '';
669
319
  state.workflows[id] = {
670
320
  lastSyncedHash: hash,
671
- lastSyncedAt: remoteUpdatedAt || new Date().toISOString(),
672
- filename: filename
321
+ lastSyncedAt: remoteUpdatedAt || new Date().toISOString()
673
322
  };
674
323
  this.saveState(state);
675
324
  }
@@ -712,21 +361,12 @@ export class Watcher extends EventEmitter {
712
361
  return { workflows: {} };
713
362
  }
714
363
  /**
715
- * Restore ID→filename mappings from persisted state
716
- * Should only be called once at startup and after state changes
364
+ * No-op: file→ID mappings are now built exclusively by refreshLocalState()
365
+ * which scans the @workflow({ id: "..." }) decorator in each *.workflow.ts.
366
+ * This guarantees correct reconciliation after a local rename.
717
367
  */
718
368
  restoreMappingsFromState() {
719
- const state = this.loadState();
720
- for (const [id, workflowState] of Object.entries(state.workflows)) {
721
- const ws = workflowState;
722
- if (ws.filename) {
723
- // Only set if not already mapped (current session takes precedence)
724
- if (!this.idToFileMap.has(id)) {
725
- this.idToFileMap.set(id, ws.filename);
726
- this.fileToIdMap.set(ws.filename, id);
727
- }
728
- }
729
- }
369
+ // Intentionally empty — mappings are built by refreshLocalState() scan.
730
370
  }
731
371
  /**
732
372
  * Save state to .n8n-state.json
@@ -741,17 +381,15 @@ export class Watcher extends EventEmitter {
741
381
  return HashUtils.computeHash(content);
742
382
  }
743
383
  broadcastStatus(filename, workflowId) {
744
- if (this.isInitializing)
745
- return;
746
- // Suppress during initial remote load — avoids spurious "Change detected" on startup
384
+ // Suppress during initial remote load — avoids spurious “Change detected” on startup
747
385
  if (this.isInitialRemoteLoad)
748
386
  return;
749
387
  const status = this.calculateStatus(filename, workflowId);
750
388
  const key = workflowId || filename;
751
389
  const lastStatus = this.lastKnownStatuses.get(key);
752
- console.log(`[Watcher] Status for ${filename}: ${status} (last: ${lastStatus || 'none'})`);
390
+ console.log(`[WorkflowStateTracker] Status for ${filename}: ${status} (last: ${lastStatus || 'none'})`);
753
391
  if (status !== lastStatus) {
754
- console.log(`[Watcher] 🔔 Status changed! Emitting statusChange event`);
392
+ console.log(`[WorkflowStateTracker] 🔔 Status changed! Emitting statusChange event`);
755
393
  this.lastKnownStatuses.set(key, status);
756
394
  this.emit('statusChange', {
757
395
  filename,
@@ -760,7 +398,7 @@ export class Watcher extends EventEmitter {
760
398
  });
761
399
  }
762
400
  else {
763
- console.log(`[Watcher] Status unchanged, not emitting event`);
401
+ console.log(`[WorkflowStateTracker] Status unchanged, not emitting event`);
764
402
  }
765
403
  }
766
404
  calculateStatus(filename, workflowId) {
@@ -779,7 +417,7 @@ export class Watcher extends EventEmitter {
779
417
  const lastSyncedHash = baseState?.lastSyncedHash;
780
418
  // Debug logging for new files
781
419
  if (!workflowId && localHash) {
782
- console.log(`[Watcher] 🆕 calculateStatus for NEW file: ${filename}`);
420
+ console.log(`[WorkflowStateTracker] 🆕 calculateStatus for NEW file: ${filename}`);
783
421
  console.log(` localHash: ${localHash ? localHash.substring(0, 8) : 'none'}`);
784
422
  console.log(` lastSyncedHash: ${lastSyncedHash ? lastSyncedHash.substring(0, 8) : 'none'}`);
785
423
  console.log(` remoteHash: ${remoteHash ? remoteHash.substring(0, 8) : 'none'}`);
@@ -797,22 +435,12 @@ export class Watcher extends EventEmitter {
797
435
  const remoteModified = remoteHash && remoteHash !== lastSyncedHash;
798
436
  if (localModified && remoteModified)
799
437
  return WorkflowSyncStatus.CONFLICT;
800
- if (localModified && remoteHash === lastSyncedHash)
801
- return WorkflowSyncStatus.MODIFIED_LOCALLY;
802
- // remoteModified && localUnchanged: remote updated but local is untouched.
803
- // Remote updated but local untouched — treat as TRACKED, user can pull explicitly.
804
- if (remoteModified && localHash === lastSyncedHash)
805
- return WorkflowSyncStatus.TRACKED;
806
- // localUnchanged && remoteHash unknown (not yet fetched by this watcher instance):
807
- // local matches last synced state — safe to report TRACKED.
808
- // This prevents a spurious CONFLICT when an external process (CLI, another SyncManager
809
- // instance) pulls a workflow and writes lastSyncedHash to state while this watcher has
810
- // never fetched the remote hash for the workflow (remoteHashes cache is empty here).
811
- if (!localModified)
812
- return WorkflowSyncStatus.TRACKED;
438
+ // All other combinations (single-side or no modification) → TRACKED.
439
+ // Pull guards detect local modifications via direct hash comparison.
440
+ return WorkflowSyncStatus.TRACKED;
813
441
  }
814
442
  // Fallback for edge cases
815
- console.warn(`[Watcher] ⚠️ CONFLICT fallback for ${filename}:`, { localHash: !!localHash, remoteHash: !!remoteHash, lastSyncedHash: !!lastSyncedHash, workflowId });
443
+ console.warn(`[WorkflowStateTracker] ⚠️ CONFLICT fallback for ${filename}:`, { localHash: !!localHash, remoteHash: !!remoteHash, lastSyncedHash: !!lastSyncedHash, workflowId });
816
444
  return WorkflowSyncStatus.CONFLICT;
817
445
  }
818
446
  shouldIgnore(wf) {
@@ -910,15 +538,6 @@ export class Watcher extends EventEmitter {
910
538
  return null;
911
539
  }
912
540
  }
913
- async writeWorkflowFile(filename, workflow) {
914
- const filePath = path.join(this.directory, filename);
915
- // Always write as TypeScript
916
- const tsCode = await WorkflowTransformerAdapter.convertToTypeScript(workflow, {
917
- format: true,
918
- commentStyle: 'verbose'
919
- });
920
- fs.writeFileSync(filePath, tsCode, 'utf-8');
921
- }
922
541
  getFileToIdMap() {
923
542
  return this.fileToIdMap;
924
543
  }
@@ -931,7 +550,7 @@ export class Watcher extends EventEmitter {
931
550
  }
932
551
  /**
933
552
  * Lightweight list of workflows with basic status (local only, remote only, both)
934
- * Does NOT compute hashes, compile TypeScript, or determine detailed status (MODIFIED_LOCALLY, CONFLICT)
553
+ * Does NOT compute hashes, compile TypeScript, or determine detailed status (CONFLICT)
935
554
  */
936
555
  async getLightweightList() {
937
556
  const results = new Map();
@@ -939,17 +558,23 @@ export class Watcher extends EventEmitter {
939
558
  // 1. Process all local files (just check existence, no hash computation)
940
559
  for (const filename of this.getLocalWorkflowFilenames()) {
941
560
  const workflowId = this.fileToIdMap.get(filename);
942
- const remoteExists = workflowId ? this.remoteIds.has(workflowId) : false;
561
+ // A workflow is considered "known on remote" if:
562
+ // a) remoteIds has it (populated by refreshRemoteState)
563
+ // b) remoteHashes has it (populated by finalizeSync after push/pull)
564
+ // c) lastSyncedHash exists in state (written by any CLI/VSCode process,
565
+ // survives cross-process CLI operations like resolve)
566
+ const remoteKnown = workflowId
567
+ ? (this.remoteIds.has(workflowId)
568
+ || this.remoteHashes.has(workflowId)
569
+ || !!state.workflows[workflowId]?.lastSyncedHash)
570
+ : false;
943
571
  // Determine basic status
944
572
  let status;
945
- if (workflowId && remoteExists) {
573
+ if (workflowId && remoteKnown) {
946
574
  status = WorkflowSyncStatus.TRACKED; // Both exist
947
575
  }
948
- else if (workflowId && !remoteExists) {
949
- status = WorkflowSyncStatus.EXIST_ONLY_LOCALLY; // Local only (but has ID)
950
- }
951
576
  else {
952
- status = WorkflowSyncStatus.EXIST_ONLY_LOCALLY; // New file without ID
577
+ status = WorkflowSyncStatus.EXIST_ONLY_LOCALLY; // New or not yet pushed
953
578
  }
954
579
  // Prefer the remote canonical name (keyed by ID, not by name since names are non-unique).
955
580
  // For local-only files, extract the name from the @workflow decorator for an accurate display.
@@ -971,9 +596,11 @@ export class Watcher extends EventEmitter {
971
596
  }
972
597
  // 2. Process all remote workflows not yet in results
973
598
  for (const workflowId of this.remoteIds) {
974
- // Use persisted filename from state for stability
975
- const persistedFilename = state.workflows[workflowId]?.filename;
976
- const filename = persistedFilename || this.idToFileMap.get(workflowId) || `${workflowId}.workflow.ts`;
599
+ // Scan-wins: idToFileMap (rebuilt from @workflow decorator) is authoritative.
600
+ // Fall back to deprecated persisted filename for old state files during transition.
601
+ const filename = this.idToFileMap.get(workflowId)
602
+ || state.workflows[workflowId]?.filename
603
+ || `${workflowId}.workflow.ts`;
977
604
  if (!results.has(filename)) {
978
605
  // Prefer the actual remote name (stored by ID to avoid name-collision issues)
979
606
  // Fallback to filename-derived name only if remote name is not available
@@ -1007,7 +634,7 @@ export class Watcher extends EventEmitter {
1007
634
  }
1008
635
  }
1009
636
  catch (error) {
1010
- console.debug('[Watcher] Failed to read local directory:', error);
637
+ console.debug('[WorkflowStateTracker] Failed to read local directory:', error);
1011
638
  }
1012
639
  return filenames;
1013
640
  }
@@ -1031,13 +658,13 @@ export class Watcher extends EventEmitter {
1031
658
  }
1032
659
  }
1033
660
  catch (e) {
1034
- console.warn(`[Watcher] Failed to parse local workflow ${filename}:`, e);
661
+ console.warn(`[WorkflowStateTracker] Failed to parse local workflow ${filename}:`, e);
1035
662
  }
1036
663
  }
1037
664
  }
1038
665
  }
1039
666
  catch (error) {
1040
- console.debug('[Watcher] Failed to load workflow metadata for status matrix:', error);
667
+ console.debug('[WorkflowStateTracker] Failed to load workflow metadata for status matrix:', error);
1041
668
  }
1042
669
  // 1. Process all local files
1043
670
  for (const [filename, hash] of this.localHashes.entries()) {
@@ -1058,9 +685,11 @@ export class Watcher extends EventEmitter {
1058
685
  }
1059
686
  // 2. Process all remote workflows not yet in results
1060
687
  for (const [workflowId, remoteHash] of this.remoteHashes.entries()) {
1061
- // Use persisted filename from state for stability
1062
- const persistedFilename = state.workflows[workflowId]?.filename;
1063
- const filename = persistedFilename || this.idToFileMap.get(workflowId) || `${workflowId}.workflow.ts`;
688
+ // Scan-wins: idToFileMap (rebuilt from @workflow decorator) is authoritative.
689
+ // Fall back to deprecated persisted filename for old state files during transition.
690
+ const filename = this.idToFileMap.get(workflowId)
691
+ || state.workflows[workflowId]?.filename
692
+ || `${workflowId}.workflow.ts`;
1064
693
  if (!results.has(filename)) {
1065
694
  const status = this.calculateStatus(filename, workflowId);
1066
695
  const workflow = workflowsMap.get(workflowId);
@@ -1079,9 +708,11 @@ export class Watcher extends EventEmitter {
1079
708
  }
1080
709
  // 3. Process tracked but deleted workflows
1081
710
  for (const id of Object.keys(state.workflows)) {
1082
- // Use persisted filename from state for stability
1083
- const persistedFilename = state.workflows[id].filename;
1084
- const filename = persistedFilename || this.idToFileMap.get(id) || `${id}.workflow.ts`;
711
+ // Scan-wins: idToFileMap (rebuilt from @workflow decorator) is authoritative.
712
+ // Fall back to deprecated persisted filename for old state files during transition.
713
+ const filename = this.idToFileMap.get(id)
714
+ || state.workflows[id]?.filename
715
+ || `${id}.workflow.ts`;
1085
716
  if (!results.has(filename)) {
1086
717
  const status = this.calculateStatus(filename, id);
1087
718
  const workflow = workflowsMap.get(id);
@@ -1145,7 +776,7 @@ export class Watcher extends EventEmitter {
1145
776
  }
1146
777
  }
1147
778
  catch (error) {
1148
- console.warn(`[Watcher] Failed to read local workflow ${filename}:`, error);
779
+ console.warn(`[WorkflowStateTracker] Failed to read local workflow ${filename}:`, error);
1149
780
  }
1150
781
  }
1151
782
  // 2. For remote-only workflows, fetch from API
@@ -1159,7 +790,7 @@ export class Watcher extends EventEmitter {
1159
790
  }
1160
791
  }
1161
792
  catch (error) {
1162
- console.warn(`[Watcher] Failed to fetch remote workflow ${workflowId}:`, error);
793
+ console.warn(`[WorkflowStateTracker] Failed to fetch remote workflow ${workflowId}:`, error);
1163
794
  }
1164
795
  }
1165
796
  }
@@ -1242,16 +873,7 @@ export class Watcher extends EventEmitter {
1242
873
  }
1243
874
  this.idToFileMap.set(remoteWf.id, filename);
1244
875
  this.fileToIdMap.set(filename, remoteWf.id);
1245
- // PERSIST: Save the derived mapping to state so subsequent 'pull' commands can find it
1246
- const state = this.loadState();
1247
- if (!state.workflows[remoteWf.id]) {
1248
- state.workflows[remoteWf.id] = {
1249
- filename: filename,
1250
- lastSyncedHash: undefined,
1251
- lastSyncedAt: undefined
1252
- };
1253
- this.saveState(state);
1254
- }
876
+ // No longer persist filename to state (mappings are rebuilt from file scan).
1255
877
  }
1256
878
  // Broadcast status update
1257
879
  const filename = this.idToFileMap.get(remoteWf.id);
@@ -1260,8 +882,8 @@ export class Watcher extends EventEmitter {
1260
882
  }
1261
883
  }
1262
884
  catch (error) {
1263
- console.error(`[Watcher] Failed to update single remote state for ${remoteWf.id}:`, error);
885
+ console.error(`[WorkflowStateTracker] Failed to update single remote state for ${remoteWf.id}:`, error);
1264
886
  }
1265
887
  }
1266
888
  }
1267
- //# sourceMappingURL=watcher.js.map
889
+ //# sourceMappingURL=workflow-state-tracker.js.map