@onlineapps/conn-orch-orchestrator 1.0.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/API.md +195 -0
- package/README.md +585 -0
- package/jest.config.js +29 -0
- package/jest.setup.js +38 -0
- package/package.json +41 -0
- package/src/WorkflowOrchestrator.js +341 -0
- package/src/index.js +43 -0
- package/test/component/orchestrator.component.test.js +378 -0
- package/test/integration/orchestrator.integration.test.js +313 -0
- package/test/unit/WorkflowOrchestrator.test.js +253 -0
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@onlineapps/conn-orch-orchestrator",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Workflow orchestration connector for OA Drive - handles message routing and workflow execution",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "jest",
|
|
8
|
+
"test:watch": "jest --watch",
|
|
9
|
+
"test:coverage": "jest --coverage",
|
|
10
|
+
"lint": "eslint src/",
|
|
11
|
+
"docs": "jsdoc2md --files src/**/*.js > API.md"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"workflow",
|
|
15
|
+
"orchestration",
|
|
16
|
+
"message-routing",
|
|
17
|
+
"cookbook",
|
|
18
|
+
"connector"
|
|
19
|
+
],
|
|
20
|
+
"author": "OA Drive Team",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@onlineapps/conn-base-logger": "^1.0.0",
|
|
24
|
+
"@onlineapps/conn-infra-mq": "^1.0.0",
|
|
25
|
+
"@onlineapps/conn-orch-registry": "^1.0.0",
|
|
26
|
+
"@onlineapps/conn-orch-cookbook": "^1.0.0",
|
|
27
|
+
"@onlineapps/conn-orch-api-mapper": "^1.0.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"jest": "^29.5.0",
|
|
31
|
+
"eslint": "^8.30.0",
|
|
32
|
+
"jsdoc-to-markdown": "^8.0.0"
|
|
33
|
+
},
|
|
34
|
+
"jest": {
|
|
35
|
+
"testEnvironment": "node",
|
|
36
|
+
"coverageDirectory": "coverage",
|
|
37
|
+
"collectCoverageFrom": [
|
|
38
|
+
"src/**/*.js"
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @class WorkflowOrchestrator
|
|
5
|
+
* @description Main orchestration class that coordinates all workflow execution.
|
|
6
|
+
* Handles message routing, step execution, and workflow state management.
|
|
7
|
+
*
|
|
8
|
+
* This is the core logic moved from service-wrapper's WorkflowProcessor to make
|
|
9
|
+
* service-wrapper a true thin orchestration layer.
|
|
10
|
+
*/
|
|
11
|
+
class WorkflowOrchestrator {
|
|
12
|
+
/**
|
|
13
|
+
* Create a new WorkflowOrchestrator instance
|
|
14
|
+
* @constructor
|
|
15
|
+
* @param {Object} config - Configuration object
|
|
16
|
+
* @param {Object} config.mqClient - MQ client for message operations
|
|
17
|
+
* @param {Object} config.registryClient - Registry client for service discovery
|
|
18
|
+
* @param {Object} config.apiMapper - API mapper for cookbook to HTTP mapping
|
|
19
|
+
* @param {Object} config.cookbook - Cookbook connector for validation and execution
|
|
20
|
+
* @param {Object} [config.cache] - Cache connector for caching
|
|
21
|
+
* @param {Object} [config.errorHandler] - Error handler connector
|
|
22
|
+
* @param {Object} [config.logger] - Logger instance
|
|
23
|
+
* @param {number} [config.defaultTimeout=30000] - Default timeout for operations
|
|
24
|
+
*/
|
|
25
|
+
constructor(config) {
|
|
26
|
+
if (!config.mqClient) throw new Error('mqClient is required');
|
|
27
|
+
if (!config.registryClient) throw new Error('registryClient is required');
|
|
28
|
+
if (!config.apiMapper) throw new Error('apiMapper is required');
|
|
29
|
+
if (!config.cookbook) throw new Error('cookbook is required');
|
|
30
|
+
|
|
31
|
+
this.mqClient = config.mqClient;
|
|
32
|
+
this.registryClient = config.registryClient;
|
|
33
|
+
this.apiMapper = config.apiMapper;
|
|
34
|
+
this.cookbook = config.cookbook;
|
|
35
|
+
this.cache = config.cache;
|
|
36
|
+
this.errorHandler = config.errorHandler;
|
|
37
|
+
this.logger = config.logger || console;
|
|
38
|
+
this.defaultTimeout = config.defaultTimeout || 30000;
|
|
39
|
+
|
|
40
|
+
// Create cookbook router
|
|
41
|
+
this.router = this.cookbook.createRouter(this.mqClient, this.registryClient, {
|
|
42
|
+
logger: this.logger
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Process a workflow message
|
|
48
|
+
* @async
|
|
49
|
+
* @method processWorkflowMessage
|
|
50
|
+
* @param {Object} message - Workflow message from queue
|
|
51
|
+
* @param {string} message.workflow_id - Workflow ID
|
|
52
|
+
* @param {Object} message.cookbook - Cookbook definition
|
|
53
|
+
* @param {string} message.current_step - Current step ID
|
|
54
|
+
* @param {Object} message.context - Workflow context
|
|
55
|
+
* @param {string} serviceName - Name of the service processing this
|
|
56
|
+
* @returns {Promise<Object>} Processing result
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* const result = await orchestrator.processWorkflowMessage({
|
|
60
|
+
* workflow_id: 'wf-123',
|
|
61
|
+
* cookbook: cookbookDef,
|
|
62
|
+
* current_step: 'step1',
|
|
63
|
+
* context: {}
|
|
64
|
+
* }, 'hello-service');
|
|
65
|
+
*/
|
|
66
|
+
async processWorkflowMessage(message, serviceName) {
|
|
67
|
+
const { workflow_id, cookbook: cookbookDef, current_step, context } = message;
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
// Validate cookbook structure
|
|
71
|
+
this.cookbook.validateCookbook(cookbookDef);
|
|
72
|
+
|
|
73
|
+
// Find current step
|
|
74
|
+
const step = cookbookDef.steps.find(s => s.id === current_step);
|
|
75
|
+
if (!step) {
|
|
76
|
+
throw new Error(`Step not found: ${current_step}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Check if this step is for this service
|
|
80
|
+
if (step.type === 'task' && step.service !== serviceName) {
|
|
81
|
+
this.logger.warn(`Step ${current_step} is for ${step.service}, not ${serviceName}`);
|
|
82
|
+
await this.router.routeToService(step.service, message);
|
|
83
|
+
return { skipped: true, reason: 'wrong_service' };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Check cache if available
|
|
87
|
+
let result;
|
|
88
|
+
if (this.cache && step.type === 'task') {
|
|
89
|
+
const cacheKey = this._getCacheKey(step, context);
|
|
90
|
+
result = await this.cache.get(cacheKey);
|
|
91
|
+
|
|
92
|
+
if (result) {
|
|
93
|
+
this.logger.info('Cache hit', { step: step.id, cacheKey });
|
|
94
|
+
} else {
|
|
95
|
+
// Execute the step
|
|
96
|
+
result = await this._executeStep(step, context, cookbookDef);
|
|
97
|
+
await this.cache.set(cacheKey, result, { ttl: 300 });
|
|
98
|
+
}
|
|
99
|
+
} else {
|
|
100
|
+
// Execute without cache
|
|
101
|
+
result = await this._executeStep(step, context, cookbookDef);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Update context with result
|
|
105
|
+
const updatedContext = {
|
|
106
|
+
...context,
|
|
107
|
+
steps: {
|
|
108
|
+
...context.steps,
|
|
109
|
+
[current_step]: result
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// Find next step
|
|
114
|
+
const currentIndex = cookbookDef.steps.findIndex(s => s.id === current_step);
|
|
115
|
+
const nextStep = cookbookDef.steps[currentIndex + 1];
|
|
116
|
+
|
|
117
|
+
if (nextStep) {
|
|
118
|
+
// Route to next step
|
|
119
|
+
await this._routeToNextStep(nextStep, cookbookDef, updatedContext, workflow_id);
|
|
120
|
+
} else {
|
|
121
|
+
// Workflow completed
|
|
122
|
+
await this._completeWorkflow(workflow_id, updatedContext);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
success: true,
|
|
127
|
+
workflow_id,
|
|
128
|
+
step_id: current_step,
|
|
129
|
+
result
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
} catch (error) {
|
|
133
|
+
this.logger.error(`Workflow processing failed: ${error.message}`, {
|
|
134
|
+
workflow_id,
|
|
135
|
+
current_step,
|
|
136
|
+
error: error.stack
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Handle error
|
|
140
|
+
if (this.errorHandler) {
|
|
141
|
+
await this.errorHandler.handleError(error, {
|
|
142
|
+
workflow_id,
|
|
143
|
+
current_step,
|
|
144
|
+
service: serviceName,
|
|
145
|
+
context
|
|
146
|
+
});
|
|
147
|
+
} else {
|
|
148
|
+
// Fallback to router DLQ
|
|
149
|
+
await this.router.routeToDLQ(cookbookDef, context, error, serviceName);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Execute a single step
|
|
158
|
+
* @private
|
|
159
|
+
* @async
|
|
160
|
+
* @param {Object} step - Step to execute
|
|
161
|
+
* @param {Object} context - Execution context
|
|
162
|
+
* @param {Object} cookbookDef - Full cookbook definition
|
|
163
|
+
* @returns {Promise<Object>} Step result
|
|
164
|
+
*/
|
|
165
|
+
async _executeStep(step, context, cookbookDef) {
|
|
166
|
+
if (step.type === 'task') {
|
|
167
|
+
// Task step - call service API
|
|
168
|
+
return await this._executeTaskStep(step, context);
|
|
169
|
+
} else {
|
|
170
|
+
// Control flow step - use cookbook executor
|
|
171
|
+
return await this._executeControlFlowStep(step, context, cookbookDef);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Execute a task step by calling service API
|
|
177
|
+
* @private
|
|
178
|
+
* @async
|
|
179
|
+
* @param {Object} step - Task step
|
|
180
|
+
* @param {Object} context - Execution context
|
|
181
|
+
* @returns {Promise<Object>} API call result
|
|
182
|
+
*/
|
|
183
|
+
async _executeTaskStep(step, context) {
|
|
184
|
+
// Use API mapper to call the service
|
|
185
|
+
const result = await this.apiMapper.callOperation(
|
|
186
|
+
step.operation || step.id,
|
|
187
|
+
step.input,
|
|
188
|
+
context
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
this.logger.info(`Task step executed`, {
|
|
192
|
+
step: step.id,
|
|
193
|
+
service: step.service,
|
|
194
|
+
operation: step.operation
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Execute control flow step (foreach, switch, fork_join, etc)
|
|
202
|
+
* @private
|
|
203
|
+
* @async
|
|
204
|
+
* @param {Object} step - Control flow step
|
|
205
|
+
* @param {Object} context - Execution context
|
|
206
|
+
* @param {Object} cookbookDef - Full cookbook definition
|
|
207
|
+
* @returns {Promise<Object>} Control flow result
|
|
208
|
+
*/
|
|
209
|
+
async _executeControlFlowStep(step, context, cookbookDef) {
|
|
210
|
+
const { CookbookExecutor } = this.cookbook;
|
|
211
|
+
|
|
212
|
+
// Create executor for control flow
|
|
213
|
+
const executor = new CookbookExecutor(
|
|
214
|
+
{ steps: [step] },
|
|
215
|
+
{
|
|
216
|
+
logger: this.logger,
|
|
217
|
+
defaultTimeout: this.defaultTimeout
|
|
218
|
+
}
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
// Set context
|
|
222
|
+
executor.context.setInput(context.api_input || {});
|
|
223
|
+
executor.context.data.steps = context.steps || {};
|
|
224
|
+
|
|
225
|
+
// Execute the control flow step
|
|
226
|
+
const result = await executor.executeStep(step);
|
|
227
|
+
|
|
228
|
+
this.logger.info(`Control flow step executed`, {
|
|
229
|
+
step: step.id,
|
|
230
|
+
type: step.type
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
return result;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Route to next step in workflow
|
|
238
|
+
* @private
|
|
239
|
+
* @async
|
|
240
|
+
* @param {Object} nextStep - Next step to execute
|
|
241
|
+
* @param {Object} cookbookDef - Full cookbook definition
|
|
242
|
+
* @param {Object} context - Updated context
|
|
243
|
+
* @param {string} workflow_id - Workflow ID
|
|
244
|
+
*/
|
|
245
|
+
async _routeToNextStep(nextStep, cookbookDef, context, workflow_id) {
|
|
246
|
+
const message = {
|
|
247
|
+
workflow_id,
|
|
248
|
+
cookbook: cookbookDef,
|
|
249
|
+
current_step: nextStep.id,
|
|
250
|
+
context
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
if (nextStep.type === 'task') {
|
|
254
|
+
// Route to specific service
|
|
255
|
+
await this.router.routeToService(nextStep.service, message);
|
|
256
|
+
this.logger.info(`Routed to service: ${nextStep.service}`, {
|
|
257
|
+
step: nextStep.id
|
|
258
|
+
});
|
|
259
|
+
} else {
|
|
260
|
+
// Control flow steps handled by workflow init queue
|
|
261
|
+
await this.mqClient.publish('workflow.init', message);
|
|
262
|
+
this.logger.info(`Routed control flow to workflow.init`, {
|
|
263
|
+
step: nextStep.id
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Complete workflow
|
|
270
|
+
* @private
|
|
271
|
+
* @async
|
|
272
|
+
* @param {string} workflow_id - Workflow ID
|
|
273
|
+
* @param {Object} finalContext - Final workflow context
|
|
274
|
+
*/
|
|
275
|
+
async _completeWorkflow(workflow_id, finalContext) {
|
|
276
|
+
await this.mqClient.publish('workflow.completed', {
|
|
277
|
+
workflow_id,
|
|
278
|
+
result: finalContext,
|
|
279
|
+
completed_at: new Date().toISOString()
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
this.logger.info(`Workflow completed: ${workflow_id}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Generate cache key for a step
|
|
287
|
+
* @private
|
|
288
|
+
* @param {Object} step - Step object
|
|
289
|
+
* @param {Object} context - Execution context
|
|
290
|
+
* @returns {string} Cache key
|
|
291
|
+
*/
|
|
292
|
+
_getCacheKey(step, context) {
|
|
293
|
+
const crypto = require('crypto');
|
|
294
|
+
const data = JSON.stringify({
|
|
295
|
+
service: step.service,
|
|
296
|
+
operation: step.operation || step.id,
|
|
297
|
+
input: step.input,
|
|
298
|
+
contextInput: context.api_input
|
|
299
|
+
});
|
|
300
|
+
const hash = crypto.createHash('sha256').update(data).digest('hex');
|
|
301
|
+
return `workflow:step:${step.service}:${step.operation || step.id}:${hash}`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Generate cookbook from OpenAPI spec (utility method)
|
|
306
|
+
* @method generateCookbook
|
|
307
|
+
* @param {Object} openApiSpec - OpenAPI specification
|
|
308
|
+
* @returns {Object} Generated cookbook
|
|
309
|
+
*
|
|
310
|
+
* @example
|
|
311
|
+
* const cookbook = orchestrator.generateCookbook(openApiSpec);
|
|
312
|
+
*/
|
|
313
|
+
generateCookbook(openApiSpec) {
|
|
314
|
+
const { CookbookGenerator } = this.cookbook;
|
|
315
|
+
|
|
316
|
+
const generator = new CookbookGenerator({
|
|
317
|
+
defaultTimeout: 10000,
|
|
318
|
+
defaultRetry: {
|
|
319
|
+
maxAttempts: 3,
|
|
320
|
+
delayMs: 2000
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
return generator.generate(openApiSpec);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Transform API response using cookbook mapping
|
|
329
|
+
* @method transformResponse
|
|
330
|
+
* @param {Object} response - API response
|
|
331
|
+
* @param {Object} mapping - Output mapping
|
|
332
|
+
* @returns {Object} Transformed response
|
|
333
|
+
*/
|
|
334
|
+
transformResponse(response, mapping) {
|
|
335
|
+
const { ResponseMapper } = this.cookbook;
|
|
336
|
+
const mapper = new ResponseMapper();
|
|
337
|
+
return mapper.mapResponse(response, mapping);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
module.exports = WorkflowOrchestrator;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @module @onlineapps/conn-orch-orchestrator
|
|
5
|
+
* @description Workflow orchestration connector that handles message routing and workflow execution.
|
|
6
|
+
* Coordinates all other connectors to process workflow messages.
|
|
7
|
+
*
|
|
8
|
+
* @see {@link https://github.com/onlineapps/oa-drive/tree/main/shared/connector/conn-orch-orchestrator|GitHub Repository}
|
|
9
|
+
* @author OA Drive Team
|
|
10
|
+
* @license MIT
|
|
11
|
+
* @since 1.0.0
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const WorkflowOrchestrator = require('./WorkflowOrchestrator');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Create orchestrator instance
|
|
18
|
+
* @function create
|
|
19
|
+
* @param {Object} config - Configuration options
|
|
20
|
+
* @param {Object} config.mqClient - MQ client instance
|
|
21
|
+
* @param {Object} config.registryClient - Registry client instance
|
|
22
|
+
* @param {Object} config.apiMapper - API mapper instance
|
|
23
|
+
* @param {Object} config.cookbook - Cookbook connector instance
|
|
24
|
+
* @param {Object} [config.logger] - Logger instance
|
|
25
|
+
* @returns {WorkflowOrchestrator} New orchestrator instance
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* const orchestrator = create({
|
|
29
|
+
* mqClient: new MQClient(),
|
|
30
|
+
* registryClient: new RegistryClient(),
|
|
31
|
+
* apiMapper: new ApiMapper(),
|
|
32
|
+
* cookbook: new CookbookConnector()
|
|
33
|
+
* });
|
|
34
|
+
*/
|
|
35
|
+
function create(config) {
|
|
36
|
+
return new WorkflowOrchestrator(config);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = {
|
|
40
|
+
WorkflowOrchestrator,
|
|
41
|
+
create,
|
|
42
|
+
VERSION: '1.0.0'
|
|
43
|
+
};
|