chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,138 @@
/**
* @vitest-environment node
*
* Custom-block lifecycle behavior in the serializer:
* - a deleted custom block (its type no longer resolves) is dropped like a removed
* block, with its edges, instead of throwing `Invalid block type` (Bug 2);
* - a deleted *input* on a live custom block no longer leaks its stale value into
* the child `inputMapping` (Bug 1).
*/
import { toolsUtilsMock } from '@sim/testing/mocks'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Build the custom-block configs INSIDE the factory (hoisted) so no top-level
// variable is referenced before initialization. `custom_block_live` declares one
// input (`title`); `custom_block_legacy` declares none (schema-agnostic);
// `custom_block_deleted` is absent, so getBlock returns null for it.
vi.mock('@/blocks', async () => {
const { buildCustomBlockConfig } = await import('@/blocks/custom/build-config')
const { createMockGetBlock, mockBlockConfigs } = await import('@sim/testing/mocks')
const icon = () => null
const getBlock = createMockGetBlock({
custom_block_live: buildCustomBlockConfig(
{ type: 'custom_block_live', name: 'Live', description: '', workflowId: 'wf-1' },
[{ id: 'title', name: 'title', type: 'string' }],
{ icon: icon as never }
),
custom_block_legacy: buildCustomBlockConfig(
{ type: 'custom_block_legacy', name: 'Legacy', description: '', workflowId: 'wf-2' },
[],
{ icon: icon as never }
),
})
return { getBlock, getAllBlocks: () => Object.values(mockBlockConfigs) }
})
vi.mock('@/tools/utils', () => toolsUtilsMock)
import { extractBlockParams, Serializer } from '@/serializer/index'
function customBlockState(type: string, fieldValues: Record<string, unknown>) {
return {
id: 'cb1',
type,
name: 'CB',
position: { x: 0, y: 0 },
enabled: true,
subBlocks: {
workflowId: { id: 'workflowId', type: 'short-input', value: null },
inputMapping: { id: 'inputMapping', type: 'code', value: null },
...Object.fromEntries(
Object.entries(fieldValues).map(([id, value]) => [id, { id, type: 'short-input', value }])
),
},
outputs: {},
data: {},
} as any
}
describe('custom-block serializer lifecycle', () => {
beforeEach(() => vi.clearAllMocks())
describe('Bug 1: deleted input does not leak into inputMapping', () => {
it('drops a stored value whose input was removed from a config that declares its inputs', () => {
const params = extractBlockParams(
customBlockState('custom_block_live', { title: 'Acme', firstName: 'Theodore' })
)
const mapping = JSON.parse(params.inputMapping)
expect(mapping).toEqual({ title: 'Acme' })
expect(mapping.firstName).toBeUndefined()
})
it('still carries every stored value for a schema-agnostic (legacy) custom block', () => {
const params = extractBlockParams(
customBlockState('custom_block_legacy', { title: 'Acme', firstName: 'Theodore' })
)
expect(JSON.parse(params.inputMapping)).toEqual({ title: 'Acme', firstName: 'Theodore' })
})
})
describe('Bug 2: a deleted custom block is dropped, not fatal', () => {
it('drops an unresolvable custom block and its edges on serialize', () => {
const blocks = {
starter: {
id: 'starter',
type: 'starter',
name: 'Start',
position: { x: 0, y: 0 },
enabled: true,
subBlocks: {},
outputs: {},
data: {},
},
cb1: customBlockState('custom_block_deleted', { title: 'x' }),
} as any
const edges = [
{ id: 'e1', source: 'starter', target: 'cb1', sourceHandle: null, targetHandle: null },
] as any
const serialized = new Serializer().serializeWorkflow(blocks, edges, {}, {})
expect(serialized.blocks.map((b) => b.id)).toEqual(['starter'])
expect(serialized.connections).toHaveLength(0)
})
it('drops an unresolvable custom block and its edges on deserialize', () => {
const wire = {
version: '1.0',
blocks: [
{
id: 'starter',
position: { x: 0, y: 0 },
config: { tool: 'starter', params: {} },
inputs: {},
outputs: {},
enabled: true,
metadata: { id: 'starter', name: 'Start' },
},
{
id: 'cb1',
position: { x: 0, y: 0 },
config: { tool: 'workflow_executor', params: {} },
inputs: {},
outputs: {},
enabled: true,
metadata: { id: 'custom_block_deleted', name: 'CB' },
},
],
connections: [{ source: 'starter', target: 'cb1' }],
loops: {},
parallels: {},
} as any
const { blocks, edges } = new Serializer().deserializeWorkflow(wire)
expect(Object.keys(blocks)).toEqual(['starter'])
expect(edges).toHaveLength(0)
})
})
})
+216
View File
@@ -0,0 +1,216 @@
/**
* @vitest-environment node
*
* Tests for the exported field-analysis helpers in serializer/index.ts
* (collectBlockFieldIssues / extractBlockParams) — the single source of truth
* shared by the serializer's required-field validation and the copilot lint.
*/
import { blocksMock, toolsUtilsMock } from '@sim/testing/mocks'
import { describe, expect, it, vi } from 'vitest'
const { svcConfig } = vi.hoisted(() => ({ svcConfig: { value: null as any } }))
vi.mock('@/tools/utils', () => toolsUtilsMock)
vi.mock('@/blocks', () => ({
...blocksMock,
getBlock: (type: string) => (type === 'svc' ? svcConfig.value : blocksMock.getBlock(type)),
}))
import { collectBlockFieldIssues, extractBlockParams } from '@/serializer/index'
function block(overrides: Record<string, any> = {}) {
return {
id: 'b1',
type: 'x',
name: 'My Block',
enabled: true,
position: { x: 0, y: 0 },
subBlocks: {},
outputs: {},
data: {},
...overrides,
} as any
}
function config(subBlocks: any[], overrides: Record<string, any> = {}) {
return {
name: 'X',
category: 'tools',
tools: { access: [] },
subBlocks,
...overrides,
} as any
}
describe('collectBlockFieldIssues', () => {
it('reports a missing required field (active mode empty)', () => {
const cfg = config([{ id: 'apiKey', title: 'API Key', type: 'short-input', required: true }])
const issues = collectBlockFieldIssues(block({ subBlocks: { apiKey: { value: '' } } }), cfg, {})
expect(issues.missingRequiredFields).toEqual(['API Key'])
expect(issues.inactiveModeValues).toEqual([])
})
it('does not report a required field that is set', () => {
const cfg = config([{ id: 'apiKey', title: 'API Key', type: 'short-input', required: true }])
const issues = collectBlockFieldIssues(
block({ subBlocks: { apiKey: { value: 'sk-123' } } }),
cfg,
{ apiKey: 'sk-123' }
)
expect(issues.missingRequiredFields).toEqual([])
})
it('skips disabled blocks', () => {
const cfg = config([{ id: 'apiKey', title: 'API Key', type: 'short-input', required: true }])
const issues = collectBlockFieldIssues(block({ enabled: false }), cfg, {})
expect(issues).toEqual({ missingRequiredFields: [], inactiveModeValues: [] })
})
it('skips trigger-mode blocks', () => {
const cfg = config([{ id: 'apiKey', title: 'API Key', type: 'short-input', required: true }])
const issues = collectBlockFieldIssues(block({ triggerMode: true }), cfg, {})
expect(issues).toEqual({ missingRequiredFields: [], inactiveModeValues: [] })
})
it('only flags a condition-gated required field when its condition is met', () => {
const cfg = config([
{ id: 'mode', type: 'dropdown' },
{
id: 'topic',
title: 'Topic',
type: 'short-input',
required: { field: 'mode', value: 'advanced' },
},
])
const inactive = collectBlockFieldIssues(
block({ subBlocks: { mode: { value: 'basic' } } }),
cfg,
{ mode: 'basic' }
)
expect(inactive.missingRequiredFields).toEqual([])
const active = collectBlockFieldIssues(
block({ subBlocks: { mode: { value: 'advanced' } } }),
cfg,
{ mode: 'advanced' }
)
expect(active.missingRequiredFields).toEqual(['Topic'])
})
it('flags a credential value stranded on the inactive member', () => {
const cfg = config([
{
id: 'credential',
title: 'Account',
type: 'oauth-input',
canonicalParamId: 'cred',
mode: 'basic',
required: true,
},
{
id: 'manualCredential',
title: 'Account',
type: 'short-input',
canonicalParamId: 'cred',
mode: 'advanced',
required: true,
},
])
// canonicalModes forces 'basic', but the value lives on the advanced member.
const issues = collectBlockFieldIssues(
block({
advancedMode: false,
data: { canonicalModes: { cred: 'basic' } },
subBlocks: { credential: { value: '' }, manualCredential: { value: 'cred_123' } },
}),
cfg,
{ cred: '' }
)
expect(issues.inactiveModeValues).toEqual([
{
canonicalId: 'cred',
activeMemberId: 'credential',
inactiveMemberId: 'manualCredential',
kind: 'credential',
},
])
// The active (basic) member is empty + required -> also a missing field.
expect(issues.missingRequiredFields).toEqual(['Account'])
})
it('does not flag a credential value on the correct active member', () => {
const cfg = config([
{
id: 'credential',
title: 'Account',
type: 'oauth-input',
canonicalParamId: 'cred',
mode: 'basic',
required: true,
},
{
id: 'manualCredential',
title: 'Account',
type: 'short-input',
canonicalParamId: 'cred',
mode: 'advanced',
required: true,
},
])
// No override: an empty basic + filled advanced resolves to 'advanced', so
// the value is on the active member and nothing is stranded.
const issues = collectBlockFieldIssues(
block({
advancedMode: false,
subBlocks: { credential: { value: '' }, manualCredential: { value: 'cred_123' } },
}),
cfg,
{ cred: 'cred_123' }
)
expect(issues.inactiveModeValues).toEqual([])
expect(issues.missingRequiredFields).toEqual([])
})
})
describe('extractBlockParams', () => {
it('returns {} for subflow containers', () => {
expect(extractBlockParams(block({ type: 'loop' }))).toEqual({})
expect(extractBlockParams(block({ type: 'parallel' }))).toEqual({})
})
it('resolves a canonical pair to its canonical id (advanced value wins when basic empty)', () => {
svcConfig.value = config([
{
id: 'credential',
title: 'Account',
type: 'oauth-input',
canonicalParamId: 'cred',
mode: 'basic',
},
{
id: 'manualCredential',
title: 'Account',
type: 'short-input',
canonicalParamId: 'cred',
mode: 'advanced',
},
])
const params = extractBlockParams(
block({
type: 'svc',
advancedMode: false,
subBlocks: { credential: { value: '' }, manualCredential: { value: 'cred_123' } },
})
)
expect(params.cred).toBe('cred_123')
expect(params.credential).toBeUndefined()
expect(params.manualCredential).toBeUndefined()
})
})
+889
View File
@@ -0,0 +1,889 @@
/**
* @vitest-environment node
*
* 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 {
createAgentWithToolsWorkflowState,
createComplexWorkflowState,
createConditionalWorkflowState,
createInvalidSerializedWorkflow,
createInvalidWorkflowState,
createLoopWorkflowState,
createMinimalWorkflowState,
createMissingMetadataWorkflow,
} from '@sim/testing/factories'
import { blocksMock, toolsUtilsMock } from '@sim/testing/mocks'
import { describe, expect, it, vi } from 'vitest'
import { Serializer } from '@/serializer/index'
import type { SerializedWorkflow } from '@/serializer/types'
vi.mock('@/blocks', () => blocksMock)
vi.mock('@/tools/utils', () => toolsUtilsMock)
describe('Serializer', () => {
describe('serializeWorkflow', () => {
it.concurrent('should serialize a minimal workflow correctly', () => {
const { blocks, edges, loops } = createMinimalWorkflowState()
const serializer = new Serializer()
const serialized = serializer.serializeWorkflow(blocks, edges, loops)
expect(serialized.blocks).toHaveLength(2)
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')
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')
expect(serialized.connections).toHaveLength(1)
expect(serialized.connections[0].source).toBe('starter')
expect(serialized.connections[0].target).toBe('agent1')
})
it.concurrent('should serialize a conditional workflow correctly', () => {
const { blocks, edges, loops } = createConditionalWorkflowState()
const serializer = new Serializer()
const serialized = serializer.serializeWorkflow(blocks, edges, loops)
expect(serialized.blocks).toHaveLength(4)
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')
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')
})
it.concurrent('should serialize a workflow with loops correctly', () => {
const { blocks, edges, loops } = createLoopWorkflowState()
const serializer = new Serializer()
const serialized = serializer.serializeWorkflow(blocks, edges, 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)
const loopBackConnection = serialized.connections.find(
(c) => c.source === 'condition1' && c.target === 'function1'
)
expect(loopBackConnection).toBeDefined()
expect(loopBackConnection?.sourceHandle).toBe('condition-true')
})
it.concurrent('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)
expect(serialized.blocks).toHaveLength(4)
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}}'],
])
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')
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')
})
it.concurrent('should serialize agent block with custom tools correctly', () => {
const { blocks, edges, loops } = createAgentWithToolsWorkflowState()
const serializer = new Serializer()
const serialized = serializer.serializeWorkflow(blocks, edges, loops)
const agentBlock = serialized.blocks.find((b) => b.id === 'agent1')
expect(agentBlock).toBeDefined()
expect(agentBlock?.config.tool).toBe('openai')
expect(agentBlock?.config.params.model).toBe('gpt-4o')
const toolsParam = agentBlock?.config.params.tools
expect(toolsParam).toBeDefined()
const tools = JSON.parse(toolsParam as string)
expect(tools).toHaveLength(2)
const customTool = tools.find((t: any) => t.type === 'custom-tool')
expect(customTool).toBeDefined()
expect(customTool.name).toBe('weather')
const functionTool = tools.find((t: any) => t.type === 'function')
expect(functionTool).toBeDefined()
expect(functionTool.name).toBe('calculator')
})
it.concurrent('should handle invalid block types gracefully', () => {
const { blocks, edges, loops } = createInvalidWorkflowState()
const serializer = new Serializer()
expect(() => serializer.serializeWorkflow(blocks, edges, loops)).toThrow(
'Invalid block type: invalid-type'
)
})
})
describe('deserializeWorkflow', () => {
it.concurrent('should deserialize a serialized workflow correctly', () => {
const { blocks, edges, loops } = createMinimalWorkflowState()
const serializer = new Serializer()
const serialized = serializer.serializeWorkflow(blocks, edges, loops)
const deserialized = serializer.deserializeWorkflow(serialized)
expect(Object.keys(deserialized.blocks)).toHaveLength(2)
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')
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')
expect(deserialized.edges).toHaveLength(1)
expect(deserialized.edges[0].source).toBe('starter')
expect(deserialized.edges[0].target).toBe('agent1')
})
it.concurrent('should deserialize a complex workflow with all block types', () => {
const { blocks, edges, loops } = createComplexWorkflowState()
const serializer = new Serializer()
const serialized = serializer.serializeWorkflow(blocks, edges, loops)
const deserialized = serializer.deserializeWorkflow(serialized)
expect(Object.keys(deserialized.blocks)).toHaveLength(4)
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}}'],
])
const functionBlock = deserialized.blocks.function1
expect(functionBlock).toBeDefined()
expect(functionBlock.type).toBe('function')
expect(functionBlock.subBlocks.language.value).toBe('javascript')
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')
})
it.concurrent('should handle serialized workflow with invalid block metadata', () => {
const invalidWorkflow = createInvalidSerializedWorkflow() as SerializedWorkflow
const serializer = new Serializer()
expect(() => serializer.deserializeWorkflow(invalidWorkflow)).toThrow(
'Invalid block type: non-existent-type'
)
})
it.concurrent('should handle serialized workflow with missing metadata', () => {
const invalidWorkflow = createMissingMetadataWorkflow() as SerializedWorkflow
const serializer = new Serializer()
expect(() => serializer.deserializeWorkflow(invalidWorkflow)).toThrow()
})
})
describe('round-trip serialization', () => {
it.concurrent('should preserve all data through serialization and deserialization', () => {
const { blocks, edges, loops } = createComplexWorkflowState()
const serializer = new Serializer()
const serialized = serializer.serializeWorkflow(blocks, edges, loops)
const deserialized = serializer.deserializeWorkflow(serialized)
const reserialized = serializer.serializeWorkflow(
deserialized.blocks,
deserialized.edges,
loops
)
expect(reserialized.blocks.length).toBe(serialized.blocks.length)
expect(reserialized.connections.length).toBe(serialized.connections.length)
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)
Object.entries(originalBlock.config.params).forEach(([key, value]) => {
if (value !== null) {
expect(reserializedBlock?.config.params[key]).toEqual(value)
}
})
})
expect(reserialized.connections).toEqual(serialized.connections)
expect(reserialized.loops).toEqual(serialized.loops)
})
})
describe('validation during serialization', () => {
it.concurrent('should throw error for missing user-only required fields', () => {
const serializer = new Serializer()
const blockWithMissingUserOnlyField: any = {
id: 'test-block',
type: 'jina',
name: 'Test Jina Block',
position: { x: 0, y: 0 },
subBlocks: {
url: { value: 'https://example.com' },
apiKey: { value: null },
},
outputs: {},
enabled: true,
}
expect(() => {
serializer.serializeWorkflow(
{ 'test-block': blockWithMissingUserOnlyField },
[],
{},
undefined,
true
)
}).toThrow('Test Jina Block is missing required fields: API Key')
})
it.concurrent('should skip validation for disabled blocks', () => {
const serializer = new Serializer()
const disabledBlockWithMissingField: any = {
id: 'test-block',
type: 'jina',
name: 'Disabled Jina Block',
position: { x: 0, y: 0 },
subBlocks: {
url: { value: 'https://example.com' },
apiKey: { value: null },
},
outputs: {},
enabled: false,
}
expect(() => {
serializer.serializeWorkflow(
{ 'test-block': disabledBlockWithMissingField },
[],
{},
undefined,
true
)
}).not.toThrow()
})
it.concurrent('should not throw error when all user-only required fields are present', () => {
const serializer = new Serializer()
const blockWithAllUserOnlyFields: any = {
id: 'test-block',
type: 'jina',
name: 'Test Jina Block',
position: { x: 0, y: 0 },
subBlocks: {
url: { value: 'https://example.com' },
apiKey: { value: 'test-api-key' },
},
outputs: {},
enabled: true,
}
expect(() => {
serializer.serializeWorkflow(
{ 'test-block': blockWithAllUserOnlyFields },
[],
{},
undefined,
true
)
}).not.toThrow()
})
it.concurrent('should not validate user-or-llm fields during serialization', () => {
const serializer = new Serializer()
const blockWithMissingUserOrLlmField: any = {
id: 'test-block',
type: 'reddit',
name: 'Test Reddit Block',
position: { x: 0, y: 0 },
subBlocks: {
operation: { value: 'get_posts' },
credential: { value: 'test-credential' },
subreddit: { value: null },
},
outputs: {},
enabled: true,
}
expect(() => {
serializer.serializeWorkflow(
{ 'test-block': blockWithMissingUserOrLlmField },
[],
{},
undefined,
true
)
}).not.toThrow()
})
it.concurrent('should not validate when validateRequired is false', () => {
const serializer = new Serializer()
const blockWithMissingField: any = {
id: 'test-block',
type: 'jina',
name: 'Test Jina Block',
position: { x: 0, y: 0 },
subBlocks: {
url: { value: 'https://example.com' },
apiKey: { value: null },
},
outputs: {},
enabled: true,
}
expect(() => {
serializer.serializeWorkflow({ 'test-block': blockWithMissingField }, [], {})
}).not.toThrow()
})
it.concurrent('should validate multiple user-only fields and report all missing', () => {
const serializer = new Serializer()
const blockWithMultipleMissing: any = {
id: 'test-block',
type: 'jina',
name: 'Test Jina Block',
position: { x: 0, y: 0 },
subBlocks: {
url: { value: null },
apiKey: { value: null },
},
outputs: {},
enabled: true,
}
expect(() => {
serializer.serializeWorkflow(
{ 'test-block': blockWithMultipleMissing },
[],
{},
undefined,
true
)
}).toThrow('Test Jina Block is missing required fields: API Key')
})
it.concurrent('should handle blocks with no tool configuration gracefully', () => {
const serializer = new Serializer()
const blockWithNoTools: any = {
id: 'test-block',
type: 'condition',
name: 'Test Condition Block',
position: { x: 0, y: 0 },
subBlocks: {
condition: { value: null },
},
outputs: {},
enabled: true,
}
expect(() => {
serializer.serializeWorkflow({ 'test-block': blockWithNoTools }, [], {}, undefined, true)
}).not.toThrow()
})
it.concurrent(
'should validate required fields for blocks without tools (empty tools.access)',
() => {
const serializer = new Serializer()
const waitBlockMissingRequired: any = {
id: 'wait-block',
type: 'wait',
name: 'Wait Block',
position: { x: 0, y: 0 },
subBlocks: {
timeValue: { value: '' },
timeUnit: { value: 'seconds' },
},
outputs: {},
enabled: true,
}
expect(() => {
serializer.serializeWorkflow(
{ 'wait-block': waitBlockMissingRequired },
[],
{},
undefined,
true
)
}).toThrow('Wait Block is missing required fields: Wait Amount')
}
)
it.concurrent(
'should pass validation for blocks without tools when required fields are present',
() => {
const serializer = new Serializer()
const waitBlockWithFields: any = {
id: 'wait-block',
type: 'wait',
name: 'Wait Block',
position: { x: 0, y: 0 },
subBlocks: {
timeValue: { value: '10' },
timeUnit: { value: 'seconds' },
},
outputs: {},
enabled: true,
}
expect(() => {
serializer.serializeWorkflow(
{ 'wait-block': waitBlockWithFields },
[],
{},
undefined,
true
)
}).not.toThrow()
}
)
it.concurrent('should report all missing required fields for blocks without tools', () => {
const serializer = new Serializer()
const waitBlockAllMissing: any = {
id: 'wait-block',
type: 'wait',
name: 'Wait Block',
position: { x: 0, y: 0 },
subBlocks: {
timeValue: { value: null },
timeUnit: { value: '' },
},
outputs: {},
enabled: true,
}
expect(() => {
serializer.serializeWorkflow({ 'wait-block': waitBlockAllMissing }, [], {}, undefined, true)
}).toThrow('Wait Block is missing required fields: Wait Amount, Unit')
})
it.concurrent('should skip validation for disabled blocks without tools', () => {
const serializer = new Serializer()
const disabledWaitBlock: any = {
id: 'wait-block',
type: 'wait',
name: 'Wait Block',
position: { x: 0, y: 0 },
subBlocks: {
timeValue: { value: null },
timeUnit: { value: null },
},
outputs: {},
enabled: false,
}
expect(() => {
serializer.serializeWorkflow({ 'wait-block': disabledWaitBlock }, [], {}, undefined, true)
}).not.toThrow()
})
it.concurrent('should handle empty string values as missing', () => {
const serializer = new Serializer()
const blockWithEmptyString: any = {
id: 'test-block',
type: 'jina',
name: 'Test Jina Block',
position: { x: 0, y: 0 },
subBlocks: {
url: { value: 'https://example.com' },
apiKey: { value: '' },
},
outputs: {},
enabled: true,
}
expect(() => {
serializer.serializeWorkflow(
{ 'test-block': blockWithEmptyString },
[],
{},
undefined,
true
)
}).toThrow('Test Jina Block is missing required fields: API Key')
})
it.concurrent('should only validate user-only fields, not user-or-llm fields', () => {
const serializer = new Serializer()
const mixedBlock: any = {
id: 'test-block',
type: 'reddit',
name: 'Test Reddit Block',
position: { x: 0, y: 0 },
subBlocks: {
operation: { value: 'get_posts' },
credential: { value: null },
subreddit: { value: null },
},
outputs: {},
enabled: true,
}
expect(() => {
serializer.serializeWorkflow({ 'test-block': mixedBlock }, [], {}, undefined, true)
}).toThrow('Test Reddit Block is missing required fields: Reddit Account')
})
})
describe('canonical mode field selection', () => {
it.concurrent('should use advanced value when canonicalModes specifies advanced', () => {
const serializer = new Serializer()
const block: any = {
id: 'slack-1',
type: 'slack',
name: 'Test Slack Block',
position: { x: 0, y: 0 },
data: {
canonicalModes: { channel: 'advanced' },
},
subBlocks: {
operation: { value: 'send' },
destinationType: { value: 'channel' },
channel: { value: 'general' },
manualChannel: { value: 'C1234567890' },
text: { value: 'Hello world' },
username: { value: 'bot' },
},
outputs: {},
enabled: true,
}
const serialized = serializer.serializeWorkflow({ 'slack-1': block }, [], {})
const slackBlock = serialized.blocks.find((b) => b.id === 'slack-1')
expect(slackBlock).toBeDefined()
expect(slackBlock?.config.params.channel).toBe('C1234567890')
expect(slackBlock?.config.params.manualChannel).toBeUndefined()
expect(slackBlock?.config.params.text).toBe('Hello world')
expect(slackBlock?.config.params.username).toBe('bot')
})
it.concurrent('should use basic value when canonicalModes specifies basic', () => {
const serializer = new Serializer()
const block: any = {
id: 'slack-1',
type: 'slack',
name: 'Test Slack Block',
position: { x: 0, y: 0 },
data: {
canonicalModes: { channel: 'basic' },
},
subBlocks: {
operation: { value: 'send' },
destinationType: { value: 'channel' },
channel: { value: 'general' },
manualChannel: { value: 'C1234567890' },
text: { value: 'Hello world' },
username: { value: 'bot' },
},
outputs: {},
enabled: true,
}
const serialized = serializer.serializeWorkflow({ 'slack-1': block }, [], {})
const slackBlock = serialized.blocks.find((b) => b.id === 'slack-1')
expect(slackBlock).toBeDefined()
expect(slackBlock?.config.params.channel).toBe('general')
expect(slackBlock?.config.params.manualChannel).toBeUndefined()
expect(slackBlock?.config.params.text).toBe('Hello world')
expect(slackBlock?.config.params.username).toBe('bot')
})
it.concurrent(
'should fall back to legacy advancedMode for non-credential canonical groups when canonicalModes not set',
() => {
const serializer = new Serializer()
const block: any = {
id: 'slack-1',
type: 'slack',
name: 'Test Slack Block',
position: { x: 0, y: 0 },
advancedMode: true,
subBlocks: {
operation: { value: 'send' },
destinationType: { value: 'channel' },
channel: { value: 'general' },
manualChannel: { value: 'C1234567890' },
text: { value: 'Hello world' },
username: { value: 'bot' },
},
outputs: {},
enabled: true,
}
const serialized = serializer.serializeWorkflow({ 'slack-1': block }, [], {})
const slackBlock = serialized.blocks.find((b) => b.id === 'slack-1')
expect(slackBlock).toBeDefined()
expect(slackBlock?.config.params.channel).toBe('C1234567890')
expect(slackBlock?.config.params.manualChannel).toBeUndefined()
}
)
it.concurrent('should use basic value by default when no mode specified', () => {
const serializer = new Serializer()
const block: any = {
id: 'slack-1',
type: 'slack',
name: 'Test Slack Block',
position: { x: 0, y: 0 },
subBlocks: {
operation: { value: 'send' },
destinationType: { value: 'channel' },
channel: { value: 'general' },
manualChannel: { value: 'C1234567890' },
text: { value: 'Hello world' },
username: { value: 'bot' },
},
outputs: {},
enabled: true,
}
const serialized = serializer.serializeWorkflow({ 'slack-1': block }, [], {})
const slackBlock = serialized.blocks.find((b) => b.id === 'slack-1')
expect(slackBlock).toBeDefined()
expect(slackBlock?.config.params.channel).toBe('general')
expect(slackBlock?.config.params.manualChannel).toBeUndefined()
})
it.concurrent('should preserve advanced-only values when present in basic mode', () => {
const serializer = new Serializer()
const agentInBasicMode: any = {
id: 'agent-1',
type: 'agentWithMemories',
name: 'Test Agent',
position: { x: 0, y: 0 },
advancedMode: false,
subBlocks: {
systemPrompt: { value: 'You are helpful' },
userPrompt: { value: 'Hello' },
memories: { value: [{ role: 'user', content: 'My name is John' }] },
model: { value: 'claude-3-sonnet' },
},
outputs: {},
enabled: true,
}
const serialized = serializer.serializeWorkflow({ 'agent-1': agentInBasicMode }, [], {})
const agentBlock = serialized.blocks.find((b) => b.id === 'agent-1')
expect(agentBlock).toBeDefined()
expect(agentBlock?.config.params.systemPrompt).toBe('You are helpful')
expect(agentBlock?.config.params.userPrompt).toBe('Hello')
expect(agentBlock?.config.params.memories).toEqual([
{ role: 'user', content: 'My name is John' },
])
expect(agentBlock?.config.params.model).toBe('claude-3-sonnet')
})
it.concurrent('should include memories field when agent is in advanced mode', () => {
const serializer = new Serializer()
const agentInAdvancedMode: any = {
id: 'agent-1',
type: 'agentWithMemories',
name: 'Test Agent',
position: { x: 0, y: 0 },
advancedMode: true,
subBlocks: {
systemPrompt: { value: 'You are helpful' },
userPrompt: { value: 'Hello' },
memories: { value: [{ role: 'user', content: 'My name is John' }] },
model: { value: 'claude-3-sonnet' },
},
outputs: {},
enabled: true,
}
const serialized = serializer.serializeWorkflow({ 'agent-1': agentInAdvancedMode }, [], {})
const agentBlock = serialized.blocks.find((b) => b.id === 'agent-1')
expect(agentBlock).toBeDefined()
expect(agentBlock?.config.params.systemPrompt).toBe('You are helpful')
expect(agentBlock?.config.params.userPrompt).toBe('Hello')
expect(agentBlock?.config.params.memories).toEqual([
{ role: 'user', content: 'My name is John' },
])
expect(agentBlock?.config.params.model).toBe('claude-3-sonnet')
})
it.concurrent('should handle blocks with no matching subblock config gracefully', () => {
const serializer = new Serializer()
const blockWithUnknownField: any = {
id: 'slack-1',
type: 'slack',
name: 'Test Slack Block',
position: { x: 0, y: 0 },
advancedMode: false,
subBlocks: {
channel: { value: 'general' },
unknownField: { value: 'someValue' },
text: { value: 'Hello world' },
},
outputs: {},
enabled: true,
}
const serialized = serializer.serializeWorkflow({ 'slack-1': blockWithUnknownField }, [], {})
const slackBlock = serialized.blocks.find((b) => b.id === 'slack-1')
expect(slackBlock).toBeDefined()
expect(slackBlock?.config.params.channel).toBe('general')
expect(slackBlock?.config.params.text).toBe('Hello world')
expect(slackBlock?.config.params.unknownField).toBeUndefined()
})
it.concurrent(
'should preserve legacy agent fields (systemPrompt, userPrompt, memories) for backward compatibility',
() => {
const serializer = new Serializer()
const legacyAgentBlock: any = {
id: 'agent-1',
type: 'agent',
name: 'Legacy Agent',
position: { x: 0, y: 0 },
advancedMode: false,
subBlocks: {
systemPrompt: {
id: 'systemPrompt',
type: 'long-input',
value: 'You are a helpful assistant.',
},
userPrompt: {
id: 'userPrompt',
type: 'long-input',
value: 'What is the weather today?',
},
memories: {
id: 'memories',
type: 'short-input',
value: [{ role: 'user', content: 'My name is Alice' }],
},
model: {
id: 'model',
type: 'combobox',
value: 'gpt-4',
},
},
outputs: {},
enabled: true,
}
const serialized = serializer.serializeWorkflow({ 'agent-1': legacyAgentBlock }, [], {})
const agentBlock = serialized.blocks.find((b) => b.id === 'agent-1')
expect(agentBlock).toBeDefined()
expect(agentBlock?.config.params.systemPrompt).toBe('You are a helpful assistant.')
expect(agentBlock?.config.params.userPrompt).toBe('What is the weather today?')
expect(agentBlock?.config.params.memories).toEqual([
{ role: 'user', content: 'My name is Alice' },
])
expect(agentBlock?.config.params.model).toBe('gpt-4')
}
)
})
})
+764
View File
@@ -0,0 +1,764 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import type { Edge } from 'reactflow'
import type { CanonicalModeOverrides } from '@/lib/workflows/subblocks/visibility'
import {
buildCanonicalIndex,
buildSubBlockValues,
evaluateSubBlockCondition,
getCanonicalValues,
isCanonicalPair,
isNonEmptyValue,
isSubBlockFeatureEnabled,
isSubBlockHidden,
resolveCanonicalMode,
} from '@/lib/workflows/subblocks/visibility'
import { getBlock } from '@/blocks'
import { isCustomBlockType, RESERVED_PARAMS } from '@/blocks/custom/build-config'
import type { SubBlockConfig } from '@/blocks/types'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
import { getTool } from '@/tools/utils'
const logger = createLogger('Serializer')
/**
* Structured validation error for pre-execution workflow validation
*/
export class WorkflowValidationError extends Error {
constructor(
message: string,
public blockId?: string,
public blockType?: string,
public blockName?: string
) {
super(message)
this.name = 'WorkflowValidationError'
}
}
/**
* Helper function to check if a subblock should be serialized.
*/
function shouldSerializeSubBlock(
subBlockConfig: SubBlockConfig,
values: Record<string, unknown>,
displayAdvancedOptions: boolean,
isTriggerContext: boolean,
isTriggerCategory: boolean,
canonicalIndex: ReturnType<typeof buildCanonicalIndex>,
canonicalModeOverrides?: CanonicalModeOverrides
): boolean {
if (!isSubBlockFeatureEnabled(subBlockConfig)) return false
if (isSubBlockHidden(subBlockConfig)) return false
if (subBlockConfig.mode === 'trigger') {
if (!isTriggerContext && !isTriggerCategory) return false
} else if (isTriggerContext && !isTriggerCategory) {
return false
}
const isCanonicalMember = Boolean(canonicalIndex.canonicalIdBySubBlockId[subBlockConfig.id])
if (isCanonicalMember) {
const canonicalId = canonicalIndex.canonicalIdBySubBlockId[subBlockConfig.id]
const group = canonicalId ? canonicalIndex.groupsById[canonicalId] : undefined
if (group && isCanonicalPair(group)) {
const mode =
canonicalModeOverrides?.[group.canonicalId] != null || !displayAdvancedOptions
? resolveCanonicalMode(group, values, canonicalModeOverrides)
: 'advanced'
const matchesMode =
mode === 'advanced'
? group.advancedIds.includes(subBlockConfig.id)
: group.basicId === subBlockConfig.id
return matchesMode && evaluateSubBlockCondition(subBlockConfig.condition, values)
}
return evaluateSubBlockCondition(subBlockConfig.condition, values)
}
if (subBlockConfig.mode === 'advanced' && !displayAdvancedOptions) {
return isNonEmptyValue(values[subBlockConfig.id])
}
if (subBlockConfig.mode === 'basic' && displayAdvancedOptions) {
return false
}
return evaluateSubBlockCondition(subBlockConfig.condition, values)
}
/**
* Helper function to migrate agent block params from old format to messages array
* Transforms systemPrompt/userPrompt into messages array format
* Only migrates if old format exists and new format doesn't (idempotent)
*/
function migrateAgentParamsToMessages(
params: Record<string, any>,
subBlocks: Record<string, any>,
blockId: string
): void {
// Only migrate if old format exists and new format doesn't
if ((params.systemPrompt || params.userPrompt) && !params.messages) {
logger.info('Migrating agent block from legacy format to messages array', {
blockId,
hasSystemPrompt: !!params.systemPrompt,
hasUserPrompt: !!params.userPrompt,
})
const messages: any[] = []
// Add system message first (industry standard)
if (params.systemPrompt) {
messages.push({
role: 'system',
content: params.systemPrompt,
})
}
// Add user message
if (params.userPrompt) {
let userContent = params.userPrompt
// Handle object format (e.g., { input: "..." })
if (typeof userContent === 'object' && userContent !== null) {
if ('input' in userContent) {
userContent = userContent.input
} else {
// If it's an object but doesn't have 'input', stringify it
userContent = JSON.stringify(userContent)
}
}
messages.push({
role: 'user',
content: String(userContent),
})
}
// Set the migrated messages in subBlocks
subBlocks.messages = {
id: 'messages',
type: 'messages-input',
value: messages,
}
}
}
export class Serializer {
serializeWorkflow(
blocks: Record<string, BlockState>,
edges: Edge[],
loops?: Record<string, Loop>,
parallels?: Record<string, Parallel>,
validateRequired = false
): SerializedWorkflow {
const canonicalLoops = generateLoopBlocks(blocks)
const canonicalParallels = generateParallelBlocks(blocks)
const safeLoops = Object.keys(canonicalLoops).length > 0 ? canonicalLoops : loops || {}
const safeParallels =
Object.keys(canonicalParallels).length > 0 ? canonicalParallels : parallels || {}
if (validateRequired) {
this.validateSubflowsBeforeExecution(blocks, safeLoops, safeParallels)
}
// A custom block whose definition was deleted (or is out of scope) no longer
// resolves via `getBlock`. Treat it as a removed block — drop it and any edges
// touching it — so the rest of the workflow still serializes and runs, instead
// of throwing `Invalid block type` and corrupting the whole workflow.
const droppedBlockIds = new Set<string>()
const serializedBlocks: SerializedBlock[] = []
for (const block of Object.values(blocks)) {
if (isCustomBlockType(block.type) && !getBlock(block.type)) {
droppedBlockIds.add(block.id)
logger.warn(`Dropping unresolvable custom block from serialization`, {
blockId: block.id,
type: block.type,
})
continue
}
serializedBlocks.push(this.serializeBlock(block, { validateRequired, allBlocks: blocks }))
}
return {
version: '1.0',
blocks: serializedBlocks,
connections: edges
.filter((edge) => !droppedBlockIds.has(edge.source) && !droppedBlockIds.has(edge.target))
.map((edge) => ({
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle || undefined,
targetHandle: edge.targetHandle || undefined,
})),
loops: safeLoops,
parallels: safeParallels,
}
}
/**
* Validate loop and parallel subflows for required inputs when running in "each/collection" modes
*/
private validateSubflowsBeforeExecution(
blocks: Record<string, BlockState>,
loops: Record<string, Loop>,
parallels: Record<string, Parallel>
): void {
// Note: Empty collections in forEach loops and parallel collection mode are handled gracefully
// at runtime - the loop/parallel will simply be skipped. No build-time validation needed.
}
private serializeBlock(
block: BlockState,
options: {
validateRequired: boolean
allBlocks: Record<string, BlockState>
}
): SerializedBlock {
// Special handling for subflow blocks (loops, parallels, etc.)
if (block.type === 'loop' || block.type === 'parallel') {
return {
id: block.id,
position: block.position,
config: {
tool: '', // Loop blocks don't have tools
params: (block.data || {}) as Record<string, unknown>, // Preserve the block data (parallelType, count, etc.)
},
inputs: {},
outputs: block.outputs,
metadata: {
id: block.type,
name: block.name,
description: block.type === 'loop' ? 'Loop container' : 'Parallel container',
category: 'subflow',
color: block.type === 'loop' ? '#3b82f6' : '#8b5cf6',
},
enabled: block.enabled,
}
}
const blockConfig = getBlock(block.type)
if (!blockConfig) {
throw new Error(`Invalid block type: ${block.type}`)
}
// Extract parameters from UI state
const params = extractBlockParams(block)
const isTriggerCategory = blockConfig.category === 'triggers'
if (block.triggerMode === true || isTriggerCategory) {
params.triggerMode = true
}
if (block.advancedMode === true) {
params.advancedMode = true
}
// Validate required fields that only users can provide (before execution starts)
if (options.validateRequired) {
const { missingRequiredFields } = collectBlockFieldIssues(block, blockConfig, params)
if (missingRequiredFields.length > 0) {
const blockName = block.name || blockConfig.name || 'Block'
throw new Error(
`${blockName} is missing required fields: ${missingRequiredFields.join(', ')}`
)
}
}
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 = selectToolId(blockConfig, params)
}
} 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 = selectToolId(blockConfig, params)
}
// Get inputs from block config
const inputs: Record<string, any> = {}
if (blockConfig.inputs) {
Object.entries(blockConfig.inputs).forEach(([key, config]) => {
inputs[key] = config.type
})
}
const serialized: SerializedBlock = {
id: block.id,
position: block.position,
config: {
tool: toolId,
params,
},
inputs,
outputs: {
...block.outputs,
},
metadata: {
id: block.type,
name: block.name,
description: blockConfig.description,
category: blockConfig.category,
color: blockConfig.bgColor,
},
enabled: block.enabled,
}
if (block.data?.canonicalModes) {
serialized.canonicalModes = block.data.canonicalModes as Record<string, 'basic' | 'advanced'>
}
return serialized
}
deserializeWorkflow(workflow: SerializedWorkflow): {
blocks: Record<string, BlockState>
edges: Edge[]
} {
const blocks: Record<string, BlockState> = {}
const edges: Edge[] = []
// A deleted custom block no longer resolves via `getBlock`. Treat it as a
// removed block — skip it and drop any edges touching it — so deserialization
// of the rest of the workflow succeeds instead of throwing `Invalid block type`.
const droppedBlockIds = new Set<string>()
// Deserialize blocks
workflow.blocks.forEach((serializedBlock) => {
const type = serializedBlock.metadata?.id
if (isCustomBlockType(type) && !getBlock(type)) {
droppedBlockIds.add(serializedBlock.id)
logger.warn(`Dropping unresolvable custom block from deserialization`, {
blockId: serializedBlock.id,
type,
})
return
}
const block = this.deserializeBlock(serializedBlock)
blocks[block.id] = block
})
// Deserialize connections
workflow.connections.forEach((connection) => {
if (droppedBlockIds.has(connection.source) || droppedBlockIds.has(connection.target)) {
return
}
edges.push({
id: generateId(),
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}`)
}
// Special handling for subflow blocks (loops, parallels, etc.)
if (blockType === 'loop' || blockType === 'parallel') {
return {
id: serializedBlock.id,
type: blockType,
name: serializedBlock.metadata?.name || (blockType === 'loop' ? 'Loop' : 'Parallel'),
position: serializedBlock.position,
subBlocks: {}, // Loops and parallels don't have traditional subBlocks
outputs: serializedBlock.outputs,
enabled: serializedBlock.enabled ?? true,
data: serializedBlock.config.params, // Preserve the data (parallelType, count, etc.)
}
}
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,
}
})
// Migration logic for agent blocks: Transform old systemPrompt/userPrompt to messages array
if (blockType === 'agent') {
migrateAgentParamsToMessages(serializedBlock.config.params, subBlocks, serializedBlock.id)
}
return {
id: serializedBlock.id,
type: blockType,
name: serializedBlock.metadata?.name || blockConfig.name,
position: serializedBlock.position,
subBlocks,
outputs: serializedBlock.outputs,
enabled: true,
triggerMode:
serializedBlock.config?.params?.triggerMode === true ||
serializedBlock.metadata?.category === 'triggers',
advancedMode: serializedBlock.config?.params?.advancedMode === true,
}
}
}
/** A canonical pair where the active member is empty but an inactive member holds a value that will be silently dropped. */
export interface InactiveModeValue {
canonicalId: string
/** The member the active mode reads from (where the value should live). */
activeMemberId?: string
/** The member that currently holds the stranded value. */
inactiveMemberId: string
kind: 'credential' | 'resource' | 'other'
}
export interface BlockFieldIssues {
missingRequiredFields: string[]
inactiveModeValues: InactiveModeValue[]
}
/**
* Select the tool id for a block given its resolved params.
*/
export function selectToolId(blockConfig: any, params: Record<string, any>): string {
try {
return blockConfig.tools.config?.tool
? blockConfig.tools.config.tool(params)
: blockConfig.tools.access[0]
} catch (error) {
logger.warn('Tool selection failed during serialization, using default:', {
error: toError(error).message,
})
return blockConfig.tools.access[0]
}
}
/**
* Resolve a block's UI sub-block state into the flat `params` map the runtime
* sees. Loop/parallel containers have no params; unknown block types throw.
*
* Exported as the single source of truth so the copilot workflow lint resolves
* params exactly the way execution (serializeBlock) does.
*/
export function extractBlockParams(block: BlockState): Record<string, any> {
if (block.type === 'loop' || block.type === 'parallel') {
return {}
}
const blockConfig = getBlock(block.type)
if (!blockConfig) {
throw new Error(`Invalid block type: ${block.type}`)
}
const params: Record<string, any> = {}
const legacyAdvancedMode = block.advancedMode ?? false
const canonicalModeOverrides = block.data?.canonicalModes
const isStarterBlock = block.type === 'starter'
const isAgentBlock = block.type === 'agent'
const isCustomBlock = isCustomBlockType(block.type)
// A custom block whose config declares its input fields (client overlay, or the
// server overlay once it carries curated `inputFields`) can tell a live input
// from a deleted one. Only when the config is schema-agnostic (legacy rows with
// no curated inputs) do we carry every stored field value forward blindly. A
// declared input is any sub-block that isn't the block's own reserved wiring.
const customBlockHasDeclaredInputs =
isCustomBlock && blockConfig.subBlocks.some((config) => !RESERVED_PARAMS.has(config.id))
const isTriggerContext = block.triggerMode ?? false
const isTriggerCategory = blockConfig.category === 'triggers'
const canonicalIndex = buildCanonicalIndex(blockConfig.subBlocks)
const allValues = buildSubBlockValues(block.subBlocks)
Object.entries(block.subBlocks).forEach(([id, subBlock]) => {
const matchingConfigs = blockConfig.subBlocks.filter((config) => config.id === id)
const hasStarterInputFormatValues =
isStarterBlock &&
id === 'inputFormat' &&
Array.isArray(subBlock.value) &&
subBlock.value.length > 0
const isLegacyAgentField =
isAgentBlock && ['systemPrompt', 'userPrompt', 'memories'].includes(id)
const shouldInclude =
matchingConfigs.length === 0 ||
matchingConfigs.some((config) =>
shouldSerializeSubBlock(
config,
allValues,
legacyAdvancedMode,
isTriggerContext,
isTriggerCategory,
canonicalIndex,
canonicalModeOverrides
)
)
// Include a stored input value that has no matching sub-block config only for
// schema-agnostic custom blocks. When the config declares its inputs, a value
// with no config is a DELETED input — dropping it stops the block passing a
// field the child no longer has (and stops resolving its now-stale reference).
const isCustomBlockInputField =
isCustomBlock && matchingConfigs.length === 0 && !customBlockHasDeclaredInputs
// A custom block's `workflowId`/`inputMapping` are computed (value-fn) sub-blocks,
// not user data. The canvas persists their last-computed value, which goes stale
// as input fields change — so never carry the stored value forward; let the value
// fn in the pass below recompute them from the current field params.
const isCustomBlockWiring = isCustomBlock && (id === 'workflowId' || id === 'inputMapping')
if (
!isCustomBlockWiring &&
((matchingConfigs.length > 0 && shouldInclude) ||
hasStarterInputFormatValues ||
isLegacyAgentField ||
isCustomBlockInputField)
) {
params[id] = subBlock.value
}
})
blockConfig.subBlocks.forEach((subBlockConfig) => {
const id = subBlockConfig.id
if (
params[id] == null &&
subBlockConfig.value &&
shouldSerializeSubBlock(
subBlockConfig,
allValues,
legacyAdvancedMode,
isTriggerContext,
isTriggerCategory,
canonicalIndex,
canonicalModeOverrides
)
) {
params[id] = subBlockConfig.value(params)
}
})
Object.values(canonicalIndex.groupsById).forEach((group) => {
const { basicValue, advancedValue } = getCanonicalValues(group, params)
const hasExplicitOverride = canonicalModeOverrides?.[group.canonicalId] != null
const pairMode =
hasExplicitOverride || !legacyAdvancedMode
? resolveCanonicalMode(group, allValues, canonicalModeOverrides)
: 'advanced'
const chosen = pairMode === 'advanced' ? advancedValue : basicValue
const sourceIds = [group.basicId, ...group.advancedIds].filter(Boolean) as string[]
sourceIds.forEach((id) => delete params[id])
if (chosen !== undefined) {
params[group.canonicalId] = chosen
}
})
return params
}
/**
* Classify a canonical group as a credential/resource selector based on the
* sub-block type of its members (oauth-input -> credential, *-selector ->
* resource).
*/
function classifyCanonicalKind(
blockConfig: any,
memberIds: string[]
): 'credential' | 'resource' | 'other' {
for (const id of memberIds) {
const cfg = blockConfig.subBlocks?.find((sb: any) => sb.id === id)
const type = cfg?.type
if (type === 'oauth-input') return 'credential'
if (typeof type === 'string' && type.endsWith('-selector')) return 'resource'
}
return 'other'
}
/**
* Non-throwing analysis of a block's required fields and canonical-mode value
* placement. `serializeBlock` wraps this and throws on missing required fields
* (execution ground truth); the copilot workflow lint consumes the structured
* result. Single source of truth shared by both, so they can never drift.
*/
export function collectBlockFieldIssues(
block: BlockState,
blockConfig: any,
params: Record<string, any>
): BlockFieldIssues {
// Disabled blocks and trigger-mode blocks are not validated (mirrors runtime).
if (block.enabled === false) {
return { missingRequiredFields: [], inactiveModeValues: [] }
}
if (
block.triggerMode === true ||
blockConfig.category === 'triggers' ||
params.triggerMode === true
) {
return { missingRequiredFields: [], inactiveModeValues: [] }
}
const missingFields: string[] = []
const displayAdvancedOptions = block.advancedMode ?? false
const isTriggerContext = block.triggerMode ?? false
const isTriggerCategory = blockConfig.category === 'triggers'
const canonicalIndex = buildCanonicalIndex(blockConfig.subBlocks || [])
const canonicalModeOverrides = block.data?.canonicalModes
const allValues = buildSubBlockValues(block.subBlocks)
// Get the tool configuration to check parameter visibility
const toolAccess = blockConfig.tools?.access
const currentToolId = toolAccess?.length > 0 ? selectToolId(blockConfig, params) : null
const currentTool = currentToolId ? getTool(currentToolId) : null
// Validate tool parameters (for blocks with tools).
// Lookup contract: a tool param's value lives under its own paramId in `params`.
// Block subBlocks align via either `id === paramId` or `canonicalParamId === paramId`.
if (currentTool) {
Object.entries(currentTool.params || {}).forEach(([paramId, paramConfig]: [string, any]) => {
if (paramConfig.required && paramConfig.visibility === 'user-only') {
const matchingConfigs =
blockConfig.subBlocks?.filter(
(sb: any) => sb.id === paramId || sb.canonicalParamId === paramId
) || []
let shouldValidateParam = true
if (matchingConfigs.length > 0) {
shouldValidateParam = matchingConfigs.some((subBlockConfig: any) => {
const includedByMode = shouldSerializeSubBlock(
subBlockConfig,
allValues,
displayAdvancedOptions,
isTriggerContext,
isTriggerCategory,
canonicalIndex,
canonicalModeOverrides
)
const isRequired = (() => {
if (!subBlockConfig.required) return false
if (typeof subBlockConfig.required === 'boolean') return subBlockConfig.required
return evaluateSubBlockCondition(subBlockConfig.required, params)
})()
return includedByMode && isRequired
})
}
if (!shouldValidateParam) {
return
}
const fieldValue = params[paramId]
if (fieldValue === undefined || fieldValue === null || fieldValue === '') {
const activeConfig = matchingConfigs.find((config: any) =>
shouldSerializeSubBlock(
config,
allValues,
displayAdvancedOptions,
isTriggerContext,
isTriggerCategory,
canonicalIndex,
canonicalModeOverrides
)
)
const displayName = activeConfig?.title || paramId
missingFields.push(displayName)
}
}
})
}
// Validate required subBlocks not covered by tool params (e.g., blocks with empty tools.access)
const validatedByTool = new Set(currentTool ? Object.keys(currentTool.params || {}) : [])
blockConfig.subBlocks?.forEach((subBlockConfig: SubBlockConfig) => {
if (validatedByTool.has(subBlockConfig.id)) {
return
}
if (subBlockConfig.canonicalParamId && validatedByTool.has(subBlockConfig.canonicalParamId)) {
return
}
const isVisible = shouldSerializeSubBlock(
subBlockConfig,
allValues,
displayAdvancedOptions,
isTriggerContext,
isTriggerCategory,
canonicalIndex,
canonicalModeOverrides
)
if (!isVisible) {
return
}
const isRequired = (() => {
if (!subBlockConfig.required) return false
if (typeof subBlockConfig.required === 'boolean') return subBlockConfig.required
return evaluateSubBlockCondition(subBlockConfig.required, params)
})()
if (!isRequired) {
return
}
// For canonical subBlocks, look up the canonical param value (original IDs were deleted)
const canonicalId = canonicalIndex.canonicalIdBySubBlockId[subBlockConfig.id]
const fieldValue = canonicalId ? params[canonicalId] : params[subBlockConfig.id]
if (fieldValue === undefined || fieldValue === null || fieldValue === '') {
missingFields.push(subBlockConfig.title || subBlockConfig.id)
}
})
// Detect canonical pairs whose active member is empty while an inactive member
// holds a value (the value is silently dropped at serialize time).
const inactiveModeValues: InactiveModeValue[] = []
for (const group of Object.values(canonicalIndex.groupsById)) {
if (!isCanonicalPair(group)) continue
const mode = resolveCanonicalMode(group, allValues, canonicalModeOverrides)
const { basicValue, advancedValue } = getCanonicalValues(group, allValues)
const activeValue = mode === 'advanced' ? advancedValue : basicValue
if (isNonEmptyValue(activeValue)) continue
const memberIds = [group.basicId, ...group.advancedIds].filter(Boolean) as string[]
const activeMemberId = mode === 'advanced' ? group.advancedIds[0] : group.basicId
const inactiveMemberId = memberIds.find(
(id) => id !== activeMemberId && isNonEmptyValue(allValues[id])
)
if (inactiveMemberId) {
inactiveModeValues.push({
canonicalId: group.canonicalId,
activeMemberId,
inactiveMemberId,
kind: classifyCanonicalKind(blockConfig, memberIds),
})
}
}
return { missingRequiredFields: missingFields, inactiveModeValues }
}
@@ -0,0 +1,351 @@
/**
* @vitest-environment node
*
* Integration Tests for Validation Architecture
*
* These tests verify the complete validation flow:
* 1. Early validation (serialization) - user-only required fields
* 2. Late validation (tool execution) - user-or-llm required fields
*/
import { blocksMock } from '@sim/testing/mocks'
import { describe, expect, it, vi } from 'vitest'
import { Serializer } from '@/serializer/index'
vi.mock('@/blocks', () => blocksMock)
/**
* Validates required parameters after user and LLM parameter merge.
* This checks user-or-llm visibility fields that should have been provided by either source.
*/
function validateRequiredParametersAfterMerge(
toolId: string,
tool: any,
params: Record<string, any>
): void {
if (!tool?.params) return
Object.entries(tool.params).forEach(([paramId, paramConfig]: [string, any]) => {
// Only validate user-or-llm visibility fields (user-only are validated earlier)
if (paramConfig.required && paramConfig.visibility === 'user-or-llm') {
const value = params[paramId]
if (value === undefined || value === null || value === '') {
// Capitalize first letter of paramId for display
const displayName = paramId.charAt(0).toUpperCase() + paramId.slice(1)
throw new Error(`${displayName} is required for ${tool.name}`)
}
}
})
}
vi.mock('@/tools/utils', () => ({
getTool: (toolId: string) => {
const mockTools: Record<string, any> = {
jina_read_url: {
name: 'Jina Reader',
params: {
url: {
type: 'string',
visibility: 'user-or-llm',
required: true,
description: 'URL to extract content from',
},
apiKey: {
type: 'string',
visibility: 'user-only',
required: true,
description: 'Your Jina API key',
},
},
},
reddit_get_posts: {
name: 'Reddit Posts',
params: {
subreddit: {
type: 'string',
visibility: 'user-or-llm',
required: true,
description: 'Subreddit name',
},
credential: {
type: 'string',
visibility: 'user-only',
required: true,
description: 'Reddit credentials',
},
},
},
}
return mockTools[toolId] || null
},
validateRequiredParametersAfterMerge,
}))
describe('Validation Integration Tests', () => {
it.concurrent('early validation should catch missing user-only fields', () => {
const serializer = new Serializer()
// Block missing user-only field (API key)
const blockWithMissingUserOnlyField: any = {
id: 'jina-block',
type: 'jina',
name: 'Jina Content Extractor',
position: { x: 0, y: 0 },
subBlocks: {
url: { value: 'https://example.com' }, // Present
apiKey: { value: null }, // Missing user-only field
},
outputs: {},
enabled: true,
}
// Should fail at serialization (early validation)
expect(() => {
serializer.serializeWorkflow(
{ 'jina-block': blockWithMissingUserOnlyField },
[],
{},
undefined,
true
)
}).toThrow('Jina Content Extractor is missing required fields: API Key')
})
it.concurrent(
'early validation should allow missing user-or-llm fields (LLM can provide later)',
() => {
const serializer = new Serializer()
// Block missing user-or-llm field (URL) but has user-only field (API key)
const blockWithMissingUserOrLlmField: any = {
id: 'jina-block',
type: 'jina',
name: 'Jina Content Extractor',
position: { x: 0, y: 0 },
subBlocks: {
url: { value: null }, // Missing user-or-llm field (LLM can provide)
apiKey: { value: 'test-api-key' }, // Present user-only field
},
outputs: {},
enabled: true,
}
// Should pass serialization (early validation doesn't check user-or-llm fields)
expect(() => {
serializer.serializeWorkflow(
{ 'jina-block': blockWithMissingUserOrLlmField },
[],
{},
undefined,
true
)
}).not.toThrow()
}
)
it.concurrent(
'late validation should catch missing user-or-llm fields after parameter merge',
() => {
// Simulate parameters after user + LLM merge
const mergedParams = {
url: null, // Missing user-or-llm field
apiKey: 'test-api-key', // Present user-only field
}
// Should fail at tool validation (late validation)
expect(() => {
validateRequiredParametersAfterMerge(
'jina_read_url',
{
name: 'Jina Reader',
params: {
url: {
type: 'string',
visibility: 'user-or-llm',
required: true,
description: 'URL to extract content from',
},
apiKey: {
type: 'string',
visibility: 'user-only',
required: true,
description: 'Your Jina API key',
},
},
} as any,
mergedParams
)
}).toThrow('Url is required for Jina Reader')
}
)
it.concurrent('late validation should NOT validate user-only fields (validated earlier)', () => {
// Simulate parameters after user + LLM merge - missing user-only field
const mergedParams = {
url: 'https://example.com', // Present user-or-llm field
apiKey: null, // Missing user-only field (but shouldn't be checked here)
}
// Should pass tool validation (late validation doesn't check user-only fields)
expect(() => {
validateRequiredParametersAfterMerge(
'jina_read_url',
{
name: 'Jina Reader',
params: {
url: {
type: 'string',
visibility: 'user-or-llm',
required: true,
description: 'URL to extract content from',
},
apiKey: {
type: 'string',
visibility: 'user-only',
required: true,
description: 'Your Jina API key',
},
},
} as any,
mergedParams
)
}).not.toThrow()
})
it.concurrent('complete validation flow: both layers working together', () => {
const serializer = new Serializer()
// Scenario 1: Missing user-only field - should fail at serialization
const blockMissingUserOnly: any = {
id: 'reddit-block',
type: 'reddit',
name: 'Reddit Posts',
position: { x: 0, y: 0 },
subBlocks: {
operation: { value: 'get_posts' },
credential: { value: null }, // Missing user-only
subreddit: { value: 'programming' }, // Present user-or-llm
},
outputs: {},
enabled: true,
}
expect(() => {
serializer.serializeWorkflow(
{ 'reddit-block': blockMissingUserOnly },
[],
{},
undefined,
true
)
}).toThrow('Reddit Posts is missing required fields: Reddit Account')
// Scenario 2: Has user-only fields but missing user-or-llm - should pass serialization
const blockMissingUserOrLlm: any = {
id: 'reddit-block',
type: 'reddit',
name: 'Reddit Posts',
position: { x: 0, y: 0 },
subBlocks: {
operation: { value: 'get_posts' },
credential: { value: 'reddit-token' }, // Present user-only
subreddit: { value: null }, // Missing user-or-llm
},
outputs: {},
enabled: true,
}
// Should pass serialization
expect(() => {
serializer.serializeWorkflow(
{ 'reddit-block': blockMissingUserOrLlm },
[],
{},
undefined,
true
)
}).not.toThrow()
// But should fail at tool validation
const mergedParams = {
subreddit: null, // Missing user-or-llm field
credential: 'reddit-token', // Present user-only field
}
expect(() => {
validateRequiredParametersAfterMerge(
'reddit_get_posts',
{
name: 'Reddit Posts',
params: {
subreddit: {
type: 'string',
visibility: 'user-or-llm',
required: true,
description: 'Subreddit name',
},
credential: {
type: 'string',
visibility: 'user-only',
required: true,
description: 'Reddit credentials',
},
},
} as any,
mergedParams
)
}).toThrow('Subreddit is required for Reddit Posts')
})
it.concurrent('complete success: all required fields provided correctly', () => {
const serializer = new Serializer()
// Block with all required fields present
const completeBlock: any = {
id: 'jina-block',
type: 'jina',
name: 'Jina Content Extractor',
position: { x: 0, y: 0 },
subBlocks: {
url: { value: 'https://example.com' }, // Present user-or-llm
apiKey: { value: 'test-api-key' }, // Present user-only
},
outputs: {},
enabled: true,
}
// Should pass serialization (early validation)
expect(() => {
serializer.serializeWorkflow({ 'jina-block': completeBlock }, [], {}, undefined, true)
}).not.toThrow()
// Should pass tool validation (late validation)
const completeParams = {
url: 'https://example.com',
apiKey: 'test-api-key',
}
expect(() => {
validateRequiredParametersAfterMerge(
'jina_read_url',
{
name: 'Jina Reader',
params: {
url: {
type: 'string',
visibility: 'user-or-llm',
required: true,
description: 'URL to extract content from',
},
apiKey: {
type: 'string',
visibility: 'user-only',
required: true,
description: 'Your Jina API key',
},
},
} as any,
completeParams
)
}).not.toThrow()
})
})
File diff suppressed because it is too large Load Diff
+62
View File
@@ -0,0 +1,62 @@
import type { OutputFieldDefinition, ParamType } from '@/blocks/types'
import type { Position } from '@/stores/workflows/workflow/types'
export interface SerializedWorkflow {
version: string
blocks: SerializedBlock[]
connections: SerializedConnection[]
loops: Record<string, SerializedLoop>
parallels?: Record<string, SerializedParallel>
}
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, unknown>
}
inputs: Record<string, ParamType>
outputs: Record<string, OutputFieldDefinition>
metadata?: {
id: string
name?: string
description?: string
category?: string
icon?: string
color?: string
}
enabled: boolean
/** Canonical mode overrides from block.data (used by agent handler for tool param resolution) */
canonicalModes?: Record<string, 'basic' | 'advanced'>
}
export interface SerializedLoop {
id: string
nodes: string[]
iterations: number
loopType?: 'for' | 'forEach' | 'while' | 'doWhile'
forEachItems?: any[] | Record<string, any> | string // Items to iterate over or expression to evaluate
whileCondition?: string // JS expression that evaluates to boolean (for while loops)
doWhileCondition?: string // JS expression that evaluates to boolean (for do-while loops)
}
export interface SerializedParallel {
id: string
nodes: string[]
distribution?: any[] | Record<string, any> | string // Items to distribute or expression to evaluate
count?: number // Number of parallel executions for count-based parallel
parallelType?: 'count' | 'collection' // Explicit parallel type to avoid inference bugs
batchSize?: number // Maximum number of branches to run concurrently per batch
}