mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-19 02:34:37 -05:00
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:
662
apps/sim/serializer/__test-utils__/test-workflows.ts
Normal file
662
apps/sim/serializer/__test-utils__/test-workflows.ts
Normal 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: {},
|
||||
}
|
||||
}
|
||||
449
apps/sim/serializer/index.test.ts
Normal file
449
apps/sim/serializer/index.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
183
apps/sim/serializer/index.ts
Normal file
183
apps/sim/serializer/index.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
48
apps/sim/serializer/types.ts
Normal file
48
apps/sim/serializer/types.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user