Переглянути джерело

feat(runtime): schema validation (#534)

* fix(demo): test run default value

* feat(runtime): llm node check api valid & existence

* feat(runtime): schema format validation

* feat(runtime): schema start/end validation

* feat(runtime): schema edge source target exist validation

* feat(runtime): schema cycle detection validation

* feat(runtime): schema validation integration
Louis Young 6 місяців тому
батько
коміт
f24d9c3e41

+ 8 - 0
apps/demo-free-layout/src/components/testrun/testrun-form/use-fields.ts

@@ -14,6 +14,14 @@ export const useFields = (params: {
 
   // Convert each meta item to a form field with value and onChange handler
   const fields: TestRunFormField[] = formMeta.map((meta) => {
+    // If there is no value in values but there is a default value, trigger onChange once
+    if (!(meta.name in values) && meta.defaultValue !== undefined) {
+      setValues({
+        ...values,
+        [meta.name]: meta.defaultValue,
+      });
+    }
+
     // Handle object type specially - serialize object to JSON string for display
     const getCurrentValue = (): unknown => {
       const rawValue = values[meta.name] ?? meta.defaultValue;

+ 473 - 0
packages/runtime/js-core/src/domain/validation/index.test.ts

@@ -0,0 +1,473 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { beforeEach, describe, expect, it } from 'vitest';
+import { WorkflowSchema, FlowGramNode } from '@flowgram.ai/runtime-interface';
+
+import { WorkflowRuntimeValidation } from './index';
+
+describe('WorkflowRuntimeValidation Integration', () => {
+  let validation: WorkflowRuntimeValidation;
+
+  beforeEach(() => {
+    validation = new WorkflowRuntimeValidation();
+  });
+
+  const createMockNode = (id: string, type: string = 'test') => ({
+    id,
+    type,
+    meta: { position: { x: 0, y: 0 } },
+    data: {},
+  });
+
+  const createMockEdge = (sourceNodeID: string, targetNodeID: string) => ({
+    sourceNodeID,
+    targetNodeID,
+  });
+
+  const createMockStartNode = (id: string) => ({
+    id,
+    type: FlowGramNode.Start,
+    meta: { position: { x: 0, y: 0 } },
+    data: {
+      outputs: {
+        type: 'object',
+        properties: {},
+      },
+    },
+  });
+
+  const createMockEndNode = (id: string) => ({
+    id,
+    type: FlowGramNode.End,
+    meta: { position: { x: 0, y: 0 } },
+    data: {},
+  });
+
+  it('should pass validation for valid acyclic workflow', () => {
+    const schema: WorkflowSchema = {
+      nodes: [
+        createMockStartNode('start'),
+        createMockNode('middle'),
+        { id: 'end', type: FlowGramNode.End, meta: { position: { x: 0, y: 0 } }, data: {} },
+      ],
+      edges: [createMockEdge('start', 'middle'), createMockEdge('middle', 'end')],
+    };
+
+    const result = validation.invoke({ schema, inputs: {} });
+    expect(result.valid).toBe(true);
+  });
+
+  it('should fail validation for workflow with cycles', () => {
+    const schema: WorkflowSchema = {
+      nodes: [
+        createMockStartNode('start'),
+        createMockNode('A'),
+        createMockNode('B'),
+        { id: 'end', type: FlowGramNode.End, meta: { position: { x: 0, y: 0 } }, data: {} },
+      ],
+      edges: [
+        createMockEdge('start', 'A'),
+        createMockEdge('A', 'B'),
+        createMockEdge('B', 'A'), // Creates a cycle
+        createMockEdge('B', 'end'),
+      ],
+    };
+
+    const result = validation.invoke({ schema, inputs: {} });
+    expect(result.valid).toBe(false);
+    expect(result.errors).toContain('Workflow schema contains a cycle, which is not allowed');
+  });
+
+  it('should fail validation for workflow with non-existent edge targets', () => {
+    const schema: WorkflowSchema = {
+      nodes: [
+        createMockStartNode('start'),
+        { id: 'end', type: FlowGramNode.End, meta: { position: { x: 0, y: 0 } }, data: {} },
+      ],
+      edges: [
+        createMockEdge('start', 'nonexistent'), // Non-existent target
+      ],
+    };
+
+    const result = validation.invoke({ schema, inputs: {} });
+    expect(result.valid).toBe(false);
+    expect(result.errors).toContain('Workflow schema edge target node "nonexistent" not exist');
+  });
+
+  it('should fail validation for workflow without start node', () => {
+    const schema: WorkflowSchema = {
+      nodes: [createMockNode('middle'), createMockEndNode('end')],
+      edges: [createMockEdge('middle', 'end')],
+    };
+
+    const result = validation.invoke({ schema, inputs: {} });
+    expect(result.valid).toBe(false);
+    expect(result.errors).toContain('Workflow schema must have a start node');
+  });
+
+  it('should fail validation for workflow without end node', () => {
+    const schema: WorkflowSchema = {
+      nodes: [createMockStartNode('start'), createMockNode('middle')],
+      edges: [createMockEdge('start', 'middle')],
+    };
+
+    const result = validation.invoke({ schema, inputs: {} });
+    expect(result.valid).toBe(false);
+    expect(result.errors).toContain('Workflow schema must have an end node');
+  });
+
+  it('should fail validation for workflow with multiple start nodes', () => {
+    const schema: WorkflowSchema = {
+      nodes: [
+        createMockStartNode('start1'),
+        createMockStartNode('start2'),
+        createMockEndNode('end'),
+      ],
+      edges: [createMockEdge('start1', 'end'), createMockEdge('start2', 'end')],
+    };
+
+    const result = validation.invoke({ schema, inputs: {} });
+    expect(result.valid).toBe(false);
+    expect(result.errors).toContain('Workflow schema must have only one start node');
+  });
+
+  it('should handle complex workflow with nested blocks and cycles', () => {
+    const schema: WorkflowSchema = {
+      nodes: [
+        createMockStartNode('start'),
+        {
+          id: 'container',
+          type: 'container',
+          meta: { position: { x: 0, y: 0 } },
+          data: {},
+          blocks: [createMockNode('block1'), createMockNode('block2')],
+          edges: [
+            createMockEdge('block1', 'block2'),
+            createMockEdge('block2', 'block1'), // Cycle in nested blocks
+          ],
+        },
+        createMockEndNode('end'),
+      ],
+      edges: [createMockEdge('start', 'container'), createMockEdge('container', 'end')],
+    };
+
+    const result = validation.invoke({ schema, inputs: {} });
+    expect(result.valid).toBe(false);
+    expect(result.errors).toContain('Workflow schema contains a cycle, which is not allowed');
+  });
+
+  // Schema format validation tests
+  describe('Schema Format Validation', () => {
+    it('should fail validation for invalid schema structure', () => {
+      const invalidSchema = null as any;
+      const result = validation.invoke({ schema: invalidSchema, inputs: {} });
+      expect(result.valid).toBe(false);
+      expect(result.errors).toContain('Workflow schema must be a valid object');
+    });
+
+    it('should fail validation for schema without nodes array', () => {
+      const invalidSchema = { edges: [] } as any;
+      const result = validation.invoke({ schema: invalidSchema, inputs: {} });
+      expect(result.valid).toBe(false);
+      expect(result.errors).toContain('Workflow schema must have a valid nodes array');
+    });
+
+    it('should fail validation for schema without edges array', () => {
+      const invalidSchema = { nodes: [] } as any;
+      const result = validation.invoke({ schema: invalidSchema, inputs: {} });
+      expect(result.valid).toBe(false);
+      expect(result.errors).toContain('Workflow schema must have a valid edges array');
+    });
+
+    it('should fail validation for node without required fields', () => {
+      const schema: WorkflowSchema = {
+        nodes: [
+          {
+            // Missing id field
+            type: 'test',
+            meta: { position: { x: 0, y: 0 } },
+            data: {},
+          } as any,
+        ],
+        edges: [],
+      };
+      const result = validation.invoke({ schema, inputs: {} });
+      expect(result.valid).toBe(false);
+      expect(result.errors).toContain('nodes[0].id must be a non-empty string');
+    });
+
+    it('should fail validation for edge without required fields', () => {
+      const schema: WorkflowSchema = {
+        nodes: [createMockStartNode('start'), createMockEndNode('end')],
+        edges: [
+          {
+            // Missing targetNodeID
+            sourceNodeID: 'start',
+          } as any,
+        ],
+      };
+      const result = validation.invoke({ schema, inputs: {} });
+      expect(result.valid).toBe(false);
+      expect(result.errors).toContain('edges[0].targetNodeID must be a non-empty string');
+    });
+  });
+
+  // Input validation tests
+  describe('Input Validation', () => {
+    const createSchemaWithInputs = () => ({
+      nodes: [
+        {
+          id: 'start',
+          type: FlowGramNode.Start,
+          meta: { position: { x: 0, y: 0 } },
+          data: {
+            outputs: {
+              type: 'object',
+              properties: {
+                name: { type: 'string' },
+                age: { type: 'number' },
+                active: { type: 'boolean' },
+              },
+              required: ['name', 'age'],
+            },
+          },
+        },
+        createMockEndNode('end'),
+      ],
+      edges: [createMockEdge('start', 'end')],
+    });
+
+    it('should pass validation with valid inputs', () => {
+      const schema = createSchemaWithInputs();
+      const inputs = {
+        name: 'John Doe',
+        age: 30,
+        active: true,
+      };
+      const result = validation.invoke({ schema, inputs });
+      expect(result.valid).toBe(true);
+    });
+
+    it('should fail validation with missing required inputs', () => {
+      const schema = createSchemaWithInputs();
+      const inputs = {
+        name: 'John Doe',
+        // Missing required 'age' field
+      };
+      const result = validation.invoke({ schema, inputs });
+      expect(result.valid).toBe(false);
+      expect(result.errors?.[0]).toContain('JSON Schema validation failed');
+    });
+
+    it('should fail validation with wrong input types', () => {
+      const schema = createSchemaWithInputs();
+      const inputs = {
+        name: 'John Doe',
+        age: 'thirty', // Should be number
+        active: true,
+      };
+      const result = validation.invoke({ schema, inputs });
+      expect(result.valid).toBe(false);
+      expect(result.errors?.[0]).toContain('JSON Schema validation failed');
+    });
+  });
+
+  // Edge cases and boundary conditions
+  describe('Edge Cases', () => {
+    it('should handle empty workflow', () => {
+      const schema: WorkflowSchema = {
+        nodes: [],
+        edges: [],
+      };
+      const result = validation.invoke({ schema, inputs: {} });
+      expect(result.valid).toBe(false);
+      expect(result.errors).toBeDefined();
+      expect(result.errors!.length).toBeGreaterThan(0);
+      // Empty workflow should trigger start/end node validation errors
+      const errorMessages = result.errors!.join(' ');
+      expect(errorMessages).toContain('start node');
+    });
+
+    it('should handle workflow with only start node', () => {
+      const schema: WorkflowSchema = {
+        nodes: [createMockStartNode('start')],
+        edges: [],
+      };
+      const result = validation.invoke({ schema, inputs: {} });
+      expect(result.valid).toBe(false);
+      expect(result.errors).toContain('Workflow schema must have an end node');
+    });
+
+    it('should handle workflow with disconnected nodes', () => {
+      const schema: WorkflowSchema = {
+        nodes: [createMockStartNode('start'), createMockNode('isolated'), createMockEndNode('end')],
+        edges: [createMockEdge('start', 'end')], // 'isolated' node is not connected
+      };
+      const result = validation.invoke({ schema, inputs: {} });
+      expect(result.valid).toBe(true); // This should pass as disconnected nodes are allowed
+    });
+
+    it('should handle workflow with self-referencing edge', () => {
+      const schema: WorkflowSchema = {
+        nodes: [createMockStartNode('start'), createMockNode('self'), createMockEndNode('end')],
+        edges: [
+          createMockEdge('start', 'self'),
+          createMockEdge('self', 'self'), // Self-referencing edge
+          createMockEdge('self', 'end'),
+        ],
+      };
+      const result = validation.invoke({ schema, inputs: {} });
+      expect(result.valid).toBe(false);
+      expect(result.errors).toContain('Workflow schema contains a cycle, which is not allowed');
+    });
+
+    it('should handle workflow with multiple end nodes', () => {
+      const schema: WorkflowSchema = {
+        nodes: [createMockStartNode('start'), createMockEndNode('end1'), createMockEndNode('end2')],
+        edges: [createMockEdge('start', 'end1'), createMockEdge('start', 'end2')],
+      };
+      const result = validation.invoke({ schema, inputs: {} });
+      expect(result.valid).toBe(false);
+      expect(result.errors).toContain('Workflow schema must have only one end node');
+    });
+  });
+
+  // Multiple error scenarios
+  describe('Multiple Error Scenarios', () => {
+    it('should collect multiple validation errors', () => {
+      const schema: WorkflowSchema = {
+        nodes: [
+          createMockStartNode('start1'),
+          createMockStartNode('start2'), // Multiple start nodes
+          createMockNode('A'),
+          createMockNode('B'),
+        ],
+        edges: [
+          createMockEdge('start1', 'A'),
+          createMockEdge('A', 'B'),
+          createMockEdge('B', 'A'), // Cycle
+          createMockEdge('A', 'nonexistent'), // Non-existent target
+        ],
+      };
+      const result = validation.invoke({ schema, inputs: {} });
+      expect(result.valid).toBe(false);
+      expect(result.errors).toBeDefined();
+      expect(result.errors!.length).toBeGreaterThan(1);
+      // Check that multiple errors are collected
+      expect(result.errors!.some((error) => error.includes('cycle'))).toBe(true);
+      expect(result.errors!.some((error) => error.includes('target node'))).toBe(true);
+    });
+
+    it('should handle schema format errors before other validations', () => {
+      const invalidSchema = {
+        nodes: 'invalid', // Should be array
+        edges: [],
+      } as any;
+      const result = validation.invoke({ schema: invalidSchema, inputs: {} });
+      expect(result.valid).toBe(false);
+      expect(result.errors).toContain('Workflow schema must have a valid nodes array');
+    });
+  });
+
+  // Complex nested scenarios
+  describe('Complex Nested Scenarios', () => {
+    it('should validate deeply nested blocks', () => {
+      const schema: WorkflowSchema = {
+        nodes: [
+          createMockStartNode('start'),
+          {
+            id: 'container1',
+            type: 'container',
+            meta: { position: { x: 0, y: 0 } },
+            data: {},
+            blocks: [
+              {
+                id: 'block-start',
+                type: FlowGramNode.BlockStart,
+                meta: { position: { x: 0, y: 0 } },
+                data: {},
+              },
+              {
+                id: 'container2',
+                type: 'container',
+                meta: { position: { x: 0, y: 0 } },
+                data: {},
+                blocks: [
+                  {
+                    id: 'nested-block-start',
+                    type: FlowGramNode.BlockStart,
+                    meta: { position: { x: 0, y: 0 } },
+                    data: {},
+                  },
+                  createMockNode('deep1'),
+                  createMockNode('deep2'),
+                  {
+                    id: 'nested-block-end',
+                    type: FlowGramNode.BlockEnd,
+                    meta: { position: { x: 0, y: 0 } },
+                    data: {},
+                  },
+                ],
+                edges: [
+                  createMockEdge('nested-block-start', 'deep1'),
+                  createMockEdge('deep1', 'deep2'),
+                  createMockEdge('deep2', 'nested-block-end'),
+                ],
+              },
+              {
+                id: 'block-end',
+                type: FlowGramNode.BlockEnd,
+                meta: { position: { x: 0, y: 0 } },
+                data: {},
+              },
+            ],
+            edges: [
+              createMockEdge('block-start', 'container2'),
+              createMockEdge('container2', 'block-end'),
+            ],
+          },
+          createMockEndNode('end'),
+        ],
+        edges: [createMockEdge('start', 'container1'), createMockEdge('container1', 'end')],
+      };
+      const result = validation.invoke({ schema, inputs: {} });
+      expect(result.valid).toBe(true);
+    });
+
+    it('should detect cycles in deeply nested blocks', () => {
+      const schema: WorkflowSchema = {
+        nodes: [
+          createMockStartNode('start'),
+          {
+            id: 'container1',
+            type: 'container',
+            meta: { position: { x: 0, y: 0 } },
+            data: {},
+            blocks: [
+              {
+                id: 'container2',
+                type: 'container',
+                meta: { position: { x: 0, y: 0 } },
+                data: {},
+                blocks: [createMockNode('deep1'), createMockNode('deep2')],
+                edges: [
+                  createMockEdge('deep1', 'deep2'),
+                  createMockEdge('deep2', 'deep1'), // Cycle in deep nested block
+                ],
+              },
+            ],
+            edges: [],
+          },
+          createMockEndNode('end'),
+        ],
+        edges: [createMockEdge('start', 'container1'), createMockEdge('container1', 'end')],
+      };
+      const result = validation.invoke({ schema, inputs: {} });
+      expect(result.valid).toBe(false);
+      expect(result.errors).toContain('Workflow schema contains a cycle, which is not allowed');
+    });
+  });
+});

+ 22 - 10
packages/runtime/js-core/src/domain/validation/index.ts

@@ -13,6 +13,7 @@ import {
 } from '@flowgram.ai/runtime-interface';
 
 import { JSONSchemaValidator } from '@infra/index';
+import { cycleDetection, edgeSourceTargetExist, startEndNode, schemaFormat } from './validators';
 
 export class WorkflowRuntimeValidation implements IValidation {
   public invoke(params: InvokeParams): ValidationResult {
@@ -31,17 +32,28 @@ export class WorkflowRuntimeValidation implements IValidation {
   }
 
   private schema(schema: WorkflowSchema): ValidationResult {
-    // TODO
-    // 检查成环
-    // 检查边的节点是否存在
-    // 检查跨层级连线
-    // 检查是否只有一个开始节点和一个结束节点
-    // 检查开始节点是否在根节点
-    // 检查结束节点是否在根节点
+    const errors: string[] = [];
+
+    // Run all validations concurrently and collect errors
+    const validations = [
+      () => schemaFormat(schema),
+      () => cycleDetection(schema),
+      () => edgeSourceTargetExist(schema),
+      () => startEndNode(schema),
+    ];
+
+    // Execute all validations and collect any errors
+    validations.forEach((validation) => {
+      try {
+        validation();
+      } catch (error) {
+        errors.push(error instanceof Error ? error.message : String(error));
+      }
+    });
 
-    // 注册节点检查器
     return {
-      valid: true,
+      valid: errors.length === 0,
+      errors: errors.length > 0 ? errors : undefined,
     };
   }
 
@@ -63,7 +75,7 @@ export class WorkflowRuntimeValidation implements IValidation {
   }
 
   private getWorkflowInputsDeclare(schema: WorkflowSchema): IJsonSchema {
-    const startNode = schema.nodes.find((node) => node.type === FlowGramNode.Start);
+    const startNode = schema.nodes.find((node) => node.type === FlowGramNode.Start)!;
     if (!startNode) {
       throw new Error('Workflow schema must have a start node');
     }

+ 146 - 0
packages/runtime/js-core/src/domain/validation/validators/cycle-detection.test.ts

@@ -0,0 +1,146 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { describe, expect, it } from 'vitest';
+import { WorkflowSchema } from '@flowgram.ai/runtime-interface';
+
+import { cycleDetection } from './cycle-detection';
+
+describe('cycleDetection', () => {
+  const createMockNode = (id: string, type: string = 'test') => ({
+    id,
+    type,
+    meta: { position: { x: 0, y: 0 } },
+    data: {},
+  });
+
+  const createMockEdge = (sourceNodeID: string, targetNodeID: string) => ({
+    sourceNodeID,
+    targetNodeID,
+  });
+
+  it('should not throw error for acyclic graph', () => {
+    const schema: WorkflowSchema = {
+      nodes: [createMockNode('A'), createMockNode('B'), createMockNode('C')],
+      edges: [createMockEdge('A', 'B'), createMockEdge('B', 'C')],
+    };
+
+    expect(() => cycleDetection(schema)).not.toThrow();
+  });
+
+  it('should throw error for simple cycle', () => {
+    const schema: WorkflowSchema = {
+      nodes: [createMockNode('A'), createMockNode('B'), createMockNode('C')],
+      edges: [
+        createMockEdge('A', 'B'),
+        createMockEdge('B', 'C'),
+        createMockEdge('C', 'A'), // Creates a cycle
+      ],
+    };
+
+    expect(() => cycleDetection(schema)).toThrow(
+      'Workflow schema contains a cycle, which is not allowed'
+    );
+  });
+
+  it('should throw error for self-loop', () => {
+    const schema: WorkflowSchema = {
+      nodes: [createMockNode('A'), createMockNode('B')],
+      edges: [
+        createMockEdge('A', 'B'),
+        createMockEdge('B', 'B'), // Self-loop
+      ],
+    };
+
+    expect(() => cycleDetection(schema)).toThrow(
+      'Workflow schema contains a cycle, which is not allowed'
+    );
+  });
+
+  it('should handle disconnected components without cycles', () => {
+    const schema: WorkflowSchema = {
+      nodes: [createMockNode('A'), createMockNode('B'), createMockNode('C'), createMockNode('D')],
+      edges: [createMockEdge('A', 'B'), createMockEdge('C', 'D')],
+    };
+
+    expect(() => cycleDetection(schema)).not.toThrow();
+  });
+
+  it('should detect cycle in disconnected components', () => {
+    const schema: WorkflowSchema = {
+      nodes: [createMockNode('A'), createMockNode('B'), createMockNode('C'), createMockNode('D')],
+      edges: [
+        createMockEdge('A', 'B'),
+        createMockEdge('C', 'D'),
+        createMockEdge('D', 'C'), // Creates a cycle in second component
+      ],
+    };
+
+    expect(() => cycleDetection(schema)).toThrow(
+      'Workflow schema contains a cycle, which is not allowed'
+    );
+  });
+
+  it('should handle empty schema', () => {
+    const schema: WorkflowSchema = {
+      nodes: [],
+      edges: [],
+    };
+
+    expect(() => cycleDetection(schema)).not.toThrow();
+  });
+
+  it('should handle schema with nodes but no edges', () => {
+    const schema: WorkflowSchema = {
+      nodes: [createMockNode('A'), createMockNode('B'), createMockNode('C')],
+      edges: [],
+    };
+
+    expect(() => cycleDetection(schema)).not.toThrow();
+  });
+
+  it('should detect cycles in nested blocks', () => {
+    const schema: WorkflowSchema = {
+      nodes: [
+        createMockNode('A'),
+        {
+          id: 'B',
+          type: 'container',
+          meta: { position: { x: 0, y: 0 } },
+          data: {},
+          blocks: [createMockNode('B1'), createMockNode('B2')],
+          edges: [
+            createMockEdge('B1', 'B2'),
+            createMockEdge('B2', 'B1'), // Creates a cycle in nested blocks
+          ],
+        },
+      ],
+      edges: [createMockEdge('A', 'B')],
+    };
+
+    expect(() => cycleDetection(schema)).toThrow(
+      'Workflow schema contains a cycle, which is not allowed'
+    );
+  });
+
+  it('should handle nested blocks without cycles', () => {
+    const schema: WorkflowSchema = {
+      nodes: [
+        createMockNode('A'),
+        {
+          id: 'B',
+          type: 'container',
+          meta: { position: { x: 0, y: 0 } },
+          data: {},
+          blocks: [createMockNode('B1'), createMockNode('B2'), createMockNode('B3')],
+          edges: [createMockEdge('B1', 'B2'), createMockEdge('B2', 'B3')],
+        },
+      ],
+      edges: [createMockEdge('A', 'B')],
+    };
+
+    expect(() => cycleDetection(schema)).not.toThrow();
+  });
+});

+ 80 - 0
packages/runtime/js-core/src/domain/validation/validators/cycle-detection.ts

@@ -0,0 +1,80 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { WorkflowSchema } from '@flowgram.ai/runtime-interface';
+
+export const cycleDetection = (schema: WorkflowSchema) => {
+  const { nodes, edges } = schema;
+
+  // Build adjacency list for the graph
+  const adjacencyList = new Map<string, string[]>();
+  const nodeIds = new Set(nodes.map((node) => node.id));
+
+  // Initialize adjacency list
+  nodeIds.forEach((nodeId) => {
+    adjacencyList.set(nodeId, []);
+  });
+
+  // Populate adjacency list with edges
+  edges.forEach((edge) => {
+    const sourceList = adjacencyList.get(edge.sourceNodeID);
+    if (sourceList) {
+      sourceList.push(edge.targetNodeID);
+    }
+  });
+
+  enum NodeStatus {
+    Unvisited,
+    Visiting,
+    Visited,
+  }
+
+  const nodeStatusMap = new Map<string, NodeStatus>();
+
+  // Initialize all nodes as WHITE
+  nodeIds.forEach((nodeId) => {
+    nodeStatusMap.set(nodeId, NodeStatus.Unvisited);
+  });
+
+  const detectCycleFromNode = (nodeId: string): boolean => {
+    nodeStatusMap.set(nodeId, NodeStatus.Visiting);
+
+    const neighbors = adjacencyList.get(nodeId) || [];
+    for (const neighbor of neighbors) {
+      const neighborColor = nodeStatusMap.get(neighbor);
+
+      if (neighborColor === NodeStatus.Visiting) {
+        // Back edge found - cycle detected
+        return true;
+      }
+
+      if (neighborColor === NodeStatus.Unvisited && detectCycleFromNode(neighbor)) {
+        return true;
+      }
+    }
+
+    nodeStatusMap.set(nodeId, NodeStatus.Visited);
+    return false;
+  };
+
+  // Check for cycles starting from each unvisited node
+  for (const nodeId of nodeIds) {
+    if (nodeStatusMap.get(nodeId) === NodeStatus.Unvisited) {
+      if (detectCycleFromNode(nodeId)) {
+        throw new Error('Workflow schema contains a cycle, which is not allowed');
+      }
+    }
+  }
+
+  // Recursively check cycles in nested blocks
+  nodes.forEach((node) => {
+    if (node.blocks) {
+      cycleDetection({
+        nodes: node.blocks,
+        edges: node.edges ?? [],
+      });
+    }
+  });
+};

+ 154 - 0
packages/runtime/js-core/src/domain/validation/validators/edge-source-target-exist.test.ts

@@ -0,0 +1,154 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { describe, expect, it } from 'vitest';
+import { WorkflowSchema } from '@flowgram.ai/runtime-interface';
+
+import { edgeSourceTargetExist } from './edge-source-target-exist';
+
+describe('edgeSourceTargetExist', () => {
+  const createMockNode = (id: string, type: string = 'test') => ({
+    id,
+    type,
+    meta: { position: { x: 0, y: 0 } },
+    data: {},
+  });
+
+  const createMockEdge = (sourceNodeID: string, targetNodeID: string) => ({
+    sourceNodeID,
+    targetNodeID,
+  });
+
+  it('should not throw error when all edge nodes exist', () => {
+    const schema: WorkflowSchema = {
+      nodes: [createMockNode('A'), createMockNode('B'), createMockNode('C')],
+      edges: [createMockEdge('A', 'B'), createMockEdge('B', 'C')],
+    };
+
+    expect(() => edgeSourceTargetExist(schema)).not.toThrow();
+  });
+
+  it('should not throw error for empty edges', () => {
+    const schema: WorkflowSchema = {
+      nodes: [createMockNode('A'), createMockNode('B')],
+      edges: [],
+    };
+
+    expect(() => edgeSourceTargetExist(schema)).not.toThrow();
+  });
+
+  it('should throw error when source node does not exist', () => {
+    const schema: WorkflowSchema = {
+      nodes: [createMockNode('A'), createMockNode('B')],
+      edges: [createMockEdge('C', 'A')], // 'C' does not exist
+    };
+
+    expect(() => edgeSourceTargetExist(schema)).toThrow(
+      'Workflow schema edge source node "C" not exist'
+    );
+  });
+
+  it('should throw error when target node does not exist', () => {
+    const schema: WorkflowSchema = {
+      nodes: [createMockNode('A'), createMockNode('B')],
+      edges: [createMockEdge('A', 'C')], // 'C' does not exist
+    };
+
+    expect(() => edgeSourceTargetExist(schema)).toThrow(
+      'Workflow schema edge target node "C" not exist'
+    );
+  });
+
+  it('should validate edges in nested blocks', () => {
+    const schema: WorkflowSchema = {
+      nodes: [
+        createMockNode('root'),
+        {
+          ...createMockNode('parent', 'container'),
+          blocks: [createMockNode('child1'), createMockNode('child2')],
+          edges: [createMockEdge('child1', 'child2')],
+        },
+      ],
+      edges: [createMockEdge('root', 'parent')],
+    };
+
+    expect(() => edgeSourceTargetExist(schema)).not.toThrow();
+  });
+
+  it('should throw error when nested edge source node does not exist', () => {
+    const schema: WorkflowSchema = {
+      nodes: [
+        createMockNode('root'),
+        {
+          ...createMockNode('parent', 'container'),
+          blocks: [createMockNode('child1'), createMockNode('child2')],
+          edges: [createMockEdge('nonexistent', 'child2')], // 'nonexistent' does not exist in blocks
+        },
+      ],
+      edges: [createMockEdge('root', 'parent')],
+    };
+
+    expect(() => edgeSourceTargetExist(schema)).toThrow(
+      'Workflow schema edge source node "nonexistent" not exist'
+    );
+  });
+
+  it('should throw error when nested edge target node does not exist', () => {
+    const schema: WorkflowSchema = {
+      nodes: [
+        createMockNode('root'),
+        {
+          ...createMockNode('parent', 'container'),
+          blocks: [createMockNode('child1'), createMockNode('child2')],
+          edges: [createMockEdge('child1', 'nonexistent')], // 'nonexistent' does not exist in blocks
+        },
+      ],
+      edges: [createMockEdge('root', 'parent')],
+    };
+
+    expect(() => edgeSourceTargetExist(schema)).toThrow(
+      'Workflow schema edge target node "nonexistent" not exist'
+    );
+  });
+
+  it('should handle deeply nested structures', () => {
+    const schema: WorkflowSchema = {
+      nodes: [
+        createMockNode('root'),
+        {
+          ...createMockNode('level1', 'container'),
+          blocks: [
+            createMockNode('child1'),
+            {
+              ...createMockNode('level2', 'container'),
+              blocks: [createMockNode('grandchild1'), createMockNode('grandchild2')],
+              edges: [createMockEdge('grandchild1', 'grandchild2')],
+            },
+          ],
+          edges: [createMockEdge('child1', 'level2')],
+        },
+      ],
+      edges: [createMockEdge('root', 'level1')],
+    };
+
+    expect(() => edgeSourceTargetExist(schema)).not.toThrow();
+  });
+
+  it('should handle nodes without blocks or edges', () => {
+    const schema: WorkflowSchema = {
+      nodes: [
+        createMockNode('A'),
+        createMockNode('B'),
+        {
+          ...createMockNode('C', 'container'),
+          // No blocks or edges defined
+        },
+      ],
+      edges: [createMockEdge('A', 'B'), createMockEdge('B', 'C')],
+    };
+
+    expect(() => edgeSourceTargetExist(schema)).not.toThrow();
+  });
+});

+ 27 - 0
packages/runtime/js-core/src/domain/validation/validators/edge-source-target-exist.ts

@@ -0,0 +1,27 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { WorkflowSchema } from '@flowgram.ai/runtime-interface';
+
+export const edgeSourceTargetExist = (schema: WorkflowSchema) => {
+  const { nodes, edges } = schema;
+  const nodeSet = new Set(nodes.map((node) => node.id));
+  edges.forEach((edge) => {
+    if (!nodeSet.has(edge.sourceNodeID)) {
+      throw new Error(`Workflow schema edge source node "${edge.sourceNodeID}" not exist`);
+    }
+    if (!nodeSet.has(edge.targetNodeID)) {
+      throw new Error(`Workflow schema edge target node "${edge.targetNodeID}" not exist`);
+    }
+  });
+  nodes.forEach((node) => {
+    if (node.blocks) {
+      edgeSourceTargetExist({
+        nodes: node.blocks,
+        edges: node.edges ?? [],
+      });
+    }
+  });
+};

+ 9 - 0
packages/runtime/js-core/src/domain/validation/validators/index.ts

@@ -0,0 +1,9 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+export { cycleDetection } from './cycle-detection';
+export { startEndNode } from './start-end-node';
+export { edgeSourceTargetExist } from './edge-source-target-exist';
+export { schemaFormat } from './schema-format';

+ 398 - 0
packages/runtime/js-core/src/domain/validation/validators/schema-format.test.ts

@@ -0,0 +1,398 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { describe, expect, it } from 'vitest';
+import { WorkflowSchema } from '@flowgram.ai/runtime-interface';
+
+import { schemaFormat } from './schema-format';
+
+describe('schemaFormat', () => {
+  const validSchema: WorkflowSchema = {
+    nodes: [
+      {
+        id: 'start_1',
+        type: 'start',
+        meta: {
+          position: { x: 0, y: 0 },
+        },
+        data: {
+          title: 'Start Node',
+        },
+      },
+      {
+        id: 'end_1',
+        type: 'end',
+        meta: {
+          position: { x: 100, y: 100 },
+        },
+        data: {
+          title: 'End Node',
+        },
+      },
+    ],
+    edges: [
+      {
+        sourceNodeID: 'start_1',
+        targetNodeID: 'end_1',
+      },
+    ],
+  };
+
+  describe('valid schemas', () => {
+    it('should pass validation for a valid basic schema', () => {
+      expect(() => schemaFormat(validSchema)).not.toThrow();
+    });
+
+    it('should pass validation for schema with optional fields', () => {
+      const schemaWithOptionals: WorkflowSchema = {
+        nodes: [
+          {
+            id: 'node_1',
+            type: 'custom',
+            meta: { position: { x: 0, y: 0 } },
+            data: {
+              title: 'Custom Node',
+              inputs: {
+                type: 'object',
+                properties: {
+                  input1: { type: 'string' },
+                },
+              },
+              outputs: {
+                type: 'object',
+                properties: {
+                  output1: { type: 'string' },
+                },
+              },
+              inputsValues: {
+                input1: { value: 'test', type: 'string' },
+              },
+            },
+          },
+        ],
+        edges: [],
+      };
+
+      expect(() => schemaFormat(schemaWithOptionals)).not.toThrow();
+    });
+
+    it('should pass validation for schema with nested blocks', () => {
+      const schemaWithBlocks: WorkflowSchema = {
+        nodes: [
+          {
+            id: 'parent_node',
+            type: 'container',
+            meta: { position: { x: 0, y: 0 } },
+            data: { title: 'Parent Node' },
+            blocks: [
+              {
+                id: 'child_node',
+                type: 'child',
+                meta: { position: { x: 10, y: 10 } },
+                data: { title: 'Child Node' },
+              },
+            ],
+            edges: [],
+          },
+        ],
+        edges: [],
+      };
+
+      expect(() => schemaFormat(schemaWithBlocks)).not.toThrow();
+    });
+
+    it('should pass validation for edges with optional port IDs', () => {
+      const schemaWithPorts: WorkflowSchema = {
+        nodes: validSchema.nodes,
+        edges: [
+          {
+            sourceNodeID: 'start_1',
+            targetNodeID: 'end_1',
+            sourcePortID: 'output_port',
+            targetPortID: 'input_port',
+          },
+        ],
+      };
+
+      expect(() => schemaFormat(schemaWithPorts)).not.toThrow();
+    });
+  });
+
+  describe('invalid schemas', () => {
+    it('should throw error for null schema', () => {
+      expect(() => schemaFormat(null as any)).toThrow('Workflow schema must be a valid object');
+    });
+
+    it('should throw error for undefined schema', () => {
+      expect(() => schemaFormat(undefined as any)).toThrow(
+        'Workflow schema must be a valid object'
+      );
+    });
+
+    it('should throw error for non-object schema', () => {
+      expect(() => schemaFormat('invalid' as any)).toThrow(
+        'Workflow schema must be a valid object'
+      );
+    });
+
+    it('should throw error for missing nodes array', () => {
+      const invalidSchema = { edges: [] } as any;
+      expect(() => schemaFormat(invalidSchema)).toThrow(
+        'Workflow schema must have a valid nodes array'
+      );
+    });
+
+    it('should throw error for non-array nodes', () => {
+      const invalidSchema = { nodes: 'invalid', edges: [] } as any;
+      expect(() => schemaFormat(invalidSchema)).toThrow(
+        'Workflow schema must have a valid nodes array'
+      );
+    });
+
+    it('should throw error for missing edges array', () => {
+      const invalidSchema = { nodes: [] } as any;
+      expect(() => schemaFormat(invalidSchema)).toThrow(
+        'Workflow schema must have a valid edges array'
+      );
+    });
+
+    it('should throw error for non-array edges', () => {
+      const invalidSchema = { nodes: [], edges: 'invalid' } as any;
+      expect(() => schemaFormat(invalidSchema)).toThrow(
+        'Workflow schema must have a valid edges array'
+      );
+    });
+  });
+
+  describe('invalid nodes', () => {
+    it('should throw error for node without id', () => {
+      const invalidSchema: WorkflowSchema = {
+        nodes: [
+          {
+            type: 'start',
+            meta: { position: { x: 0, y: 0 } },
+            data: {},
+          } as any,
+        ],
+        edges: [],
+      };
+
+      expect(() => schemaFormat(invalidSchema)).toThrow('nodes[0].id must be a non-empty string');
+    });
+
+    it('should throw error for node with empty id', () => {
+      const invalidSchema: WorkflowSchema = {
+        nodes: [
+          {
+            id: '',
+            type: 'start',
+            meta: { position: { x: 0, y: 0 } },
+            data: {},
+          },
+        ],
+        edges: [],
+      };
+
+      expect(() => schemaFormat(invalidSchema)).toThrow('nodes[0].id must be a non-empty string');
+    });
+
+    it('should throw error for node without type', () => {
+      const invalidSchema: WorkflowSchema = {
+        nodes: [
+          {
+            id: 'node_1',
+            meta: { position: { x: 0, y: 0 } },
+            data: {},
+          } as any,
+        ],
+        edges: [],
+      };
+
+      expect(() => schemaFormat(invalidSchema)).toThrow('nodes[0].type must be a non-empty string');
+    });
+
+    it('should throw error for node without meta', () => {
+      const invalidSchema: WorkflowSchema = {
+        nodes: [
+          {
+            id: 'node_1',
+            type: 'start',
+            data: {},
+          } as any,
+        ],
+        edges: [],
+      };
+
+      expect(() => schemaFormat(invalidSchema)).toThrow('nodes[0].meta must be a valid object');
+    });
+
+    it('should throw error for node without data', () => {
+      const invalidSchema: WorkflowSchema = {
+        nodes: [
+          {
+            id: 'node_1',
+            type: 'start',
+            meta: { position: { x: 0, y: 0 } },
+          } as any,
+        ],
+        edges: [],
+      };
+
+      expect(() => schemaFormat(invalidSchema)).toThrow('nodes[0].data must be a valid object');
+    });
+
+    it('should throw error for invalid blocks field', () => {
+      const invalidSchema: WorkflowSchema = {
+        nodes: [
+          {
+            id: 'node_1',
+            type: 'container',
+            meta: { position: { x: 0, y: 0 } },
+            data: {},
+            blocks: 'invalid' as any,
+          },
+        ],
+        edges: [],
+      };
+
+      expect(() => schemaFormat(invalidSchema)).toThrow(
+        'nodes[0].blocks must be an array if present'
+      );
+    });
+
+    it('should throw error for invalid data.inputs field', () => {
+      const invalidSchema: WorkflowSchema = {
+        nodes: [
+          {
+            id: 'node_1',
+            type: 'start',
+            meta: { position: { x: 0, y: 0 } },
+            data: {
+              inputs: 'invalid',
+            } as any,
+          },
+        ],
+        edges: [],
+      };
+
+      expect(() => schemaFormat(invalidSchema)).toThrow(
+        'nodes[0].data.inputs must be a valid object if present'
+      );
+    });
+  });
+
+  describe('invalid edges', () => {
+    it('should throw error for edge without sourceNodeID', () => {
+      const invalidSchema: WorkflowSchema = {
+        nodes: validSchema.nodes,
+        edges: [
+          {
+            targetNodeID: 'end_1',
+          } as any,
+        ],
+      };
+
+      expect(() => schemaFormat(invalidSchema)).toThrow(
+        'edges[0].sourceNodeID must be a non-empty string'
+      );
+    });
+
+    it('should throw error for edge with empty sourceNodeID', () => {
+      const invalidSchema: WorkflowSchema = {
+        nodes: validSchema.nodes,
+        edges: [
+          {
+            sourceNodeID: '',
+            targetNodeID: 'end_1',
+          },
+        ],
+      };
+
+      expect(() => schemaFormat(invalidSchema)).toThrow(
+        'edges[0].sourceNodeID must be a non-empty string'
+      );
+    });
+
+    it('should throw error for edge without targetNodeID', () => {
+      const invalidSchema: WorkflowSchema = {
+        nodes: validSchema.nodes,
+        edges: [
+          {
+            sourceNodeID: 'start_1',
+          } as any,
+        ],
+      };
+
+      expect(() => schemaFormat(invalidSchema)).toThrow(
+        'edges[0].targetNodeID must be a non-empty string'
+      );
+    });
+
+    it('should throw error for invalid sourcePortID type', () => {
+      const invalidSchema: WorkflowSchema = {
+        nodes: validSchema.nodes,
+        edges: [
+          {
+            sourceNodeID: 'start_1',
+            targetNodeID: 'end_1',
+            sourcePortID: 123 as any,
+          },
+        ],
+      };
+
+      expect(() => schemaFormat(invalidSchema)).toThrow(
+        'edges[0].sourcePortID must be a string if present'
+      );
+    });
+  });
+
+  describe('nested validation', () => {
+    it('should validate nested blocks recursively', () => {
+      const invalidNestedSchema: WorkflowSchema = {
+        nodes: [
+          {
+            id: 'parent_node',
+            type: 'container',
+            meta: { position: { x: 0, y: 0 } },
+            data: { title: 'Parent Node' },
+            blocks: [
+              {
+                id: '', // Invalid empty id in nested block
+                type: 'child',
+                meta: { position: { x: 10, y: 10 } },
+                data: { title: 'Child Node' },
+              },
+            ],
+            edges: [],
+          },
+        ],
+        edges: [],
+      };
+
+      expect(() => schemaFormat(invalidNestedSchema)).toThrow(
+        'nodes[0].id must be a non-empty string'
+      );
+    });
+
+    it('should throw error for invalid blocks structure', () => {
+      const invalidSchema: WorkflowSchema = {
+        nodes: [
+          {
+            id: 'parent_node',
+            type: 'container',
+            meta: { position: { x: 0, y: 0 } },
+            data: { title: 'Parent Node' },
+            blocks: 'not an array' as any,
+          },
+        ],
+        edges: [],
+      };
+
+      expect(() => schemaFormat(invalidSchema)).toThrow(
+        'nodes[0].blocks must be an array if present'
+      );
+    });
+  });
+});

+ 141 - 0
packages/runtime/js-core/src/domain/validation/validators/schema-format.ts

@@ -0,0 +1,141 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { WorkflowSchema } from '@flowgram.ai/runtime-interface';
+
+/**
+ * Validates the basic format and structure of a workflow schema
+ * Ensures all required fields are present and have correct types
+ */
+export const schemaFormat = (schema: WorkflowSchema): void => {
+  // Check if schema is a valid object
+  if (!schema || typeof schema !== 'object') {
+    throw new Error('Workflow schema must be a valid object');
+  }
+
+  // Check if nodes array exists and is valid
+  if (!Array.isArray(schema.nodes)) {
+    throw new Error('Workflow schema must have a valid nodes array');
+  }
+
+  // Check if edges array exists and is valid
+  if (!Array.isArray(schema.edges)) {
+    throw new Error('Workflow schema must have a valid edges array');
+  }
+
+  // Validate each node structure
+  schema.nodes.forEach((node, index) => {
+    validateNodeFormat(node, `nodes[${index}]`);
+  });
+
+  // Validate each edge structure
+  schema.edges.forEach((edge, index) => {
+    validateEdgeFormat(edge, `edges[${index}]`);
+  });
+
+  // Recursively validate nested blocks
+  schema.nodes.forEach((node, nodeIndex) => {
+    if (node.blocks) {
+      if (!Array.isArray(node.blocks)) {
+        throw new Error(`Node nodes[${nodeIndex}].blocks must be an array`);
+      }
+
+      const nestedSchema = {
+        nodes: node.blocks,
+        edges: node.edges || [],
+      };
+
+      schemaFormat(nestedSchema);
+    }
+  });
+};
+
+/**
+ * Validates the format of a single node
+ */
+const validateNodeFormat = (node: any, path: string): void => {
+  if (!node || typeof node !== 'object') {
+    throw new Error(`${path} must be a valid object`);
+  }
+
+  // Check required fields
+  if (typeof node.id !== 'string' || !node.id.trim()) {
+    throw new Error(`${path}.id must be a non-empty string`);
+  }
+
+  if (typeof node.type !== 'string' || !node.type.trim()) {
+    throw new Error(`${path}.type must be a non-empty string`);
+  }
+
+  if (!node.meta || typeof node.meta !== 'object') {
+    throw new Error(`${path}.meta must be a valid object`);
+  }
+
+  if (!node.data || typeof node.data !== 'object') {
+    throw new Error(`${path}.data must be a valid object`);
+  }
+
+  // Validate optional fields if present
+  if (node.blocks !== undefined && !Array.isArray(node.blocks)) {
+    throw new Error(`${path}.blocks must be an array if present`);
+  }
+
+  if (node.edges !== undefined && !Array.isArray(node.edges)) {
+    throw new Error(`${path}.edges must be an array if present`);
+  }
+
+  // Validate data.inputs and data.outputs if present
+  if (
+    node.data.inputs !== undefined &&
+    (typeof node.data.inputs !== 'object' || node.data.inputs === null)
+  ) {
+    throw new Error(`${path}.data.inputs must be a valid object if present`);
+  }
+
+  if (
+    node.data.outputs !== undefined &&
+    (typeof node.data.outputs !== 'object' || node.data.outputs === null)
+  ) {
+    throw new Error(`${path}.data.outputs must be a valid object if present`);
+  }
+
+  if (
+    node.data.inputsValues !== undefined &&
+    (typeof node.data.inputsValues !== 'object' || node.data.inputsValues === null)
+  ) {
+    throw new Error(`${path}.data.inputsValues must be a valid object if present`);
+  }
+
+  if (node.data.title !== undefined && typeof node.data.title !== 'string') {
+    throw new Error(`${path}.data.title must be a string if present`);
+  }
+};
+
+/**
+ * Validates the format of a single edge
+ */
+const validateEdgeFormat = (edge: any, path: string): void => {
+  if (!edge || typeof edge !== 'object') {
+    throw new Error(`${path} must be a valid object`);
+  }
+
+  // Check required fields
+  if (typeof edge.sourceNodeID !== 'string' || !edge.sourceNodeID.trim()) {
+    throw new Error(`${path}.sourceNodeID must be a non-empty string`);
+  }
+
+  if (typeof edge.targetNodeID !== 'string' || !edge.targetNodeID.trim()) {
+    throw new Error(`${path}.targetNodeID must be a non-empty string`);
+  }
+
+  // Validate optional fields if present
+  if (edge.sourcePortID !== undefined && typeof edge.sourcePortID !== 'string') {
+    throw new Error(`${path}.sourcePortID must be a string if present`);
+  }
+
+  if (edge.targetPortID !== undefined && typeof edge.targetPortID !== 'string') {
+    throw new Error(`${path}.targetPortID must be a string if present`);
+  }
+};

+ 376 - 0
packages/runtime/js-core/src/domain/validation/validators/start-end-node.test.ts

@@ -0,0 +1,376 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { describe, expect, it } from 'vitest';
+import { WorkflowSchema, FlowGramNode } from '@flowgram.ai/runtime-interface';
+
+import { startEndNode } from './start-end-node';
+
+describe('startEndNode', () => {
+  const createMockNode = (id: string, type: string) => ({
+    id,
+    type,
+    meta: { position: { x: 0, y: 0 } },
+    data: {},
+  });
+
+  describe('valid scenarios', () => {
+    it('should not throw error when schema has exactly one start and one end node', () => {
+      const schema: WorkflowSchema = {
+        nodes: [
+          createMockNode('start1', FlowGramNode.Start),
+          createMockNode('middle1', 'custom'),
+          createMockNode('end1', FlowGramNode.End),
+        ],
+        edges: [],
+      };
+
+      expect(() => startEndNode(schema)).not.toThrow();
+    });
+
+    it('should not throw error when schema has start, end nodes and nested blocks with block-start/block-end', () => {
+      const schema: WorkflowSchema = {
+        nodes: [
+          createMockNode('start1', FlowGramNode.Start),
+          {
+            id: 'loop1',
+            type: FlowGramNode.Loop,
+            meta: { position: { x: 0, y: 0 } },
+            data: {},
+            blocks: [
+              createMockNode('block-start1', FlowGramNode.BlockStart),
+              createMockNode('custom1', 'custom'),
+              createMockNode('block-end1', FlowGramNode.BlockEnd),
+            ],
+            edges: [],
+          },
+          createMockNode('end1', FlowGramNode.End),
+        ],
+        edges: [],
+      };
+
+      expect(() => startEndNode(schema)).not.toThrow();
+    });
+  });
+
+  describe('missing start and end nodes', () => {
+    it('should throw error when schema has no start and no end nodes', () => {
+      const schema: WorkflowSchema = {
+        nodes: [createMockNode('middle1', 'custom')],
+        edges: [],
+      };
+
+      expect(() => startEndNode(schema)).toThrow(
+        'Workflow schema must have a start node and an end node'
+      );
+    });
+  });
+
+  describe('missing start node', () => {
+    it('should throw error when schema has no start node', () => {
+      const schema: WorkflowSchema = {
+        nodes: [createMockNode('middle1', 'custom'), createMockNode('end1', FlowGramNode.End)],
+        edges: [],
+      };
+
+      expect(() => startEndNode(schema)).toThrow('Workflow schema must have a start node');
+    });
+  });
+
+  describe('missing end node', () => {
+    it('should throw error when schema has no end node', () => {
+      const schema: WorkflowSchema = {
+        nodes: [createMockNode('start1', FlowGramNode.Start), createMockNode('middle1', 'custom')],
+        edges: [],
+      };
+
+      expect(() => startEndNode(schema)).toThrow('Workflow schema must have an end node');
+    });
+  });
+
+  describe('multiple start nodes', () => {
+    it('should throw error when schema has multiple start nodes', () => {
+      const schema: WorkflowSchema = {
+        nodes: [
+          createMockNode('start1', FlowGramNode.Start),
+          createMockNode('start2', FlowGramNode.Start),
+          createMockNode('end1', FlowGramNode.End),
+        ],
+        edges: [],
+      };
+
+      expect(() => startEndNode(schema)).toThrow('Workflow schema must have only one start node');
+    });
+  });
+
+  describe('multiple end nodes', () => {
+    it('should throw error when schema has multiple end nodes', () => {
+      const schema: WorkflowSchema = {
+        nodes: [
+          createMockNode('start1', FlowGramNode.Start),
+          createMockNode('end1', FlowGramNode.End),
+          createMockNode('end2', FlowGramNode.End),
+        ],
+        edges: [],
+      };
+
+      expect(() => startEndNode(schema)).toThrow('Workflow schema must have only one end node');
+    });
+  });
+
+  describe('nested block validation', () => {
+    it('should throw error when nested block has no block-start node', () => {
+      const schema: WorkflowSchema = {
+        nodes: [
+          createMockNode('start1', FlowGramNode.Start),
+          {
+            id: 'loop1',
+            type: FlowGramNode.Loop,
+            meta: { position: { x: 0, y: 0 } },
+            data: {},
+            blocks: [
+              createMockNode('custom1', 'custom'),
+              createMockNode('block-end1', FlowGramNode.BlockEnd),
+            ],
+            edges: [],
+          },
+          createMockNode('end1', FlowGramNode.End),
+        ],
+        edges: [],
+      };
+
+      expect(() => startEndNode(schema)).toThrow(
+        'Workflow block schema must have a block-start node'
+      );
+    });
+
+    it('should throw error when nested block has no block-end node', () => {
+      const schema: WorkflowSchema = {
+        nodes: [
+          createMockNode('start1', FlowGramNode.Start),
+          {
+            id: 'loop1',
+            type: FlowGramNode.Loop,
+            meta: { position: { x: 0, y: 0 } },
+            data: {},
+            blocks: [
+              createMockNode('block-start1', FlowGramNode.BlockStart),
+              createMockNode('custom1', 'custom'),
+            ],
+            edges: [],
+          },
+          createMockNode('end1', FlowGramNode.End),
+        ],
+        edges: [],
+      };
+
+      expect(() => startEndNode(schema)).toThrow(
+        'Workflow block schema must have an block-end node'
+      );
+    });
+
+    it('should throw error when nested block has multiple block-start nodes', () => {
+      const schema: WorkflowSchema = {
+        nodes: [
+          createMockNode('start1', FlowGramNode.Start),
+          {
+            id: 'loop1',
+            type: FlowGramNode.Loop,
+            meta: { position: { x: 0, y: 0 } },
+            data: {},
+            blocks: [
+              createMockNode('block-start1', FlowGramNode.BlockStart),
+              createMockNode('block-start2', FlowGramNode.BlockStart),
+              createMockNode('block-end1', FlowGramNode.BlockEnd),
+            ],
+            edges: [],
+          },
+          createMockNode('end1', FlowGramNode.End),
+        ],
+        edges: [],
+      };
+
+      expect(() => startEndNode(schema)).toThrow(
+        'Workflow block schema must have only one block-start node'
+      );
+    });
+
+    it('should throw error when nested block has multiple block-end nodes', () => {
+      const schema: WorkflowSchema = {
+        nodes: [
+          createMockNode('start1', FlowGramNode.Start),
+          {
+            id: 'loop1',
+            type: FlowGramNode.Loop,
+            meta: { position: { x: 0, y: 0 } },
+            data: {},
+            blocks: [
+              createMockNode('block-start1', FlowGramNode.BlockStart),
+              createMockNode('block-end1', FlowGramNode.BlockEnd),
+              createMockNode('block-end2', FlowGramNode.BlockEnd),
+            ],
+            edges: [],
+          },
+          createMockNode('end1', FlowGramNode.End),
+        ],
+        edges: [],
+      };
+
+      expect(() => startEndNode(schema)).toThrow(
+        'Workflow block schema must have only one block-end node'
+      );
+    });
+
+    it('should throw error when nested block has no block-start and no block-end nodes', () => {
+      const schema: WorkflowSchema = {
+        nodes: [
+          createMockNode('start1', FlowGramNode.Start),
+          {
+            id: 'loop1',
+            type: FlowGramNode.Loop,
+            meta: { position: { x: 0, y: 0 } },
+            data: {},
+            blocks: [createMockNode('custom1', 'custom')],
+            edges: [],
+          },
+          createMockNode('end1', FlowGramNode.End),
+        ],
+        edges: [],
+      };
+
+      expect(() => startEndNode(schema)).toThrow(
+        'Workflow block schema must have a block-start node and a block-end node'
+      );
+    });
+  });
+
+  describe('deeply nested blocks', () => {
+    it('should validate deeply nested blocks recursively', () => {
+      const schema: WorkflowSchema = {
+        nodes: [
+          createMockNode('start1', FlowGramNode.Start),
+          {
+            id: 'loop1',
+            type: FlowGramNode.Loop,
+            meta: { position: { x: 0, y: 0 } },
+            data: {},
+            blocks: [
+              createMockNode('block-start1', FlowGramNode.BlockStart),
+              {
+                id: 'nested-loop',
+                type: FlowGramNode.Loop,
+                meta: { position: { x: 0, y: 0 } },
+                data: {},
+                blocks: [
+                  createMockNode('nested-block-start', FlowGramNode.BlockStart),
+                  createMockNode('nested-custom', 'custom'),
+                  createMockNode('nested-block-end', FlowGramNode.BlockEnd),
+                ],
+                edges: [],
+              },
+              createMockNode('block-end1', FlowGramNode.BlockEnd),
+            ],
+            edges: [],
+          },
+          createMockNode('end1', FlowGramNode.End),
+        ],
+        edges: [],
+      };
+
+      expect(() => startEndNode(schema)).not.toThrow();
+    });
+
+    it('should throw error for invalid deeply nested blocks', () => {
+      const schema: WorkflowSchema = {
+        nodes: [
+          createMockNode('start1', FlowGramNode.Start),
+          {
+            id: 'loop1',
+            type: FlowGramNode.Loop,
+            meta: { position: { x: 0, y: 0 } },
+            data: {},
+            blocks: [
+              createMockNode('block-start1', FlowGramNode.BlockStart),
+              {
+                id: 'nested-loop',
+                type: FlowGramNode.Loop,
+                meta: { position: { x: 0, y: 0 } },
+                data: {},
+                blocks: [
+                  // Missing nested block-start node
+                  createMockNode('nested-custom', 'custom'),
+                  createMockNode('nested-block-end', FlowGramNode.BlockEnd),
+                ],
+                edges: [],
+              },
+              createMockNode('block-end1', FlowGramNode.BlockEnd),
+            ],
+            edges: [],
+          },
+          createMockNode('end1', FlowGramNode.End),
+        ],
+        edges: [],
+      };
+
+      expect(() => startEndNode(schema)).toThrow(
+        'Workflow block schema must have a block-start node'
+      );
+    });
+  });
+
+  describe('edge cases', () => {
+    it('should handle empty nodes array', () => {
+      const schema: WorkflowSchema = {
+        nodes: [],
+        edges: [],
+      };
+
+      expect(() => startEndNode(schema)).toThrow(
+        'Workflow schema must have a start node and an end node'
+      );
+    });
+
+    it('should handle nodes without blocks property', () => {
+      const schema: WorkflowSchema = {
+        nodes: [
+          createMockNode('start1', FlowGramNode.Start),
+          {
+            id: 'custom1',
+            type: 'custom',
+            meta: { position: { x: 0, y: 0 } },
+            data: {},
+            // No blocks property
+          },
+          createMockNode('end1', FlowGramNode.End),
+        ],
+        edges: [],
+      };
+
+      expect(() => startEndNode(schema)).not.toThrow();
+    });
+
+    it('should handle nodes with empty blocks array', () => {
+      const schema: WorkflowSchema = {
+        nodes: [
+          createMockNode('start1', FlowGramNode.Start),
+          {
+            id: 'loop1',
+            type: FlowGramNode.Loop,
+            meta: { position: { x: 0, y: 0 } },
+            data: {},
+            blocks: [], // Empty blocks
+            edges: [],
+          },
+          createMockNode('end1', FlowGramNode.End),
+        ],
+        edges: [],
+      };
+
+      expect(() => startEndNode(schema)).toThrow(
+        'Workflow block schema must have a block-start node and a block-end node'
+      );
+    });
+  });
+});

+ 82 - 0
packages/runtime/js-core/src/domain/validation/validators/start-end-node.ts

@@ -0,0 +1,82 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { WorkflowSchema, FlowGramNode } from '@flowgram.ai/runtime-interface';
+
+const blockStartEndNode = (schema: WorkflowSchema) => {
+  // Optimize performance by using single traversal instead of two separate filter operations
+  const { blockStartNodes, blockEndNodes } = schema.nodes.reduce(
+    (acc, node) => {
+      if (node.type === FlowGramNode.BlockStart) {
+        acc.blockStartNodes.push(node);
+      } else if (node.type === FlowGramNode.BlockEnd) {
+        acc.blockEndNodes.push(node);
+      }
+      return acc;
+    },
+    { blockStartNodes: [] as typeof schema.nodes, blockEndNodes: [] as typeof schema.nodes }
+  );
+  if (!blockStartNodes.length && !blockEndNodes.length) {
+    throw new Error('Workflow block schema must have a block-start node and a block-end node');
+  }
+  if (!blockStartNodes.length) {
+    throw new Error('Workflow block schema must have a block-start node');
+  }
+  if (!blockEndNodes.length) {
+    throw new Error('Workflow block schema must have an block-end node');
+  }
+  if (blockStartNodes.length > 1) {
+    throw new Error('Workflow block schema must have only one block-start node');
+  }
+  if (blockEndNodes.length > 1) {
+    throw new Error('Workflow block schema must have only one block-end node');
+  }
+  schema.nodes.forEach((node) => {
+    if (node.blocks) {
+      blockStartEndNode({
+        nodes: node.blocks,
+        edges: node.edges ?? [],
+      });
+    }
+  });
+};
+
+export const startEndNode = (schema: WorkflowSchema) => {
+  // Optimize performance by using single traversal instead of two separate filter operations
+  const { startNodes, endNodes } = schema.nodes.reduce(
+    (acc, node) => {
+      if (node.type === FlowGramNode.Start) {
+        acc.startNodes.push(node);
+      } else if (node.type === FlowGramNode.End) {
+        acc.endNodes.push(node);
+      }
+      return acc;
+    },
+    { startNodes: [] as typeof schema.nodes, endNodes: [] as typeof schema.nodes }
+  );
+  if (!startNodes.length && !endNodes.length) {
+    throw new Error('Workflow schema must have a start node and an end node');
+  }
+  if (!startNodes.length) {
+    throw new Error('Workflow schema must have a start node');
+  }
+  if (!endNodes.length) {
+    throw new Error('Workflow schema must have an end node');
+  }
+  if (startNodes.length > 1) {
+    throw new Error('Workflow schema must have only one start node');
+  }
+  if (endNodes.length > 1) {
+    throw new Error('Workflow schema must have only one end node');
+  }
+  schema.nodes.forEach((node) => {
+    if (node.blocks) {
+      blockStartEndNode({
+        nodes: node.blocks,
+        edges: node.edges ?? [],
+      });
+    }
+  });
+};

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

@@ -62,11 +62,11 @@ export class ConditionExecutor implements INodeExecutor {
   private checkCondition(condition: ConditionValue): boolean {
     const rule = conditionRules[condition.leftType];
     if (isNil(rule)) {
-      throw new Error(`condition left type ${condition.leftType} is not supported`);
+      throw new Error(`left type "${condition.leftType}" is not supported`);
     }
     const ruleType = rule[condition.operator];
     if (isNil(ruleType)) {
-      throw new Error(`condition operator ${condition.operator} is not supported`);
+      throw new Error(`left type "${condition.leftType}" has no operator "${condition.operator}"`);
     }
     if (ruleType !== condition.rightType) {
       // throw new Error(`condition right type expected ${ruleType}, got ${condition.rightType}`);

+ 79 - 0
packages/runtime/js-core/src/nodes/llm/api-validator.ts

@@ -0,0 +1,79 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+export namespace APIValidator {
+  /**
+   * Simple validation for API host format
+   * Just check if it's a valid URL with http/https protocol
+   */
+  export const isValidFormat = (apiHost: string): boolean => {
+    if (!apiHost || typeof apiHost !== 'string') {
+      return false;
+    }
+
+    try {
+      const url = new URL(apiHost);
+      return url.protocol === 'http:' || url.protocol === 'https:';
+    } catch (error) {
+      return false;
+    }
+  };
+
+  /**
+   * Check if the API host is reachable by sending a simple request
+   * Any response (including 404, 500, etc.) indicates the host exists
+   * Only network-level failures indicate the host doesn't exist
+   */
+  export const isExist = async (apiHost: string): Promise<boolean> => {
+    try {
+      // Use AbortController to set a reasonable timeout
+      const controller = new AbortController();
+      const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout
+
+      await fetch(apiHost, {
+        method: 'HEAD', // Use HEAD to minimize data transfer
+        signal: controller.signal,
+        // Disable following redirects to get the actual host response
+        redirect: 'manual',
+      });
+
+      clearTimeout(timeoutId);
+
+      // Any HTTP response (including errors like 404, 500) means the host exists
+      return true;
+    } catch (error: any) {
+      // Check if it's a timeout/abort error
+      if (error.name === 'AbortError') {
+        return false;
+      }
+
+      // For fetch errors, we need to distinguish between network failures and HTTP errors
+      // Network failures (DNS resolution failed, connection refused) mean host doesn't exist
+      // HTTP errors (404, 500, etc.) mean host exists but returned an error
+
+      // Unfortunately, fetch doesn't provide detailed error types
+      // But we can check if the error is related to network connectivity
+      const errorMessage = error.message?.toLowerCase() || '';
+
+      // These patterns typically indicate network-level failures
+      const networkFailurePatterns = [
+        'network error',
+        'connection refused',
+        'dns',
+        'resolve',
+        'timeout',
+        'unreachable',
+      ];
+
+      const isNetworkFailure = networkFailurePatterns.some((pattern) =>
+        errorMessage.includes(pattern)
+      );
+
+      // If it's a network failure, host doesn't exist
+      // Otherwise, assume host exists but returned an error
+      return !isNetworkFailure;
+    }
+  };
+}

+ 14 - 2
packages/runtime/js-core/src/nodes/llm/index.ts

@@ -13,6 +13,8 @@ import {
   INodeExecutor,
 } from '@flowgram.ai/runtime-interface';
 
+import { APIValidator } from './api-validator';
+
 export interface LLMExecutorInputs {
   modelName: string;
   apiKey: string;
@@ -27,7 +29,7 @@ export class LLMExecutor implements INodeExecutor {
 
   public async execute(context: ExecutionContext): Promise<ExecutionResult> {
     const inputs = context.inputs as LLMExecutorInputs;
-    this.checkInputs(inputs);
+    await this.checkInputs(inputs);
 
     const { modelName, temperature, apiKey, apiHost, systemPrompt, prompt } = inputs;
 
@@ -57,7 +59,7 @@ export class LLMExecutor implements INodeExecutor {
     };
   }
 
-  protected checkInputs(inputs: LLMExecutorInputs) {
+  protected async checkInputs(inputs: LLMExecutorInputs) {
     const { modelName, temperature, apiKey, apiHost, prompt } = inputs;
     const missingInputs = [];
 
@@ -70,5 +72,15 @@ export class LLMExecutor implements INodeExecutor {
     if (missingInputs.length > 0) {
       throw new Error(`LLM node missing required inputs: ${missingInputs.join(', ')}`);
     }
+
+    // Validate apiHost format before checking existence
+    if (!APIValidator.isValidFormat(apiHost)) {
+      throw new Error(`Invalid API host format - ${apiHost}`);
+    }
+
+    const apiHostExists = await APIValidator.isExist(apiHost);
+    if (!apiHostExists) {
+      throw new Error(`Unreachable API host - ${apiHost}`);
+    }
   }
 }

+ 8 - 4
packages/runtime/js-core/src/nodes/loop/index.ts

@@ -61,10 +61,14 @@ export class LoopExecutor implements INodeExecutor {
         type: WorkflowVariableType.Number,
         value: index,
       });
-      await engine.executeNode({
-        context: subContext,
-        node: blockStartNode,
-      });
+      try {
+        await engine.executeNode({
+          context: subContext,
+          node: blockStartNode,
+        });
+      } catch (e) {
+        throw new Error(`loop block execute error`);
+      }
       const blockOutput = this.getBlockOutput(context, subContext);
       blockOutputs.push(blockOutput);
     }