@arela/uploader 1.1.4 → 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/index.js +55 -0
- 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
package/src/config/config.js
CHANGED
|
@@ -37,10 +37,10 @@ class Config {
|
|
|
37
37
|
const __dirname = path.dirname(__filename);
|
|
38
38
|
const packageJsonPath = path.resolve(__dirname, '../../package.json');
|
|
39
39
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
|
40
|
-
return packageJson.version || '1.
|
|
40
|
+
return packageJson.version || '1.2.0';
|
|
41
41
|
} catch (error) {
|
|
42
42
|
console.warn('⚠️ Could not read package.json version, using fallback');
|
|
43
|
-
return '1.
|
|
43
|
+
return '1.2.0';
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
@@ -126,7 +126,7 @@ class Config {
|
|
|
126
126
|
* @param {string} target - API target: 'default', 'agencia', or 'cliente'
|
|
127
127
|
*/
|
|
128
128
|
setApiTarget(target) {
|
|
129
|
-
const validTargets = ['default',
|
|
129
|
+
const validTargets = ['default', ...Object.keys(this.api.targets)];
|
|
130
130
|
if (!validTargets.includes(target.toLowerCase())) {
|
|
131
131
|
throw new Error(
|
|
132
132
|
`Invalid API target '${target}'. Must be one of: ${validTargets.join(', ')}`,
|
|
@@ -143,7 +143,7 @@ class Config {
|
|
|
143
143
|
* @param {string} targetTarget - Target API target (for writing data)
|
|
144
144
|
*/
|
|
145
145
|
setCrossTenantTargets(sourceTarget, targetTarget) {
|
|
146
|
-
const validTargets = ['default',
|
|
146
|
+
const validTargets = ['default', ...Object.keys(this.api.targets)];
|
|
147
147
|
|
|
148
148
|
if (!validTargets.includes(sourceTarget.toLowerCase())) {
|
|
149
149
|
throw new Error(
|
|
@@ -171,6 +171,41 @@ class Config {
|
|
|
171
171
|
);
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Upsert an API target in memory (does not touch process.env).
|
|
176
|
+
* Used by agent mode to register per-profile targets at runtime.
|
|
177
|
+
* @param {string} name - Target name, e.g. 'cliente', 'agencia'
|
|
178
|
+
* @param {{baseUrl: string, token: string}} targetConfig
|
|
179
|
+
*/
|
|
180
|
+
setTargetConfig(name, { baseUrl, token }) {
|
|
181
|
+
this.api.targets[name.toLowerCase()] = { baseUrl, token };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Apply an agent profile: API targets, server id and env overrides.
|
|
186
|
+
* Idempotent — the agent calls it before every poll/job of the profile so the
|
|
187
|
+
* singleton always reflects the ACTIVE profile (jobs reference targets by
|
|
188
|
+
* symbolic name: 'agencia', 'cliente').
|
|
189
|
+
* @param {Object} profile - Normalized profile from ProfileManager
|
|
190
|
+
*/
|
|
191
|
+
applyProfile(profile) {
|
|
192
|
+
this.api.baseUrl = profile.url;
|
|
193
|
+
this.api.token = profile.token;
|
|
194
|
+
for (const [name, targetConfig] of Object.entries(profile.targets)) {
|
|
195
|
+
this.setTargetConfig(name, targetConfig);
|
|
196
|
+
}
|
|
197
|
+
if (profile.serverId) {
|
|
198
|
+
process.env.ARELA_SERVER_ID = profile.serverId;
|
|
199
|
+
}
|
|
200
|
+
for (const [key, value] of Object.entries(profile.env || {})) {
|
|
201
|
+
process.env[key] = String(value);
|
|
202
|
+
}
|
|
203
|
+
// Clear cross-tenant state left over from a previous profile's job
|
|
204
|
+
this.api.sourceTarget = null;
|
|
205
|
+
this.api.targetTarget = null;
|
|
206
|
+
this.api.activeTarget = 'default';
|
|
207
|
+
}
|
|
208
|
+
|
|
174
209
|
/**
|
|
175
210
|
* Get source API config for cross-tenant operations
|
|
176
211
|
* @returns {Object} Source API configuration
|
|
@@ -780,12 +815,15 @@ class Config {
|
|
|
780
815
|
}
|
|
781
816
|
|
|
782
817
|
/**
|
|
783
|
-
* Reload upload and
|
|
784
|
-
* Must be called after modifying env vars at runtime (e.g.,
|
|
818
|
+
* Reload upload, scan, push and performance config from current process.env
|
|
819
|
+
* values. Must be called after modifying env vars at runtime (e.g.,
|
|
820
|
+
* PipelineJobRunner applying a job's scanConfig/tuning overrides).
|
|
785
821
|
*/
|
|
786
822
|
reloadScanConfig() {
|
|
787
823
|
this.upload = this.#loadUploadConfig();
|
|
788
824
|
this.scan = this.#loadScanConfig();
|
|
825
|
+
this.push = this.#loadPushConfig();
|
|
826
|
+
this.performance = this.#loadPerformanceConfig();
|
|
789
827
|
}
|
|
790
828
|
|
|
791
829
|
/**
|
package/src/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
|
|
4
|
+
import agentCommand from './commands/AgentCommand.js';
|
|
5
|
+
import agentInitCommand from './commands/AgentInitCommand.js';
|
|
4
6
|
import datastageCommand from './commands/DatastageCommand.js';
|
|
5
7
|
import gdriveSyncCommand from './commands/GDriveSyncCommand.js';
|
|
6
8
|
import identifyCommand from './commands/IdentifyCommand.js';
|
|
@@ -646,6 +648,59 @@ class ArelaUploaderCLI {
|
|
|
646
648
|
}
|
|
647
649
|
});
|
|
648
650
|
|
|
651
|
+
// ============================================================================
|
|
652
|
+
// AGENT MODE - one process polling N tenant APIs (replaces N worker terminals)
|
|
653
|
+
// ============================================================================
|
|
654
|
+
|
|
655
|
+
const agent = this.program
|
|
656
|
+
.command('agent')
|
|
657
|
+
.description(
|
|
658
|
+
'🤖 Multi-profile polling agent: one process for all tenant APIs (profiles.json)',
|
|
659
|
+
);
|
|
660
|
+
|
|
661
|
+
agent
|
|
662
|
+
.command('run', { isDefault: true })
|
|
663
|
+
.description('Run the agent (default: `arela agent` runs this)')
|
|
664
|
+
.option(
|
|
665
|
+
'--config <path>',
|
|
666
|
+
'Path to profiles.json (default: ~/.arela/profiles.json)',
|
|
667
|
+
)
|
|
668
|
+
.option('--interval <ms>', 'Base poll interval per idle round')
|
|
669
|
+
.option(
|
|
670
|
+
'--profile <names>',
|
|
671
|
+
'Comma-separated subset of profiles to run (debugging)',
|
|
672
|
+
)
|
|
673
|
+
.action(async (options) => {
|
|
674
|
+
try {
|
|
675
|
+
await agentCommand.execute(options);
|
|
676
|
+
} catch (error) {
|
|
677
|
+
this.errorHandler.handleFatalError(error, { command: 'agent' });
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
agent
|
|
682
|
+
.command('init')
|
|
683
|
+
.description(
|
|
684
|
+
'Generate profiles.json + pipeline_config seeds from legacy per-folder .env files',
|
|
685
|
+
)
|
|
686
|
+
.requiredOption(
|
|
687
|
+
'--from <dirs...>',
|
|
688
|
+
'Folders containing the legacy .env files (one per RFC×source)',
|
|
689
|
+
)
|
|
690
|
+
.option(
|
|
691
|
+
'--server-id <id>',
|
|
692
|
+
'Machine server id (default: ARELA_SERVER_ID from first .env, else hostname)',
|
|
693
|
+
)
|
|
694
|
+
.option('--out <dir>', 'Output directory', '~/.arela')
|
|
695
|
+
.option('--force', 'Overwrite an existing profiles.json')
|
|
696
|
+
.action(async (options) => {
|
|
697
|
+
try {
|
|
698
|
+
await agentInitCommand.execute(options.from, options);
|
|
699
|
+
} catch (error) {
|
|
700
|
+
this.errorHandler.handleFatalError(error, { command: 'agent init' });
|
|
701
|
+
}
|
|
702
|
+
});
|
|
703
|
+
|
|
649
704
|
// Version command (already handled by program.version())
|
|
650
705
|
|
|
651
706
|
// Help command
|
|
@@ -17,6 +17,7 @@ export class LoggingService {
|
|
|
17
17
|
this.bufferSize = appConfig.performance.logBufferSize;
|
|
18
18
|
this.flushInterval = appConfig.performance.logFlushInterval;
|
|
19
19
|
this.lastFlushTime = Date.now();
|
|
20
|
+
this.context = null;
|
|
20
21
|
|
|
21
22
|
// Event tracking (new in Fase 5)
|
|
22
23
|
this.uploadEvents = [];
|
|
@@ -50,6 +51,15 @@ export class LoggingService {
|
|
|
50
51
|
});
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Prefix a message with the active context (agent profile name), so both the
|
|
56
|
+
* console echo and the file entry stay grep-able per profile
|
|
57
|
+
* @private
|
|
58
|
+
*/
|
|
59
|
+
#withContext(message) {
|
|
60
|
+
return this.context ? `[${this.context}] ${message}` : message;
|
|
61
|
+
}
|
|
62
|
+
|
|
53
63
|
/**
|
|
54
64
|
* Write a log message
|
|
55
65
|
* @param {string} message - Log message
|
|
@@ -80,6 +90,7 @@ export class LoggingService {
|
|
|
80
90
|
* @param {string} message - Message to log
|
|
81
91
|
*/
|
|
82
92
|
info(message) {
|
|
93
|
+
message = this.#withContext(message);
|
|
83
94
|
this.writeLog(message, 'info');
|
|
84
95
|
// Echo to console: run banners, stats and the 🧩 observability lines were
|
|
85
96
|
// file-only, so operators watching the terminal never saw them (the v1
|
|
@@ -92,6 +103,7 @@ export class LoggingService {
|
|
|
92
103
|
* @param {string} message - Message to log
|
|
93
104
|
*/
|
|
94
105
|
warn(message) {
|
|
106
|
+
message = this.#withContext(message);
|
|
95
107
|
this.writeLog(message, 'warn');
|
|
96
108
|
console.warn(`⚠️ ${message}`);
|
|
97
109
|
}
|
|
@@ -101,6 +113,7 @@ export class LoggingService {
|
|
|
101
113
|
* @param {string} message - Message to log
|
|
102
114
|
*/
|
|
103
115
|
error(message) {
|
|
116
|
+
message = this.#withContext(message);
|
|
104
117
|
this.writeLog(message, 'error');
|
|
105
118
|
console.error(`❌ ${message}`);
|
|
106
119
|
}
|
|
@@ -111,6 +124,7 @@ export class LoggingService {
|
|
|
111
124
|
*/
|
|
112
125
|
debug(message) {
|
|
113
126
|
if (this.isVerbose) {
|
|
127
|
+
message = this.#withContext(message);
|
|
114
128
|
this.writeLog(message, 'debug');
|
|
115
129
|
console.log(`🔍 ${message}`);
|
|
116
130
|
}
|
|
@@ -122,6 +136,7 @@ export class LoggingService {
|
|
|
122
136
|
*/
|
|
123
137
|
verbose(message) {
|
|
124
138
|
if (this.isVerbose) {
|
|
139
|
+
message = this.#withContext(message);
|
|
125
140
|
this.writeLog(message, 'verbose');
|
|
126
141
|
console.log(message);
|
|
127
142
|
}
|
|
@@ -132,6 +147,7 @@ export class LoggingService {
|
|
|
132
147
|
* @param {string} message - Message to log
|
|
133
148
|
*/
|
|
134
149
|
success(message) {
|
|
150
|
+
message = this.#withContext(message);
|
|
135
151
|
this.writeLog(message, 'success');
|
|
136
152
|
console.log(`✅ ${message}`);
|
|
137
153
|
}
|
|
@@ -173,6 +189,25 @@ export class LoggingService {
|
|
|
173
189
|
return this.logFilePath;
|
|
174
190
|
}
|
|
175
191
|
|
|
192
|
+
/**
|
|
193
|
+
* Redirect logging to a different file (agent mode uses ~/.arela/arela-agent.log
|
|
194
|
+
* instead of the cwd-bound arela-upload.log). Flushes pending entries first.
|
|
195
|
+
* @param {string} filePath - Absolute path to the new log file
|
|
196
|
+
*/
|
|
197
|
+
setLogFilePath(filePath) {
|
|
198
|
+
this.flush();
|
|
199
|
+
this.logFilePath = filePath;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Set a context prefix added to every file log entry (e.g. the active agent
|
|
204
|
+
* profile name, so one shared log stays grep-able per profile).
|
|
205
|
+
* @param {string|null} prefix - Context name, or null to clear
|
|
206
|
+
*/
|
|
207
|
+
setContext(prefix) {
|
|
208
|
+
this.context = prefix || null;
|
|
209
|
+
}
|
|
210
|
+
|
|
176
211
|
/**
|
|
177
212
|
* Check if verbose logging is enabled
|
|
178
213
|
* @returns {boolean} True if verbose logging is enabled
|
|
@@ -146,9 +146,12 @@ export class PipelineApiService {
|
|
|
146
146
|
/**
|
|
147
147
|
* Get the next available job for this server
|
|
148
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)
|
|
149
152
|
* @returns {Promise<Object|null>} Job data or null if no jobs available
|
|
150
153
|
*/
|
|
151
|
-
async getNextJob(serverId) {
|
|
154
|
+
async getNextJob(serverId, { throwOnError = false } = {}) {
|
|
152
155
|
try {
|
|
153
156
|
const result = await this.#request(
|
|
154
157
|
'GET',
|
|
@@ -156,6 +159,9 @@ export class PipelineApiService {
|
|
|
156
159
|
);
|
|
157
160
|
return result;
|
|
158
161
|
} catch (error) {
|
|
162
|
+
if (throwOnError) {
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
159
165
|
logger.error(`❌ Failed to get next job: ${error.message}`);
|
|
160
166
|
return null;
|
|
161
167
|
}
|
|
@@ -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;
|