@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,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;
@@ -0,0 +1,164 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+
5
+ export const DEFAULT_PROFILES_PATH = '~/.arela/profiles.json';
6
+ export const DEFAULT_AGENT_LOG_PATH = '~/.arela/arela-agent.log';
7
+ const DEFAULT_POLL_INTERVAL_MS = 5000;
8
+
9
+ /**
10
+ * Expand a leading '~' to the user's home directory
11
+ * @param {string} inputPath
12
+ * @returns {string}
13
+ */
14
+ export function expandHome(inputPath) {
15
+ if (inputPath && inputPath.startsWith('~')) {
16
+ return path.join(os.homedir(), inputPath.slice(1));
17
+ }
18
+ return inputPath;
19
+ }
20
+
21
+ /**
22
+ * Profile Manager
23
+ * Loads and validates the agent's profiles.json — the only local configuration
24
+ * the multi-profile agent needs: one machine serverId plus {name, url, token}
25
+ * per tenant API. Everything else (scan directories, tuning, schedule) lives
26
+ * server-side in pipeline_config.
27
+ *
28
+ * Minimal schema:
29
+ * {
30
+ * "version": 1,
31
+ * "serverId": "PALCO-01",
32
+ * "pollIntervalMs": 5000, // optional
33
+ * "logFile": "~/.arela/arela-agent.log", // optional
34
+ * "profiles": [
35
+ * { "name": "ktj", "url": "https://…", "token": "ak_…" },
36
+ * {
37
+ * "name": "cem", "url": "https://…", "token": "ak_…",
38
+ * "targets": { "agencia": { "baseUrl": "https://…", "token": "ak_…" } },
39
+ * "env": { "SCAN_DIRECTORY_LEVEL": "2" }
40
+ * }
41
+ * ]
42
+ * }
43
+ */
44
+ export class ProfileManager {
45
+ /**
46
+ * Load, validate and normalize the profiles file.
47
+ * @param {string} [configPath] - Path to profiles.json (supports '~')
48
+ * @returns {{serverId: string, pollIntervalMs: number, logFile: string, profiles: Object[]}}
49
+ */
50
+ static loadProfiles(configPath = DEFAULT_PROFILES_PATH) {
51
+ const resolvedPath = path.resolve(expandHome(configPath));
52
+
53
+ if (!fs.existsSync(resolvedPath)) {
54
+ throw new Error(
55
+ `Profiles file not found: ${resolvedPath}. ` +
56
+ `Run 'arela agent init --from <dir...>' to generate one from existing .env folders.`,
57
+ );
58
+ }
59
+
60
+ let raw;
61
+ try {
62
+ raw = JSON.parse(fs.readFileSync(resolvedPath, 'utf-8'));
63
+ } catch (error) {
64
+ throw new Error(`Invalid JSON in ${resolvedPath}: ${error.message}`);
65
+ }
66
+
67
+ if (!raw.serverId || typeof raw.serverId !== 'string') {
68
+ throw new Error(
69
+ `${resolvedPath}: 'serverId' is required at the root level (one per machine, e.g. "PALCO-01")`,
70
+ );
71
+ }
72
+
73
+ if (!Array.isArray(raw.profiles) || raw.profiles.length === 0) {
74
+ throw new Error(`${resolvedPath}: 'profiles' must be a non-empty array`);
75
+ }
76
+
77
+ const seenNames = new Set();
78
+ const profiles = [];
79
+
80
+ raw.profiles.forEach((profile, index) => {
81
+ const label = `Profile ${profile?.name ? `'${profile.name}'` : `#${index + 1}`}`;
82
+
83
+ if (!profile.name || typeof profile.name !== 'string') {
84
+ throw new Error(`Profile #${index + 1}: 'name' is required`);
85
+ }
86
+ const name = profile.name.toLowerCase();
87
+ if (seenNames.has(name)) {
88
+ throw new Error(`${label}: duplicate profile name`);
89
+ }
90
+ seenNames.add(name);
91
+
92
+ if (profile.enabled === false) return;
93
+
94
+ this.#validateEndpoint(label, profile.url, profile.token);
95
+
96
+ const targets = {
97
+ // Same tenant answers under every symbolic name jobs may reference
98
+ agencia: { baseUrl: profile.url, token: profile.token },
99
+ cliente: { baseUrl: profile.url, token: profile.token },
100
+ [name]: { baseUrl: profile.url, token: profile.token },
101
+ };
102
+
103
+ // Explicit targets override symbolic names (real cross-tenant cases)
104
+ for (const [targetName, targetConfig] of Object.entries(
105
+ profile.targets || {},
106
+ )) {
107
+ this.#validateEndpoint(
108
+ `${label}: targets.${targetName}`,
109
+ targetConfig?.baseUrl,
110
+ targetConfig?.token,
111
+ );
112
+ targets[targetName.toLowerCase()] = {
113
+ baseUrl: targetConfig.baseUrl,
114
+ token: targetConfig.token,
115
+ };
116
+ }
117
+
118
+ if (profile.env && typeof profile.env !== 'object') {
119
+ throw new Error(`${label}: 'env' must be an object of key/value pairs`);
120
+ }
121
+
122
+ profiles.push({
123
+ name,
124
+ url: profile.url,
125
+ token: profile.token,
126
+ serverId: raw.serverId,
127
+ targets,
128
+ env: profile.env || {},
129
+ });
130
+ });
131
+
132
+ if (profiles.length === 0) {
133
+ throw new Error(`${resolvedPath}: all profiles are disabled`);
134
+ }
135
+
136
+ return {
137
+ serverId: raw.serverId,
138
+ pollIntervalMs:
139
+ parseInt(raw.pollIntervalMs) > 0
140
+ ? parseInt(raw.pollIntervalMs)
141
+ : DEFAULT_POLL_INTERVAL_MS,
142
+ logFile: path.resolve(expandHome(raw.logFile || DEFAULT_AGENT_LOG_PATH)),
143
+ profiles,
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Validate a {url, token} pair with a per-field error message
149
+ * @private
150
+ */
151
+ static #validateEndpoint(label, url, token) {
152
+ if (!url || typeof url !== 'string') {
153
+ throw new Error(`${label}: 'url' is required`);
154
+ }
155
+ if (!/^https?:\/\//.test(url)) {
156
+ throw new Error(`${label}: 'url' must start with http:// or https://`);
157
+ }
158
+ if (!token || typeof token !== 'string') {
159
+ throw new Error(`${label}: 'token' is required`);
160
+ }
161
+ }
162
+ }
163
+
164
+ export default ProfileManager;