@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,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
|
@@ -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();
|