@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
@@ -17,6 +17,8 @@ export interface JsonObject {
17
17
  }
18
18
  export type JsonValue = JsonPrimitive | JsonObject | JsonArray;
19
19
  export type JsonArray = ReadonlyArray<JsonValue>;
20
+ /** JSON value that is not a top-level array (nested arrays inside objects are allowed). */
21
+ export type JsonNonArray = JsonPrimitive | JsonObject;
20
22
 
21
23
  export interface Edge {
22
24
  from: { nodeId: NodeId; output: OutputPortKey };
@@ -81,74 +83,45 @@ export interface NodeConfigBase {
81
83
  * main batches skip downstream execution and propagate the empty path.
82
84
  */
83
85
  readonly continueWhenEmptyOutput?: boolean;
86
+ /**
87
+ * Declared I/O port names for canvas authoring (unioned with ports inferred from edges).
88
+ * Use for dynamic routers (Switch) and future error ports.
89
+ */
90
+ readonly declaredOutputPorts?: ReadonlyArray<OutputPortKey>;
91
+ readonly declaredInputPorts?: ReadonlyArray<InputPortKey>;
84
92
  getCredentialRequirements?(): ReadonlyArray<CredentialRequirement>;
85
93
  }
86
94
 
87
95
  export declare const runnableNodeInputType: unique symbol;
88
96
  export declare const runnableNodeOutputType: unique symbol;
89
- /** Phantom: JSON shape on the wire from upstream before {@link RunnableNodeConfig.mapInput}. */
90
- export declare const runnableNodeWireType: unique symbol;
91
97
  export declare const triggerNodeOutputType: unique symbol;
92
98
 
93
- /**
94
- * Read-only execution slice passed to {@link RunnableNodeConfig.mapInput} (aligned with the engine’s
95
- * node execution context for `runId`, `data`, etc.). Use **`ctx.data`** to read **any completed** upstream
96
- * node’s outputs in this run (e.g. `ctx.data.getOutputItems(nodeIdA, "main")` while mapping at D), not only
97
- * the immediate predecessor’s {@link ItemInputMapperArgs.item}.
98
- */
99
- export interface ItemInputMapperContext {
100
- readonly runId: RunId;
101
- readonly workflowId: WorkflowId;
102
- /** Node whose activation is being prepared (the consumer of `mapInput`). */
103
- readonly nodeId: NodeId;
104
- readonly activationId: NodeActivationId;
105
- readonly parent?: ParentExecutionRef;
106
- readonly data: RunDataSnapshot;
107
- }
99
+ export type LineageCarryPolicy = "emitOnly" | "carryThrough";
108
100
 
109
101
  /**
110
- * Arguments for optional per-item input mapping applied by the engine before Zod validation.
102
+ * Runnable node: **`TInputJson`** is what **`inputSchema`** validates on **`item.json`** (the wire payload).
103
+ * **`TOutputJson`** is emitted `item.json` on outputs.
111
104
  */
112
- export interface ItemInputMapperArgs<TWireJson = unknown> {
113
- readonly item: Item<TWireJson>;
114
- readonly itemIndex: number;
115
- readonly items: Items<TWireJson>;
116
- readonly ctx: ItemInputMapperContext;
117
- }
118
-
119
- /**
120
- * Per-item mapper before Zod validation. Uses a **bivariant** method signature so concrete
121
- * `ItemInputMapper<SpecificWire, TIn>` remains assignable to `RunnableNodeConfig` fields typed as
122
- * `ItemInputMapper<unknown, unknown>` (same pattern as React-style callbacks).
123
- */
124
- export type ItemInputMapper<TWireJson = unknown, TInputJson = unknown> = {
125
- bivarianceHack(args: ItemInputMapperArgs<TWireJson>): TInputJson | Promise<TInputJson>;
126
- }["bivarianceHack"];
127
-
128
- /**
129
- * Runnable node: **`TInputJson`** is the payload after `mapInput` (if any) + Zod validation — what {@link ItemNode}
130
- * `executeOne` receives. **`TOutputJson`** is emitted `item.json` on outputs. **`TWireJson`** is `item.json` from
131
- * upstream **before** `mapInput`; it defaults to **`TInputJson`** when there is no mapper or wire differs from execute input.
132
- */
133
- export interface RunnableNodeConfig<
134
- TInputJson = unknown,
135
- TOutputJson = unknown,
136
- TWireJson = TInputJson,
137
- > extends NodeConfigBase {
105
+ export interface RunnableNodeConfig<TInputJson = unknown, TOutputJson = unknown> extends NodeConfigBase {
138
106
  readonly kind: "node";
139
107
  readonly [runnableNodeInputType]?: TInputJson;
140
108
  readonly [runnableNodeOutputType]?: TOutputJson;
141
- readonly [runnableNodeWireType]?: TWireJson;
142
109
  /**
143
- * Optional Zod input contract for {@link ItemNode} when not set on the node class.
110
+ * Optional Zod input contract for {@link RunnableNode} when not set on the node class.
144
111
  * Resolution order: node instance `inputSchema`, then config `inputSchema`, then `z.unknown()`.
145
112
  */
146
113
  readonly inputSchema?: ZodType<TInputJson>;
147
114
  /**
148
- * Optional per-item mapper: engine applies it before validating against the node’s `inputSchema`.
149
- * When omitted, the engine validates `item.json` directly.
115
+ * Overrides default lineage propagation for `execute` outputs (binary/meta/paired).
116
+ * Routers with multiple {@link RunnableNode#outputPorts} default to **`carryThrough`**; others default to **`emitOnly`**.
117
+ */
118
+ readonly lineageCarry?: LineageCarryPolicy;
119
+ /**
120
+ * When an activation receives **zero** input items, the engine normally runs `execute` zero times.
121
+ * Set to **`runOnce`** to run `execute` once with an empty `items` batch (and a synthetic wire item for schema parsing).
122
+ * Used by batch-style callback nodes (built-in `Callback`) so `callback([], ctx)` still runs.
150
123
  */
151
- readonly mapInput?: ItemInputMapper<TWireJson, TInputJson>;
124
+ readonly emptyBatchExecution?: "skip" | "runOnce";
152
125
  }
153
126
 
154
127
  export declare const triggerNodeSetupStateType: unique symbol;
@@ -162,14 +135,11 @@ export interface TriggerNodeConfig<
162
135
  readonly [triggerNodeSetupStateType]?: TSetupState;
163
136
  }
164
137
 
165
- export type RunnableNodeInputJson<TConfig extends RunnableNodeConfig<any, any, any>> =
166
- TConfig extends RunnableNodeConfig<infer TInputJson, any, any> ? TInputJson : never;
167
-
168
- export type RunnableNodeWireJson<TConfig extends RunnableNodeConfig<any, any, any>> =
169
- TConfig extends RunnableNodeConfig<any, any, infer TWireJson> ? TWireJson : never;
138
+ export type RunnableNodeInputJson<TConfig extends RunnableNodeConfig<any, any>> =
139
+ TConfig extends RunnableNodeConfig<infer TInputJson, any> ? TInputJson : never;
170
140
 
171
- export type RunnableNodeOutputJson<TConfig extends RunnableNodeConfig<any, any, any>> =
172
- TConfig extends RunnableNodeConfig<any, infer TOutputJson, any> ? TOutputJson : never;
141
+ export type RunnableNodeOutputJson<TConfig extends RunnableNodeConfig<any, any>> =
142
+ TConfig extends RunnableNodeConfig<any, infer TOutputJson> ? TOutputJson : never;
173
143
 
174
144
  export type TriggerNodeOutputJson<TConfig extends TriggerNodeConfig<any, any>> =
175
145
  TConfig extends TriggerNodeConfig<infer TOutputJson, any> ? TOutputJson : never;
@@ -0,0 +1,67 @@
1
+ import type { InputPortKey, Item, Items, NodeInputsByPort } from "../types";
2
+
3
+ import { getOriginIndexFromItem } from "../contracts/itemMeta";
4
+
5
+ /**
6
+ * Default fan-in: combine multi-port {@link NodeInputsByPort} into one {@link Items} batch for per-item nodes.
7
+ *
8
+ * This is used when a single-input per-item node has multiple inbound edges (for example, branch reconverge
9
+ * after an `If` / `Switch`). The default behavior is **append / union** (preserving item payloads) with a
10
+ * deterministic order:
11
+ *
12
+ * - When router origin metadata exists (`meta._cm.originIndex`), items are sorted by origin index so the
13
+ * downstream batch preserves original ordering across branches.
14
+ * - Otherwise, items are appended by port-key order, preserving each port's local order.
15
+ */
16
+ export class FanInMergeByOriginMerger {
17
+ merge(inputsByPort: NodeInputsByPort): Items {
18
+ const portKeys = Object.keys(inputsByPort).sort();
19
+ if (portKeys.length === 0) {
20
+ return [];
21
+ }
22
+ if (portKeys.length === 1) {
23
+ const only = portKeys[0]!;
24
+ return [...(inputsByPort[only] ?? [])];
25
+ }
26
+
27
+ type Entry = Readonly<{
28
+ portKey: InputPortKey;
29
+ portIndex: number;
30
+ item: Item;
31
+ originIndex: number | undefined;
32
+ }>;
33
+
34
+ const entries: Entry[] = [];
35
+ let anyOrigin = false;
36
+
37
+ for (let p = 0; p < portKeys.length; p++) {
38
+ const portKey = portKeys[p]!;
39
+ const items = inputsByPort[portKey] ?? [];
40
+ for (let i = 0; i < items.length; i++) {
41
+ const item = items[i] as Item;
42
+ const originIndex = getOriginIndexFromItem(item);
43
+ if (originIndex !== undefined) {
44
+ anyOrigin = true;
45
+ }
46
+ entries.push({ portKey, portIndex: i, item, originIndex });
47
+ }
48
+ }
49
+
50
+ if (!anyOrigin) {
51
+ return entries.map((e) => e.item);
52
+ }
53
+
54
+ const missingOriginRank = Number.MAX_SAFE_INTEGER;
55
+ return entries
56
+ .slice()
57
+ .sort((a, b) => {
58
+ const ao = a.originIndex ?? missingOriginRank;
59
+ const bo = b.originIndex ?? missingOriginRank;
60
+ if (ao !== bo) return ao - bo;
61
+ const pk = a.portKey.localeCompare(b.portKey);
62
+ if (pk !== 0) return pk;
63
+ return a.portIndex - b.portIndex;
64
+ })
65
+ .map((e) => e.item);
66
+ }
67
+ }
@@ -0,0 +1,27 @@
1
+ import { resolveItemValuesForExecution } from "../contracts/itemValue";
2
+ import type { Item, NodeExecutionContext, RunnableNodeConfig } from "../types";
3
+
4
+ /**
5
+ * Resolves {@link import("../contracts/itemValue").ItemValue} leaves on runnable config before {@link RunnableNode.execute}.
6
+ */
7
+ export class ItemValueResolver {
8
+ async resolveConfigForItem<TConfig extends RunnableNodeConfig<any, any>>(
9
+ ctx: NodeExecutionContext<TConfig>,
10
+ item: Item,
11
+ itemIndex: number,
12
+ items: ReadonlyArray<Item>,
13
+ ): Promise<NodeExecutionContext<TConfig>> {
14
+ if (!ctx) {
15
+ throw new Error("ItemValueResolver.resolveConfigForItem: ctx is required");
16
+ }
17
+ const resolvedConfig = await resolveItemValuesForExecution(ctx.config, ctx, item, itemIndex, items);
18
+ const merged = resolvedConfig !== undefined && resolvedConfig !== null ? resolvedConfig : ctx.config;
19
+ if (merged === undefined || merged === null) {
20
+ return ctx;
21
+ }
22
+ return {
23
+ ...ctx,
24
+ config: merged as TConfig,
25
+ };
26
+ }
27
+ }
@@ -6,6 +6,7 @@ import type {
6
6
  NodeActivationRequest,
7
7
  NodeExecutionContext,
8
8
  NodeId,
9
+ NodeInputsByPort,
9
10
  ParentExecutionRef,
10
11
  RunDataFactory,
11
12
  RunExecutionOptions,
@@ -34,6 +35,12 @@ export type SingleDefinitionActivationRequest = NodeActivationContextArgs & {
34
35
  input: Items;
35
36
  };
36
37
 
38
+ export type MultiDefinitionActivationRequest = NodeActivationContextArgs & {
39
+ definition: NodeExecutionDefinition;
40
+ batchId: string;
41
+ inputsByPort: NodeInputsByPort;
42
+ };
43
+
37
44
  export type PlannedNodeActivationRequest = NodeActivationContextArgs & {
38
45
  next: PlannedActivation;
39
46
  nodeDefinition: NodeExecutionDefinition;
@@ -75,6 +82,24 @@ export class NodeActivationRequestComposer {
75
82
  };
76
83
  }
77
84
 
85
+ createMultiFromDefinitionWithActivation(
86
+ args: MultiDefinitionActivationRequest & Readonly<{ activationId: NodeActivationId }>,
87
+ ): Extract<NodeActivationRequest, { kind: "multi" }> {
88
+ const ctx = this.createNodeExecutionContext(args, args.definition, args.activationId);
89
+ return {
90
+ kind: "multi",
91
+ runId: args.runId,
92
+ activationId: args.activationId,
93
+ workflowId: args.workflowId,
94
+ nodeId: args.definition.id,
95
+ parent: args.parent,
96
+ executionOptions: args.executionOptions,
97
+ batchId: args.batchId,
98
+ inputsByPort: args.inputsByPort,
99
+ ctx,
100
+ };
101
+ }
102
+
78
103
  createFromPlannedActivation(args: PlannedNodeActivationRequest): NodeActivationRequest {
79
104
  const activationId = this.activationIdFactory.makeActivationId();
80
105
  const ctx = this.createNodeExecutionContext(args, args.nodeDefinition, activationId);
@@ -2,40 +2,62 @@ import { z, ZodError } from "zod";
2
2
 
3
3
  import type { Item, NodeActivationRequest, RunnableNodeConfig, WorkflowNodeInstanceFactory } from "../types";
4
4
 
5
+ import { FanInMergeByOriginMerger } from "./FanInMergeByOriginMerger";
5
6
  import { NodeInputContractError } from "./NodeInputContractError";
6
7
 
7
8
  /**
8
- * Maps and validates per-item inputs for {@link ItemNode} before enqueue persistence.
9
+ * Validates per-item inputs for {@link RunnableNode} before enqueue persistence (Zod on `item.json`).
10
+ * Does not rewrite `item.json` (wire stays as emitted upstream; engine passes parsed input via `execute` args).
11
+ * Converts multi-input activations into a single-input batch when the node is per-item only (engine fan-in).
9
12
  */
10
13
  export class NodeActivationRequestInputPreparer {
14
+ private readonly fanInMerger = new FanInMergeByOriginMerger();
15
+
11
16
  constructor(private readonly workflowNodeInstanceFactory: WorkflowNodeInstanceFactory) {}
12
17
 
13
18
  async prepare(request: NodeActivationRequest): Promise<NodeActivationRequest> {
14
- if (request.kind !== "single") {
19
+ if (request.kind === "multi") {
20
+ return await this.prepareMulti(request);
21
+ }
22
+ return await this.prepareSingle(request);
23
+ }
24
+
25
+ private async prepareMulti(
26
+ request: Extract<NodeActivationRequest, { kind: "multi" }>,
27
+ ): Promise<NodeActivationRequest> {
28
+ const nodeInstance: unknown = this.workflowNodeInstanceFactory.createByType(request.ctx.config.type);
29
+ if (
30
+ !this.hasRunnableExecute(nodeInstance) ||
31
+ this.hasExecuteMulti(nodeInstance) ||
32
+ this.isTriggerNode(nodeInstance)
33
+ ) {
15
34
  return request;
16
35
  }
36
+ const merged = this.fanInMerger.merge(request.inputsByPort);
37
+ const single: Extract<NodeActivationRequest, { kind: "single" }> = {
38
+ ...request,
39
+ kind: "single",
40
+ input: merged,
41
+ };
42
+ return await this.prepareSingle(single);
43
+ }
44
+
45
+ private async prepareSingle(
46
+ request: Extract<NodeActivationRequest, { kind: "single" }>,
47
+ ): Promise<NodeActivationRequest> {
17
48
  const nodeInstance: unknown = this.workflowNodeInstanceFactory.createByType(request.ctx.config.type);
18
- if (!this.hasExecuteOne(nodeInstance)) {
49
+ if (!this.hasRunnableExecute(nodeInstance) || this.isTriggerNode(nodeInstance)) {
19
50
  return request;
20
51
  }
21
52
  const inputSchema = this.resolveInputSchema(nodeInstance, request.ctx.config as RunnableNodeConfig);
22
- const config = request.ctx.config as RunnableNodeConfig;
23
- const mappedItems: Item[] = [];
24
- for (let i = 0; i < request.input.length; i++) {
25
- const item = request.input[i] as Item;
53
+ const inputBatch = request.input ?? [];
54
+ for (let i = 0; i < inputBatch.length; i++) {
55
+ const item = inputBatch[i] as Item;
26
56
  try {
27
- const mappedRaw = config.mapInput
28
- ? await Promise.resolve(
29
- config.mapInput({
30
- item,
31
- itemIndex: i,
32
- items: request.input,
33
- ctx: request.ctx,
34
- }),
35
- )
36
- : item.json;
37
- const parsed = inputSchema.parse(mappedRaw);
38
- mappedItems.push({ ...item, json: parsed });
57
+ if (Array.isArray(item.json)) {
58
+ throw new Error("Item JSON must not be a top-level array");
59
+ }
60
+ inputSchema.parse(item.json);
39
61
  } catch (cause) {
40
62
  const message = this.formatContractFailure(cause);
41
63
  throw new NodeInputContractError(
@@ -46,20 +68,30 @@ export class NodeActivationRequestInputPreparer {
46
68
  );
47
69
  }
48
70
  }
49
- return {
50
- ...request,
51
- input: mappedItems,
52
- };
71
+ return request.input === undefined ? { ...request, input: inputBatch } : request;
53
72
  }
54
73
 
55
- private hasExecuteOne(nodeInstance: unknown): boolean {
74
+ private isTriggerNode(nodeInstance: unknown): boolean {
56
75
  return (
57
76
  typeof nodeInstance === "object" &&
58
77
  nodeInstance !== null &&
59
- typeof (nodeInstance as { executeOne?: unknown }).executeOne === "function"
78
+ (nodeInstance as { kind?: string }).kind === "trigger"
60
79
  );
61
80
  }
62
81
 
82
+ private hasRunnableExecute(nodeInstance: unknown): boolean {
83
+ return (
84
+ typeof nodeInstance === "object" &&
85
+ nodeInstance !== null &&
86
+ (nodeInstance as { kind?: string }).kind === "node" &&
87
+ typeof (nodeInstance as { execute?: unknown }).execute === "function"
88
+ );
89
+ }
90
+
91
+ private hasExecuteMulti(nodeInstance: unknown): boolean {
92
+ return typeof (nodeInstance as { executeMulti?: unknown }).executeMulti === "function";
93
+ }
94
+
63
95
  private resolveInputSchema(
64
96
  nodeInstance: unknown,
65
97
  config: RunnableNodeConfig,
@@ -1,80 +1,249 @@
1
+ import { z } from "zod";
2
+ import { isPortsEmission, isUnbrandedPortsEmissionShape } from "../contracts/emitPorts";
3
+
1
4
  import type {
2
5
  Item,
6
+ LineageCarryPolicy,
3
7
  MultiInputNode,
4
- Node,
5
8
  NodeActivationRequest,
9
+ NodeConfigBase,
10
+ NodeExecutionContext,
6
11
  NodeOutputs,
12
+ RunnableNode,
13
+ RunnableNodeConfig,
14
+ RunnableNodeExecuteArgs,
15
+ TriggerNode,
7
16
  WorkflowNodeInstanceFactory,
8
17
  } from "../types";
9
18
 
19
+ import { FanInMergeByOriginMerger } from "./FanInMergeByOriginMerger";
20
+ import { ItemValueResolver } from "./ItemValueResolver";
10
21
  import { InProcessRetryRunner } from "./InProcessRetryRunner";
22
+ import { NodeOutputNormalizer } from "./NodeOutputNormalizer";
11
23
 
12
24
  export class NodeExecutor {
25
+ private readonly fanInMerger = new FanInMergeByOriginMerger();
26
+ private readonly outputNormalizer = new NodeOutputNormalizer();
27
+ private readonly itemValueResolver: ItemValueResolver;
28
+
13
29
  constructor(
14
30
  private readonly nodeInstanceFactory: WorkflowNodeInstanceFactory,
15
31
  private readonly retryRunner: InProcessRetryRunner,
16
- ) {}
32
+ itemValueResolver?: ItemValueResolver,
33
+ ) {
34
+ this.itemValueResolver = itemValueResolver ?? new ItemValueResolver();
35
+ }
17
36
 
18
37
  async execute(request: NodeActivationRequest): Promise<NodeOutputs> {
19
38
  const policy = request.ctx.config.retryPolicy;
20
39
  return await this.retryRunner.run(policy, async () => {
21
40
  const nodeInstance = this.nodeInstanceFactory.createByType(request.ctx.config.type);
22
41
  if (request.kind === "multi") {
23
- return await this.executeMultiInputNode(request, nodeInstance);
42
+ return await this.executeMultiInputActivation(request, nodeInstance);
24
43
  }
25
44
  return await this.executeSingleInputNode(request, nodeInstance);
26
45
  });
27
46
  }
28
47
 
29
- private async executeMultiInputNode(
48
+ private async executeMultiInputActivation(
30
49
  request: Extract<NodeActivationRequest, { kind: "multi" }>,
31
50
  node: unknown,
32
51
  ): Promise<NodeOutputs> {
33
52
  const multiInputNode = node as MultiInputNode;
34
- if (typeof (multiInputNode as { executeMulti?: unknown }).executeMulti !== "function") {
35
- throw new Error(`Node ${request.nodeId} does not support executeMulti but received multi-input activation`);
53
+ if (typeof (multiInputNode as { executeMulti?: unknown }).executeMulti === "function") {
54
+ const raw = await multiInputNode.executeMulti(request.inputsByPort, request.ctx as never);
55
+ this.assertNoPortEnvelopeBypass(request.nodeId, raw, "executeMulti()");
56
+ return raw;
36
57
  }
37
- return await multiInputNode.executeMulti(request.inputsByPort, request.ctx as any);
58
+ if (this.isRunnableNode(node)) {
59
+ const merged = this.fanInMerger.merge(request.inputsByPort);
60
+ const single: Extract<NodeActivationRequest, { kind: "single" }> = {
61
+ ...request,
62
+ kind: "single",
63
+ input: merged,
64
+ };
65
+ return await this.executeRunnableActivation(single, node);
66
+ }
67
+ throw new Error(
68
+ `Node ${request.nodeId} does not support executeMulti or RunnableNode.execute but received multi-input activation`,
69
+ );
38
70
  }
39
71
 
40
72
  private async executeSingleInputNode(
41
73
  request: Extract<NodeActivationRequest, { kind: "single" }>,
42
74
  node: unknown,
43
75
  ): Promise<NodeOutputs> {
44
- if (this.hasExecuteOne(node)) {
45
- return await this.executeItemNode(request, node);
76
+ if (this.isTriggerNode(node)) {
77
+ const raw = await (node as TriggerNode).execute(request.input, request.ctx as never);
78
+ this.assertNoPortEnvelopeBypass(request.nodeId, raw, "trigger execute()");
79
+ return raw;
46
80
  }
47
- const singleInputNode = node as Node;
48
- if (typeof (singleInputNode as { execute?: unknown }).execute !== "function") {
49
- throw new Error(`Node ${request.nodeId} does not support execute but received single-input activation`);
81
+ if (this.isRunnableNode(node)) {
82
+ return await this.executeRunnableActivation(request, node);
50
83
  }
51
- return await singleInputNode.execute(request.input, request.ctx as any);
84
+ if (this.hasExecuteMulti(node)) {
85
+ return await this.executeMultiInputActivation(this.asMultiFromSingleActivation(request), node);
86
+ }
87
+ throw new Error(`Node ${request.nodeId} does not support trigger or RunnableNode execution`);
88
+ }
89
+
90
+ private isTriggerNode(node: unknown): node is TriggerNode {
91
+ return typeof node === "object" && node !== null && (node as { kind?: string }).kind === "trigger";
52
92
  }
53
93
 
54
- private hasExecuteOne(node: unknown): node is { executeOne: (args: unknown) => unknown | Promise<unknown> } {
94
+ private isRunnableNode(node: unknown): node is RunnableNode {
55
95
  return (
56
- typeof node === "object" && node !== null && typeof (node as { executeOne?: unknown }).executeOne === "function"
96
+ typeof node === "object" &&
97
+ node !== null &&
98
+ (node as { kind?: string }).kind === "node" &&
99
+ typeof (node as { execute?: unknown }).execute === "function"
57
100
  );
58
101
  }
59
102
 
60
- private async executeItemNode(
103
+ private hasExecuteMulti(node: unknown): boolean {
104
+ return typeof (node as { executeMulti?: unknown })?.executeMulti === "function";
105
+ }
106
+
107
+ private asMultiFromSingleActivation(
61
108
  request: Extract<NodeActivationRequest, { kind: "single" }>,
62
- node: { executeOne: (args: unknown) => unknown | Promise<unknown> },
109
+ ): Extract<NodeActivationRequest, { kind: "multi" }> {
110
+ return {
111
+ kind: "multi",
112
+ runId: request.runId,
113
+ activationId: request.activationId,
114
+ workflowId: request.workflowId,
115
+ nodeId: request.nodeId,
116
+ parent: request.parent,
117
+ executionOptions: request.executionOptions,
118
+ batchId: request.batchId,
119
+ ctx: request.ctx,
120
+ inputsByPort: { in: request.input ?? [] },
121
+ };
122
+ }
123
+
124
+ private async executeRunnableActivation(
125
+ request: Extract<NodeActivationRequest, { kind: "single" }>,
126
+ node: RunnableNode,
63
127
  ): Promise<NodeOutputs> {
64
- const out: Item[] = [];
65
- for (let i = 0; i < request.input.length; i++) {
66
- const item = request.input[i] as Item;
67
- const outputJson = await Promise.resolve(
68
- node.executeOne({
69
- input: item.json,
70
- item,
71
- itemIndex: i,
72
- items: request.input,
73
- ctx: request.ctx,
74
- }),
128
+ const runnableConfig = request.ctx.config as RunnableNodeConfig;
129
+ const carry = this.resolveLineageCarry(node, runnableConfig);
130
+ const inputSchema = this.resolveInputSchema(node, runnableConfig);
131
+ const inputBatch = request.input ?? [];
132
+ if (inputBatch.length === 0 && runnableConfig.emptyBatchExecution === "runOnce") {
133
+ const syntheticItem: Item = { json: {} };
134
+ const parsed = inputSchema.parse(syntheticItem.json);
135
+ const runnableCtx = request.ctx as NodeExecutionContext<RunnableNodeConfig>;
136
+ const resolvedCtx = await this.itemValueResolver.resolveConfigForItem(runnableCtx, syntheticItem, 0, inputBatch);
137
+ const ctx = this.pickExecutionContext(runnableCtx, resolvedCtx);
138
+ const args: RunnableNodeExecuteArgs = {
139
+ input: parsed,
140
+ item: syntheticItem,
141
+ itemIndex: 0,
142
+ items: inputBatch,
143
+ ctx,
144
+ };
145
+ const raw = await Promise.resolve(node.execute(args));
146
+ return this.outputNormalizer.normalizeExecuteResult({
147
+ baseItem: syntheticItem,
148
+ raw,
149
+ carry,
150
+ }) as NodeOutputs;
151
+ }
152
+ const byPort: Partial<Record<string, Item[]>> = {};
153
+ for (let i = 0; i < inputBatch.length; i++) {
154
+ const item = inputBatch[i] as Item;
155
+ this.assertItemJsonNotTopLevelArray(request.nodeId, item);
156
+ const parsed = inputSchema.parse(item.json);
157
+ const runnableCtx = request.ctx as NodeExecutionContext<RunnableNodeConfig>;
158
+ const resolvedCtx = await this.itemValueResolver.resolveConfigForItem(runnableCtx, item, i, inputBatch);
159
+ const ctx = this.pickExecutionContext(runnableCtx, resolvedCtx);
160
+ const args: RunnableNodeExecuteArgs = {
161
+ input: parsed,
162
+ item,
163
+ itemIndex: i,
164
+ items: inputBatch,
165
+ ctx,
166
+ };
167
+ const raw = await Promise.resolve(node.execute(args));
168
+ const normalized = this.outputNormalizer.normalizeExecuteResult({
169
+ baseItem: item,
170
+ raw,
171
+ carry,
172
+ });
173
+ for (const [port, batch] of Object.entries(normalized)) {
174
+ if (!batch || batch.length === 0) {
175
+ continue;
176
+ }
177
+ const list = byPort[port] ?? [];
178
+ list.push(...batch);
179
+ byPort[port] = list;
180
+ }
181
+ }
182
+ return byPort as NodeOutputs;
183
+ }
184
+
185
+ /** Use resolver ctx only when {@link NodeExecutionContext.config} is non-nullish. */
186
+ private pickExecutionContext<TConfig extends RunnableNodeConfig<any, any>>(
187
+ runnableCtx: NodeExecutionContext<TConfig>,
188
+ resolvedCtx: NodeExecutionContext<TConfig> | null | undefined,
189
+ ): NodeExecutionContext<TConfig> {
190
+ if (resolvedCtx != null && resolvedCtx.config != null) {
191
+ return resolvedCtx;
192
+ }
193
+ return runnableCtx;
194
+ }
195
+
196
+ private resolveInputSchema(
197
+ nodeInstance: RunnableNode,
198
+ config: RunnableNodeConfig,
199
+ ): {
200
+ parse: (data: unknown) => unknown;
201
+ } {
202
+ const fromInstance = nodeInstance.inputSchema;
203
+ if (fromInstance && typeof fromInstance.parse === "function") {
204
+ return fromInstance as { parse: (data: unknown) => unknown };
205
+ }
206
+ const fromConfig = config.inputSchema;
207
+ if (fromConfig && typeof fromConfig.parse === "function") {
208
+ return fromConfig as { parse: (data: unknown) => unknown };
209
+ }
210
+ return z.unknown();
211
+ }
212
+
213
+ private assertItemJsonNotTopLevelArray(nodeId: string, item: Item): void {
214
+ if (Array.isArray(item.json)) {
215
+ throw new Error(`Node ${nodeId}: item.json must not be a top-level JSON array`);
216
+ }
217
+ }
218
+
219
+ private assertNoPortEnvelopeBypass(nodeId: string, value: unknown, methodName: string): void {
220
+ if (isPortsEmission(value)) {
221
+ throw new Error(`Node ${nodeId}: ${methodName} must return NodeOutputs, not emitPorts(...).`);
222
+ }
223
+ if (isUnbrandedPortsEmissionShape(value)) {
224
+ throw new Error(
225
+ `Node ${nodeId}: ${methodName} returned an unbranded \`{ ports: ... }\` object. Return NodeOutputs instead.`,
75
226
  );
76
- out.push({ ...item, json: outputJson });
77
227
  }
78
- return { main: out };
228
+ }
229
+
230
+ private resolveLineageCarry(node: unknown, config: RunnableNodeConfig): LineageCarryPolicy {
231
+ if (config.lineageCarry) {
232
+ return config.lineageCarry;
233
+ }
234
+
235
+ const base = config as NodeConfigBase;
236
+ const declared = base.declaredOutputPorts;
237
+ const ports =
238
+ declared && declared.length > 0
239
+ ? [...new Set([...(declared as readonly string[]), ...(base.nodeErrorHandler ? ["error"] : [])])]
240
+ : base.nodeErrorHandler
241
+ ? (["main", "error"] as const)
242
+ : (["main"] as const);
243
+
244
+ if (ports.length > 1) {
245
+ return "carryThrough";
246
+ }
247
+ return "emitOnly";
79
248
  }
80
249
  }