@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,221 @@
1
+ /**
2
+ * Tests for the best-match scoring engine (PROTOTYPE).
3
+ *
4
+ * Two layers:
5
+ * 1. Engine mechanics (pure): extension filter, required/negative
6
+ * disqualification, weighted scoring, threshold, tie-breaking.
7
+ * 2. Faithfulness on representative documents: the seed matchers select the
8
+ * same document type best-match would, including the key
9
+ * `factura_inter_agencia` vs `factura_comercial` case resolved by SCORE
10
+ * (not registration order).
11
+ */
12
+ import { describe, it, expect } from '@jest/globals';
13
+
14
+ import {
15
+ classifyDocument,
16
+ scoreMatcher,
17
+ selectBestMatch,
18
+ } from '../../src/scoring/scoring-engine.js';
19
+ import { scoringMatchers } from '../../src/scoring/matchers-seed.js';
20
+
21
+ // ----------------------------- engine mechanics -----------------------------
22
+ describe('scoreMatcher (mechanics)', () => {
23
+ const base = {
24
+ documentType: 't',
25
+ extensions: ['pdf'],
26
+ minScore: 2,
27
+ clues: [
28
+ { kind: 'CONTENT_REGEX', pattern: /alpha/i, weight: 2 },
29
+ { kind: 'CONTENT_REGEX', pattern: /beta/i, weight: 1 },
30
+ ],
31
+ };
32
+
33
+ it('returns null when the extension does not apply', () => {
34
+ expect(scoreMatcher(base, { source: 'alpha', extension: 'xml' })).toBeNull();
35
+ });
36
+
37
+ it('sums the weights of matched clues', () => {
38
+ const r = scoreMatcher(base, { source: 'alpha beta', extension: 'pdf' });
39
+ expect(r.score).toBe(3);
40
+ expect(r.totalWeight).toBe(3);
41
+ expect(r.fraction).toBeCloseTo(1);
42
+ expect(r.passed).toBe(true);
43
+ });
44
+
45
+ it('does not pass below minScore', () => {
46
+ const r = scoreMatcher(base, { source: 'beta', extension: 'pdf' });
47
+ expect(r.score).toBe(1);
48
+ expect(r.passed).toBe(false);
49
+ });
50
+
51
+ it('disqualifies on a negative clue hit', () => {
52
+ const m = {
53
+ ...base,
54
+ clues: [
55
+ { kind: 'CONTENT_REGEX', pattern: /alpha/i, weight: 2 },
56
+ { kind: 'CONTENT_REGEX', pattern: /excluded/i, negative: true },
57
+ ],
58
+ };
59
+ const r = scoreMatcher(m, { source: 'alpha excluded', extension: 'pdf' });
60
+ expect(r.disqualified).toBe(true);
61
+ });
62
+
63
+ it('disqualifies when a required clue is missing', () => {
64
+ const m = {
65
+ ...base,
66
+ clues: [
67
+ { kind: 'CONTENT_REGEX', pattern: /alpha/i, weight: 2 },
68
+ { kind: 'CONTENT_REGEX', pattern: /mandatory/i, required: true, weight: 1 },
69
+ ],
70
+ };
71
+ const r = scoreMatcher(m, { source: 'alpha', extension: 'pdf' });
72
+ expect(r.disqualified).toBe(true);
73
+ });
74
+ });
75
+
76
+ describe('selectBestMatch (ordering)', () => {
77
+ const ctx = { source: 'alpha beta gamma', extension: 'pdf' };
78
+ const low = {
79
+ documentType: 'low',
80
+ extensions: ['pdf'],
81
+ minScore: 1,
82
+ clues: [{ kind: 'CONTENT_REGEX', pattern: /alpha/i, weight: 1 }],
83
+ };
84
+ const high = {
85
+ documentType: 'high',
86
+ extensions: ['pdf'],
87
+ minScore: 1,
88
+ clues: [
89
+ { kind: 'CONTENT_REGEX', pattern: /alpha/i, weight: 1 },
90
+ { kind: 'CONTENT_REGEX', pattern: /beta/i, weight: 1 },
91
+ { kind: 'CONTENT_REGEX', pattern: /gamma/i, weight: 1 },
92
+ ],
93
+ };
94
+
95
+ it('picks the highest score regardless of array order', () => {
96
+ expect(selectBestMatch([low, high], ctx).documentType).toBe('high');
97
+ expect(selectBestMatch([high, low], ctx).documentType).toBe('high');
98
+ });
99
+
100
+ it('breaks score ties by priority', () => {
101
+ const a = {
102
+ documentType: 'a',
103
+ extensions: ['pdf'],
104
+ minScore: 1,
105
+ priority: 0,
106
+ clues: [{ kind: 'CONTENT_REGEX', pattern: /alpha/i, weight: 2 }],
107
+ };
108
+ const b = { ...a, documentType: 'b', priority: 5 };
109
+ expect(selectBestMatch([a, b], ctx).documentType).toBe('b');
110
+ });
111
+ });
112
+
113
+ describe('qualify rules (group gate, OR-of-ANDs)', () => {
114
+ // mirrors factura_comercial: (cfdi>=2) OR (invoice>=1 AND customs>=1)
115
+ const m = {
116
+ documentType: 'q',
117
+ extensions: ['xml'],
118
+ qualify: [{ cfdi: 2 }, { invoice: 1, customs: 1 }],
119
+ clues: [
120
+ { kind: 'CONTENT_REGEX', pattern: /cfdiA/i, group: 'cfdi' },
121
+ { kind: 'CONTENT_REGEX', pattern: /cfdiB/i, group: 'cfdi' },
122
+ { kind: 'CONTENT_REGEX', pattern: /factura/i, group: 'invoice' },
123
+ { kind: 'CONTENT_REGEX', pattern: /aduana/i, group: 'customs' },
124
+ ],
125
+ };
126
+
127
+ it('qualifies when a single rule is fully satisfied (cfdi>=2)', () => {
128
+ expect(scoreMatcher(m, { source: 'cfdiA cfdiB', extension: 'xml' }).passed).toBe(true);
129
+ });
130
+
131
+ it('qualifies on the AND rule (invoice AND customs)', () => {
132
+ expect(scoreMatcher(m, { source: 'factura aduana', extension: 'xml' }).passed).toBe(true);
133
+ });
134
+
135
+ it('does NOT qualify on a partial AND rule (customs alone)', () => {
136
+ // This is the COVE-acuse false-positive case the flat model produced.
137
+ const r = scoreMatcher(m, { source: 'aduana pedimento aduana', extension: 'xml' });
138
+ expect(r.passed).toBe(false);
139
+ });
140
+
141
+ it('does NOT qualify with only one cfdi marker', () => {
142
+ expect(scoreMatcher(m, { source: 'cfdiA', extension: 'xml' }).passed).toBe(false);
143
+ });
144
+ });
145
+
146
+ // --------------------------- faithfulness on docs ---------------------------
147
+ const DOCS = {
148
+ simplificadoPaid: {
149
+ extension: 'pdf',
150
+ source: `FORMA SIMPLIFICADA DEL PEDIMENTO
151
+ NUM. PEDIMENTO: 26 07 3429 6000079
152
+ CVE. PEDIMENTO: A1
153
+ T. OPER: IMP
154
+ RFC: CSM9204097Q1
155
+ FECHA DE PAGO: 04/03/2026`,
156
+ },
157
+ simplificadoUnpaid: {
158
+ extension: 'pdf',
159
+ source: `FORMA SIMPLIFICADA DE PEDIMENTO
160
+ NUM. PEDIMENTO: 26 07 3429 6000080
161
+ CVE. PEDIMENTO: A1
162
+ T. OPER: IMP
163
+ RFC: CSM9204097Q1
164
+ *** NO PAGADO ***`,
165
+ },
166
+ completo: {
167
+ extension: 'pdf',
168
+ source: `NUM. PEDIMENTO: 26 07 3429 2002089
169
+ CVE. PEDIMENTO: A1
170
+ T. OPER: IMP
171
+ SEGUNDA COPIA TRANSPORTISTA
172
+ CUADRO DE LIQUIDACION
173
+ *** PAGO ELECTRONICO ***
174
+ FECHA DE PAGO: 02/03/2026`,
175
+ },
176
+ interAgencia: {
177
+ extension: 'xml',
178
+ source: `<cfdi:Comprobante xmlns:cfdi="..." TipoDeComprobante="I">
179
+ <cfdi:Emisor Rfc="NAA120215F20"/>
180
+ <cfdi:Receptor Rfc="PCC1008161WA"/>
181
+ <cfdi:Concepto ClaveProdServ="78141502"/>
182
+ </cfdi:Comprobante>`,
183
+ },
184
+ };
185
+
186
+ describe('seed matchers select the correct document type', () => {
187
+ it('classifies a paid simplificado', () => {
188
+ const r = classifyDocument(scoringMatchers, DOCS.simplificadoPaid);
189
+ expect(r.detectedType).toBe('pedimento_simplificado');
190
+ });
191
+
192
+ it('resolves an unpaid simplificado to proforma (resolveType reused)', () => {
193
+ const r = classifyDocument(scoringMatchers, DOCS.simplificadoUnpaid);
194
+ expect(r.detectedType).toBe('proforma');
195
+ });
196
+
197
+ it('separates completo from simplificado without registration order', () => {
198
+ const r = classifyDocument(scoringMatchers, DOCS.completo);
199
+ expect(r.detectedType).toBe('pedimento_completo');
200
+ });
201
+ });
202
+
203
+ describe('inter-agencia precedence is by score, not order', () => {
204
+ it('wins over factura_comercial with the default seed', () => {
205
+ const r = classifyDocument(scoringMatchers, DOCS.interAgencia);
206
+ expect(r.detectedType).toBe('factura_inter_agencia');
207
+ // factura_comercial also qualifies but scores far lower
208
+ const comercial = r.candidates.find(
209
+ (c) => c.documentType === 'factura_comercial',
210
+ );
211
+ const winner = r.candidates[0];
212
+ expect(winner.documentType).toBe('factura_inter_agencia');
213
+ if (comercial) expect(winner.score).toBeGreaterThan(comercial.score);
214
+ });
215
+
216
+ it('still wins when the matcher array is reversed (order-independent)', () => {
217
+ const reversed = [...scoringMatchers].reverse();
218
+ const r = classifyDocument(reversed, DOCS.interAgencia);
219
+ expect(r.detectedType).toBe('factura_inter_agencia');
220
+ });
221
+ });
package/README.md CHANGED
@@ -197,6 +197,71 @@ Display current configuration for all API targets and settings.
197
197
  arela config
198
198
  ```
199
199
 
200
+ ### `arela agent`
201
+
202
+ Multi-profile polling agent: ONE process (one terminal) serves every tenant API,
203
+ replacing the N `arela worker --poll` terminals (one per RFCƗsource folder with
204
+ its own `.env`). Scan directories, tuning and scheduling live **server-side** in
205
+ `pipeline_config`; the only local configuration is `profiles.json`.
206
+
207
+ ```bash
208
+ arela agent # uses ~/.arela/profiles.json
209
+ arela agent --config ./profiles.json # explicit config path
210
+ arela agent --profile ktj,cem # run a subset (debugging)
211
+ arela agent --interval 10000 # idle poll interval in ms
212
+ ```
213
+
214
+ `~/.arela/profiles.json` (keep it private — it contains API keys):
215
+
216
+ ```jsonc
217
+ {
218
+ "version": 1,
219
+ "serverId": "PALCO-01", // ONE id per machine; must match pipeline_config.serverId (or leave that null)
220
+ "pollIntervalMs": 5000, // optional
221
+ "logFile": "~/.arela/arela-agent.log", // optional
222
+ "profiles": [
223
+ { "name": "ktj", "url": "https://ktj-api.example.com", "token": "ak_..." },
224
+ {
225
+ "name": "cem", "url": "https://cem-api.example.com", "token": "ak_...",
226
+ // Only for real cross-tenant jobs (sourceApi != targetApi):
227
+ "targets": { "agencia": { "baseUrl": "https://agencia-api.example.com", "token": "ak_..." } }
228
+ }
229
+ ]
230
+ }
231
+ ```
232
+
233
+ Behavior notes:
234
+
235
+ - Jobs are processed **one at a time globally** (round-robin between profiles).
236
+ The config singleton and per-job env overrides make in-process parallelism
237
+ unsafe by design; if parallelism is ever needed, run child processes.
238
+ - An unreachable tenant API backs off exponentially (30s → 10min) without
239
+ blocking the other profiles.
240
+ - `Ctrl+C` waits for the in-flight job to finish before exiting, so jobs never
241
+ end up orphaned in RUNNING state.
242
+ - While a long job runs, the OTHER profiles' `worker_status` may show OFFLINE
243
+ (no poll for >30s). Cosmetic: their jobs stay QUEUED and are picked up next.
244
+ - Server-side setup per tenant: seed `pipeline_config` rows (one per RFCƗsource,
245
+ with `scanDirectories`, `extraOptions` tuning and schedule fields) and enable
246
+ the `PIPELINE_SCHEDULER` cronjob (`PUT /api/cronjobs`).
247
+
248
+ ### `arela agent init`
249
+
250
+ Migration helper: reads the legacy per-folder `.env` files and generates
251
+ `profiles.json` plus the `pipeline_config` seed payloads — locally, without
252
+ calling any API (tokens never leave the machine).
253
+
254
+ ```bash
255
+ arela agent init --from ../ktj-cli-maya ../ktj-cli-matro ../cem-cli-nas --server-id PALCO-01
256
+ # Generates in ~/.arela/: profiles.json, seed-configs.json, seed-configs.sh
257
+ ```
258
+
259
+ Folders pointing at the same tenant API collapse into one profile; each
260
+ folderƗRFC becomes a `pipeline_config` payload (`scheduleEnabled: false`
261
+ initially). Review the generated files, run `bash ~/.arela/seed-configs.sh` to
262
+ seed the server, then start `arela agent`. The old `.env` folders keep working
263
+ during the migration — `arela worker --poll` is untouched.
264
+
200
265
  ### Global Options
201
266
 
202
267
  | Flag | Description |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arela/uploader",
3
- "version": "1.1.3",
3
+ "version": "1.2.0",
4
4
  "description": "CLI to upload files/directories to Arela",
5
5
  "bin": {
6
6
  "arela": "./src/index.js"
@@ -0,0 +1,210 @@
1
+ import logger from '../services/LoggingService.js';
2
+ import { PipelineApiService } from '../services/PipelineApiService.js';
3
+ import { PipelineJobRunner } from '../services/PipelineJobRunner.js';
4
+ import {
5
+ DEFAULT_PROFILES_PATH,
6
+ ProfileManager,
7
+ } from '../services/ProfileManager.js';
8
+
9
+ import appConfig from '../config/config.js';
10
+
11
+ const BACKOFF_BASE_MS = 30_000;
12
+ const BACKOFF_MAX_MS = 600_000;
13
+ const SHUTDOWN_JOB_TIMEOUT_MS = 300_000;
14
+
15
+ /**
16
+ * Agent Command Handler
17
+ * Multi-profile polling agent: ONE process (one terminal) replaces the N
18
+ * `arela worker --poll` terminals (one per RFCƗsource folder with its own .env).
19
+ * Profiles (tenant API url + token) come from profiles.json; everything else
20
+ * (scan directories, tuning, schedule) lives server-side in pipeline_config.
21
+ *
22
+ * Jobs are processed ONE AT A TIME globally: appConfig is a singleton and the
23
+ * job runner mutates process.env per job, so running two jobs concurrently
24
+ * would corrupt each other's configuration. If parallelism is ever needed,
25
+ * spawn child processes — never threads/promises inside this process.
26
+ *
27
+ * Usage:
28
+ * arela agent
29
+ * arela agent --config ./profiles.json --profile ktj,cem
30
+ */
31
+ export class AgentCommand {
32
+ constructor() {
33
+ this.isShuttingDown = false;
34
+ this.activeJobPromise = null;
35
+ this.pollTimer = null;
36
+ }
37
+
38
+ /**
39
+ * Execute the agent command
40
+ * @param {Object} options - Command options
41
+ * @param {string} options.config - Path to profiles.json
42
+ * @param {string} options.interval - Base poll interval per round (ms)
43
+ * @param {string} options.profile - Comma-separated subset of profiles to run
44
+ */
45
+ async execute(options = {}) {
46
+ const agentConfig = ProfileManager.loadProfiles(
47
+ options.config || DEFAULT_PROFILES_PATH,
48
+ );
49
+
50
+ let profiles = agentConfig.profiles;
51
+ if (options.profile) {
52
+ const requested = options.profile
53
+ .split(',')
54
+ .map((name) => name.trim().toLowerCase())
55
+ .filter(Boolean);
56
+ const known = new Set(profiles.map((p) => p.name));
57
+ const unknown = requested.filter((name) => !known.has(name));
58
+ if (unknown.length > 0) {
59
+ throw new Error(
60
+ `Unknown profile(s): ${unknown.join(', ')}. Available: ${[...known].join(', ')}`,
61
+ );
62
+ }
63
+ profiles = profiles.filter((p) => requested.includes(p.name));
64
+ }
65
+
66
+ const interval =
67
+ parseInt(options.interval) > 0
68
+ ? parseInt(options.interval)
69
+ : agentConfig.pollIntervalMs;
70
+
71
+ logger.setLogFilePath(agentConfig.logFile);
72
+
73
+ logger.info('šŸ¤– Starting Arela Agent (multi-profile poll worker)');
74
+ logger.info(`šŸ–„ļø Server ID: ${agentConfig.serverId}`);
75
+ logger.info(`ā±ļø Poll Interval: ${interval}ms`);
76
+ logger.info(`šŸ“’ Log file: ${agentConfig.logFile}`);
77
+
78
+ // One API client + job runner per profile, built once. PipelineApiService
79
+ // copies baseUrl/token at construction, so each instance stays bound to its
80
+ // profile even while applyProfile() reconfigures the singleton for others.
81
+ for (const profile of profiles) {
82
+ appConfig.applyProfile(profile);
83
+ profile.api = new PipelineApiService(profile.name);
84
+ profile.runner = new PipelineJobRunner(profile.api, agentConfig.serverId);
85
+ profile.failures = 0;
86
+ profile.backoffUntil = 0;
87
+ logger.info(`šŸ“” Profile '${profile.name}' → ${profile.url}`);
88
+ }
89
+
90
+ this.#setupShutdownHandlers(profiles);
91
+
92
+ logger.success('Agent is running. Press Ctrl+C to stop.');
93
+
94
+ await this.#runLoop(profiles, agentConfig.serverId, interval);
95
+ }
96
+
97
+ /**
98
+ * Round-robin polling loop over all profiles
99
+ * @private
100
+ */
101
+ async #runLoop(profiles, serverId, interval) {
102
+ while (!this.isShuttingDown) {
103
+ let gotAnyJob = false;
104
+
105
+ for (const profile of profiles) {
106
+ if (this.isShuttingDown) break;
107
+ if (Date.now() < profile.backoffUntil) continue;
108
+
109
+ try {
110
+ // Reconfigure the singleton for THIS profile before polling/processing
111
+ appConfig.applyProfile(profile);
112
+ logger.setContext(profile.name);
113
+
114
+ const job = await profile.api.getNextJob(serverId, {
115
+ throwOnError: true,
116
+ });
117
+ profile.failures = 0;
118
+ profile.backoffUntil = 0;
119
+
120
+ if (job && job.id) {
121
+ gotAnyJob = true;
122
+ logger.info(
123
+ `šŸ“„ Got job: ${job.id} - ${job.type} for RFC ${job.rfc}`,
124
+ );
125
+ this.activeJobPromise = profile.runner.processJob(job);
126
+ await this.activeJobPromise;
127
+ this.activeJobPromise = null;
128
+ } else {
129
+ logger.debug('šŸ“­ No jobs available');
130
+ }
131
+ } catch (error) {
132
+ // A failing profile backs off exponentially but never kills the loop
133
+ profile.failures += 1;
134
+ const backoffMs = Math.min(
135
+ BACKOFF_BASE_MS * 2 ** (profile.failures - 1),
136
+ BACKOFF_MAX_MS,
137
+ );
138
+ profile.backoffUntil = Date.now() + backoffMs;
139
+ logger.error(
140
+ `Poll failed (attempt ${profile.failures}): ${error.message} — backing off ${Math.round(backoffMs / 1000)}s`,
141
+ );
142
+ } finally {
143
+ logger.setContext(null);
144
+ }
145
+ }
146
+
147
+ // One sleep per idle round (not one per profile)
148
+ if (!gotAnyJob && !this.isShuttingDown) {
149
+ await this.#sleep(interval);
150
+ }
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Sleep for specified milliseconds
156
+ * @private
157
+ */
158
+ #sleep(ms) {
159
+ return new Promise((resolve) => {
160
+ this.pollTimer = setTimeout(resolve, ms);
161
+ });
162
+ }
163
+
164
+ /**
165
+ * Setup graceful shutdown handlers
166
+ * @private
167
+ */
168
+ #setupShutdownHandlers(profiles) {
169
+ const shutdown = async (signal) => {
170
+ if (this.isShuttingDown) return;
171
+ this.isShuttingDown = true;
172
+
173
+ logger.setContext(null);
174
+ logger.info(`\nšŸ‘‹ Received ${signal}. Shutting down agent...`);
175
+
176
+ if (this.pollTimer) {
177
+ clearTimeout(this.pollTimer);
178
+ }
179
+
180
+ // Wait for the in-flight job so it ends COMPLETED/FAILED, never orphaned RUNNING
181
+ if (this.activeJobPromise) {
182
+ logger.info('ā³ Waiting for the current job to finish...');
183
+ await Promise.race([
184
+ this.activeJobPromise,
185
+ new Promise((resolve) =>
186
+ setTimeout(resolve, SHUTDOWN_JOB_TIMEOUT_MS),
187
+ ),
188
+ ]);
189
+ }
190
+
191
+ for (const profile of profiles) {
192
+ profile.api?.destroy();
193
+ }
194
+
195
+ logger.info('āœ… Agent stopped gracefully');
196
+ logger.flush();
197
+ process.exit(0);
198
+ };
199
+
200
+ // The global SIGINT/SIGTERM handlers (index.js and LoggingService) call
201
+ // process.exit(0) immediately, which would kill the in-flight job mid-write.
202
+ // Agent mode owns shutdown: replace them with the graceful version.
203
+ process.removeAllListeners('SIGINT');
204
+ process.removeAllListeners('SIGTERM');
205
+ process.on('SIGINT', () => shutdown('SIGINT'));
206
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
207
+ }
208
+ }
209
+
210
+ export default new AgentCommand();