@onlineapps/conn-orch-orchestrator 1.0.63 → 1.0.65
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/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onlineapps/conn-orch-orchestrator",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.65",
|
|
4
4
|
"description": "Workflow orchestration connector for OA Drive - handles message routing and workflow execution",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"test": "jest",
|
|
8
|
-
"test:watch": "jest --watch",
|
|
9
|
-
"test:coverage": "jest --coverage",
|
|
7
|
+
"test": "jest --config jest.config.js",
|
|
8
|
+
"test:watch": "jest --config jest.config.js --watch",
|
|
9
|
+
"test:coverage": "jest --config jest.config.js --coverage",
|
|
10
10
|
"lint": "eslint src/",
|
|
11
11
|
"docs": "jsdoc2md --files src/**/*.js > API.md"
|
|
12
12
|
},
|
|
@@ -24,18 +24,11 @@
|
|
|
24
24
|
"@onlineapps/conn-infra-mq": "^1.1.0",
|
|
25
25
|
"@onlineapps/conn-orch-registry": "1.1.25",
|
|
26
26
|
"@onlineapps/conn-orch-cookbook": "2.0.12",
|
|
27
|
-
"@onlineapps/conn-orch-api-mapper": "1.0.
|
|
27
|
+
"@onlineapps/conn-orch-api-mapper": "1.0.14"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"jest": "^29.5.0",
|
|
31
31
|
"eslint": "^8.30.0",
|
|
32
32
|
"jsdoc-to-markdown": "^8.0.0"
|
|
33
|
-
},
|
|
34
|
-
"jest": {
|
|
35
|
-
"testEnvironment": "node",
|
|
36
|
-
"coverageDirectory": "coverage",
|
|
37
|
-
"collectCoverageFrom": [
|
|
38
|
-
"src/**/*.js"
|
|
39
|
-
]
|
|
40
33
|
}
|
|
41
34
|
}
|
|
@@ -141,6 +141,15 @@ class WorkflowOrchestrator {
|
|
|
141
141
|
return { skipped: true, reason: 'wrong_service' };
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
// OPTIONAL: pin control-flow execution to a specific service (explicit, no magic)
|
|
145
|
+
// If a control-flow step has "service", only that service is allowed to execute it.
|
|
146
|
+
if (step.type !== 'task' && step.service && step.service !== serviceName) {
|
|
147
|
+
this.logger.warn(`Control-flow step ${current_step} is pinned to ${step.service}, not ${serviceName}`);
|
|
148
|
+
const serviceQueue = `${step.service}.workflow`;
|
|
149
|
+
await this.mqClient.publish(serviceQueue, message);
|
|
150
|
+
return { skipped: true, reason: 'wrong_service_control_flow' };
|
|
151
|
+
}
|
|
152
|
+
|
|
144
153
|
// Mark step as started (preserve existing retry_history if requeued)
|
|
145
154
|
const updatedSteps = [...stepsArray];
|
|
146
155
|
updatedSteps[currentIndex] = {
|
|
@@ -162,6 +171,8 @@ class WorkflowOrchestrator {
|
|
|
162
171
|
}
|
|
163
172
|
});
|
|
164
173
|
const stepContext = {
|
|
174
|
+
workflow_id: workflow_id,
|
|
175
|
+
step_id: current_step,
|
|
165
176
|
api_input: cookbookDef.api_input || {},
|
|
166
177
|
steps: stepsArrayCtx, // array (legacy consumers expect .map)
|
|
167
178
|
steps_by_id: stepsMap, // fast lookup by step_id for templating
|
|
@@ -174,11 +185,11 @@ class WorkflowOrchestrator {
|
|
|
174
185
|
const cacheKey = this._getCacheKey(step, stepContext);
|
|
175
186
|
result = await this.cache.get(cacheKey);
|
|
176
187
|
if (!result) {
|
|
177
|
-
result = await this._executeStep(step, stepContext, cookbookDef);
|
|
188
|
+
result = await this._executeStep(step, stepContext, cookbookDef, serviceName);
|
|
178
189
|
await this.cache.set(cacheKey, result, { ttl: 300 });
|
|
179
190
|
}
|
|
180
191
|
} else {
|
|
181
|
-
result = await this._executeStep(step, stepContext, cookbookDef);
|
|
192
|
+
result = await this._executeStep(step, stepContext, cookbookDef, serviceName);
|
|
182
193
|
}
|
|
183
194
|
|
|
184
195
|
// Calculate duration
|
|
@@ -456,13 +467,13 @@ class WorkflowOrchestrator {
|
|
|
456
467
|
* @param {Object} cookbookDef - Full cookbook definition
|
|
457
468
|
* @returns {Promise<Object>} Step result
|
|
458
469
|
*/
|
|
459
|
-
async _executeStep(step, context, cookbookDef) {
|
|
470
|
+
async _executeStep(step, context, cookbookDef, serviceName) {
|
|
460
471
|
if (step.type === 'task') {
|
|
461
472
|
// Task step - call service API
|
|
462
|
-
return await this._executeTaskStep(step, context);
|
|
473
|
+
return await this._executeTaskStep(step, context, serviceName);
|
|
463
474
|
} else {
|
|
464
|
-
// Control flow step
|
|
465
|
-
return await this._executeControlFlowStep(step, context, cookbookDef);
|
|
475
|
+
// Control flow step
|
|
476
|
+
return await this._executeControlFlowStep(step, context, cookbookDef, serviceName);
|
|
466
477
|
}
|
|
467
478
|
}
|
|
468
479
|
|
|
@@ -474,7 +485,7 @@ class WorkflowOrchestrator {
|
|
|
474
485
|
* @param {Object} context - Execution context
|
|
475
486
|
* @returns {Promise<Object>} API call result
|
|
476
487
|
*/
|
|
477
|
-
async _executeTaskStep(step, context) {
|
|
488
|
+
async _executeTaskStep(step, context, serviceName) {
|
|
478
489
|
const stepId = this._getStepId(step);
|
|
479
490
|
// Resolve variable references in step.input (e.g. ${steps[0].output.message})
|
|
480
491
|
console.log(`[WorkflowOrchestrator] [RESOLVE] Step ${stepId} input BEFORE:`, JSON.stringify(step.input));
|
|
@@ -482,14 +493,25 @@ class WorkflowOrchestrator {
|
|
|
482
493
|
console.log(`[WorkflowOrchestrator] [RESOLVE] Context.api_input:`, JSON.stringify(context?.api_input));
|
|
483
494
|
console.log(`[WorkflowOrchestrator] [RESOLVE] Context.steps:`, JSON.stringify(context.steps?.map(s => ({ id: this._getStepId(s), output: s.output }))));
|
|
484
495
|
|
|
485
|
-
const
|
|
496
|
+
const helperContext = {
|
|
497
|
+
data: context,
|
|
498
|
+
workflow_id: context?.workflow_id || null,
|
|
499
|
+
step_id: stepId,
|
|
500
|
+
service: serviceName || null
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
const resolvedInput = await this._resolveInputReferencesAsync(step.input, context, helperContext);
|
|
486
504
|
console.log(`[WorkflowOrchestrator] [RESOLVE] Step ${stepId} input AFTER:`, JSON.stringify(resolvedInput));
|
|
487
505
|
|
|
488
506
|
// Use API mapper to call the service with resolved input
|
|
489
507
|
const result = await this.apiMapper.callOperation(
|
|
490
508
|
step.operation || stepId,
|
|
491
509
|
resolvedInput,
|
|
492
|
-
|
|
510
|
+
{
|
|
511
|
+
...context,
|
|
512
|
+
workflow_id: context?.workflow_id,
|
|
513
|
+
step_id: stepId
|
|
514
|
+
}
|
|
493
515
|
);
|
|
494
516
|
|
|
495
517
|
this.logger.info(`Task step executed`, {
|
|
@@ -501,51 +523,166 @@ class WorkflowOrchestrator {
|
|
|
501
523
|
|
|
502
524
|
return result;
|
|
503
525
|
}
|
|
504
|
-
|
|
526
|
+
|
|
505
527
|
/**
|
|
506
|
-
* Resolve
|
|
507
|
-
*
|
|
528
|
+
* Resolve template variables in input recursively (async)
|
|
529
|
+
*
|
|
530
|
+
* Supported:
|
|
531
|
+
* - {{steps.step_id.output.field}}, {{api_input.field}}, {{current.field}}
|
|
532
|
+
* - helper calls: {{normalizeString(api_input.text)}}, {{webalizeString(api_input.text)}}
|
|
533
|
+
* - async helper calls: {{string2file(api_input.text)}}, {{file2string(steps.x.output.file)}}
|
|
534
|
+
*
|
|
535
|
+
* Helper contract:
|
|
536
|
+
* - Helper is resolved from cookbook connector: this.cookbook.templateHelpers[helperName]
|
|
537
|
+
* - Helper is called with (...args, helperContext)
|
|
538
|
+
*
|
|
508
539
|
* @private
|
|
509
|
-
* @param {Object} input - Input object with potential references
|
|
510
|
-
* @param {Object} context - Current context with steps, api_input, etc
|
|
511
|
-
* @returns {Object} Resolved input object
|
|
512
540
|
*/
|
|
513
|
-
|
|
541
|
+
async _resolveInputReferencesAsync(input, context, helperContext) {
|
|
514
542
|
if (!input) return input;
|
|
515
|
-
|
|
516
|
-
const resolveValue = (value) => {
|
|
543
|
+
|
|
544
|
+
const resolveValue = async (value) => {
|
|
517
545
|
if (typeof value === 'string' && value.includes('{{')) {
|
|
518
|
-
//
|
|
519
|
-
// e.g. "{{steps.greeting.output}}" should return the object directly
|
|
546
|
+
// Entire value is a single template expression -> preserve non-string types
|
|
520
547
|
const singleRefMatch = value.match(/^\{\{([^}]+)\}\}$/);
|
|
521
548
|
if (singleRefMatch) {
|
|
522
|
-
|
|
523
|
-
const resolved = this._getValueFromPath(singleRefMatch[1].trim(), context);
|
|
549
|
+
const resolved = await this._resolveTemplateExpression(singleRefMatch[1].trim(), context, helperContext);
|
|
524
550
|
return resolved !== undefined ? resolved : value;
|
|
525
551
|
}
|
|
526
|
-
|
|
527
|
-
//
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
552
|
+
|
|
553
|
+
// Mixed string with multiple expressions -> string replacement
|
|
554
|
+
const matches = [...value.matchAll(/\{\{([^}]+)\}\}/g)];
|
|
555
|
+
let out = value;
|
|
556
|
+
for (const match of matches) {
|
|
557
|
+
const full = match[0];
|
|
558
|
+
const expr = match[1].trim();
|
|
559
|
+
const resolved = await this._resolveTemplateExpression(expr, context, helperContext);
|
|
560
|
+
if (resolved === undefined) continue;
|
|
561
|
+
const replacement = typeof resolved === 'object' ? JSON.stringify(resolved) : String(resolved);
|
|
562
|
+
out = out.replace(full, replacement);
|
|
563
|
+
}
|
|
564
|
+
return out;
|
|
536
565
|
}
|
|
566
|
+
|
|
537
567
|
if (Array.isArray(value)) {
|
|
538
|
-
return value.map(resolveValue);
|
|
568
|
+
return Promise.all(value.map(resolveValue));
|
|
539
569
|
}
|
|
570
|
+
|
|
540
571
|
if (value && typeof value === 'object') {
|
|
541
|
-
|
|
542
|
-
Object.entries(value).map(([k, v]) => [k, resolveValue(v)])
|
|
572
|
+
const entries = await Promise.all(
|
|
573
|
+
Object.entries(value).map(async ([k, v]) => [k, await resolveValue(v)])
|
|
543
574
|
);
|
|
575
|
+
return Object.fromEntries(entries);
|
|
544
576
|
}
|
|
577
|
+
|
|
545
578
|
return value;
|
|
546
579
|
};
|
|
547
|
-
|
|
548
|
-
return resolveValue(input);
|
|
580
|
+
|
|
581
|
+
return await resolveValue(input);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Resolve a single template expression.
|
|
586
|
+
* @private
|
|
587
|
+
*/
|
|
588
|
+
async _resolveTemplateExpression(expression, context, helperContext) {
|
|
589
|
+
// Detect helper call: helperName(...)
|
|
590
|
+
const helperCallMatch = expression.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\(([\s\S]*)\)$/);
|
|
591
|
+
if (helperCallMatch) {
|
|
592
|
+
const helperName = helperCallMatch[1];
|
|
593
|
+
const argsRaw = helperCallMatch[2].trim();
|
|
594
|
+
return await this._executeTemplateHelper(helperName, argsRaw, context, helperContext);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Plain path reference
|
|
598
|
+
return this._getValueFromPath(expression, context);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Execute helperName(args...) and return its result.
|
|
603
|
+
* @private
|
|
604
|
+
*/
|
|
605
|
+
async _executeTemplateHelper(helperName, argsRaw, context, helperContext) {
|
|
606
|
+
const helpers = this.cookbook?.templateHelpers || {};
|
|
607
|
+
const helperFn = helpers[helperName];
|
|
608
|
+
|
|
609
|
+
if (typeof helperFn !== 'function') {
|
|
610
|
+
throw new Error(`[WorkflowOrchestrator] Unknown template helper '${helperName}' - Expected helper to exist in cookbook.templateHelpers`);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const argTokens = this._splitHelperArgs(argsRaw);
|
|
614
|
+
const args = [];
|
|
615
|
+
for (const token of argTokens) {
|
|
616
|
+
args.push(await this._evalHelperArg(token, context, helperContext));
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Convention: helper(...args, helperContext)
|
|
620
|
+
return await helperFn(...args, helperContext);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Split helper args by commas, respecting quotes.
|
|
625
|
+
* @private
|
|
626
|
+
*/
|
|
627
|
+
_splitHelperArgs(argsRaw) {
|
|
628
|
+
if (!argsRaw) return [];
|
|
629
|
+
const args = [];
|
|
630
|
+
let cur = '';
|
|
631
|
+
let inSingle = false;
|
|
632
|
+
let inDouble = false;
|
|
633
|
+
|
|
634
|
+
for (let i = 0; i < argsRaw.length; i++) {
|
|
635
|
+
const ch = argsRaw[i];
|
|
636
|
+
const prev = argsRaw[i - 1];
|
|
637
|
+
|
|
638
|
+
if (ch === "'" && !inDouble && prev !== '\\') {
|
|
639
|
+
inSingle = !inSingle;
|
|
640
|
+
cur += ch;
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
if (ch === '"' && !inSingle && prev !== '\\') {
|
|
644
|
+
inDouble = !inDouble;
|
|
645
|
+
cur += ch;
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
if (ch === ',' && !inSingle && !inDouble) {
|
|
649
|
+
const trimmed = cur.trim();
|
|
650
|
+
if (trimmed.length > 0) args.push(trimmed);
|
|
651
|
+
cur = '';
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
cur += ch;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const last = cur.trim();
|
|
658
|
+
if (last.length > 0) args.push(last);
|
|
659
|
+
return args;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Evaluate a helper argument token.
|
|
664
|
+
* Supports: string literals, booleans/null/numbers, and path references.
|
|
665
|
+
* @private
|
|
666
|
+
*/
|
|
667
|
+
async _evalHelperArg(token, context, helperContext) {
|
|
668
|
+
const t = token.trim();
|
|
669
|
+
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
|
|
670
|
+
return t.slice(1, -1);
|
|
671
|
+
}
|
|
672
|
+
if (t === 'true') return true;
|
|
673
|
+
if (t === 'false') return false;
|
|
674
|
+
if (t === 'null') return null;
|
|
675
|
+
if (t === 'undefined') return undefined;
|
|
676
|
+
if (/^-?\d+(\.\d+)?$/.test(t)) return Number(t);
|
|
677
|
+
|
|
678
|
+
// Default: treat as path in context
|
|
679
|
+
// Allow nested helper calls as args: helperName(...)
|
|
680
|
+
const nestedHelperMatch = t.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\(([\s\S]*)\)$/);
|
|
681
|
+
if (nestedHelperMatch) {
|
|
682
|
+
return await this._executeTemplateHelper(nestedHelperMatch[1], nestedHelperMatch[2].trim(), context, helperContext);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
return this._getValueFromPath(t, context);
|
|
549
686
|
}
|
|
550
687
|
|
|
551
688
|
/**
|
|
@@ -603,31 +740,273 @@ class WorkflowOrchestrator {
|
|
|
603
740
|
* @param {Object} cookbookDef - Full cookbook definition
|
|
604
741
|
* @returns {Promise<Object>} Control flow result
|
|
605
742
|
*/
|
|
606
|
-
async _executeControlFlowStep(step, context, cookbookDef) {
|
|
607
|
-
const
|
|
743
|
+
async _executeControlFlowStep(step, context, cookbookDef, serviceName) {
|
|
744
|
+
const stepId = this._getStepId(step);
|
|
608
745
|
|
|
609
|
-
//
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
746
|
+
// Fail-fast: if pinned, it must already match (enforced earlier), but keep it explicit.
|
|
747
|
+
if (step.service && serviceName && step.service !== serviceName) {
|
|
748
|
+
throw new Error(`[WorkflowOrchestrator] Control-flow step '${stepId}' pinned to '${step.service}' - Current service is '${serviceName}'`);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
switch (step.type) {
|
|
752
|
+
case 'steps':
|
|
753
|
+
return await this._executeStepsGroup(step, context, cookbookDef, serviceName);
|
|
754
|
+
case 'foreach':
|
|
755
|
+
return await this._executeForeach(step, context, cookbookDef, serviceName);
|
|
756
|
+
case 'switch':
|
|
757
|
+
return await this._executeSwitch(step, context, cookbookDef, serviceName);
|
|
758
|
+
case 'fork_join':
|
|
759
|
+
return await this._executeForkJoin(step, context, cookbookDef, serviceName);
|
|
760
|
+
default: {
|
|
761
|
+
// Fallback to legacy executor behavior (does NOT execute nested tasks)
|
|
762
|
+
const { CookbookExecutor } = this.cookbook;
|
|
763
|
+
if (!CookbookExecutor) {
|
|
764
|
+
throw new Error(`[WorkflowOrchestrator] CookbookExecutor missing - Cannot execute control-flow step '${stepId}'`);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const executor = new CookbookExecutor(
|
|
768
|
+
{ steps: [step] },
|
|
769
|
+
{ logger: this.logger, defaultTimeout: this.defaultTimeout }
|
|
770
|
+
);
|
|
771
|
+
executor.context.setInput(context.api_input || {});
|
|
772
|
+
executor.context.data.steps = context.steps || {};
|
|
773
|
+
return await executor.executeStep(step);
|
|
615
774
|
}
|
|
616
|
-
|
|
775
|
+
}
|
|
776
|
+
}
|
|
617
777
|
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
778
|
+
/**
|
|
779
|
+
* Execute nested steps group (V2.1: type=steps, steps: Step[])
|
|
780
|
+
* @private
|
|
781
|
+
*/
|
|
782
|
+
async _executeStepsGroup(step, context, cookbookDef, serviceName) {
|
|
783
|
+
const stepId = this._getStepId(step);
|
|
784
|
+
const steps = Array.isArray(step.steps) ? step.steps : [];
|
|
621
785
|
|
|
622
|
-
|
|
623
|
-
|
|
786
|
+
if (!Array.isArray(step.steps)) {
|
|
787
|
+
throw new Error(`[WorkflowOrchestrator] Steps step '${stepId}' invalid - Expected 'steps' to be an array`);
|
|
788
|
+
}
|
|
624
789
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
type: step.type
|
|
628
|
-
});
|
|
790
|
+
const resultsByStepId = {};
|
|
791
|
+
const order = [];
|
|
629
792
|
|
|
630
|
-
|
|
793
|
+
for (const nested of steps) {
|
|
794
|
+
const nestedId = this._getStepId(nested);
|
|
795
|
+
if (!nestedId) {
|
|
796
|
+
throw new Error(`[WorkflowOrchestrator] Steps step '${stepId}' contains nested step without step_id`);
|
|
797
|
+
}
|
|
798
|
+
const output = await this._executeNestedStep(nested, context, cookbookDef, serviceName);
|
|
799
|
+
resultsByStepId[nestedId] = output;
|
|
800
|
+
order.push(nestedId);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
return {
|
|
804
|
+
type: 'steps',
|
|
805
|
+
step_id: stepId,
|
|
806
|
+
order,
|
|
807
|
+
results: resultsByStepId
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Execute foreach (V2.1: iterator + body[])
|
|
813
|
+
* @private
|
|
814
|
+
*/
|
|
815
|
+
async _executeForeach(step, context, cookbookDef, serviceName) {
|
|
816
|
+
const stepId = this._getStepId(step);
|
|
817
|
+
const helperContext = {
|
|
818
|
+
data: context,
|
|
819
|
+
workflow_id: context?.workflow_id || null,
|
|
820
|
+
step_id: stepId,
|
|
821
|
+
service: serviceName || null
|
|
822
|
+
};
|
|
823
|
+
|
|
824
|
+
const iteratorResolved = await this._resolveInputReferencesAsync(step.iterator, context, helperContext);
|
|
825
|
+
const items = Array.isArray(iteratorResolved) ? iteratorResolved : iteratorResolved;
|
|
826
|
+
|
|
827
|
+
if (!Array.isArray(items)) {
|
|
828
|
+
throw new Error(`[WorkflowOrchestrator] Foreach '${stepId}' iterator must resolve to array - Got ${typeof items}`);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const body = Array.isArray(step.body) ? step.body : [];
|
|
832
|
+
if (!Array.isArray(step.body) || body.length === 0) {
|
|
833
|
+
throw new Error(`[WorkflowOrchestrator] Foreach '${stepId}' invalid - Expected non-empty 'body' array`);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const iterations = [];
|
|
837
|
+
for (let i = 0; i < items.length; i++) {
|
|
838
|
+
const item = items[i];
|
|
839
|
+
const iterationContext = {
|
|
840
|
+
...context,
|
|
841
|
+
current: item,
|
|
842
|
+
index: i
|
|
843
|
+
};
|
|
844
|
+
|
|
845
|
+
const outputs = {};
|
|
846
|
+
for (const nested of body) {
|
|
847
|
+
const nestedId = this._getStepId(nested);
|
|
848
|
+
if (!nestedId) {
|
|
849
|
+
throw new Error(`[WorkflowOrchestrator] Foreach '${stepId}' body contains nested step without step_id`);
|
|
850
|
+
}
|
|
851
|
+
outputs[nestedId] = await this._executeNestedStep(nested, iterationContext, cookbookDef, serviceName);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
iterations.push({ index: i, item, outputs });
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
return {
|
|
858
|
+
type: 'foreach',
|
|
859
|
+
step_id: stepId,
|
|
860
|
+
count: items.length,
|
|
861
|
+
iterations
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Execute switch (V2.1: expression + cases{} + optional default)
|
|
867
|
+
* @private
|
|
868
|
+
*/
|
|
869
|
+
async _executeSwitch(step, context, cookbookDef, serviceName) {
|
|
870
|
+
const stepId = this._getStepId(step);
|
|
871
|
+
const helperContext = {
|
|
872
|
+
data: context,
|
|
873
|
+
workflow_id: context?.workflow_id || null,
|
|
874
|
+
step_id: stepId,
|
|
875
|
+
service: serviceName || null
|
|
876
|
+
};
|
|
877
|
+
|
|
878
|
+
const value = await this._evaluateSwitchExpression(step.expression, context, helperContext);
|
|
879
|
+
const key = value === null ? 'null' : String(value);
|
|
880
|
+
|
|
881
|
+
const cases = step.cases && typeof step.cases === 'object' ? step.cases : null;
|
|
882
|
+
if (!cases) {
|
|
883
|
+
throw new Error(`[WorkflowOrchestrator] Switch '${stepId}' invalid - Expected 'cases' object`);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const selectedStep = cases[key] ? cases[key] : (step.default ? step.default : null);
|
|
887
|
+
const selectedCase = cases[key] ? key : (step.default ? 'default' : null);
|
|
888
|
+
|
|
889
|
+
if (!selectedStep) {
|
|
890
|
+
return {
|
|
891
|
+
type: 'switch',
|
|
892
|
+
step_id: stepId,
|
|
893
|
+
expression: step.expression,
|
|
894
|
+
value,
|
|
895
|
+
selectedCase: null,
|
|
896
|
+
result: null
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
const result = await this._executeNestedStep(selectedStep, context, cookbookDef, serviceName);
|
|
901
|
+
|
|
902
|
+
return {
|
|
903
|
+
type: 'switch',
|
|
904
|
+
step_id: stepId,
|
|
905
|
+
expression: step.expression,
|
|
906
|
+
value,
|
|
907
|
+
selectedCase,
|
|
908
|
+
result
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* Execute fork_join (V2.1: branches{} + join)
|
|
914
|
+
* @private
|
|
915
|
+
*/
|
|
916
|
+
async _executeForkJoin(step, context, cookbookDef, serviceName) {
|
|
917
|
+
const stepId = this._getStepId(step);
|
|
918
|
+
const branches = step.branches && typeof step.branches === 'object' ? step.branches : null;
|
|
919
|
+
if (!branches) {
|
|
920
|
+
throw new Error(`[WorkflowOrchestrator] ForkJoin '${stepId}' invalid - Expected 'branches' object`);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
const branchEntries = Object.entries(branches);
|
|
924
|
+
if (branchEntries.length === 0) {
|
|
925
|
+
throw new Error(`[WorkflowOrchestrator] ForkJoin '${stepId}' invalid - Expected at least 1 branch`);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const results = {};
|
|
929
|
+
await Promise.all(branchEntries.map(async ([branchName, branchStep]) => {
|
|
930
|
+
results[branchName] = await this._executeNestedStep(branchStep, context, cookbookDef, serviceName);
|
|
931
|
+
}));
|
|
932
|
+
|
|
933
|
+
const strategy = step.join?.strategy || 'all';
|
|
934
|
+
|
|
935
|
+
return {
|
|
936
|
+
type: 'fork_join',
|
|
937
|
+
step_id: stepId,
|
|
938
|
+
join: { strategy },
|
|
939
|
+
branches: results
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* Execute nested step within a control-flow step.
|
|
945
|
+
* @private
|
|
946
|
+
*/
|
|
947
|
+
async _executeNestedStep(step, context, cookbookDef, serviceName) {
|
|
948
|
+
const stepId = this._getStepId(step);
|
|
949
|
+
|
|
950
|
+
if (!step || typeof step !== 'object') {
|
|
951
|
+
throw new Error('[WorkflowOrchestrator] Nested step invalid - Expected object');
|
|
952
|
+
}
|
|
953
|
+
if (!step.type) {
|
|
954
|
+
throw new Error(`[WorkflowOrchestrator] Nested step '${stepId || 'unknown'}' invalid - Missing 'type'`);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
if (step.type === 'task') {
|
|
958
|
+
// Fail-fast: nested task must be for THIS service (ApiMapper is service-bound)
|
|
959
|
+
if (step.service && serviceName && step.service !== serviceName) {
|
|
960
|
+
throw new Error(`[WorkflowOrchestrator] Nested task '${stepId}' targets service '${step.service}' but is executing in '${serviceName}' - Expected same service`);
|
|
961
|
+
}
|
|
962
|
+
return await this._executeTaskStep(step, context, serviceName);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
return await this._executeControlFlowStep(step, context, cookbookDef, serviceName);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* Evaluate switch expression (explicit, predictable).
|
|
970
|
+
* Supports:
|
|
971
|
+
* - {{...}} templates (including helpers)
|
|
972
|
+
* - $api_input.* / $steps.* / $.api_input.* / $.steps.*
|
|
973
|
+
* - plain path like api_input.mode
|
|
974
|
+
* @private
|
|
975
|
+
*/
|
|
976
|
+
async _evaluateSwitchExpression(expression, context, helperContext) {
|
|
977
|
+
if (expression === null || expression === undefined) {
|
|
978
|
+
return null;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
if (typeof expression !== 'string') {
|
|
982
|
+
return expression;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
if (expression.includes('{{')) {
|
|
986
|
+
return await this._resolveInputReferencesAsync(expression, context, helperContext);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// $api_input.x, $steps.x, $.api_input.x
|
|
990
|
+
if (expression.startsWith('$.')) {
|
|
991
|
+
const v = this._getValueFromPath(expression.substring(2), context);
|
|
992
|
+
return v !== undefined ? v : undefined;
|
|
993
|
+
}
|
|
994
|
+
if (expression.startsWith('$api_input.') || expression.startsWith('$steps.')) {
|
|
995
|
+
const v = this._getValueFromPath(expression.substring(1), context);
|
|
996
|
+
return v !== undefined ? v : undefined;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// plain dot path (api_input.mode)
|
|
1000
|
+
const v = this._getValueFromPath(expression, context);
|
|
1001
|
+
if (v !== undefined) return v;
|
|
1002
|
+
|
|
1003
|
+
// If it looks like a path (contains dot) but cannot be resolved -> fail-fast
|
|
1004
|
+
if (expression.includes('.')) {
|
|
1005
|
+
throw new Error(`[WorkflowOrchestrator] Switch expression could not be resolved: '${expression}'`);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// Otherwise treat as literal
|
|
1009
|
+
return expression;
|
|
631
1010
|
}
|
|
632
1011
|
|
|
633
1012
|
/**
|
|
@@ -664,11 +1043,17 @@ class WorkflowOrchestrator {
|
|
|
664
1043
|
});
|
|
665
1044
|
}
|
|
666
1045
|
} else {
|
|
667
|
-
// Control flow steps
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
1046
|
+
// Control flow steps:
|
|
1047
|
+
// - if pinned (step.service), route to that service queue
|
|
1048
|
+
// - otherwise fall back to workflow.init (any service may execute)
|
|
1049
|
+
if (nextStep.service) {
|
|
1050
|
+
const serviceQueue = `${nextStep.service}.workflow`;
|
|
1051
|
+
await this.mqClient.publish(serviceQueue, message);
|
|
1052
|
+
this.logger.info(`Routed control flow to pinned service queue: ${serviceQueue}`, { step: nextStepId });
|
|
1053
|
+
} else {
|
|
1054
|
+
await this.mqClient.publish('workflow.init', message);
|
|
1055
|
+
this.logger.info(`Routed control flow to workflow.init`, { step: nextStepId });
|
|
1056
|
+
}
|
|
672
1057
|
}
|
|
673
1058
|
}
|
|
674
1059
|
|