diff --git a/app/w/[id]/workflow.tsx b/app/w/[id]/workflow.tsx index a80ca6e6e6..72cc1d48da 100644 --- a/app/w/[id]/workflow.tsx +++ b/app/w/[id]/workflow.tsx @@ -236,33 +236,6 @@ function WorkflowCanvas() { initializeStateLogger() }, []) - /** - * Gets the initial node in the workflow by finding the node with no incoming edges - * @returns {Object} Object containing the initial block and its configuration - * @throws {Error} If no initial block is found or block configuration is invalid - */ - const getInitialNode = () => { - const initialBlockId = Object.values(blocks).find( - (block) => !edges.some((edge) => edge.target === block.id) - )?.id - - if (!initialBlockId) { - throw new Error('Could not determine the initial block in the workflow') - } - - const blockConfig = getBlock(blocks[initialBlockId].type) - if (!blockConfig) { - throw new Error( - `Block configuration not found for type: ${blocks[initialBlockId].type}` - ) - } - - return { - block: blocks[initialBlockId], - config: blockConfig, - } - } - /** * Determines the initial input parameters based on the block type * @param {BlockState} block - The block to get initial input for @@ -339,27 +312,20 @@ function WorkflowCanvas() { setIsExecuting(true) setExecutionResult(null) - // 1. Get initial node - const { block: initialBlock, config: initialBlockConfig } = - getInitialNode() - - // 2. Serialize the workflow + // 1. Serialize the workflow const serializer = new Serializer() const serializedWorkflow = serializer.serializeWorkflow( - Object.values(blocks).map(serializeBlock), + blocks, edges ) - // 3. Create executor and run workflow + // 2. Create executor and run workflow const executor = new Executor(serializedWorkflow) - const initialInput = getInitialInput(initialBlock, initialBlockConfig) - const result = await executor.execute( - window.location.pathname.split('/').pop() || 'workflow', - initialInput + window.location.pathname.split('/').pop() || 'workflow' ) - // 4. Handle result + // 3. Handle result setExecutionResult(result) if (result.success) { @@ -403,6 +369,13 @@ function WorkflowCanvas() {
+ {/* */} mockError || 'Mock error' }); +jest.mock('@/tools', () => ({ + tools: {} +})); + describe('Executor', () => { beforeEach(() => { // Reset tools mock @@ -82,7 +86,7 @@ describe('Executor', () => { ); const executor = new Executor(workflow); - const result = await executor.execute('workflow-1', { input: 'test' }); + const result = await executor.execute('workflow-1'); expect(result.success).toBe(true); expect(result.data).toEqual({ result: 'test processed' }); @@ -125,7 +129,7 @@ describe('Executor', () => { }; const executor = new Executor(workflow); - const result = await executor.execute('workflow-1', {}); + const result = await executor.execute('workflow-1'); expect(result.success).toBe(false); expect(result.error).toContain('Missing required parameter'); @@ -166,7 +170,7 @@ describe('Executor', () => { ); const executor = new Executor(workflow); - const result = await executor.execute('workflow-1', { input: 'test' }); + const result = await executor.execute('workflow-1'); expect(result.success).toBe(false); expect(result.error).toContain('API Error'); @@ -200,7 +204,7 @@ describe('Executor', () => { }; const executor = new Executor(workflow); - const result = await executor.execute('workflow-1', { input: 42 }); + const result = await executor.execute('workflow-1'); expect(result.success).toBe(false); expect(result.error).toContain('Invalid type for input'); @@ -240,7 +244,7 @@ describe('Executor', () => { ); const executor = new Executor(workflow); - const result = await executor.execute('workflow-1', { input: 'test' }); + const result = await executor.execute('workflow-1'); expect(result.success).toBe(false); expect(result.error).toContain('Tool output missing required field'); @@ -248,40 +252,40 @@ describe('Executor', () => { }); describe('Complex Workflows', () => { - it('should execute a workflow with multiple connected blocks', async () => { - const processorTool = createMockTool( - 'processor', - 'Processor Tool', - { processed: 'TEST' } + it('should execute blocks in correct order and pass data between them', async () => { + const mockTool1 = createMockTool( + 'tool-1', + 'Tool 1', + { output: 'test data' } ); - const formatterTool = createMockTool( - 'formatter', - 'Formatter Tool', - { result: '' } + const mockTool2 = createMockTool( + 'tool-2', + 'Tool 2', + { result: 'processed data' } ); - (tools as any)['processor'] = processorTool; - (tools as any)['formatter'] = formatterTool; + (tools as any)['tool-1'] = mockTool1; + (tools as any)['tool-2'] = mockTool2; const workflow: SerializedWorkflow = { version: '1.0', blocks: [ { - id: 'process', + id: 'block-1', position: { x: 0, y: 0 }, config: { - tool: 'processor', - params: { input: 'test' }, + tool: 'tool-1', + params: { input: 'initial' }, interface: { - inputs: { input: 'string' }, - outputs: { processed: 'string' } + inputs: {}, + outputs: { output: 'string' } } } }, { - id: 'format', - position: { x: 100, y: 0 }, + id: 'block-2', + position: { x: 200, y: 0 }, config: { - tool: 'formatter', + tool: 'tool-2', params: {}, interface: { inputs: { input: 'string' }, @@ -290,31 +294,89 @@ describe('Executor', () => { } } ], - connections: [{ - source: 'process', - target: 'format', - sourceHandle: 'processed', - targetHandle: 'input' - }] + connections: [ + { + source: 'block-1', + target: 'block-2', + sourceHandle: 'output', + targetHandle: 'input' + } + ] }; // Mock fetch for both tools global.fetch = jest.fn() - .mockImplementationOnce(() => Promise.resolve({ - ok: true, - json: () => Promise.resolve({ processed: 'TEST' }) - })) - .mockImplementationOnce(() => Promise.resolve({ - ok: true, - json: () => Promise.resolve({ result: '' }) - })); + .mockImplementationOnce(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ output: 'test data' }) + }) + ) + .mockImplementationOnce(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ result: 'processed data' }) + }) + ); const executor = new Executor(workflow); - const result = await executor.execute('workflow-1', { input: 'test' }); + const result = await executor.execute('workflow-1'); expect(result.success).toBe(true); - expect(result.data).toEqual({ result: '' }); + expect(result.data).toEqual({ result: 'processed data' }); expect(global.fetch).toHaveBeenCalledTimes(2); }); + + it('should handle cycles in workflow', async () => { + const workflow: SerializedWorkflow = { + version: '1.0', + blocks: [ + { + id: 'block-1', + position: { x: 0, y: 0 }, + config: { + tool: 'test-tool', + params: {}, + interface: { + inputs: {}, + outputs: {} + } + } + }, + { + id: 'block-2', + position: { x: 200, y: 0 }, + config: { + tool: 'test-tool', + params: {}, + interface: { + inputs: {}, + outputs: {} + } + } + } + ], + connections: [ + { + source: 'block-1', + target: 'block-2', + sourceHandle: 'output', + targetHandle: 'input' + }, + { + source: 'block-2', + target: 'block-1', + sourceHandle: 'output', + targetHandle: 'input' + } + ] + }; + + const executor = new Executor(workflow); + const result = await executor.execute('workflow-1'); + + expect(result.success).toBe(false); + expect(result.error).toContain('Workflow contains cycles'); + }); }); }); diff --git a/executor/index.ts b/executor/index.ts index 97b695a982..3aa428f306 100644 --- a/executor/index.ts +++ b/executor/index.ts @@ -1,6 +1,7 @@ -import { SerializedWorkflow, SerializedBlock, BlockConfig } from '@/serializer/types'; +import { SerializedWorkflow, SerializedBlock } from '@/serializer/types'; import { ExecutionContext, ExecutionResult, Tool } from './types'; import { tools } from '@/tools'; +import { BlockState } from '@/stores/workflow/types'; export class Executor { private workflow: SerializedWorkflow; @@ -14,7 +15,7 @@ export class Executor { inputs: Record, context: ExecutionContext ): Promise> { - const config = block.config as BlockConfig; + const config = block.config; const toolId = config.tool; if (!toolId) { @@ -113,15 +114,17 @@ export class Executor { return typeof value === 'number'; case 'boolean': return typeof value === 'boolean'; - case 'object': - return typeof value === 'object' && value !== null; - case 'array': - return Array.isArray(value); - case 'function': - return typeof value === 'function'; + case 'json': + try { + if (typeof value === 'string') { + JSON.parse(value); + } + return true; + } catch { + return false; + } default: - // For complex types like 'Record', 'string[]', etc. - // We just do basic object/array validation + // For complex types, we just do basic object/array validation return true; } } @@ -187,20 +190,22 @@ export class Executor { } }); - // If this is a start block, pass through workflow inputs - if (Object.keys(inputs).length === 0 && context.input) { - return context.input; + // If this is a start block with no inputs, use the block's params + if (Object.keys(inputs).length === 0) { + const targetBlock = this.workflow.blocks.find(b => b.id === block.id); + if (targetBlock) { + return targetBlock.config.params; + } } return inputs; } - async execute(workflowId: string, input: Record): Promise { + async execute(workflowId: string): Promise { const startTime = new Date(); const context: ExecutionContext = { workflowId, blockStates: new Map(), - input, metadata: { startTime: startTime.toISOString() } diff --git a/executor/types.ts b/executor/types.ts index e835ab80f8..34c23b5b0b 100644 --- a/executor/types.ts +++ b/executor/types.ts @@ -28,7 +28,7 @@ export interface ToolRegistry { export interface ExecutionContext { workflowId: string; blockStates: Map; - input: Record; + input?: Record; metadata?: Record; } diff --git a/serializer/__tests__/serializer.test.ts b/serializer/__tests__/serializer.test.ts index 6af98ebfa9..a90a1e4b25 100644 --- a/serializer/__tests__/serializer.test.ts +++ b/serializer/__tests__/serializer.test.ts @@ -1,6 +1,47 @@ -import { Node, Edge } from 'reactflow'; +import { Edge } from 'reactflow'; import { Serializer } from '../index'; import { SerializedWorkflow } from '../types'; +import { BlockState } from '@/stores/workflow/types'; +import { OutputType } from '@/blocks/types'; + +jest.mock('@/blocks', () => ({ + getBlock: (type: string) => { + if (type === 'http') { + return { + type, + workflow: { + tools: { + access: ['http.request'], + config: { + tool: () => 'http.request' + } + }, + inputs: { + url: 'string', + method: 'string' + }, + outputType: 'json' + } + }; + } + // Default agent block config + return { + type, + workflow: { + tools: { + access: ['openai.chat'], + config: { + tool: () => 'openai.chat' + } + }, + inputs: { + prompt: 'string' + }, + outputType: 'string' + } + }; + } +})); describe('Serializer', () => { let serializer: Serializer; @@ -11,61 +52,51 @@ describe('Serializer', () => { describe('serializeWorkflow', () => { it('should serialize a workflow with agent and http blocks', () => { - const blocks: Node[] = [ - { + const blocks: Record = { + 'agent-1': { id: 'agent-1', - type: 'custom', + type: 'agent', + name: 'GPT-4o Agent', position: { x: 100, y: 100 }, - data: { - tool: 'openai.chat', - params: { - model: 'gpt-4o', - systemPrompt: 'You are helpful', - temperature: 0.7 + subBlocks: { + 'model': { + id: 'model', + type: 'dropdown', + value: 'gpt-4o' }, - interface: { - inputs: { - prompt: 'string' - }, - outputs: { - response: 'string', - tokens: 'number' - } + 'systemPrompt': { + id: 'systemPrompt', + type: 'long-input', + value: 'You are helpful' }, - title: 'GPT-4o Agent', - description: 'Language model block', - category: 'AI', - icon: 'brain', - color: '#7F2FFF' - } + 'temperature': { + id: 'temperature', + type: 'slider', + value: 0.7 + } + }, + outputType: 'string' }, - { + 'http-1': { id: 'http-1', - type: 'custom', + type: 'http', + name: 'API Call', position: { x: 400, y: 100 }, - data: { - tool: 'http.request', - params: { - url: 'https://api.example.com', - method: 'GET' + subBlocks: { + 'url': { + id: 'url', + type: 'short-input', + value: 'https://api.example.com' }, - interface: { - inputs: { - body: 'object' - }, - outputs: { - data: 'object', - status: 'number' - } - }, - title: 'API Call', - description: 'HTTP request block', - category: 'Web', - icon: 'globe', - color: '#00FF00' - } + 'method': { + id: 'method', + type: 'dropdown', + value: 'GET' + } + }, + outputType: 'json' } - ]; + }; const connections: Edge[] = [ { @@ -93,13 +124,6 @@ describe('Serializer', () => { systemPrompt: 'You are helpful', temperature: 0.7 }); - expect(agentBlock?.metadata).toEqual({ - title: 'GPT-4o Agent', - description: 'Language model block', - category: 'AI', - icon: 'brain', - color: '#7F2FFF' - }); // Test http block serialization const httpBlock = serialized.blocks.find(b => b.id === 'http-1'); @@ -112,21 +136,22 @@ describe('Serializer', () => { }); it('should handle blocks with minimal required configuration', () => { - const blocks: Node[] = [{ - id: 'minimal-1', - type: 'custom', - position: { x: 0, y: 0 }, - data: { - tool: 'openai.chat', - params: { - model: 'gpt-4o' + const blocks: Record = { + 'minimal-1': { + id: 'minimal-1', + type: 'agent', + name: 'Minimal Agent', + position: { x: 0, y: 0 }, + subBlocks: { + 'model': { + id: 'model', + type: 'dropdown', + value: 'gpt-4o' + } }, - interface: { - inputs: {}, - outputs: {} - } + outputType: 'string' } - }]; + }; const serialized = serializer.serializeWorkflow(blocks, []); const block = serialized.blocks[0]; @@ -134,71 +159,68 @@ describe('Serializer', () => { expect(block.id).toBe('minimal-1'); expect(block.config.tool).toBe('openai.chat'); expect(block.config.params).toEqual({ model: 'gpt-4o' }); - expect(block.metadata).toBeUndefined(); }); it('should handle complex workflow with multiple interconnected blocks', () => { - const blocks: Node[] = [ - { + const blocks: Record = { + 'input-1': { id: 'input-1', - type: 'custom', + type: 'http', + name: 'Data Input', position: { x: 100, y: 100 }, - data: { - tool: 'http.request', - params: { - url: 'https://api.data.com', - method: 'GET' + subBlocks: { + 'url': { + id: 'url', + type: 'short-input', + value: 'https://api.data.com' }, - interface: { - inputs: {}, - outputs: { - data: 'object' - } + 'method': { + id: 'method', + type: 'dropdown', + value: 'GET' } - } + }, + outputType: 'json' }, - { + 'process-1': { id: 'process-1', - type: 'custom', + type: 'agent', + name: 'Data Processor', position: { x: 300, y: 100 }, - data: { - tool: 'openai.chat', - params: { - model: 'gpt-4o', - systemPrompt: 'Process this data' + subBlocks: { + 'model': { + id: 'model', + type: 'dropdown', + value: 'gpt-4o' }, - interface: { - inputs: { - data: 'object', - config: 'object' - }, - outputs: { - result: 'string' - } + 'systemPrompt': { + id: 'systemPrompt', + type: 'long-input', + value: 'Process this data' } - } + }, + outputType: 'string' }, - { + 'output-1': { id: 'output-1', - type: 'custom', + type: 'http', + name: 'Data Output', position: { x: 500, y: 100 }, - data: { - tool: 'http.request', - params: { - url: 'https://api.output.com', - method: 'POST' + subBlocks: { + 'url': { + id: 'url', + type: 'short-input', + value: 'https://api.output.com' }, - interface: { - inputs: { - body: 'string' - }, - outputs: { - status: 'number' - } + 'method': { + id: 'method', + type: 'dropdown', + value: 'POST' } - } + }, + outputType: 'json' } - ]; + }; const connections: Edge[] = [ { @@ -230,57 +252,56 @@ describe('Serializer', () => { expect(conn1.target).toBe('process-1'); expect(conn2.source).toBe('process-1'); expect(conn2.target).toBe('output-1'); - - // Verify interface matching - const process = serialized.blocks.find(b => b.id === 'process-1'); - expect(process?.config.interface.inputs).toHaveProperty('data'); - expect(process?.config.interface.outputs).toHaveProperty('result'); }); it('should preserve tool-specific parameters', () => { - const blocks: Node[] = [{ - id: 'agent-1', - type: 'custom', - position: { x: 0, y: 0 }, - data: { - tool: 'openai.chat', - params: { - model: 'gpt-4o', - temperature: 0.7, - maxTokens: 1000, - topP: 0.9, - frequencyPenalty: 0.1, - presencePenalty: 0.1 + const blocks: Record = { + 'agent-1': { + id: 'agent-1', + type: 'agent', + name: 'Advanced Agent', + position: { x: 0, y: 0 }, + subBlocks: { + 'model': { + id: 'model', + type: 'dropdown', + value: 'gpt-4o' + }, + 'temperature': { + id: 'temperature', + type: 'slider', + value: 0.7 + }, + 'maxTokens': { + id: 'maxTokens', + type: 'slider', + value: 1000 + } }, - interface: { - inputs: { prompt: 'string' }, - outputs: { response: 'string' } - } + outputType: 'string' } - }]; + }; const serialized = serializer.serializeWorkflow(blocks, []); const block = serialized.blocks[0]; + expect(block.config.tool).toBe('openai.chat'); expect(block.config.params).toEqual({ model: 'gpt-4o', temperature: 0.7, - maxTokens: 1000, - topP: 0.9, - frequencyPenalty: 0.1, - presencePenalty: 0.1 + maxTokens: 1000 }); }); }); describe('deserializeWorkflow', () => { - it('should deserialize a workflow back to ReactFlow format', () => { + it('should deserialize a workflow back to blocks and connections', () => { const workflow: SerializedWorkflow = { version: '1.0', blocks: [ { id: 'agent-1', - position: { x: 100, y: 100 }, + position: { x: 0, y: 0 }, config: { tool: 'openai.chat', params: { @@ -289,27 +310,21 @@ describe('Serializer', () => { }, interface: { inputs: { prompt: 'string' }, - outputs: { response: 'string' } + outputs: { output: 'string' } } - }, - metadata: { - title: 'GPT-4o Agent', - category: 'AI' } } ], connections: [] }; - const { blocks, connections } = serializer.deserializeWorkflow(workflow); + const { blocks } = serializer.deserializeWorkflow(workflow); + const block = blocks['agent-1']; - expect(blocks).toHaveLength(1); - const block = blocks[0]; - expect(block.id).toBe('agent-1'); - expect(block.type).toBe('custom'); - expect(block.data.tool).toBe('openai.chat'); - expect(block.data.params.model).toBe('gpt-4o'); - expect(block.data.title).toBe('GPT-4o Agent'); + expect(block.type).toBe('openai.chat'); + expect(block.subBlocks.model.value).toBe('gpt-4o'); + expect(block.subBlocks.systemPrompt.value).toBe('You are helpful'); + expect(block.outputType).toBe('string'); }); }); }); \ No newline at end of file diff --git a/serializer/index.ts b/serializer/index.ts index 37cdb82f9e..3b512c3eec 100644 --- a/serializer/index.ts +++ b/serializer/index.ts @@ -1,11 +1,14 @@ -import { Node, Edge } from "reactflow"; -import { SerializedBlock, SerializedConnection, SerializedWorkflow } from "./types"; +import { BlockState, SubBlockState } from '@/stores/workflow/types'; +import { Edge } from 'reactflow'; +import { SerializedBlock, SerializedConnection, SerializedWorkflow } from './types'; +import { getBlock } from '@/blocks'; +import { OutputType, SubBlockType } from '@/blocks/types'; export class Serializer { - serializeWorkflow(blocks: Node[], connections: Edge[]): SerializedWorkflow { + serializeWorkflow(blocks: Record, connections: Edge[]): SerializedWorkflow { return { version: '1.0', - blocks: blocks.map(block => this.serializeBlock(block)), + blocks: Object.values(blocks).map(block => this.serializeBlock(block)), connections: connections.map(conn => ({ source: conn.source, target: conn.target, @@ -15,45 +18,60 @@ export class Serializer { }; } - private serializeBlock(block: Node): SerializedBlock { - const { data } = block; - const serialized: SerializedBlock = { + private serializeBlock(block: BlockState): SerializedBlock { + const blockConfig = getBlock(block.type); + if (!blockConfig) { + throw new Error(`Block configuration not found for type: ${block.type}`); + } + + // Get the tool ID from the block's configuration + const tools = blockConfig.workflow.tools; + if (!tools?.access || tools.access.length === 0) { + throw new Error(`No tools specified for block type: ${block.type}`); + } + + // Get all values from subBlocks + const params: Record = {}; + Object.entries(block.subBlocks || {}).forEach(([id, subBlock]) => { + if (subBlock?.value !== undefined) { + params[id] = subBlock.value; + } + }); + + // Get the tool ID from the block's configuration + const toolId = tools.config?.tool?.(params) || params.tool || tools.access[0]; + if (!toolId || !tools.access.includes(toolId)) { + throw new Error(`Invalid or unauthorized tool: ${toolId}`); + } + + return { id: block.id, - position: { - x: block.position.x, - y: block.position.y - }, + position: block.position, config: { - tool: data.tool, - params: data.params || {}, + tool: toolId, + params: params, interface: { - inputs: data.interface?.inputs || {}, - outputs: data.interface?.outputs || {} + inputs: blockConfig.workflow.inputs || {}, + outputs: { + output: block.outputType + } } } }; - - const metadata = { - title: data.title, - description: data.description, - category: data.category, - icon: data.icon, - color: data.color - }; - - if (Object.values(metadata).some(value => value !== undefined)) { - serialized.metadata = metadata; - } - - return serialized; } deserializeWorkflow(serialized: SerializedWorkflow): { - blocks: Node[]; + blocks: Record; connections: Edge[]; } { + const blocks: Record = {}; + serialized.blocks.forEach(block => { + const deserialized = this.deserializeBlock(block); + blocks[deserialized.id] = deserialized; + }); + return { - blocks: serialized.blocks.map(block => this.deserializeBlock(block)), + blocks, connections: serialized.connections.map(conn => ({ id: `${conn.source}-${conn.target}`, source: conn.source, @@ -64,17 +82,31 @@ export class Serializer { }; } - private deserializeBlock(serialized: SerializedBlock): Node { + private deserializeBlock(serialized: SerializedBlock): BlockState { return { id: serialized.id, - type: 'custom', + type: serialized.config.tool, + name: `${serialized.config.tool} Block`, position: serialized.position, - data: { - tool: serialized.config.tool, - params: serialized.config.params, - interface: serialized.config.interface, - ...(serialized.metadata || {}) - } + subBlocks: Object.entries(serialized.config.params).reduce((acc, [key, value]) => { + acc[key] = { + id: key, + type: this.inferSubBlockType(value), + value: value + }; + return acc; + }, {} as Record), + outputType: serialized.config.interface.outputs.output as OutputType }; } + + private inferSubBlockType(value: any): SubBlockType { + if (Array.isArray(value) && Array.isArray(value[0])) { + return 'table'; + } + if (typeof value === 'string' && value.length > 100) { + return 'long-input'; + } + return 'short-input'; + } } \ No newline at end of file