@arela/uploader 1.1.4 → 1.2.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.
Files changed (95) hide show
  1. package/.claude/worktrees/agent-multi-profile/.env.template +224 -0
  2. package/.claude/worktrees/agent-multi-profile/.prettierrc +13 -0
  3. package/.claude/worktrees/agent-multi-profile/README.md +405 -0
  4. package/.claude/worktrees/agent-multi-profile/package-lock.json +7096 -0
  5. package/.claude/worktrees/agent-multi-profile/package.json +78 -0
  6. package/.claude/worktrees/agent-multi-profile/scripts/cleanup-ds-store.js +109 -0
  7. package/.claude/worktrees/agent-multi-profile/scripts/cleanup-system-files.js +69 -0
  8. package/.claude/worktrees/agent-multi-profile/scripts/scoring-compare.js +243 -0
  9. package/.claude/worktrees/agent-multi-profile/scripts/scoring-phase4-check.js +96 -0
  10. package/.claude/worktrees/agent-multi-profile/scripts/tests/phase-7-features.test.js +415 -0
  11. package/.claude/worktrees/agent-multi-profile/scripts/tests/signal-handling.test.js +275 -0
  12. package/.claude/worktrees/agent-multi-profile/scripts/tests/smart-watch-integration.test.js +554 -0
  13. package/.claude/worktrees/agent-multi-profile/scripts/tests/watch-service-integration.test.js +584 -0
  14. package/.claude/worktrees/agent-multi-profile/src/commands/AgentCommand.js +229 -0
  15. package/.claude/worktrees/agent-multi-profile/src/commands/AgentInitCommand.js +316 -0
  16. package/.claude/worktrees/agent-multi-profile/src/commands/DatastageCommand.js +164 -0
  17. package/.claude/worktrees/agent-multi-profile/src/commands/GDriveSyncCommand.js +475 -0
  18. package/.claude/worktrees/agent-multi-profile/src/commands/IdentifyCommand.js +708 -0
  19. package/.claude/worktrees/agent-multi-profile/src/commands/PollWorkerCommand.js +169 -0
  20. package/.claude/worktrees/agent-multi-profile/src/commands/PropagateCommand.js +636 -0
  21. package/.claude/worktrees/agent-multi-profile/src/commands/PushCommand.js +743 -0
  22. package/.claude/worktrees/agent-multi-profile/src/commands/ScanCommand.js +722 -0
  23. package/.claude/worktrees/agent-multi-profile/src/commands/UploadCommand.js +587 -0
  24. package/.claude/worktrees/agent-multi-profile/src/commands/WatchCommand.js +1342 -0
  25. package/.claude/worktrees/agent-multi-profile/src/commands/WorkerCommand.js +337 -0
  26. package/.claude/worktrees/agent-multi-profile/src/config/config.js +862 -0
  27. package/.claude/worktrees/agent-multi-profile/src/document-type-shared.js +131 -0
  28. package/.claude/worktrees/agent-multi-profile/src/document-types/_pedimento-shared-extractors.js +348 -0
  29. package/.claude/worktrees/agent-multi-profile/src/document-types/doda-pdf.js +121 -0
  30. package/.claude/worktrees/agent-multi-profile/src/document-types/doda-xml.js +118 -0
  31. package/.claude/worktrees/agent-multi-profile/src/document-types/factura-inter-agencia.js +186 -0
  32. package/.claude/worktrees/agent-multi-profile/src/document-types/facturas-comerciales.js +233 -0
  33. package/.claude/worktrees/agent-multi-profile/src/document-types/pedimento-completo-xml.js +372 -0
  34. package/.claude/worktrees/agent-multi-profile/src/document-types/pedimento-completo.js +108 -0
  35. package/.claude/worktrees/agent-multi-profile/src/document-types/pedimento-simplificado.js +76 -0
  36. package/.claude/worktrees/agent-multi-profile/src/document-types/proforma.js +29 -0
  37. package/.claude/worktrees/agent-multi-profile/src/document-types/support-document.js +200 -0
  38. package/.claude/worktrees/agent-multi-profile/src/errors/ErrorHandler.js +278 -0
  39. package/.claude/worktrees/agent-multi-profile/src/errors/ErrorTypes.js +104 -0
  40. package/.claude/worktrees/agent-multi-profile/src/file-detection.js +338 -0
  41. package/.claude/worktrees/agent-multi-profile/src/index.js +890 -0
  42. package/.claude/worktrees/agent-multi-profile/src/scoring/db-matcher-adapter.js +98 -0
  43. package/.claude/worktrees/agent-multi-profile/src/scoring/matchers-seed.js +386 -0
  44. package/.claude/worktrees/agent-multi-profile/src/scoring/scoring-engine.js +251 -0
  45. package/.claude/worktrees/agent-multi-profile/src/services/AdvancedFilterService.js +505 -0
  46. package/.claude/worktrees/agent-multi-profile/src/services/AutoProcessingService.js +749 -0
  47. package/.claude/worktrees/agent-multi-profile/src/services/BenchmarkingService.js +381 -0
  48. package/.claude/worktrees/agent-multi-profile/src/services/DatabaseService.js +2173 -0
  49. package/.claude/worktrees/agent-multi-profile/src/services/DatastageApiService.js +240 -0
  50. package/.claude/worktrees/agent-multi-profile/src/services/ErrorMonitor.js +275 -0
  51. package/.claude/worktrees/agent-multi-profile/src/services/GoogleDriveService.js +217 -0
  52. package/.claude/worktrees/agent-multi-profile/src/services/LoggingService.js +649 -0
  53. package/.claude/worktrees/agent-multi-profile/src/services/MonitoringService.js +401 -0
  54. package/.claude/worktrees/agent-multi-profile/src/services/PerformanceOptimizer.js +511 -0
  55. package/.claude/worktrees/agent-multi-profile/src/services/PipelineApiService.js +274 -0
  56. package/.claude/worktrees/agent-multi-profile/src/services/PipelineJobRunner.js +389 -0
  57. package/.claude/worktrees/agent-multi-profile/src/services/ProfileManager.js +164 -0
  58. package/.claude/worktrees/agent-multi-profile/src/services/ReportingService.js +511 -0
  59. package/.claude/worktrees/agent-multi-profile/src/services/ScanApiService.js +775 -0
  60. package/.claude/worktrees/agent-multi-profile/src/services/SignalHandler.js +255 -0
  61. package/.claude/worktrees/agent-multi-profile/src/services/SmartWatchDatabaseService.js +527 -0
  62. package/.claude/worktrees/agent-multi-profile/src/services/WatchService.js +783 -0
  63. package/.claude/worktrees/agent-multi-profile/src/services/upload/ApiUploadService.js +676 -0
  64. package/.claude/worktrees/agent-multi-profile/src/services/upload/BaseUploadService.js +36 -0
  65. package/.claude/worktrees/agent-multi-profile/src/services/upload/MultiApiUploadService.js +233 -0
  66. package/.claude/worktrees/agent-multi-profile/src/services/upload/SupabaseUploadService.js +148 -0
  67. package/.claude/worktrees/agent-multi-profile/src/services/upload/UploadServiceFactory.js +100 -0
  68. package/.claude/worktrees/agent-multi-profile/src/utils/CleanupManager.js +262 -0
  69. package/.claude/worktrees/agent-multi-profile/src/utils/FileOperations.js +192 -0
  70. package/.claude/worktrees/agent-multi-profile/src/utils/FileSanitizer.js +99 -0
  71. package/.claude/worktrees/agent-multi-profile/src/utils/PathDetector.js +198 -0
  72. package/.claude/worktrees/agent-multi-profile/src/utils/PathNormalizer.js +274 -0
  73. package/.claude/worktrees/agent-multi-profile/src/utils/WatchEventHandler.js +522 -0
  74. package/.claude/worktrees/agent-multi-profile/supabase/migrations/001_create_initial_schema.sql +366 -0
  75. package/.claude/worktrees/agent-multi-profile/supabase/migrations/002_align_with_arela_api_schema.sql +145 -0
  76. package/.claude/worktrees/agent-multi-profile/tests/commands/IdentifyCommand.test.js +570 -0
  77. package/.claude/worktrees/agent-multi-profile/tests/commands/PropagateCommand.test.js +568 -0
  78. package/.claude/worktrees/agent-multi-profile/tests/commands/PushCommand.test.js +754 -0
  79. package/.claude/worktrees/agent-multi-profile/tests/commands/ScanCommand.test.js +382 -0
  80. package/.claude/worktrees/agent-multi-profile/tests/unit/PathAndTableNameGeneration.test.js +1211 -0
  81. package/.claude/worktrees/agent-multi-profile/tests/unit/factura-inter-agencia.test.js +218 -0
  82. package/.claude/worktrees/agent-multi-profile/tests/unit/pedimento-completo-xml-matcher.test.js +271 -0
  83. package/.claude/worktrees/agent-multi-profile/tests/unit/pedimento-simplificado-matcher.test.js +185 -0
  84. package/.claude/worktrees/agent-multi-profile/tests/unit/scoring-engine.test.js +221 -0
  85. package/README.md +65 -0
  86. package/package.json +1 -1
  87. package/src/commands/AgentCommand.js +210 -0
  88. package/src/commands/AgentInitCommand.js +316 -0
  89. package/src/commands/PollWorkerCommand.js +11 -322
  90. package/src/config/config.js +44 -6
  91. package/src/index.js +55 -0
  92. package/src/services/LoggingService.js +35 -0
  93. package/src/services/PipelineApiService.js +7 -1
  94. package/src/services/PipelineJobRunner.js +389 -0
  95. package/src/services/ProfileManager.js +164 -0
@@ -0,0 +1,316 @@
1
+ import { parse as parseEnv } from 'dotenv';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+
6
+ import {
7
+ DEFAULT_AGENT_LOG_PATH,
8
+ expandHome,
9
+ } from '../services/ProfileManager.js';
10
+
11
+ const TUNING_ENV_KEYS = [
12
+ 'SCAN_EXCLUDE_PATTERNS',
13
+ 'SCAN_BATCH_SIZE',
14
+ 'PUSH_BATCH_SIZE',
15
+ 'PUSH_UPLOAD_BATCH_SIZE',
16
+ 'MAX_CONCURRENT_SOURCES',
17
+ 'MAX_API_CONNECTIONS',
18
+ 'API_CONNECTION_TIMEOUT',
19
+ 'BATCH_DELAY',
20
+ 'BATCH_SIZE',
21
+ ];
22
+
23
+ /**
24
+ * Agent Init Command Handler
25
+ * Migration helper: reads the legacy per-folder .env files (one folder per
26
+ * RFC×source) and generates, WITHOUT calling any API:
27
+ * - profiles.json → minimal agent config (folders on the same tenant API
28
+ * collapse into one profile)
29
+ * - seed-configs.json → one pipeline_config payload per folder (the data that
30
+ * moves server-side: scan directories, tuning, schedule)
31
+ * - seed-configs.sh → ready-to-run curls to POST those payloads
32
+ *
33
+ * Tokens never leave the machine: everything is generated locally for review.
34
+ *
35
+ * Usage:
36
+ * arela agent init --from ../ktj-cli-maya ../ktj-cli-matro --server-id PALCO-01
37
+ */
38
+ export class AgentInitCommand {
39
+ /**
40
+ * Execute the init command
41
+ * @param {string[]} dirs - Folders containing legacy .env files
42
+ * @param {Object} options
43
+ * @param {string} options.serverId - Machine server id (default: ARELA_SERVER_ID from first .env, else hostname)
44
+ * @param {string} options.out - Output directory (default ~/.arela)
45
+ * @param {boolean} options.force - Overwrite an existing profiles.json
46
+ */
47
+ async execute(dirs, options = {}) {
48
+ if (!dirs || dirs.length === 0) {
49
+ throw new Error(
50
+ 'At least one folder is required: arela agent init --from <dir1> <dir2> …',
51
+ );
52
+ }
53
+
54
+ const outDir = path.resolve(expandHome(options.out || '~/.arela'));
55
+ fs.mkdirSync(outDir, { recursive: true });
56
+
57
+ const profilesPath = path.join(outDir, 'profiles.json');
58
+ if (fs.existsSync(profilesPath) && !options.force) {
59
+ throw new Error(
60
+ `${profilesPath} already exists. Use --force to overwrite it.`,
61
+ );
62
+ }
63
+
64
+ const parsedFolders = [];
65
+ for (const dir of dirs) {
66
+ const resolvedDir = path.resolve(expandHome(dir));
67
+ const envPath = path.join(resolvedDir, '.env');
68
+ if (!fs.existsSync(envPath)) {
69
+ console.warn(`⚠️ Skipping ${resolvedDir}: no .env file found`);
70
+ continue;
71
+ }
72
+ const env = parseEnv(fs.readFileSync(envPath));
73
+ const clienteUrl = env.ARELA_API_CLIENTE_URL || env.ARELA_API_URL || null;
74
+ const clienteToken =
75
+ env.ARELA_API_CLIENTE_TOKEN || env.ARELA_API_TOKEN || null;
76
+ if (!clienteUrl || !clienteToken) {
77
+ console.warn(
78
+ `⚠️ Skipping ${resolvedDir}: no ARELA_API_CLIENTE_URL/_TOKEN (nor ARELA_API_URL/_TOKEN)`,
79
+ );
80
+ continue;
81
+ }
82
+ parsedFolders.push({ dir: resolvedDir, env, clienteUrl, clienteToken });
83
+ }
84
+
85
+ if (parsedFolders.length === 0) {
86
+ throw new Error('No usable .env files found in the given folders');
87
+ }
88
+
89
+ const serverId =
90
+ options.serverId ||
91
+ parsedFolders.find((f) => f.env.ARELA_SERVER_ID)?.env.ARELA_SERVER_ID ||
92
+ os.hostname();
93
+
94
+ // Group folders by tenant API URL → one profile per tenant
95
+ const profilesByUrl = new Map();
96
+ for (const folder of parsedFolders) {
97
+ const key = folder.clienteUrl.replace(/\/+$/, '').toLowerCase();
98
+ if (!profilesByUrl.has(key)) {
99
+ const profile = {
100
+ name: this.#deriveProfileName(folder, profilesByUrl),
101
+ url: folder.clienteUrl.replace(/\/+$/, ''),
102
+ token: folder.clienteToken,
103
+ };
104
+ const agenciaUrl = folder.env.ARELA_API_AGENCIA_URL;
105
+ if (
106
+ agenciaUrl &&
107
+ agenciaUrl.replace(/\/+$/, '').toLowerCase() !== key
108
+ ) {
109
+ profile.targets = {
110
+ agencia: {
111
+ baseUrl: agenciaUrl.replace(/\/+$/, ''),
112
+ token: folder.env.ARELA_API_AGENCIA_TOKEN || folder.clienteToken,
113
+ },
114
+ };
115
+ }
116
+ profilesByUrl.set(key, profile);
117
+ }
118
+ folder.profile = profilesByUrl.get(key);
119
+ }
120
+
121
+ const profilesJson = {
122
+ version: 1,
123
+ serverId,
124
+ pollIntervalMs: 5000,
125
+ logFile: DEFAULT_AGENT_LOG_PATH,
126
+ profiles: [...profilesByUrl.values()],
127
+ };
128
+
129
+ // One pipeline_config payload per folder×RFC
130
+ const seeds = [];
131
+ for (const folder of parsedFolders) {
132
+ const rfcs = this.#splitPipe(
133
+ folder.env.UPLOAD_RFCS || folder.env.PUSH_RFCS,
134
+ );
135
+ if (rfcs.length === 0) {
136
+ console.warn(
137
+ `⚠️ ${folder.dir}: no UPLOAD_RFCS/PUSH_RFCS — using placeholder RFC_PENDIENTE (edit before seeding)`,
138
+ );
139
+ rfcs.push('RFC_PENDIENTE');
140
+ }
141
+ for (const rfc of rfcs) {
142
+ seeds.push({
143
+ profile: folder.profile.name,
144
+ apiUrl: folder.profile.url,
145
+ sourceFolder: folder.dir,
146
+ config: this.#buildPipelineConfig(folder, rfc, serverId),
147
+ });
148
+ }
149
+ }
150
+
151
+ const seedsJsonPath = path.join(outDir, 'seed-configs.json');
152
+ const seedsShPath = path.join(outDir, 'seed-configs.sh');
153
+
154
+ fs.writeFileSync(profilesPath, JSON.stringify(profilesJson, null, 2));
155
+ try {
156
+ fs.chmodSync(profilesPath, 0o600);
157
+ } catch {
158
+ // chmod is best-effort on Windows
159
+ }
160
+ fs.writeFileSync(seedsJsonPath, JSON.stringify(seeds, null, 2));
161
+ fs.writeFileSync(seedsShPath, this.#buildSeedScript(seeds, profilesJson));
162
+ try {
163
+ fs.chmodSync(seedsShPath, 0o755);
164
+ } catch {
165
+ // chmod is best-effort on Windows
166
+ }
167
+
168
+ console.log(`\n✅ Generated from ${parsedFolders.length} folder(s):`);
169
+ console.log(
170
+ ` 📄 ${profilesPath} (${profilesJson.profiles.length} profile(s))`,
171
+ );
172
+ console.log(
173
+ ` 📄 ${seedsJsonPath} (${seeds.length} pipeline_config payload(s))`,
174
+ );
175
+ console.log(` 📄 ${seedsShPath}`);
176
+ console.log('\nNext steps:');
177
+ console.log(' 1. Review both files (RFCs, scan directories, slugs)');
178
+ console.log(` 2. Seed the server configs: bash ${seedsShPath}`);
179
+ console.log(' 3. Start the agent: arela agent');
180
+ console.log(
181
+ ' 4. Once stable, enable scheduling per config (scheduleEnabled) and the PIPELINE_SCHEDULER cronjob per tenant',
182
+ );
183
+ }
184
+
185
+ /**
186
+ * Profile name from the API hostname's first label (ktj.api… → 'ktj'),
187
+ * falling back to the folder basename; deduped with numeric suffixes.
188
+ * @private
189
+ */
190
+ #deriveProfileName(folder, profilesByUrl) {
191
+ let base;
192
+ try {
193
+ base = new URL(folder.clienteUrl).hostname.split('.')[0];
194
+ } catch {
195
+ base = path.basename(folder.dir);
196
+ }
197
+ base = base.toLowerCase();
198
+ const taken = new Set(
199
+ [...profilesByUrl.values()].map((profile) => profile.name),
200
+ );
201
+ let name = base;
202
+ let suffix = 2;
203
+ while (taken.has(name)) {
204
+ name = `${base}-${suffix++}`;
205
+ }
206
+ return name;
207
+ }
208
+
209
+ /**
210
+ * Build the POST /api/uploader/pipeline/config payload for one folder×RFC
211
+ * @private
212
+ */
213
+ #buildPipelineConfig(folder, rfc, serverId) {
214
+ const { env, dir } = folder;
215
+
216
+ // Folder naming convention 'ktj-cli-maya' → source slug 'maya'
217
+ const basenameParts = path.basename(dir).split('-');
218
+ const agenciaSlug =
219
+ basenameParts.length > 1
220
+ ? basenameParts[basenameParts.length - 1]
221
+ : env.ARELA_COMPANY_SLUG || path.basename(dir);
222
+
223
+ const basePath = env.UPLOAD_BASE_PATH;
224
+ const sources = this.#splitPipe(env.UPLOAD_SOURCES);
225
+ let scanDirectories = [];
226
+ if (basePath && basePath !== '*' && sources.length > 0) {
227
+ const trimmedBase = basePath.replace(/[\\/]+$/, '');
228
+ const separator = trimmedBase.includes('\\') ? '\\' : '/';
229
+ scanDirectories = sources.map(
230
+ (source) => `${trimmedBase}${separator}${source}`,
231
+ );
232
+ } else if (basePath && basePath !== '*') {
233
+ scanDirectories = [basePath];
234
+ } else if (sources.length > 0) {
235
+ scanDirectories = sources;
236
+ }
237
+
238
+ const extraOptions = {};
239
+ if (env.ARELA_COMPANY_SLUG) {
240
+ extraOptions.companySlug = env.ARELA_COMPANY_SLUG;
241
+ }
242
+ if (env.SCAN_DIRECTORY_LEVEL !== undefined) {
243
+ extraOptions.directoryLevel = parseInt(env.SCAN_DIRECTORY_LEVEL) || 0;
244
+ }
245
+ for (const key of TUNING_ENV_KEYS) {
246
+ if (env[key] !== undefined) {
247
+ extraOptions[key] = env[key];
248
+ }
249
+ }
250
+
251
+ const crossTenant = !!folder.profile.targets?.agencia;
252
+
253
+ return {
254
+ rfc,
255
+ agenciaSlug: agenciaSlug.toLowerCase(),
256
+ displayName: `${rfc} — ${agenciaSlug}`,
257
+ scanDirectories,
258
+ ...(env.UPLOAD_FILE_EXTENSIONS && {
259
+ fileExtensions: env.UPLOAD_FILE_EXTENSIONS.split(',')
260
+ .map((extension) => extension.trim())
261
+ .filter(Boolean),
262
+ }),
263
+ ...(env.PUSH_BUCKET && { pushBucket: env.PUSH_BUCKET }),
264
+ folderStructure: env.PUSH_FOLDER_STRUCTURE || 'pedimento',
265
+ serverId,
266
+ sourceApi: crossTenant ? 'agencia' : 'cliente',
267
+ targetApi: 'cliente',
268
+ active: true,
269
+ extraOptions,
270
+ // Enable after the agent runs stable (see README)
271
+ scheduleEnabled: false,
272
+ scheduleIntervalHours: 24,
273
+ scheduleStartTime: '02:00',
274
+ scheduleJobType: 'full',
275
+ };
276
+ }
277
+
278
+ /**
279
+ * Build the curl script that seeds every pipeline_config
280
+ * @private
281
+ */
282
+ #buildSeedScript(seeds, profilesJson) {
283
+ const tokenByProfile = Object.fromEntries(
284
+ profilesJson.profiles.map((profile) => [profile.name, profile.token]),
285
+ );
286
+
287
+ let script = '#!/usr/bin/env bash\n';
288
+ script += '# Generated by `arela agent init` — review before running.\n';
289
+ script += '# Seeds one pipeline_config row per legacy folder×RFC.\n';
290
+ script += 'set -euo pipefail\n\n';
291
+
292
+ for (const seed of seeds) {
293
+ script += `# ${seed.config.rfc} / ${seed.config.agenciaSlug} (from ${seed.sourceFolder})\n`;
294
+ script += `curl -sS -X POST '${seed.apiUrl}/api/uploader/pipeline/config' \\\n`;
295
+ script += ` -H 'x-api-key: ${tokenByProfile[seed.profile]}' \\\n`;
296
+ script += ` -H 'Content-Type: application/json' \\\n`;
297
+ script += ` --data '${JSON.stringify(seed.config).replace(/'/g, `'\\''`)}'\n`;
298
+ script += 'echo\n\n';
299
+ }
300
+
301
+ return script;
302
+ }
303
+
304
+ /**
305
+ * Split a pipe-separated env value into trimmed parts
306
+ * @private
307
+ */
308
+ #splitPipe(value) {
309
+ return (value || '')
310
+ .split('|')
311
+ .map((part) => part.trim())
312
+ .filter(Boolean);
313
+ }
314
+ }
315
+
316
+ export default new AgentInitCommand();
@@ -1,16 +1,15 @@
1
- import path from 'path';
2
-
3
1
  import logger from '../services/LoggingService.js';
4
2
  import { PipelineApiService } from '../services/PipelineApiService.js';
3
+ import { PipelineJobRunner } from '../services/PipelineJobRunner.js';
5
4
 
6
5
  import appConfig from '../config/config.js';
7
6
  import ErrorHandler from '../errors/ErrorHandler.js';
8
- import { PathNormalizer } from '../utils/PathNormalizer.js';
9
7
 
10
8
  /**
11
9
  * Poll Worker Command Handler
12
10
  * Runs arela-uploader as an HTTP-polling worker, fetching jobs from the API.
13
11
  * Alternative to BullMQ/Redis-based worker for environments without Redis access.
12
+ * Job execution lives in PipelineJobRunner (shared with `arela agent`).
14
13
  *
15
14
  * Usage:
16
15
  * arela worker --poll
@@ -27,6 +26,7 @@ export class PollWorkerCommand {
27
26
  this.isShuttingDown = false;
28
27
  this.currentJob = null;
29
28
  this.pipelineApi = null;
29
+ this.jobRunner = null;
30
30
  this.pollTimer = null;
31
31
  }
32
32
 
@@ -54,8 +54,9 @@ export class PollWorkerCommand {
54
54
 
55
55
  this.serverId = serverId;
56
56
 
57
- // Initialize Pipeline API service
57
+ // Initialize Pipeline API service and the shared job runner
58
58
  this.pipelineApi = new PipelineApiService(apiTarget);
59
+ this.jobRunner = new PipelineJobRunner(this.pipelineApi, serverId);
59
60
 
60
61
  console.log('\n🔧 Starting Arela Poll Worker');
61
62
  console.log(`📡 API Target: ${apiTarget}`);
@@ -94,7 +95,12 @@ export class PollWorkerCommand {
94
95
  if (job && job.id) {
95
96
  console.log(`📥 Got job: ${job.type} for RFC ${job.rfc}`);
96
97
  logger.info(`📥 Got job: ${job.id} - ${job.type} for RFC ${job.rfc}`);
97
- await this.#processJob(job);
98
+ this.currentJob = job;
99
+ try {
100
+ await this.jobRunner.processJob(job);
101
+ } finally {
102
+ this.currentJob = null;
103
+ }
98
104
  } else {
99
105
  // Only log to file, not console (too noisy)
100
106
  logger.debug('📭 No jobs available, waiting...');
@@ -111,323 +117,6 @@ export class PollWorkerCommand {
111
117
  }
112
118
  }
113
119
 
114
- /**
115
- * Process a job
116
- * @private
117
- */
118
- async #processJob(job) {
119
- this.currentJob = job;
120
-
121
- // Start heartbeat timer to keep worker alive during long-running jobs
122
- const heartbeatInterval = setInterval(async () => {
123
- try {
124
- await this.pipelineApi.sendHeartbeat(this.serverId, 'busy');
125
- logger.debug(`💓 Heartbeat sent for ${this.serverId}`);
126
- } catch {
127
- // Non-critical — progress updates also refresh heartbeat
128
- }
129
- }, 20000);
130
-
131
- try {
132
- logger.info(`🔄 Processing ${job.type} job ${job.id}`);
133
-
134
- // DEBUG: Delay to observe busy status in UI (remove in production)
135
- const debugDelay = parseInt(process.env.DEBUG_JOB_DELAY) || 0;
136
- if (debugDelay > 0) {
137
- console.log(
138
- `⏳ DEBUG: Waiting ${debugDelay / 1000}s before executing job...`,
139
- );
140
- await this.#sleep(debugDelay);
141
- }
142
-
143
- // Create progress callback that reports to API
144
- const onProgress = async (percent, message) => {
145
- await this.pipelineApi.updateProgress(
146
- job.id,
147
- percent,
148
- message,
149
- null,
150
- job.type,
151
- );
152
- };
153
-
154
- // Configure API targets for this job
155
- this.#configureApiTargets(job);
156
-
157
- // Execute the appropriate command
158
- let result;
159
- switch (job.type) {
160
- case 'scan':
161
- result = await this.#processScanJob(job, onProgress);
162
- break;
163
- case 'identify':
164
- result = await this.#processIdentifyJob(job, onProgress);
165
- break;
166
- case 'propagate':
167
- result = await this.#processPropagateJob(job, onProgress);
168
- break;
169
- case 'push':
170
- result = await this.#processPushJob(job, onProgress);
171
- break;
172
- case 'full':
173
- result = await this.#processFullPipeline(job, onProgress);
174
- break;
175
- default:
176
- throw new Error(`Unknown job type: ${job.type}`);
177
- }
178
-
179
- // Mark job as completed
180
- await this.pipelineApi.completeJob(job.id, result);
181
- logger.success(`✅ Job ${job.id} completed successfully`);
182
- } catch (error) {
183
- logger.error(`❌ Job ${job.id} failed: ${error.message}`);
184
- await this.pipelineApi.failJob(job.id, error.message);
185
- } finally {
186
- clearInterval(heartbeatInterval);
187
- this.currentJob = null;
188
- // Reset API configuration
189
- this.#resetApiConfig();
190
- }
191
- }
192
-
193
- /**
194
- * Configure API targets for cross-tenant operations
195
- * @private
196
- */
197
- #configureApiTargets(job) {
198
- if (job.sourceApi && job.targetApi) {
199
- appConfig.setCrossTenantTargets(job.sourceApi, job.targetApi);
200
- } else {
201
- appConfig.setApiTarget(job.sourceApi || 'agencia');
202
- }
203
- }
204
-
205
- /**
206
- * Reset API configuration after job
207
- * @private
208
- */
209
- #resetApiConfig() {
210
- // Clear cross-tenant mode
211
- appConfig.api.sourceTarget = null;
212
- appConfig.api.targetTarget = null;
213
- }
214
-
215
- /**
216
- * Override scan configuration from job data
217
- * @private
218
- */
219
- #overrideScanConfig(job) {
220
- if (job.scanConfig) {
221
- if (job.scanConfig.companySlug) {
222
- process.env.ARELA_COMPANY_SLUG = job.scanConfig.companySlug;
223
- }
224
- if (job.scanConfig.serverId) {
225
- process.env.ARELA_SERVER_ID = job.scanConfig.serverId;
226
- }
227
- if (job.scanConfig.basePath) {
228
- process.env.UPLOAD_BASE_PATH = job.scanConfig.basePath;
229
- process.env.ARELA_BASE_PATH_LABEL = job.scanConfig.basePath;
230
- }
231
- if (job.scanConfig.directoryLevel !== undefined) {
232
- process.env.SCAN_DIRECTORY_LEVEL = String(
233
- job.scanConfig.directoryLevel,
234
- );
235
- }
236
- }
237
-
238
- // Override scan directories if provided
239
- if (job.scanDirectories && job.scanDirectories.length > 0) {
240
- const allAbsolute = job.scanDirectories.every((d) =>
241
- PathNormalizer.isAbsolutePath(d),
242
- );
243
-
244
- if (allAbsolute) {
245
- const ancestor = this.#commonAncestor(job.scanDirectories);
246
- // Check if ancestor is meaningful (not just root or a drive letter)
247
- const isUseful =
248
- ancestor.length > 1 && !/^[a-zA-Z]:[/\\]?$/.test(ancestor);
249
-
250
- if (isUseful) {
251
- // Common ancestor found — set as base path, make sources relative
252
- process.env.UPLOAD_BASE_PATH = ancestor;
253
- process.env.ARELA_BASE_PATH_LABEL = ancestor;
254
- const relativeSources = job.scanDirectories.map(
255
- (d) => path.relative(ancestor, d) || '.',
256
- );
257
- process.env.UPLOAD_SOURCES = relativeSources.join('|');
258
- } else {
259
- // Cross-drive or no common ancestor — wildcard base, absolute sources
260
- process.env.UPLOAD_BASE_PATH = '*';
261
- process.env.ARELA_BASE_PATH_LABEL = '*';
262
- process.env.UPLOAD_SOURCES = job.scanDirectories.join('|');
263
- }
264
- } else {
265
- process.env.UPLOAD_SOURCES = job.scanDirectories.join('|');
266
- }
267
- }
268
-
269
- // Override file extensions if provided
270
- if (job.fileExtensions && job.fileExtensions.length > 0) {
271
- process.env.UPLOAD_FILE_EXTENSIONS = job.fileExtensions.join(',');
272
- }
273
-
274
- // Reload cached config from the updated env vars
275
- appConfig.reloadScanConfig();
276
- }
277
-
278
- /**
279
- * Compute the longest common ancestor directory of a list of absolute paths.
280
- * Uses '/' as separator (PathNormalizer normalizes Windows \\ to /).
281
- * @param {string[]} paths
282
- * @returns {string}
283
- */
284
- #commonAncestor(paths) {
285
- if (paths.length === 0) return '/';
286
- if (paths.length === 1) return paths[0];
287
-
288
- // Normalize separators so O:\exp\... becomes O:/exp/...
289
- const normalized = paths.map((p) => PathNormalizer.normalizeSeparators(p));
290
- const split = normalized.map((p) => p.split('/').filter(Boolean));
291
- const minLen = Math.min(...split.map((s) => s.length));
292
- const common = [];
293
-
294
- for (let i = 0; i < minLen; i++) {
295
- const seg = split[0][i];
296
- if (split.every((s) => s[i] === seg)) {
297
- common.push(seg);
298
- } else {
299
- break;
300
- }
301
- }
302
-
303
- // Preserve drive letter format (e.g., 'O:' → 'O:/')
304
- if (common.length > 0 && /^[a-zA-Z]:$/.test(common[0])) {
305
- return common[0] + '/' + common.slice(1).join('/');
306
- }
307
- return '/' + common.join('/');
308
- }
309
-
310
- /**
311
- * Process a scan job
312
- * @private
313
- */
314
- async #processScanJob(job, onProgress) {
315
- const { ScanCommand } = await import('./ScanCommand.js');
316
- const scanCommand = new ScanCommand();
317
-
318
- this.#overrideScanConfig(job);
319
-
320
- const options = {
321
- api: job.sourceApi || 'agencia',
322
- countFirst: false,
323
- stream: true,
324
- onProgress,
325
- };
326
-
327
- return scanCommand.execute(options);
328
- }
329
-
330
- /**
331
- * Process an identify job
332
- * @private
333
- */
334
- async #processIdentifyJob(job, onProgress) {
335
- const { IdentifyCommand } = await import('./IdentifyCommand.js');
336
- const identifyCommand = new IdentifyCommand();
337
-
338
- this.#overrideScanConfig(job);
339
-
340
- const options = {
341
- api: job.sourceApi || 'agencia',
342
- batchSize: 100,
343
- showStats: false,
344
- onProgress,
345
- ...(job.fileId && { fileId: job.fileId }),
346
- ...(job.table && { table: job.table }),
347
- };
348
-
349
- return identifyCommand.execute(options);
350
- }
351
-
352
- /**
353
- * Process a propagate job
354
- * @private
355
- */
356
- async #processPropagateJob(job, onProgress) {
357
- const { PropagateCommand } = await import('./PropagateCommand.js');
358
-
359
- this.#overrideScanConfig(job);
360
-
361
- const options = {
362
- api: job.sourceApi || 'agencia',
363
- batchSize: 50,
364
- showStats: false,
365
- onProgress,
366
- };
367
-
368
- const propagateCommand = new PropagateCommand(options);
369
- return propagateCommand.execute();
370
- }
371
-
372
- /**
373
- * Process a push job
374
- * @private
375
- */
376
- async #processPushJob(job, onProgress) {
377
- const { PushCommand } = await import('./PushCommand.js');
378
- const pushCommand = new PushCommand();
379
-
380
- this.#overrideScanConfig(job);
381
-
382
- const options = {
383
- scanApi: job.sourceApi || 'agencia',
384
- pushApi: job.targetApi || 'cliente',
385
- rfcs: [job.rfc],
386
- batchSize: 100,
387
- uploadBatchSize: 10,
388
- folderStructure: job.folderStructure || 'pedimento',
389
- autoOrganize: true,
390
- showStats: false,
391
- onProgress,
392
- };
393
-
394
- return pushCommand.execute(options);
395
- }
396
-
397
- /**
398
- * Process full pipeline (scan → identify → propagate → push)
399
- * @private
400
- */
401
- async #processFullPipeline(job, onProgress) {
402
- const results = {};
403
-
404
- // Step 1: Scan (0-25%)
405
- logger.info('📁 Step 1/4: Scanning files...');
406
- const wrappedProgress1 = (pct, msg) =>
407
- onProgress(pct * 0.25, `[Scan] ${msg}`);
408
- results.scan = await this.#processScanJob(job, wrappedProgress1);
409
-
410
- // Step 2: Identify (25-50%)
411
- logger.info('🔍 Step 2/4: Identifying documents...');
412
- const wrappedProgress2 = (pct, msg) =>
413
- onProgress(25 + pct * 0.25, `[Identify] ${msg}`);
414
- results.identify = await this.#processIdentifyJob(job, wrappedProgress2);
415
-
416
- // Step 3: Propagate (50-75%)
417
- logger.info('🔄 Step 3/4: Propagating paths...');
418
- const wrappedProgress3 = (pct, msg) =>
419
- onProgress(50 + pct * 0.25, `[Propagate] ${msg}`);
420
- results.propagate = await this.#processPropagateJob(job, wrappedProgress3);
421
-
422
- // Step 4: Push (75-100%)
423
- logger.info('📤 Step 4/4: Pushing to storage...');
424
- const wrappedProgress4 = (pct, msg) =>
425
- onProgress(75 + pct * 0.25, `[Push] ${msg}`);
426
- results.push = await this.#processPushJob(job, wrappedProgress4);
427
-
428
- return results;
429
- }
430
-
431
120
  /**
432
121
  * Sleep for specified milliseconds
433
122
  * @private