@fkom13/mcp-sftp-orchestrator 6.0.0 → 11.8.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/queue.js CHANGED
@@ -5,8 +5,11 @@ import config from './config.js';
5
5
 
6
6
  const QUEUE_FILE = path.join(config.dataDir, 'queue.json');
7
7
  const QUEUE_BACKUP = path.join(config.dataDir, 'queue.backup.json');
8
- const SAVE_INTERVAL = 5000; // Sauvegarder toutes les 5 secondes
9
- const MAX_QUEUE_SIZE = 1000; // Limite de tâches en mémoire
8
+ const SAVE_INTERVAL = config.saveInterval || 5000;
9
+ const MAX_QUEUE_SIZE = config.maxQueueSize || 1000;
10
+
11
+ // ✅ Mode silencieux par défaut (logs désactivés sauf si MCP_DEBUG=true)
12
+ const SILENT_MODE = process.env.MCP_DEBUG !== 'true';
10
13
 
11
14
  const jobQueue = {};
12
15
  const logHistory = [];
@@ -14,37 +17,39 @@ const MAX_LOGS = 500;
14
17
 
15
18
  let saveTimer = null;
16
19
  let isDirty = false;
20
+ let dirtyRevision = 0;
21
+
22
+ function markDirty() {
23
+ isDirty = true;
24
+ dirtyRevision++;
25
+ }
17
26
 
18
27
  // Charger la queue au démarrage
19
28
  async function loadQueue() {
20
29
  try {
21
30
  const data = await fs.readFile(QUEUE_FILE, 'utf-8');
22
31
  const savedQueue = JSON.parse(data);
23
-
24
- // Restaurer les tâches non terminées
32
+
25
33
  for (const [id, job] of Object.entries(savedQueue)) {
26
- // Convertir les dates string en objets Date
27
34
  if (job.createdAt) job.createdAt = new Date(job.createdAt);
28
35
  if (job.updatedAt) job.updatedAt = new Date(job.updatedAt);
29
36
  if (job.reminderAt) job.reminderAt = new Date(job.reminderAt);
30
-
31
- // Marquer les tâches running comme crashed
37
+
32
38
  if (job.status === 'running') {
33
39
  job.status = 'crashed';
34
40
  job.crashedAt = new Date();
35
41
  job.canRetry = true;
36
42
  log('warn', `Tâche ${id} marquée comme crashed (reprise après redémarrage)`);
37
43
  }
38
-
44
+
39
45
  jobQueue[id] = job;
40
46
  }
41
-
47
+
42
48
  log('info', `${Object.keys(jobQueue).length} tâches restaurées depuis la sauvegarde`);
43
49
  } catch (err) {
44
50
  if (err.code !== 'ENOENT') {
45
51
  log('error', `Erreur lors du chargement de la queue: ${err.message}`);
46
-
47
- // Essayer de charger la sauvegarde de secours
52
+
48
53
  try {
49
54
  const backupData = await fs.readFile(QUEUE_BACKUP, 'utf-8');
50
55
  const backupQueue = JSON.parse(backupData);
@@ -59,58 +64,73 @@ async function loadQueue() {
59
64
 
60
65
  let isSaving = false;
61
66
 
62
- // Sauvegarder la queue
67
+ let saveLock = null;
68
+
63
69
  async function saveQueue() {
64
- if (!isDirty || isSaving) return;
65
-
66
- isSaving = true;
67
- try {
68
- // Créer une sauvegarde de l'ancienne queue
70
+ if (!isDirty) return;
71
+
72
+ // Attendre le save précédent puis revalider : il a peut-être déjà persisté
73
+ // toutes les mutations connues au moment de cet appel.
74
+ if (saveLock) {
75
+ await saveLock;
76
+ if (!isDirty) return;
77
+ }
78
+
79
+ saveLock = (async () => {
80
+ isSaving = true;
81
+ let tmpPath = null;
82
+ const revisionAtStart = dirtyRevision;
69
83
  try {
70
- await fs.copyFile(QUEUE_FILE, QUEUE_BACKUP);
71
- } catch (e) {
72
- // Ignorer si le fichier n'existe pas encore
73
- }
74
-
75
- // Filtrer les tâches terminées anciennes (garder 24h)
76
- const now = Date.now();
77
- const filteredQueue = {};
78
-
79
- for (const [id, job] of Object.entries(jobQueue)) {
80
- const age = now - new Date(job.createdAt).getTime();
81
- const isRecent = age < 86400000; // 24 heures
82
- const isActive = ['pending', 'running', 'crashed'].includes(job.status);
83
-
84
- if (isActive || isRecent) {
85
- filteredQueue[id] = job;
84
+ try {
85
+ await fs.copyFile(QUEUE_FILE, QUEUE_BACKUP);
86
+ } catch (e) {
87
+ // Ignorer si le fichier n'existe pas
88
+ }
89
+
90
+ const now = Date.now();
91
+ const filteredQueue = {};
92
+
93
+ for (const [id, job] of Object.entries(jobQueue)) {
94
+ const age = now - new Date(job.createdAt).getTime();
95
+ const isRecent = age < 86400000;
96
+ const isActive = ['pending', 'running', 'crashed'].includes(job.status);
97
+
98
+ if (isActive || isRecent) {
99
+ filteredQueue[id] = job;
100
+ }
86
101
  }
102
+
103
+ // Écriture atomique : un crash ne peut plus laisser queue.json tronqué.
104
+ tmpPath = `${QUEUE_FILE}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
105
+ await fs.writeFile(tmpPath, JSON.stringify(filteredQueue, null, 2));
106
+ await fs.rename(tmpPath, QUEUE_FILE);
107
+ tmpPath = null;
108
+
109
+ // Une mutation a pu arriver pendant les awaits ci-dessus. Ne jamais
110
+ // effacer son dirty flag : le prochain tick la persistera.
111
+ isDirty = dirtyRevision !== revisionAtStart;
112
+ log('debug', `Queue sauvegardée (${Object.keys(filteredQueue).length} tâches, dirty=${isDirty})`);
113
+ } catch (err) {
114
+ isDirty = true;
115
+ if (tmpPath) await fs.rm(tmpPath, { force: true }).catch(() => {});
116
+ log('error', `Erreur lors de la sauvegarde de la queue: ${err.message}`);
117
+ } finally {
118
+ isSaving = false;
119
+ saveLock = null;
87
120
  }
88
-
89
- // Sauvegarder la queue filtrée
90
- await fs.writeFile(
91
- QUEUE_FILE,
92
- JSON.stringify(filteredQueue, null, 2)
93
- );
94
-
95
- isDirty = false;
96
- log('debug', `Queue sauvegardée (${Object.keys(filteredQueue).length} tâches)`);
97
- } catch (err) {
98
- log('error', `Erreur lors de la sauvegarde de la queue: ${err.message}`);
99
- } finally {
100
- isSaving = false;
101
- }
121
+ })();
122
+
123
+ return saveLock;
102
124
  }
103
125
 
104
- // Démarrer la sauvegarde automatique
105
126
  function startAutoSave() {
106
127
  if (saveTimer) clearInterval(saveTimer);
107
-
128
+
108
129
  saveTimer = setInterval(() => {
109
130
  saveQueue();
110
131
  }, SAVE_INTERVAL);
111
132
  }
112
133
 
113
- // Arrêter la sauvegarde automatique
114
134
  function stopAutoSave() {
115
135
  if (saveTimer) {
116
136
  clearInterval(saveTimer);
@@ -124,31 +144,30 @@ function log(level, message) {
124
144
  if (logHistory.length > MAX_LOGS) {
125
145
  logHistory.shift();
126
146
  }
127
-
128
- // Afficher les logs importants
129
- if (['error', 'warn', 'info'].includes(level)) {
147
+
148
+ // ✅ N'afficher les logs que si MCP_DEBUG=true
149
+ if (!SILENT_MODE && ['error', 'warn', 'info'].includes(level)) {
130
150
  const prefix = {
131
151
  error: '[❌ ERROR]',
132
152
  warn: '[⚠️ WARN]',
133
153
  info: '[ℹ️ INFO]',
134
154
  debug: '[🔧 DEBUG]'
135
155
  }[level] || `[${level.toUpperCase()}]`;
136
-
137
- console.log(`${prefix} ${new Date().toISOString().split('T')[1].split('.')[0]} - ${message}`);
156
+
157
+ // ✅ TOUJOURS utiliser stderr (pas stdout)
158
+ console.error(`${prefix} ${new Date().toISOString().split('T')[1].split('.')[0]} - ${message}`);
138
159
  }
139
160
  }
140
161
 
141
162
  function addJob(details) {
142
- // Vérifier la taille de la queue
143
163
  if (Object.keys(jobQueue).length >= MAX_QUEUE_SIZE) {
144
- // Nettoyer les vieilles tâches terminées
145
164
  cleanOldJobs();
146
-
165
+
147
166
  if (Object.keys(jobQueue).length >= MAX_QUEUE_SIZE) {
148
167
  throw new Error(`Queue pleine (${MAX_QUEUE_SIZE} tâches max)`);
149
168
  }
150
169
  }
151
-
170
+
152
171
  const id = uuidv4().split('-')[0];
153
172
  const job = {
154
173
  id,
@@ -164,7 +183,7 @@ function addJob(details) {
164
183
  }
165
184
 
166
185
  jobQueue[id] = job;
167
- isDirty = true;
186
+ markDirty();
168
187
  log('info', `Nouvelle tâche ${id} (${job.type}) ajoutée.`);
169
188
  return jobQueue[id];
170
189
  }
@@ -174,8 +193,7 @@ function updateJobStatus(id, status, data = {}) {
174
193
  const oldStatus = jobQueue[id].status;
175
194
  jobQueue[id].status = status;
176
195
  jobQueue[id].updatedAt = new Date();
177
-
178
- // Copier toutes les données supplémentaires
196
+
179
197
  Object.assign(jobQueue[id], data);
180
198
 
181
199
  if (data.error) {
@@ -186,12 +204,12 @@ function updateJobStatus(id, status, data = {}) {
186
204
  jobQueue[id].completedAt = new Date();
187
205
  const duration = jobQueue[id].completedAt - jobQueue[id].createdAt;
188
206
  jobQueue[id].duration = duration;
189
- log('info', `Tâche ${id} terminée en ${(duration/1000).toFixed(2)}s`);
207
+ log('info', `Tâche ${id} terminée en ${(duration / 1000).toFixed(2)}s`);
190
208
  } else {
191
209
  log('info', `Tâche ${id}: ${oldStatus} -> ${status}`);
192
210
  }
193
211
 
194
- isDirty = true;
212
+ markDirty();
195
213
  }
196
214
  }
197
215
 
@@ -207,7 +225,7 @@ function getLogs(filter = {}) {
207
225
  if (!filter || Object.keys(filter).length === 0) {
208
226
  return logHistory;
209
227
  }
210
-
228
+
211
229
  return logHistory.filter(log => {
212
230
  if (filter.level && log.level !== filter.level) return false;
213
231
  if (filter.since && new Date(log.timestamp) < new Date(filter.since)) return false;
@@ -216,47 +234,57 @@ function getLogs(filter = {}) {
216
234
  });
217
235
  }
218
236
 
219
- // Nettoyer les vieilles tâches terminées
220
237
  function cleanOldJobs() {
221
238
  const now = Date.now();
222
239
  const toDelete = [];
223
-
240
+ const MAX_AGE = 86400000;
241
+
224
242
  for (const [id, job] of Object.entries(jobQueue)) {
225
- const age = now - new Date(job.createdAt).getTime();
226
- const isOld = age > 86400000; // Plus de 24h
243
+ const createdAt = job.createdAt ? new Date(job.createdAt).getTime() : now;
244
+ if (isNaN(createdAt)) {
245
+ log('warn', `Tâche ${id} a une date de création invalide, conservation`);
246
+ continue;
247
+ }
248
+
249
+ const age = now - createdAt;
250
+
251
+ if (age < 0) {
252
+ log('warn', `Tâche ${id} a une date dans le futur, conservation`);
253
+ continue;
254
+ }
255
+
256
+ const isOld = age > MAX_AGE;
227
257
  const isCompleted = ['completed', 'failed'].includes(job.status);
228
-
258
+
229
259
  if (isOld && isCompleted) {
230
260
  toDelete.push(id);
231
261
  }
232
262
  }
233
-
263
+
234
264
  for (const id of toDelete) {
235
265
  delete jobQueue[id];
236
266
  }
237
-
267
+
238
268
  if (toDelete.length > 0) {
239
269
  log('info', `${toDelete.length} vieilles tâches supprimées de la queue`);
240
- isDirty = true;
270
+ markDirty();
241
271
  }
242
272
  }
243
273
 
244
- // Réessayer une tâche échouée ou crashed
245
274
  async function retryJob(id) {
246
275
  const job = jobQueue[id];
247
276
  if (!job) {
248
277
  throw new Error(`Tâche ${id} introuvable`);
249
278
  }
250
-
279
+
251
280
  if (!['failed', 'crashed'].includes(job.status)) {
252
281
  throw new Error(`La tâche ${id} ne peut pas être réessayée (statut: ${job.status})`);
253
282
  }
254
-
283
+
255
284
  if (job.retryCount >= job.maxRetries) {
256
285
  throw new Error(`La tâche ${id} a atteint le nombre max de tentatives (${job.maxRetries})`);
257
286
  }
258
-
259
- // Créer une nouvelle tâche basée sur l'ancienne
287
+
260
288
  const newJob = {
261
289
  ...job,
262
290
  id: uuidv4().split('-')[0],
@@ -268,29 +296,77 @@ async function retryJob(id) {
268
296
  error: null,
269
297
  output: null
270
298
  };
271
-
299
+
272
300
  delete newJob.failedAt;
273
301
  delete newJob.crashedAt;
274
302
  delete newJob.completedAt;
275
-
303
+
276
304
  jobQueue[newJob.id] = newJob;
277
- isDirty = true;
278
-
305
+ markDirty();
306
+
279
307
  log('info', `Tâche ${id} réessayée -> nouvelle tâche ${newJob.id} (tentative ${newJob.retryCount}/${newJob.maxRetries})`);
280
-
308
+
281
309
  return newJob;
282
310
  }
283
311
 
284
- // Obtenir les tâches crashées qui peuvent être reprises
285
312
  function getCrashedJobs() {
286
- return Object.values(jobQueue).filter(job =>
287
- job.status === 'crashed' &&
313
+ return Object.values(jobQueue).filter(job =>
314
+ job.status === 'crashed' &&
288
315
  job.canRetry &&
289
316
  job.retryCount < job.maxRetries
290
317
  );
291
318
  }
292
319
 
293
- // Statistiques de la queue
320
+ function getRetryableJobs(statusFilter = null) {
321
+ return Object.values(jobQueue).filter(job => {
322
+ if (!['failed', 'crashed'].includes(job.status)) return false;
323
+ if (statusFilter && job.status !== statusFilter) return false;
324
+ if ((job.retryCount || 0) >= (job.maxRetries || 3)) return false;
325
+ return true;
326
+ });
327
+ }
328
+
329
+ /**
330
+ * Purge des jobs terminés/crashés.
331
+ * options = { status?: 'crashed'|'failed'|'completed'|'all_terminal', olderThanDays?: number, dryRun?: bool }
332
+ */
333
+ function purgeJobs(options = {}) {
334
+ const olderThanDays = options.olderThanDays ?? 0;
335
+ const dryRun = options.dryRun === true;
336
+ const status = options.status || 'crashed';
337
+ const now = Date.now();
338
+ const maxAge = olderThanDays > 0 ? olderThanDays * 86400000 : 0;
339
+
340
+ const terminal = new Set(['completed', 'failed', 'crashed', 'partial']);
341
+ const toDelete = [];
342
+
343
+ for (const [id, job] of Object.entries(jobQueue)) {
344
+ let match = false;
345
+ if (status === 'all_terminal') {
346
+ match = terminal.has(job.status);
347
+ } else {
348
+ match = job.status === status;
349
+ }
350
+ if (!match) continue;
351
+
352
+ if (maxAge > 0) {
353
+ const created = job.createdAt ? new Date(job.createdAt).getTime() : now;
354
+ if (now - created < maxAge) continue;
355
+ }
356
+ toDelete.push(id);
357
+ }
358
+
359
+ if (!dryRun) {
360
+ for (const id of toDelete) delete jobQueue[id];
361
+ if (toDelete.length) {
362
+ markDirty();
363
+ log('info', `Purge: ${toDelete.length} tâche(s) supprimée(s) (status=${status})`);
364
+ }
365
+ }
366
+
367
+ return { purged: toDelete.length, ids: toDelete, dryRun };
368
+ }
369
+
294
370
  function getStats() {
295
371
  const stats = {
296
372
  total: Object.keys(jobQueue).length,
@@ -299,79 +375,58 @@ function getStats() {
299
375
  avgDuration: 0,
300
376
  successRate: 0
301
377
  };
302
-
378
+
303
379
  let totalDuration = 0;
304
380
  let completedCount = 0;
305
-
381
+
306
382
  for (const job of Object.values(jobQueue)) {
307
- // Par statut
308
383
  stats.byStatus[job.status] = (stats.byStatus[job.status] || 0) + 1;
309
-
310
- // Par type
311
384
  stats.byType[job.type] = (stats.byType[job.type] || 0) + 1;
312
-
313
- // Durée moyenne
385
+
314
386
  if (job.duration) {
315
387
  totalDuration += job.duration;
316
388
  completedCount++;
317
389
  }
318
390
  }
319
-
391
+
320
392
  if (completedCount > 0) {
321
393
  stats.avgDuration = Math.round(totalDuration / completedCount);
322
394
  }
323
-
395
+
324
396
  const totalFinished = (stats.byStatus.completed || 0) + (stats.byStatus.failed || 0);
325
397
  if (totalFinished > 0) {
326
398
  stats.successRate = Math.round((stats.byStatus.completed || 0) / totalFinished * 100);
327
399
  }
328
-
400
+
329
401
  return stats;
330
402
  }
331
403
 
332
- // Initialiser le module
333
404
  async function init() {
334
405
  await loadQueue();
335
406
  startAutoSave();
336
-
337
- // Nettoyer périodiquement
338
- setInterval(cleanOldJobs, 3600000); // Toutes les heures
407
+ setInterval(cleanOldJobs, 3600000);
339
408
  }
340
409
 
341
- // Arrêter proprement
342
410
  async function shutdown() {
343
411
  log('info', 'Arrêt du gestionnaire de queue...');
344
412
  stopAutoSave();
345
413
  await saveQueue();
346
414
  }
347
415
 
348
- // Gérer l'arrêt du processus
349
- process.on('SIGINT', async () => {
350
- await shutdown();
351
- process.exit(0);
352
- });
353
-
354
- process.on('SIGTERM', async () => {
355
- await shutdown();
356
- process.exit(0);
357
- });
358
-
359
- // Démarrer automatiquement
360
- init().catch(err => {
361
- console.error('Erreur lors de l\'initialisation de la queue:', err);
362
- });
363
-
364
- export default {
365
- addJob,
366
- updateJobStatus,
367
- getJob,
368
- getQueue,
369
- getLogs,
416
+ export default {
417
+ addJob,
418
+ updateJobStatus,
419
+ getJob,
420
+ getQueue,
421
+ getLogs,
370
422
  log,
371
423
  retryJob,
372
424
  getCrashedJobs,
425
+ getRetryableJobs,
426
+ purgeJobs,
373
427
  getStats,
374
428
  cleanOldJobs,
375
429
  saveQueue,
376
- shutdown
430
+ shutdown,
431
+ init
377
432
  };