@n8n-as-code/core 0.2.0 → 0.3.1
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/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/services/hash-utils.d.ts +22 -0
- package/dist/services/hash-utils.d.ts.map +1 -0
- package/dist/services/hash-utils.js +31 -0
- package/dist/services/hash-utils.js.map +1 -0
- package/dist/services/n8n-api-client.d.ts.map +1 -1
- package/dist/services/n8n-api-client.js +44 -50
- package/dist/services/n8n-api-client.js.map +1 -1
- package/dist/services/resolution-manager.d.ts +73 -0
- package/dist/services/resolution-manager.d.ts.map +1 -0
- package/dist/services/resolution-manager.js +149 -0
- package/dist/services/resolution-manager.js.map +1 -0
- package/dist/services/state-manager.d.ts +18 -17
- package/dist/services/state-manager.d.ts.map +1 -1
- package/dist/services/state-manager.js +22 -53
- package/dist/services/state-manager.js.map +1 -1
- package/dist/services/sync-engine.d.ts +57 -0
- package/dist/services/sync-engine.d.ts.map +1 -0
- package/dist/services/sync-engine.js +301 -0
- package/dist/services/sync-engine.js.map +1 -0
- package/dist/services/sync-manager.d.ts +19 -83
- package/dist/services/sync-manager.d.ts.map +1 -1
- package/dist/services/sync-manager.js +208 -620
- package/dist/services/sync-manager.js.map +1 -1
- package/dist/services/watcher.d.ts +121 -0
- package/dist/services/watcher.d.ts.map +1 -0
- package/dist/services/watcher.js +609 -0
- package/dist/services/watcher.js.map +1 -0
- package/dist/services/workflow-sanitizer.d.ts +9 -4
- package/dist/services/workflow-sanitizer.d.ts.map +1 -1
- package/dist/services/workflow-sanitizer.js +55 -35
- package/dist/services/workflow-sanitizer.js.map +1 -1
- package/dist/types.d.ts +10 -5
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +8 -5
- package/dist/types.js.map +1 -1
- package/package.json +4 -2
|
@@ -1,692 +1,280 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import EventEmitter from 'events';
|
|
4
|
-
import deepEqual from 'deep-equal';
|
|
5
|
-
import * as chokidar from 'chokidar';
|
|
6
|
-
import { WorkflowSanitizer } from './workflow-sanitizer.js';
|
|
7
|
-
import { createInstanceIdentifier, createFallbackInstanceIdentifier } from './directory-utils.js';
|
|
8
4
|
import { StateManager } from './state-manager.js';
|
|
9
|
-
import {
|
|
5
|
+
import { Watcher } from './watcher.js';
|
|
6
|
+
import { SyncEngine } from './sync-engine.js';
|
|
7
|
+
import { ResolutionManager } from './resolution-manager.js';
|
|
10
8
|
import { WorkflowSyncStatus } from '../types.js';
|
|
11
9
|
export class SyncManager extends EventEmitter {
|
|
12
10
|
client;
|
|
13
11
|
config;
|
|
14
|
-
// Maps filename -> Workflow ID
|
|
15
|
-
fileToIdMap = new Map();
|
|
16
|
-
// Maps filePath -> Content (to detect self-written changes)
|
|
17
|
-
selfWrittenCache = new Map();
|
|
18
|
-
// Busy writing flag to avoid loops
|
|
19
|
-
isWriting = new Set();
|
|
20
|
-
// Pending deletions (IDs) to prevent immediate re‑download
|
|
21
|
-
pendingDeletions = new Set();
|
|
22
|
-
watcher = null;
|
|
23
|
-
pollInterval = null;
|
|
24
12
|
stateManager = null;
|
|
25
|
-
|
|
13
|
+
watcher = null;
|
|
14
|
+
syncEngine = null;
|
|
15
|
+
resolutionManager = null;
|
|
26
16
|
constructor(client, config) {
|
|
27
17
|
super();
|
|
28
18
|
this.client = client;
|
|
29
19
|
this.config = config;
|
|
30
|
-
// Create base directory if it doesn't exist
|
|
31
20
|
if (!fs.existsSync(this.config.directory)) {
|
|
32
21
|
fs.mkdirSync(this.config.directory, { recursive: true });
|
|
33
22
|
}
|
|
34
23
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
24
|
+
async ensureInitialized() {
|
|
25
|
+
if (this.watcher)
|
|
26
|
+
return;
|
|
27
|
+
// Note: instanceIdentifier logic handling omitted for brevity,
|
|
28
|
+
// assuming it's handled or using default directory for now
|
|
29
|
+
// to focus on the 3-way merge integration.
|
|
30
|
+
const instanceDir = path.join(this.config.directory, this.config.instanceIdentifier || 'default');
|
|
31
|
+
if (!fs.existsSync(instanceDir))
|
|
32
|
+
fs.mkdirSync(instanceDir, { recursive: true });
|
|
33
|
+
this.stateManager = new StateManager(instanceDir);
|
|
34
|
+
this.watcher = new Watcher(this.client, {
|
|
35
|
+
directory: instanceDir,
|
|
36
|
+
pollIntervalMs: this.config.pollIntervalMs,
|
|
37
|
+
syncInactive: this.config.syncInactive,
|
|
38
|
+
ignoredTags: this.config.ignoredTags
|
|
39
|
+
});
|
|
40
|
+
this.syncEngine = new SyncEngine(this.client, this.watcher, instanceDir);
|
|
41
|
+
this.resolutionManager = new ResolutionManager(this.syncEngine, this.watcher, this.client);
|
|
42
|
+
this.watcher.on('statusChange', (data) => {
|
|
43
|
+
this.emit('change', data);
|
|
44
|
+
// Emit specific events for deletions and conflicts
|
|
45
|
+
if (data.status === WorkflowSyncStatus.DELETED_LOCALLY && data.workflowId) {
|
|
46
|
+
this.emit('local-deletion', {
|
|
47
|
+
id: data.workflowId,
|
|
48
|
+
filename: data.filename
|
|
49
|
+
});
|
|
53
50
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
* Ensure instance identifier is set and persistent
|
|
66
|
-
*/
|
|
67
|
-
async ensureInstanceIdentifier() {
|
|
68
|
-
// Check if instance identifier is already provided in config
|
|
69
|
-
if (this.config.instanceIdentifier && this.stateManager) {
|
|
70
|
-
return this.config.instanceIdentifier;
|
|
71
|
-
}
|
|
72
|
-
// Try to load from persistent storage
|
|
73
|
-
const instanceConfig = this.loadInstanceConfig();
|
|
74
|
-
if (instanceConfig.instanceIdentifier) {
|
|
75
|
-
this.config.instanceIdentifier = instanceConfig.instanceIdentifier;
|
|
76
|
-
this.stateManager = new StateManager(this.getInstanceDirectory());
|
|
77
|
-
this.trashService = new TrashService(this.getInstanceDirectory());
|
|
78
|
-
return instanceConfig.instanceIdentifier;
|
|
79
|
-
}
|
|
80
|
-
// Generate new instance identifier
|
|
81
|
-
const newIdentifier = await this.initializeInstanceIdentifier();
|
|
82
|
-
// Save to persistent storage
|
|
83
|
-
instanceConfig.instanceIdentifier = newIdentifier;
|
|
84
|
-
instanceConfig.lastUsed = new Date().toISOString();
|
|
85
|
-
this.saveInstanceConfig(instanceConfig);
|
|
86
|
-
// Update config
|
|
87
|
-
this.config.instanceIdentifier = newIdentifier;
|
|
88
|
-
this.stateManager = new StateManager(this.getInstanceDirectory());
|
|
89
|
-
this.trashService = new TrashService(this.getInstanceDirectory());
|
|
90
|
-
return newIdentifier;
|
|
91
|
-
}
|
|
92
|
-
async initializeInstanceIdentifier() {
|
|
93
|
-
const host = this.client['client']?.defaults?.baseURL || 'unknown';
|
|
94
|
-
try {
|
|
95
|
-
// Try to get user information for friendly naming
|
|
96
|
-
const user = await this.client.getCurrentUser();
|
|
97
|
-
if (user) {
|
|
98
|
-
// Use host from client configuration
|
|
99
|
-
return createInstanceIdentifier(host, user);
|
|
51
|
+
else if (data.status === WorkflowSyncStatus.CONFLICT && data.workflowId) {
|
|
52
|
+
// Fetch remote content for conflict notification
|
|
53
|
+
this.client.getWorkflow(data.workflowId).then(remoteContent => {
|
|
54
|
+
this.emit('conflict', {
|
|
55
|
+
id: data.workflowId,
|
|
56
|
+
filename: data.filename,
|
|
57
|
+
remoteContent
|
|
58
|
+
});
|
|
59
|
+
}).catch(err => {
|
|
60
|
+
console.error(`[SyncManager] Failed to fetch remote content for conflict: ${err.message}`);
|
|
61
|
+
});
|
|
100
62
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if (!this.config.instanceIdentifier) {
|
|
110
|
-
// This should not happen if callers await ensureInstanceIdentifier
|
|
111
|
-
const instanceConfig = this.loadInstanceConfig();
|
|
112
|
-
if (instanceConfig.instanceIdentifier) {
|
|
113
|
-
this.config.instanceIdentifier = instanceConfig.instanceIdentifier;
|
|
63
|
+
// Auto-sync in auto mode
|
|
64
|
+
console.log(`[SyncManager] statusChange event: ${data.filename}, status: ${data.status}, syncMode: ${this.config.syncMode}`);
|
|
65
|
+
if (this.config.syncMode === 'auto') {
|
|
66
|
+
console.log(`[SyncManager] Triggering auto-sync for ${data.filename}`);
|
|
67
|
+
this.handleAutoSync(data).catch(err => {
|
|
68
|
+
console.error('[SyncManager] Auto-sync error:', err);
|
|
69
|
+
this.emit('error', `Auto-sync failed: ${err.message}`);
|
|
70
|
+
});
|
|
114
71
|
}
|
|
115
72
|
else {
|
|
116
|
-
|
|
73
|
+
console.log(`[SyncManager] Auto-sync skipped (mode: ${this.config.syncMode})`);
|
|
117
74
|
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
return name.replace(/[\/\\:]/g, '_').replace(/\s+/g, ' ').trim();
|
|
126
|
-
}
|
|
127
|
-
normalizeContent(content) {
|
|
128
|
-
return content.replace(/\r\n/g, '\n').trim();
|
|
129
|
-
}
|
|
130
|
-
markAsSelfWritten(filePath, content) {
|
|
131
|
-
this.selfWrittenCache.set(filePath, this.normalizeContent(content));
|
|
132
|
-
}
|
|
133
|
-
isSelfWritten(filePath, currentContent) {
|
|
134
|
-
if (!this.selfWrittenCache.has(filePath))
|
|
135
|
-
return false;
|
|
136
|
-
const cached = this.selfWrittenCache.get(filePath);
|
|
137
|
-
const current = this.normalizeContent(currentContent);
|
|
138
|
-
return cached === current;
|
|
139
|
-
}
|
|
140
|
-
async loadRemoteState() {
|
|
141
|
-
this.emit('log', '🔄 [SyncManager] Loading remote state...');
|
|
142
|
-
const remoteWorkflows = await this.client.getAllWorkflows();
|
|
143
|
-
// Populate map
|
|
144
|
-
for (const wf of remoteWorkflows) {
|
|
145
|
-
if (this.shouldIgnore(wf))
|
|
146
|
-
continue;
|
|
147
|
-
const filename = `${this.safeName(wf.name)}.json`;
|
|
148
|
-
this.fileToIdMap.set(filename, wf.id);
|
|
149
|
-
}
|
|
150
|
-
console.log(`[DEBUG] loadRemoteState populated ${this.fileToIdMap.size} entries`);
|
|
75
|
+
});
|
|
76
|
+
this.watcher.on('error', (err) => {
|
|
77
|
+
this.emit('error', err);
|
|
78
|
+
});
|
|
79
|
+
this.watcher.on('connection-lost', (err) => {
|
|
80
|
+
this.emit('connection-lost', err);
|
|
81
|
+
});
|
|
151
82
|
}
|
|
152
|
-
/**
|
|
153
|
-
* Retrieves the status of all workflows (local and remote)
|
|
154
|
-
*/
|
|
155
83
|
async getWorkflowsStatus() {
|
|
156
|
-
await this.
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
// 1. Check all Remote Workflows (and compare with local)
|
|
160
|
-
const remoteWorkflows = await this.client.getAllWorkflows();
|
|
161
|
-
for (const wf of remoteWorkflows) {
|
|
162
|
-
if (this.shouldIgnore(wf))
|
|
163
|
-
continue;
|
|
164
|
-
const filename = `${this.safeName(wf.name)}.json`;
|
|
165
|
-
const filePath = this.getFilePath(filename);
|
|
166
|
-
let status = WorkflowSyncStatus.SYNCED;
|
|
167
|
-
if (!fs.existsSync(filePath)) {
|
|
168
|
-
status = WorkflowSyncStatus.MISSING_LOCAL;
|
|
169
|
-
}
|
|
170
|
-
else {
|
|
171
|
-
// Check local modification using state tracking
|
|
172
|
-
const localContent = this.readLocalFile(filePath);
|
|
173
|
-
const localClean = WorkflowSanitizer.cleanForStorage(localContent);
|
|
174
|
-
const isSynced = this.stateManager?.isLocalSynced(wf.id, localClean) ?? true;
|
|
175
|
-
if (!isSynced) {
|
|
176
|
-
status = WorkflowSyncStatus.LOCAL_MODIFIED;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
statuses.push({
|
|
180
|
-
id: wf.id,
|
|
181
|
-
name: wf.name,
|
|
182
|
-
filename: filename,
|
|
183
|
-
active: wf.active,
|
|
184
|
-
status: status
|
|
185
|
-
});
|
|
186
|
-
}
|
|
187
|
-
// 2. Check Local Files (for Orphans)
|
|
188
|
-
const instanceDirectory = this.getInstanceDirectory();
|
|
189
|
-
if (fs.existsSync(instanceDirectory)) {
|
|
190
|
-
const localFiles = fs.readdirSync(instanceDirectory).filter(f => f.endsWith('.json') && !f.startsWith('.'));
|
|
191
|
-
for (const file of localFiles) {
|
|
192
|
-
const alreadyListed = statuses.find(s => s.filename === file);
|
|
193
|
-
if (!alreadyListed) {
|
|
194
|
-
const filePath = this.getFilePath(file);
|
|
195
|
-
const content = this.readLocalFile(filePath);
|
|
196
|
-
const name = content?.name || path.parse(file).name;
|
|
197
|
-
statuses.push({
|
|
198
|
-
id: '',
|
|
199
|
-
name: name,
|
|
200
|
-
filename: file,
|
|
201
|
-
active: false,
|
|
202
|
-
status: WorkflowSyncStatus.MISSING_REMOTE
|
|
203
|
-
});
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
return statuses.sort((a, b) => a.name.localeCompare(b.name));
|
|
84
|
+
await this.ensureInitialized();
|
|
85
|
+
// Return status from watcher
|
|
86
|
+
return this.watcher.getStatusMatrix();
|
|
208
87
|
}
|
|
209
|
-
formatSummary(counts) {
|
|
210
|
-
const summary = [];
|
|
211
|
-
if (counts.new && counts.new > 0)
|
|
212
|
-
summary.push(`${counts.new} new`);
|
|
213
|
-
if (counts.updated && counts.updated > 0)
|
|
214
|
-
summary.push(`${counts.updated} updated`);
|
|
215
|
-
if (counts.conflict && counts.conflict > 0)
|
|
216
|
-
summary.push(`${counts.conflict} conflicts`);
|
|
217
|
-
if (counts.upToDate && counts.upToDate > 0)
|
|
218
|
-
summary.push(`${counts.upToDate} up-to-date`);
|
|
219
|
-
return summary.length > 0 ? summary.join(', ') : 'No changes';
|
|
220
|
-
}
|
|
221
|
-
/**
|
|
222
|
-
* Scans n8n instance and updates local files (Downstream Sync)
|
|
223
|
-
*/
|
|
224
88
|
async syncDown() {
|
|
225
|
-
await this.
|
|
226
|
-
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
continue;
|
|
235
|
-
const filename = `${this.safeName(wf.name)}.json`;
|
|
236
|
-
if (processedFiles.has(filename))
|
|
237
|
-
continue;
|
|
238
|
-
processedFiles.add(filename);
|
|
239
|
-
this.fileToIdMap.set(filename, wf.id);
|
|
240
|
-
const result = await this.pullWorkflowWithConflictResolution(filename, wf.id, wf.updatedAt || wf.createdAt);
|
|
241
|
-
if (result === 'updated')
|
|
242
|
-
counts.updated++;
|
|
243
|
-
else if (result === 'new')
|
|
244
|
-
counts.new++;
|
|
245
|
-
else if (result === 'up-to-date')
|
|
246
|
-
counts.upToDate++;
|
|
247
|
-
else if (result === 'conflict')
|
|
248
|
-
counts.conflict++;
|
|
89
|
+
await this.ensureInitialized();
|
|
90
|
+
const statuses = await this.getWorkflowsStatus();
|
|
91
|
+
for (const s of statuses) {
|
|
92
|
+
if (s.status === WorkflowSyncStatus.EXIST_ONLY_REMOTELY ||
|
|
93
|
+
s.status === WorkflowSyncStatus.MODIFIED_REMOTELY) {
|
|
94
|
+
await this.syncEngine.pull(s.id, s.filename, s.status);
|
|
95
|
+
}
|
|
96
|
+
// DELETED_REMOTELY requires user confirmation via confirmDeletion()
|
|
97
|
+
// Per spec 5.2: "Halt. Trigger Deletion Validation."
|
|
249
98
|
}
|
|
250
|
-
// 3. Process remote deletions
|
|
251
|
-
const deletionCounts = await this.processRemoteDeletions(remoteWorkflows);
|
|
252
|
-
this.emit('log', `📥 [SyncManager] Sync complete: ${this.formatSummary(counts)}${deletionCounts > 0 ? `, ${deletionCounts} remote deleted` : ''}`);
|
|
253
99
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
if (!remoteIds.has(id)) {
|
|
265
|
-
// Workflow deleted remotely!
|
|
266
|
-
const state = this.stateManager.getWorkflowState(id);
|
|
267
|
-
if (!state)
|
|
268
|
-
continue;
|
|
269
|
-
// Find local file for this ID
|
|
270
|
-
const filename = Array.from(this.fileToIdMap.entries())
|
|
271
|
-
.find(([_, fid]) => fid === id)?.[0];
|
|
272
|
-
if (filename) {
|
|
273
|
-
const filePath = this.getFilePath(filename);
|
|
274
|
-
if (fs.existsSync(filePath)) {
|
|
275
|
-
this.emit('log', `🗑️ [Remote->Local] Remote workflow deleted: "${filename}". Moving local file to .archive`);
|
|
276
|
-
await this.trashService.archiveFile(filePath, filename);
|
|
277
|
-
this.stateManager.removeWorkflowState(id);
|
|
278
|
-
this.fileToIdMap.delete(filename);
|
|
279
|
-
this.emit('change', { type: 'remote-deletion', filename, id });
|
|
280
|
-
deletedCount++;
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
else {
|
|
284
|
-
// ID tracked but no file found? Just clean state
|
|
285
|
-
this.stateManager.removeWorkflowState(id);
|
|
286
|
-
}
|
|
100
|
+
async syncUp() {
|
|
101
|
+
await this.ensureInitialized();
|
|
102
|
+
const statuses = await this.getWorkflowsStatus();
|
|
103
|
+
for (const s of statuses) {
|
|
104
|
+
if (s.status === WorkflowSyncStatus.EXIST_ONLY_LOCALLY || s.status === WorkflowSyncStatus.MODIFIED_LOCALLY) {
|
|
105
|
+
await this.syncEngine.push(s.filename, s.id, s.status);
|
|
106
|
+
}
|
|
107
|
+
else if (s.status === WorkflowSyncStatus.DELETED_LOCALLY) {
|
|
108
|
+
// Per spec: Halt and trigger deletion validation
|
|
109
|
+
throw new Error(`Local deletion detected for workflow "${s.filename}". Use confirmDeletion() to proceed with remote deletion or restoreWorkflow() to restore the file.`);
|
|
287
110
|
}
|
|
288
111
|
}
|
|
289
|
-
return deletedCount;
|
|
290
112
|
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
remoteWorkflows.sort((a, b) => (a.active === b.active ? 0 : a.active ? -1 : 1));
|
|
298
|
-
const processedFiles = new Set();
|
|
299
|
-
const counts = { updated: 0, new: 0, upToDate: 0, conflict: 0 };
|
|
300
|
-
for (const wf of remoteWorkflows) {
|
|
301
|
-
if (this.shouldIgnore(wf))
|
|
302
|
-
continue;
|
|
303
|
-
const filename = `${this.safeName(wf.name)}.json`;
|
|
304
|
-
if (processedFiles.has(filename))
|
|
305
|
-
continue;
|
|
306
|
-
processedFiles.add(filename);
|
|
307
|
-
this.fileToIdMap.set(filename, wf.id);
|
|
308
|
-
const result = await this.pullWorkflowWithConflictResolution(filename, wf.id, wf.updatedAt || wf.createdAt);
|
|
309
|
-
if (result === 'updated')
|
|
310
|
-
counts.updated++;
|
|
311
|
-
else if (result === 'new')
|
|
312
|
-
counts.new++;
|
|
313
|
-
else if (result === 'up-to-date')
|
|
314
|
-
counts.upToDate++;
|
|
315
|
-
else if (result === 'conflict')
|
|
316
|
-
counts.conflict++;
|
|
317
|
-
}
|
|
318
|
-
// 3. Process remote deletions
|
|
319
|
-
await this.processRemoteDeletions(remoteWorkflows);
|
|
320
|
-
if (counts.updated > 0 || counts.new > 0 || counts.conflict > 0) {
|
|
321
|
-
this.emit('log', `📥 [SyncManager] Applied: ${this.formatSummary({ new: counts.new, updated: counts.updated, conflict: counts.conflict })}`);
|
|
322
|
-
}
|
|
113
|
+
async startWatch() {
|
|
114
|
+
await this.ensureInitialized();
|
|
115
|
+
await this.watcher.start();
|
|
116
|
+
// Create instance config file to mark workspace as initialized
|
|
117
|
+
this.ensureInstanceConfigFile();
|
|
118
|
+
this.emit('log', 'Watcher started.');
|
|
323
119
|
}
|
|
324
120
|
/**
|
|
325
|
-
*
|
|
326
|
-
*
|
|
121
|
+
* Create or update the n8n-as-code-instance.json file
|
|
122
|
+
* This file marks the workspace as initialized and stores the instance identifier
|
|
327
123
|
*/
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
if (!fullWf)
|
|
124
|
+
ensureInstanceConfigFile() {
|
|
125
|
+
if (!this.config.instanceConfigPath || !this.config.instanceIdentifier) {
|
|
331
126
|
return;
|
|
332
|
-
const cleanRemote = WorkflowSanitizer.cleanForStorage(fullWf);
|
|
333
|
-
const filePath = this.getFilePath(filename);
|
|
334
|
-
if (!force && fs.existsSync(filePath)) {
|
|
335
|
-
const localContent = this.readLocalFile(filePath);
|
|
336
|
-
const localClean = WorkflowSanitizer.cleanForStorage(localContent);
|
|
337
|
-
const isLocalSynced = this.stateManager?.isLocalSynced(id, localClean) ?? false;
|
|
338
|
-
if (!isLocalSynced) {
|
|
339
|
-
if (!deepEqual(localClean, cleanRemote)) {
|
|
340
|
-
this.emit('conflict', {
|
|
341
|
-
id,
|
|
342
|
-
filename,
|
|
343
|
-
localContent: localClean,
|
|
344
|
-
remoteContent: cleanRemote
|
|
345
|
-
});
|
|
346
|
-
return;
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
127
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
// Skip if this workflow is pending deletion (user hasn't decided yet)
|
|
358
|
-
if (this.pendingDeletions.has(id)) {
|
|
359
|
-
console.log(`[DEBUG] Skipping pull for ${filename} because ID ${id} is pending deletion`);
|
|
360
|
-
return 'skipped';
|
|
361
|
-
}
|
|
362
|
-
const fullWf = await this.client.getWorkflow(id);
|
|
363
|
-
if (!fullWf)
|
|
364
|
-
return 'skipped';
|
|
365
|
-
const cleanRemote = WorkflowSanitizer.cleanForStorage(fullWf);
|
|
366
|
-
const filePath = this.getFilePath(filename);
|
|
367
|
-
if (fs.existsSync(filePath)) {
|
|
368
|
-
const localContent = this.readLocalFile(filePath);
|
|
369
|
-
const localClean = WorkflowSanitizer.cleanForStorage(localContent);
|
|
370
|
-
const isRemoteSynced = this.stateManager?.isRemoteSynced(id, cleanRemote) ?? false;
|
|
371
|
-
if (isRemoteSynced) {
|
|
372
|
-
return 'up-to-date';
|
|
373
|
-
}
|
|
374
|
-
const isLocalSynced = this.stateManager?.isLocalSynced(id, localClean) ?? false;
|
|
375
|
-
if (!isLocalSynced) {
|
|
376
|
-
this.emit('log', `⚠️ [Conflict] Workflow "${filename}" changed both locally and on n8n. Skipping auto-pull.`);
|
|
377
|
-
this.emit('conflict', {
|
|
378
|
-
id,
|
|
379
|
-
filename,
|
|
380
|
-
localContent: localClean,
|
|
381
|
-
remoteContent: cleanRemote
|
|
382
|
-
});
|
|
383
|
-
return 'conflict';
|
|
384
|
-
}
|
|
385
|
-
this.emit('log', `📥 [n8n->Local] Updated: "${filename}" (Remote changes detected)`);
|
|
386
|
-
await this.writeLocalFile(filePath, cleanRemote, filename, id);
|
|
387
|
-
return 'updated';
|
|
128
|
+
const configData = {
|
|
129
|
+
instanceIdentifier: this.config.instanceIdentifier,
|
|
130
|
+
directory: this.config.directory,
|
|
131
|
+
lastSync: new Date().toISOString()
|
|
132
|
+
};
|
|
133
|
+
try {
|
|
134
|
+
fs.writeFileSync(this.config.instanceConfigPath, JSON.stringify(configData, null, 2), 'utf-8');
|
|
388
135
|
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
await this.writeLocalFile(filePath, cleanRemote, filename, id);
|
|
392
|
-
return 'new';
|
|
136
|
+
catch (error) {
|
|
137
|
+
console.warn(`[SyncManager] Failed to write instance config file: ${error}`);
|
|
393
138
|
}
|
|
394
139
|
}
|
|
395
140
|
/**
|
|
396
|
-
*
|
|
141
|
+
* Handle automatic synchronization based on status changes
|
|
142
|
+
* Only triggered in auto mode
|
|
397
143
|
*/
|
|
398
|
-
async
|
|
399
|
-
const
|
|
400
|
-
const doWrite = (isNew) => {
|
|
401
|
-
this.isWriting.add(filePath);
|
|
402
|
-
this.markAsSelfWritten(filePath, contentStr);
|
|
403
|
-
const dir = path.dirname(filePath);
|
|
404
|
-
if (!fs.existsSync(dir)) {
|
|
405
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
406
|
-
}
|
|
407
|
-
fs.writeFileSync(filePath, contentStr);
|
|
408
|
-
this.stateManager?.updateWorkflowState(id, contentObj);
|
|
409
|
-
this.emit('change', { type: 'remote-to-local', filename, id });
|
|
410
|
-
setTimeout(() => this.isWriting.delete(filePath), 1000);
|
|
411
|
-
};
|
|
412
|
-
if (!fs.existsSync(filePath)) {
|
|
413
|
-
doWrite(true);
|
|
414
|
-
return;
|
|
415
|
-
}
|
|
416
|
-
const localContent = fs.readFileSync(filePath, 'utf8');
|
|
144
|
+
async handleAutoSync(data) {
|
|
145
|
+
const { filename, workflowId, status } = data;
|
|
417
146
|
try {
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
147
|
+
switch (status) {
|
|
148
|
+
case WorkflowSyncStatus.MODIFIED_LOCALLY:
|
|
149
|
+
case WorkflowSyncStatus.EXIST_ONLY_LOCALLY:
|
|
150
|
+
// Auto-push local changes
|
|
151
|
+
this.emit('log', `🔄 Auto-sync: Pushing "${filename}"...`);
|
|
152
|
+
await this.syncEngine.push(filename, workflowId, status);
|
|
153
|
+
this.emit('log', `✅ Auto-sync: Pushed "${filename}"`);
|
|
154
|
+
// Emit event to notify that remote was updated (for webview reload)
|
|
155
|
+
if (workflowId) {
|
|
156
|
+
this.emit('remote-updated', { workflowId, filename });
|
|
157
|
+
}
|
|
158
|
+
break;
|
|
159
|
+
case WorkflowSyncStatus.MODIFIED_REMOTELY:
|
|
160
|
+
case WorkflowSyncStatus.EXIST_ONLY_REMOTELY:
|
|
161
|
+
// Auto-pull remote changes
|
|
162
|
+
if (workflowId) {
|
|
163
|
+
this.emit('log', `🔄 Auto-sync: Pulling "${filename}"...`);
|
|
164
|
+
await this.syncEngine.pull(workflowId, filename, status);
|
|
165
|
+
this.emit('log', `✅ Auto-sync: Pulled "${filename}"`);
|
|
166
|
+
}
|
|
167
|
+
break;
|
|
168
|
+
case WorkflowSyncStatus.CONFLICT:
|
|
169
|
+
// Conflicts require manual resolution
|
|
170
|
+
this.emit('log', `⚠️ Conflict detected for "${filename}". Manual resolution required.`);
|
|
171
|
+
// conflict event is handled in ensureInitialized above
|
|
172
|
+
break;
|
|
173
|
+
case WorkflowSyncStatus.DELETED_LOCALLY:
|
|
174
|
+
case WorkflowSyncStatus.DELETED_REMOTELY:
|
|
175
|
+
// Deletions require manual confirmation
|
|
176
|
+
// Note: local-deletion event is already emitted by the Watcher
|
|
177
|
+
// We don't re-emit it here to avoid duplicates
|
|
178
|
+
this.emit('log', `🗑️ Deletion detected for "${filename}". Manual confirmation required.`);
|
|
179
|
+
break;
|
|
180
|
+
case WorkflowSyncStatus.IN_SYNC:
|
|
181
|
+
// Already in sync, nothing to do
|
|
182
|
+
break;
|
|
422
183
|
}
|
|
423
184
|
}
|
|
424
|
-
catch (
|
|
425
|
-
|
|
185
|
+
catch (error) {
|
|
186
|
+
this.emit('error', `Auto-sync failed for "${filename}": ${error.message}`);
|
|
426
187
|
}
|
|
427
188
|
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
if (wf.tags) {
|
|
432
|
-
const hasIgnoredTag = wf.tags.some(t => this.config.ignoredTags.includes(t.name.toLowerCase()));
|
|
433
|
-
if (hasIgnoredTag)
|
|
434
|
-
return true;
|
|
435
|
-
}
|
|
436
|
-
return false;
|
|
189
|
+
stopWatch() {
|
|
190
|
+
this.watcher?.stop();
|
|
191
|
+
this.emit('log', 'Watcher stopped.');
|
|
437
192
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
await this.
|
|
443
|
-
this.emit('log', '🔄 [SyncManager] Checking for orphans...');
|
|
444
|
-
const instanceDirectory = this.getInstanceDirectory();
|
|
445
|
-
if (!fs.existsSync(instanceDirectory))
|
|
446
|
-
return;
|
|
447
|
-
const localFiles = fs.readdirSync(instanceDirectory).filter(f => f.endsWith('.json') && !f.startsWith('.'));
|
|
448
|
-
for (const file of localFiles) {
|
|
449
|
-
if (this.fileToIdMap.has(file))
|
|
450
|
-
continue;
|
|
451
|
-
const filePath = this.getFilePath(file);
|
|
452
|
-
await this.handleLocalFileChange(filePath);
|
|
453
|
-
}
|
|
193
|
+
async refreshState() {
|
|
194
|
+
await this.ensureInitialized();
|
|
195
|
+
// Run sequentially to avoid potential race conditions during state loading
|
|
196
|
+
await this.watcher.refreshRemoteState();
|
|
197
|
+
await this.watcher.refreshLocalState();
|
|
454
198
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
*/
|
|
458
|
-
async syncUp() {
|
|
459
|
-
await this.ensureInstanceIdentifier();
|
|
460
|
-
this.emit('log', '📤 [SyncManager] Starting Upstream Sync (Push)...');
|
|
461
|
-
const instanceDirectory = this.getInstanceDirectory();
|
|
462
|
-
if (!fs.existsSync(instanceDirectory))
|
|
463
|
-
return;
|
|
464
|
-
const localFiles = fs.readdirSync(instanceDirectory).filter(f => f.endsWith('.json') && !f.startsWith('.'));
|
|
465
|
-
const counts = { updated: 0, created: 0, upToDate: 0, conflict: 0 };
|
|
466
|
-
for (const file of localFiles) {
|
|
467
|
-
const filePath = this.getFilePath(file);
|
|
468
|
-
const result = await this.handleLocalFileChange(filePath, true);
|
|
469
|
-
if (result === 'updated')
|
|
470
|
-
counts.updated++;
|
|
471
|
-
else if (result === 'created')
|
|
472
|
-
counts.created++;
|
|
473
|
-
else if (result === 'up-to-date')
|
|
474
|
-
counts.upToDate++;
|
|
475
|
-
else if (result === 'conflict')
|
|
476
|
-
counts.conflict++;
|
|
477
|
-
}
|
|
478
|
-
this.emit('log', `📤 [SyncManager] Push complete: ${this.formatSummary(counts)}`);
|
|
199
|
+
getInstanceDirectory() {
|
|
200
|
+
return path.join(this.config.directory, this.config.instanceIdentifier || 'default');
|
|
479
201
|
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
if (!filename.endsWith('.json') || filename.startsWith('.n8n-state'))
|
|
486
|
-
return;
|
|
487
|
-
console.log(`[DEBUG] handleLocalFileDeletion called for ${filename}`);
|
|
488
|
-
console.log(`[DEBUG] fileToIdMap entries: ${Array.from(this.fileToIdMap.entries()).map(([f, i]) => `${f}=${i}`).join(', ')}`);
|
|
489
|
-
const id = this.fileToIdMap.get(filename);
|
|
490
|
-
if (id) {
|
|
491
|
-
this.pendingDeletions.add(id);
|
|
492
|
-
this.emit('log', `🗑️ [Local->Remote] Local file deleted: "${filename}". (ID: ${id})`);
|
|
493
|
-
this.emit('local-deletion', { id, filename, filePath });
|
|
202
|
+
// Bridge for conflict resolution
|
|
203
|
+
async resolveConflict(id, filename, choice) {
|
|
204
|
+
await this.ensureInitialized();
|
|
205
|
+
if (choice === 'local') {
|
|
206
|
+
await this.resolutionManager.keepLocal(id, filename);
|
|
494
207
|
}
|
|
495
208
|
else {
|
|
496
|
-
|
|
209
|
+
await this.resolutionManager.keepRemote(id, filename);
|
|
497
210
|
}
|
|
498
211
|
}
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
*/
|
|
502
|
-
async deleteRemoteWorkflow(id, filename) {
|
|
503
|
-
try {
|
|
504
|
-
// First archive the remote version locally for safety
|
|
505
|
-
const remoteWf = await this.client.getWorkflow(id);
|
|
506
|
-
if (remoteWf && this.trashService) {
|
|
507
|
-
await this.trashService.archiveWorkflow(remoteWf, filename);
|
|
508
|
-
}
|
|
509
|
-
const success = await this.client.deleteWorkflow(id);
|
|
510
|
-
if (success) {
|
|
511
|
-
this.stateManager?.removeWorkflowState(id);
|
|
512
|
-
this.fileToIdMap.delete(filename);
|
|
513
|
-
this.pendingDeletions.delete(id);
|
|
514
|
-
this.emit('log', `✅ [n8n] Workflow ${id} deleted successfully.`);
|
|
515
|
-
return true;
|
|
516
|
-
}
|
|
517
|
-
return false;
|
|
518
|
-
}
|
|
519
|
-
catch (error) {
|
|
520
|
-
this.emit('log', `❌ Failed to delete remote workflow ${id}: ${error.message}`);
|
|
521
|
-
return false;
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
/**
|
|
525
|
-
* Restore a deleted local file from remote
|
|
526
|
-
*/
|
|
527
|
-
async restoreLocalFile(id, filename) {
|
|
528
|
-
try {
|
|
529
|
-
const remoteWf = await this.client.getWorkflow(id);
|
|
530
|
-
if (!remoteWf)
|
|
531
|
-
throw new Error('Remote workflow not found');
|
|
532
|
-
const cleanRemote = WorkflowSanitizer.cleanForStorage(remoteWf);
|
|
533
|
-
const filePath = this.getFilePath(filename);
|
|
534
|
-
await this.writeLocalFile(filePath, cleanRemote, filename, id);
|
|
535
|
-
this.pendingDeletions.delete(id);
|
|
536
|
-
this.emit('log', `✅ [Local] Workflow "${filename}" restored from n8n.`);
|
|
537
|
-
return true;
|
|
538
|
-
}
|
|
539
|
-
catch (error) {
|
|
540
|
-
this.emit('log', `❌ Failed to restore local file ${filename}: ${error.message}`);
|
|
541
|
-
return false;
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
/**
|
|
545
|
-
* Handle FS watcher events
|
|
546
|
-
*/
|
|
547
|
-
async handleLocalFileChange(filePath, silent = false) {
|
|
548
|
-
await this.ensureInstanceIdentifier();
|
|
212
|
+
async handleLocalFileChange(filePath) {
|
|
213
|
+
await this.ensureInitialized();
|
|
549
214
|
const filename = path.basename(filePath);
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
if (this.isSelfWritten(filePath, rawContent)) {
|
|
560
|
-
return 'skipped';
|
|
561
|
-
}
|
|
562
|
-
const id = this.fileToIdMap.get(filename);
|
|
563
|
-
const nameFromFile = path.parse(filename).name;
|
|
564
|
-
let json;
|
|
565
|
-
try {
|
|
566
|
-
json = JSON.parse(rawContent);
|
|
567
|
-
}
|
|
568
|
-
catch (e) {
|
|
569
|
-
return 'skipped'; // Invalid JSON
|
|
570
|
-
}
|
|
571
|
-
const payload = WorkflowSanitizer.cleanForPush(json);
|
|
572
|
-
try {
|
|
573
|
-
if (id) {
|
|
574
|
-
const remoteRaw = await this.client.getWorkflow(id);
|
|
575
|
-
if (!remoteRaw)
|
|
576
|
-
throw new Error('Remote workflow not found');
|
|
577
|
-
const remoteClean = WorkflowSanitizer.cleanForStorage(remoteRaw);
|
|
578
|
-
const localClean = WorkflowSanitizer.cleanForStorage(json);
|
|
579
|
-
const workflowState = this.stateManager?.getWorkflowState(id);
|
|
580
|
-
if (workflowState) {
|
|
581
|
-
const isRemoteSynced = this.stateManager?.isRemoteSynced(id, remoteClean);
|
|
582
|
-
if (!isRemoteSynced) {
|
|
583
|
-
this.emit('log', `⚠️ [Conflict] Remote workflow "${filename}" has been modified on n8n. Push aborted to prevent overwriting.`);
|
|
584
|
-
this.emit('conflict', { id, filename, localContent: localClean, remoteContent: remoteClean });
|
|
585
|
-
return 'conflict';
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
if (deepEqual(remoteClean, localClean)) {
|
|
589
|
-
return 'up-to-date';
|
|
590
|
-
}
|
|
591
|
-
if (!payload.name)
|
|
592
|
-
payload.name = nameFromFile;
|
|
593
|
-
this.emit('log', `📤 [Local->n8n] Update: "${filename}" (ID: ${id})`);
|
|
594
|
-
const updatedWf = await this.client.updateWorkflow(id, payload);
|
|
595
|
-
if (updatedWf && updatedWf.id) {
|
|
596
|
-
this.emit('log', `✅ Update OK (ID: ${updatedWf.id})`);
|
|
597
|
-
this.stateManager?.updateWorkflowState(id, localClean);
|
|
598
|
-
this.emit('change', { type: 'local-to-remote', filename, id });
|
|
599
|
-
return 'updated';
|
|
600
|
-
}
|
|
601
|
-
else {
|
|
602
|
-
return 'skipped';
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
else {
|
|
606
|
-
const safePayloadName = this.safeName(payload.name || '');
|
|
607
|
-
if (safePayloadName !== nameFromFile) {
|
|
608
|
-
if (!silent)
|
|
609
|
-
this.emit('log', `⚠️ Name mismatch on creation. Using filename: "${nameFromFile}"`);
|
|
610
|
-
payload.name = nameFromFile;
|
|
611
|
-
}
|
|
612
|
-
else {
|
|
613
|
-
payload.name = payload.name || nameFromFile;
|
|
614
|
-
}
|
|
615
|
-
this.emit('log', `✨ [Local->n8n] Create: "${filename}"`);
|
|
616
|
-
const newWf = await this.client.createWorkflow(payload);
|
|
617
|
-
this.emit('log', `✅ Created (ID: ${newWf.id})`);
|
|
618
|
-
this.fileToIdMap.set(filename, newWf.id);
|
|
619
|
-
this.emit('change', { type: 'local-to-remote', filename, id: newWf.id });
|
|
215
|
+
console.log(`[DEBUG] handleLocalFileChange: ${filename}`);
|
|
216
|
+
// Ensure we have the latest from both worlds
|
|
217
|
+
await this.refreshState();
|
|
218
|
+
const status = this.watcher.calculateStatus(filename);
|
|
219
|
+
switch (status) {
|
|
220
|
+
case WorkflowSyncStatus.IN_SYNC: return 'updated'; // If it's in-sync, we return updated for legacy compatibility in tests
|
|
221
|
+
case WorkflowSyncStatus.CONFLICT: return 'conflict';
|
|
222
|
+
case WorkflowSyncStatus.EXIST_ONLY_LOCALLY:
|
|
223
|
+
await this.syncEngine.push(filename);
|
|
620
224
|
return 'created';
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
this.emit('error', `Sync Up Error: ${errorMsg}`);
|
|
627
|
-
return 'skipped';
|
|
225
|
+
case WorkflowSyncStatus.MODIFIED_LOCALLY:
|
|
226
|
+
const wfId = this.watcher.getFileToIdMap().get(filename);
|
|
227
|
+
await this.syncEngine.push(filename, wfId, status);
|
|
228
|
+
return 'updated';
|
|
229
|
+
default: return 'skipped';
|
|
628
230
|
}
|
|
629
231
|
}
|
|
630
|
-
|
|
232
|
+
async restoreLocalFile(id, filename) {
|
|
233
|
+
await this.ensureInitialized();
|
|
631
234
|
try {
|
|
632
|
-
|
|
235
|
+
// Determine the deletion type based on current status
|
|
236
|
+
const statuses = await this.getWorkflowsStatus();
|
|
237
|
+
const workflow = statuses.find(s => s.id === id);
|
|
238
|
+
if (!workflow) {
|
|
239
|
+
throw new Error(`Workflow ${id} not found in state`);
|
|
240
|
+
}
|
|
241
|
+
const deletionType = workflow.status === WorkflowSyncStatus.DELETED_LOCALLY ? 'local' : 'remote';
|
|
242
|
+
await this.resolutionManager.restoreWorkflow(id, filename, deletionType);
|
|
243
|
+
return true;
|
|
633
244
|
}
|
|
634
245
|
catch {
|
|
635
|
-
return
|
|
246
|
+
return false;
|
|
636
247
|
}
|
|
637
248
|
}
|
|
638
|
-
|
|
249
|
+
async deleteRemoteWorkflow(id, filename) {
|
|
250
|
+
await this.ensureInitialized();
|
|
639
251
|
try {
|
|
640
|
-
|
|
252
|
+
// Step 1: Archive local file (if exists)
|
|
253
|
+
await this.syncEngine.archive(filename);
|
|
254
|
+
// Step 2: Delete from API
|
|
255
|
+
await this.client.deleteWorkflow(id);
|
|
256
|
+
// Step 3: Remove from state (workflow is completely deleted)
|
|
257
|
+
await this.watcher.removeWorkflowState(id);
|
|
258
|
+
return true;
|
|
641
259
|
}
|
|
642
260
|
catch {
|
|
643
|
-
return
|
|
261
|
+
return false;
|
|
644
262
|
}
|
|
645
263
|
}
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
fs.mkdirSync(instanceDirectory, { recursive: true });
|
|
654
|
-
}
|
|
655
|
-
await this.syncDown();
|
|
656
|
-
await this.syncUp();
|
|
657
|
-
console.log(`[DEBUG] startWatch: fileToIdMap size = ${this.fileToIdMap.size}`);
|
|
658
|
-
console.log(`[DEBUG] entries: ${Array.from(this.fileToIdMap.entries()).map(([f, i]) => `${f}=${i}`).join(', ')}`);
|
|
659
|
-
this.watcher = chokidar.watch(instanceDirectory, {
|
|
660
|
-
ignored: /(^|[\/\\])\../,
|
|
661
|
-
persistent: true,
|
|
662
|
-
ignoreInitial: true,
|
|
663
|
-
awaitWriteFinish: { stabilityThreshold: 500, pollInterval: 100 }
|
|
664
|
-
});
|
|
665
|
-
this.watcher
|
|
666
|
-
.on('change', (p) => this.handleLocalFileChange(p))
|
|
667
|
-
.on('add', (p) => this.handleLocalFileChange(p))
|
|
668
|
-
.on('unlink', (p) => this.handleLocalFileDeletion(p));
|
|
669
|
-
if (this.config.pollIntervalMs > 0) {
|
|
670
|
-
this.pollInterval = setInterval(async () => {
|
|
671
|
-
try {
|
|
672
|
-
await this.syncDownWithConflictResolution();
|
|
673
|
-
}
|
|
674
|
-
catch (e) {
|
|
675
|
-
this.emit('error', `Remote poll failed: ${e.message}`);
|
|
676
|
-
}
|
|
677
|
-
}, this.config.pollIntervalMs);
|
|
264
|
+
// Deletion Validation Methods (6.2 from spec)
|
|
265
|
+
async confirmDeletion(id, filename) {
|
|
266
|
+
await this.ensureInitialized();
|
|
267
|
+
const statuses = await this.getWorkflowsStatus();
|
|
268
|
+
const workflow = statuses.find(s => s.id === id);
|
|
269
|
+
if (!workflow) {
|
|
270
|
+
throw new Error(`Workflow ${id} not found in state`);
|
|
678
271
|
}
|
|
272
|
+
const deletionType = workflow.status === WorkflowSyncStatus.DELETED_LOCALLY ? 'local' : 'remote';
|
|
273
|
+
await this.resolutionManager.confirmDeletion(id, filename, deletionType);
|
|
679
274
|
}
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
this.watcher = null;
|
|
684
|
-
}
|
|
685
|
-
if (this.pollInterval) {
|
|
686
|
-
clearInterval(this.pollInterval);
|
|
687
|
-
this.pollInterval = null;
|
|
688
|
-
}
|
|
689
|
-
this.emit('log', '🛑 [SyncManager] Watcher Stopped.');
|
|
275
|
+
async restoreRemoteWorkflow(id, filename) {
|
|
276
|
+
await this.ensureInitialized();
|
|
277
|
+
return await this.resolutionManager.restoreWorkflow(id, filename, 'remote');
|
|
690
278
|
}
|
|
691
279
|
}
|
|
692
280
|
//# sourceMappingURL=sync-manager.js.map
|