feat(turbo): restructured repo to be a standard turborepo monorepo (#341)

* added turborepo

* finished turbo migration

* updated gitignore

* use dotenv & run format

* fixed error in docs

* remove standalone deployment in prod

* fix ts error, remove ignore ts errors during build

* added formatter to the end of the docs generator
This commit is contained in:
Waleed Latif
2025-05-09 21:45:49 -07:00
committed by GitHub
parent 1438028982
commit a92ee8bf46
1072 changed files with 39956 additions and 22581 deletions

View File

@@ -0,0 +1,662 @@
/**
* Test Workflows
*
* This file contains test fixtures for serializer tests, providing
* sample workflow states with different configurations.
*/
import { Edge } from 'reactflow'
import { BlockState, Loop } from '@/stores/workflows/workflow/types'
/**
* Workflow State Interface
*/
export interface WorkflowStateFixture {
blocks: Record<string, BlockState>
edges: Edge[]
loops: Record<string, Loop>
}
/**
* Create a minimal workflow with just a starter and one block
*/
export function createMinimalWorkflowState(): WorkflowStateFixture {
const blocks: Record<string, BlockState> = {
starter: {
id: 'starter',
type: 'starter',
name: 'Starter Block',
position: { x: 0, y: 0 },
subBlocks: {
description: {
id: 'description',
type: 'long-input',
value: 'This is the starter block',
},
},
outputs: {},
enabled: true,
},
agent1: {
id: 'agent1',
type: 'agent',
name: 'Agent Block',
position: { x: 300, y: 0 },
subBlocks: {
provider: {
id: 'provider',
type: 'dropdown',
value: 'anthropic',
},
model: {
id: 'model',
type: 'dropdown',
value: 'claude-3-7-sonnet-20250219',
},
prompt: {
id: 'prompt',
type: 'long-input',
value: 'Hello, world!',
},
tools: {
id: 'tools',
type: 'tool-input',
value: '[]',
},
system: {
id: 'system',
type: 'long-input',
value: 'You are a helpful assistant.',
},
responseFormat: {
id: 'responseFormat',
type: 'code',
value: null,
},
},
outputs: {},
enabled: true,
},
}
const edges: Edge[] = [
{
id: 'edge1',
source: 'starter',
target: 'agent1',
},
]
const loops: Record<string, Loop> = {}
return { blocks, edges, loops }
}
/**
* Create a workflow with condition blocks
*/
export function createConditionalWorkflowState(): WorkflowStateFixture {
const blocks: Record<string, BlockState> = {
starter: {
id: 'starter',
type: 'starter',
name: 'Starter Block',
position: { x: 0, y: 0 },
subBlocks: {
description: {
id: 'description',
type: 'long-input',
value: 'This is the starter block',
},
},
outputs: {},
enabled: true,
},
condition1: {
id: 'condition1',
type: 'condition',
name: 'Condition Block',
position: { x: 300, y: 0 },
subBlocks: {
condition: {
id: 'condition',
type: 'long-input',
value: 'input.value > 10',
},
},
outputs: {},
enabled: true,
},
agent1: {
id: 'agent1',
type: 'agent',
name: 'True Path Agent',
position: { x: 600, y: -100 },
subBlocks: {
provider: {
id: 'provider',
type: 'dropdown',
value: 'anthropic',
},
model: {
id: 'model',
type: 'dropdown',
value: 'claude-3-7-sonnet-20250219',
},
prompt: {
id: 'prompt',
type: 'long-input',
value: 'Value is greater than 10',
},
tools: {
id: 'tools',
type: 'tool-input',
value: '[]',
},
system: {
id: 'system',
type: 'long-input',
value: 'You are a helpful assistant.',
},
responseFormat: {
id: 'responseFormat',
type: 'code',
value: null,
},
},
outputs: {},
enabled: true,
},
agent2: {
id: 'agent2',
type: 'agent',
name: 'False Path Agent',
position: { x: 600, y: 100 },
subBlocks: {
provider: {
id: 'provider',
type: 'dropdown',
value: 'anthropic',
},
model: {
id: 'model',
type: 'dropdown',
value: 'claude-3-7-sonnet-20250219',
},
prompt: {
id: 'prompt',
type: 'long-input',
value: 'Value is less than or equal to 10',
},
tools: {
id: 'tools',
type: 'tool-input',
value: '[]',
},
system: {
id: 'system',
type: 'long-input',
value: 'You are a helpful assistant.',
},
responseFormat: {
id: 'responseFormat',
type: 'code',
value: null,
},
},
outputs: {},
enabled: true,
},
}
const edges: Edge[] = [
{
id: 'edge1',
source: 'starter',
target: 'condition1',
},
{
id: 'edge2',
source: 'condition1',
target: 'agent1',
sourceHandle: 'condition-true',
},
{
id: 'edge3',
source: 'condition1',
target: 'agent2',
sourceHandle: 'condition-false',
},
]
const loops: Record<string, Loop> = {}
return { blocks, edges, loops }
}
/**
* Create a workflow with a loop
*/
export function createLoopWorkflowState(): WorkflowStateFixture {
const blocks: Record<string, BlockState> = {
starter: {
id: 'starter',
type: 'starter',
name: 'Starter Block',
position: { x: 0, y: 0 },
subBlocks: {
description: {
id: 'description',
type: 'long-input',
value: 'This is the starter block',
},
},
outputs: {},
enabled: true,
},
function1: {
id: 'function1',
type: 'function',
name: 'Function Block',
position: { x: 300, y: 0 },
subBlocks: {
code: {
id: 'code',
type: 'code',
value: 'let counter = input.counter || 0;\ncounter++;\nreturn { counter };',
},
language: {
id: 'language',
type: 'dropdown',
value: 'javascript',
},
},
outputs: {},
enabled: true,
},
condition1: {
id: 'condition1',
type: 'condition',
name: 'Loop Condition',
position: { x: 600, y: 0 },
subBlocks: {
condition: {
id: 'condition',
type: 'long-input',
value: 'input.counter < 5',
},
},
outputs: {},
enabled: true,
},
agent1: {
id: 'agent1',
type: 'agent',
name: 'Loop Complete Agent',
position: { x: 900, y: 100 },
subBlocks: {
provider: {
id: 'provider',
type: 'dropdown',
value: 'anthropic',
},
model: {
id: 'model',
type: 'dropdown',
value: 'claude-3-7-sonnet-20250219',
},
prompt: {
id: 'prompt',
type: 'long-input',
value: 'Loop completed after {{input.counter}} iterations',
},
tools: {
id: 'tools',
type: 'tool-input',
value: '[]',
},
system: {
id: 'system',
type: 'long-input',
value: 'You are a helpful assistant.',
},
responseFormat: {
id: 'responseFormat',
type: 'code',
value: null,
},
},
outputs: {},
enabled: true,
},
}
const edges: Edge[] = [
{
id: 'edge1',
source: 'starter',
target: 'function1',
},
{
id: 'edge2',
source: 'function1',
target: 'condition1',
},
{
id: 'edge3',
source: 'condition1',
target: 'function1',
sourceHandle: 'condition-true',
},
{
id: 'edge4',
source: 'condition1',
target: 'agent1',
sourceHandle: 'condition-false',
},
]
const loops: Record<string, Loop> = {
loop1: {
id: 'loop1',
nodes: ['function1', 'condition1'],
iterations: 10,
loopType: 'for',
},
}
return { blocks, edges, loops }
}
/**
* Create a workflow with multiple block types
*/
export function createComplexWorkflowState(): WorkflowStateFixture {
const blocks: Record<string, BlockState> = {
starter: {
id: 'starter',
type: 'starter',
name: 'Starter Block',
position: { x: 0, y: 0 },
subBlocks: {
description: {
id: 'description',
type: 'long-input',
value: 'This is the starter block',
},
},
outputs: {},
enabled: true,
},
api1: {
id: 'api1',
type: 'api',
name: 'API Request',
position: { x: 300, y: 0 },
subBlocks: {
url: {
id: 'url',
type: 'short-input',
value: 'https://api.example.com/data',
},
method: {
id: 'method',
type: 'dropdown',
value: 'GET',
},
headers: {
id: 'headers',
type: 'table',
value: [
['Content-Type', 'application/json'],
['Authorization', 'Bearer {{API_KEY}}'],
],
},
body: {
id: 'body',
type: 'long-input',
value: '',
},
},
outputs: {},
enabled: true,
},
function1: {
id: 'function1',
type: 'function',
name: 'Process Data',
position: { x: 600, y: 0 },
subBlocks: {
code: {
id: 'code',
type: 'code',
value: 'const data = input.data;\nreturn { processed: data.map(item => item.name) };',
},
language: {
id: 'language',
type: 'dropdown',
value: 'javascript',
},
},
outputs: {},
enabled: true,
},
agent1: {
id: 'agent1',
type: 'agent',
name: 'Summarize Data',
position: { x: 900, y: 0 },
subBlocks: {
provider: {
id: 'provider',
type: 'dropdown',
value: 'openai',
},
model: {
id: 'model',
type: 'dropdown',
value: 'gpt-4o',
},
prompt: {
id: 'prompt',
type: 'long-input',
value: 'Summarize the following data:\n\n{{input.processed}}',
},
tools: {
id: 'tools',
type: 'tool-input',
value:
'[{"type":"function","name":"calculator","description":"Perform calculations","parameters":{"type":"object","properties":{"expression":{"type":"string","description":"Math expression to evaluate"}},"required":["expression"]}}]',
},
system: {
id: 'system',
type: 'long-input',
value: 'You are a data analyst assistant.',
},
responseFormat: {
id: 'responseFormat',
type: 'code',
value:
'{"type":"object","properties":{"summary":{"type":"string"},"keyPoints":{"type":"array","items":{"type":"string"}},"sentiment":{"type":"string","enum":["positive","neutral","negative"]}},"required":["summary","keyPoints","sentiment"]}',
},
},
outputs: {},
enabled: true,
},
}
const edges: Edge[] = [
{
id: 'edge1',
source: 'starter',
target: 'api1',
},
{
id: 'edge2',
source: 'api1',
target: 'function1',
},
{
id: 'edge3',
source: 'function1',
target: 'agent1',
},
]
const loops: Record<string, Loop> = {}
return { blocks, edges, loops }
}
/**
* Create a workflow with agent blocks that have custom tools
*/
export function createAgentWithToolsWorkflowState(): WorkflowStateFixture {
const blocks: Record<string, BlockState> = {
starter: {
id: 'starter',
type: 'starter',
name: 'Starter Block',
position: { x: 0, y: 0 },
subBlocks: {
description: {
id: 'description',
type: 'long-input',
value: 'This is the starter block',
},
},
outputs: {},
enabled: true,
},
agent1: {
id: 'agent1',
type: 'agent',
name: 'Custom Tools Agent',
position: { x: 300, y: 0 },
subBlocks: {
provider: {
id: 'provider',
type: 'dropdown',
value: 'openai',
},
model: {
id: 'model',
type: 'dropdown',
value: 'gpt-4o',
},
prompt: {
id: 'prompt',
type: 'long-input',
value: 'Use the tools to help answer: {{input.question}}',
},
tools: {
id: 'tools',
type: 'tool-input',
value:
'[{"type":"custom-tool","name":"weather","description":"Get current weather","parameters":{"type":"object","properties":{"location":{"type":"string"}},"required":["location"]}},{"type":"function","name":"calculator","description":"Calculate expression","parameters":{"type":"object","properties":{"expression":{"type":"string"}},"required":["expression"]}}]',
},
system: {
id: 'system',
type: 'long-input',
value: 'You are a helpful assistant with access to tools.',
},
responseFormat: {
id: 'responseFormat',
type: 'code',
value: null,
},
},
outputs: {},
enabled: true,
},
}
const edges: Edge[] = [
{
id: 'edge1',
source: 'starter',
target: 'agent1',
},
]
const loops: Record<string, Loop> = {}
return { blocks, edges, loops }
}
/**
* Create a workflow state with an invalid block type for error testing
*/
export function createInvalidWorkflowState(): WorkflowStateFixture {
const { blocks, edges, loops } = createMinimalWorkflowState()
// Add an invalid block type
blocks.invalid = {
id: 'invalid',
type: 'invalid-type',
name: 'Invalid Block',
position: { x: 600, y: 0 },
subBlocks: {},
outputs: {},
enabled: true,
}
edges.push({
id: 'edge-invalid',
source: 'agent1',
target: 'invalid',
})
return { blocks, edges, loops }
}
/**
* Create a serialized workflow with invalid metadata for error testing
*/
export function createInvalidSerializedWorkflow() {
return {
version: '1.0',
blocks: [
{
id: 'invalid',
position: { x: 0, y: 0 },
config: {
tool: 'invalid',
params: {},
},
inputs: {},
outputs: {},
metadata: {
id: 'non-existent-type',
},
enabled: true,
},
],
connections: [],
loops: {},
}
}
/**
* Create a serialized workflow with missing metadata for error testing
*/
export function createMissingMetadataWorkflow() {
return {
version: '1.0',
blocks: [
{
id: 'invalid',
position: { x: 0, y: 0 },
config: {
tool: 'invalid',
params: {},
},
inputs: {},
outputs: {},
metadata: undefined,
enabled: true,
},
],
connections: [],
loops: {},
}
}

View File

@@ -0,0 +1,449 @@
/**
* @vitest-environment jsdom
*
* Serializer Class Unit Tests
*
* This file contains unit tests for the Serializer class, which is responsible for
* converting between workflow state (blocks, edges, loops) and serialized format
* used by the executor.
*/
import { describe, expect, test, vi } from 'vitest'
import { getProviderFromModel } from '@/providers/utils'
import {
createAgentWithToolsWorkflowState,
createComplexWorkflowState,
createConditionalWorkflowState,
createInvalidSerializedWorkflow,
createInvalidWorkflowState,
createLoopWorkflowState,
createMinimalWorkflowState,
createMissingMetadataWorkflow,
} from './__test-utils__/test-workflows'
import { Serializer } from './index'
import { SerializedWorkflow } from './types'
// Mock getBlock function
vi.mock('@/blocks', () => ({
getBlock: (type: string) => {
// Mock block configurations for different block types
const mockConfigs: Record<string, any> = {
starter: {
name: 'Starter',
description: 'Start of the workflow',
category: 'flow',
bgColor: '#4CAF50',
tools: {
access: ['starter'],
config: {
tool: () => 'starter',
},
},
subBlocks: [{ id: 'description', type: 'long-input', label: 'Description' }],
inputs: {},
},
agent: {
name: 'Agent',
description: 'AI Agent',
category: 'ai',
bgColor: '#2196F3',
tools: {
access: ['anthropic_chat', 'openai_chat', 'google_chat'],
config: {
// Use the real getProviderFromModel that we imported
tool: (params: Record<string, any>) => getProviderFromModel(params.model || 'gpt-4o'),
},
},
subBlocks: [
{ id: 'provider', type: 'dropdown', label: 'Provider' },
{ id: 'model', type: 'dropdown', label: 'Model' },
{ id: 'prompt', type: 'long-input', label: 'Prompt' },
{ id: 'tools', type: 'tool-input', label: 'Tools' },
{ id: 'system', type: 'long-input', label: 'System Message' },
{ id: 'responseFormat', type: 'code', label: 'Response Format' },
],
inputs: {
input: { type: 'string' },
tools: { type: 'array' },
},
},
condition: {
name: 'Condition',
description: 'Branch based on condition',
category: 'flow',
bgColor: '#FF9800',
tools: {
access: ['condition'],
config: {
tool: () => 'condition',
},
},
subBlocks: [{ id: 'condition', type: 'long-input', label: 'Condition' }],
inputs: {
input: { type: 'any' },
},
},
function: {
name: 'Function',
description: 'Execute custom code',
category: 'code',
bgColor: '#9C27B0',
tools: {
access: ['function'],
config: {
tool: () => 'function',
},
},
subBlocks: [
{ id: 'code', type: 'code', label: 'Code' },
{ id: 'language', type: 'dropdown', label: 'Language' },
],
inputs: {
input: { type: 'any' },
},
},
api: {
name: 'API',
description: 'Make API request',
category: 'data',
bgColor: '#E91E63',
tools: {
access: ['api'],
config: {
tool: () => 'api',
},
},
subBlocks: [
{ id: 'url', type: 'short-input', label: 'URL' },
{ id: 'method', type: 'dropdown', label: 'Method' },
{ id: 'headers', type: 'table', label: 'Headers' },
{ id: 'body', type: 'long-input', label: 'Body' },
],
inputs: {},
},
}
return mockConfigs[type] || null
},
}))
// Mock logger
vi.mock('@/lib/logs/console-logger', () => ({
createLogger: () => ({
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
}),
}))
describe('Serializer', () => {
/**
* Serialization tests
*/
describe('serializeWorkflow', () => {
test('should serialize a minimal workflow correctly', () => {
const { blocks, edges, loops } = createMinimalWorkflowState()
const serializer = new Serializer()
const serialized = serializer.serializeWorkflow(blocks, edges, loops)
// Check if blocks are correctly serialized
expect(serialized.blocks).toHaveLength(2)
// Check starter block
const starterBlock = serialized.blocks.find((b) => b.id === 'starter')
expect(starterBlock).toBeDefined()
expect(starterBlock?.metadata?.id).toBe('starter')
expect(starterBlock?.config.tool).toBe('starter')
expect(starterBlock?.config.params.description).toBe('This is the starter block')
// Check agent block
const agentBlock = serialized.blocks.find((b) => b.id === 'agent1')
expect(agentBlock).toBeDefined()
expect(agentBlock?.metadata?.id).toBe('agent')
expect(agentBlock?.config.params.prompt).toBe('Hello, world!')
expect(agentBlock?.config.params.model).toBe('claude-3-7-sonnet-20250219')
// Check if edges are correctly serialized
expect(serialized.connections).toHaveLength(1)
expect(serialized.connections[0].source).toBe('starter')
expect(serialized.connections[0].target).toBe('agent1')
})
test('should serialize a conditional workflow correctly', () => {
const { blocks, edges, loops } = createConditionalWorkflowState()
const serializer = new Serializer()
const serialized = serializer.serializeWorkflow(blocks, edges, loops)
// Check blocks
expect(serialized.blocks).toHaveLength(4)
// Check condition block
const conditionBlock = serialized.blocks.find((b) => b.id === 'condition1')
expect(conditionBlock).toBeDefined()
expect(conditionBlock?.metadata?.id).toBe('condition')
expect(conditionBlock?.config.tool).toBe('condition')
expect(conditionBlock?.config.params.condition).toBe('input.value > 10')
// Check connections with handles
expect(serialized.connections).toHaveLength(3)
const truePathConnection = serialized.connections.find(
(c) => c.source === 'condition1' && c.sourceHandle === 'condition-true'
)
expect(truePathConnection).toBeDefined()
expect(truePathConnection?.target).toBe('agent1')
const falsePathConnection = serialized.connections.find(
(c) => c.source === 'condition1' && c.sourceHandle === 'condition-false'
)
expect(falsePathConnection).toBeDefined()
expect(falsePathConnection?.target).toBe('agent2')
})
test('should serialize a workflow with loops correctly', () => {
const { blocks, edges, loops } = createLoopWorkflowState()
const serializer = new Serializer()
const serialized = serializer.serializeWorkflow(blocks, edges, loops)
// Check loops
expect(Object.keys(serialized.loops)).toHaveLength(1)
expect(serialized.loops.loop1).toBeDefined()
expect(serialized.loops.loop1.nodes).toContain('function1')
expect(serialized.loops.loop1.nodes).toContain('condition1')
expect(serialized.loops.loop1.iterations).toBe(10)
// Check connections for loop
const loopBackConnection = serialized.connections.find(
(c) => c.source === 'condition1' && c.target === 'function1'
)
expect(loopBackConnection).toBeDefined()
expect(loopBackConnection?.sourceHandle).toBe('condition-true')
})
test('should serialize a complex workflow with multiple block types', () => {
const { blocks, edges, loops } = createComplexWorkflowState()
const serializer = new Serializer()
const serialized = serializer.serializeWorkflow(blocks, edges, loops)
// Check all blocks
expect(serialized.blocks).toHaveLength(4)
// Check API block
const apiBlock = serialized.blocks.find((b) => b.id === 'api1')
expect(apiBlock).toBeDefined()
expect(apiBlock?.metadata?.id).toBe('api')
expect(apiBlock?.config.tool).toBe('api')
expect(apiBlock?.config.params.url).toBe('https://api.example.com/data')
expect(apiBlock?.config.params.method).toBe('GET')
expect(apiBlock?.config.params.headers).toEqual([
['Content-Type', 'application/json'],
['Authorization', 'Bearer {{API_KEY}}'],
])
// Check function block
const functionBlock = serialized.blocks.find((b) => b.id === 'function1')
expect(functionBlock).toBeDefined()
expect(functionBlock?.metadata?.id).toBe('function')
expect(functionBlock?.config.tool).toBe('function')
expect(functionBlock?.config.params.language).toBe('javascript')
// Check agent block with response format
const agentBlock = serialized.blocks.find((b) => b.id === 'agent1')
expect(agentBlock).toBeDefined()
expect(agentBlock?.metadata?.id).toBe('agent')
expect(agentBlock?.config.tool).toBe('openai')
expect(agentBlock?.config.params.model).toBe('gpt-4o')
expect(agentBlock?.outputs.responseFormat).toBeDefined()
})
test('should serialize agent block with custom tools correctly', () => {
const { blocks, edges, loops } = createAgentWithToolsWorkflowState()
const serializer = new Serializer()
const serialized = serializer.serializeWorkflow(blocks, edges, loops)
// Check agent block
const agentBlock = serialized.blocks.find((b) => b.id === 'agent1')
expect(agentBlock).toBeDefined()
// The model used is 'gpt-4o', so tool should be 'openai'
expect(agentBlock?.config.tool).toBe('openai')
expect(agentBlock?.config.params.model).toBe('gpt-4o')
// Tools should be preserved as-is in params
const toolsParam = agentBlock?.config.params.tools
expect(toolsParam).toBeDefined()
// Parse tools to verify content
const tools = JSON.parse(toolsParam)
expect(tools).toHaveLength(2)
// Check custom tool
const customTool = tools.find((t: any) => t.type === 'custom-tool')
expect(customTool).toBeDefined()
expect(customTool.name).toBe('weather')
// Check function tool
const functionTool = tools.find((t: any) => t.type === 'function')
expect(functionTool).toBeDefined()
expect(functionTool.name).toBe('calculator')
})
test('should handle invalid block types gracefully', () => {
const { blocks, edges, loops } = createInvalidWorkflowState()
const serializer = new Serializer()
// Should throw an error when serializing an invalid block type
expect(() => serializer.serializeWorkflow(blocks, edges, loops)).toThrow(
'Invalid block type: invalid-type'
)
})
})
/**
* Deserialization tests
*/
describe('deserializeWorkflow', () => {
test('should deserialize a serialized workflow correctly', () => {
const { blocks, edges, loops } = createMinimalWorkflowState()
const serializer = new Serializer()
// First serialize
const serialized = serializer.serializeWorkflow(blocks, edges, loops)
// Then deserialize
const deserialized = serializer.deserializeWorkflow(serialized)
// Check blocks
expect(Object.keys(deserialized.blocks)).toHaveLength(2)
// Check starter block
const starterBlock = deserialized.blocks.starter
expect(starterBlock).toBeDefined()
expect(starterBlock.type).toBe('starter')
expect(starterBlock.name).toBe('Starter Block')
expect(starterBlock.subBlocks.description.value).toBe('This is the starter block')
// Check agent block
const agentBlock = deserialized.blocks.agent1
expect(agentBlock).toBeDefined()
expect(agentBlock.type).toBe('agent')
expect(agentBlock.name).toBe('Agent Block')
expect(agentBlock.subBlocks.prompt.value).toBe('Hello, world!')
expect(agentBlock.subBlocks.model.value).toBe('claude-3-7-sonnet-20250219')
// Check edges
expect(deserialized.edges).toHaveLength(1)
expect(deserialized.edges[0].source).toBe('starter')
expect(deserialized.edges[0].target).toBe('agent1')
})
test('should deserialize a complex workflow with all block types', () => {
const { blocks, edges, loops } = createComplexWorkflowState()
const serializer = new Serializer()
// First serialize
const serialized = serializer.serializeWorkflow(blocks, edges, loops)
// Then deserialize
const deserialized = serializer.deserializeWorkflow(serialized)
// Check all blocks are deserialized
expect(Object.keys(deserialized.blocks)).toHaveLength(4)
// Check API block
const apiBlock = deserialized.blocks.api1
expect(apiBlock).toBeDefined()
expect(apiBlock.type).toBe('api')
expect(apiBlock.subBlocks.url.value).toBe('https://api.example.com/data')
expect(apiBlock.subBlocks.method.value).toBe('GET')
expect(apiBlock.subBlocks.headers.value).toEqual([
['Content-Type', 'application/json'],
['Authorization', 'Bearer {{API_KEY}}'],
])
// Check function block
const functionBlock = deserialized.blocks.function1
expect(functionBlock).toBeDefined()
expect(functionBlock.type).toBe('function')
expect(functionBlock.subBlocks.language.value).toBe('javascript')
// Check agent block
const agentBlock = deserialized.blocks.agent1
expect(agentBlock).toBeDefined()
expect(agentBlock.type).toBe('agent')
expect(agentBlock.subBlocks.model.value).toBe('gpt-4o')
expect(agentBlock.subBlocks.provider.value).toBe('openai')
})
test('should handle serialized workflow with invalid block metadata', () => {
const invalidWorkflow = createInvalidSerializedWorkflow() as SerializedWorkflow
const serializer = new Serializer()
// Should throw an error when deserializing an invalid block type
expect(() => serializer.deserializeWorkflow(invalidWorkflow)).toThrow(
'Invalid block type: non-existent-type'
)
})
test('should handle serialized workflow with missing metadata', () => {
const invalidWorkflow = createMissingMetadataWorkflow() as SerializedWorkflow
const serializer = new Serializer()
// Should throw an error when deserializing with missing metadata
expect(() => serializer.deserializeWorkflow(invalidWorkflow)).toThrow()
})
})
/**
* End-to-end serialization/deserialization tests
*/
describe('round-trip serialization', () => {
test('should preserve all data through serialization and deserialization', () => {
const { blocks, edges, loops } = createComplexWorkflowState()
const serializer = new Serializer()
// Serialize
const serialized = serializer.serializeWorkflow(blocks, edges, loops)
// Deserialize
const deserialized = serializer.deserializeWorkflow(serialized)
// Re-serialize to check for consistency
const reserialized = serializer.serializeWorkflow(
deserialized.blocks,
deserialized.edges,
loops
)
// Compare the two serialized versions
expect(reserialized.blocks.length).toBe(serialized.blocks.length)
expect(reserialized.connections.length).toBe(serialized.connections.length)
// Check blocks by ID
serialized.blocks.forEach((originalBlock) => {
const reserializedBlock = reserialized.blocks.find((b) => b.id === originalBlock.id)
expect(reserializedBlock).toBeDefined()
expect(reserializedBlock?.config.tool).toBe(originalBlock.config.tool)
expect(reserializedBlock?.metadata?.id).toBe(originalBlock.metadata?.id)
// Check params - we only check a subset because some default values might be added
Object.entries(originalBlock.config.params).forEach(([key, value]) => {
if (value !== null) {
expect(reserializedBlock?.config.params[key]).toEqual(value)
}
})
})
// Check connections
expect(reserialized.connections).toEqual(serialized.connections)
// Check loops
expect(reserialized.loops).toEqual(serialized.loops)
})
})
})

View File

@@ -0,0 +1,183 @@
import { Edge } from 'reactflow'
import { createLogger } from '@/lib/logs/console-logger'
import { BlockState, Loop } from '@/stores/workflows/workflow/types'
import { getBlock } from '@/blocks'
import { SerializedBlock, SerializedWorkflow } from './types'
const logger = createLogger('Serializer')
export class Serializer {
serializeWorkflow(
blocks: Record<string, BlockState>,
edges: Edge[],
loops: Record<string, Loop>
): SerializedWorkflow {
return {
version: '1.0',
blocks: Object.values(blocks).map((block) => this.serializeBlock(block)),
connections: edges.map((edge) => ({
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle || undefined,
targetHandle: edge.targetHandle || undefined,
})),
loops,
}
}
private serializeBlock(block: BlockState): SerializedBlock {
const blockConfig = getBlock(block.type)
if (!blockConfig) {
throw new Error(`Invalid block type: ${block.type}`)
}
// Check if this is an agent block with custom tools
const params = this.extractParams(block)
let toolId = ''
if (block.type === 'agent' && params.tools) {
// Process the tools in the agent block
try {
const tools = Array.isArray(params.tools) ? params.tools : JSON.parse(params.tools)
// If there are custom tools, we just keep them as is
// They'll be handled by the executor during runtime
// For non-custom tools, we determine the tool ID
const nonCustomTools = tools.filter((tool: any) => tool.type !== 'custom-tool')
if (nonCustomTools.length > 0) {
toolId = blockConfig.tools.config?.tool
? blockConfig.tools.config.tool(params)
: blockConfig.tools.access[0]
}
} catch (error) {
logger.error('Error processing tools in agent block:', { error })
// Default to the first tool if we can't process tools
toolId = blockConfig.tools.access[0]
}
} else {
// For non-agent blocks, get tool ID from block config as usual
toolId = blockConfig.tools.config?.tool
? blockConfig.tools.config.tool(params)
: blockConfig.tools.access[0]
}
// Get inputs from block config
const inputs: Record<string, any> = {}
if (blockConfig.inputs) {
Object.entries(blockConfig.inputs).forEach(([key, config]) => {
inputs[key] = config.type
})
}
return {
id: block.id,
position: block.position,
config: {
tool: toolId,
params,
},
inputs,
outputs: {
...block.outputs,
// Include response format fields if available
...(params.responseFormat
? {
responseFormat: JSON.parse(params.responseFormat),
}
: {}),
},
metadata: {
id: block.type,
name: block.name,
description: blockConfig.description,
category: blockConfig.category,
color: blockConfig.bgColor,
},
enabled: block.enabled,
}
}
private extractParams(block: BlockState): Record<string, any> {
const blockConfig = getBlock(block.type)
if (!blockConfig) {
throw new Error(`Invalid block type: ${block.type}`)
}
const params: Record<string, any> = {}
// First collect all current values from subBlocks
Object.entries(block.subBlocks).forEach(([id, subBlock]) => {
params[id] = subBlock.value
})
// Then check for any subBlocks with default values
blockConfig.subBlocks.forEach((subBlockConfig) => {
const id = subBlockConfig.id
if (params[id] === null && subBlockConfig.value) {
// If the value is null and there's a default value function, use it
params[id] = subBlockConfig.value(params)
}
})
return params
}
deserializeWorkflow(workflow: SerializedWorkflow): {
blocks: Record<string, BlockState>
edges: Edge[]
} {
const blocks: Record<string, BlockState> = {}
const edges: Edge[] = []
// Deserialize blocks
workflow.blocks.forEach((serializedBlock) => {
const block = this.deserializeBlock(serializedBlock)
blocks[block.id] = block
})
// Deserialize connections
workflow.connections.forEach((connection) => {
edges.push({
id: crypto.randomUUID(),
source: connection.source,
target: connection.target,
sourceHandle: connection.sourceHandle,
targetHandle: connection.targetHandle,
})
})
return { blocks, edges }
}
private deserializeBlock(serializedBlock: SerializedBlock): BlockState {
const blockType = serializedBlock.metadata?.id
if (!blockType) {
throw new Error(`Invalid block type: ${serializedBlock.metadata?.id}`)
}
const blockConfig = getBlock(blockType)
if (!blockConfig) {
throw new Error(`Invalid block type: ${blockType}`)
}
const subBlocks: Record<string, any> = {}
blockConfig.subBlocks.forEach((subBlock) => {
subBlocks[subBlock.id] = {
id: subBlock.id,
type: subBlock.type,
value: serializedBlock.config.params[subBlock.id] ?? null,
}
})
return {
id: serializedBlock.id,
type: blockType,
name: serializedBlock.metadata?.name || blockConfig.name,
position: serializedBlock.position,
subBlocks,
outputs: serializedBlock.outputs,
enabled: true,
}
}
}

View File

@@ -0,0 +1,48 @@
import { Position } from '@/stores/workflows/workflow/types'
import { BlockOutput, ParamType } from '@/blocks/types'
export interface SerializedWorkflow {
version: string
blocks: SerializedBlock[]
connections: SerializedConnection[]
loops: Record<string, SerializedLoop>
}
export interface SerializedConnection {
source: string
target: string
sourceHandle?: string
targetHandle?: string
condition?: {
type: 'if' | 'else' | 'else if'
expression?: string // JavaScript expression to evaluate
}
}
export interface SerializedBlock {
id: string
position: Position
config: {
tool: string
params: Record<string, any>
}
inputs: Record<string, ParamType>
outputs: Record<string, BlockOutput>
metadata?: {
id: string
name?: string
description?: string
category?: string
icon?: string
color?: string
}
enabled: boolean
}
export interface SerializedLoop {
id: string
nodes: string[]
iterations: number
loopType?: 'for' | 'forEach' | 'while'
forEachItems?: any[] | Record<string, any> | string // Items to iterate over or expression to evaluate
}