@arela/uploader 1.0.15 → 1.0.16

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.
@@ -0,0 +1,334 @@
1
+ import { Worker } from 'bullmq';
2
+
3
+ import logger from '../services/LoggingService.js';
4
+
5
+ import appConfig from '../config/config.js';
6
+ import ErrorHandler from '../errors/ErrorHandler.js';
7
+
8
+ /**
9
+ * Worker Command Handler
10
+ * Runs arela-uploader as a BullMQ worker, listening for jobs from the API
11
+ *
12
+ * This enables UI-controlled execution of:
13
+ * - scan: Filesystem scanning and file cataloging
14
+ * - identify: Document type detection (pedimento-simplificado)
15
+ * - propagate: Spread arela_path to related files
16
+ * - push: Upload files to storage
17
+ */
18
+ export class WorkerCommand {
19
+ constructor() {
20
+ this.errorHandler = new ErrorHandler(logger);
21
+ this.workers = new Map();
22
+ this.isShuttingDown = false;
23
+ }
24
+
25
+ /**
26
+ * Execute the worker command
27
+ * @param {Object} options - Command options
28
+ * @param {string} options.queues - Comma-separated list of queues to listen to
29
+ * @param {number} options.concurrency - Number of concurrent jobs per queue
30
+ */
31
+ async execute(options = {}) {
32
+ try {
33
+ // Load configuration
34
+ const redisConfig = appConfig.getRedisConfig();
35
+ const workerConfig = appConfig.getWorkerConfig();
36
+
37
+ // Determine which queues to listen to
38
+ const queueNames = options.queues
39
+ ? options.queues.split(',').map((q) => q.trim())
40
+ : workerConfig.queues;
41
+
42
+ const concurrency =
43
+ parseInt(options.concurrency) || workerConfig.concurrency;
44
+
45
+ logger.info('šŸ”§ Starting Arela Worker');
46
+ logger.info(`šŸ“” Redis: ${redisConfig.host}:${redisConfig.port}`);
47
+ logger.info(`šŸ“‹ Queues: ${queueNames.join(', ')}`);
48
+ logger.info(`⚔ Concurrency: ${concurrency}`);
49
+
50
+ // Setup graceful shutdown handlers
51
+ this.#setupShutdownHandlers();
52
+
53
+ // Start workers for each queue
54
+ for (const queueName of queueNames) {
55
+ await this.#startWorker(queueName, redisConfig, concurrency);
56
+ }
57
+
58
+ logger.success('\nāœ… Worker is running. Press Ctrl+C to stop.\n');
59
+
60
+ // Keep the process alive
61
+ await this.#keepAlive();
62
+ } catch (error) {
63
+ logger.error('āŒ Worker failed to start:', error.message);
64
+ throw error;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Start a worker for a specific queue
70
+ * @private
71
+ */
72
+ async #startWorker(queueName, redisConfig, concurrency) {
73
+ const processor = this.#getProcessor(queueName);
74
+
75
+ if (!processor) {
76
+ logger.warn(`āš ļø No processor found for queue: ${queueName}`);
77
+ return;
78
+ }
79
+
80
+ const worker = new Worker(queueName, processor, {
81
+ connection: redisConfig,
82
+ concurrency,
83
+ lockDuration: appConfig.getWorkerConfig().lockDuration,
84
+ stalledInterval: appConfig.getWorkerConfig().stalledInterval,
85
+ });
86
+
87
+ // Event handlers
88
+ worker.on('completed', (job) => {
89
+ logger.success(`āœ… Job ${job.id} completed on ${queueName}`);
90
+ });
91
+
92
+ worker.on('failed', (job, err) => {
93
+ logger.error(`āŒ Job ${job?.id} failed on ${queueName}: ${err.message}`);
94
+ });
95
+
96
+ worker.on('progress', (job, progress) => {
97
+ logger.info(`šŸ“Š Job ${job.id} progress: ${JSON.stringify(progress)}`);
98
+ });
99
+
100
+ worker.on('error', (err) => {
101
+ logger.error(`šŸ”“ Worker error on ${queueName}: ${err.message}`);
102
+ });
103
+
104
+ this.workers.set(queueName, worker);
105
+ logger.info(` āœ“ Worker started for queue: ${queueName}`);
106
+ }
107
+
108
+ /**
109
+ * Get the processor function for a queue
110
+ * @private
111
+ */
112
+ #getProcessor(queueName) {
113
+ const processors = {
114
+ 'pipeline-scan': this.#processScanJob.bind(this),
115
+ 'pipeline-identify': this.#processIdentifyJob.bind(this),
116
+ 'pipeline-propagate': this.#processPropagateJob.bind(this),
117
+ 'pipeline-push': this.#processPushJob.bind(this),
118
+ };
119
+
120
+ return processors[queueName];
121
+ }
122
+
123
+ /**
124
+ * Process a scan job
125
+ * @private
126
+ */
127
+ async #processScanJob(job) {
128
+ logger.info(`šŸ” Processing scan job ${job.id}`);
129
+
130
+ const { ScanCommand } = await import('./ScanCommand.js');
131
+ const scanCommand = new ScanCommand();
132
+
133
+ // Create progress callback for the job
134
+ const onProgress = (percent, message) => {
135
+ job.updateProgress({ percent, message });
136
+ job.log(message);
137
+ };
138
+
139
+ try {
140
+ // Extract options from job data
141
+ const options = {
142
+ api: job.data.apiTarget || 'default',
143
+ countFirst: job.data.countFirst || false,
144
+ stream: job.data.stream !== false,
145
+ // Pass progress callback
146
+ onProgress,
147
+ };
148
+
149
+ // Override scan config if provided in job
150
+ if (job.data.scanConfig) {
151
+ this.#overrideScanConfig(job.data.scanConfig);
152
+ }
153
+
154
+ const result = await scanCommand.execute(options);
155
+ return { success: true, ...result };
156
+ } catch (error) {
157
+ logger.error(`Scan job ${job.id} failed: ${error.message}`);
158
+ throw error;
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Process an identify job
164
+ * @private
165
+ */
166
+ async #processIdentifyJob(job) {
167
+ logger.info(`šŸ” Processing identify job ${job.id}`);
168
+
169
+ const { IdentifyCommand } = await import('./IdentifyCommand.js');
170
+ const identifyCommand = new IdentifyCommand();
171
+
172
+ const onProgress = (percent, message) => {
173
+ job.updateProgress({ percent, message });
174
+ job.log(message);
175
+ };
176
+
177
+ try {
178
+ const options = {
179
+ api: job.data.apiTarget || 'default',
180
+ batchSize: job.data.batchSize || 100,
181
+ showStats: job.data.showStats || false,
182
+ onProgress,
183
+ };
184
+
185
+ if (job.data.scanConfig) {
186
+ this.#overrideScanConfig(job.data.scanConfig);
187
+ }
188
+
189
+ const result = await identifyCommand.execute(options);
190
+ return { success: true, ...result };
191
+ } catch (error) {
192
+ logger.error(`Identify job ${job.id} failed: ${error.message}`);
193
+ throw error;
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Process a propagate job
199
+ * @private
200
+ */
201
+ async #processPropagateJob(job) {
202
+ logger.info(`šŸ”„ Processing propagate job ${job.id}`);
203
+
204
+ const { PropagateCommand } = await import('./PropagateCommand.js');
205
+
206
+ const onProgress = (percent, message) => {
207
+ job.updateProgress({ percent, message });
208
+ job.log(message);
209
+ };
210
+
211
+ try {
212
+ const options = {
213
+ api: job.data.apiTarget || 'default',
214
+ batchSize: job.data.batchSize || 50,
215
+ showStats: job.data.showStats || false,
216
+ onProgress,
217
+ };
218
+
219
+ if (job.data.scanConfig) {
220
+ this.#overrideScanConfig(job.data.scanConfig);
221
+ }
222
+
223
+ const propagateCommand = new PropagateCommand(options);
224
+ const result = await propagateCommand.execute();
225
+ return { success: true, ...result };
226
+ } catch (error) {
227
+ logger.error(`Propagate job ${job.id} failed: ${error.message}`);
228
+ throw error;
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Process a push job
234
+ * @private
235
+ */
236
+ async #processPushJob(job) {
237
+ logger.info(`šŸ“¤ Processing push job ${job.id}`);
238
+
239
+ const { PushCommand } = await import('./PushCommand.js');
240
+ const pushCommand = new PushCommand();
241
+
242
+ const onProgress = (percent, message) => {
243
+ job.updateProgress({ percent, message });
244
+ job.log(message);
245
+ };
246
+
247
+ try {
248
+ const options = {
249
+ api: job.data.apiTarget || 'default',
250
+ scanApi: job.data.scanApi,
251
+ pushApi: job.data.pushApi,
252
+ batchSize: job.data.batchSize || 100,
253
+ uploadBatchSize: job.data.uploadBatchSize || 10,
254
+ rfcs: job.data.rfcs,
255
+ years: job.data.years,
256
+ folderStructure: job.data.folderStructure,
257
+ autoOrganize: job.data.autoOrganize !== false,
258
+ showStats: job.data.showStats || false,
259
+ onProgress,
260
+ };
261
+
262
+ if (job.data.scanConfig) {
263
+ this.#overrideScanConfig(job.data.scanConfig);
264
+ }
265
+
266
+ const result = await pushCommand.execute(options);
267
+ return { success: true, ...result };
268
+ } catch (error) {
269
+ logger.error(`Push job ${job.id} failed: ${error.message}`);
270
+ throw error;
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Override scan configuration from job data
276
+ * @private
277
+ */
278
+ #overrideScanConfig(scanConfig) {
279
+ // Temporarily override environment variables for this job
280
+ if (scanConfig.companySlug) {
281
+ process.env.ARELA_COMPANY_SLUG = scanConfig.companySlug;
282
+ }
283
+ if (scanConfig.serverId) {
284
+ process.env.ARELA_SERVER_ID = scanConfig.serverId;
285
+ }
286
+ if (scanConfig.basePath) {
287
+ process.env.UPLOAD_BASE_PATH = scanConfig.basePath;
288
+ process.env.ARELA_BASE_PATH_LABEL = scanConfig.basePath;
289
+ }
290
+ if (scanConfig.directoryLevel !== undefined) {
291
+ process.env.SCAN_DIRECTORY_LEVEL = String(scanConfig.directoryLevel);
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Setup graceful shutdown handlers
297
+ * @private
298
+ */
299
+ #setupShutdownHandlers() {
300
+ const shutdown = async (signal) => {
301
+ if (this.isShuttingDown) return;
302
+ this.isShuttingDown = true;
303
+
304
+ logger.info(`\nšŸ‘‹ Received ${signal}. Shutting down workers...`);
305
+
306
+ // Close all workers gracefully
307
+ const closePromises = [];
308
+ for (const [queueName, worker] of this.workers) {
309
+ logger.info(` ā³ Closing worker: ${queueName}`);
310
+ closePromises.push(worker.close());
311
+ }
312
+
313
+ await Promise.all(closePromises);
314
+ logger.info('āœ… All workers closed gracefully');
315
+ process.exit(0);
316
+ };
317
+
318
+ process.on('SIGINT', () => shutdown('SIGINT'));
319
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
320
+ }
321
+
322
+ /**
323
+ * Keep the process alive while workers are running
324
+ * @private
325
+ */
326
+ async #keepAlive() {
327
+ return new Promise((resolve) => {
328
+ // This promise never resolves, keeping the process alive
329
+ // The process will exit via the shutdown handlers
330
+ });
331
+ }
332
+ }
333
+
334
+ export default new WorkerCommand();
@@ -22,6 +22,8 @@ class Config {
22
22
  this.performance = this.#loadPerformanceConfig();
23
23
  this.logging = this.#loadLoggingConfig();
24
24
  this.watch = this.#loadWatchConfig();
25
+ this.redis = this.#loadRedisConfig();
26
+ this.worker = this.#loadWorkerConfig();
25
27
  }
26
28
 
27
29
  /**
@@ -34,10 +36,10 @@ class Config {
34
36
  const __dirname = path.dirname(__filename);
35
37
  const packageJsonPath = path.resolve(__dirname, '../../package.json');
36
38
  const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
37
- return packageJson.version || '1.0.15';
39
+ return packageJson.version || '1.0.16';
38
40
  } catch (error) {
39
41
  console.warn('āš ļø Could not read package.json version, using fallback');
40
- return '1.0.15';
42
+ return '1.0.16';
41
43
  }
42
44
  }
43
45
 
@@ -78,6 +80,11 @@ class Config {
78
80
  token:
79
81
  process.env.ARELA_API_CLIENTE_TOKEN || process.env.ARELA_API_TOKEN,
80
82
  },
83
+ // Dynamic targets - add more as needed via ARELA_API_{NAME}_URL
84
+ ktj: {
85
+ baseUrl: process.env.ARELA_API_KTJ_URL || process.env.ARELA_API_URL,
86
+ token: process.env.ARELA_API_KTJ_TOKEN || process.env.ARELA_API_TOKEN,
87
+ },
81
88
  },
82
89
  // Current active target (can be changed at runtime)
83
90
  activeTarget: process.env.ARELA_API_TARGET || 'default',
@@ -118,7 +125,7 @@ class Config {
118
125
  * @param {string} target - API target: 'default', 'agencia', or 'cliente'
119
126
  */
120
127
  setApiTarget(target) {
121
- const validTargets = ['default', 'agencia', 'cliente'];
128
+ const validTargets = ['default', 'agencia', 'cliente', 'ktj'];
122
129
  if (!validTargets.includes(target.toLowerCase())) {
123
130
  throw new Error(
124
131
  `Invalid API target '${target}'. Must be one of: ${validTargets.join(', ')}`,
@@ -135,7 +142,7 @@ class Config {
135
142
  * @param {string} targetTarget - Target API target (for writing data)
136
143
  */
137
144
  setCrossTenantTargets(sourceTarget, targetTarget) {
138
- const validTargets = ['default', 'agencia', 'cliente'];
145
+ const validTargets = ['default', 'agencia', 'cliente', 'ktj'];
139
146
 
140
147
  if (!validTargets.includes(sourceTarget.toLowerCase())) {
141
148
  throw new Error(
@@ -496,6 +503,81 @@ class Config {
496
503
  return !!(this.supabase.url && this.supabase.key && this.supabase.bucket);
497
504
  }
498
505
 
506
+ /**
507
+ * Load Redis configuration for BullMQ worker mode
508
+ * @private
509
+ */
510
+ #loadRedisConfig() {
511
+ return {
512
+ host: process.env.REDIS_HOST || 'localhost',
513
+ port: parseInt(process.env.REDIS_PORT) || 6379,
514
+ password: process.env.REDIS_PASSWORD || undefined,
515
+ db: parseInt(process.env.REDIS_DB) || 0,
516
+ maxRetriesPerRequest: null, // Required by BullMQ
517
+ };
518
+ }
519
+
520
+ /**
521
+ * Load worker configuration
522
+ * @private
523
+ */
524
+ #loadWorkerConfig() {
525
+ const queues = process.env.WORKER_QUEUES?.split(',')
526
+ .map((q) => q.trim())
527
+ .filter(Boolean) || [
528
+ 'pipeline-scan',
529
+ 'pipeline-identify',
530
+ 'pipeline-propagate',
531
+ 'pipeline-push',
532
+ ];
533
+
534
+ return {
535
+ queues,
536
+ concurrency: parseInt(process.env.WORKER_CONCURRENCY) || 1,
537
+ lockDuration: parseInt(process.env.WORKER_LOCK_DURATION) || 30000,
538
+ stalledInterval: parseInt(process.env.WORKER_STALLED_INTERVAL) || 30000,
539
+ // HTTP Polling mode configuration
540
+ poll: {
541
+ enabled: process.env.WORKER_POLL_MODE === 'true',
542
+ interval: parseInt(process.env.WORKER_POLL_INTERVAL) || 5000,
543
+ apiTarget: process.env.WORKER_POLL_API_TARGET || 'agencia',
544
+ },
545
+ };
546
+ }
547
+
548
+ /**
549
+ * Get Redis configuration
550
+ * @returns {Object} Redis connection options for ioredis/BullMQ
551
+ */
552
+ getRedisConfig() {
553
+ return this.redis;
554
+ }
555
+
556
+ /**
557
+ * Get worker configuration
558
+ * @returns {Object} Worker settings
559
+ */
560
+ getWorkerConfig() {
561
+ return this.worker;
562
+ }
563
+
564
+ /**
565
+ * Get the server ID for this instance
566
+ * Used for worker job assignment in poll mode
567
+ * @returns {string|null} Server ID or null if not configured
568
+ */
569
+ getServerId() {
570
+ return process.env.ARELA_SERVER_ID || null;
571
+ }
572
+
573
+ /**
574
+ * Check if worker mode is available (Redis configured)
575
+ * @returns {boolean}
576
+ */
577
+ isWorkerModeAvailable() {
578
+ return !!(this.redis.host && this.redis.port);
579
+ }
580
+
499
581
  /**
500
582
  * Validate configuration for the requested mode
501
583
  * @param {boolean} forceSupabase - Whether to force Supabase mode
package/src/index.js CHANGED
@@ -2,11 +2,13 @@
2
2
  import { Command } from 'commander';
3
3
 
4
4
  import identifyCommand from './commands/IdentifyCommand.js';
5
+ import pollWorkerCommand from './commands/PollWorkerCommand.js';
5
6
  import PropagateCommand from './commands/PropagateCommand.js';
6
7
  import PushCommand from './commands/PushCommand.js';
7
8
  import scanCommand from './commands/ScanCommand.js';
8
9
  import UploadCommand from './commands/UploadCommand.js';
9
10
  import watchCommand from './commands/WatchCommand.js';
11
+ import workerCommand from './commands/WorkerCommand.js';
10
12
  import appConfig from './config/config.js';
11
13
  import ErrorHandler from './errors/ErrorHandler.js';
12
14
  import logger from './services/LoggingService.js';
@@ -522,6 +524,46 @@ class ArelaUploaderCLI {
522
524
  }
523
525
  });
524
526
 
527
+ // ============================================================================
528
+ // WORKER MODE - BullMQ job processor
529
+ // ============================================================================
530
+
531
+ // Worker command - process jobs from BullMQ queues
532
+ this.program
533
+ .command('worker')
534
+ .description('šŸ”§ Run as BullMQ worker to process pipeline jobs from UI')
535
+ .option(
536
+ '--queues <queues>',
537
+ 'Comma-separated queues to listen to (default: all pipeline queues)',
538
+ )
539
+ .option(
540
+ '-c, --concurrency <number>',
541
+ 'Number of concurrent jobs per queue',
542
+ '1',
543
+ )
544
+ .option(
545
+ '--poll',
546
+ 'Use HTTP polling mode instead of BullMQ (for environments without Redis)',
547
+ )
548
+ .option(
549
+ '--api <target>',
550
+ 'API target for polling (e.g., agencia, cliente, ktj)',
551
+ 'agencia',
552
+ )
553
+ .action(async (options) => {
554
+ try {
555
+ if (options.poll) {
556
+ // HTTP polling mode - for Windows Server or environments without Redis
557
+ await pollWorkerCommand.execute(options);
558
+ } else {
559
+ // BullMQ mode - default for environments with Redis
560
+ await workerCommand.execute(options);
561
+ }
562
+ } catch (error) {
563
+ this.errorHandler.handleFatalError(error, { command: 'worker' });
564
+ }
565
+ });
566
+
525
567
  // Version command (already handled by program.version())
526
568
 
527
569
  // Help command