@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
|
@@ -0,0 +1,229 @@
|
|
|
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
|
+
let round = 0;
|
|
103
|
+
|
|
104
|
+
while (!this.isShuttingDown) {
|
|
105
|
+
let gotAnyJob = false;
|
|
106
|
+
let polled = 0;
|
|
107
|
+
let jobs = 0;
|
|
108
|
+
round += 1;
|
|
109
|
+
|
|
110
|
+
for (const profile of profiles) {
|
|
111
|
+
if (this.isShuttingDown) break;
|
|
112
|
+
if (Date.now() < profile.backoffUntil) continue;
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
// Reconfigure the singleton for THIS profile before polling/processing
|
|
116
|
+
appConfig.applyProfile(profile);
|
|
117
|
+
logger.setContext(profile.name);
|
|
118
|
+
|
|
119
|
+
const job = await profile.api.getNextJob(serverId, {
|
|
120
|
+
throwOnError: true,
|
|
121
|
+
});
|
|
122
|
+
profile.failures = 0;
|
|
123
|
+
profile.backoffUntil = 0;
|
|
124
|
+
polled += 1;
|
|
125
|
+
|
|
126
|
+
if (job && job.id) {
|
|
127
|
+
gotAnyJob = true;
|
|
128
|
+
jobs += 1;
|
|
129
|
+
logger.info(
|
|
130
|
+
`📥 Got job: ${job.id} - ${job.type} for RFC ${job.rfc}`,
|
|
131
|
+
);
|
|
132
|
+
this.activeJobPromise = profile.runner.processJob(job);
|
|
133
|
+
await this.activeJobPromise;
|
|
134
|
+
this.activeJobPromise = null;
|
|
135
|
+
} else {
|
|
136
|
+
logger.debug('📭 No jobs available');
|
|
137
|
+
}
|
|
138
|
+
} catch (error) {
|
|
139
|
+
// A failing profile backs off exponentially but never kills the loop
|
|
140
|
+
profile.failures += 1;
|
|
141
|
+
const backoffMs = Math.min(
|
|
142
|
+
BACKOFF_BASE_MS * 2 ** (profile.failures - 1),
|
|
143
|
+
BACKOFF_MAX_MS,
|
|
144
|
+
);
|
|
145
|
+
profile.backoffUntil = Date.now() + backoffMs;
|
|
146
|
+
logger.error(
|
|
147
|
+
`Poll failed (attempt ${profile.failures}): ${error.message} — backing off ${Math.round(backoffMs / 1000)}s`,
|
|
148
|
+
);
|
|
149
|
+
} finally {
|
|
150
|
+
logger.setContext(null);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Heartbeat line: first round always, then every ~5 min of idle rounds,
|
|
155
|
+
// so a quiet log is distinguishable from a stalled one
|
|
156
|
+
const summaryEvery = Math.max(1, Math.round(300_000 / interval));
|
|
157
|
+
if (round === 1 || jobs > 0 || round % summaryEvery === 0) {
|
|
158
|
+
const inBackoff = profiles.filter(
|
|
159
|
+
(p) => Date.now() < p.backoffUntil,
|
|
160
|
+
).length;
|
|
161
|
+
logger.info(
|
|
162
|
+
`🔁 Round ${round}: polled ${polled}/${profiles.length} profiles, ${jobs} job(s), ${inBackoff} in backoff`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// One sleep per idle round (not one per profile)
|
|
167
|
+
if (!gotAnyJob && !this.isShuttingDown) {
|
|
168
|
+
await this.#sleep(interval);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Sleep for specified milliseconds
|
|
175
|
+
* @private
|
|
176
|
+
*/
|
|
177
|
+
#sleep(ms) {
|
|
178
|
+
return new Promise((resolve) => {
|
|
179
|
+
this.pollTimer = setTimeout(resolve, ms);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Setup graceful shutdown handlers
|
|
185
|
+
* @private
|
|
186
|
+
*/
|
|
187
|
+
#setupShutdownHandlers(profiles) {
|
|
188
|
+
const shutdown = async (signal) => {
|
|
189
|
+
if (this.isShuttingDown) return;
|
|
190
|
+
this.isShuttingDown = true;
|
|
191
|
+
|
|
192
|
+
logger.setContext(null);
|
|
193
|
+
logger.info(`\n👋 Received ${signal}. Shutting down agent...`);
|
|
194
|
+
|
|
195
|
+
if (this.pollTimer) {
|
|
196
|
+
clearTimeout(this.pollTimer);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Wait for the in-flight job so it ends COMPLETED/FAILED, never orphaned RUNNING
|
|
200
|
+
if (this.activeJobPromise) {
|
|
201
|
+
logger.info('⏳ Waiting for the current job to finish...');
|
|
202
|
+
await Promise.race([
|
|
203
|
+
this.activeJobPromise,
|
|
204
|
+
new Promise((resolve) =>
|
|
205
|
+
setTimeout(resolve, SHUTDOWN_JOB_TIMEOUT_MS),
|
|
206
|
+
),
|
|
207
|
+
]);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
for (const profile of profiles) {
|
|
211
|
+
profile.api?.destroy();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
logger.info('✅ Agent stopped gracefully');
|
|
215
|
+
logger.flush();
|
|
216
|
+
process.exit(0);
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
// The global SIGINT/SIGTERM handlers (index.js and LoggingService) call
|
|
220
|
+
// process.exit(0) immediately, which would kill the in-flight job mid-write.
|
|
221
|
+
// Agent mode owns shutdown: replace them with the graceful version.
|
|
222
|
+
process.removeAllListeners('SIGINT');
|
|
223
|
+
process.removeAllListeners('SIGTERM');
|
|
224
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
225
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export default new AgentCommand();
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { parse as parseEnv } from 'dotenv';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_AGENT_LOG_PATH,
|
|
8
|
+
expandHome,
|
|
9
|
+
} from '../services/ProfileManager.js';
|
|
10
|
+
|
|
11
|
+
const TUNING_ENV_KEYS = [
|
|
12
|
+
'SCAN_EXCLUDE_PATTERNS',
|
|
13
|
+
'SCAN_BATCH_SIZE',
|
|
14
|
+
'PUSH_BATCH_SIZE',
|
|
15
|
+
'PUSH_UPLOAD_BATCH_SIZE',
|
|
16
|
+
'MAX_CONCURRENT_SOURCES',
|
|
17
|
+
'MAX_API_CONNECTIONS',
|
|
18
|
+
'API_CONNECTION_TIMEOUT',
|
|
19
|
+
'BATCH_DELAY',
|
|
20
|
+
'BATCH_SIZE',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Agent Init Command Handler
|
|
25
|
+
* Migration helper: reads the legacy per-folder .env files (one folder per
|
|
26
|
+
* RFC×source) and generates, WITHOUT calling any API:
|
|
27
|
+
* - profiles.json → minimal agent config (folders on the same tenant API
|
|
28
|
+
* collapse into one profile)
|
|
29
|
+
* - seed-configs.json → one pipeline_config payload per folder (the data that
|
|
30
|
+
* moves server-side: scan directories, tuning, schedule)
|
|
31
|
+
* - seed-configs.sh → ready-to-run curls to POST those payloads
|
|
32
|
+
*
|
|
33
|
+
* Tokens never leave the machine: everything is generated locally for review.
|
|
34
|
+
*
|
|
35
|
+
* Usage:
|
|
36
|
+
* arela agent init --from ../ktj-cli-maya ../ktj-cli-matro --server-id PALCO-01
|
|
37
|
+
*/
|
|
38
|
+
export class AgentInitCommand {
|
|
39
|
+
/**
|
|
40
|
+
* Execute the init command
|
|
41
|
+
* @param {string[]} dirs - Folders containing legacy .env files
|
|
42
|
+
* @param {Object} options
|
|
43
|
+
* @param {string} options.serverId - Machine server id (default: ARELA_SERVER_ID from first .env, else hostname)
|
|
44
|
+
* @param {string} options.out - Output directory (default ~/.arela)
|
|
45
|
+
* @param {boolean} options.force - Overwrite an existing profiles.json
|
|
46
|
+
*/
|
|
47
|
+
async execute(dirs, options = {}) {
|
|
48
|
+
if (!dirs || dirs.length === 0) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
'At least one folder is required: arela agent init --from <dir1> <dir2> …',
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const outDir = path.resolve(expandHome(options.out || '~/.arela'));
|
|
55
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
56
|
+
|
|
57
|
+
const profilesPath = path.join(outDir, 'profiles.json');
|
|
58
|
+
if (fs.existsSync(profilesPath) && !options.force) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`${profilesPath} already exists. Use --force to overwrite it.`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const parsedFolders = [];
|
|
65
|
+
for (const dir of dirs) {
|
|
66
|
+
const resolvedDir = path.resolve(expandHome(dir));
|
|
67
|
+
const envPath = path.join(resolvedDir, '.env');
|
|
68
|
+
if (!fs.existsSync(envPath)) {
|
|
69
|
+
console.warn(`⚠️ Skipping ${resolvedDir}: no .env file found`);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const env = parseEnv(fs.readFileSync(envPath));
|
|
73
|
+
const clienteUrl = env.ARELA_API_CLIENTE_URL || env.ARELA_API_URL || null;
|
|
74
|
+
const clienteToken =
|
|
75
|
+
env.ARELA_API_CLIENTE_TOKEN || env.ARELA_API_TOKEN || null;
|
|
76
|
+
if (!clienteUrl || !clienteToken) {
|
|
77
|
+
console.warn(
|
|
78
|
+
`⚠️ Skipping ${resolvedDir}: no ARELA_API_CLIENTE_URL/_TOKEN (nor ARELA_API_URL/_TOKEN)`,
|
|
79
|
+
);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
parsedFolders.push({ dir: resolvedDir, env, clienteUrl, clienteToken });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (parsedFolders.length === 0) {
|
|
86
|
+
throw new Error('No usable .env files found in the given folders');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const serverId =
|
|
90
|
+
options.serverId ||
|
|
91
|
+
parsedFolders.find((f) => f.env.ARELA_SERVER_ID)?.env.ARELA_SERVER_ID ||
|
|
92
|
+
os.hostname();
|
|
93
|
+
|
|
94
|
+
// Group folders by tenant API URL → one profile per tenant
|
|
95
|
+
const profilesByUrl = new Map();
|
|
96
|
+
for (const folder of parsedFolders) {
|
|
97
|
+
const key = folder.clienteUrl.replace(/\/+$/, '').toLowerCase();
|
|
98
|
+
if (!profilesByUrl.has(key)) {
|
|
99
|
+
const profile = {
|
|
100
|
+
name: this.#deriveProfileName(folder, profilesByUrl),
|
|
101
|
+
url: folder.clienteUrl.replace(/\/+$/, ''),
|
|
102
|
+
token: folder.clienteToken,
|
|
103
|
+
};
|
|
104
|
+
const agenciaUrl = folder.env.ARELA_API_AGENCIA_URL;
|
|
105
|
+
if (
|
|
106
|
+
agenciaUrl &&
|
|
107
|
+
agenciaUrl.replace(/\/+$/, '').toLowerCase() !== key
|
|
108
|
+
) {
|
|
109
|
+
profile.targets = {
|
|
110
|
+
agencia: {
|
|
111
|
+
baseUrl: agenciaUrl.replace(/\/+$/, ''),
|
|
112
|
+
token: folder.env.ARELA_API_AGENCIA_TOKEN || folder.clienteToken,
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
profilesByUrl.set(key, profile);
|
|
117
|
+
}
|
|
118
|
+
folder.profile = profilesByUrl.get(key);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const profilesJson = {
|
|
122
|
+
version: 1,
|
|
123
|
+
serverId,
|
|
124
|
+
pollIntervalMs: 5000,
|
|
125
|
+
logFile: DEFAULT_AGENT_LOG_PATH,
|
|
126
|
+
profiles: [...profilesByUrl.values()],
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// One pipeline_config payload per folder×RFC
|
|
130
|
+
const seeds = [];
|
|
131
|
+
for (const folder of parsedFolders) {
|
|
132
|
+
const rfcs = this.#splitPipe(
|
|
133
|
+
folder.env.UPLOAD_RFCS || folder.env.PUSH_RFCS,
|
|
134
|
+
);
|
|
135
|
+
if (rfcs.length === 0) {
|
|
136
|
+
console.warn(
|
|
137
|
+
`⚠️ ${folder.dir}: no UPLOAD_RFCS/PUSH_RFCS — using placeholder RFC_PENDIENTE (edit before seeding)`,
|
|
138
|
+
);
|
|
139
|
+
rfcs.push('RFC_PENDIENTE');
|
|
140
|
+
}
|
|
141
|
+
for (const rfc of rfcs) {
|
|
142
|
+
seeds.push({
|
|
143
|
+
profile: folder.profile.name,
|
|
144
|
+
apiUrl: folder.profile.url,
|
|
145
|
+
sourceFolder: folder.dir,
|
|
146
|
+
config: this.#buildPipelineConfig(folder, rfc, serverId),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const seedsJsonPath = path.join(outDir, 'seed-configs.json');
|
|
152
|
+
const seedsShPath = path.join(outDir, 'seed-configs.sh');
|
|
153
|
+
|
|
154
|
+
fs.writeFileSync(profilesPath, JSON.stringify(profilesJson, null, 2));
|
|
155
|
+
try {
|
|
156
|
+
fs.chmodSync(profilesPath, 0o600);
|
|
157
|
+
} catch {
|
|
158
|
+
// chmod is best-effort on Windows
|
|
159
|
+
}
|
|
160
|
+
fs.writeFileSync(seedsJsonPath, JSON.stringify(seeds, null, 2));
|
|
161
|
+
fs.writeFileSync(seedsShPath, this.#buildSeedScript(seeds, profilesJson));
|
|
162
|
+
try {
|
|
163
|
+
fs.chmodSync(seedsShPath, 0o755);
|
|
164
|
+
} catch {
|
|
165
|
+
// chmod is best-effort on Windows
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
console.log(`\n✅ Generated from ${parsedFolders.length} folder(s):`);
|
|
169
|
+
console.log(
|
|
170
|
+
` 📄 ${profilesPath} (${profilesJson.profiles.length} profile(s))`,
|
|
171
|
+
);
|
|
172
|
+
console.log(
|
|
173
|
+
` 📄 ${seedsJsonPath} (${seeds.length} pipeline_config payload(s))`,
|
|
174
|
+
);
|
|
175
|
+
console.log(` 📄 ${seedsShPath}`);
|
|
176
|
+
console.log('\nNext steps:');
|
|
177
|
+
console.log(' 1. Review both files (RFCs, scan directories, slugs)');
|
|
178
|
+
console.log(` 2. Seed the server configs: bash ${seedsShPath}`);
|
|
179
|
+
console.log(' 3. Start the agent: arela agent');
|
|
180
|
+
console.log(
|
|
181
|
+
' 4. Once stable, enable scheduling per config (scheduleEnabled) and the PIPELINE_SCHEDULER cronjob per tenant',
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Profile name from the API hostname's first label (ktj.api… → 'ktj'),
|
|
187
|
+
* falling back to the folder basename; deduped with numeric suffixes.
|
|
188
|
+
* @private
|
|
189
|
+
*/
|
|
190
|
+
#deriveProfileName(folder, profilesByUrl) {
|
|
191
|
+
let base;
|
|
192
|
+
try {
|
|
193
|
+
base = new URL(folder.clienteUrl).hostname.split('.')[0];
|
|
194
|
+
} catch {
|
|
195
|
+
base = path.basename(folder.dir);
|
|
196
|
+
}
|
|
197
|
+
base = base.toLowerCase();
|
|
198
|
+
const taken = new Set(
|
|
199
|
+
[...profilesByUrl.values()].map((profile) => profile.name),
|
|
200
|
+
);
|
|
201
|
+
let name = base;
|
|
202
|
+
let suffix = 2;
|
|
203
|
+
while (taken.has(name)) {
|
|
204
|
+
name = `${base}-${suffix++}`;
|
|
205
|
+
}
|
|
206
|
+
return name;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Build the POST /api/uploader/pipeline/config payload for one folder×RFC
|
|
211
|
+
* @private
|
|
212
|
+
*/
|
|
213
|
+
#buildPipelineConfig(folder, rfc, serverId) {
|
|
214
|
+
const { env, dir } = folder;
|
|
215
|
+
|
|
216
|
+
// Folder naming convention 'ktj-cli-maya' → source slug 'maya'
|
|
217
|
+
const basenameParts = path.basename(dir).split('-');
|
|
218
|
+
const agenciaSlug =
|
|
219
|
+
basenameParts.length > 1
|
|
220
|
+
? basenameParts[basenameParts.length - 1]
|
|
221
|
+
: env.ARELA_COMPANY_SLUG || path.basename(dir);
|
|
222
|
+
|
|
223
|
+
const basePath = env.UPLOAD_BASE_PATH;
|
|
224
|
+
const sources = this.#splitPipe(env.UPLOAD_SOURCES);
|
|
225
|
+
let scanDirectories = [];
|
|
226
|
+
if (basePath && basePath !== '*' && sources.length > 0) {
|
|
227
|
+
const trimmedBase = basePath.replace(/[\\/]+$/, '');
|
|
228
|
+
const separator = trimmedBase.includes('\\') ? '\\' : '/';
|
|
229
|
+
scanDirectories = sources.map(
|
|
230
|
+
(source) => `${trimmedBase}${separator}${source}`,
|
|
231
|
+
);
|
|
232
|
+
} else if (basePath && basePath !== '*') {
|
|
233
|
+
scanDirectories = [basePath];
|
|
234
|
+
} else if (sources.length > 0) {
|
|
235
|
+
scanDirectories = sources;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const extraOptions = {};
|
|
239
|
+
if (env.ARELA_COMPANY_SLUG) {
|
|
240
|
+
extraOptions.companySlug = env.ARELA_COMPANY_SLUG;
|
|
241
|
+
}
|
|
242
|
+
if (env.SCAN_DIRECTORY_LEVEL !== undefined) {
|
|
243
|
+
extraOptions.directoryLevel = parseInt(env.SCAN_DIRECTORY_LEVEL) || 0;
|
|
244
|
+
}
|
|
245
|
+
for (const key of TUNING_ENV_KEYS) {
|
|
246
|
+
if (env[key] !== undefined) {
|
|
247
|
+
extraOptions[key] = env[key];
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const crossTenant = !!folder.profile.targets?.agencia;
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
rfc,
|
|
255
|
+
agenciaSlug: agenciaSlug.toLowerCase(),
|
|
256
|
+
displayName: `${rfc} — ${agenciaSlug}`,
|
|
257
|
+
scanDirectories,
|
|
258
|
+
...(env.UPLOAD_FILE_EXTENSIONS && {
|
|
259
|
+
fileExtensions: env.UPLOAD_FILE_EXTENSIONS.split(',')
|
|
260
|
+
.map((extension) => extension.trim())
|
|
261
|
+
.filter(Boolean),
|
|
262
|
+
}),
|
|
263
|
+
...(env.PUSH_BUCKET && { pushBucket: env.PUSH_BUCKET }),
|
|
264
|
+
folderStructure: env.PUSH_FOLDER_STRUCTURE || 'pedimento',
|
|
265
|
+
serverId,
|
|
266
|
+
sourceApi: crossTenant ? 'agencia' : 'cliente',
|
|
267
|
+
targetApi: 'cliente',
|
|
268
|
+
active: true,
|
|
269
|
+
extraOptions,
|
|
270
|
+
// Enable after the agent runs stable (see README)
|
|
271
|
+
scheduleEnabled: false,
|
|
272
|
+
scheduleIntervalHours: 24,
|
|
273
|
+
scheduleStartTime: '02:00',
|
|
274
|
+
scheduleJobType: 'full',
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Build the curl script that seeds every pipeline_config
|
|
280
|
+
* @private
|
|
281
|
+
*/
|
|
282
|
+
#buildSeedScript(seeds, profilesJson) {
|
|
283
|
+
const tokenByProfile = Object.fromEntries(
|
|
284
|
+
profilesJson.profiles.map((profile) => [profile.name, profile.token]),
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
let script = '#!/usr/bin/env bash\n';
|
|
288
|
+
script += '# Generated by `arela agent init` — review before running.\n';
|
|
289
|
+
script += '# Seeds one pipeline_config row per legacy folder×RFC.\n';
|
|
290
|
+
script += 'set -euo pipefail\n\n';
|
|
291
|
+
|
|
292
|
+
for (const seed of seeds) {
|
|
293
|
+
script += `# ${seed.config.rfc} / ${seed.config.agenciaSlug} (from ${seed.sourceFolder})\n`;
|
|
294
|
+
script += `curl -sS -X POST '${seed.apiUrl}/api/uploader/pipeline/config' \\\n`;
|
|
295
|
+
script += ` -H 'x-api-key: ${tokenByProfile[seed.profile]}' \\\n`;
|
|
296
|
+
script += ` -H 'Content-Type: application/json' \\\n`;
|
|
297
|
+
script += ` --data '${JSON.stringify(seed.config).replace(/'/g, `'\\''`)}'\n`;
|
|
298
|
+
script += 'echo\n\n';
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return script;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Split a pipe-separated env value into trimmed parts
|
|
306
|
+
* @private
|
|
307
|
+
*/
|
|
308
|
+
#splitPipe(value) {
|
|
309
|
+
return (value || '')
|
|
310
|
+
.split('|')
|
|
311
|
+
.map((part) => part.trim())
|
|
312
|
+
.filter(Boolean);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export default new AgentInitCommand();
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
import { DatastageApiService } from '../services/DatastageApiService.js';
|
|
5
|
+
import logger from '../services/LoggingService.js';
|
|
6
|
+
|
|
7
|
+
import appConfig from '../config/config.js';
|
|
8
|
+
import ErrorHandler from '../errors/ErrorHandler.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Datastage Command Handler
|
|
12
|
+
* Uploads monthly Datastage *.zip files from a directory to the API.
|
|
13
|
+
* Sequential, idempotent via the cli `datastage_uploads` tracking table.
|
|
14
|
+
*/
|
|
15
|
+
export class DatastageCommand {
|
|
16
|
+
constructor() {
|
|
17
|
+
this.errorHandler = new ErrorHandler(logger);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {Object} options
|
|
22
|
+
* @param {string} options.dir - directory containing *.zip files (required)
|
|
23
|
+
* @param {string} [options.api] - 'default'|'agencia'|'cliente'
|
|
24
|
+
* @param {boolean} [options.retryFailed] - re-attempt files in 'failed' status
|
|
25
|
+
* @param {boolean} [options.showStats] - print final stats from API
|
|
26
|
+
*/
|
|
27
|
+
async execute(options = {}) {
|
|
28
|
+
const startTime = Date.now();
|
|
29
|
+
|
|
30
|
+
if (!options.dir) {
|
|
31
|
+
throw new Error('--dir <path> is required');
|
|
32
|
+
}
|
|
33
|
+
const sourceDirectory = path.resolve(options.dir);
|
|
34
|
+
if (!fs.existsSync(sourceDirectory)) {
|
|
35
|
+
throw new Error(`Directory not found: ${sourceDirectory}`);
|
|
36
|
+
}
|
|
37
|
+
const dirStat = fs.statSync(sourceDirectory);
|
|
38
|
+
if (!dirStat.isDirectory()) {
|
|
39
|
+
throw new Error(`Not a directory: ${sourceDirectory}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const apiTarget = options.api || 'default';
|
|
43
|
+
const api = new DatastageApiService(apiTarget);
|
|
44
|
+
|
|
45
|
+
logger.info('📦 Starting arela datastage command');
|
|
46
|
+
logger.info(`🎯 API Target: ${apiTarget}`);
|
|
47
|
+
logger.info(`📂 Source: ${sourceDirectory}`);
|
|
48
|
+
|
|
49
|
+
// 1. Enumerate *.zip in root directory (non-recursive)
|
|
50
|
+
const entries = fs.readdirSync(sourceDirectory, { withFileTypes: true });
|
|
51
|
+
const zipFiles = entries
|
|
52
|
+
.filter((e) => e.isFile() && /\.zip$/i.test(e.name))
|
|
53
|
+
.map((e) => path.join(sourceDirectory, e.name));
|
|
54
|
+
|
|
55
|
+
if (zipFiles.length === 0) {
|
|
56
|
+
logger.warn('No *.zip files found in directory. Nothing to do.');
|
|
57
|
+
return { uploaded: 0, failed: 0, skipped: 0 };
|
|
58
|
+
}
|
|
59
|
+
logger.info(`🗂 Found ${zipFiles.length} zip file(s)`);
|
|
60
|
+
|
|
61
|
+
// 2. Register each file (idempotent upsert)
|
|
62
|
+
logger.info('📝 Registering files...');
|
|
63
|
+
for (const zipPath of zipFiles) {
|
|
64
|
+
const stats = fs.statSync(zipPath);
|
|
65
|
+
try {
|
|
66
|
+
await api.registerUpload({
|
|
67
|
+
absolutePath: zipPath,
|
|
68
|
+
fileName: path.basename(zipPath),
|
|
69
|
+
sizeBytes: stats.size,
|
|
70
|
+
fileModifiedAt: stats.mtime.toISOString(),
|
|
71
|
+
sourceDirectory,
|
|
72
|
+
});
|
|
73
|
+
} catch (err) {
|
|
74
|
+
logger.error(
|
|
75
|
+
` ✗ register failed for ${path.basename(zipPath)}: ${err.message}`,
|
|
76
|
+
);
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 3. Fetch pending list scoped to this directory
|
|
82
|
+
const pending = await api.getPending(sourceDirectory);
|
|
83
|
+
const pendingPaths = new Set(pending.map((p) => p.absolutePath));
|
|
84
|
+
|
|
85
|
+
const alreadyUploaded = zipFiles.length - pendingPaths.size;
|
|
86
|
+
if (alreadyUploaded > 0) {
|
|
87
|
+
logger.info(`⏭ Skipping ${alreadyUploaded} already uploaded file(s)`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (pending.length === 0) {
|
|
91
|
+
logger.success('✅ All files already uploaded. Nothing to do.');
|
|
92
|
+
if (options.showStats) {
|
|
93
|
+
const s = await api.getStats(sourceDirectory);
|
|
94
|
+
logger.info(`📊 Stats: ${JSON.stringify(s)}`);
|
|
95
|
+
}
|
|
96
|
+
return { uploaded: 0, failed: 0, skipped: alreadyUploaded };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 4. Sequential upload loop
|
|
100
|
+
logger.info(`🚀 Uploading ${pending.length} file(s) sequentially...`);
|
|
101
|
+
let uploaded = 0;
|
|
102
|
+
let failed = 0;
|
|
103
|
+
|
|
104
|
+
for (let i = 0; i < pending.length; i++) {
|
|
105
|
+
const row = pending[i];
|
|
106
|
+
const localPath = row.absolutePath;
|
|
107
|
+
const label = `[${i + 1}/${pending.length}] ${row.fileName}`;
|
|
108
|
+
|
|
109
|
+
if (!fs.existsSync(localPath)) {
|
|
110
|
+
const err = `File missing on disk: ${localPath}`;
|
|
111
|
+
logger.error(`✗ ${label}: ${err}`);
|
|
112
|
+
try {
|
|
113
|
+
await api.markFailed(row.id, err);
|
|
114
|
+
} catch (e) {
|
|
115
|
+
logger.error(` mark-failed error: ${e.message}`);
|
|
116
|
+
}
|
|
117
|
+
failed++;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
logger.info(`⬆ ${label}: uploading...`);
|
|
123
|
+
const result = await api.uploadZip(localPath);
|
|
124
|
+
const datastageId = result?.id || result?.data?.id;
|
|
125
|
+
const folio = result?.folio || result?.data?.folio;
|
|
126
|
+
if (!datastageId) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
'API returned no datastage id in response: ' +
|
|
129
|
+
JSON.stringify(result).slice(0, 300),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
await api.markUploaded(row.id, { datastageId, folio });
|
|
133
|
+
logger.success(
|
|
134
|
+
`✓ ${label}: folio=${folio || 'n/a'} datastageId=${datastageId}`,
|
|
135
|
+
);
|
|
136
|
+
uploaded++;
|
|
137
|
+
} catch (err) {
|
|
138
|
+
logger.error(`✗ ${label}: ${err.message}`);
|
|
139
|
+
try {
|
|
140
|
+
await api.markFailed(row.id, err.message);
|
|
141
|
+
} catch (e) {
|
|
142
|
+
logger.error(` mark-failed error: ${e.message}`);
|
|
143
|
+
}
|
|
144
|
+
failed++;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
149
|
+
logger.info('—'.repeat(60));
|
|
150
|
+
logger.info(
|
|
151
|
+
`Done in ${elapsed}s — uploaded=${uploaded} failed=${failed} skipped=${alreadyUploaded}`,
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
if (options.showStats) {
|
|
155
|
+
const s = await api.getStats(sourceDirectory);
|
|
156
|
+
logger.info(`📊 Final stats: ${JSON.stringify(s)}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { uploaded, failed, skipped: alreadyUploaded };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const datastageCommand = new DatastageCommand();
|
|
164
|
+
export default datastageCommand;
|