@arela/uploader 1.1.3 → 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 (99) 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/document-type-shared.js +1 -1
  92. package/src/document-types/pedimento-completo.js +8 -1
  93. package/src/file-detection.js +9 -0
  94. package/src/index.js +55 -0
  95. package/src/scoring/scoring-engine.js +1 -1
  96. package/src/services/LoggingService.js +35 -0
  97. package/src/services/PipelineApiService.js +7 -1
  98. package/src/services/PipelineJobRunner.js +389 -0
  99. package/src/services/ProfileManager.js +164 -0
@@ -0,0 +1,274 @@
1
+ import { Agent } from 'http';
2
+ import { Agent as HttpsAgent } from 'https';
3
+ import fetch from 'node-fetch';
4
+
5
+ import appConfig from '../config/config.js';
6
+ import logger from './LoggingService.js';
7
+
8
+ /**
9
+ * Pipeline API Service
10
+ * Handles HTTP communication with the API for pipeline job polling.
11
+ * Used by the poll worker mode as an alternative to Redis/BullMQ.
12
+ */
13
+ export class PipelineApiService {
14
+ /**
15
+ * @param {string|null} apiTarget - API target for polling: 'agencia' recommended
16
+ */
17
+ constructor(apiTarget = 'agencia') {
18
+ this.apiTarget = apiTarget;
19
+ const apiConfig = appConfig.getApiConfig(apiTarget);
20
+ this.baseUrl = apiConfig.baseUrl;
21
+ this.token = apiConfig.token;
22
+
23
+ // Get API connection settings
24
+ const maxApiConnections = parseInt(process.env.MAX_API_CONNECTIONS) || 5;
25
+ const connectionTimeout =
26
+ parseInt(process.env.API_CONNECTION_TIMEOUT) || 30000;
27
+
28
+ // Retry configuration
29
+ this.maxRetries = parseInt(process.env.API_MAX_RETRIES) || 3;
30
+ this.retryDelay = parseInt(process.env.API_RETRY_DELAY) || 1000;
31
+
32
+ // Initialize HTTP agents for connection pooling
33
+ this.httpAgent = new Agent({
34
+ keepAlive: true,
35
+ keepAliveMsecs: 30000,
36
+ maxSockets: maxApiConnections,
37
+ timeout: connectionTimeout,
38
+ });
39
+
40
+ this.httpsAgent = new HttpsAgent({
41
+ keepAlive: true,
42
+ keepAliveMsecs: 30000,
43
+ maxSockets: maxApiConnections,
44
+ timeout: connectionTimeout,
45
+ });
46
+
47
+ logger.debug(
48
+ `🔗 Pipeline API Service configured for ${apiTarget} → ${this.baseUrl}`,
49
+ );
50
+ }
51
+
52
+ /**
53
+ * Get the appropriate HTTP agent based on URL protocol
54
+ * @private
55
+ */
56
+ #getAgent(url) {
57
+ return url.startsWith('https://') ? this.httpsAgent : this.httpAgent;
58
+ }
59
+
60
+ /**
61
+ * Make an HTTP request with retry logic
62
+ * @private
63
+ */
64
+ async #request(method, endpoint, body = null, retries = 0) {
65
+ const url = `${this.baseUrl}${endpoint}`;
66
+
67
+ try {
68
+ const options = {
69
+ method,
70
+ headers: {
71
+ 'Content-Type': 'application/json',
72
+ 'x-api-key': this.token,
73
+ },
74
+ agent: this.#getAgent(url),
75
+ };
76
+
77
+ if (body) {
78
+ options.body = JSON.stringify(body);
79
+ }
80
+
81
+ const response = await fetch(url, options);
82
+
83
+ if (!response.ok) {
84
+ // Handle non-2xx responses
85
+ if (response.status === 404) {
86
+ return null; // Resource not found is not an error for polling
87
+ }
88
+
89
+ const errorText = await response.text();
90
+ throw new Error(
91
+ `HTTP ${response.status}: ${response.statusText} - ${errorText}`,
92
+ );
93
+ }
94
+
95
+ // Check if response has content
96
+ const contentType = response.headers.get('content-type');
97
+ if (contentType && contentType.includes('application/json')) {
98
+ return response.json();
99
+ }
100
+
101
+ return { success: true };
102
+ } catch (error) {
103
+ // Retry on network errors
104
+ if (retries < this.maxRetries && this.#isRetryableError(error)) {
105
+ logger.debug(
106
+ `⚠️ Retrying request (${retries + 1}/${this.maxRetries}): ${error.message}`,
107
+ );
108
+ await this.#sleep(this.retryDelay * (retries + 1));
109
+ return this.#request(method, endpoint, body, retries + 1);
110
+ }
111
+
112
+ throw error;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Check if error is retryable
118
+ * @private
119
+ */
120
+ #isRetryableError(error) {
121
+ const retryableCodes = [
122
+ 'ECONNRESET',
123
+ 'ETIMEDOUT',
124
+ 'ECONNREFUSED',
125
+ 'ENOTFOUND',
126
+ 'EAI_AGAIN',
127
+ ];
128
+ return (
129
+ retryableCodes.includes(error.code) ||
130
+ (error.message && error.message.includes('timeout'))
131
+ );
132
+ }
133
+
134
+ /**
135
+ * Sleep for specified milliseconds
136
+ * @private
137
+ */
138
+ #sleep(ms) {
139
+ return new Promise((resolve) => setTimeout(resolve, ms));
140
+ }
141
+
142
+ // =====================
143
+ // Public API Methods
144
+ // =====================
145
+
146
+ /**
147
+ * Get the next available job for this server
148
+ * @param {string} serverId - Server identifier
149
+ * @param {Object} [options]
150
+ * @param {boolean} [options.throwOnError=false] - Rethrow request failures
151
+ * instead of swallowing them (agent mode needs this to back off a dead API)
152
+ * @returns {Promise<Object|null>} Job data or null if no jobs available
153
+ */
154
+ async getNextJob(serverId, { throwOnError = false } = {}) {
155
+ try {
156
+ const result = await this.#request(
157
+ 'GET',
158
+ `/api/uploader/pipeline/worker/jobs/next?serverId=${encodeURIComponent(serverId)}`,
159
+ );
160
+ return result;
161
+ } catch (error) {
162
+ if (throwOnError) {
163
+ throw error;
164
+ }
165
+ logger.error(`❌ Failed to get next job: ${error.message}`);
166
+ return null;
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Update job progress
172
+ * @param {string} jobId - Job UUID
173
+ * @param {number} progress - Progress percentage (0-100)
174
+ * @param {string} message - Progress message
175
+ * @param {string} currentFile - Current file being processed
176
+ * @param {string} currentStep - Current pipeline step
177
+ * @returns {Promise<boolean>} Success status
178
+ */
179
+ async updateProgress(jobId, progress, message, currentFile, currentStep) {
180
+ try {
181
+ await this.#request(
182
+ 'PATCH',
183
+ `/api/uploader/pipeline/worker/jobs/${jobId}/progress`,
184
+ {
185
+ progress,
186
+ message,
187
+ currentFile,
188
+ currentStep,
189
+ },
190
+ );
191
+ return true;
192
+ } catch (error) {
193
+ logger.warn(`⚠️ Failed to update progress: ${error.message}`);
194
+ return false;
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Mark job as completed
200
+ * @param {string} jobId - Job UUID
201
+ * @param {Object} result - Result data
202
+ * @returns {Promise<boolean>} Success status
203
+ */
204
+ async completeJob(jobId, result = {}) {
205
+ try {
206
+ await this.#request(
207
+ 'PATCH',
208
+ `/api/uploader/pipeline/worker/jobs/${jobId}/complete`,
209
+ {
210
+ status: 'completed',
211
+ result,
212
+ },
213
+ );
214
+ logger.success(`✅ Job ${jobId} marked as completed`);
215
+ return true;
216
+ } catch (error) {
217
+ logger.error(`❌ Failed to complete job: ${error.message}`);
218
+ return false;
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Mark job as failed
224
+ * @param {string} jobId - Job UUID
225
+ * @param {string} errorMessage - Error description
226
+ * @returns {Promise<boolean>} Success status
227
+ */
228
+ async failJob(jobId, errorMessage) {
229
+ try {
230
+ await this.#request(
231
+ 'PATCH',
232
+ `/api/uploader/pipeline/worker/jobs/${jobId}/complete`,
233
+ {
234
+ status: 'failed',
235
+ error: errorMessage,
236
+ },
237
+ );
238
+ logger.error(`❌ Job ${jobId} marked as failed: ${errorMessage}`);
239
+ return true;
240
+ } catch (error) {
241
+ logger.error(`❌ Failed to mark job as failed: ${error.message}`);
242
+ return false;
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Send a heartbeat to keep the worker alive during long-running jobs
248
+ * @param {string} serverId - Server identifier
249
+ * @param {'online'|'busy'} status - Worker status
250
+ * @returns {Promise<boolean>} Success status
251
+ */
252
+ async sendHeartbeat(serverId, status = 'busy') {
253
+ try {
254
+ await this.#request('POST', '/api/uploader/pipeline/worker/heartbeat', {
255
+ serverId,
256
+ status,
257
+ });
258
+ return true;
259
+ } catch (error) {
260
+ logger.warn(`⚠️ Failed to send heartbeat: ${error.message}`);
261
+ return false;
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Close connections and cleanup
267
+ */
268
+ destroy() {
269
+ this.httpAgent.destroy();
270
+ this.httpsAgent.destroy();
271
+ }
272
+ }
273
+
274
+ export default PipelineApiService;
@@ -0,0 +1,389 @@
1
+ import path from 'path';
2
+
3
+ import appConfig from '../config/config.js';
4
+ import { PathNormalizer } from '../utils/PathNormalizer.js';
5
+ import logger from './LoggingService.js';
6
+
7
+ /**
8
+ * Tuning env vars that can be overridden per job via pipeline_config.extraOptions
9
+ * (delivered as job.scanConfig). Values not present in the job revert to whatever
10
+ * the process started with, so overrides never leak between jobs/profiles.
11
+ */
12
+ const TUNING_ENV_KEYS = [
13
+ 'SCAN_EXCLUDE_PATTERNS',
14
+ 'SCAN_BATCH_SIZE',
15
+ 'PUSH_BATCH_SIZE',
16
+ 'PUSH_UPLOAD_BATCH_SIZE',
17
+ 'MAX_CONCURRENT_SOURCES',
18
+ 'MAX_API_CONNECTIONS',
19
+ 'API_CONNECTION_TIMEOUT',
20
+ 'BATCH_DELAY',
21
+ 'BATCH_SIZE',
22
+ ];
23
+
24
+ /**
25
+ * Pipeline Job Runner
26
+ * Executes pipeline jobs (scan/identify/propagate/push/full) fetched from the API.
27
+ * Extracted from PollWorkerCommand so both `arela worker --poll` (single tenant)
28
+ * and `arela agent` (multi-profile) share the same job execution logic.
29
+ *
30
+ * NOT safe for concurrent jobs: it reconfigures the appConfig singleton and
31
+ * mutates process.env per job. Callers must run one job at a time per process.
32
+ */
33
+ export class PipelineJobRunner {
34
+ /**
35
+ * @param {import('./PipelineApiService.js').PipelineApiService} pipelineApi - API where progress/completion is reported
36
+ * @param {string} serverId - Server identifier used for heartbeats and scan registration
37
+ */
38
+ constructor(pipelineApi, serverId) {
39
+ this.pipelineApi = pipelineApi;
40
+ this.serverId = serverId;
41
+ this.currentJob = null;
42
+ // Baseline for tuning overrides: what the process started with (.env or nothing)
43
+ this.tuningEnvSnapshot = Object.fromEntries(
44
+ TUNING_ENV_KEYS.map((key) => [key, process.env[key]]),
45
+ );
46
+ }
47
+
48
+ /**
49
+ * Process a job end-to-end: heartbeats, progress, completion/failure reporting.
50
+ * @param {Object} job - Job payload from GET /worker/jobs/next
51
+ */
52
+ async processJob(job) {
53
+ this.currentJob = job;
54
+
55
+ // Start heartbeat timer to keep worker alive during long-running jobs
56
+ const heartbeatInterval = setInterval(async () => {
57
+ try {
58
+ await this.pipelineApi.sendHeartbeat(this.serverId, 'busy');
59
+ logger.debug(`💓 Heartbeat sent for ${this.serverId}`);
60
+ } catch {
61
+ // Non-critical — progress updates also refresh heartbeat
62
+ }
63
+ }, 20000);
64
+
65
+ try {
66
+ logger.info(`🔄 Processing ${job.type} job ${job.id}`);
67
+
68
+ // DEBUG: Delay to observe busy status in UI (remove in production)
69
+ const debugDelay = parseInt(process.env.DEBUG_JOB_DELAY) || 0;
70
+ if (debugDelay > 0) {
71
+ console.log(
72
+ `⏳ DEBUG: Waiting ${debugDelay / 1000}s before executing job...`,
73
+ );
74
+ await this.#sleep(debugDelay);
75
+ }
76
+
77
+ // Create progress callback that reports to API
78
+ const onProgress = async (percent, message) => {
79
+ await this.pipelineApi.updateProgress(
80
+ job.id,
81
+ percent,
82
+ message,
83
+ null,
84
+ job.type,
85
+ );
86
+ };
87
+
88
+ // Configure API targets for this job
89
+ this.#configureApiTargets(job);
90
+
91
+ // Execute the appropriate command
92
+ let result;
93
+ switch (job.type) {
94
+ case 'scan':
95
+ result = await this.#processScanJob(job, onProgress);
96
+ break;
97
+ case 'identify':
98
+ result = await this.#processIdentifyJob(job, onProgress);
99
+ break;
100
+ case 'propagate':
101
+ result = await this.#processPropagateJob(job, onProgress);
102
+ break;
103
+ case 'push':
104
+ result = await this.#processPushJob(job, onProgress);
105
+ break;
106
+ case 'full':
107
+ result = await this.#processFullPipeline(job, onProgress);
108
+ break;
109
+ default:
110
+ throw new Error(`Unknown job type: ${job.type}`);
111
+ }
112
+
113
+ // Mark job as completed
114
+ await this.pipelineApi.completeJob(job.id, result);
115
+ logger.success(`✅ Job ${job.id} completed successfully`);
116
+ } catch (error) {
117
+ logger.error(`❌ Job ${job.id} failed: ${error.message}`);
118
+ await this.pipelineApi.failJob(job.id, error.message);
119
+ } finally {
120
+ clearInterval(heartbeatInterval);
121
+ this.currentJob = null;
122
+ // Reset API configuration
123
+ this.#resetApiConfig();
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Configure API targets for cross-tenant operations
129
+ * @private
130
+ */
131
+ #configureApiTargets(job) {
132
+ if (job.sourceApi && job.targetApi) {
133
+ appConfig.setCrossTenantTargets(job.sourceApi, job.targetApi);
134
+ } else {
135
+ appConfig.setApiTarget(job.sourceApi || 'agencia');
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Reset API configuration after job
141
+ * @private
142
+ */
143
+ #resetApiConfig() {
144
+ // Clear cross-tenant mode
145
+ appConfig.api.sourceTarget = null;
146
+ appConfig.api.targetTarget = null;
147
+ }
148
+
149
+ /**
150
+ * Override scan configuration from job data
151
+ * @private
152
+ */
153
+ #overrideScanConfig(job) {
154
+ // The runner's serverId always wins: a stale serverId inside job.scanConfig
155
+ // must not leak into scan registration (or into other profiles' jobs).
156
+ process.env.ARELA_SERVER_ID = this.serverId;
157
+
158
+ if (job.scanConfig) {
159
+ if (job.scanConfig.companySlug) {
160
+ process.env.ARELA_COMPANY_SLUG = job.scanConfig.companySlug;
161
+ }
162
+ if (job.scanConfig.basePath) {
163
+ process.env.UPLOAD_BASE_PATH = job.scanConfig.basePath;
164
+ process.env.ARELA_BASE_PATH_LABEL = job.scanConfig.basePath;
165
+ }
166
+ if (job.scanConfig.directoryLevel !== undefined) {
167
+ process.env.SCAN_DIRECTORY_LEVEL = String(
168
+ job.scanConfig.directoryLevel,
169
+ );
170
+ }
171
+ }
172
+
173
+ // Per-job tuning from extraOptions; absent keys revert to the process baseline
174
+ for (const key of TUNING_ENV_KEYS) {
175
+ const value = job.scanConfig?.[key];
176
+ if (value !== undefined && value !== null) {
177
+ process.env[key] = String(value);
178
+ } else if (this.tuningEnvSnapshot[key] !== undefined) {
179
+ process.env[key] = this.tuningEnvSnapshot[key];
180
+ } else {
181
+ delete process.env[key];
182
+ }
183
+ }
184
+
185
+ // Override scan directories if provided
186
+ if (job.scanDirectories && job.scanDirectories.length > 0) {
187
+ const allAbsolute = job.scanDirectories.every((d) =>
188
+ PathNormalizer.isAbsolutePath(d),
189
+ );
190
+
191
+ if (allAbsolute) {
192
+ const ancestor = this.#commonAncestor(job.scanDirectories);
193
+ // Check if ancestor is meaningful (not just root or a drive letter)
194
+ const isUseful =
195
+ ancestor.length > 1 && !/^[a-zA-Z]:[/\\]?$/.test(ancestor);
196
+
197
+ if (isUseful) {
198
+ // Common ancestor found — set as base path, make sources relative
199
+ process.env.UPLOAD_BASE_PATH = ancestor;
200
+ process.env.ARELA_BASE_PATH_LABEL = ancestor;
201
+ const relativeSources = job.scanDirectories.map(
202
+ (d) => path.relative(ancestor, d) || '.',
203
+ );
204
+ process.env.UPLOAD_SOURCES = relativeSources.join('|');
205
+ } else {
206
+ // Cross-drive or no common ancestor — wildcard base, absolute sources
207
+ process.env.UPLOAD_BASE_PATH = '*';
208
+ process.env.ARELA_BASE_PATH_LABEL = '*';
209
+ process.env.UPLOAD_SOURCES = job.scanDirectories.join('|');
210
+ }
211
+ } else {
212
+ process.env.UPLOAD_SOURCES = job.scanDirectories.join('|');
213
+ }
214
+ }
215
+
216
+ // Override file extensions if provided
217
+ if (job.fileExtensions && job.fileExtensions.length > 0) {
218
+ process.env.UPLOAD_FILE_EXTENSIONS = job.fileExtensions.join(',');
219
+ }
220
+
221
+ // Reload cached config from the updated env vars
222
+ appConfig.reloadScanConfig();
223
+ }
224
+
225
+ /**
226
+ * Compute the longest common ancestor directory of a list of absolute paths.
227
+ * Uses '/' as separator (PathNormalizer normalizes Windows \\ to /).
228
+ * @param {string[]} paths
229
+ * @returns {string}
230
+ */
231
+ #commonAncestor(paths) {
232
+ if (paths.length === 0) return '/';
233
+ if (paths.length === 1) return paths[0];
234
+
235
+ // Normalize separators so O:\exp\... becomes O:/exp/...
236
+ const normalized = paths.map((p) => PathNormalizer.normalizeSeparators(p));
237
+ const split = normalized.map((p) => p.split('/').filter(Boolean));
238
+ const minLen = Math.min(...split.map((s) => s.length));
239
+ const common = [];
240
+
241
+ for (let i = 0; i < minLen; i++) {
242
+ const seg = split[0][i];
243
+ if (split.every((s) => s[i] === seg)) {
244
+ common.push(seg);
245
+ } else {
246
+ break;
247
+ }
248
+ }
249
+
250
+ // Preserve drive letter format (e.g., 'O:' → 'O:/')
251
+ if (common.length > 0 && /^[a-zA-Z]:$/.test(common[0])) {
252
+ return common[0] + '/' + common.slice(1).join('/');
253
+ }
254
+ return '/' + common.join('/');
255
+ }
256
+
257
+ /**
258
+ * Process a scan job
259
+ * @private
260
+ */
261
+ async #processScanJob(job, onProgress) {
262
+ const { ScanCommand } = await import('../commands/ScanCommand.js');
263
+ const scanCommand = new ScanCommand();
264
+
265
+ this.#overrideScanConfig(job);
266
+
267
+ const options = {
268
+ api: job.sourceApi || 'agencia',
269
+ countFirst: false,
270
+ stream: true,
271
+ onProgress,
272
+ };
273
+
274
+ return scanCommand.execute(options);
275
+ }
276
+
277
+ /**
278
+ * Process an identify job
279
+ * @private
280
+ */
281
+ async #processIdentifyJob(job, onProgress) {
282
+ const { IdentifyCommand } = await import('../commands/IdentifyCommand.js');
283
+ const identifyCommand = new IdentifyCommand();
284
+
285
+ this.#overrideScanConfig(job);
286
+
287
+ const options = {
288
+ api: job.sourceApi || 'agencia',
289
+ batchSize: 100,
290
+ showStats: false,
291
+ onProgress,
292
+ ...(job.fileId && { fileId: job.fileId }),
293
+ ...(job.table && { table: job.table }),
294
+ };
295
+
296
+ return identifyCommand.execute(options);
297
+ }
298
+
299
+ /**
300
+ * Process a propagate job
301
+ * @private
302
+ */
303
+ async #processPropagateJob(job, onProgress) {
304
+ const { PropagateCommand } = await import(
305
+ '../commands/PropagateCommand.js'
306
+ );
307
+
308
+ this.#overrideScanConfig(job);
309
+
310
+ const options = {
311
+ api: job.sourceApi || 'agencia',
312
+ batchSize: 50,
313
+ showStats: false,
314
+ onProgress,
315
+ };
316
+
317
+ const propagateCommand = new PropagateCommand(options);
318
+ return propagateCommand.execute();
319
+ }
320
+
321
+ /**
322
+ * Process a push job
323
+ * @private
324
+ */
325
+ async #processPushJob(job, onProgress) {
326
+ const { PushCommand } = await import('../commands/PushCommand.js');
327
+ const pushCommand = new PushCommand();
328
+
329
+ this.#overrideScanConfig(job);
330
+
331
+ const options = {
332
+ scanApi: job.sourceApi || 'agencia',
333
+ pushApi: job.targetApi || 'cliente',
334
+ rfcs: [job.rfc],
335
+ batchSize: 100,
336
+ uploadBatchSize: 10,
337
+ folderStructure: job.folderStructure || 'pedimento',
338
+ autoOrganize: true,
339
+ showStats: false,
340
+ onProgress,
341
+ };
342
+
343
+ return pushCommand.execute(options);
344
+ }
345
+
346
+ /**
347
+ * Process full pipeline (scan → identify → propagate → push)
348
+ * @private
349
+ */
350
+ async #processFullPipeline(job, onProgress) {
351
+ const results = {};
352
+
353
+ // Step 1: Scan (0-25%)
354
+ logger.info('📁 Step 1/4: Scanning files...');
355
+ const wrappedProgress1 = (pct, msg) =>
356
+ onProgress(pct * 0.25, `[Scan] ${msg}`);
357
+ results.scan = await this.#processScanJob(job, wrappedProgress1);
358
+
359
+ // Step 2: Identify (25-50%)
360
+ logger.info('🔍 Step 2/4: Identifying documents...');
361
+ const wrappedProgress2 = (pct, msg) =>
362
+ onProgress(25 + pct * 0.25, `[Identify] ${msg}`);
363
+ results.identify = await this.#processIdentifyJob(job, wrappedProgress2);
364
+
365
+ // Step 3: Propagate (50-75%)
366
+ logger.info('🔄 Step 3/4: Propagating paths...');
367
+ const wrappedProgress3 = (pct, msg) =>
368
+ onProgress(50 + pct * 0.25, `[Propagate] ${msg}`);
369
+ results.propagate = await this.#processPropagateJob(job, wrappedProgress3);
370
+
371
+ // Step 4: Push (75-100%)
372
+ logger.info('📤 Step 4/4: Pushing to storage...');
373
+ const wrappedProgress4 = (pct, msg) =>
374
+ onProgress(75 + pct * 0.25, `[Push] ${msg}`);
375
+ results.push = await this.#processPushJob(job, wrappedProgress4);
376
+
377
+ return results;
378
+ }
379
+
380
+ /**
381
+ * Sleep for specified milliseconds
382
+ * @private
383
+ */
384
+ #sleep(ms) {
385
+ return new Promise((resolve) => setTimeout(resolve, ms));
386
+ }
387
+ }
388
+
389
+ export default PipelineJobRunner;