@codemation/core 0.3.0 → 0.4.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/{EngineRuntimeRegistration.types-Bjeo7Sfq.d.ts → EngineRuntimeRegistration.types-DU6MsjU9.d.ts} +2 -2
  3. package/dist/{EngineWorkflowRunnerService-Dd4yD31l.d.cts → EngineWorkflowRunnerService-BBkL4VQF.d.cts} +2 -2
  4. package/dist/{InMemoryRunDataFactory-OUzDmAHt.d.cts → InMemoryRunDataFactory-CsYEMJK2.d.cts} +11 -3
  5. package/dist/{RunIntentService-Bkg4oYrM.d.cts → RunIntentService-BvlTpmEb.d.cts} +224 -237
  6. package/dist/{RunIntentService-BAKikN8h.d.ts → RunIntentService-zbTchO9T.d.ts} +305 -259
  7. package/dist/bootstrap/index.cjs +2 -2
  8. package/dist/bootstrap/index.d.cts +19 -7
  9. package/dist/bootstrap/index.d.ts +3 -3
  10. package/dist/bootstrap/index.js +2 -2
  11. package/dist/{bootstrap-DwS5S7s9.cjs → bootstrap-DHH2uo-W.cjs} +4 -2
  12. package/dist/bootstrap-DHH2uo-W.cjs.map +1 -0
  13. package/dist/{bootstrap-BD6CobHl.js → bootstrap-DbUlOl11.js} +4 -2
  14. package/dist/bootstrap-DbUlOl11.js.map +1 -0
  15. package/dist/{index-uCm9l0nw.d.ts → index-CUt13qs1.d.ts} +62 -34
  16. package/dist/index.cjs +22 -15
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.cts +108 -42
  19. package/dist/index.d.ts +3 -3
  20. package/dist/index.js +13 -16
  21. package/dist/index.js.map +1 -1
  22. package/dist/{runtime-Cy-3FTI_.js → runtime-BdH94eBR.js} +502 -123
  23. package/dist/runtime-BdH94eBR.js.map +1 -0
  24. package/dist/{runtime-ZJUpWmPH.cjs → runtime-feFn8OmG.cjs} +561 -122
  25. package/dist/runtime-feFn8OmG.cjs.map +1 -0
  26. package/dist/testing.cjs +40 -36
  27. package/dist/testing.cjs.map +1 -1
  28. package/dist/testing.d.cts +17 -26
  29. package/dist/testing.d.ts +17 -26
  30. package/dist/testing.js +40 -36
  31. package/dist/testing.js.map +1 -1
  32. package/dist/{workflowActivationPolicy-BzyzXLa_.cjs → workflowActivationPolicy-6V3OJD3N.cjs} +65 -19
  33. package/dist/workflowActivationPolicy-6V3OJD3N.cjs.map +1 -0
  34. package/dist/{workflowActivationPolicy-B8HzTk3o.js → workflowActivationPolicy-Td9HTOuD.js} +65 -19
  35. package/dist/workflowActivationPolicy-Td9HTOuD.js.map +1 -0
  36. package/package.json +2 -1
  37. package/src/ai/AgentConfigInspectorFactory.ts +4 -0
  38. package/src/ai/AgentMessageConfigNormalizerFactory.ts +7 -0
  39. package/src/ai/AgentToolFactory.ts +2 -2
  40. package/src/ai/AiHost.ts +11 -10
  41. package/src/ai/NodeBackedToolConfig.ts +1 -1
  42. package/src/authoring/defineNode.types.ts +48 -72
  43. package/src/authoring/index.ts +1 -1
  44. package/src/bootstrap/runtime/EngineRuntimeRegistrar.ts +8 -0
  45. package/src/contracts/emitPorts.ts +27 -0
  46. package/src/contracts/index.ts +3 -0
  47. package/src/contracts/itemMeta.ts +11 -0
  48. package/src/contracts/itemValue.ts +147 -0
  49. package/src/contracts/runtimeTypes.ts +39 -22
  50. package/src/contracts/workflowTypes.ts +26 -56
  51. package/src/execution/FanInMergeByOriginMerger.ts +67 -0
  52. package/src/execution/ItemValueResolver.ts +27 -0
  53. package/src/execution/NodeActivationRequestComposer.ts +25 -0
  54. package/src/execution/NodeActivationRequestInputPreparer.ts +57 -25
  55. package/src/execution/NodeExecutor.ts +199 -30
  56. package/src/execution/NodeOutputNormalizer.ts +90 -0
  57. package/src/execution/index.ts +2 -0
  58. package/src/index.ts +2 -0
  59. package/src/orchestration/NodeExecutionRequestHandlerService.ts +39 -18
  60. package/src/orchestration/RunContinuationService.ts +11 -17
  61. package/src/planning/CurrentStateFrontierPlanner.ts +20 -20
  62. package/src/planning/RunQueuePlanner.ts +56 -19
  63. package/src/planning/WorkflowTopologyPlanner.ts +57 -33
  64. package/src/testing/ItemHarnessNode.ts +4 -10
  65. package/src/testing/ItemHarnessNodeConfig.ts +7 -16
  66. package/src/testing/RegistrarEngineTestKitFactory.ts +2 -0
  67. package/src/testing/SubWorkflowRunnerTestNode.ts +28 -43
  68. package/src/testing/SwitchHarnessNode.ts +54 -0
  69. package/src/types/index.ts +3 -0
  70. package/src/workflow/dsl/ChainCursorResolver.ts +68 -23
  71. package/src/workflow/dsl/WorkflowBuilder.ts +3 -5
  72. package/src/workflow/dsl/workflowBuilderTypes.ts +5 -8
  73. package/src/workflowSnapshots/MissingRuntimeNode.ts +4 -4
  74. package/src/workflowSnapshots/MissingRuntimeNodeConfig.ts +2 -2
  75. package/src/workflowSnapshots/WorkflowSnapshotCodec.ts +16 -7
  76. package/dist/bootstrap-BD6CobHl.js.map +0 -1
  77. package/dist/bootstrap-DwS5S7s9.cjs.map +0 -1
  78. package/dist/runtime-Cy-3FTI_.js.map +0 -1
  79. package/dist/runtime-ZJUpWmPH.cjs.map +0 -1
  80. package/dist/workflowActivationPolicy-B8HzTk3o.js.map +0 -1
  81. package/dist/workflowActivationPolicy-BzyzXLa_.cjs.map +0 -1
@@ -0,0 +1,90 @@
1
+ import { isPortsEmission, isUnbrandedPortsEmissionShape } from "../contracts/emitPorts";
2
+ import type { PortsEmission } from "../contracts/emitPorts";
3
+ import type { Item, JsonNonArray, LineageCarryPolicy, NodeOutputs, OutputPortKey, Items } from "../types";
4
+
5
+ export class NodeOutputNormalizer {
6
+ normalizeExecuteResult(
7
+ args: Readonly<{
8
+ baseItem: Item;
9
+ raw: unknown;
10
+ carry: LineageCarryPolicy;
11
+ }>,
12
+ ): NodeOutputs {
13
+ const { baseItem, raw, carry } = args;
14
+ if (isPortsEmission(raw)) {
15
+ return this.emitPortsToOutputs(baseItem, raw, carry);
16
+ }
17
+ if (isUnbrandedPortsEmissionShape(raw)) {
18
+ throw new Error(
19
+ "execute() returned an unbranded `{ ports: ... }` object. Use emitPorts(...) for multi-port runnable outputs.",
20
+ );
21
+ }
22
+ if (Array.isArray(raw)) {
23
+ return this.arrayFanOutToMain(baseItem, raw, carry);
24
+ }
25
+ if (this.isItemLike(raw)) {
26
+ return { main: [this.applyLineage(baseItem, raw, carry)] };
27
+ }
28
+ return {
29
+ main: [this.applyLineage(baseItem, { json: raw as JsonNonArray }, carry)],
30
+ };
31
+ }
32
+
33
+ private arrayFanOutToMain(baseItem: Item, raw: readonly unknown[], carry: LineageCarryPolicy): NodeOutputs {
34
+ for (const el of raw) {
35
+ if (Array.isArray(el)) {
36
+ throw new Error(
37
+ "execute() fan-out arrays must contain only non-array JSON elements (nested arrays belong inside objects).",
38
+ );
39
+ }
40
+ }
41
+ const main: Item[] = raw.map((json) => this.applyLineage(baseItem, { json: json as JsonNonArray }, carry));
42
+ return { main };
43
+ }
44
+
45
+ private emitPortsToOutputs(baseItem: Item, emission: PortsEmission, carry: LineageCarryPolicy): NodeOutputs {
46
+ const out: NodeOutputs = {};
47
+ for (const [port, payload] of Object.entries(emission.ports)) {
48
+ if (payload === undefined) {
49
+ continue;
50
+ }
51
+ out[port as OutputPortKey] = this.normalizePortPayload(baseItem, payload, carry);
52
+ }
53
+ return out;
54
+ }
55
+
56
+ private normalizePortPayload(
57
+ baseItem: Item,
58
+ payload: Items | ReadonlyArray<JsonNonArray>,
59
+ carry: LineageCarryPolicy,
60
+ ): Items {
61
+ if (payload.length === 0) {
62
+ return [];
63
+ }
64
+ const el0 = payload[0] as unknown;
65
+ if (this.isItemLike(el0)) {
66
+ return (payload as Items).map((it) => this.applyLineage(baseItem, it, carry));
67
+ }
68
+ return (payload as readonly JsonNonArray[]).map((json) => this.applyLineage(baseItem, { json }, carry));
69
+ }
70
+
71
+ private isItemLike(value: unknown): value is Item {
72
+ return typeof value === "object" && value !== null && "json" in value;
73
+ }
74
+
75
+ private applyLineage(baseItem: Item, next: Item, carry: LineageCarryPolicy): Item {
76
+ if (carry === "carryThrough") {
77
+ return {
78
+ ...baseItem,
79
+ ...next,
80
+ json: next.json,
81
+ };
82
+ }
83
+ return {
84
+ json: next.json,
85
+ ...(next.binary ? { binary: next.binary } : {}),
86
+ ...(next.meta ? { meta: next.meta } : {}),
87
+ ...(next.paired ? { paired: next.paired } : {}),
88
+ };
89
+ }
90
+ }
@@ -5,6 +5,8 @@ export { CredentialResolverFactory } from "./CredentialResolverFactory";
5
5
  export { DefaultAsyncSleeper } from "./DefaultAsyncSleeper";
6
6
  export { DefaultExecutionContextFactory } from "./DefaultExecutionContextFactory";
7
7
  export { InProcessRetryRunner } from "./InProcessRetryRunner";
8
+ export { ItemValueResolver } from "./ItemValueResolver";
9
+ export { NodeOutputNormalizer } from "./NodeOutputNormalizer";
8
10
  export { InProcessRetryRunnerFactory } from "./InProcessRetryRunnerFactory";
9
11
  export { NodeActivationRequestComposer } from "./NodeActivationRequestComposer";
10
12
  export { NodeExecutionSnapshotFactory } from "./NodeExecutionSnapshotFactory";
package/src/index.ts CHANGED
@@ -18,6 +18,8 @@ export {
18
18
  DefaultAsyncSleeper,
19
19
  DefaultExecutionContextFactory,
20
20
  InProcessRetryRunner,
21
+ ItemValueResolver,
22
+ NodeOutputNormalizer,
21
23
  } from "./execution";
22
24
  export { EngineExecutionLimitsPolicy, type EngineExecutionLimitsPolicyConfig } from "./policies";
23
25
  export { InMemoryBinaryStorage, InMemoryRunDataFactory } from "./runStorage";
@@ -66,7 +66,6 @@ export class NodeExecutionRequestHandlerService implements NodeExecutionRequestH
66
66
  const resolvedParent = request.parent ?? state.parent;
67
67
  const data = this.runDataFactory.create(state.outputsByNode);
68
68
  const limits = this.resolveEngineLimitsFromState(state);
69
- const persistedInput = pendingExecution.inputsByPort.in ?? request.input;
70
69
  const base = this.runExecutionContextFactory.create({
71
70
  runId: state.runId,
72
71
  workflowId: state.workflowId,
@@ -78,21 +77,43 @@ export class NodeExecutionRequestHandlerService implements NodeExecutionRequestH
78
77
  data,
79
78
  nodeState: this.nodeStatePublisherFactory.create(state.runId, state.workflowId, resolvedParent),
80
79
  });
81
- const activationRequest = this.nodeActivationRequestComposer.createSingleFromDefinitionWithActivation({
82
- activationId: request.activationId,
83
- runId: request.runId,
84
- workflowId: request.workflowId,
85
- parent: resolvedParent,
86
- executionOptions: request.executionOptions ?? state.executionOptions,
87
- base,
88
- data,
89
- definition: {
90
- id: definition.id,
91
- config: definition.config,
92
- },
93
- batchId: pendingExecution.batchId ?? "batch_1",
94
- input: persistedInput,
95
- });
80
+
81
+ const inputsByPort = pendingExecution.inputsByPort;
82
+ const portKeys = Object.keys(inputsByPort);
83
+ const kind = portKeys.length === 1 && portKeys[0] === "in" ? ("single" as const) : ("multi" as const);
84
+ const batchId = pendingExecution.batchId ?? "batch_1";
85
+ const activationRequest =
86
+ kind === "multi"
87
+ ? this.nodeActivationRequestComposer.createMultiFromDefinitionWithActivation({
88
+ activationId: request.activationId,
89
+ runId: request.runId,
90
+ workflowId: request.workflowId,
91
+ parent: resolvedParent,
92
+ executionOptions: request.executionOptions ?? state.executionOptions,
93
+ base,
94
+ data,
95
+ definition: {
96
+ id: definition.id,
97
+ config: definition.config,
98
+ },
99
+ batchId,
100
+ inputsByPort,
101
+ })
102
+ : this.nodeActivationRequestComposer.createSingleFromDefinitionWithActivation({
103
+ activationId: request.activationId,
104
+ runId: request.runId,
105
+ workflowId: request.workflowId,
106
+ parent: resolvedParent,
107
+ executionOptions: request.executionOptions ?? state.executionOptions,
108
+ base,
109
+ data,
110
+ definition: {
111
+ id: definition.id,
112
+ config: definition.config,
113
+ },
114
+ batchId,
115
+ input: inputsByPort.in ?? request.input ?? [],
116
+ });
96
117
 
97
118
  await this.continuation.markNodeRunning({
98
119
  runId: activationRequest.runId,
@@ -131,7 +152,7 @@ export class NodeExecutionRequestHandlerService implements NodeExecutionRequestH
131
152
  }
132
153
 
133
154
  private async resumeAfterExecutionResult(
134
- request: ReturnType<NodeActivationRequestComposer["createSingleFromDefinitionWithActivation"]>,
155
+ request: Readonly<{ runId: string; activationId: string; nodeId: string }>,
135
156
  outputs: unknown,
136
157
  ): Promise<void> {
137
158
  try {
@@ -147,7 +168,7 @@ export class NodeExecutionRequestHandlerService implements NodeExecutionRequestH
147
168
  }
148
169
 
149
170
  private async resumeAfterExecutionError(
150
- request: ReturnType<NodeActivationRequestComposer["createSingleFromDefinitionWithActivation"]>,
171
+ request: Readonly<{ runId: string; activationId: string; nodeId: string }>,
151
172
  error: Error,
152
173
  ): Promise<void> {
153
174
  try {
@@ -335,7 +335,7 @@ export class RunContinuationService {
335
335
  nextNodeId: next.nodeId,
336
336
  request,
337
337
  completedSnapshot,
338
- nextNodeSnapshotsByNodeId,
338
+ nextNodeSnapshotsByNodeId: nextNodeSnapshotsByNodeId,
339
339
  outputsByNode: data.dump(),
340
340
  engineCounters,
341
341
  error,
@@ -653,6 +653,12 @@ export class RunContinuationService {
653
653
  planner.applyOutputs(queue, { fromNodeId: args.args.nodeId, outputs: triggerOutputs as any, batchId });
654
654
  const next = planner.nextActivation(queue);
655
655
 
656
+ const finishedAt = completedSnapshot.finishedAt ?? completedSnapshot.updatedAt;
657
+ const mergedSnapshots = {
658
+ ...(args.state.nodeSnapshotsByNodeId ?? {}),
659
+ [args.args.nodeId]: completedSnapshot,
660
+ };
661
+
656
662
  if (!next) {
657
663
  const lastNodeId = WorkflowExecutableNodeClassifierFactory.create(
658
664
  args.workflow,
@@ -664,11 +670,8 @@ export class RunContinuationService {
664
670
  status: "completed",
665
671
  queue: [],
666
672
  outputsByNode: data.dump(),
667
- nodeSnapshotsByNodeId: {
668
- ...(args.state.nodeSnapshotsByNodeId ?? {}),
669
- [args.args.nodeId]: completedSnapshot,
670
- },
671
- finishedAtIso: completedSnapshot.finishedAt ?? completedSnapshot.updatedAt,
673
+ nodeSnapshotsByNodeId: mergedSnapshots,
674
+ finishedAtIso: finishedAt,
672
675
  });
673
676
  await this.workflowExecutionRepository.save(completedState);
674
677
  await this.nodeEventPublisher.publish("nodeCompleted", completedSnapshot);
@@ -705,11 +708,8 @@ export class RunContinuationService {
705
708
  status: "failed",
706
709
  queue: queue.map((q) => ({ ...q })),
707
710
  outputsByNode: data.dump(),
708
- nodeSnapshotsByNodeId: {
709
- ...(args.state.nodeSnapshotsByNodeId ?? {}),
710
- [args.args.nodeId]: completedSnapshot,
711
- },
712
- finishedAtIso: completedSnapshot.finishedAt ?? completedSnapshot.updatedAt,
711
+ nodeSnapshotsByNodeId: mergedSnapshots,
712
+ finishedAtIso: finishedAt,
713
713
  });
714
714
  await this.workflowExecutionRepository.save(failedState);
715
715
  await this.nodeEventPublisher.publish("nodeCompleted", completedSnapshot);
@@ -765,11 +765,6 @@ export class RunContinuationService {
765
765
  nodeDefinition: nextDefinition,
766
766
  });
767
767
 
768
- const mergedSnapshots = {
769
- ...(args.state.nodeSnapshotsByNodeId ?? {}),
770
- [args.args.nodeId]: completedSnapshot,
771
- };
772
-
773
768
  try {
774
769
  const { queuedSnapshot, result } = await this.activationEnqueueService.enqueueActivationWithSnapshot({
775
770
  runId: args.state.runId,
@@ -800,7 +795,6 @@ export class RunContinuationService {
800
795
  return result;
801
796
  } catch (cause) {
802
797
  const error = cause instanceof Error ? cause : new Error(String(cause));
803
- const finishedAt = completedSnapshot.finishedAt ?? completedSnapshot.updatedAt;
804
798
  const result = await this.terminateRunAfterActivationEnqueueRejected({
805
799
  wf: args.workflow,
806
800
  state: args.state,
@@ -200,7 +200,7 @@ export class CurrentStateFrontierPlanner {
200
200
  continue;
201
201
  }
202
202
  const incomingEdges = this.topology.incomingByNode.get(nodeId) ?? [];
203
- const isFrontier = incomingEdges.every((edge) => this.isEdgeSatisfied(currentState, nodeId, edge.input));
203
+ const isFrontier = incomingEdges.every((edge) => this.isEdgeSatisfied(currentState, nodeId, edge.collectKey));
204
204
  if (isFrontier) {
205
205
  frontierNodeIds.push(nodeId);
206
206
  }
@@ -235,7 +235,7 @@ export class CurrentStateFrontierPlanner {
235
235
  requiredNodeIds.add(nodeId);
236
236
  for (const edge of this.topology.incomingByNode.get(nodeId) ?? []) {
237
237
  if (
238
- !this.isEdgeSatisfied(currentState, nodeId, edge.input) ||
238
+ !this.isEdgeSatisfied(currentState, nodeId, edge.collectKey) ||
239
239
  this.isNodeSatisfiedByOutputsOnly(currentState, edge.from.nodeId)
240
240
  ) {
241
241
  this.collectRequiredNode(requiredNodeIds, currentState, edge.from.nodeId);
@@ -249,7 +249,7 @@ export class CurrentStateFrontierPlanner {
249
249
  return [];
250
250
  }
251
251
  const expectedInputs = this.topology.expectedInputsByNode.get(nodeId) ?? [];
252
- const usesCollect = expectedInputs.length !== 1 || expectedInputs[0] !== "in";
252
+ const usesCollect = this.usesCollect(nodeId);
253
253
  if (usesCollect) {
254
254
  const received: Record<InputPortKey, Items> = {};
255
255
  for (const input of expectedInputs) {
@@ -268,7 +268,7 @@ export class CurrentStateFrontierPlanner {
268
268
  ];
269
269
  }
270
270
  const input = expectedInputs[0] ?? "in";
271
- const incomingEdge = incomingEdges.find((edge) => edge.input === input);
271
+ const incomingEdge = incomingEdges.find((edge) => edge.collectKey === input);
272
272
  return [
273
273
  {
274
274
  nodeId,
@@ -298,26 +298,31 @@ export class CurrentStateFrontierPlanner {
298
298
  return this.hasOutputs(currentState, nodeId) && !this.hasCompletedSnapshot(currentState, nodeId);
299
299
  }
300
300
 
301
- private isEdgeSatisfied(currentState: RunCurrentState, nodeId: NodeId, input: InputPortKey): boolean {
302
- const incomingEdge = (this.topology.incomingByNode.get(nodeId) ?? []).find((edge) => edge.input === input);
301
+ private isEdgeSatisfied(currentState: RunCurrentState, nodeId: NodeId, collectKey: InputPortKey): boolean {
302
+ const incomingEdge = (this.topology.incomingByNode.get(nodeId) ?? []).find(
303
+ (edge) => edge.collectKey === collectKey,
304
+ );
303
305
  if (!incomingEdge) {
304
306
  return false;
305
307
  }
306
- if (!this.hasOutputPort(currentState, incomingEdge.from.nodeId, incomingEdge.from.output)) {
308
+ const fromNodeId = incomingEdge.from.nodeId;
309
+ if (!this.isNodeSatisfied(currentState, fromNodeId)) {
307
310
  return false;
308
311
  }
309
312
  if (this.usesCollect(nodeId)) {
310
313
  return true;
311
314
  }
312
- const items = this.resolveOutputItems(currentState, incomingEdge.from.nodeId, incomingEdge.from.output);
315
+ const items = this.resolveOutputItems(currentState, fromNodeId, incomingEdge.from.output);
313
316
  if (items.length > 0) {
314
317
  return true;
315
318
  }
316
- return this.shouldContinueAfterEmptyOutputFromSource(incomingEdge.from.nodeId);
319
+ return this.shouldContinueAfterEmptyOutputFromSource(fromNodeId);
317
320
  }
318
321
 
319
- private resolveInput(currentState: RunCurrentState, nodeId: NodeId, input: InputPortKey): Items {
320
- const incomingEdge = (this.topology.incomingByNode.get(nodeId) ?? []).find((edge) => edge.input === input);
322
+ private resolveInput(currentState: RunCurrentState, nodeId: NodeId, collectKey: InputPortKey): Items {
323
+ const incomingEdge = (this.topology.incomingByNode.get(nodeId) ?? []).find(
324
+ (edge) => edge.collectKey === collectKey,
325
+ );
321
326
  if (!incomingEdge) {
322
327
  return [];
323
328
  }
@@ -333,21 +338,16 @@ export class CurrentStateFrontierPlanner {
333
338
  return snapshot?.status === "completed" || snapshot?.status === "skipped";
334
339
  }
335
340
 
336
- private hasOutputPort(currentState: RunCurrentState, nodeId: NodeId, output: OutputPortKey): boolean {
337
- const outputs = currentState.outputsByNode[nodeId];
338
- if (!outputs) {
339
- return false;
340
- }
341
- return Object.prototype.hasOwnProperty.call(outputs, output);
342
- }
343
-
344
341
  private resolveOutputItems(currentState: RunCurrentState, nodeId: NodeId, output: OutputPortKey): Items {
345
342
  return currentState.outputsByNode[nodeId]?.[output] ?? [];
346
343
  }
347
344
 
348
345
  private usesCollect(nodeId: NodeId): boolean {
349
346
  const expectedInputs = this.topology.expectedInputsByNode.get(nodeId) ?? [];
350
- return expectedInputs.length !== 1 || expectedInputs[0] !== "in";
347
+ if (expectedInputs.length !== 1 || expectedInputs[0] !== "in") {
348
+ return true;
349
+ }
350
+ return (this.topology.incomingByNode.get(nodeId) ?? []).length > 1;
351
351
  }
352
352
 
353
353
  private shouldContinueAfterEmptyOutputFromSource(nodeId: NodeId): boolean {
@@ -1,6 +1,7 @@
1
1
  import type { InputPortKey, Items, NodeId, OutputPortKey, RunQueueEntry } from "../types";
2
2
 
3
3
  import { WorkflowTopology } from "./WorkflowTopologyPlanner";
4
+ import type { TopologyOutgoingEdge } from "./WorkflowTopologyPlanner";
4
5
 
5
6
  export type PlannedActivation =
6
7
  | Readonly<{ kind: "single"; nodeId: NodeId; input: Items; batchId: string }>
@@ -25,9 +26,9 @@ export class RunQueuePlanner {
25
26
  }
26
27
 
27
28
  const inst = this.nodeInstances.get(toNodeId);
28
- if (!this.isMultiInputNode(inst)) {
29
+ if (!this.isMultiInputNode(inst) && !this.supportsEngineFanInMerge(inst)) {
29
30
  throw new Error(
30
- `Node ${toNodeId} has ${inputs.length} inbound edges. Insert a Merge node to combine branches.`,
31
+ `Node ${toNodeId} has ${inputs.length} inbound edges but does not support multi-input execution.`,
31
32
  );
32
33
  }
33
34
  }
@@ -39,7 +40,7 @@ export class RunQueuePlanner {
39
40
  if (e.output !== "main") continue;
40
41
  this.enqueueEdge(queue, {
41
42
  batchId: args.batchId,
42
- to: e.to,
43
+ to: this.toEnqueueTarget(e),
43
44
  from: { nodeId: args.startNodeId, output: "main" },
44
45
  items: args.items,
45
46
  });
@@ -55,7 +56,7 @@ export class RunQueuePlanner {
55
56
  const outItems = (args.outputs as any)[e.output] ?? [];
56
57
  this.enqueueEdge(queue, {
57
58
  batchId: args.batchId,
58
- to: e.to,
59
+ to: this.toEnqueueTarget(e),
59
60
  from: { nodeId: args.fromNodeId, output: e.output },
60
61
  items: outItems,
61
62
  });
@@ -157,40 +158,54 @@ export class RunQueuePlanner {
157
158
  */
158
159
  private usesTopologyCollectMerge(toNodeId: NodeId): boolean {
159
160
  const expectedInputs = this.topology.expectedInputsByNode.get(toNodeId) ?? [];
160
- return expectedInputs.length !== 1 || expectedInputs[0] !== "in";
161
+ if (expectedInputs.length !== 1 || expectedInputs[0] !== "in") {
162
+ return true;
163
+ }
164
+ return (this.topology.incomingByNode.get(toNodeId) ?? []).length > 1;
165
+ }
166
+
167
+ private toEnqueueTarget(edge: TopologyOutgoingEdge): Readonly<{
168
+ nodeId: NodeId;
169
+ input: InputPortKey;
170
+ collectKey: InputPortKey;
171
+ }> {
172
+ return edge.to;
161
173
  }
162
174
 
163
175
  private enqueueEdge(
164
176
  queue: RunQueueEntry[],
165
177
  args: Readonly<{
166
178
  batchId: string;
167
- to: { nodeId: NodeId; input: InputPortKey };
179
+ to: { nodeId: NodeId; input: InputPortKey; collectKey: InputPortKey };
168
180
  from: { nodeId: NodeId; output: OutputPortKey };
169
181
  items: Items;
170
182
  }>,
183
+ emptyPathSourceNodeId?: NodeId,
171
184
  ): void {
172
185
  const target = this.nodeInstances.get(args.to.nodeId);
173
186
  const isMulti = this.usesTopologyCollectMerge(args.to.nodeId) || this.isMultiInputNode(target);
174
187
 
175
188
  if (!isMulti) {
176
189
  if (args.items.length === 0) {
177
- if (this.shouldContinueAfterEmptyOutputFromSource(args.from.nodeId)) {
190
+ const continueSourceNodeId = emptyPathSourceNodeId ?? args.from.nodeId;
191
+ if (this.shouldContinueAfterEmptyOutputFromSource(continueSourceNodeId)) {
178
192
  queue.push({
179
193
  nodeId: args.to.nodeId,
180
194
  input: args.items,
181
- toInput: args.to.input,
195
+ toInput: args.to.collectKey,
182
196
  batchId: args.batchId,
183
197
  from: args.from,
184
198
  });
185
199
  return;
186
200
  }
187
- this.propagateEmptyPath(queue, args.to.nodeId, args.batchId);
201
+ const source = emptyPathSourceNodeId ?? args.from.nodeId;
202
+ this.propagateEmptyPath(queue, args.to.nodeId, args.batchId, source);
188
203
  return;
189
204
  }
190
205
  queue.push({
191
206
  nodeId: args.to.nodeId,
192
207
  input: args.items,
193
- toInput: args.to.input,
208
+ toInput: args.to.collectKey,
194
209
  batchId: args.batchId,
195
210
  from: args.from,
196
211
  });
@@ -212,7 +227,7 @@ export class RunQueuePlanner {
212
227
  }
213
228
 
214
229
  const received = (collect.collect as any).received as Record<InputPortKey, Items>;
215
- received[args.to.input] = args.items;
230
+ received[args.to.collectKey] = args.items;
216
231
  }
217
232
 
218
233
  private shouldContinueAfterEmptyOutputFromSource(fromNodeId: NodeId): boolean {
@@ -223,14 +238,23 @@ export class RunQueuePlanner {
223
238
  return def.config.continueWhenEmptyOutput === true;
224
239
  }
225
240
 
226
- private propagateEmptyPath(queue: RunQueueEntry[], nodeId: NodeId, batchId: string): void {
241
+ private propagateEmptyPath(
242
+ queue: RunQueueEntry[],
243
+ nodeId: NodeId,
244
+ batchId: string,
245
+ emptyPathSourceNodeId: NodeId,
246
+ ): void {
227
247
  for (const edge of this.topology.outgoingByNode.get(nodeId) ?? []) {
228
- this.enqueueEdge(queue, {
229
- batchId,
230
- to: edge.to,
231
- from: { nodeId, output: edge.output },
232
- items: [],
233
- });
248
+ this.enqueueEdge(
249
+ queue,
250
+ {
251
+ batchId,
252
+ to: edge.to,
253
+ from: { nodeId, output: edge.output },
254
+ items: [],
255
+ },
256
+ emptyPathSourceNodeId,
257
+ );
234
258
  }
235
259
  }
236
260
 
@@ -238,6 +262,19 @@ export class RunQueuePlanner {
238
262
  return typeof (n as any)?.executeMulti === "function";
239
263
  }
240
264
 
265
+ private hasRunnableExecute(n: unknown): boolean {
266
+ return (
267
+ typeof n === "object" &&
268
+ n !== null &&
269
+ (n as { kind?: string }).kind === "node" &&
270
+ typeof (n as { execute?: unknown }).execute === "function"
271
+ );
272
+ }
273
+
274
+ private supportsEngineFanInMerge(n: unknown): boolean {
275
+ return this.hasRunnableExecute(n) && !this.isMultiInputNode(n);
276
+ }
277
+
241
278
  private describeUnsatisfiedCollect(queueEntry: RunQueueEntry): string {
242
279
  const batchId = queueEntry.batchId ?? "batch_1";
243
280
  const expectedInputs = queueEntry.collect?.expectedInputs ?? [];
@@ -287,7 +324,7 @@ export class RunQueuePlanner {
287
324
  const matches: string[] = [];
288
325
  for (const [sourceNodeId, edges] of this.topology.outgoingByNode.entries()) {
289
326
  for (const edge of edges) {
290
- if (edge.to.nodeId === nodeId && edge.to.input === input) {
327
+ if (edge.to.nodeId === nodeId && edge.to.collectKey === input) {
291
328
  matches.push(this.formatNodeLabel(sourceNodeId));
292
329
  }
293
330
  }
@@ -3,17 +3,22 @@ import { WorkflowExecutableNodeClassifierFactory } from "../workflow/definition/
3
3
 
4
4
  type NodeDef = WorkflowDefinition["nodes"][number];
5
5
 
6
+ export type TopologyIncomingEdge = Readonly<{
7
+ from: Readonly<{ nodeId: NodeId; output: OutputPortKey }>;
8
+ input: InputPortKey;
9
+ collectKey: InputPortKey;
10
+ }>;
11
+
12
+ export type TopologyOutgoingEdge = Readonly<{
13
+ output: OutputPortKey;
14
+ to: Readonly<{ nodeId: NodeId; input: InputPortKey; collectKey: InputPortKey }>;
15
+ }>;
16
+
6
17
  export class WorkflowTopology {
7
18
  private constructor(
8
19
  public readonly defsById: ReadonlyMap<NodeId, NodeDef>,
9
- public readonly outgoingByNode: ReadonlyMap<
10
- NodeId,
11
- ReadonlyArray<Readonly<{ output: OutputPortKey; to: Readonly<{ nodeId: NodeId; input: InputPortKey }> }>>
12
- >,
13
- public readonly incomingByNode: ReadonlyMap<
14
- NodeId,
15
- ReadonlyArray<Readonly<{ from: Readonly<{ nodeId: NodeId; output: OutputPortKey }>; input: InputPortKey }>>
16
- >,
20
+ public readonly outgoingByNode: ReadonlyMap<NodeId, ReadonlyArray<TopologyOutgoingEdge>>,
21
+ public readonly incomingByNode: ReadonlyMap<NodeId, ReadonlyArray<TopologyIncomingEdge>>,
17
22
  public readonly expectedInputsByNode: ReadonlyMap<NodeId, ReadonlyArray<InputPortKey>>,
18
23
  public readonly rootNodeIds: ReadonlyArray<NodeId>,
19
24
  ) {}
@@ -25,46 +30,65 @@ export class WorkflowTopology {
25
30
  if (classifier.isExecutableNodeId(n.id)) defs.set(n.id, n);
26
31
  }
27
32
 
28
- const outgoing = new Map<
29
- NodeId,
30
- Array<Readonly<{ output: OutputPortKey; to: Readonly<{ nodeId: NodeId; input: InputPortKey }> }>>
31
- >();
33
+ const incomingByNode = new Map<NodeId, TopologyIncomingEdge[]>();
32
34
  for (const e of wf.edges) {
33
35
  if (!classifier.isExecutableNodeId(e.from.nodeId) || !classifier.isExecutableNodeId(e.to.nodeId)) {
34
36
  continue;
35
37
  }
36
- const list = outgoing.get(e.from.nodeId) ?? [];
37
- list.push({ output: e.from.output, to: { nodeId: e.to.nodeId, input: e.to.input } });
38
- outgoing.set(e.from.nodeId, list);
38
+ const list = incomingByNode.get(e.to.nodeId) ?? [];
39
+ list.push({
40
+ from: { nodeId: e.from.nodeId, output: e.from.output },
41
+ input: e.to.input,
42
+ collectKey: e.to.input,
43
+ });
44
+ incomingByNode.set(e.to.nodeId, list);
45
+ }
46
+
47
+ const duplicateInputCounts = new Map<NodeId, Map<InputPortKey, number>>();
48
+ for (const [toNodeId, edges] of incomingByNode.entries()) {
49
+ const counts = new Map<InputPortKey, number>();
50
+ for (const edge of edges) {
51
+ counts.set(edge.input, (counts.get(edge.input) ?? 0) + 1);
52
+ }
53
+ duplicateInputCounts.set(toNodeId, counts);
39
54
  }
40
55
 
41
- const incomingByNode = new Map<
42
- NodeId,
43
- Array<Readonly<{ from: Readonly<{ nodeId: NodeId; output: OutputPortKey }>; input: InputPortKey }>>
44
- >();
56
+ for (const [toNodeId, edges] of incomingByNode.entries()) {
57
+ const counts = duplicateInputCounts.get(toNodeId) ?? new Map();
58
+ for (let i = 0; i < edges.length; i++) {
59
+ const edge = edges[i]!;
60
+ const dup = (counts.get(edge.input) ?? 0) > 1;
61
+ const collectKey = dup ? `${edge.from.nodeId}:${edge.from.output}` : edge.input;
62
+ edges[i] = { ...edge, collectKey };
63
+ }
64
+ }
65
+
66
+ const outgoing = new Map<NodeId, TopologyOutgoingEdge[]>();
45
67
  for (const e of wf.edges) {
46
68
  if (!classifier.isExecutableNodeId(e.from.nodeId) || !classifier.isExecutableNodeId(e.to.nodeId)) {
47
69
  continue;
48
70
  }
49
- const list = incomingByNode.get(e.to.nodeId) ?? [];
50
- list.push({ from: { nodeId: e.from.nodeId, output: e.from.output }, input: e.to.input });
51
- incomingByNode.set(e.to.nodeId, list);
71
+ const counts = duplicateInputCounts.get(e.to.nodeId) ?? new Map();
72
+ const dup = (counts.get(e.to.input) ?? 0) > 1;
73
+ const collectKey = dup ? `${e.from.nodeId}:${e.from.output}` : e.to.input;
74
+ const list = outgoing.get(e.from.nodeId) ?? [];
75
+ list.push({
76
+ output: e.from.output,
77
+ to: { nodeId: e.to.nodeId, input: e.to.input, collectKey },
78
+ });
79
+ outgoing.set(e.from.nodeId, list);
52
80
  }
53
81
 
54
82
  const expected = new Map<NodeId, InputPortKey[]>();
55
- for (const [toNodeId, inputs] of incomingByNode.entries()) {
56
- const counts = new Map<InputPortKey, number>();
57
- for (const edge of inputs) counts.set(edge.input, (counts.get(edge.input) ?? 0) + 1);
58
- for (const [k, n] of counts.entries()) {
59
- if (n > 1) throw new Error(`Node ${toNodeId} has multiple edges into input '${k}'. Use a Merge node upstream.`);
60
- }
61
-
83
+ for (const [toNodeId, edges] of incomingByNode.entries()) {
62
84
  const order: InputPortKey[] = [];
63
85
  const seen = new Set<InputPortKey>();
64
- for (const edge of inputs) {
65
- if (seen.has(edge.input)) continue;
66
- seen.add(edge.input);
67
- order.push(edge.input);
86
+ for (const edge of edges) {
87
+ if (seen.has(edge.collectKey)) {
88
+ continue;
89
+ }
90
+ seen.add(edge.collectKey);
91
+ order.push(edge.collectKey);
68
92
  }
69
93
  expected.set(toNodeId, order);
70
94
  }