فهرست منبع

feat(runtime): code node (#556)

Louis Young 6 ماه پیش
والد
کامیت
6b8576911f

+ 22 - 0
packages/runtime/interface/src/node/code/index.ts

@@ -0,0 +1,22 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { IFlowValue } from '@schema/value';
+import { WorkflowNodeSchema } from '@schema/node';
+import { IJsonSchema } from '@schema/json-schema';
+import { FlowGramNode } from '@node/constant';
+
+interface CodeNodeData {
+  title: string;
+  inputsValues: Record<string, IFlowValue>;
+  inputs: IJsonSchema<'object'>;
+  outputs: IJsonSchema<'object'>;
+  script: {
+    language: 'javascript';
+    content: string;
+  };
+}
+
+export type CodeNodeSchema = WorkflowNodeSchema<FlowGramNode.Code, CodeNodeData>;

+ 1 - 1
packages/runtime/interface/src/node/constant.ts

@@ -8,7 +8,7 @@ export enum FlowGramNode {
   Start = 'start',
   End = 'end',
   LLM = 'llm',
-  code = 'code',
+  Code = 'code',
   Condition = 'condition',
   Loop = 'loop',
   Comment = 'comment',

+ 1 - 0
packages/runtime/interface/src/node/index.ts

@@ -10,3 +10,4 @@ export { StartNodeSchema } from './start';
 export { LoopNodeSchema } from './loop';
 export { ConditionNodeSchema, ConditionOperation, ConditionItem } from './condition';
 export { HTTPNodeSchema, HTTPMethod, HTTPBodyType } from './http';
+export { CodeNodeSchema } from './code';

+ 143 - 0
packages/runtime/js-core/src/domain/__tests__/schemas/code.test.ts

@@ -0,0 +1,143 @@
+/**
+ * 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 { snapshotsToVOData } from '../utils';
+import { WorkflowRuntimeContainer } from '../../container';
+import { TestSchemas } from '.';
+
+const container: IContainer = WorkflowRuntimeContainer.instance;
+
+describe('WorkflowRuntime code schema', () => {
+  it('should execute a workflow with code node', async () => {
+    const engine = container.get<IEngine>(IEngine);
+    const { context, processing } = engine.invoke({
+      schema: TestSchemas.codeSchema,
+      inputs: {
+        input: 'hello~',
+      },
+    });
+
+    expect(context.statusCenter.workflow.status).toBe(WorkflowStatus.Processing);
+    const result = await processing;
+    expect(context.statusCenter.workflow.status).toBe(WorkflowStatus.Succeeded);
+
+    // Verify the result structure based on code schema output
+    expect(result).toStrictEqual({
+      input: 'hello~',
+      output_key0: 'hello~hello~', // Concatenated input
+      output_key1: ['hello', 'world'], // Array output
+      output_key2: {
+        // Object output
+        key21: 'hi',
+      },
+    });
+
+    // Verify snapshots
+    const snapshots = snapshotsToVOData(context.snapshotCenter.exportAll());
+    expect(snapshots).toStrictEqual([
+      {
+        nodeID: 'start_0',
+        inputs: {},
+        outputs: {
+          input: 'hello~',
+        },
+        data: {},
+      },
+      {
+        nodeID: 'code_0',
+        inputs: {
+          input: 'hello~',
+        },
+        outputs: {
+          key0: 'hello~hello~',
+          key1: ['hello', 'world'],
+          key2: {
+            key21: 'hi',
+          },
+        },
+        data: {
+          script: {
+            language: 'javascript',
+            content:
+              '// Here, you can use \'params\' to access the input variables in the node and use \'output\' to output the result\n// \'params\'  have already been properly injected into the environment\n// Below is an example of retrieving the value of the parameter \'input\' from the node\'s input:\n// const input = params.input; \n// Below is an example of outputting a \'ret\' object containing multiple data types:\n// const output = { "name": \'Jack\', "hobbies": ["reading", "traveling"] };\nasync function main({ params }) {\n    // Construct the output object\n    const output = {\n        "key0": params.input + params.input, // Concatenate the value of the two input parameters\n        "key1": ["hello", "world"], // Output an array\n        "key2": { // Output an Object\n            "key21": "hi"\n        },\n    };\n    return output;\n}',
+          },
+        },
+      },
+      {
+        nodeID: 'end_0',
+        inputs: {
+          input: 'hello~',
+          output_key0: 'hello~hello~',
+          output_key1: ['hello', 'world'],
+          output_key2: {
+            key21: 'hi',
+          },
+        },
+        outputs: {
+          input: 'hello~',
+          output_key0: 'hello~hello~',
+          output_key1: ['hello', 'world'],
+          output_key2: {
+            key21: 'hi',
+          },
+        },
+        data: {},
+      },
+    ]);
+  });
+
+  it('should handle different input types in code node', async () => {
+    const engine = container.get<IEngine>(IEngine);
+    const { context, processing } = engine.invoke({
+      schema: TestSchemas.codeSchema,
+      inputs: {
+        input: 'test123',
+      },
+    });
+
+    expect(context.statusCenter.workflow.status).toBe(WorkflowStatus.Processing);
+    const result = await processing;
+    expect(context.statusCenter.workflow.status).toBe(WorkflowStatus.Succeeded);
+
+    // Verify the result with different input
+    expect(result).toStrictEqual({
+      input: 'test123',
+      output_key0: 'test123test123', // Concatenated input
+      output_key1: ['hello', 'world'], // Static array output
+      output_key2: {
+        // Static object output
+        key21: 'hi',
+      },
+    });
+  });
+
+  it('should handle empty string input in code node', async () => {
+    const engine = container.get<IEngine>(IEngine);
+    const { context, processing } = engine.invoke({
+      schema: TestSchemas.codeSchema,
+      inputs: {
+        input: '',
+      },
+    });
+
+    expect(context.statusCenter.workflow.status).toBe(WorkflowStatus.Processing);
+    const result = await processing;
+    expect(context.statusCenter.workflow.status).toBe(WorkflowStatus.Succeeded);
+
+    // Verify the result with empty input
+    expect(result).toStrictEqual({
+      input: '',
+      output_key0: '', // Empty string concatenated
+      output_key1: ['hello', 'world'], // Static array output
+      output_key2: {
+        // Static object output
+        key21: 'hi',
+      },
+    });
+  });
+});

+ 150 - 0
packages/runtime/js-core/src/domain/__tests__/schemas/code.ts

@@ -0,0 +1,150 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import type { WorkflowSchema } from '@flowgram.ai/runtime-interface';
+
+export const codeSchema: WorkflowSchema = {
+  nodes: [
+    {
+      id: 'start_0',
+      type: 'start',
+      meta: {
+        position: {
+          x: 180,
+          y: 171.6,
+        },
+      },
+      data: {
+        title: 'Start',
+        outputs: {
+          type: 'object',
+          properties: {
+            input: {
+              type: 'string',
+            },
+          },
+          required: ['input'],
+        },
+      },
+    },
+    {
+      id: 'end_0',
+      type: 'end',
+      meta: {
+        position: {
+          x: 1124.4,
+          y: 171.6,
+        },
+      },
+      data: {
+        title: 'End',
+        inputsValues: {
+          input: {
+            type: 'ref',
+            content: ['start_0', 'input'],
+          },
+          output_key0: {
+            type: 'ref',
+            content: ['code_0', 'key0'],
+          },
+          output_key1: {
+            type: 'ref',
+            content: ['code_0', 'key1'],
+          },
+          output_key2: {
+            type: 'ref',
+            content: ['code_0', 'key2'],
+          },
+        },
+        inputs: {
+          type: 'object',
+          properties: {
+            input: {
+              type: 'string',
+            },
+            output_key0: {
+              type: 'string',
+            },
+            output_key1: {
+              type: 'array',
+              items: {
+                type: 'string',
+              },
+            },
+            output_key2: {
+              type: 'object',
+              properties: {
+                type: 'string',
+              },
+            },
+          },
+        },
+      },
+    },
+    {
+      id: 'code_0',
+      type: 'code',
+      meta: {
+        position: {
+          x: 652.2,
+          y: 0,
+        },
+      },
+      data: {
+        title: 'Code_0',
+        inputsValues: {
+          input: {
+            type: 'ref',
+            content: ['start_0', 'input'],
+          },
+        },
+        inputs: {
+          type: 'object',
+          required: ['input'],
+          properties: {
+            input: {
+              type: 'string',
+            },
+          },
+        },
+        outputs: {
+          type: 'object',
+          properties: {
+            key0: {
+              type: 'string',
+            },
+            key1: {
+              type: 'array',
+              items: {
+                type: 'string',
+              },
+            },
+            key2: {
+              type: 'object',
+              properties: {
+                type: 'string',
+              },
+            },
+          },
+        },
+        script: {
+          language: 'javascript',
+          content:
+            '// Here, you can use \'params\' to access the input variables in the node and use \'output\' to output the result\n// \'params\'  have already been properly injected into the environment\n// Below is an example of retrieving the value of the parameter \'input\' from the node\'s input:\n// const input = params.input; \n// Below is an example of outputting a \'ret\' object containing multiple data types:\n// const output = { "name": \'Jack\', "hobbies": ["reading", "traveling"] };\nasync function main({ params }) {\n    // Construct the output object\n    const output = {\n        "key0": params.input + params.input, // Concatenate the value of the two input parameters\n        "key1": ["hello", "world"], // Output an array\n        "key2": { // Output an Object\n            "key21": "hi"\n        },\n    };\n    return output;\n}',
+        },
+      },
+    },
+  ],
+  edges: [
+    {
+      sourceNodeID: 'start_0',
+      targetNodeID: 'code_0',
+    },
+    {
+      sourceNodeID: 'code_0',
+      targetNodeID: 'end_0',
+    },
+  ],
+};

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

@@ -8,6 +8,7 @@ import { twoLLMSchema } from './two-llm';
 import { loopSchema } from './loop';
 import { llmRealSchema } from './llm-real';
 import { httpSchema } from './http';
+import { codeSchema } from './code';
 import { branchTwoLayersSchema } from './branch-two-layers';
 import { branchSchema } from './branch';
 import { basicSchema } from './basic';
@@ -21,4 +22,5 @@ export const TestSchemas = {
   branchTwoLayersSchema,
   validateInputsSchema,
   httpSchema,
+  codeSchema,
 };

+ 94 - 0
packages/runtime/js-core/src/nodes/code/index.ts

@@ -0,0 +1,94 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import {
+  CodeNodeSchema,
+  ExecutionContext,
+  ExecutionResult,
+  FlowGramNode,
+  INode,
+  INodeExecutor,
+} from '@flowgram.ai/runtime-interface';
+
+export interface CodeExecutorInputs {
+  params: Record<string, any>;
+  script: {
+    language: 'javascript';
+    content: string;
+  };
+}
+
+export class CodeExecutor implements INodeExecutor {
+  public readonly type = FlowGramNode.Code;
+
+  public async execute(context: ExecutionContext): Promise<ExecutionResult> {
+    const inputs = this.parseInputs(context);
+    if (inputs.script.language === 'javascript') {
+      return this.javascript(inputs);
+    }
+    throw new Error(`Unsupported code language "${inputs.script.language}"`);
+  }
+
+  private parseInputs(context: ExecutionContext): CodeExecutorInputs {
+    const codeNode = context.node as INode<CodeNodeSchema['data']>;
+    const params = context.inputs;
+    const { language, content } = codeNode.data.script;
+    if (!content) {
+      throw new Error('Code content is required');
+    }
+    return {
+      params,
+      script: {
+        language,
+        content,
+      },
+    };
+  }
+
+  private async javascript(inputs: CodeExecutorInputs): Promise<ExecutionResult> {
+    // Extract script content and inputs
+    const { params = {}, script } = inputs;
+
+    try {
+      // Create a safe execution environment with basic restrictions
+      const executeCode = new Function(
+        'params',
+        `
+        'use strict';
+
+        ${script.content}
+
+        // Ensure main function exists
+        if (typeof main !== 'function') {
+          throw new Error('main function is required in the script');
+        }
+
+        // Execute main function with params
+        return main({ params });
+        `
+      );
+
+      // Execute with timeout protection (1 minute)
+      const timeoutPromise = new Promise<never>((_, reject) => {
+        setTimeout(() => {
+          reject(new Error('Code execution timeout: exceeded 1 minute'));
+        }, 1000 * 60);
+      });
+
+      // Execute the code with input parameters and timeout
+      const result = await Promise.race([executeCode(params), timeoutPromise]);
+
+      // Ensure result is an object
+      const outputs =
+        result && typeof result === 'object' && !Array.isArray(result) ? result : { result };
+
+      return {
+        outputs,
+      };
+    } catch (error: any) {
+      throw new Error(`Code execution failed: ${error.message}`);
+    }
+  }
+}

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

@@ -18,7 +18,7 @@ import { conditionRules } from './rules';
 import { conditionHandlers } from './handlers';
 
 export class ConditionExecutor implements INodeExecutor {
-  public type = FlowGramNode.Condition;
+  public readonly type = FlowGramNode.Condition;
 
   public async execute(context: ExecutionContext): Promise<ExecutionResult> {
     const conditions: Conditions = context.node.data?.conditions;

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

@@ -11,7 +11,7 @@ import {
 } from '@flowgram.ai/runtime-interface';
 
 export class BlockStartExecutor implements INodeExecutor {
-  public type = FlowGramNode.BlockStart;
+  public readonly type = FlowGramNode.BlockStart;
 
   public async execute(context: ExecutionContext): Promise<ExecutionResult> {
     return {

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

@@ -11,7 +11,7 @@ import {
 } from '@flowgram.ai/runtime-interface';
 
 export class EndExecutor implements INodeExecutor {
-  public type = FlowGramNode.End;
+  public readonly type = FlowGramNode.End;
 
   public async execute(context: ExecutionContext): Promise<ExecutionResult> {
     context.runtime.ioCenter.setOutputs(context.inputs);

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

@@ -26,7 +26,7 @@ export interface HTTPExecutorInputs {
 }
 
 export class HTTPExecutor implements INodeExecutor {
-  public type = FlowGramNode.HTTP;
+  public readonly type = FlowGramNode.HTTP;
 
   public async execute(context: ExecutionContext): Promise<ExecutionResult> {
     const inputs = this.parseInputs(context);

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

@@ -12,6 +12,7 @@ import { HTTPExecutor } from './http';
 import { EndExecutor } from './end';
 import { BlockEndExecutor, BlockStartExecutor } from './empty';
 import { ConditionExecutor } from './condition';
+import { CodeExecutor } from './code';
 
 export const WorkflowRuntimeNodeExecutors: INodeExecutorFactory[] = [
   StartExecutor,
@@ -22,4 +23,5 @@ export const WorkflowRuntimeNodeExecutors: INodeExecutorFactory[] = [
   BlockStartExecutor,
   BlockEndExecutor,
   HTTPExecutor,
+  CodeExecutor,
 ];

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

@@ -23,7 +23,7 @@ export interface LLMExecutorInputs {
 }
 
 export class LLMExecutor implements INodeExecutor {
-  public type = FlowGramNode.LLM;
+  public readonly type = FlowGramNode.LLM;
 
   public async execute(context: ExecutionContext): Promise<ExecutionResult> {
     const inputs = context.inputs as LLMExecutorInputs;

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

@@ -29,7 +29,7 @@ export interface LoopExecutorInputs {
 }
 
 export class LoopExecutor implements INodeExecutor {
-  public type = FlowGramNode.Loop;
+  public readonly type = FlowGramNode.Loop;
 
   public async execute(context: ExecutionContext): Promise<ExecutionResult> {
     const loopNodeID = context.node.id;

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

@@ -11,7 +11,7 @@ import {
 } from '@flowgram.ai/runtime-interface';
 
 export class StartExecutor implements INodeExecutor {
-  public type = FlowGramNode.Start;
+  public readonly type = FlowGramNode.Start;
 
   public async execute(context: ExecutionContext): Promise<ExecutionResult> {
     return {