@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.
- package/.claude/worktrees/agent-multi-profile/.env.template +224 -0
- package/.claude/worktrees/agent-multi-profile/.prettierrc +13 -0
- package/.claude/worktrees/agent-multi-profile/README.md +405 -0
- package/.claude/worktrees/agent-multi-profile/package-lock.json +7096 -0
- package/.claude/worktrees/agent-multi-profile/package.json +78 -0
- package/.claude/worktrees/agent-multi-profile/scripts/cleanup-ds-store.js +109 -0
- package/.claude/worktrees/agent-multi-profile/scripts/cleanup-system-files.js +69 -0
- package/.claude/worktrees/agent-multi-profile/scripts/scoring-compare.js +243 -0
- package/.claude/worktrees/agent-multi-profile/scripts/scoring-phase4-check.js +96 -0
- package/.claude/worktrees/agent-multi-profile/scripts/tests/phase-7-features.test.js +415 -0
- package/.claude/worktrees/agent-multi-profile/scripts/tests/signal-handling.test.js +275 -0
- package/.claude/worktrees/agent-multi-profile/scripts/tests/smart-watch-integration.test.js +554 -0
- package/.claude/worktrees/agent-multi-profile/scripts/tests/watch-service-integration.test.js +584 -0
- package/.claude/worktrees/agent-multi-profile/src/commands/AgentCommand.js +229 -0
- package/.claude/worktrees/agent-multi-profile/src/commands/AgentInitCommand.js +316 -0
- package/.claude/worktrees/agent-multi-profile/src/commands/DatastageCommand.js +164 -0
- package/.claude/worktrees/agent-multi-profile/src/commands/GDriveSyncCommand.js +475 -0
- package/.claude/worktrees/agent-multi-profile/src/commands/IdentifyCommand.js +708 -0
- package/.claude/worktrees/agent-multi-profile/src/commands/PollWorkerCommand.js +169 -0
- package/.claude/worktrees/agent-multi-profile/src/commands/PropagateCommand.js +636 -0
- package/.claude/worktrees/agent-multi-profile/src/commands/PushCommand.js +743 -0
- package/.claude/worktrees/agent-multi-profile/src/commands/ScanCommand.js +722 -0
- package/.claude/worktrees/agent-multi-profile/src/commands/UploadCommand.js +587 -0
- package/.claude/worktrees/agent-multi-profile/src/commands/WatchCommand.js +1342 -0
- package/.claude/worktrees/agent-multi-profile/src/commands/WorkerCommand.js +337 -0
- package/.claude/worktrees/agent-multi-profile/src/config/config.js +862 -0
- package/.claude/worktrees/agent-multi-profile/src/document-type-shared.js +131 -0
- package/.claude/worktrees/agent-multi-profile/src/document-types/_pedimento-shared-extractors.js +348 -0
- package/.claude/worktrees/agent-multi-profile/src/document-types/doda-pdf.js +121 -0
- package/.claude/worktrees/agent-multi-profile/src/document-types/doda-xml.js +118 -0
- package/.claude/worktrees/agent-multi-profile/src/document-types/factura-inter-agencia.js +186 -0
- package/.claude/worktrees/agent-multi-profile/src/document-types/facturas-comerciales.js +233 -0
- package/.claude/worktrees/agent-multi-profile/src/document-types/pedimento-completo-xml.js +372 -0
- package/.claude/worktrees/agent-multi-profile/src/document-types/pedimento-completo.js +108 -0
- package/.claude/worktrees/agent-multi-profile/src/document-types/pedimento-simplificado.js +76 -0
- package/.claude/worktrees/agent-multi-profile/src/document-types/proforma.js +29 -0
- package/.claude/worktrees/agent-multi-profile/src/document-types/support-document.js +200 -0
- package/.claude/worktrees/agent-multi-profile/src/errors/ErrorHandler.js +278 -0
- package/.claude/worktrees/agent-multi-profile/src/errors/ErrorTypes.js +104 -0
- package/.claude/worktrees/agent-multi-profile/src/file-detection.js +338 -0
- package/.claude/worktrees/agent-multi-profile/src/index.js +890 -0
- package/.claude/worktrees/agent-multi-profile/src/scoring/db-matcher-adapter.js +98 -0
- package/.claude/worktrees/agent-multi-profile/src/scoring/matchers-seed.js +386 -0
- package/.claude/worktrees/agent-multi-profile/src/scoring/scoring-engine.js +251 -0
- package/.claude/worktrees/agent-multi-profile/src/services/AdvancedFilterService.js +505 -0
- package/.claude/worktrees/agent-multi-profile/src/services/AutoProcessingService.js +749 -0
- package/.claude/worktrees/agent-multi-profile/src/services/BenchmarkingService.js +381 -0
- package/.claude/worktrees/agent-multi-profile/src/services/DatabaseService.js +2173 -0
- package/.claude/worktrees/agent-multi-profile/src/services/DatastageApiService.js +240 -0
- package/.claude/worktrees/agent-multi-profile/src/services/ErrorMonitor.js +275 -0
- package/.claude/worktrees/agent-multi-profile/src/services/GoogleDriveService.js +217 -0
- package/.claude/worktrees/agent-multi-profile/src/services/LoggingService.js +649 -0
- package/.claude/worktrees/agent-multi-profile/src/services/MonitoringService.js +401 -0
- package/.claude/worktrees/agent-multi-profile/src/services/PerformanceOptimizer.js +511 -0
- package/.claude/worktrees/agent-multi-profile/src/services/PipelineApiService.js +274 -0
- package/.claude/worktrees/agent-multi-profile/src/services/PipelineJobRunner.js +389 -0
- package/.claude/worktrees/agent-multi-profile/src/services/ProfileManager.js +164 -0
- package/.claude/worktrees/agent-multi-profile/src/services/ReportingService.js +511 -0
- package/.claude/worktrees/agent-multi-profile/src/services/ScanApiService.js +775 -0
- package/.claude/worktrees/agent-multi-profile/src/services/SignalHandler.js +255 -0
- package/.claude/worktrees/agent-multi-profile/src/services/SmartWatchDatabaseService.js +527 -0
- package/.claude/worktrees/agent-multi-profile/src/services/WatchService.js +783 -0
- package/.claude/worktrees/agent-multi-profile/src/services/upload/ApiUploadService.js +676 -0
- package/.claude/worktrees/agent-multi-profile/src/services/upload/BaseUploadService.js +36 -0
- package/.claude/worktrees/agent-multi-profile/src/services/upload/MultiApiUploadService.js +233 -0
- package/.claude/worktrees/agent-multi-profile/src/services/upload/SupabaseUploadService.js +148 -0
- package/.claude/worktrees/agent-multi-profile/src/services/upload/UploadServiceFactory.js +100 -0
- package/.claude/worktrees/agent-multi-profile/src/utils/CleanupManager.js +262 -0
- package/.claude/worktrees/agent-multi-profile/src/utils/FileOperations.js +192 -0
- package/.claude/worktrees/agent-multi-profile/src/utils/FileSanitizer.js +99 -0
- package/.claude/worktrees/agent-multi-profile/src/utils/PathDetector.js +198 -0
- package/.claude/worktrees/agent-multi-profile/src/utils/PathNormalizer.js +274 -0
- package/.claude/worktrees/agent-multi-profile/src/utils/WatchEventHandler.js +522 -0
- package/.claude/worktrees/agent-multi-profile/supabase/migrations/001_create_initial_schema.sql +366 -0
- package/.claude/worktrees/agent-multi-profile/supabase/migrations/002_align_with_arela_api_schema.sql +145 -0
- package/.claude/worktrees/agent-multi-profile/tests/commands/IdentifyCommand.test.js +570 -0
- package/.claude/worktrees/agent-multi-profile/tests/commands/PropagateCommand.test.js +568 -0
- package/.claude/worktrees/agent-multi-profile/tests/commands/PushCommand.test.js +754 -0
- package/.claude/worktrees/agent-multi-profile/tests/commands/ScanCommand.test.js +382 -0
- package/.claude/worktrees/agent-multi-profile/tests/unit/PathAndTableNameGeneration.test.js +1211 -0
- package/.claude/worktrees/agent-multi-profile/tests/unit/factura-inter-agencia.test.js +218 -0
- package/.claude/worktrees/agent-multi-profile/tests/unit/pedimento-completo-xml-matcher.test.js +271 -0
- package/.claude/worktrees/agent-multi-profile/tests/unit/pedimento-simplificado-matcher.test.js +185 -0
- package/.claude/worktrees/agent-multi-profile/tests/unit/scoring-engine.test.js +221 -0
- package/README.md +65 -0
- package/package.json +1 -1
- package/src/commands/AgentCommand.js +210 -0
- package/src/commands/AgentInitCommand.js +316 -0
- package/src/commands/PollWorkerCommand.js +11 -322
- package/src/config/config.js +44 -6
- package/src/document-type-shared.js +1 -1
- package/src/document-types/pedimento-completo.js +8 -1
- package/src/file-detection.js +9 -0
- package/src/index.js +55 -0
- package/src/scoring/scoring-engine.js +1 -1
- package/src/services/LoggingService.js +35 -0
- package/src/services/PipelineApiService.js +7 -1
- package/src/services/PipelineJobRunner.js +389 -0
- 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;
|