@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.
Files changed (197) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +5 -0
  3. package/README.md +43 -0
  4. package/build/cli/api-client.d.ts +170 -0
  5. package/build/cli/api-client.d.ts.map +1 -0
  6. package/build/cli/api-client.js +284 -0
  7. package/build/cli/api-client.js.map +1 -0
  8. package/build/cli/commands/agents.d.ts +7 -0
  9. package/build/cli/commands/agents.d.ts.map +1 -0
  10. package/build/cli/commands/agents.js +694 -0
  11. package/build/cli/commands/agents.js.map +1 -0
  12. package/build/cli/commands/completion.d.ts +9 -0
  13. package/build/cli/commands/completion.d.ts.map +1 -0
  14. package/build/cli/commands/completion.js +177 -0
  15. package/build/cli/commands/completion.js.map +1 -0
  16. package/build/cli/commands/config.d.ts +10 -0
  17. package/build/cli/commands/config.d.ts.map +1 -0
  18. package/build/cli/commands/config.js +284 -0
  19. package/build/cli/commands/config.js.map +1 -0
  20. package/build/cli/commands/health.d.ts +11 -0
  21. package/build/cli/commands/health.d.ts.map +1 -0
  22. package/build/cli/commands/health.js +38 -0
  23. package/build/cli/commands/health.js.map +1 -0
  24. package/build/cli/commands/help.d.ts +6 -0
  25. package/build/cli/commands/help.d.ts.map +1 -0
  26. package/build/cli/commands/help.js +20 -0
  27. package/build/cli/commands/help.js.map +1 -0
  28. package/build/cli/commands/history.d.ts +11 -0
  29. package/build/cli/commands/history.d.ts.map +1 -0
  30. package/build/cli/commands/history.js +50 -0
  31. package/build/cli/commands/history.js.map +1 -0
  32. package/build/cli/commands/jobs.d.ts +12 -0
  33. package/build/cli/commands/jobs.d.ts.map +1 -0
  34. package/build/cli/commands/jobs.js +84 -0
  35. package/build/cli/commands/jobs.js.map +1 -0
  36. package/build/cli/commands/lint.d.ts +15 -0
  37. package/build/cli/commands/lint.d.ts.map +1 -0
  38. package/build/cli/commands/lint.js +384 -0
  39. package/build/cli/commands/lint.js.map +1 -0
  40. package/build/cli/commands/ps.d.ts +8 -0
  41. package/build/cli/commands/ps.d.ts.map +1 -0
  42. package/build/cli/commands/ps.js +74 -0
  43. package/build/cli/commands/ps.js.map +1 -0
  44. package/build/cli/commands/reproduce.d.ts +9 -0
  45. package/build/cli/commands/reproduce.d.ts.map +1 -0
  46. package/build/cli/commands/reproduce.js +31 -0
  47. package/build/cli/commands/reproduce.js.map +1 -0
  48. package/build/cli/commands/reset.d.ts +5 -0
  49. package/build/cli/commands/reset.d.ts.map +1 -0
  50. package/build/cli/commands/reset.js +13 -0
  51. package/build/cli/commands/reset.js.map +1 -0
  52. package/build/cli/commands/results.d.ts +13 -0
  53. package/build/cli/commands/results.d.ts.map +1 -0
  54. package/build/cli/commands/results.js +129 -0
  55. package/build/cli/commands/results.js.map +1 -0
  56. package/build/cli/commands/rulesets/check.d.ts +12 -0
  57. package/build/cli/commands/rulesets/check.d.ts.map +1 -0
  58. package/build/cli/commands/rulesets/check.js +226 -0
  59. package/build/cli/commands/rulesets/check.js.map +1 -0
  60. package/build/cli/commands/rulesets/index.d.ts +5 -0
  61. package/build/cli/commands/rulesets/index.d.ts.map +1 -0
  62. package/build/cli/commands/rulesets/index.js +6 -0
  63. package/build/cli/commands/rulesets/index.js.map +1 -0
  64. package/build/cli/commands/rulesets/view.d.ts +16 -0
  65. package/build/cli/commands/rulesets/view.d.ts.map +1 -0
  66. package/build/cli/commands/rulesets/view.js +100 -0
  67. package/build/cli/commands/rulesets/view.js.map +1 -0
  68. package/build/cli/commands/start.d.ts +16 -0
  69. package/build/cli/commands/start.d.ts.map +1 -0
  70. package/build/cli/commands/start.js +167 -0
  71. package/build/cli/commands/start.js.map +1 -0
  72. package/build/cli/commands/status.d.ts +9 -0
  73. package/build/cli/commands/status.d.ts.map +1 -0
  74. package/build/cli/commands/status.js +46 -0
  75. package/build/cli/commands/status.js.map +1 -0
  76. package/build/cli/commands/stop.d.ts +11 -0
  77. package/build/cli/commands/stop.d.ts.map +1 -0
  78. package/build/cli/commands/stop.js +78 -0
  79. package/build/cli/commands/stop.js.map +1 -0
  80. package/build/cli/config-manager.d.ts +134 -0
  81. package/build/cli/config-manager.d.ts.map +1 -0
  82. package/build/cli/config-manager.js +288 -0
  83. package/build/cli/config-manager.js.map +1 -0
  84. package/build/cli/formatters.d.ts +62 -0
  85. package/build/cli/formatters.d.ts.map +1 -0
  86. package/build/cli/formatters.js +715 -0
  87. package/build/cli/formatters.js.map +1 -0
  88. package/build/cli/history-manager.d.ts +97 -0
  89. package/build/cli/history-manager.d.ts.map +1 -0
  90. package/build/cli/history-manager.js +201 -0
  91. package/build/cli/history-manager.js.map +1 -0
  92. package/build/cli/index.d.ts +16 -0
  93. package/build/cli/index.d.ts.map +1 -0
  94. package/build/cli/index.js +335 -0
  95. package/build/cli/index.js.map +1 -0
  96. package/build/cli/list-rulesets.d.ts +15 -0
  97. package/build/cli/list-rulesets.d.ts.map +1 -0
  98. package/build/cli/list-rulesets.js +193 -0
  99. package/build/cli/list-rulesets.js.map +1 -0
  100. package/build/cli/utils/connection-error.d.ts +9 -0
  101. package/build/cli/utils/connection-error.d.ts.map +1 -0
  102. package/build/cli/utils/connection-error.js +30 -0
  103. package/build/cli/utils/connection-error.js.map +1 -0
  104. package/build/cli/utils/embedded-server.d.ts +21 -0
  105. package/build/cli/utils/embedded-server.d.ts.map +1 -0
  106. package/build/cli/utils/embedded-server.js +61 -0
  107. package/build/cli/utils/embedded-server.js.map +1 -0
  108. package/build/cli/utils/mode-validator.d.ts +13 -0
  109. package/build/cli/utils/mode-validator.d.ts.map +1 -0
  110. package/build/cli/utils/mode-validator.js +31 -0
  111. package/build/cli/utils/mode-validator.js.map +1 -0
  112. package/build/cli/utils/port-checker.d.ts +20 -0
  113. package/build/cli/utils/port-checker.d.ts.map +1 -0
  114. package/build/cli/utils/port-checker.js +49 -0
  115. package/build/cli/utils/port-checker.js.map +1 -0
  116. package/build/config.d.ts +57 -0
  117. package/build/config.d.ts.map +1 -0
  118. package/build/config.js +527 -0
  119. package/build/config.js.map +1 -0
  120. package/build/document-accessor.d.ts +79 -0
  121. package/build/document-accessor.d.ts.map +1 -0
  122. package/build/document-accessor.js +148 -0
  123. package/build/document-accessor.js.map +1 -0
  124. package/build/formatters/reproduce-markdown.d.ts +14 -0
  125. package/build/formatters/reproduce-markdown.d.ts.map +1 -0
  126. package/build/formatters/reproduce-markdown.js +182 -0
  127. package/build/formatters/reproduce-markdown.js.map +1 -0
  128. package/build/formatters/sarif-builder.d.ts +86 -0
  129. package/build/formatters/sarif-builder.d.ts.map +1 -0
  130. package/build/formatters/sarif-builder.js +276 -0
  131. package/build/formatters/sarif-builder.js.map +1 -0
  132. package/build/index.d.ts +3 -0
  133. package/build/index.d.ts.map +1 -0
  134. package/build/index.js +174 -0
  135. package/build/index.js.map +1 -0
  136. package/build/logger.d.ts +38 -0
  137. package/build/logger.d.ts.map +1 -0
  138. package/build/logger.js +105 -0
  139. package/build/logger.js.map +1 -0
  140. package/build/mock-server.d.ts +2 -0
  141. package/build/mock-server.d.ts.map +1 -0
  142. package/build/mock-server.js +290 -0
  143. package/build/mock-server.js.map +1 -0
  144. package/build/orchestrator.d.ts +149 -0
  145. package/build/orchestrator.d.ts.map +1 -0
  146. package/build/orchestrator.js +874 -0
  147. package/build/orchestrator.js.map +1 -0
  148. package/build/ruleset-loader.d.ts +79 -0
  149. package/build/ruleset-loader.d.ts.map +1 -0
  150. package/build/ruleset-loader.js +514 -0
  151. package/build/ruleset-loader.js.map +1 -0
  152. package/build/schemas.d.ts +2568 -0
  153. package/build/schemas.d.ts.map +1 -0
  154. package/build/schemas.js +674 -0
  155. package/build/schemas.js.map +1 -0
  156. package/build/server.d.ts +39 -0
  157. package/build/server.d.ts.map +1 -0
  158. package/build/server.js +834 -0
  159. package/build/server.js.map +1 -0
  160. package/build/storage/memory-storage.d.ts +190 -0
  161. package/build/storage/memory-storage.d.ts.map +1 -0
  162. package/build/storage/memory-storage.js +629 -0
  163. package/build/storage/memory-storage.js.map +1 -0
  164. package/build/storage/redis-storage.d.ts +134 -0
  165. package/build/storage/redis-storage.d.ts.map +1 -0
  166. package/build/storage/redis-storage.js +236 -0
  167. package/build/storage/redis-storage.js.map +1 -0
  168. package/build/storage/storage-adapter.d.ts +189 -0
  169. package/build/storage/storage-adapter.d.ts.map +1 -0
  170. package/build/storage/storage-adapter.js +36 -0
  171. package/build/storage/storage-adapter.js.map +1 -0
  172. package/build/types.d.ts +981 -0
  173. package/build/types.d.ts.map +1 -0
  174. package/build/types.js +5 -0
  175. package/build/types.js.map +1 -0
  176. package/build/utils/version.d.ts +40 -0
  177. package/build/utils/version.d.ts.map +1 -0
  178. package/build/utils/version.js +94 -0
  179. package/build/utils/version.js.map +1 -0
  180. package/build/validation.d.ts +95 -0
  181. package/build/validation.d.ts.map +1 -0
  182. package/build/validation.js +150 -0
  183. package/build/validation.js.map +1 -0
  184. package/build/worker-pool.d.ts +137 -0
  185. package/build/worker-pool.d.ts.map +1 -0
  186. package/build/worker-pool.js +549 -0
  187. package/build/worker-pool.js.map +1 -0
  188. package/build/worker.d.ts +2 -0
  189. package/build/worker.d.ts.map +1 -0
  190. package/build/worker.js +427 -0
  191. package/build/worker.js.map +1 -0
  192. package/package.json +110 -0
  193. package/rulesets/CHANGELOG.md +25 -0
  194. package/rulesets/config/rulesets.yaml +96 -0
  195. package/rulesets/sources/README.md +47 -0
  196. package/rulesets/sources/example/oas-recommended/v1.0.0/ruleset.yaml +6 -0
  197. 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