2
0
Эх сурвалжийг харах

fix(runtime): branch edge cases (#511)

* feat(runtime): node add successors & predecessors api

* fix(runtime): branch followed by multiple layer nodes

* fix(runtime): no condition activated should be an error
Louis Young 6 сар өмнө
parent
commit
6a8344edd9

+ 2 - 0
packages/runtime/interface/src/runtime/document/node.ts

@@ -33,6 +33,8 @@ export interface INode<T = any> {
   children: INode[];
   prev: INode[];
   next: INode[];
+  successors: INode[];
+  predecessors: INode[];
   isBranch: boolean;
 }
 

+ 68 - 0
packages/runtime/js-core/src/domain/__tests__/schemas/branch-two-layers.test.ts

@@ -0,0 +1,68 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { describe, expect, it } from 'vitest';
+import { IContainer, IEngine, WorkflowStatus } from '@flowgram.ai/runtime-interface';
+
+import { WorkflowRuntimeContainer } from '../../container';
+import { TestSchemas } from '.';
+
+const container: IContainer = WorkflowRuntimeContainer.instance;
+
+describe('WorkflowRuntime branch schema', () => {
+  it('should execute a workflow with branch 1', async () => {
+    const engine = container.get<IEngine>(IEngine);
+    const { context, processing } = engine.invoke({
+      schema: TestSchemas.branchTwoLayersSchema,
+      inputs: {
+        model_id: 1,
+        prompt: 'Tell me a joke',
+      },
+    });
+    expect(context.statusCenter.workflow.status).toBe(WorkflowStatus.Processing);
+    const result = await processing;
+    expect(context.statusCenter.workflow.status).toBe(WorkflowStatus.Succeeded);
+    expect(result).toStrictEqual({
+      m3_res:
+        'Hi, I am an AI model, my name is AI_MODEL_3, temperature is 0.5, system prompt is "I\'m Model 3", prompt is "Hi, I am an AI model, my name is AI_MODEL_1, temperature is 0.5, system prompt is "I\'m Model 1", prompt is "Tell me a joke""',
+    });
+    const report = context.reporter.export();
+    expect(report.workflowStatus.status).toBe(WorkflowStatus.Succeeded);
+    expect(report.reports.start_0.status).toBe(WorkflowStatus.Succeeded);
+    expect(report.reports.condition_0.status).toBe(WorkflowStatus.Succeeded);
+    expect(report.reports.llm_1.status).toBe(WorkflowStatus.Succeeded);
+    expect(report.reports.llm_3.status).toBe(WorkflowStatus.Succeeded);
+    expect(report.reports.end_0.status).toBe(WorkflowStatus.Succeeded);
+    expect(report.reports.llm_2).toBeUndefined();
+    expect(report.reports.llm_4).toBeUndefined();
+  });
+
+  it('should execute a workflow with branch 2', async () => {
+    const engine = container.get<IEngine>(IEngine);
+    const { context, processing } = engine.invoke({
+      schema: TestSchemas.branchTwoLayersSchema,
+      inputs: {
+        model_id: 2,
+        prompt: 'Tell me a story',
+      },
+    });
+    expect(context.statusCenter.workflow.status).toBe(WorkflowStatus.Processing);
+    const result = await processing;
+    expect(context.statusCenter.workflow.status).toBe(WorkflowStatus.Succeeded);
+    expect(result).toStrictEqual({
+      m4_res:
+        'Hi, I am an AI model, my name is AI_MODEL_4, temperature is 0.5, system prompt is "I\'m Model 4", prompt is "Hi, I am an AI model, my name is AI_MODEL_2, temperature is 0.6, system prompt is "I\'m Model 2", prompt is "Tell me a story""',
+    });
+    const report = context.reporter.export();
+    expect(report.workflowStatus.status).toBe(WorkflowStatus.Succeeded);
+    expect(report.reports.start_0.status).toBe(WorkflowStatus.Succeeded);
+    expect(report.reports.condition_0.status).toBe(WorkflowStatus.Succeeded);
+    expect(report.reports.llm_2.status).toBe(WorkflowStatus.Succeeded);
+    expect(report.reports.llm_4.status).toBe(WorkflowStatus.Succeeded);
+    expect(report.reports.end_0.status).toBe(WorkflowStatus.Succeeded);
+    expect(report.reports.llm_1).toBeUndefined();
+    expect(report.reports.llm_3).toBeUndefined();
+  });
+});

+ 466 - 0
packages/runtime/js-core/src/domain/__tests__/schemas/branch-two-layers.ts

@@ -0,0 +1,466 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { WorkflowSchema } from '@flowgram.ai/runtime-interface';
+
+export const branchTwoLayersSchema: WorkflowSchema = {
+  nodes: [
+    {
+      id: 'start_0',
+      type: 'start',
+      meta: {
+        position: {
+          x: 180,
+          y: 368.3,
+        },
+      },
+      data: {
+        title: 'Start',
+        outputs: {
+          type: 'object',
+          properties: {
+            model_id: {
+              key: 0,
+              name: 'model_id',
+              isPropertyRequired: false,
+              type: 'integer',
+              default: 'Hello Flow.',
+              extra: {
+                index: 0,
+              },
+            },
+            prompt: {
+              key: 5,
+              name: 'prompt',
+              isPropertyRequired: false,
+              type: 'string',
+              extra: {
+                index: 1,
+              },
+            },
+          },
+          required: [],
+        },
+      },
+    },
+    {
+      id: 'end_0',
+      type: 'end',
+      meta: {
+        position: {
+          x: 2020,
+          y: 368.29999999999995,
+        },
+      },
+      data: {
+        title: 'End',
+        inputs: {
+          type: 'object',
+          properties: {
+            m3_res: {
+              type: 'string',
+            },
+            m4_res: {
+              type: 'string',
+            },
+          },
+        },
+        inputsValues: {
+          m3_res: {
+            type: 'ref',
+            content: ['llm_3', 'result'],
+          },
+          m4_res: {
+            type: 'ref',
+            content: ['llm_4', 'result'],
+          },
+        },
+      },
+    },
+    {
+      id: 'condition_0',
+      type: 'condition',
+      meta: {
+        position: {
+          x: 640,
+          y: 304.8,
+        },
+      },
+      data: {
+        title: 'Condition',
+        conditions: [
+          {
+            value: {
+              left: {
+                type: 'ref',
+                content: ['start_0', 'model_id'],
+              },
+              operator: 'eq',
+              right: {
+                type: 'constant',
+                content: 1,
+              },
+            },
+            key: 'if_1',
+          },
+          {
+            value: {
+              left: {
+                type: 'ref',
+                content: ['start_0', 'model_id'],
+              },
+              operator: 'eq',
+              right: {
+                type: 'constant',
+                content: 2,
+              },
+            },
+            key: 'if_2',
+          },
+        ],
+      },
+    },
+    {
+      id: 'llm_1',
+      type: 'llm',
+      meta: {
+        position: {
+          x: 1100,
+          y: 0,
+        },
+      },
+      data: {
+        title: 'LLM_1',
+        inputsValues: {
+          modelName: {
+            type: 'constant',
+            content: 'AI_MODEL_1',
+          },
+          apiKey: {
+            type: 'constant',
+            content: 'sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
+          },
+          apiHost: {
+            type: 'constant',
+            content: 'https://mock-ai-url/api/v3',
+          },
+          temperature: {
+            type: 'constant',
+            content: 0.5,
+          },
+          systemPrompt: {
+            type: 'template',
+            content: "I'm Model 1",
+          },
+          prompt: {
+            type: 'template',
+            content: '{{start_0.prompt}}',
+          },
+        },
+        inputs: {
+          type: 'object',
+          required: ['modelName', 'temperature', 'prompt'],
+          properties: {
+            modelName: {
+              type: 'string',
+            },
+            apiKey: {
+              type: 'string',
+            },
+            apiHost: {
+              type: 'string',
+            },
+            temperature: {
+              type: 'number',
+            },
+            systemPrompt: {
+              type: 'string',
+              extra: {
+                formComponent: 'prompt-editor',
+              },
+            },
+            prompt: {
+              type: 'string',
+              extra: {
+                formComponent: 'prompt-editor',
+              },
+            },
+          },
+        },
+        outputs: {
+          type: 'object',
+          properties: {
+            result: {
+              type: 'string',
+            },
+          },
+        },
+      },
+    },
+    {
+      id: 'llm_2',
+      type: 'llm',
+      meta: {
+        position: {
+          x: 1100,
+          y: 459.3,
+        },
+      },
+      data: {
+        title: 'LLM_2',
+        inputsValues: {
+          modelName: {
+            type: 'constant',
+            content: 'AI_MODEL_2',
+          },
+          apiKey: {
+            type: 'constant',
+            content: 'sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
+          },
+          apiHost: {
+            type: 'constant',
+            content: 'https://mock-ai-url/api/v3',
+          },
+          temperature: {
+            type: 'constant',
+            content: 0.6,
+          },
+          systemPrompt: {
+            type: 'template',
+            content: "I'm Model 2",
+          },
+          prompt: {
+            type: 'template',
+            content: '{{start_0.prompt}}',
+          },
+        },
+        inputs: {
+          type: 'object',
+          required: ['modelName', 'temperature', 'prompt'],
+          properties: {
+            modelName: {
+              type: 'string',
+            },
+            apiKey: {
+              type: 'string',
+            },
+            apiHost: {
+              type: 'string',
+            },
+            temperature: {
+              type: 'number',
+            },
+            systemPrompt: {
+              type: 'string',
+              extra: {
+                formComponent: 'prompt-editor',
+              },
+            },
+            prompt: {
+              type: 'string',
+              extra: {
+                formComponent: 'prompt-editor',
+              },
+            },
+          },
+        },
+        outputs: {
+          type: 'object',
+          properties: {
+            result: {
+              type: 'string',
+            },
+          },
+        },
+      },
+    },
+    {
+      id: 'llm_3',
+      type: 'llm',
+      meta: {
+        position: {
+          x: 1560,
+          y: 0,
+        },
+      },
+      data: {
+        title: 'LLM_3',
+        inputsValues: {
+          modelName: {
+            type: 'constant',
+            content: 'AI_MODEL_3',
+          },
+          apiKey: {
+            type: 'constant',
+            content: 'sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
+          },
+          apiHost: {
+            type: 'constant',
+            content: 'https://mock-ai-url/api/v3',
+          },
+          temperature: {
+            type: 'constant',
+            content: 0.5,
+          },
+          systemPrompt: {
+            type: 'template',
+            content: "I'm Model 3",
+          },
+          prompt: {
+            type: 'template',
+            content: '{{llm_1.result}}',
+          },
+        },
+        inputs: {
+          type: 'object',
+          required: ['modelName', 'apiKey', 'apiHost', 'temperature', 'prompt'],
+          properties: {
+            modelName: {
+              type: 'string',
+            },
+            apiKey: {
+              type: 'string',
+            },
+            apiHost: {
+              type: 'string',
+            },
+            temperature: {
+              type: 'number',
+            },
+            systemPrompt: {
+              type: 'string',
+              extra: {
+                formComponent: 'prompt-editor',
+              },
+            },
+            prompt: {
+              type: 'string',
+              extra: {
+                formComponent: 'prompt-editor',
+              },
+            },
+          },
+        },
+        outputs: {
+          type: 'object',
+          properties: {
+            result: {
+              type: 'string',
+            },
+          },
+        },
+      },
+    },
+    {
+      id: 'llm_4',
+      type: 'llm',
+      meta: {
+        position: {
+          x: 1560,
+          y: 459.8,
+        },
+      },
+      data: {
+        title: 'LLM_4',
+        inputsValues: {
+          modelName: {
+            type: 'constant',
+            content: 'AI_MODEL_4',
+          },
+          apiKey: {
+            type: 'constant',
+            content: 'sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
+          },
+          apiHost: {
+            type: 'constant',
+            content: 'https://mock-ai-url/api/v3',
+          },
+          temperature: {
+            type: 'constant',
+            content: 0.5,
+          },
+          systemPrompt: {
+            type: 'template',
+            content: "I'm Model 4",
+          },
+          prompt: {
+            type: 'template',
+            content: '{{llm_2.result}}',
+          },
+        },
+        inputs: {
+          type: 'object',
+          required: ['modelName', 'apiKey', 'apiHost', 'temperature', 'prompt'],
+          properties: {
+            modelName: {
+              type: 'string',
+            },
+            apiKey: {
+              type: 'string',
+            },
+            apiHost: {
+              type: 'string',
+            },
+            temperature: {
+              type: 'number',
+            },
+            systemPrompt: {
+              type: 'string',
+              extra: {
+                formComponent: 'prompt-editor',
+              },
+            },
+            prompt: {
+              type: 'string',
+              extra: {
+                formComponent: 'prompt-editor',
+              },
+            },
+          },
+        },
+        outputs: {
+          type: 'object',
+          properties: {
+            result: {
+              type: 'string',
+            },
+          },
+        },
+      },
+    },
+  ],
+  edges: [
+    {
+      sourceNodeID: 'start_0',
+      targetNodeID: 'condition_0',
+    },
+    {
+      sourceNodeID: 'llm_3',
+      targetNodeID: 'end_0',
+    },
+    {
+      sourceNodeID: 'llm_4',
+      targetNodeID: 'end_0',
+    },
+    {
+      sourceNodeID: 'condition_0',
+      targetNodeID: 'llm_1',
+      sourcePortID: 'if_1',
+    },
+    {
+      sourceNodeID: 'condition_0',
+      targetNodeID: 'llm_2',
+      sourcePortID: 'if_2',
+    },
+    {
+      sourceNodeID: 'llm_1',
+      targetNodeID: 'llm_3',
+    },
+    {
+      sourceNodeID: 'llm_2',
+      targetNodeID: 'llm_4',
+    },
+  ],
+};

+ 25 - 0
packages/runtime/js-core/src/domain/__tests__/schemas/branch.test.ts

@@ -98,6 +98,7 @@ describe('WorkflowRuntime branch schema', () => {
     expect(report.reports.condition_0.status).toBe(WorkflowStatus.Succeeded);
     expect(report.reports.llm_1.status).toBe(WorkflowStatus.Succeeded);
     expect(report.reports.end_0.status).toBe(WorkflowStatus.Succeeded);
+    expect(report.reports.llm_2).toBeUndefined();
   });
 
   it('should execute a workflow with branch 2', async () => {
@@ -185,5 +186,29 @@ describe('WorkflowRuntime branch schema', () => {
     expect(report.reports.condition_0.status).toBe(WorkflowStatus.Succeeded);
     expect(report.reports.llm_2.status).toBe(WorkflowStatus.Succeeded);
     expect(report.reports.end_0.status).toBe(WorkflowStatus.Succeeded);
+    expect(report.reports.llm_1).toBeUndefined();
+  });
+
+  it('should execute a workflow with branch not exist', async () => {
+    const engine = container.get<IEngine>(IEngine);
+    const { context, processing } = engine.invoke({
+      schema: TestSchemas.branchSchema,
+      inputs: {
+        model_id: 3,
+        prompt: 'Not Exist',
+      },
+    });
+    expect(context.statusCenter.workflow.status).toBe(WorkflowStatus.Processing);
+    const result = await processing;
+    expect(context.statusCenter.workflow.status).toBe(WorkflowStatus.Succeeded);
+    expect(result).toStrictEqual({});
+
+    const report = context.reporter.export();
+    expect(report.workflowStatus.status).toBe(WorkflowStatus.Succeeded);
+    expect(report.reports.start_0.status).toBe(WorkflowStatus.Succeeded);
+    expect(report.reports.condition_0.status).toBe(WorkflowStatus.Failed);
+    expect(report.reports.llm_1).toBeUndefined();
+    expect(report.reports.llm_2).toBeUndefined();
+    expect(report.reports.end_0).toBeUndefined();
   });
 });

+ 10 - 10
packages/runtime/js-core/src/domain/__tests__/schemas/branch.ts

@@ -12,8 +12,8 @@ export const branchSchema: WorkflowSchema = {
       type: 'start',
       meta: {
         position: {
-          x: 0,
-          y: 0,
+          x: 180,
+          y: 368.3,
         },
       },
       data: {
@@ -50,8 +50,8 @@ export const branchSchema: WorkflowSchema = {
       type: 'end',
       meta: {
         position: {
-          x: 1500,
-          y: 0,
+          x: 1560,
+          y: 368.3,
         },
       },
       data: {
@@ -84,8 +84,8 @@ export const branchSchema: WorkflowSchema = {
       type: 'condition',
       meta: {
         position: {
-          x: 500,
-          y: 0,
+          x: 640,
+          y: 304.8,
         },
       },
       data: {
@@ -127,8 +127,8 @@ export const branchSchema: WorkflowSchema = {
       type: 'llm',
       meta: {
         position: {
-          x: 1000,
-          y: -500,
+          x: 1100,
+          y: 0,
         },
       },
       data: {
@@ -204,8 +204,8 @@ export const branchSchema: WorkflowSchema = {
       type: 'llm',
       meta: {
         position: {
-          x: 1000,
-          y: 500,
+          x: 1100,
+          y: 459.8,
         },
       },
       data: {

+ 2 - 0
packages/runtime/js-core/src/domain/__tests__/schemas/index.ts

@@ -5,6 +5,7 @@
 
 import { twoLLMSchema } from './two-llm';
 import { loopSchema } from './loop';
+import { branchTwoLayersSchema } from './branch-two-layers';
 import { branchSchema } from './branch';
 import { basicLLMSchema } from './basic-llm';
 import { basicSchema } from './basic';
@@ -15,4 +16,5 @@ export const TestSchemas = {
   branchSchema,
   basicLLMSchema,
   loopSchema,
+  branchTwoLayersSchema,
 };

+ 225 - 3
packages/runtime/js-core/src/domain/document/entity/node/index.test.ts

@@ -6,7 +6,6 @@
 import { beforeEach, describe, expect, it } from 'vitest';
 import {
   FlowGramNode,
-  PositionSchema,
   WorkflowPortType,
   CreateNodeParams,
   IEdge,
@@ -25,7 +24,7 @@ describe('WorkflowRuntimeNode', () => {
       id: 'test-node',
       type: FlowGramNode.Start,
       name: 'Test Node',
-      position: { x: 0, y: 0 } as PositionSchema,
+      position: { x: 0, y: 0 },
       variable: {},
       data: { testData: 'data' },
     };
@@ -47,7 +46,7 @@ describe('WorkflowRuntimeNode', () => {
         id: 'test-node',
         type: FlowGramNode.Start,
         name: 'Test Node',
-        position: { x: 0, y: 0 } as PositionSchema,
+        position: { x: 0, y: 0 },
       };
       const minimalNode = new WorkflowRuntimeNode(minimalParams);
       expect(minimalNode.declare).toEqual({});
@@ -154,4 +153,227 @@ describe('WorkflowRuntimeNode', () => {
       expect(node.isBranch).toBe(false);
     });
   });
+
+  describe('successors', () => {
+    it('should return empty array when node has no successors', () => {
+      expect(node.successors).toEqual([]);
+    });
+
+    it('should return direct successors', () => {
+      const successor1 = new WorkflowRuntimeNode({ ...mockParams, id: 'successor-1' });
+      const successor2 = new WorkflowRuntimeNode({ ...mockParams, id: 'successor-2' });
+
+      const edge1 = {
+        id: 'edge-1',
+        from: node,
+        to: successor1,
+        fromPort: {} as IPort,
+        toPort: {} as IPort,
+      };
+      const edge2 = {
+        id: 'edge-2',
+        from: node,
+        to: successor2,
+        fromPort: {} as IPort,
+        toPort: {} as IPort,
+      };
+
+      node.addOutputEdge(edge1);
+      node.addOutputEdge(edge2);
+
+      const { successors } = node;
+      expect(successors).toHaveLength(2);
+      expect(successors).toContain(successor1);
+      expect(successors).toContain(successor2);
+    });
+
+    it('should return all nested successors recursively', () => {
+      const successor1 = new WorkflowRuntimeNode({ ...mockParams, id: 'successor-1' });
+      const successor2 = new WorkflowRuntimeNode({ ...mockParams, id: 'successor-2' });
+      const successor3 = new WorkflowRuntimeNode({ ...mockParams, id: 'successor-3' });
+
+      // node -> successor1 -> successor2 -> successor3
+      const edge1 = {
+        id: 'edge-1',
+        from: node,
+        to: successor1,
+        fromPort: {} as IPort,
+        toPort: {} as IPort,
+      };
+      const edge2 = {
+        id: 'edge-2',
+        from: successor1,
+        to: successor2,
+        fromPort: {} as IPort,
+        toPort: {} as IPort,
+      };
+      const edge3 = {
+        id: 'edge-3',
+        from: successor2,
+        to: successor3,
+        fromPort: {} as IPort,
+        toPort: {} as IPort,
+      };
+
+      node.addOutputEdge(edge1);
+      successor1.addOutputEdge(edge2);
+      successor2.addOutputEdge(edge3);
+
+      const { successors } = node;
+      expect(successors).toHaveLength(3);
+      expect(successors).toContain(successor1);
+      expect(successors).toContain(successor2);
+      expect(successors).toContain(successor3);
+    });
+
+    it('should handle circular references without infinite loop', () => {
+      const successor1 = new WorkflowRuntimeNode({ ...mockParams, id: 'successor-1' });
+      const successor2 = new WorkflowRuntimeNode({ ...mockParams, id: 'successor-2' });
+
+      // Create a cycle: node -> successor1 -> successor2 -> node
+      const edge1 = {
+        id: 'edge-1',
+        from: node,
+        to: successor1,
+        fromPort: {} as IPort,
+        toPort: {} as IPort,
+      };
+      const edge2 = {
+        id: 'edge-2',
+        from: successor1,
+        to: successor2,
+        fromPort: {} as IPort,
+        toPort: {} as IPort,
+      };
+      const edge3 = {
+        id: 'edge-3',
+        from: successor2,
+        to: node,
+        fromPort: {} as IPort,
+        toPort: {} as IPort,
+      };
+
+      node.addOutputEdge(edge1);
+      successor1.addOutputEdge(edge2);
+      successor2.addOutputEdge(edge3);
+
+      const { successors } = node;
+      // In a circular reference, we should get all nodes in the cycle except the starting node
+      expect(successors).toHaveLength(3);
+      expect(successors).toContain(successor1);
+      expect(successors).toContain(successor2);
+      expect(successors).toContain(node); // node will be visited when traversing from successor2
+    });
+  });
+
+  describe('predecessors', () => {
+    it('should return empty array when node has no predecessors', () => {
+      expect(node.predecessors).toEqual([]);
+    });
+
+    it('should return direct predecessors', () => {
+      const predecessor1 = new WorkflowRuntimeNode({ ...mockParams, id: 'predecessor-1' });
+      const predecessor2 = new WorkflowRuntimeNode({ ...mockParams, id: 'predecessor-2' });
+
+      const edge1 = {
+        id: 'edge-1',
+        from: predecessor1,
+        to: node,
+        fromPort: {} as IPort,
+        toPort: {} as IPort,
+      };
+      const edge2 = {
+        id: 'edge-2',
+        from: predecessor2,
+        to: node,
+        fromPort: {} as IPort,
+        toPort: {} as IPort,
+      };
+
+      node.addInputEdge(edge1);
+      node.addInputEdge(edge2);
+
+      const { predecessors } = node;
+      expect(predecessors).toHaveLength(2);
+      expect(predecessors).toContain(predecessor1);
+      expect(predecessors).toContain(predecessor2);
+    });
+
+    it('should return all nested predecessors recursively', () => {
+      const predecessor1 = new WorkflowRuntimeNode({ ...mockParams, id: 'predecessor-1' });
+      const predecessor2 = new WorkflowRuntimeNode({ ...mockParams, id: 'predecessor-2' });
+      const predecessor3 = new WorkflowRuntimeNode({ ...mockParams, id: 'predecessor-3' });
+
+      // predecessor3 -> predecessor2 -> predecessor1 -> node
+      const edge1 = {
+        id: 'edge-1',
+        from: predecessor3,
+        to: predecessor2,
+        fromPort: {} as IPort,
+        toPort: {} as IPort,
+      };
+      const edge2 = {
+        id: 'edge-2',
+        from: predecessor2,
+        to: predecessor1,
+        fromPort: {} as IPort,
+        toPort: {} as IPort,
+      };
+      const edge3 = {
+        id: 'edge-3',
+        from: predecessor1,
+        to: node,
+        fromPort: {} as IPort,
+        toPort: {} as IPort,
+      };
+
+      predecessor2.addInputEdge(edge1);
+      predecessor1.addInputEdge(edge2);
+      node.addInputEdge(edge3);
+
+      const { predecessors } = node;
+      expect(predecessors).toHaveLength(3);
+      expect(predecessors).toContain(predecessor1);
+      expect(predecessors).toContain(predecessor2);
+      expect(predecessors).toContain(predecessor3);
+    });
+
+    it('should handle circular references without infinite loop', () => {
+      const predecessor1 = new WorkflowRuntimeNode({ ...mockParams, id: 'predecessor-1' });
+      const predecessor2 = new WorkflowRuntimeNode({ ...mockParams, id: 'predecessor-2' });
+
+      // Create a cycle: node -> predecessor1 -> predecessor2 -> node
+      const edge1 = {
+        id: 'edge-1',
+        from: node,
+        to: predecessor1,
+        fromPort: {} as IPort,
+        toPort: {} as IPort,
+      };
+      const edge2 = {
+        id: 'edge-2',
+        from: predecessor1,
+        to: predecessor2,
+        fromPort: {} as IPort,
+        toPort: {} as IPort,
+      };
+      const edge3 = {
+        id: 'edge-3',
+        from: predecessor2,
+        to: node,
+        fromPort: {} as IPort,
+        toPort: {} as IPort,
+      };
+
+      node.addOutputEdge(edge1);
+      predecessor1.addOutputEdge(edge2);
+      node.addInputEdge(edge3);
+
+      const { predecessors } = node;
+      expect(predecessors).toHaveLength(1);
+      expect(predecessors).toContain(predecessor2);
+      // node itself should not be included in predecessors
+      expect(predecessors).not.toContain(node);
+    });
+  });
 });

+ 10 - 0
packages/runtime/js-core/src/domain/document/entity/node/index.ts

@@ -14,6 +14,8 @@ import {
   WorkflowPortType,
 } from '@flowgram.ai/runtime-interface';
 
+import { traverseNodes } from '@infra/index';
+
 export class WorkflowRuntimeNode<T = any> implements INode {
   public readonly id: string;
 
@@ -112,6 +114,14 @@ export class WorkflowRuntimeNode<T = any> implements INode {
     return this._next;
   }
 
+  public get successors(): INode[] {
+    return traverseNodes(this, (node) => node.next);
+  }
+
+  public get predecessors(): INode[] {
+    return traverseNodes(this, (node) => node.prev);
+  }
+
   public get isBranch() {
     return this.ports.outputs.length > 1;
   }

+ 6 - 2
packages/runtime/js-core/src/domain/engine/index.ts

@@ -15,6 +15,7 @@ import {
   FlowGramNode,
 } from '@flowgram.ai/runtime-interface';
 
+import { compareNodeGroups } from '@infra/utils';
 import { WorkflowRuntimeTask } from '../task';
 import { WorkflowRuntimeContext } from '../context';
 import { WorkflowRuntimeContainer } from '../container';
@@ -111,8 +112,11 @@ export class WorkflowRuntimeEngine implements IEngine {
     const nextNodeIDs: Set<string> = new Set(targetPort.edges.map((edge) => edge.to.id));
     const nextNodes = allNextNodes.filter((nextNode) => nextNodeIDs.has(nextNode.id));
     const skipNodes = allNextNodes.filter((nextNode) => !nextNodeIDs.has(nextNode.id));
-    skipNodes.forEach((skipNode) => {
-      context.state.addExecutedNode(skipNode);
+    const nextGroups = nextNodes.map((nextNode) => [nextNode, ...nextNode.successors]);
+    const skipGroups = skipNodes.map((skipNode) => [skipNode, ...skipNode.successors]);
+    const { uniqueToB: skippedNodes } = compareNodeGroups(nextGroups, skipGroups);
+    skippedNodes.forEach((node) => {
+      context.state.addExecutedNode(node);
     });
     return nextNodes;
   }

+ 258 - 0
packages/runtime/js-core/src/infrastructure/utils/compare-node-groups.test.ts

@@ -0,0 +1,258 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { describe, expect, it } from 'vitest';
+import { INode } from '@flowgram.ai/runtime-interface';
+
+import { compareNodeGroups } from './compare-node-groups';
+
+// Helper function to create mock nodes
+function createMockNode(id: string): INode {
+  return {
+    id,
+    type: 'basic' as any,
+    name: `Node ${id}`,
+    position: { x: 0, y: 0 },
+    declare: {},
+    data: {},
+    ports: { inputs: [], outputs: [] },
+    edges: { inputs: [], outputs: [] },
+    parent: null,
+    children: [],
+    prev: [],
+    next: [],
+    successors: [],
+    predecessors: [],
+    isBranch: false,
+  };
+}
+
+describe('compareNodeGroups', () => {
+  it('should correctly identify common, unique to A, and unique to B nodes', () => {
+    // Create test nodes
+    const nodeA = createMockNode('A');
+    const nodeB = createMockNode('B');
+    const nodeC = createMockNode('C');
+    const nodeD = createMockNode('D');
+    const nodeE = createMockNode('E');
+    const nodeF = createMockNode('F');
+    const nodeG = createMockNode('G');
+    const nodeH = createMockNode('H');
+    const nodeI = createMockNode('I');
+    const nodeJ = createMockNode('J');
+
+    // Set up groups according to user example
+    const groupA = [
+      [nodeA, nodeD, nodeE, nodeF, nodeJ],
+      [nodeB, nodeC, nodeD, nodeE, nodeF, nodeJ],
+    ];
+    const groupB = [[nodeG, nodeH, nodeI, nodeD, nodeE, nodeF, nodeJ]];
+
+    const result = compareNodeGroups(groupA, groupB);
+
+    // Verify common nodes: D,E,F,J
+    expect(result.common).toHaveLength(4);
+    expect(result.common.map((n) => n.id)).toEqual(expect.arrayContaining(['D', 'E', 'F', 'J']));
+
+    // Verify nodes unique to group A: A,B,C
+    expect(result.uniqueToA).toHaveLength(3);
+    expect(result.uniqueToA.map((n) => n.id)).toEqual(expect.arrayContaining(['A', 'B', 'C']));
+
+    // Verify nodes unique to group B: G,H,I
+    expect(result.uniqueToB).toHaveLength(3);
+    expect(result.uniqueToB.map((n) => n.id)).toEqual(expect.arrayContaining(['G', 'H', 'I']));
+  });
+
+  it('should handle empty groups', () => {
+    const result = compareNodeGroups([], []);
+
+    expect(result.common).toHaveLength(0);
+    expect(result.uniqueToA).toHaveLength(0);
+    expect(result.uniqueToB).toHaveLength(0);
+  });
+
+  it('should handle one empty group', () => {
+    const nodeA = createMockNode('A');
+    const nodeB = createMockNode('B');
+
+    const groupA = [[nodeA, nodeB]];
+    const groupB: INode[][] = [];
+
+    const result = compareNodeGroups(groupA, groupB);
+
+    expect(result.common).toHaveLength(0);
+    expect(result.uniqueToA).toHaveLength(2);
+    expect(result.uniqueToA.map((n) => n.id)).toEqual(expect.arrayContaining(['A', 'B']));
+    expect(result.uniqueToB).toHaveLength(0);
+  });
+
+  it('should handle duplicate nodes within the same group', () => {
+    const nodeA = createMockNode('A');
+    const nodeB = createMockNode('B');
+
+    const groupA = [
+      [nodeA, nodeA, nodeB], // nodeA duplicated
+      [nodeA, nodeB], // nodeA and nodeB duplicated
+    ];
+    const groupB = [[nodeA]];
+
+    const result = compareNodeGroups(groupA, groupB);
+
+    // Should deduplicate, nodeA is common, nodeB is unique to A
+    expect(result.common).toHaveLength(1);
+    expect(result.common[0].id).toBe('A');
+    expect(result.uniqueToA).toHaveLength(1);
+    expect(result.uniqueToA[0].id).toBe('B');
+    expect(result.uniqueToB).toHaveLength(0);
+  });
+
+  it('should handle all nodes being common', () => {
+    const nodeA = createMockNode('A');
+    const nodeB = createMockNode('B');
+    const nodeC = createMockNode('C');
+
+    const groupA = [[nodeA, nodeB, nodeC]];
+    const groupB = [[nodeA, nodeB, nodeC]];
+
+    const result = compareNodeGroups(groupA, groupB);
+
+    expect(result.common).toHaveLength(3);
+    expect(result.common.map((n) => n.id)).toEqual(expect.arrayContaining(['A', 'B', 'C']));
+    expect(result.uniqueToA).toHaveLength(0);
+    expect(result.uniqueToB).toHaveLength(0);
+  });
+
+  it('should handle no common nodes', () => {
+    const nodeA = createMockNode('A');
+    const nodeB = createMockNode('B');
+    const nodeC = createMockNode('C');
+    const nodeD = createMockNode('D');
+
+    const groupA = [[nodeA, nodeB]];
+    const groupB = [[nodeC, nodeD]];
+
+    const result = compareNodeGroups(groupA, groupB);
+
+    expect(result.common).toHaveLength(0);
+    expect(result.uniqueToA).toHaveLength(2);
+    expect(result.uniqueToA.map((n) => n.id)).toEqual(expect.arrayContaining(['A', 'B']));
+    expect(result.uniqueToB).toHaveLength(2);
+    expect(result.uniqueToB.map((n) => n.id)).toEqual(expect.arrayContaining(['C', 'D']));
+  });
+
+  it('should handle single node in each group', () => {
+    const nodeA = createMockNode('A');
+    const nodeB = createMockNode('B');
+
+    const groupA = [[nodeA]];
+    const groupB = [[nodeB]];
+
+    const result = compareNodeGroups(groupA, groupB);
+
+    expect(result.common).toHaveLength(0);
+    expect(result.uniqueToA).toHaveLength(1);
+    expect(result.uniqueToA[0].id).toBe('A');
+    expect(result.uniqueToB).toHaveLength(1);
+    expect(result.uniqueToB[0].id).toBe('B');
+  });
+
+  it('should handle multiple sub-arrays with mixed scenarios', () => {
+    const nodeA = createMockNode('A');
+    const nodeB = createMockNode('B');
+    const nodeC = createMockNode('C');
+    const nodeD = createMockNode('D');
+    const nodeE = createMockNode('E');
+
+    const groupA = [
+      [nodeA, nodeB],
+      [nodeC, nodeD],
+    ];
+    const groupB = [[nodeB, nodeC], [nodeE]];
+
+    const result = compareNodeGroups(groupA, groupB);
+
+    expect(result.common).toHaveLength(2);
+    expect(result.common.map((n) => n.id)).toEqual(expect.arrayContaining(['B', 'C']));
+    expect(result.uniqueToA).toHaveLength(2);
+    expect(result.uniqueToA.map((n) => n.id)).toEqual(expect.arrayContaining(['A', 'D']));
+    expect(result.uniqueToB).toHaveLength(1);
+    expect(result.uniqueToB[0].id).toBe('E');
+  });
+
+  it('should preserve node object references in results', () => {
+    const nodeA = createMockNode('A');
+    const nodeB = createMockNode('B');
+
+    const groupA = [[nodeA]];
+    const groupB = [[nodeA, nodeB]];
+
+    const result = compareNodeGroups(groupA, groupB);
+
+    // Verify that original node object references are returned
+    expect(result.common[0]).toBe(nodeA);
+    expect(result.uniqueToB[0]).toBe(nodeB);
+  });
+
+  it('should handle large number of nodes efficiently', () => {
+    // Create large number of nodes to test performance
+    const nodes = Array.from({ length: 100 }, (_, i) => createMockNode(`node${i}`));
+
+    const groupA = [nodes.slice(0, 60)];
+    const groupB = [nodes.slice(40, 100)];
+
+    const result = compareNodeGroups(groupA, groupB);
+
+    // Verify result correctness
+    expect(result.common).toHaveLength(20); // nodes 40-59 (20 nodes)
+    expect(result.uniqueToA).toHaveLength(40); // nodes 0-39 (40 nodes)
+    expect(result.uniqueToB).toHaveLength(40); // nodes 60-99 (40 nodes)
+  });
+
+  it('should handle edge case with same node instance in both groups', () => {
+    const sharedNode = createMockNode('shared');
+    const nodeA = createMockNode('A');
+    const nodeB = createMockNode('B');
+
+    const groupA = [[sharedNode, nodeA]];
+    const groupB = [[sharedNode, nodeB]];
+
+    const result = compareNodeGroups(groupA, groupB);
+
+    expect(result.common).toHaveLength(1);
+    expect(result.common[0]).toBe(sharedNode);
+    expect(result.uniqueToA).toHaveLength(1);
+    expect(result.uniqueToA[0]).toBe(nodeA);
+    expect(result.uniqueToB).toHaveLength(1);
+    expect(result.uniqueToB[0]).toBe(nodeB);
+  });
+
+  it('should handle complex nested scenarios', () => {
+    const nodes = Array.from({ length: 10 }, (_, i) => createMockNode(String.fromCharCode(65 + i))); // A-J
+
+    const groupA = [
+      [nodes[0], nodes[1], nodes[2]], // A,B,C
+      [nodes[3], nodes[4]], // D,E
+      [nodes[5], nodes[6], nodes[7]], // F,G,H
+    ];
+    const groupB = [
+      [nodes[2], nodes[3], nodes[4]], // C,D,E
+      [nodes[7], nodes[8], nodes[9]], // H,I,J
+    ];
+
+    const result = compareNodeGroups(groupA, groupB);
+
+    // Common nodes: C,D,E,H
+    expect(result.common).toHaveLength(4);
+    expect(result.common.map((n) => n.id)).toEqual(expect.arrayContaining(['C', 'D', 'E', 'H']));
+
+    // Unique to group A: A,B,F,G
+    expect(result.uniqueToA).toHaveLength(4);
+    expect(result.uniqueToA.map((n) => n.id)).toEqual(expect.arrayContaining(['A', 'B', 'F', 'G']));
+
+    // Unique to group B: I,J
+    expect(result.uniqueToB).toHaveLength(2);
+    expect(result.uniqueToB.map((n) => n.id)).toEqual(expect.arrayContaining(['I', 'J']));
+  });
+});

+ 84 - 0
packages/runtime/js-core/src/infrastructure/utils/compare-node-groups.ts

@@ -0,0 +1,84 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { INode } from '@flowgram.ai/runtime-interface';
+
+/**
+ * Interface for node comparison results
+ */
+export interface NodeComparisonResult {
+  /** Nodes common to both groups A and B */
+  common: INode[];
+  /** Nodes unique to group A */
+  uniqueToA: INode[];
+  /** Nodes unique to group B */
+  uniqueToB: INode[];
+}
+
+/**
+ * Compare two groups of node arrays to find common nodes and nodes unique to each group
+ *
+ * @param groupA Array of nodes in group A
+ * @param groupB Array of nodes in group B
+ * @returns Node comparison result
+ *
+ * @example
+ * ```typescript
+ * const groupA = [
+ *   [node1, node4, node5, node6, node10],
+ *   [node2, node3, node4, node5, node6, node10]
+ * ];
+ * const groupB = [
+ *   [node7, node8, node9, node4, node5, node6, node10]
+ * ];
+ *
+ * const result = compareNodeGroups(groupA, groupB);
+ * -> result.common: [node4, node5, node6, node10]
+ * -> result.uniqueToA: [node1, node2, node3]
+ * -> result.uniqueToB: [node7, node8, node9]
+ * ```
+ */
+export function compareNodeGroups(groupA: INode[][], groupB: INode[][]): NodeComparisonResult {
+  // Flatten and deduplicate all nodes in group A
+  const flatA = groupA.flat();
+  const setA = new Map<string, INode>();
+  flatA.forEach((node) => {
+    setA.set(node.id, node);
+  });
+
+  // Flatten and deduplicate all nodes in group B
+  const flatB = groupB.flat();
+  const setB = new Map<string, INode>();
+  flatB.forEach((node) => {
+    setB.set(node.id, node);
+  });
+
+  // Find common nodes
+  const common: INode[] = [];
+  const uniqueToA: INode[] = [];
+  const uniqueToB: INode[] = [];
+
+  // Iterate through group A nodes to find common nodes and nodes unique to A
+  setA.forEach((node, id) => {
+    if (setB.has(id)) {
+      common.push(node);
+    } else {
+      uniqueToA.push(node);
+    }
+  });
+
+  // Iterate through group B nodes to find nodes unique to B
+  setB.forEach((node, id) => {
+    if (!setA.has(id)) {
+      uniqueToB.push(node);
+    }
+  });
+
+  return {
+    common,
+    uniqueToA,
+    uniqueToB,
+  };
+}

+ 2 - 0
packages/runtime/js-core/src/infrastructure/utils/index.ts

@@ -6,3 +6,5 @@
 export { delay } from './delay';
 export { uuid } from './uuid';
 export { WorkflowRuntimeType } from './runtime-type';
+export { traverseNodes } from './traverse-nodes';
+export { compareNodeGroups } from './compare-node-groups';

+ 474 - 0
packages/runtime/js-core/src/infrastructure/utils/runtime-type.test.ts

@@ -0,0 +1,474 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { describe, expect, it } from 'vitest';
+import { WorkflowVariableType } from '@flowgram.ai/runtime-interface';
+
+import { WorkflowRuntimeType } from './runtime-type';
+
+describe('WorkflowRuntimeType', () => {
+  describe('getWorkflowType', () => {
+    describe('null and undefined values', () => {
+      it('should return Null for null value', () => {
+        const result = WorkflowRuntimeType.getWorkflowType(null);
+        expect(result).toBe(WorkflowVariableType.Null);
+      });
+
+      it('should return Null for undefined value', () => {
+        const result = WorkflowRuntimeType.getWorkflowType(undefined);
+        expect(result).toBe(WorkflowVariableType.Null);
+      });
+
+      it('should return Null when no parameter is passed', () => {
+        const result = WorkflowRuntimeType.getWorkflowType();
+        expect(result).toBe(WorkflowVariableType.Null);
+      });
+    });
+
+    describe('string values', () => {
+      it('should return String for string value', () => {
+        const result = WorkflowRuntimeType.getWorkflowType('hello');
+        expect(result).toBe(WorkflowVariableType.String);
+      });
+
+      it('should return String for empty string', () => {
+        const result = WorkflowRuntimeType.getWorkflowType('');
+        expect(result).toBe(WorkflowVariableType.String);
+      });
+
+      it('should return String for string with spaces', () => {
+        const result = WorkflowRuntimeType.getWorkflowType('  hello world  ');
+        expect(result).toBe(WorkflowVariableType.String);
+      });
+    });
+
+    describe('boolean values', () => {
+      it('should return Boolean for true', () => {
+        const result = WorkflowRuntimeType.getWorkflowType(true);
+        expect(result).toBe(WorkflowVariableType.Boolean);
+      });
+
+      it('should return Boolean for false', () => {
+        const result = WorkflowRuntimeType.getWorkflowType(false);
+        expect(result).toBe(WorkflowVariableType.Boolean);
+      });
+    });
+
+    describe('number values', () => {
+      it('should return Integer for positive integer', () => {
+        const result = WorkflowRuntimeType.getWorkflowType(42);
+        expect(result).toBe(WorkflowVariableType.Integer);
+      });
+
+      it('should return Integer for negative integer', () => {
+        const result = WorkflowRuntimeType.getWorkflowType(-42);
+        expect(result).toBe(WorkflowVariableType.Integer);
+      });
+
+      it('should return Integer for zero', () => {
+        const result = WorkflowRuntimeType.getWorkflowType(0);
+        expect(result).toBe(WorkflowVariableType.Integer);
+      });
+
+      it('should return Number for positive float', () => {
+        const result = WorkflowRuntimeType.getWorkflowType(3.14);
+        expect(result).toBe(WorkflowVariableType.Number);
+      });
+
+      it('should return Number for negative float', () => {
+        const result = WorkflowRuntimeType.getWorkflowType(-3.14);
+        expect(result).toBe(WorkflowVariableType.Number);
+      });
+
+      it('should return Number for very small decimal', () => {
+        const result = WorkflowRuntimeType.getWorkflowType(0.001);
+        expect(result).toBe(WorkflowVariableType.Number);
+      });
+
+      it('should return Number for Infinity', () => {
+        const result = WorkflowRuntimeType.getWorkflowType(Infinity);
+        expect(result).toBe(WorkflowVariableType.Number);
+      });
+
+      it('should return Number for -Infinity', () => {
+        const result = WorkflowRuntimeType.getWorkflowType(-Infinity);
+        expect(result).toBe(WorkflowVariableType.Number);
+      });
+
+      it('should return Number for NaN', () => {
+        const result = WorkflowRuntimeType.getWorkflowType(NaN);
+        expect(result).toBe(WorkflowVariableType.Number);
+      });
+    });
+
+    describe('array values', () => {
+      it('should return Array for empty array', () => {
+        const result = WorkflowRuntimeType.getWorkflowType([]);
+        expect(result).toBe(WorkflowVariableType.Array);
+      });
+
+      it('should return Array for array with numbers', () => {
+        const result = WorkflowRuntimeType.getWorkflowType([1, 2, 3]);
+        expect(result).toBe(WorkflowVariableType.Array);
+      });
+
+      it('should return Array for array with strings', () => {
+        const result = WorkflowRuntimeType.getWorkflowType(['a', 'b', 'c']);
+        expect(result).toBe(WorkflowVariableType.Array);
+      });
+
+      it('should return Array for mixed type array', () => {
+        const result = WorkflowRuntimeType.getWorkflowType([1, 'hello', true, null]);
+        expect(result).toBe(WorkflowVariableType.Array);
+      });
+
+      it('should return Array for nested arrays', () => {
+        const result = WorkflowRuntimeType.getWorkflowType([
+          [1, 2],
+          [3, 4],
+        ]);
+        expect(result).toBe(WorkflowVariableType.Array);
+      });
+    });
+
+    describe('object values', () => {
+      it('should return Object for empty object', () => {
+        const result = WorkflowRuntimeType.getWorkflowType({});
+        expect(result).toBe(WorkflowVariableType.Object);
+      });
+
+      it('should return Object for simple object', () => {
+        const result = WorkflowRuntimeType.getWorkflowType({ name: 'John', age: 30 });
+        expect(result).toBe(WorkflowVariableType.Object);
+      });
+
+      it('should return Object for nested object', () => {
+        const result = WorkflowRuntimeType.getWorkflowType({
+          user: { name: 'John', profile: { age: 30 } },
+        });
+        expect(result).toBe(WorkflowVariableType.Object);
+      });
+
+      it('should return Object for Date object', () => {
+        const result = WorkflowRuntimeType.getWorkflowType(new Date());
+        expect(result).toBe(WorkflowVariableType.Object);
+      });
+
+      it('should return Object for RegExp object', () => {
+        const result = WorkflowRuntimeType.getWorkflowType(/test/);
+        expect(result).toBe(WorkflowVariableType.Object);
+      });
+    });
+
+    describe('unsupported types', () => {
+      it('should return null for function', () => {
+        const result = WorkflowRuntimeType.getWorkflowType(() => {});
+        expect(result).toBe(null);
+      });
+
+      it('should return null for symbol', () => {
+        const result = WorkflowRuntimeType.getWorkflowType(Symbol('test'));
+        expect(result).toBe(null);
+      });
+
+      it('should return null for bigint', () => {
+        const result = WorkflowRuntimeType.getWorkflowType(BigInt(123));
+        expect(result).toBe(null);
+      });
+    });
+  });
+
+  describe('isMatchWorkflowType', () => {
+    describe('matching types', () => {
+      it('should return true for matching string type', () => {
+        const result = WorkflowRuntimeType.isMatchWorkflowType(
+          'hello',
+          WorkflowVariableType.String
+        );
+        expect(result).toBe(true);
+      });
+
+      it('should return true for matching boolean type', () => {
+        const result = WorkflowRuntimeType.isMatchWorkflowType(true, WorkflowVariableType.Boolean);
+        expect(result).toBe(true);
+      });
+
+      it('should return true for matching integer type', () => {
+        const result = WorkflowRuntimeType.isMatchWorkflowType(42, WorkflowVariableType.Integer);
+        expect(result).toBe(true);
+      });
+
+      it('should return true for matching number type', () => {
+        const result = WorkflowRuntimeType.isMatchWorkflowType(3.14, WorkflowVariableType.Number);
+        expect(result).toBe(true);
+      });
+
+      it('should return true for matching array type', () => {
+        const result = WorkflowRuntimeType.isMatchWorkflowType(
+          [1, 2, 3],
+          WorkflowVariableType.Array
+        );
+        expect(result).toBe(true);
+      });
+
+      it('should return true for matching object type', () => {
+        const result = WorkflowRuntimeType.isMatchWorkflowType(
+          { name: 'John' },
+          WorkflowVariableType.Object
+        );
+        expect(result).toBe(true);
+      });
+
+      it('should return true for matching null type', () => {
+        const result = WorkflowRuntimeType.isMatchWorkflowType(null, WorkflowVariableType.Null);
+        expect(result).toBe(true);
+      });
+    });
+
+    describe('non-matching types', () => {
+      it('should return false for string vs number type', () => {
+        const result = WorkflowRuntimeType.isMatchWorkflowType(
+          'hello',
+          WorkflowVariableType.Number
+        );
+        expect(result).toBe(false);
+      });
+
+      it('should return false for number vs string type', () => {
+        const result = WorkflowRuntimeType.isMatchWorkflowType(42, WorkflowVariableType.String);
+        expect(result).toBe(false);
+      });
+
+      it('should return false for array vs object type', () => {
+        const result = WorkflowRuntimeType.isMatchWorkflowType(
+          [1, 2, 3],
+          WorkflowVariableType.Object
+        );
+        expect(result).toBe(false);
+      });
+
+      it('should return false for object vs array type', () => {
+        const result = WorkflowRuntimeType.isMatchWorkflowType(
+          { name: 'John' },
+          WorkflowVariableType.Array
+        );
+        expect(result).toBe(false);
+      });
+    });
+
+    describe('unsupported values', () => {
+      it('should return false for function', () => {
+        const result = WorkflowRuntimeType.isMatchWorkflowType(() => {},
+        WorkflowVariableType.Object);
+        expect(result).toBe(false);
+      });
+
+      it('should return false for symbol', () => {
+        const result = WorkflowRuntimeType.isMatchWorkflowType(
+          Symbol('test'),
+          WorkflowVariableType.String
+        );
+        expect(result).toBe(false);
+      });
+    });
+  });
+
+  describe('isTypeEqual', () => {
+    describe('exact type matches', () => {
+      it('should return true for same string types', () => {
+        const result = WorkflowRuntimeType.isTypeEqual(
+          WorkflowVariableType.String,
+          WorkflowVariableType.String
+        );
+        expect(result).toBe(true);
+      });
+
+      it('should return true for same boolean types', () => {
+        const result = WorkflowRuntimeType.isTypeEqual(
+          WorkflowVariableType.Boolean,
+          WorkflowVariableType.Boolean
+        );
+        expect(result).toBe(true);
+      });
+
+      it('should return true for same array types', () => {
+        const result = WorkflowRuntimeType.isTypeEqual(
+          WorkflowVariableType.Array,
+          WorkflowVariableType.Array
+        );
+        expect(result).toBe(true);
+      });
+
+      it('should return true for same object types', () => {
+        const result = WorkflowRuntimeType.isTypeEqual(
+          WorkflowVariableType.Object,
+          WorkflowVariableType.Object
+        );
+        expect(result).toBe(true);
+      });
+
+      it('should return true for same null types', () => {
+        const result = WorkflowRuntimeType.isTypeEqual(
+          WorkflowVariableType.Null,
+          WorkflowVariableType.Null
+        );
+        expect(result).toBe(true);
+      });
+    });
+
+    describe('number and integer equivalence', () => {
+      it('should return true for Number and Integer', () => {
+        const result = WorkflowRuntimeType.isTypeEqual(
+          WorkflowVariableType.Number,
+          WorkflowVariableType.Integer
+        );
+        expect(result).toBe(true);
+      });
+
+      it('should return true for Integer and Number', () => {
+        const result = WorkflowRuntimeType.isTypeEqual(
+          WorkflowVariableType.Integer,
+          WorkflowVariableType.Number
+        );
+        expect(result).toBe(true);
+      });
+
+      it('should return true for Number and Number', () => {
+        const result = WorkflowRuntimeType.isTypeEqual(
+          WorkflowVariableType.Number,
+          WorkflowVariableType.Number
+        );
+        expect(result).toBe(true);
+      });
+
+      it('should return true for Integer and Integer', () => {
+        const result = WorkflowRuntimeType.isTypeEqual(
+          WorkflowVariableType.Integer,
+          WorkflowVariableType.Integer
+        );
+        expect(result).toBe(true);
+      });
+    });
+
+    describe('different type mismatches', () => {
+      it('should return false for String and Boolean', () => {
+        const result = WorkflowRuntimeType.isTypeEqual(
+          WorkflowVariableType.String,
+          WorkflowVariableType.Boolean
+        );
+        expect(result).toBe(false);
+      });
+
+      it('should return false for Array and Object', () => {
+        const result = WorkflowRuntimeType.isTypeEqual(
+          WorkflowVariableType.Array,
+          WorkflowVariableType.Object
+        );
+        expect(result).toBe(false);
+      });
+
+      it('should return false for String and Number', () => {
+        const result = WorkflowRuntimeType.isTypeEqual(
+          WorkflowVariableType.String,
+          WorkflowVariableType.Number
+        );
+        expect(result).toBe(false);
+      });
+
+      it('should return false for Boolean and Null', () => {
+        const result = WorkflowRuntimeType.isTypeEqual(
+          WorkflowVariableType.Boolean,
+          WorkflowVariableType.Null
+        );
+        expect(result).toBe(false);
+      });
+    });
+  });
+
+  describe('getArrayItemsType', () => {
+    describe('uniform type arrays', () => {
+      it('should return String for all string types', () => {
+        const types = [
+          WorkflowVariableType.String,
+          WorkflowVariableType.String,
+          WorkflowVariableType.String,
+        ];
+        const result = WorkflowRuntimeType.getArrayItemsType(types);
+        expect(result).toBe(WorkflowVariableType.String);
+      });
+
+      it('should return Number for all number types', () => {
+        const types = [WorkflowVariableType.Number, WorkflowVariableType.Number];
+        const result = WorkflowRuntimeType.getArrayItemsType(types);
+        expect(result).toBe(WorkflowVariableType.Number);
+      });
+
+      it('should return Boolean for all boolean types', () => {
+        const types = [WorkflowVariableType.Boolean];
+        const result = WorkflowRuntimeType.getArrayItemsType(types);
+        expect(result).toBe(WorkflowVariableType.Boolean);
+      });
+
+      it('should return Object for all object types', () => {
+        const types = [
+          WorkflowVariableType.Object,
+          WorkflowVariableType.Object,
+          WorkflowVariableType.Object,
+          WorkflowVariableType.Object,
+        ];
+        const result = WorkflowRuntimeType.getArrayItemsType(types);
+        expect(result).toBe(WorkflowVariableType.Object);
+      });
+    });
+
+    describe('mixed type arrays', () => {
+      it('should throw error for String and Number mix', () => {
+        const types = [WorkflowVariableType.String, WorkflowVariableType.Number];
+        expect(() => WorkflowRuntimeType.getArrayItemsType(types)).toThrow(
+          'array items type must be same, expect string, but got number'
+        );
+      });
+
+      it('should throw error for Boolean and String mix', () => {
+        const types = [WorkflowVariableType.Boolean, WorkflowVariableType.String];
+        expect(() => WorkflowRuntimeType.getArrayItemsType(types)).toThrow(
+          'array items type must be same, expect boolean, but got string'
+        );
+      });
+
+      it('should throw error for Object and Array mix', () => {
+        const types = [WorkflowVariableType.Object, WorkflowVariableType.Array];
+        expect(() => WorkflowRuntimeType.getArrayItemsType(types)).toThrow(
+          'array items type must be same, expect object, but got array'
+        );
+      });
+
+      it('should throw error for multiple different types', () => {
+        const types = [
+          WorkflowVariableType.String,
+          WorkflowVariableType.Number,
+          WorkflowVariableType.Boolean,
+        ];
+        expect(() => WorkflowRuntimeType.getArrayItemsType(types)).toThrow(
+          'array items type must be same, expect string, but got number'
+        );
+      });
+    });
+
+    describe('edge cases', () => {
+      it('should handle single item array', () => {
+        const types = [WorkflowVariableType.Integer];
+        const result = WorkflowRuntimeType.getArrayItemsType(types);
+        expect(result).toBe(WorkflowVariableType.Integer);
+      });
+
+      it('should handle Null types', () => {
+        const types = [WorkflowVariableType.Null, WorkflowVariableType.Null];
+        const result = WorkflowRuntimeType.getArrayItemsType(types);
+        expect(result).toBe(WorkflowVariableType.Null);
+      });
+    });
+  });
+});

+ 263 - 0
packages/runtime/js-core/src/infrastructure/utils/traverse-nodes.test.ts

@@ -0,0 +1,263 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { beforeEach, describe, expect, it } from 'vitest';
+import { FlowGramNode, CreateNodeParams, INode, IPort } from '@flowgram.ai/runtime-interface';
+
+import { WorkflowRuntimeNode } from '@workflow/document/entity';
+import { traverseNodes } from './traverse-nodes';
+
+describe('traverseNodes', () => {
+  let node: WorkflowRuntimeNode;
+  let mockParams: CreateNodeParams;
+
+  beforeEach(() => {
+    mockParams = {
+      id: 'test-node',
+      type: FlowGramNode.Start,
+      name: 'Test Node',
+      position: { x: 0, y: 0 },
+      variable: {},
+      data: { testData: 'data' },
+    };
+    node = new WorkflowRuntimeNode(mockParams);
+  });
+
+  describe('basic functionality', () => {
+    it('should return empty array when no connected nodes', () => {
+      const result = traverseNodes(node, () => []);
+      expect(result).toEqual([]);
+    });
+
+    it('should return direct connected nodes', () => {
+      const connectedNode1 = new WorkflowRuntimeNode({ ...mockParams, id: 'connected-1' });
+      const connectedNode2 = new WorkflowRuntimeNode({ ...mockParams, id: 'connected-2' });
+
+      const result = traverseNodes(node, () => [connectedNode1, connectedNode2]);
+
+      expect(result).toHaveLength(2);
+      expect(result).toContain(connectedNode1);
+      expect(result).toContain(connectedNode2);
+    });
+
+    it('should traverse recursively through connected nodes', () => {
+      const node1 = new WorkflowRuntimeNode({ ...mockParams, id: 'node-1' });
+      const node2 = new WorkflowRuntimeNode({ ...mockParams, id: 'node-2' });
+      const node3 = new WorkflowRuntimeNode({ ...mockParams, id: 'node-3' });
+
+      // Setup edges: node -> node1 -> node2 -> node3
+      const edge1 = {
+        id: 'edge-1',
+        from: node,
+        to: node1,
+        fromPort: {} as IPort,
+        toPort: {} as IPort,
+      };
+      const edge2 = {
+        id: 'edge-2',
+        from: node1,
+        to: node2,
+        fromPort: {} as IPort,
+        toPort: {} as IPort,
+      };
+      const edge3 = {
+        id: 'edge-3',
+        from: node2,
+        to: node3,
+        fromPort: {} as IPort,
+        toPort: {} as IPort,
+      };
+
+      node.addOutputEdge(edge1);
+      node1.addOutputEdge(edge2);
+      node2.addOutputEdge(edge3);
+
+      const result = traverseNodes(node, (n) => n.next);
+
+      expect(result).toHaveLength(3);
+      expect(result).toContain(node1);
+      expect(result).toContain(node2);
+      expect(result).toContain(node3);
+    });
+  });
+
+  describe('circular reference handling', () => {
+    it('should handle circular references without infinite loop', () => {
+      const node1 = new WorkflowRuntimeNode({ ...mockParams, id: 'node-1' });
+      const node2 = new WorkflowRuntimeNode({ ...mockParams, id: 'node-2' });
+
+      // Create a cycle: node -> node1 -> node2 -> node
+      const edge1 = {
+        id: 'edge-1',
+        from: node,
+        to: node1,
+        fromPort: {} as IPort,
+        toPort: {} as IPort,
+      };
+      const edge2 = {
+        id: 'edge-2',
+        from: node1,
+        to: node2,
+        fromPort: {} as IPort,
+        toPort: {} as IPort,
+      };
+      const edge3 = {
+        id: 'edge-3',
+        from: node2,
+        to: node,
+        fromPort: {} as IPort,
+        toPort: {} as IPort,
+      };
+
+      node.addOutputEdge(edge1);
+      node1.addOutputEdge(edge2);
+      node2.addOutputEdge(edge3);
+
+      const result = traverseNodes(node, (n) => n.next);
+
+      // Should visit each node only once, avoiding infinite loop
+      expect(result).toHaveLength(3);
+      expect(result).toContain(node1);
+      expect(result).toContain(node2);
+      expect(result).toContain(node); // node will be visited when traversing from node2
+    });
+
+    it('should not revisit already visited nodes', () => {
+      const node1 = new WorkflowRuntimeNode({ ...mockParams, id: 'node-1' });
+      const node2 = new WorkflowRuntimeNode({ ...mockParams, id: 'node-2' });
+      const sharedNode = new WorkflowRuntimeNode({ ...mockParams, id: 'shared-node' });
+
+      // Create diamond pattern: node -> [node1, node2] -> sharedNode
+      const edge1 = {
+        id: 'edge-1',
+        from: node,
+        to: node1,
+        fromPort: {} as IPort,
+        toPort: {} as IPort,
+      };
+      const edge2 = {
+        id: 'edge-2',
+        from: node,
+        to: node2,
+        fromPort: {} as IPort,
+        toPort: {} as IPort,
+      };
+      const edge3 = {
+        id: 'edge-3',
+        from: node1,
+        to: sharedNode,
+        fromPort: {} as IPort,
+        toPort: {} as IPort,
+      };
+      const edge4 = {
+        id: 'edge-4',
+        from: node2,
+        to: sharedNode,
+        fromPort: {} as IPort,
+        toPort: {} as IPort,
+      };
+
+      node.addOutputEdge(edge1);
+      node.addOutputEdge(edge2);
+      node1.addOutputEdge(edge3);
+      node2.addOutputEdge(edge4);
+
+      const result = traverseNodes(node, (n) => n.next);
+
+      // sharedNode should only appear once in the result
+      expect(result).toHaveLength(3);
+      expect(result).toContain(node1);
+      expect(result).toContain(node2);
+      expect(result).toContain(sharedNode);
+
+      // Verify sharedNode appears only once
+      const sharedNodeCount = result.filter((n) => n.id === 'shared-node').length;
+      expect(sharedNodeCount).toBe(1);
+    });
+  });
+
+  describe('different connection types', () => {
+    it('should work with predecessor connections', () => {
+      const predecessor1 = new WorkflowRuntimeNode({ ...mockParams, id: 'pred-1' });
+      const predecessor2 = new WorkflowRuntimeNode({ ...mockParams, id: 'pred-2' });
+
+      const edge1 = {
+        id: 'edge-1',
+        from: predecessor1,
+        to: node,
+        fromPort: {} as IPort,
+        toPort: {} as IPort,
+      };
+      const edge2 = {
+        id: 'edge-2',
+        from: predecessor2,
+        to: node,
+        fromPort: {} as IPort,
+        toPort: {} as IPort,
+      };
+
+      node.addInputEdge(edge1);
+      node.addInputEdge(edge2);
+
+      const result = traverseNodes(node, (n) => n.prev);
+
+      expect(result).toHaveLength(2);
+      expect(result).toContain(predecessor1);
+      expect(result).toContain(predecessor2);
+    });
+
+    it('should work with custom connection function', () => {
+      const customNode1 = new WorkflowRuntimeNode({ ...mockParams, id: 'custom-1' });
+      const customNode2 = new WorkflowRuntimeNode({ ...mockParams, id: 'custom-2' });
+
+      // Custom function that returns specific nodes
+      const customConnector = (n: INode) => {
+        if (n.id === 'test-node') {
+          return [customNode1];
+        }
+        if (n.id === 'custom-1') {
+          return [customNode2];
+        }
+        return [];
+      };
+
+      const result = traverseNodes(node, customConnector);
+
+      expect(result).toHaveLength(2);
+      expect(result).toContain(customNode1);
+      expect(result).toContain(customNode2);
+    });
+  });
+
+  describe('edge cases', () => {
+    it('should handle empty connections gracefully', () => {
+      const result = traverseNodes(node, () => []);
+      expect(result).toEqual([]);
+    });
+
+    it('should handle null/undefined connections gracefully', () => {
+      const result = traverseNodes(node, () => []);
+      expect(result).toEqual([]);
+    });
+
+    it('should handle single node with self-reference', () => {
+      const edge = {
+        id: 'self-edge',
+        from: node,
+        to: node,
+        fromPort: {} as IPort,
+        toPort: {} as IPort,
+      };
+
+      node.addOutputEdge(edge);
+
+      const result = traverseNodes(node, (n) => n.next);
+
+      // Should include the node itself when it's connected to itself
+      expect(result).toHaveLength(1);
+      expect(result).toContain(node);
+    });
+  });
+});

+ 33 - 0
packages/runtime/js-core/src/infrastructure/utils/traverse-nodes.ts

@@ -0,0 +1,33 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { INode } from '@flowgram.ai/runtime-interface';
+
+/**
+ * Generic function to traverse a node graph
+ * @param startNode The starting node
+ * @param getConnectedNodes Function to get connected nodes
+ * @returns Array of all traversed nodes
+ */
+export function traverseNodes(
+  startNode: INode,
+  getConnectedNodes: (node: INode) => INode[]
+): INode[] {
+  const visited = new Set<string>();
+  const result: INode[] = [];
+
+  const traverse = (node: INode) => {
+    for (const connectedNode of getConnectedNodes(node)) {
+      if (!visited.has(connectedNode.id)) {
+        visited.add(connectedNode.id);
+        result.push(connectedNode);
+        traverse(connectedNode);
+      }
+    }
+  };
+
+  traverse(startNode);
+  return result;
+}

+ 1 - 3
packages/runtime/js-core/src/nodes/condition/index.ts

@@ -32,9 +32,7 @@ export class ConditionExecutor implements INodeExecutor {
       .filter((item) => this.checkCondition(item));
     const activatedCondition = parsedConditions.find((item) => this.handleCondition(item));
     if (!activatedCondition) {
-      return {
-        outputs: {},
-      };
+      throw new Error('no condition is activated');
     }
     return {
       outputs: {},