@cisco_open/linting-orchestrator 1.0.0-rc.4
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/LICENSE +201 -0
- package/NOTICE +5 -0
- package/README.md +43 -0
- package/build/cli/api-client.d.ts +170 -0
- package/build/cli/api-client.d.ts.map +1 -0
- package/build/cli/api-client.js +284 -0
- package/build/cli/api-client.js.map +1 -0
- package/build/cli/commands/agents.d.ts +7 -0
- package/build/cli/commands/agents.d.ts.map +1 -0
- package/build/cli/commands/agents.js +694 -0
- package/build/cli/commands/agents.js.map +1 -0
- package/build/cli/commands/completion.d.ts +9 -0
- package/build/cli/commands/completion.d.ts.map +1 -0
- package/build/cli/commands/completion.js +177 -0
- package/build/cli/commands/completion.js.map +1 -0
- package/build/cli/commands/config.d.ts +10 -0
- package/build/cli/commands/config.d.ts.map +1 -0
- package/build/cli/commands/config.js +284 -0
- package/build/cli/commands/config.js.map +1 -0
- package/build/cli/commands/health.d.ts +11 -0
- package/build/cli/commands/health.d.ts.map +1 -0
- package/build/cli/commands/health.js +38 -0
- package/build/cli/commands/health.js.map +1 -0
- package/build/cli/commands/help.d.ts +6 -0
- package/build/cli/commands/help.d.ts.map +1 -0
- package/build/cli/commands/help.js +20 -0
- package/build/cli/commands/help.js.map +1 -0
- package/build/cli/commands/history.d.ts +11 -0
- package/build/cli/commands/history.d.ts.map +1 -0
- package/build/cli/commands/history.js +50 -0
- package/build/cli/commands/history.js.map +1 -0
- package/build/cli/commands/jobs.d.ts +12 -0
- package/build/cli/commands/jobs.d.ts.map +1 -0
- package/build/cli/commands/jobs.js +84 -0
- package/build/cli/commands/jobs.js.map +1 -0
- package/build/cli/commands/lint.d.ts +15 -0
- package/build/cli/commands/lint.d.ts.map +1 -0
- package/build/cli/commands/lint.js +384 -0
- package/build/cli/commands/lint.js.map +1 -0
- package/build/cli/commands/ps.d.ts +8 -0
- package/build/cli/commands/ps.d.ts.map +1 -0
- package/build/cli/commands/ps.js +74 -0
- package/build/cli/commands/ps.js.map +1 -0
- package/build/cli/commands/reproduce.d.ts +9 -0
- package/build/cli/commands/reproduce.d.ts.map +1 -0
- package/build/cli/commands/reproduce.js +31 -0
- package/build/cli/commands/reproduce.js.map +1 -0
- package/build/cli/commands/reset.d.ts +5 -0
- package/build/cli/commands/reset.d.ts.map +1 -0
- package/build/cli/commands/reset.js +13 -0
- package/build/cli/commands/reset.js.map +1 -0
- package/build/cli/commands/results.d.ts +13 -0
- package/build/cli/commands/results.d.ts.map +1 -0
- package/build/cli/commands/results.js +129 -0
- package/build/cli/commands/results.js.map +1 -0
- package/build/cli/commands/rulesets/check.d.ts +12 -0
- package/build/cli/commands/rulesets/check.d.ts.map +1 -0
- package/build/cli/commands/rulesets/check.js +226 -0
- package/build/cli/commands/rulesets/check.js.map +1 -0
- package/build/cli/commands/rulesets/index.d.ts +5 -0
- package/build/cli/commands/rulesets/index.d.ts.map +1 -0
- package/build/cli/commands/rulesets/index.js +6 -0
- package/build/cli/commands/rulesets/index.js.map +1 -0
- package/build/cli/commands/rulesets/view.d.ts +16 -0
- package/build/cli/commands/rulesets/view.d.ts.map +1 -0
- package/build/cli/commands/rulesets/view.js +100 -0
- package/build/cli/commands/rulesets/view.js.map +1 -0
- package/build/cli/commands/start.d.ts +16 -0
- package/build/cli/commands/start.d.ts.map +1 -0
- package/build/cli/commands/start.js +167 -0
- package/build/cli/commands/start.js.map +1 -0
- package/build/cli/commands/status.d.ts +9 -0
- package/build/cli/commands/status.d.ts.map +1 -0
- package/build/cli/commands/status.js +46 -0
- package/build/cli/commands/status.js.map +1 -0
- package/build/cli/commands/stop.d.ts +11 -0
- package/build/cli/commands/stop.d.ts.map +1 -0
- package/build/cli/commands/stop.js +78 -0
- package/build/cli/commands/stop.js.map +1 -0
- package/build/cli/config-manager.d.ts +134 -0
- package/build/cli/config-manager.d.ts.map +1 -0
- package/build/cli/config-manager.js +288 -0
- package/build/cli/config-manager.js.map +1 -0
- package/build/cli/formatters.d.ts +62 -0
- package/build/cli/formatters.d.ts.map +1 -0
- package/build/cli/formatters.js +715 -0
- package/build/cli/formatters.js.map +1 -0
- package/build/cli/history-manager.d.ts +97 -0
- package/build/cli/history-manager.d.ts.map +1 -0
- package/build/cli/history-manager.js +201 -0
- package/build/cli/history-manager.js.map +1 -0
- package/build/cli/index.d.ts +16 -0
- package/build/cli/index.d.ts.map +1 -0
- package/build/cli/index.js +335 -0
- package/build/cli/index.js.map +1 -0
- package/build/cli/list-rulesets.d.ts +15 -0
- package/build/cli/list-rulesets.d.ts.map +1 -0
- package/build/cli/list-rulesets.js +193 -0
- package/build/cli/list-rulesets.js.map +1 -0
- package/build/cli/utils/connection-error.d.ts +9 -0
- package/build/cli/utils/connection-error.d.ts.map +1 -0
- package/build/cli/utils/connection-error.js +30 -0
- package/build/cli/utils/connection-error.js.map +1 -0
- package/build/cli/utils/embedded-server.d.ts +21 -0
- package/build/cli/utils/embedded-server.d.ts.map +1 -0
- package/build/cli/utils/embedded-server.js +61 -0
- package/build/cli/utils/embedded-server.js.map +1 -0
- package/build/cli/utils/mode-validator.d.ts +13 -0
- package/build/cli/utils/mode-validator.d.ts.map +1 -0
- package/build/cli/utils/mode-validator.js +31 -0
- package/build/cli/utils/mode-validator.js.map +1 -0
- package/build/cli/utils/port-checker.d.ts +20 -0
- package/build/cli/utils/port-checker.d.ts.map +1 -0
- package/build/cli/utils/port-checker.js +49 -0
- package/build/cli/utils/port-checker.js.map +1 -0
- package/build/config.d.ts +57 -0
- package/build/config.d.ts.map +1 -0
- package/build/config.js +527 -0
- package/build/config.js.map +1 -0
- package/build/document-accessor.d.ts +79 -0
- package/build/document-accessor.d.ts.map +1 -0
- package/build/document-accessor.js +148 -0
- package/build/document-accessor.js.map +1 -0
- package/build/formatters/reproduce-markdown.d.ts +14 -0
- package/build/formatters/reproduce-markdown.d.ts.map +1 -0
- package/build/formatters/reproduce-markdown.js +182 -0
- package/build/formatters/reproduce-markdown.js.map +1 -0
- package/build/formatters/sarif-builder.d.ts +86 -0
- package/build/formatters/sarif-builder.d.ts.map +1 -0
- package/build/formatters/sarif-builder.js +276 -0
- package/build/formatters/sarif-builder.js.map +1 -0
- package/build/index.d.ts +3 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +174 -0
- package/build/index.js.map +1 -0
- package/build/logger.d.ts +38 -0
- package/build/logger.d.ts.map +1 -0
- package/build/logger.js +105 -0
- package/build/logger.js.map +1 -0
- package/build/mock-server.d.ts +2 -0
- package/build/mock-server.d.ts.map +1 -0
- package/build/mock-server.js +290 -0
- package/build/mock-server.js.map +1 -0
- package/build/orchestrator.d.ts +149 -0
- package/build/orchestrator.d.ts.map +1 -0
- package/build/orchestrator.js +874 -0
- package/build/orchestrator.js.map +1 -0
- package/build/ruleset-loader.d.ts +79 -0
- package/build/ruleset-loader.d.ts.map +1 -0
- package/build/ruleset-loader.js +514 -0
- package/build/ruleset-loader.js.map +1 -0
- package/build/schemas.d.ts +2568 -0
- package/build/schemas.d.ts.map +1 -0
- package/build/schemas.js +674 -0
- package/build/schemas.js.map +1 -0
- package/build/server.d.ts +39 -0
- package/build/server.d.ts.map +1 -0
- package/build/server.js +834 -0
- package/build/server.js.map +1 -0
- package/build/storage/memory-storage.d.ts +190 -0
- package/build/storage/memory-storage.d.ts.map +1 -0
- package/build/storage/memory-storage.js +629 -0
- package/build/storage/memory-storage.js.map +1 -0
- package/build/storage/redis-storage.d.ts +134 -0
- package/build/storage/redis-storage.d.ts.map +1 -0
- package/build/storage/redis-storage.js +236 -0
- package/build/storage/redis-storage.js.map +1 -0
- package/build/storage/storage-adapter.d.ts +189 -0
- package/build/storage/storage-adapter.d.ts.map +1 -0
- package/build/storage/storage-adapter.js +36 -0
- package/build/storage/storage-adapter.js.map +1 -0
- package/build/types.d.ts +981 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +5 -0
- package/build/types.js.map +1 -0
- package/build/utils/version.d.ts +40 -0
- package/build/utils/version.d.ts.map +1 -0
- package/build/utils/version.js +94 -0
- package/build/utils/version.js.map +1 -0
- package/build/validation.d.ts +95 -0
- package/build/validation.d.ts.map +1 -0
- package/build/validation.js +150 -0
- package/build/validation.js.map +1 -0
- package/build/worker-pool.d.ts +137 -0
- package/build/worker-pool.d.ts.map +1 -0
- package/build/worker-pool.js +549 -0
- package/build/worker-pool.js.map +1 -0
- package/build/worker.d.ts +2 -0
- package/build/worker.d.ts.map +1 -0
- package/build/worker.js +427 -0
- package/build/worker.js.map +1 -0
- package/package.json +110 -0
- package/rulesets/CHANGELOG.md +25 -0
- package/rulesets/config/rulesets.yaml +96 -0
- package/rulesets/sources/README.md +47 -0
- package/rulesets/sources/example/oas-recommended/v1.0.0/ruleset.yaml +6 -0
- package/rulesets/sources/example/oas-recommended/v2.0.0/ruleset.yaml +14 -0
|
@@ -0,0 +1,874 @@
|
|
|
1
|
+
// Copyright 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
/**
|
|
5
|
+
* Job Orchestrator
|
|
6
|
+
*
|
|
7
|
+
* Manages the complete lifecycle of lint jobs:
|
|
8
|
+
* - Job submission and queue management
|
|
9
|
+
* - Task distribution to worker pool
|
|
10
|
+
* - Result aggregation
|
|
11
|
+
* - Retry logic with exponential backoff
|
|
12
|
+
* - Job status tracking
|
|
13
|
+
*
|
|
14
|
+
* Architecture:
|
|
15
|
+
* - One job = one document + one ruleset
|
|
16
|
+
* - Job creates tasks for execution
|
|
17
|
+
* - Tasks executed by worker pool
|
|
18
|
+
* - Results aggregated and stored
|
|
19
|
+
*
|
|
20
|
+
* @module orchestrator
|
|
21
|
+
*/
|
|
22
|
+
import { randomUUID } from 'crypto';
|
|
23
|
+
import { ReportServiceClient, CLIENT_VERSION } from '@cisco_open/linting-reports/client';
|
|
24
|
+
// ============================================
|
|
25
|
+
// Custom Error Classes
|
|
26
|
+
// ============================================
|
|
27
|
+
/**
|
|
28
|
+
* Thrown when the orchestrator has reached its maximum concurrent job capacity.
|
|
29
|
+
* The HTTP layer should translate this into a 429 Too Many Requests response.
|
|
30
|
+
*/
|
|
31
|
+
export class CapacityExceededError extends Error {
|
|
32
|
+
activeJobs;
|
|
33
|
+
maxJobs;
|
|
34
|
+
constructor(message, activeJobs, maxJobs) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = 'CapacityExceededError';
|
|
37
|
+
this.activeJobs = activeJobs;
|
|
38
|
+
this.maxJobs = maxJobs;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// ============================================
|
|
42
|
+
// Job Orchestrator
|
|
43
|
+
// ============================================
|
|
44
|
+
export class Orchestrator {
|
|
45
|
+
workerPool;
|
|
46
|
+
storage;
|
|
47
|
+
rulesetLoader;
|
|
48
|
+
documentStore;
|
|
49
|
+
jobs = new Map();
|
|
50
|
+
config;
|
|
51
|
+
initialized = false;
|
|
52
|
+
cacheHits = 0;
|
|
53
|
+
cacheMisses = 0;
|
|
54
|
+
activeJobCount = 0; // O(1) atomic counter for active (queued + running) jobs
|
|
55
|
+
reportClient = null;
|
|
56
|
+
reportServiceConfig = undefined;
|
|
57
|
+
constructor(workerPool, storage, rulesetLoader, documentStore, config) {
|
|
58
|
+
this.workerPool = workerPool;
|
|
59
|
+
this.storage = storage;
|
|
60
|
+
this.rulesetLoader = rulesetLoader;
|
|
61
|
+
this.documentStore = documentStore;
|
|
62
|
+
this.config = {
|
|
63
|
+
maxConcurrentJobs: 100,
|
|
64
|
+
enableCache: true,
|
|
65
|
+
maxIssuesPerJob: 100000,
|
|
66
|
+
...config
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Initialize orchestrator
|
|
71
|
+
*/
|
|
72
|
+
async initialize() {
|
|
73
|
+
if (this.initialized) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
console.log('🔧 Initializing Job Orchestrator...');
|
|
77
|
+
// Storage initialization handled externally
|
|
78
|
+
console.log(' ✅ Storage initialized');
|
|
79
|
+
// Worker pool should already be initialized
|
|
80
|
+
console.log(' ✅ Worker pool ready');
|
|
81
|
+
// Initialize Report Service integration if configured
|
|
82
|
+
await this.initializeReportClient();
|
|
83
|
+
this.initialized = true;
|
|
84
|
+
console.log('✅ Job Orchestrator initialized\n');
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Initialize Report Service client integration
|
|
88
|
+
*
|
|
89
|
+
* Requirements:
|
|
90
|
+
* - Info logs on successful start showing version compatibility
|
|
91
|
+
* - Always try to start if configured, warn if unavailable (graceful degradation)
|
|
92
|
+
* - stopIfUnavailable flag to fail startup for production safety
|
|
93
|
+
*/
|
|
94
|
+
async initializeReportClient() {
|
|
95
|
+
if (!this.config.reportService?.enabled) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const config = this.config.reportService;
|
|
99
|
+
this.reportServiceConfig = config;
|
|
100
|
+
console.log(`🔗 Initializing Report Service integration...`);
|
|
101
|
+
console.log(` URL: ${config.url}`);
|
|
102
|
+
console.log(` Client Version: ${CLIENT_VERSION}`);
|
|
103
|
+
// Always create client instance - it handles connection failures gracefully
|
|
104
|
+
this.reportClient = new ReportServiceClient({
|
|
105
|
+
url: config.url,
|
|
106
|
+
apiKey: config.apiKey,
|
|
107
|
+
timeout: config.timeout,
|
|
108
|
+
maxRetries: config.maxRetries,
|
|
109
|
+
baseRetryDelay: config.baseRetryDelay,
|
|
110
|
+
pendingDir: config.pendingDir,
|
|
111
|
+
enableRetryJob: config.enableRetryJob,
|
|
112
|
+
retryJobInterval: config.retryJobInterval,
|
|
113
|
+
});
|
|
114
|
+
await this.reportClient.initialize();
|
|
115
|
+
// Test connectivity, but don't fail if unavailable
|
|
116
|
+
try {
|
|
117
|
+
const compatibility = await this.reportClient.checkCompatibility();
|
|
118
|
+
if (compatibility.compatible) {
|
|
119
|
+
console.log(` ✅ Report Service CONNECTED`);
|
|
120
|
+
console.log(` Server Version: ${compatibility.serverVersion}`);
|
|
121
|
+
console.log(` Status: Compatible`);
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
const message = `Report Service version incompatible (server: ${compatibility.serverVersion}, client: ${CLIENT_VERSION})`;
|
|
125
|
+
console.warn(` ⚠️ ${message}`);
|
|
126
|
+
if (compatibility.details) {
|
|
127
|
+
console.warn(` Details: ${compatibility.details}`);
|
|
128
|
+
}
|
|
129
|
+
if (config.stopIfUnavailable) {
|
|
130
|
+
throw new Error(`${message} - startup blocked by stopIfUnavailable flag`);
|
|
131
|
+
}
|
|
132
|
+
console.warn(` Continuing in DEGRADED MODE (notifications will queue to pending directory)`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
const message = `Report Service unavailable: ${error.message}`;
|
|
137
|
+
if (config.stopIfUnavailable) {
|
|
138
|
+
console.error(` ❌ ${message} (startup blocked by stopIfUnavailable flag)`);
|
|
139
|
+
throw new Error(message);
|
|
140
|
+
}
|
|
141
|
+
console.warn(` ⚠️ ${message}`);
|
|
142
|
+
console.warn(` Continuing in DEGRADED MODE (notifications will queue to pending directory)`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Submit a new lint job
|
|
147
|
+
*/
|
|
148
|
+
async submitJob(request) {
|
|
149
|
+
if (!this.initialized) {
|
|
150
|
+
throw new Error('Orchestrator not initialized');
|
|
151
|
+
}
|
|
152
|
+
const { documentId, rulesetName, rulesetVersion, callbackUrl, ruleOverrides, options } = request;
|
|
153
|
+
// Enforce concurrent job capacity (backpressure)
|
|
154
|
+
const maxConcurrent = this.config.maxConcurrentJobs ?? 100;
|
|
155
|
+
if (this.activeJobCount >= maxConcurrent) {
|
|
156
|
+
console.warn(`⚠️ Job rejected: capacity exceeded (${this.activeJobCount}/${maxConcurrent} active jobs)`);
|
|
157
|
+
throw new CapacityExceededError(`Server at capacity: ${this.activeJobCount}/${maxConcurrent} concurrent jobs. Retry after a short delay.`, this.activeJobCount, maxConcurrent);
|
|
158
|
+
}
|
|
159
|
+
// Check cache first (if enabled)
|
|
160
|
+
if (this.config.enableCache && !options?.forceRun) {
|
|
161
|
+
const cached = await this.checkCache(documentId, rulesetName, rulesetVersion, ruleOverrides);
|
|
162
|
+
if (cached) {
|
|
163
|
+
this.cacheHits++;
|
|
164
|
+
console.log(`📦 Cache hit for ${documentId} + ${rulesetName} - returning cached result`);
|
|
165
|
+
return cached.jobId;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// Verify document exists
|
|
169
|
+
const documentExists = await this.documentStore.documentExists(documentId);
|
|
170
|
+
if (!documentExists) {
|
|
171
|
+
throw new Error(`Document not found: ${documentId}`);
|
|
172
|
+
}
|
|
173
|
+
// Cache miss - create new job
|
|
174
|
+
this.cacheMisses++;
|
|
175
|
+
// Resolve ruleset version
|
|
176
|
+
const version = rulesetVersion || this.rulesetLoader.getMetadata(rulesetName).defaultVersion;
|
|
177
|
+
// Create job
|
|
178
|
+
const jobId = randomUUID();
|
|
179
|
+
const job = {
|
|
180
|
+
jobId,
|
|
181
|
+
documentId,
|
|
182
|
+
rulesetName,
|
|
183
|
+
rulesetVersion: version,
|
|
184
|
+
callbackUrl,
|
|
185
|
+
ruleOverrides,
|
|
186
|
+
status: 'queued',
|
|
187
|
+
progress: {
|
|
188
|
+
totalTasks: 1,
|
|
189
|
+
completedTasks: 0,
|
|
190
|
+
failedTasks: 0,
|
|
191
|
+
timeoutTasks: 0,
|
|
192
|
+
runningTasks: 0,
|
|
193
|
+
queuedTasks: 1
|
|
194
|
+
},
|
|
195
|
+
startTime: new Date(),
|
|
196
|
+
priority: options?.priority || 'normal',
|
|
197
|
+
tasks: []
|
|
198
|
+
};
|
|
199
|
+
// Create task for execution
|
|
200
|
+
const task = {
|
|
201
|
+
taskId: randomUUID(),
|
|
202
|
+
jobId,
|
|
203
|
+
documentId,
|
|
204
|
+
documentPath: await this.documentStore.getDocumentPath(documentId),
|
|
205
|
+
rulesetName,
|
|
206
|
+
rulesetVersion: version,
|
|
207
|
+
ruleOverrides,
|
|
208
|
+
status: 'queued',
|
|
209
|
+
attempt: 0,
|
|
210
|
+
maxAttempts: this.config.workerPool?.maxRetries || 3
|
|
211
|
+
};
|
|
212
|
+
job.tasks.push(task);
|
|
213
|
+
this.jobs.set(jobId, job);
|
|
214
|
+
this.activeJobCount++; // Increment active job counter
|
|
215
|
+
console.log(`📝 Job submitted: ${jobId} (${documentId} + ${rulesetName}@${version}) [active: ${this.activeJobCount}/${maxConcurrent}]`);
|
|
216
|
+
// Execute job asynchronously
|
|
217
|
+
this.executeJob(job).catch(error => {
|
|
218
|
+
console.error(`❌ Job ${jobId} failed:`, error);
|
|
219
|
+
job.status = 'failed';
|
|
220
|
+
job.endTime = job.endTime || new Date();
|
|
221
|
+
// Build a minimal failure result so callback/notification still fires
|
|
222
|
+
const failureResult = {
|
|
223
|
+
jobId: job.jobId,
|
|
224
|
+
documentId: job.documentId,
|
|
225
|
+
rulesetName: job.rulesetName,
|
|
226
|
+
rulesetVersion: job.rulesetVersion,
|
|
227
|
+
ruleOverrides: job.ruleOverrides,
|
|
228
|
+
status: 'failed',
|
|
229
|
+
timestamp: job.endTime,
|
|
230
|
+
totalExecutionTime: job.endTime.getTime() - (job.startTime?.getTime() || job.endTime.getTime()),
|
|
231
|
+
summary: { totalIssues: 0, errorCount: 0, warningCount: 0, infoCount: 0, hintCount: 0 },
|
|
232
|
+
results: [],
|
|
233
|
+
executionDetails: {
|
|
234
|
+
rulesetName: job.rulesetName,
|
|
235
|
+
rulesetVersion: job.rulesetVersion,
|
|
236
|
+
executionTime: 0,
|
|
237
|
+
success: false,
|
|
238
|
+
issueCount: 0,
|
|
239
|
+
issues: [],
|
|
240
|
+
metadata: { ruleEngine: 'spectral', documentId: job.documentId }
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
// Store failure result (best-effort)
|
|
244
|
+
this.storage.storeJob(failureResult).catch(storeErr => {
|
|
245
|
+
console.error(`⚠️ Failed to store failure result for ${jobId}:`, storeErr);
|
|
246
|
+
});
|
|
247
|
+
// Notify Report Service about failure (best-effort)
|
|
248
|
+
this.notifyReportService(failureResult).catch(notifyErr => {
|
|
249
|
+
console.warn(`⚠️ Report Service notification failed for failed job ${jobId}: ${notifyErr instanceof Error ? notifyErr.message : String(notifyErr)}`);
|
|
250
|
+
});
|
|
251
|
+
// Deliver callback even on failure
|
|
252
|
+
if (job.callbackUrl) {
|
|
253
|
+
this.deliverCallback(job.callbackUrl, failureResult).catch(cbErr => {
|
|
254
|
+
console.warn(`⚠️ Callback delivery failed for failed job ${jobId}: ${cbErr instanceof Error ? cbErr.message : String(cbErr)}`);
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}).finally(() => {
|
|
258
|
+
this.activeJobCount--; // Decrement active job counter when job completes or fails
|
|
259
|
+
});
|
|
260
|
+
return jobId;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Execute a job (run tasks via worker pool)
|
|
264
|
+
*/
|
|
265
|
+
async executeJob(job) {
|
|
266
|
+
job.status = 'running';
|
|
267
|
+
job.startTime = new Date();
|
|
268
|
+
const results = [];
|
|
269
|
+
const errors = [];
|
|
270
|
+
const maxIssues = this.config.maxIssuesPerJob ?? 100000;
|
|
271
|
+
let truncated = false;
|
|
272
|
+
let actualPreTruncationCount = 0;
|
|
273
|
+
for (const task of job.tasks) {
|
|
274
|
+
try {
|
|
275
|
+
// Update task status
|
|
276
|
+
task.status = 'running';
|
|
277
|
+
task.attempt++;
|
|
278
|
+
task.startTime = new Date();
|
|
279
|
+
job.progress.runningTasks++;
|
|
280
|
+
job.progress.queuedTasks--;
|
|
281
|
+
// Execute task with retry logic
|
|
282
|
+
const result = await this.executeTaskWithRetry(task);
|
|
283
|
+
// Update task status
|
|
284
|
+
task.status = 'completed';
|
|
285
|
+
task.endTime = new Date();
|
|
286
|
+
task.result = {
|
|
287
|
+
rulesetName: task.rulesetName,
|
|
288
|
+
rulesetVersion: task.rulesetVersion,
|
|
289
|
+
executionTime: result.executionTime,
|
|
290
|
+
success: result.success,
|
|
291
|
+
issueCount: result.issues?.length || 0,
|
|
292
|
+
issues: result.issues || [],
|
|
293
|
+
metadata: {
|
|
294
|
+
ruleEngine: 'spectral',
|
|
295
|
+
documentId: task.documentId,
|
|
296
|
+
cacheHit: result.cacheHit
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
job.progress.runningTasks--;
|
|
300
|
+
job.progress.completedTasks++;
|
|
301
|
+
// Collect issues (worker returns 'results' array)
|
|
302
|
+
if (result.results) {
|
|
303
|
+
const incoming = result.results;
|
|
304
|
+
actualPreTruncationCount += incoming.length;
|
|
305
|
+
if (results.length < maxIssues) {
|
|
306
|
+
const remaining = maxIssues - results.length;
|
|
307
|
+
if (incoming.length <= remaining) {
|
|
308
|
+
results.push(...incoming);
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
results.push(...incoming.slice(0, remaining));
|
|
312
|
+
truncated = true;
|
|
313
|
+
console.warn(`⚠️ Job ${job.jobId}: maxIssuesPerJob limit (${maxIssues}) reached, truncating results`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
truncated = true;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
// Detect timeout vs. other failures
|
|
323
|
+
const isTimeout = error instanceof Error && error.code === 'TIMEOUT';
|
|
324
|
+
task.status = isTimeout ? 'timeout' : 'failed';
|
|
325
|
+
task.endTime = new Date();
|
|
326
|
+
task.error = error instanceof Error ? error.message : String(error);
|
|
327
|
+
job.progress.runningTasks--;
|
|
328
|
+
if (isTimeout) {
|
|
329
|
+
job.progress.timeoutTasks++;
|
|
330
|
+
console.error(`⏱️ Task ${task.taskId} timed out`);
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
job.progress.failedTasks++;
|
|
334
|
+
console.error(`❌ Task ${task.taskId} failed:`, task.error);
|
|
335
|
+
}
|
|
336
|
+
errors.push(task.error);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
// Job complete - determine final status
|
|
340
|
+
job.endTime = new Date();
|
|
341
|
+
if (job.progress.timeoutTasks > 0) {
|
|
342
|
+
job.status = 'timeout';
|
|
343
|
+
}
|
|
344
|
+
else if (errors.length > 0) {
|
|
345
|
+
job.status = 'completed_with_errors';
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
job.status = 'completed';
|
|
349
|
+
}
|
|
350
|
+
// Aggregate and store results
|
|
351
|
+
const taskResult = job.tasks[0]?.result;
|
|
352
|
+
// Avoid duplicating issues in executionDetails — only store metadata
|
|
353
|
+
const executionDetails = taskResult ? {
|
|
354
|
+
...taskResult,
|
|
355
|
+
issues: [], // issues live in results[], not duplicated here
|
|
356
|
+
issueCount: taskResult.issueCount
|
|
357
|
+
} : {
|
|
358
|
+
rulesetName: job.rulesetName,
|
|
359
|
+
rulesetVersion: job.rulesetVersion,
|
|
360
|
+
executionTime: job.endTime.getTime() - job.startTime.getTime(),
|
|
361
|
+
success: errors.length === 0,
|
|
362
|
+
issueCount: results.length,
|
|
363
|
+
issues: [], // issues live in results[], not duplicated here
|
|
364
|
+
metadata: {
|
|
365
|
+
ruleEngine: 'spectral',
|
|
366
|
+
documentId: job.documentId
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
const jobResult = {
|
|
370
|
+
jobId: job.jobId,
|
|
371
|
+
documentId: job.documentId,
|
|
372
|
+
rulesetName: job.rulesetName,
|
|
373
|
+
rulesetVersion: job.rulesetVersion,
|
|
374
|
+
ruleOverrides: job.ruleOverrides,
|
|
375
|
+
status: job.status,
|
|
376
|
+
timestamp: job.endTime,
|
|
377
|
+
totalExecutionTime: job.endTime.getTime() - job.startTime.getTime(),
|
|
378
|
+
summary: {
|
|
379
|
+
totalIssues: results.length,
|
|
380
|
+
errorCount: results.filter(r => r.severity === 0).length,
|
|
381
|
+
warningCount: results.filter(r => r.severity === 1).length,
|
|
382
|
+
infoCount: results.filter(r => r.severity === 2).length,
|
|
383
|
+
hintCount: results.filter(r => r.severity === 3).length
|
|
384
|
+
},
|
|
385
|
+
results,
|
|
386
|
+
executionDetails,
|
|
387
|
+
...(truncated ? {
|
|
388
|
+
truncated: true,
|
|
389
|
+
truncationInfo: {
|
|
390
|
+
limit: maxIssues,
|
|
391
|
+
actualCount: actualPreTruncationCount
|
|
392
|
+
}
|
|
393
|
+
} : {})
|
|
394
|
+
};
|
|
395
|
+
// Store result (errors here must not block callback/notification delivery)
|
|
396
|
+
try {
|
|
397
|
+
await this.storage.storeJob(jobResult);
|
|
398
|
+
console.log(`✅ Job ${job.jobId} completed: ${results.length} issues found`);
|
|
399
|
+
}
|
|
400
|
+
catch (error) {
|
|
401
|
+
console.error(`⚠️ Failed to store job result for ${job.jobId}:`, error);
|
|
402
|
+
}
|
|
403
|
+
// Notify Report Service (fire-and-forget, non-blocking)
|
|
404
|
+
// Runs independently — failures here never block callback delivery
|
|
405
|
+
this.notifyReportService(jobResult).catch(err => {
|
|
406
|
+
console.warn(`⚠️ Report Service notification failed for job ${job.jobId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
407
|
+
});
|
|
408
|
+
// Deliver callback if URL was provided (fire-and-forget)
|
|
409
|
+
// Always attempted regardless of storage or notification outcome
|
|
410
|
+
if (job.callbackUrl) {
|
|
411
|
+
this.deliverCallback(job.callbackUrl, jobResult).catch(err => {
|
|
412
|
+
console.warn(`⚠️ Callback delivery failed for job ${job.jobId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Execute task with exponential backoff retry
|
|
418
|
+
*/
|
|
419
|
+
async executeTaskWithRetry(task) {
|
|
420
|
+
let lastError;
|
|
421
|
+
const backoff = this.config.workerPool?.exponentialBackoff || {
|
|
422
|
+
initialDelay: 1000,
|
|
423
|
+
maxDelay: 30000,
|
|
424
|
+
multiplier: 2
|
|
425
|
+
};
|
|
426
|
+
for (let attempt = 1; attempt <= task.maxAttempts; attempt++) {
|
|
427
|
+
try {
|
|
428
|
+
// Execute task via worker pool
|
|
429
|
+
const request = {
|
|
430
|
+
taskId: task.taskId,
|
|
431
|
+
documentId: task.documentId,
|
|
432
|
+
rulesetName: task.rulesetName,
|
|
433
|
+
rulesetVersion: task.rulesetVersion,
|
|
434
|
+
ruleOverrides: task.ruleOverrides,
|
|
435
|
+
timeout: this.config.workerPool?.taskTimeout
|
|
436
|
+
};
|
|
437
|
+
const result = await this.workerPool.executeTask(request);
|
|
438
|
+
if (!result.success) {
|
|
439
|
+
throw new Error(result.error || 'Task execution failed');
|
|
440
|
+
}
|
|
441
|
+
return result;
|
|
442
|
+
}
|
|
443
|
+
catch (error) {
|
|
444
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
445
|
+
if (attempt < task.maxAttempts) {
|
|
446
|
+
// Calculate delay with exponential backoff
|
|
447
|
+
const delay = Math.min(backoff.initialDelay * Math.pow(backoff.multiplier, attempt - 1), backoff.maxDelay);
|
|
448
|
+
console.log(`⚠️ Task ${task.taskId} attempt ${attempt} failed, retrying in ${delay}ms...`);
|
|
449
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
throw lastError || new Error('Task failed after all retry attempts');
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Get job status
|
|
457
|
+
*/
|
|
458
|
+
async getJobStatus(jobId) {
|
|
459
|
+
return this.jobs.get(jobId) || null;
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Get job result
|
|
463
|
+
*/
|
|
464
|
+
async getJobResult(jobId) {
|
|
465
|
+
const job = this.jobs.get(jobId);
|
|
466
|
+
if (!job) {
|
|
467
|
+
// Try to retrieve from storage
|
|
468
|
+
return await this.storage.retrieveJobById(jobId);
|
|
469
|
+
}
|
|
470
|
+
// If job still in progress, return null
|
|
471
|
+
if (job.status === 'queued' || job.status === 'running') {
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
// Retrieve from storage
|
|
475
|
+
return await this.storage.retrieveJobById(jobId);
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Check cache for existing result
|
|
479
|
+
*/
|
|
480
|
+
async checkCache(documentId, rulesetName, rulesetVersion, ruleOverrides) {
|
|
481
|
+
// Skip cache when rule overrides are present — overrides make results unique
|
|
482
|
+
if (ruleOverrides && Object.keys(ruleOverrides).length > 0) {
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
try {
|
|
486
|
+
const version = rulesetVersion || this.rulesetLoader.getMetadata(rulesetName).defaultVersion;
|
|
487
|
+
const exists = await this.storage.exists(documentId, rulesetName, version);
|
|
488
|
+
if (exists) {
|
|
489
|
+
return await this.storage.retrieveJob(documentId, rulesetName, version);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
catch (error) {
|
|
493
|
+
// Cache check failed, continue with new job
|
|
494
|
+
console.warn('Cache check failed:', error);
|
|
495
|
+
}
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Invalidate cache for a document
|
|
500
|
+
*/
|
|
501
|
+
async invalidateCache(documentId) {
|
|
502
|
+
await this.storage.invalidate(documentId);
|
|
503
|
+
console.log(`🗑️ Cache invalidated for document: ${documentId}`);
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Get orchestrator statistics
|
|
507
|
+
*/
|
|
508
|
+
getStats() {
|
|
509
|
+
const workerStats = this.workerPool.getStats();
|
|
510
|
+
const maxConcurrent = this.config.maxConcurrentJobs ?? 100;
|
|
511
|
+
const jobStats = {
|
|
512
|
+
total: this.jobs.size,
|
|
513
|
+
queued: 0,
|
|
514
|
+
running: 0,
|
|
515
|
+
completed: 0,
|
|
516
|
+
failed: 0
|
|
517
|
+
};
|
|
518
|
+
for (const job of this.jobs.values()) {
|
|
519
|
+
if (job.status === 'queued')
|
|
520
|
+
jobStats.queued++;
|
|
521
|
+
else if (job.status === 'running')
|
|
522
|
+
jobStats.running++;
|
|
523
|
+
else if (job.status === 'completed' || job.status === 'completed_with_errors')
|
|
524
|
+
jobStats.completed++;
|
|
525
|
+
else if (job.status === 'failed')
|
|
526
|
+
jobStats.failed++;
|
|
527
|
+
}
|
|
528
|
+
return {
|
|
529
|
+
jobs: jobStats,
|
|
530
|
+
capacity: {
|
|
531
|
+
activeJobs: this.activeJobCount,
|
|
532
|
+
maxConcurrentJobs: maxConcurrent,
|
|
533
|
+
utilizationPercent: maxConcurrent > 0
|
|
534
|
+
? Math.round((this.activeJobCount / maxConcurrent) * 100)
|
|
535
|
+
: 0
|
|
536
|
+
},
|
|
537
|
+
cache: {
|
|
538
|
+
hits: this.cacheHits,
|
|
539
|
+
misses: this.cacheMisses,
|
|
540
|
+
hitRate: this.cacheHits + this.cacheMisses > 0
|
|
541
|
+
? this.cacheHits / (this.cacheHits + this.cacheMisses)
|
|
542
|
+
: 0
|
|
543
|
+
},
|
|
544
|
+
workers: {
|
|
545
|
+
total: workerStats.totalWorkers,
|
|
546
|
+
active: workerStats.busyWorkers,
|
|
547
|
+
idle: workerStats.readyWorkers
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* List jobs with filtering and pagination (lightweight - documentId only)
|
|
553
|
+
*/
|
|
554
|
+
async listJobs(options, runtimeSessionId) {
|
|
555
|
+
if (!this.storage.listJobs) {
|
|
556
|
+
throw new Error('Storage adapter does not support job listing');
|
|
557
|
+
}
|
|
558
|
+
// Add runtime session ID filter if provided
|
|
559
|
+
const filterOptions = runtimeSessionId
|
|
560
|
+
? { ...options, sessionId: runtimeSessionId }
|
|
561
|
+
: options;
|
|
562
|
+
const jobs = await this.storage.listJobs(filterOptions);
|
|
563
|
+
// Calculate pagination
|
|
564
|
+
const limit = options?.limit || 50;
|
|
565
|
+
const offset = options?.offset || 0;
|
|
566
|
+
const total = jobs.length; // Note: This is the filtered count, not total in storage
|
|
567
|
+
const hasMore = total > offset + limit;
|
|
568
|
+
return {
|
|
569
|
+
jobs,
|
|
570
|
+
pagination: {
|
|
571
|
+
total,
|
|
572
|
+
limit,
|
|
573
|
+
offset,
|
|
574
|
+
hasMore
|
|
575
|
+
},
|
|
576
|
+
sessionId: runtimeSessionId || 'unknown'
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* List jobs with document metadata enrichment (detailed)
|
|
581
|
+
*/
|
|
582
|
+
async listJobsDetailed(options, runtimeSessionId) {
|
|
583
|
+
// First get lightweight jobs
|
|
584
|
+
const response = await this.listJobs(options, runtimeSessionId);
|
|
585
|
+
// Enrich with document metadata
|
|
586
|
+
const enrichedJobs = await Promise.all(response.jobs.map(async (job) => {
|
|
587
|
+
try {
|
|
588
|
+
const storedDoc = await this.documentStore.getDocument(job.documentId);
|
|
589
|
+
if (!storedDoc) {
|
|
590
|
+
// Document not found - return minimal metadata
|
|
591
|
+
return {
|
|
592
|
+
...job,
|
|
593
|
+
document: {
|
|
594
|
+
documentId: job.documentId,
|
|
595
|
+
name: 'Document not found',
|
|
596
|
+
format: 'json'
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
// Map document-store metadata to our DocumentMetadata type
|
|
601
|
+
const metadata = storedDoc.metadata;
|
|
602
|
+
return {
|
|
603
|
+
...job,
|
|
604
|
+
document: {
|
|
605
|
+
documentId: metadata.id,
|
|
606
|
+
name: metadata.name || metadata.filename || 'Unknown',
|
|
607
|
+
version: metadata.version,
|
|
608
|
+
organization: metadata.organization,
|
|
609
|
+
tags: metadata.tags,
|
|
610
|
+
format: metadata.format,
|
|
611
|
+
operationCount: metadata.operationCount,
|
|
612
|
+
uploadedAt: metadata.uploadedAt,
|
|
613
|
+
updatedAt: metadata.updatedAt,
|
|
614
|
+
uploadedBy: metadata.uploadedBy,
|
|
615
|
+
size: metadata.size
|
|
616
|
+
}
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
catch (error) {
|
|
620
|
+
console.error(`Failed to load metadata for document ${job.documentId}:`, error);
|
|
621
|
+
// Return job with minimal document metadata on error
|
|
622
|
+
return {
|
|
623
|
+
...job,
|
|
624
|
+
document: {
|
|
625
|
+
documentId: job.documentId,
|
|
626
|
+
name: 'Metadata unavailable',
|
|
627
|
+
format: 'json'
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
}));
|
|
632
|
+
return {
|
|
633
|
+
...response,
|
|
634
|
+
jobs: enrichedJobs
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Get lint activity for a specific document
|
|
639
|
+
*/
|
|
640
|
+
async getDocumentLintActivity(documentId) {
|
|
641
|
+
if (!this.storage.getDocumentLintActivity) {
|
|
642
|
+
throw new Error('Storage adapter does not support document lint activity');
|
|
643
|
+
}
|
|
644
|
+
return this.storage.getDocumentLintActivity(documentId);
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Build JobNotification from LintJobResult for Report Service
|
|
648
|
+
*/
|
|
649
|
+
async buildJobNotification(jobResult) {
|
|
650
|
+
// Get document metadata from document store
|
|
651
|
+
let documentMetadata = {
|
|
652
|
+
name: `document-${jobResult.documentId}`,
|
|
653
|
+
format: 'openapi'
|
|
654
|
+
};
|
|
655
|
+
try {
|
|
656
|
+
const storedDoc = await this.documentStore.getDocument(jobResult.documentId);
|
|
657
|
+
if (storedDoc?.metadata) {
|
|
658
|
+
// Map file format (yaml/json) to API spec type (openapi/swagger/asyncapi)
|
|
659
|
+
// Report Service expects spec type, not file format
|
|
660
|
+
let apiFormat = storedDoc.metadata.format || 'openapi';
|
|
661
|
+
// If format is a file format (json/yaml), default to openapi
|
|
662
|
+
if (apiFormat === 'json' || apiFormat === 'yaml') {
|
|
663
|
+
apiFormat = 'openapi';
|
|
664
|
+
}
|
|
665
|
+
// Ensure format is one of the allowed values
|
|
666
|
+
if (!['openapi', 'swagger', 'asyncapi', 'unknown'].includes(apiFormat)) {
|
|
667
|
+
apiFormat = 'openapi';
|
|
668
|
+
}
|
|
669
|
+
documentMetadata = {
|
|
670
|
+
name: storedDoc.metadata.name || `document-${jobResult.documentId}`,
|
|
671
|
+
version: storedDoc.metadata.version,
|
|
672
|
+
organization: storedDoc.metadata.organization,
|
|
673
|
+
format: apiFormat
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
catch (error) {
|
|
678
|
+
console.warn(`Could not retrieve document metadata for ${jobResult.documentId}:`, error);
|
|
679
|
+
}
|
|
680
|
+
// Map job status
|
|
681
|
+
let notificationStatus;
|
|
682
|
+
if (jobResult.status === 'timeout') {
|
|
683
|
+
notificationStatus = 'timeout';
|
|
684
|
+
}
|
|
685
|
+
else if (jobResult.status === 'completed_with_errors' || jobResult.status === 'failed') {
|
|
686
|
+
notificationStatus = 'failed';
|
|
687
|
+
}
|
|
688
|
+
else {
|
|
689
|
+
notificationStatus = 'completed';
|
|
690
|
+
}
|
|
691
|
+
// Transform issues to match Report Service format
|
|
692
|
+
const transformedIssues = jobResult.results.map(issue => ({
|
|
693
|
+
code: issue.code,
|
|
694
|
+
message: issue.message,
|
|
695
|
+
severity: issue.severity,
|
|
696
|
+
path: issue.path.join('.'), // Convert path array to string
|
|
697
|
+
range: issue.range,
|
|
698
|
+
source: issue.ruleId
|
|
699
|
+
}));
|
|
700
|
+
const rulesetResult = {
|
|
701
|
+
rulesetName: jobResult.rulesetName,
|
|
702
|
+
rulesetVersion: jobResult.rulesetVersion,
|
|
703
|
+
status: notificationStatus,
|
|
704
|
+
issues: transformedIssues,
|
|
705
|
+
summary: {
|
|
706
|
+
errorCount: jobResult.summary.errorCount,
|
|
707
|
+
warningCount: jobResult.summary.warningCount,
|
|
708
|
+
infoCount: jobResult.summary.infoCount,
|
|
709
|
+
hintCount: jobResult.summary.hintCount,
|
|
710
|
+
totalIssues: jobResult.summary.totalIssues
|
|
711
|
+
},
|
|
712
|
+
durationMs: jobResult.executionDetails.executionTime
|
|
713
|
+
};
|
|
714
|
+
const notification = {
|
|
715
|
+
jobId: jobResult.jobId,
|
|
716
|
+
documentId: jobResult.documentId,
|
|
717
|
+
status: notificationStatus,
|
|
718
|
+
results: [rulesetResult],
|
|
719
|
+
summary: {
|
|
720
|
+
totalIssues: jobResult.summary.totalIssues,
|
|
721
|
+
errorCount: jobResult.summary.errorCount,
|
|
722
|
+
warningCount: jobResult.summary.warningCount,
|
|
723
|
+
infoCount: jobResult.summary.infoCount,
|
|
724
|
+
hintCount: jobResult.summary.hintCount,
|
|
725
|
+
durationMs: jobResult.totalExecutionTime
|
|
726
|
+
},
|
|
727
|
+
metadata: documentMetadata,
|
|
728
|
+
timestamp: jobResult.timestamp.toISOString()
|
|
729
|
+
};
|
|
730
|
+
return notification;
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Notify Report Service about completed job (fire-and-forget)
|
|
734
|
+
*
|
|
735
|
+
* On errors, checks compatibility to diagnose version incompatibility issues
|
|
736
|
+
*/
|
|
737
|
+
async notifyReportService(jobResult) {
|
|
738
|
+
if (!this.reportClient) {
|
|
739
|
+
return; // Report Service not configured or unavailable
|
|
740
|
+
}
|
|
741
|
+
try {
|
|
742
|
+
const notification = await this.buildJobNotification(jobResult);
|
|
743
|
+
await this.reportClient.notify(notification);
|
|
744
|
+
// Fire-and-forget - don't wait for or log success
|
|
745
|
+
}
|
|
746
|
+
catch (error) {
|
|
747
|
+
// Error occurred - check if it's due to version incompatibility
|
|
748
|
+
try {
|
|
749
|
+
const compatibility = await this.reportClient.checkCompatibility();
|
|
750
|
+
if (!compatibility.compatible) {
|
|
751
|
+
console.warn(`⚠️ Report Service version incompatible - notifications will queue to pending directory`);
|
|
752
|
+
console.warn(` Client: ${compatibility.clientVersion}, Server expects: ${compatibility.serverExpectedVersion}, Server: ${compatibility.serverVersion}`);
|
|
753
|
+
if (compatibility.details) {
|
|
754
|
+
console.warn(` ${compatibility.details}`);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
else {
|
|
758
|
+
// Compatible but notification failed for another reason (network, etc.)
|
|
759
|
+
console.debug(`Report Service notification queued for job ${jobResult.jobId}: ${error.message}`);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
catch (compatError) {
|
|
763
|
+
// Couldn't check compatibility (service unreachable)
|
|
764
|
+
console.debug(`Report Service notification queued for job ${jobResult.jobId} (service unreachable)`);
|
|
765
|
+
}
|
|
766
|
+
// Client handles retries and pending storage internally - error is non-fatal
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Deliver job result to callback URL (fire-and-forget).
|
|
771
|
+
* Attempts one delivery with a 10s timeout.
|
|
772
|
+
*/
|
|
773
|
+
async deliverCallback(callbackUrl, jobResult) {
|
|
774
|
+
const controller = new AbortController();
|
|
775
|
+
const timeout = setTimeout(() => controller.abort(), 10000);
|
|
776
|
+
try {
|
|
777
|
+
const response = await fetch(callbackUrl, {
|
|
778
|
+
method: 'POST',
|
|
779
|
+
headers: { 'Content-Type': 'application/json' },
|
|
780
|
+
body: JSON.stringify({
|
|
781
|
+
jobId: jobResult.jobId,
|
|
782
|
+
status: jobResult.status,
|
|
783
|
+
documentId: jobResult.documentId,
|
|
784
|
+
rulesetName: jobResult.rulesetName,
|
|
785
|
+
rulesetVersion: jobResult.rulesetVersion,
|
|
786
|
+
summary: jobResult.summary,
|
|
787
|
+
results: jobResult.results,
|
|
788
|
+
totalExecutionTime: jobResult.totalExecutionTime,
|
|
789
|
+
timestamp: jobResult.timestamp
|
|
790
|
+
}),
|
|
791
|
+
signal: controller.signal
|
|
792
|
+
});
|
|
793
|
+
if (!response.ok) {
|
|
794
|
+
console.warn(`⚠️ Callback returned ${response.status} for job ${jobResult.jobId}`);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
finally {
|
|
798
|
+
clearTimeout(timeout);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Check if Report Service client is active
|
|
803
|
+
*/
|
|
804
|
+
hasReportClient() {
|
|
805
|
+
return this.reportServiceConfig?.enabled === true;
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Get Report Service client status
|
|
809
|
+
*
|
|
810
|
+
* Note: Compatibility check is done once at startup, not on every health check
|
|
811
|
+
* for performance reasons. This method returns current client state only.
|
|
812
|
+
*/
|
|
813
|
+
async getReportClientStatus() {
|
|
814
|
+
// Not configured at all
|
|
815
|
+
if (!this.reportServiceConfig?.enabled || !this.reportClient) {
|
|
816
|
+
return null;
|
|
817
|
+
}
|
|
818
|
+
// Client exists - get its status (client handles connection internally)
|
|
819
|
+
try {
|
|
820
|
+
const status = await this.reportClient.getStatus();
|
|
821
|
+
// Determine connection status from actual reachability
|
|
822
|
+
let connectionStatus;
|
|
823
|
+
if (status.reachable) {
|
|
824
|
+
connectionStatus = 'connected';
|
|
825
|
+
}
|
|
826
|
+
else if (status.pendingNotifications > 0) {
|
|
827
|
+
connectionStatus = 'degraded';
|
|
828
|
+
}
|
|
829
|
+
else {
|
|
830
|
+
connectionStatus = 'unreachable';
|
|
831
|
+
}
|
|
832
|
+
return {
|
|
833
|
+
enabled: status.enabled,
|
|
834
|
+
status: connectionStatus,
|
|
835
|
+
serviceUrl: status.serviceUrl,
|
|
836
|
+
pendingNotifications: status.pendingNotifications,
|
|
837
|
+
retryJobRunning: status.retryJobRunning,
|
|
838
|
+
retryJobInterval: status.retryJobInterval,
|
|
839
|
+
lastRetryRun: status.lastRetryRun,
|
|
840
|
+
nextRetryAt: status.nextRetryAt,
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
catch (error) {
|
|
844
|
+
return {
|
|
845
|
+
enabled: true,
|
|
846
|
+
status: 'error',
|
|
847
|
+
serviceUrl: this.reportServiceConfig.url,
|
|
848
|
+
error: error instanceof Error ? error.message : 'Unknown error'
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* Shutdown orchestrator
|
|
854
|
+
*/
|
|
855
|
+
async shutdown() {
|
|
856
|
+
console.log('🛑 Shutting down orchestrator...');
|
|
857
|
+
// Shutdown Report Service client if active
|
|
858
|
+
if (this.reportClient) {
|
|
859
|
+
console.log('🔗 Shutting down Report Service client...');
|
|
860
|
+
await this.reportClient.shutdown();
|
|
861
|
+
}
|
|
862
|
+
// Wait for running jobs to complete (with timeout)
|
|
863
|
+
const timeout = 30000; // 30s
|
|
864
|
+
const start = Date.now();
|
|
865
|
+
let stats = await this.getStats();
|
|
866
|
+
while (stats.jobs.running > 0 && Date.now() - start < timeout) {
|
|
867
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
868
|
+
stats = await this.getStats();
|
|
869
|
+
}
|
|
870
|
+
this.initialized = false;
|
|
871
|
+
console.log('✅ Orchestrator shutdown complete');
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
//# sourceMappingURL=orchestrator.js.map
|