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
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user