@onlineapps/conn-orch-orchestrator 1.0.62 → 1.0.64

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.62",
3
+ "version": "1.0.64",
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
  },
@@ -20,22 +20,15 @@
20
20
  "author": "OA Drive Team",
21
21
  "license": "MIT",
22
22
  "dependencies": {
23
- "@onlineapps/conn-base-monitoring": "^1.0.0",
23
+ "@onlineapps/conn-base-monitoring": "1.0.2",
24
24
  "@onlineapps/conn-infra-mq": "^1.1.0",
25
- "@onlineapps/conn-orch-registry": "^1.1.4",
26
- "@onlineapps/conn-orch-cookbook": "2.0.11",
27
- "@onlineapps/conn-orch-api-mapper": "^1.0.0"
25
+ "@onlineapps/conn-orch-registry": "1.1.25",
26
+ "@onlineapps/conn-orch-cookbook": "2.0.12",
27
+ "@onlineapps/conn-orch-api-mapper": "1.0.13"
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 - use cookbook executor
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 resolvedInput = this._resolveInputReferences(step.input, context);
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
- context
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 variable references in input object
507
- * Supports {{steps.step_id.output.field}}, {{api_input.field}}, {{context.field}}
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
- _resolveInputReferences(input, context) {
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
- // Check if the ENTIRE string is a single template reference
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
- // Entire value is a single reference - return the resolved value directly (preserves objects)
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
- // Multiple references or mixed with text - use string replacement
528
- // e.g. "Hello {{name}}, your order {{orderId}}" becomes "Hello John, your order 123"
529
- return value.replace(/\{\{([^}]+)\}\}/g, (match, path) => {
530
- const resolved = this._getValueFromPath(path.trim(), context);
531
- if (resolved === undefined) return match;
532
- // For string concatenation, convert objects to JSON string
533
- if (typeof resolved === 'object') return JSON.stringify(resolved);
534
- return String(resolved);
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
- return Object.fromEntries(
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 { CookbookExecutor } = this.cookbook;
743
+ async _executeControlFlowStep(step, context, cookbookDef, serviceName) {
744
+ const stepId = this._getStepId(step);
608
745
 
609
- // Create executor for control flow
610
- const executor = new CookbookExecutor(
611
- { steps: [step] },
612
- {
613
- logger: this.logger,
614
- defaultTimeout: this.defaultTimeout
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
- // Set context
619
- executor.context.setInput(context.api_input || {});
620
- executor.context.data.steps = context.steps || {};
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
- // Execute the control flow step
623
- const result = await executor.executeStep(step);
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
- this.logger.info(`Control flow step executed`, {
626
- step: this._getStepId(step),
627
- type: step.type
628
- });
790
+ const resultsByStepId = {};
791
+ const order = [];
629
792
 
630
- return result;
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 handled by workflow init queue
668
- await this.mqClient.publish('workflow.init', message);
669
- this.logger.info(`Routed control flow to workflow.init`, {
670
- step: nextStepId
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