chore: import upstream snapshot with attribution
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) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
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) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createBlockFromParams } from './builders'
|
||||
|
||||
const agentBlockConfig = {
|
||||
type: 'agent',
|
||||
name: 'Agent',
|
||||
outputs: {
|
||||
content: { type: 'string', description: 'Default content output' },
|
||||
},
|
||||
subBlocks: [{ id: 'responseFormat', type: 'response-format' }],
|
||||
}
|
||||
|
||||
const conditionBlockConfig = {
|
||||
type: 'condition',
|
||||
name: 'Condition',
|
||||
outputs: {},
|
||||
subBlocks: [{ id: 'conditions', type: 'condition-input' }],
|
||||
}
|
||||
|
||||
vi.mock('@/blocks/registry', () => ({
|
||||
getAllBlocks: () => [agentBlockConfig, conditionBlockConfig],
|
||||
getBlock: (type: string) =>
|
||||
type === 'agent' ? agentBlockConfig : type === 'condition' ? conditionBlockConfig : undefined,
|
||||
}))
|
||||
|
||||
describe('createBlockFromParams', () => {
|
||||
it('derives agent outputs from responseFormat when outputs are not provided', () => {
|
||||
const block = createBlockFromParams('b-agent', {
|
||||
type: 'agent',
|
||||
name: 'Agent',
|
||||
inputs: {
|
||||
responseFormat: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
answer: {
|
||||
type: 'string',
|
||||
description: 'Structured answer text',
|
||||
},
|
||||
},
|
||||
required: ['answer'],
|
||||
},
|
||||
},
|
||||
triggerMode: false,
|
||||
})
|
||||
|
||||
expect(block.outputs.answer).toBeDefined()
|
||||
expect(block.outputs.answer.type).toBe('string')
|
||||
})
|
||||
|
||||
it('preserves configured subblock types and normalizes condition branch ids', () => {
|
||||
const block = createBlockFromParams('condition-1', {
|
||||
type: 'condition',
|
||||
name: 'Condition 1',
|
||||
inputs: {
|
||||
conditions: JSON.stringify([
|
||||
{ id: 'arbitrary-if', title: 'if', value: 'true' },
|
||||
{ id: 'arbitrary-else', title: 'else', value: '' },
|
||||
]),
|
||||
},
|
||||
triggerMode: false,
|
||||
})
|
||||
|
||||
expect(block.subBlocks.conditions.type).toBe('condition-input')
|
||||
|
||||
const parsed = JSON.parse(block.subBlocks.conditions.value)
|
||||
expect(parsed[0].id).toBe('condition-1-if')
|
||||
expect(parsed[1].id).toBe('condition-1-else')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,765 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId, isValidUuid } from '@sim/utils/id'
|
||||
import { sortObjectKeysDeep } from '@sim/utils/object'
|
||||
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
|
||||
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||
import {
|
||||
buildCanonicalIndex,
|
||||
buildDefaultCanonicalModes,
|
||||
isCanonicalPair,
|
||||
} from '@/lib/workflows/subblocks/visibility'
|
||||
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { getAllBlocks } from '@/blocks/registry'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
|
||||
import type { EditWorkflowOperation, SkippedItem, ValidationError } from './types'
|
||||
import { logSkippedItem } from './types'
|
||||
import {
|
||||
validateInputsForBlock,
|
||||
validateSourceHandleForBlock,
|
||||
validateTargetHandle,
|
||||
} from './validation'
|
||||
|
||||
/**
|
||||
* Helper to create a block state from operation params
|
||||
*/
|
||||
export function createBlockFromParams(
|
||||
blockId: string,
|
||||
params: any,
|
||||
parentId?: string,
|
||||
errorsCollector?: ValidationError[],
|
||||
permissionConfig?: PermissionGroupConfig | null,
|
||||
skippedItems?: SkippedItem[]
|
||||
): any {
|
||||
const blockConfig = getAllBlocks().find((b) => b.type === params.type)
|
||||
|
||||
// Validate inputs against block configuration
|
||||
let validatedInputs: Record<string, any> | undefined
|
||||
if (params.inputs) {
|
||||
const result = validateInputsForBlock(params.type, params.inputs, blockId)
|
||||
validatedInputs = result.validInputs
|
||||
if (errorsCollector && result.errors.length > 0) {
|
||||
errorsCollector.push(...result.errors)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine outputs based on trigger mode
|
||||
const triggerMode = params.triggerMode || false
|
||||
const isTriggerCapable = blockConfig ? hasTriggerCapability(blockConfig) : false
|
||||
const effectiveTriggerMode = Boolean(triggerMode && isTriggerCapable)
|
||||
let outputs: Record<string, any>
|
||||
|
||||
if (params.outputs) {
|
||||
outputs = params.outputs
|
||||
} else if (blockConfig) {
|
||||
const subBlocks: Record<string, any> = {}
|
||||
if (validatedInputs) {
|
||||
Object.entries(validatedInputs).forEach(([key, value]) => {
|
||||
// Skip runtime subblock IDs when computing outputs
|
||||
if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(key)) {
|
||||
return
|
||||
}
|
||||
subBlocks[key] = { id: key, type: 'short-input', value: value }
|
||||
})
|
||||
}
|
||||
outputs = getEffectiveBlockOutputs(params.type, subBlocks, {
|
||||
triggerMode: effectiveTriggerMode,
|
||||
preferToolOutputs: !effectiveTriggerMode,
|
||||
})
|
||||
} else {
|
||||
outputs = {}
|
||||
}
|
||||
|
||||
const blockState: any = {
|
||||
id: blockId,
|
||||
type: params.type,
|
||||
name: params.name,
|
||||
position: { x: 0, y: 0 },
|
||||
enabled: params.enabled !== undefined ? params.enabled : true,
|
||||
horizontalHandles: true,
|
||||
advancedMode: params.advancedMode || false,
|
||||
height: 0,
|
||||
triggerMode: triggerMode,
|
||||
subBlocks: {},
|
||||
outputs: outputs,
|
||||
data: parentId ? { parentId, extent: 'parent' as const } : {},
|
||||
locked: false,
|
||||
}
|
||||
|
||||
// Add validated inputs as subBlocks
|
||||
if (validatedInputs) {
|
||||
Object.entries(validatedInputs).forEach(([key, value]) => {
|
||||
if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(key)) {
|
||||
return
|
||||
}
|
||||
|
||||
let sanitizedValue = value
|
||||
|
||||
// Normalize array subblocks with id fields (inputFormat, table rows, etc.)
|
||||
if (shouldNormalizeArrayIds(key)) {
|
||||
sanitizedValue = normalizeArrayWithIds(value)
|
||||
if (JSON_STRING_SUBBLOCK_KEYS.has(key)) {
|
||||
sanitizedValue = JSON.stringify(sanitizedValue)
|
||||
}
|
||||
}
|
||||
|
||||
sanitizedValue = normalizeConditionRouterIds(blockId, key, sanitizedValue)
|
||||
|
||||
// Special handling for tools - normalize and filter disallowed
|
||||
if (key === 'tools' && Array.isArray(value)) {
|
||||
sanitizedValue = filterDisallowedTools(
|
||||
normalizeTools(value),
|
||||
permissionConfig ?? null,
|
||||
blockId,
|
||||
skippedItems ?? []
|
||||
)
|
||||
}
|
||||
|
||||
// Special handling for responseFormat - normalize to ensure consistent format
|
||||
if (key === 'responseFormat' && value) {
|
||||
sanitizedValue = normalizeResponseFormat(value)
|
||||
}
|
||||
|
||||
const subBlockDef = blockConfig?.subBlocks.find((subBlock) => subBlock.id === key)
|
||||
blockState.subBlocks[key] = {
|
||||
id: key,
|
||||
type: subBlockDef?.type || 'short-input',
|
||||
value: sanitizedValue,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Set up subBlocks from block configuration
|
||||
if (blockConfig) {
|
||||
blockConfig.subBlocks.forEach((subBlock) => {
|
||||
if (!blockState.subBlocks[subBlock.id]) {
|
||||
blockState.subBlocks[subBlock.id] = {
|
||||
id: subBlock.id,
|
||||
type: subBlock.type,
|
||||
value: null,
|
||||
}
|
||||
} else {
|
||||
blockState.subBlocks[subBlock.id].type = subBlock.type
|
||||
}
|
||||
})
|
||||
|
||||
const defaultModes = buildDefaultCanonicalModes(blockConfig.subBlocks)
|
||||
if (Object.keys(defaultModes).length > 0) {
|
||||
if (!blockState.data) blockState.data = {}
|
||||
blockState.data.canonicalModes = defaultModes
|
||||
}
|
||||
|
||||
if (validatedInputs) {
|
||||
updateCanonicalModesForInputs(blockState, Object.keys(validatedInputs), blockConfig)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize default conditions/routes so edge handle validation works.
|
||||
// The UI does this in the React component; we need to mirror it here.
|
||||
if (params.type === 'condition' && !blockState.subBlocks.conditions?.value) {
|
||||
blockState.subBlocks.conditions = {
|
||||
id: 'conditions',
|
||||
type: 'condition-input',
|
||||
value: JSON.stringify([
|
||||
{ id: generateId(), title: 'If', value: '' },
|
||||
{ id: generateId(), title: 'Else', value: '' },
|
||||
]),
|
||||
}
|
||||
} else if (params.type === 'router_v2' && !blockState.subBlocks.routes?.value) {
|
||||
blockState.subBlocks.routes = {
|
||||
id: 'routes',
|
||||
type: 'router-input',
|
||||
value: JSON.stringify([{ id: generateId(), title: 'Route 1', value: '' }]),
|
||||
}
|
||||
}
|
||||
|
||||
return blockState
|
||||
}
|
||||
|
||||
export function updateCanonicalModesForInputs(
|
||||
block: { data?: { canonicalModes?: Record<string, 'basic' | 'advanced'> } },
|
||||
inputKeys: string[],
|
||||
blockConfig: BlockConfig
|
||||
): void {
|
||||
if (!blockConfig.subBlocks?.length) return
|
||||
|
||||
const canonicalIndex = buildCanonicalIndex(blockConfig.subBlocks)
|
||||
const canonicalModeUpdates: Record<string, 'basic' | 'advanced'> = {}
|
||||
|
||||
for (const inputKey of inputKeys) {
|
||||
const canonicalId = canonicalIndex.canonicalIdBySubBlockId[inputKey]
|
||||
if (!canonicalId) continue
|
||||
|
||||
const group = canonicalIndex.groupsById[canonicalId]
|
||||
if (!group || !isCanonicalPair(group)) continue
|
||||
|
||||
const isAdvanced = group.advancedIds.includes(inputKey)
|
||||
const existingMode = canonicalModeUpdates[canonicalId]
|
||||
|
||||
if (!existingMode || isAdvanced) {
|
||||
canonicalModeUpdates[canonicalId] = isAdvanced ? 'advanced' : 'basic'
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(canonicalModeUpdates).length > 0) {
|
||||
if (!block.data) block.data = {}
|
||||
if (!block.data.canonicalModes) block.data.canonicalModes = {}
|
||||
Object.assign(block.data.canonicalModes, canonicalModeUpdates)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize tools array by adding back fields that were sanitized for training
|
||||
*/
|
||||
export function normalizeTools(tools: any[]): any[] {
|
||||
return tools.map((tool) => {
|
||||
if (tool.type === 'custom-tool') {
|
||||
// New reference format: minimal fields only
|
||||
if (tool.customToolId && !tool.schema && !tool.code) {
|
||||
return {
|
||||
type: tool.type,
|
||||
customToolId: tool.customToolId,
|
||||
usageControl: tool.usageControl || 'auto',
|
||||
isExpanded: tool.isExpanded ?? true,
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy inline format: include all fields
|
||||
const normalized: any = {
|
||||
...tool,
|
||||
params: tool.params || {},
|
||||
isExpanded: tool.isExpanded ?? true,
|
||||
}
|
||||
|
||||
// Ensure schema has proper structure (for inline format)
|
||||
if (normalized.schema?.function) {
|
||||
normalized.schema = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: normalized.schema.function.name || tool.title, // Preserve name or derive from title
|
||||
description: normalized.schema.function.description,
|
||||
parameters: normalized.schema.function.parameters,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
// For other tool types, just ensure isExpanded exists
|
||||
return {
|
||||
...tool,
|
||||
isExpanded: tool.isExpanded ?? true,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Subblock types that store arrays of objects with `id` fields.
|
||||
* The LLM may generate arbitrary IDs which need to be converted to proper UUIDs.
|
||||
*/
|
||||
const ARRAY_WITH_ID_SUBBLOCK_TYPES = new Set([
|
||||
'inputFormat', // input-format: Fields with id, name, type, value, collapsed
|
||||
'headers', // table: Rows with id, cells (used for HTTP headers)
|
||||
'params', // table: Rows with id, cells (used for query params)
|
||||
'variables', // table or variables-input: Rows/assignments with id
|
||||
'tagFilters', // knowledge-tag-filters: Filters with id, tagName, etc.
|
||||
'documentTags', // document-tag-entry: Tags with id, tagName, etc.
|
||||
'metrics', // eval-input: Metrics with id, name, description, range
|
||||
'conditions', // condition-input: Condition branches with id, title, value
|
||||
'routes', // router-input: Router routes with id, title, value
|
||||
])
|
||||
|
||||
/**
|
||||
* Subblock keys whose UI components expect a JSON string, not a raw array.
|
||||
* After normalizeArrayWithIds returns an array, these must be re-stringified.
|
||||
*/
|
||||
export const JSON_STRING_SUBBLOCK_KEYS = new Set(['conditions', 'routes'])
|
||||
|
||||
/**
|
||||
* Normalizes array subblock values by ensuring each item has a valid UUID.
|
||||
* The LLM may generate arbitrary IDs like "input-desc-001" or "row-1" which need
|
||||
* to be converted to proper UUIDs for consistency with UI-created items.
|
||||
*/
|
||||
export function normalizeArrayWithIds(value: unknown): any[] {
|
||||
let arr: any[]
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
arr = value
|
||||
} else if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
arr = parsed
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
|
||||
return arr.map((item: any) => {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return item
|
||||
}
|
||||
|
||||
const hasValidUUID = typeof item.id === 'string' && isValidUuid(item.id)
|
||||
if (!hasValidUUID) {
|
||||
return { ...item, id: generateId() }
|
||||
}
|
||||
|
||||
return item
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a subblock key should have its array items normalized with UUIDs.
|
||||
*/
|
||||
export function shouldNormalizeArrayIds(key: string): boolean {
|
||||
return ARRAY_WITH_ID_SUBBLOCK_TYPES.has(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes condition/router branch IDs to use canonical block-scoped format.
|
||||
* The LLM provides branch structure (if/else-if/else or routes) but should not
|
||||
* have to generate the internal IDs -- we assign them based on the block ID.
|
||||
*/
|
||||
export function normalizeConditionRouterIds(blockId: string, key: string, value: unknown): unknown {
|
||||
if (key !== 'conditions' && key !== 'routes') return value
|
||||
|
||||
let parsed: any[]
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
parsed = JSON.parse(value)
|
||||
if (!Array.isArray(parsed)) return value
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
} else if (Array.isArray(value)) {
|
||||
parsed = value
|
||||
} else {
|
||||
return value
|
||||
}
|
||||
|
||||
let elseIfCounter = 0
|
||||
const normalized = parsed.map((item, index) => {
|
||||
if (!item || typeof item !== 'object') return item
|
||||
|
||||
let canonicalId: string
|
||||
if (key === 'conditions') {
|
||||
if (index === 0) {
|
||||
canonicalId = `${blockId}-if`
|
||||
} else if (index === parsed.length - 1) {
|
||||
canonicalId = `${blockId}-else`
|
||||
} else {
|
||||
canonicalId = `${blockId}-else-if-${elseIfCounter}`
|
||||
elseIfCounter++
|
||||
}
|
||||
} else {
|
||||
canonicalId = `${blockId}-route${index + 1}`
|
||||
}
|
||||
|
||||
return { ...item, id: canonicalId }
|
||||
})
|
||||
|
||||
return typeof value === 'string' ? JSON.stringify(normalized) : normalized
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize responseFormat to ensure consistent storage
|
||||
* Handles both string (JSON) and object formats
|
||||
* Returns pretty-printed JSON for better UI readability
|
||||
*/
|
||||
export function normalizeResponseFormat(value: any): string {
|
||||
try {
|
||||
let obj = value
|
||||
|
||||
// If it's already a string, parse it first
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return ''
|
||||
}
|
||||
obj = JSON.parse(trimmed)
|
||||
}
|
||||
|
||||
// If it's an object, stringify it with consistent formatting
|
||||
if (obj && typeof obj === 'object') {
|
||||
// Return pretty-printed with 2-space indentation for UI readability
|
||||
// The sanitizer will normalize it to minified format for comparison
|
||||
return JSON.stringify(sortObjectKeysDeep(obj), null, 2)
|
||||
}
|
||||
|
||||
return String(value)
|
||||
} catch {
|
||||
// If parsing fails, return the original value as string
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a validated edge between two blocks.
|
||||
* Returns true if edge was created, false if skipped due to validation errors.
|
||||
*/
|
||||
export function createValidatedEdge(
|
||||
modifiedState: any,
|
||||
sourceBlockId: string,
|
||||
targetBlockId: string,
|
||||
sourceHandle: string,
|
||||
targetHandle: string,
|
||||
operationType: string,
|
||||
logger: ReturnType<typeof createLogger>,
|
||||
skippedItems?: SkippedItem[]
|
||||
): boolean {
|
||||
if (!modifiedState.blocks[targetBlockId]) {
|
||||
// The target doesn't exist yet. It may be created by a later operation in
|
||||
// this batch or by a future edit_workflow call. Record the connection as
|
||||
// pending on the source block (persisted in block.data) so it is resolved
|
||||
// automatically once the target appears, instead of being silently dropped.
|
||||
const pendingSource = modifiedState.blocks[sourceBlockId]
|
||||
if (pendingSource) {
|
||||
if (!pendingSource.data) pendingSource.data = {}
|
||||
if (!pendingSource.data.pendingConnections) pendingSource.data.pendingConnections = {}
|
||||
const pending = pendingSource.data.pendingConnections as Record<
|
||||
string,
|
||||
Array<{ target: string; targetHandle: string }>
|
||||
>
|
||||
if (!pending[sourceHandle]) pending[sourceHandle] = []
|
||||
if (
|
||||
!pending[sourceHandle].some(
|
||||
(p) => p.target === targetBlockId && p.targetHandle === targetHandle
|
||||
)
|
||||
) {
|
||||
pending[sourceHandle].push({ target: targetBlockId, targetHandle })
|
||||
}
|
||||
}
|
||||
logger.warn(`Target block "${targetBlockId}" not found. Connection deferred until it exists.`, {
|
||||
sourceBlockId,
|
||||
targetBlockId,
|
||||
sourceHandle,
|
||||
})
|
||||
skippedItems?.push({
|
||||
type: 'invalid_edge_target',
|
||||
operationType,
|
||||
blockId: sourceBlockId,
|
||||
reason: `Edge from "${sourceBlockId}" to "${targetBlockId}" deferred until the target block "${targetBlockId}" exists - if it is created later (in this or a following edit) the engine wires this edge automatically; if you did not intend to create "${targetBlockId}", fix the target id.`,
|
||||
details: { sourceHandle, targetHandle, targetId: targetBlockId },
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const sourceBlock = modifiedState.blocks[sourceBlockId]
|
||||
if (!sourceBlock) {
|
||||
logger.warn(`Source block "${sourceBlockId}" not found. Edge skipped.`, {
|
||||
sourceBlockId,
|
||||
targetBlockId,
|
||||
})
|
||||
skippedItems?.push({
|
||||
type: 'invalid_edge_source',
|
||||
operationType,
|
||||
blockId: sourceBlockId,
|
||||
reason: `Edge from "${sourceBlockId}" to "${targetBlockId}" skipped - source block does not exist`,
|
||||
details: { sourceHandle, targetHandle, targetId: targetBlockId },
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const sourceBlockType = sourceBlock.type
|
||||
if (!sourceBlockType) {
|
||||
logger.warn(`Source block "${sourceBlockId}" has no type. Edge skipped.`, {
|
||||
sourceBlockId,
|
||||
targetBlockId,
|
||||
})
|
||||
skippedItems?.push({
|
||||
type: 'invalid_edge_source',
|
||||
operationType,
|
||||
blockId: sourceBlockId,
|
||||
reason: `Edge from "${sourceBlockId}" to "${targetBlockId}" skipped - source block has no type`,
|
||||
details: { sourceHandle, targetHandle, targetId: targetBlockId },
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const sourceValidation = validateSourceHandleForBlock(sourceHandle, sourceBlockType, sourceBlock)
|
||||
if (!sourceValidation.valid) {
|
||||
logger.warn(`Invalid source handle. Edge skipped.`, {
|
||||
sourceBlockId,
|
||||
targetBlockId,
|
||||
sourceHandle,
|
||||
error: sourceValidation.error,
|
||||
})
|
||||
skippedItems?.push({
|
||||
type: 'invalid_source_handle',
|
||||
operationType,
|
||||
blockId: sourceBlockId,
|
||||
reason: sourceValidation.error || `Invalid source handle "${sourceHandle}"`,
|
||||
details: { sourceHandle, targetHandle, targetId: targetBlockId },
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const targetValidation = validateTargetHandle(targetHandle)
|
||||
if (!targetValidation.valid) {
|
||||
logger.warn(`Invalid target handle. Edge skipped.`, {
|
||||
sourceBlockId,
|
||||
targetBlockId,
|
||||
targetHandle,
|
||||
error: targetValidation.error,
|
||||
})
|
||||
skippedItems?.push({
|
||||
type: 'invalid_target_handle',
|
||||
operationType,
|
||||
blockId: sourceBlockId,
|
||||
reason: targetValidation.error || `Invalid target handle "${targetHandle}"`,
|
||||
details: { sourceHandle, targetHandle, targetId: targetBlockId },
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// Use normalized handle if available (e.g., 'if' -> 'condition-{uuid}')
|
||||
const finalSourceHandle = sourceValidation.normalizedHandle || sourceHandle
|
||||
|
||||
// Avoid creating duplicate edges (e.g., when a pending connection resolves to
|
||||
// the same edge a later operation already created).
|
||||
const edgeExists = (modifiedState.edges || []).some(
|
||||
(e: any) =>
|
||||
e.source === sourceBlockId &&
|
||||
e.sourceHandle === finalSourceHandle &&
|
||||
e.target === targetBlockId &&
|
||||
e.targetHandle === targetHandle
|
||||
)
|
||||
if (edgeExists) return true
|
||||
|
||||
modifiedState.edges.push({
|
||||
id: generateId(),
|
||||
source: sourceBlockId,
|
||||
sourceHandle: finalSourceHandle,
|
||||
target: targetBlockId,
|
||||
targetHandle,
|
||||
type: 'default',
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds connections as edges for a block.
|
||||
* Supports multiple target formats:
|
||||
* - String: "target-block-id"
|
||||
* - Object: { block: "target-block-id", handle?: "custom-target-handle" }
|
||||
* - Array of strings or objects
|
||||
*/
|
||||
export function addConnectionsAsEdges(
|
||||
modifiedState: any,
|
||||
blockId: string,
|
||||
connections: Record<string, any>,
|
||||
logger: ReturnType<typeof createLogger>,
|
||||
skippedItems?: SkippedItem[]
|
||||
): void {
|
||||
const normalizeHandle = (handle: string): string => {
|
||||
if (handle === 'success') return 'source'
|
||||
return handle
|
||||
}
|
||||
|
||||
Object.entries(connections).forEach(([rawHandle, targets]) => {
|
||||
if (targets === null) return
|
||||
|
||||
const sourceHandle = normalizeHandle(rawHandle)
|
||||
|
||||
const addEdgeForTarget = (targetBlock: string, targetHandle?: string) => {
|
||||
createValidatedEdge(
|
||||
modifiedState,
|
||||
blockId,
|
||||
targetBlock,
|
||||
sourceHandle,
|
||||
targetHandle || 'target',
|
||||
'add_edge',
|
||||
logger,
|
||||
skippedItems
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof targets === 'string') {
|
||||
addEdgeForTarget(targets)
|
||||
} else if (Array.isArray(targets)) {
|
||||
targets.forEach((target: any) => {
|
||||
if (typeof target === 'string') {
|
||||
addEdgeForTarget(target)
|
||||
} else if (target?.block) {
|
||||
addEdgeForTarget(target.block, target.handle)
|
||||
}
|
||||
})
|
||||
} else if (typeof targets === 'object' && targets?.block) {
|
||||
addEdgeForTarget(targets.block, targets.handle)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function applyTriggerConfigToBlockSubblocks(block: any, triggerConfig: Record<string, any>) {
|
||||
if (!block?.subBlocks || !triggerConfig || typeof triggerConfig !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
Object.entries(triggerConfig).forEach(([configKey, configValue]) => {
|
||||
const existingSubblock = block.subBlocks[configKey]
|
||||
if (existingSubblock) {
|
||||
const existingValue = existingSubblock.value
|
||||
const valuesEqual =
|
||||
typeof existingValue === 'object' || typeof configValue === 'object'
|
||||
? JSON.stringify(existingValue) === JSON.stringify(configValue)
|
||||
: existingValue === configValue
|
||||
|
||||
if (valuesEqual) {
|
||||
return
|
||||
}
|
||||
|
||||
block.subBlocks[configKey] = {
|
||||
...existingSubblock,
|
||||
value: configValue,
|
||||
}
|
||||
} else {
|
||||
block.subBlocks[configKey] = {
|
||||
id: configKey,
|
||||
type: 'short-input',
|
||||
value: configValue,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out tools that are not allowed by the permission group config
|
||||
* Returns both the allowed tools and any skipped tool items for logging
|
||||
*/
|
||||
export function filterDisallowedTools(
|
||||
tools: any[],
|
||||
permissionConfig: PermissionGroupConfig | null,
|
||||
blockId: string,
|
||||
skippedItems: SkippedItem[]
|
||||
): any[] {
|
||||
if (!permissionConfig) {
|
||||
return tools
|
||||
}
|
||||
|
||||
const allowedTools: any[] = []
|
||||
|
||||
for (const tool of tools) {
|
||||
if (tool.type === 'custom-tool' && permissionConfig.disableCustomTools) {
|
||||
logSkippedItem(skippedItems, {
|
||||
type: 'tool_not_allowed',
|
||||
operationType: 'add',
|
||||
blockId,
|
||||
reason: `Custom tool "${tool.title || tool.customToolId || 'unknown'}" is not allowed by permission group - tool not added`,
|
||||
details: { toolType: 'custom-tool', toolId: tool.customToolId },
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (tool.type === 'mcp' && permissionConfig.disableMcpTools) {
|
||||
logSkippedItem(skippedItems, {
|
||||
type: 'tool_not_allowed',
|
||||
operationType: 'add',
|
||||
blockId,
|
||||
reason: `MCP tool "${tool.title || 'unknown'}" is not allowed by permission group - tool not added`,
|
||||
details: { toolType: 'mcp', serverId: tool.params?.serverId },
|
||||
})
|
||||
continue
|
||||
}
|
||||
allowedTools.push(tool)
|
||||
}
|
||||
|
||||
return allowedTools
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes block IDs in operations to ensure they are valid UUIDs.
|
||||
* The LLM may generate human-readable IDs like "web_search" or "research_agent"
|
||||
* which need to be converted to proper UUIDs for database compatibility.
|
||||
*
|
||||
* Returns the normalized operations and a mapping from old IDs to new UUIDs.
|
||||
*/
|
||||
export function normalizeBlockIdsInOperations(operations: EditWorkflowOperation[]): {
|
||||
normalizedOperations: EditWorkflowOperation[]
|
||||
idMapping: Map<string, string>
|
||||
} {
|
||||
const logger = createLogger('EditWorkflowServerTool')
|
||||
const idMapping = new Map<string, string>()
|
||||
|
||||
// First pass: collect all non-UUID block_ids from add/insert operations
|
||||
for (const op of operations) {
|
||||
if (op.operation_type === 'add' || op.operation_type === 'insert_into_subflow') {
|
||||
if (op.block_id && !isValidUuid(op.block_id)) {
|
||||
const newId = generateId()
|
||||
idMapping.set(op.block_id, newId)
|
||||
logger.debug('Normalizing block ID', { oldId: op.block_id, newId })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (idMapping.size === 0) {
|
||||
return { normalizedOperations: operations, idMapping }
|
||||
}
|
||||
|
||||
logger.info('Normalizing block IDs in operations', {
|
||||
normalizedCount: idMapping.size,
|
||||
mappings: Object.fromEntries(idMapping),
|
||||
})
|
||||
|
||||
// Helper to replace an ID if it's in the mapping
|
||||
const replaceId = (id: string | undefined): string | undefined => {
|
||||
if (!id) return id
|
||||
return idMapping.get(id) ?? id
|
||||
}
|
||||
|
||||
// Second pass: update all references to use new UUIDs
|
||||
const normalizedOperations = operations.map((op) => {
|
||||
const normalized: EditWorkflowOperation = {
|
||||
...op,
|
||||
block_id: replaceId(op.block_id) ?? op.block_id,
|
||||
}
|
||||
|
||||
if (op.params) {
|
||||
normalized.params = { ...op.params }
|
||||
|
||||
// Update subflowId references (for insert_into_subflow)
|
||||
if (normalized.params.subflowId) {
|
||||
normalized.params.subflowId = replaceId(normalized.params.subflowId)
|
||||
}
|
||||
|
||||
// Update connection references
|
||||
if (normalized.params.connections) {
|
||||
const normalizedConnections: Record<string, any> = {}
|
||||
for (const [handle, targets] of Object.entries(normalized.params.connections)) {
|
||||
if (typeof targets === 'string') {
|
||||
normalizedConnections[handle] = replaceId(targets)
|
||||
} else if (Array.isArray(targets)) {
|
||||
normalizedConnections[handle] = targets.map((t) => {
|
||||
if (typeof t === 'string') return replaceId(t)
|
||||
if (t && typeof t === 'object' && t.block) {
|
||||
return { ...t, block: replaceId(t.block) }
|
||||
}
|
||||
return t
|
||||
})
|
||||
} else if (targets && typeof targets === 'object' && (targets as any).block) {
|
||||
normalizedConnections[handle] = { ...targets, block: replaceId((targets as any).block) }
|
||||
} else {
|
||||
normalizedConnections[handle] = targets
|
||||
}
|
||||
}
|
||||
normalized.params.connections = normalizedConnections
|
||||
}
|
||||
|
||||
// Update nestedNodes block IDs
|
||||
if (normalized.params.nestedNodes) {
|
||||
const normalizedNestedNodes: Record<string, any> = {}
|
||||
for (const [childId, childBlock] of Object.entries(normalized.params.nestedNodes)) {
|
||||
const newChildId = replaceId(childId) ?? childId
|
||||
normalizedNestedNodes[newChildId] = childBlock
|
||||
}
|
||||
normalized.params.nestedNodes = normalizedNestedNodes
|
||||
}
|
||||
}
|
||||
|
||||
return normalized
|
||||
})
|
||||
|
||||
return { normalizedOperations, idMapping }
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
|
||||
import { isValidKey } from '@/lib/workflows/sanitization/key-validation'
|
||||
import { validateEdges } from '@/stores/workflows/workflow/edge-validation'
|
||||
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
|
||||
import {
|
||||
addConnectionsAsEdges,
|
||||
createValidatedEdge,
|
||||
normalizeBlockIdsInOperations,
|
||||
} from './builders'
|
||||
import {
|
||||
handleAddOperation,
|
||||
handleDeleteOperation,
|
||||
handleEditOperation,
|
||||
handleExtractFromSubflowOperation,
|
||||
handleInsertIntoSubflowOperation,
|
||||
} from './operations'
|
||||
import type {
|
||||
ApplyOperationsResult,
|
||||
EditWorkflowOperation,
|
||||
OperationContext,
|
||||
ValidationError,
|
||||
} from './types'
|
||||
import { logSkippedItem, type SkippedItem } from './types'
|
||||
|
||||
const logger = createLogger('EditWorkflowServerTool')
|
||||
|
||||
type OperationHandler = (op: EditWorkflowOperation, ctx: OperationContext) => void
|
||||
|
||||
const OPERATION_HANDLERS: Record<EditWorkflowOperation['operation_type'], OperationHandler> = {
|
||||
delete: handleDeleteOperation,
|
||||
extract_from_subflow: handleExtractFromSubflowOperation,
|
||||
add: handleAddOperation,
|
||||
insert_into_subflow: handleInsertIntoSubflowOperation,
|
||||
edit: handleEditOperation,
|
||||
}
|
||||
|
||||
/**
|
||||
* Topologically sort insert operations to ensure parents are created before children
|
||||
* Returns sorted array where parent inserts always come before child inserts
|
||||
*/
|
||||
export function topologicalSortInserts(
|
||||
inserts: EditWorkflowOperation[],
|
||||
adds: EditWorkflowOperation[]
|
||||
): EditWorkflowOperation[] {
|
||||
if (inserts.length === 0) return []
|
||||
|
||||
// Build a map of blockId -> operation for quick lookup
|
||||
const insertMap = new Map<string, EditWorkflowOperation>()
|
||||
inserts.forEach((op) => insertMap.set(op.block_id, op))
|
||||
|
||||
// Build a set of blocks being added (potential parents)
|
||||
const addedBlocks = new Set(adds.map((op) => op.block_id))
|
||||
|
||||
// Build dependency graph: block -> blocks that depend on it
|
||||
const dependents = new Map<string, Set<string>>()
|
||||
const dependencies = new Map<string, Set<string>>()
|
||||
|
||||
inserts.forEach((op) => {
|
||||
const blockId = op.block_id
|
||||
const parentId = op.params?.subflowId
|
||||
|
||||
dependencies.set(blockId, new Set())
|
||||
|
||||
if (parentId) {
|
||||
// Track dependency if parent is being inserted OR being added
|
||||
// This ensures children wait for parents regardless of operation type
|
||||
const parentBeingCreated = insertMap.has(parentId) || addedBlocks.has(parentId)
|
||||
|
||||
if (parentBeingCreated) {
|
||||
// Only add dependency if parent is also being inserted (not added)
|
||||
// Because adds run before inserts, added parents are already created
|
||||
if (insertMap.has(parentId)) {
|
||||
dependencies.get(blockId)!.add(parentId)
|
||||
if (!dependents.has(parentId)) {
|
||||
dependents.set(parentId, new Set())
|
||||
}
|
||||
dependents.get(parentId)!.add(blockId)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Topological sort using Kahn's algorithm
|
||||
const sorted: EditWorkflowOperation[] = []
|
||||
const queue: string[] = []
|
||||
|
||||
// Start with nodes that have no dependencies (or depend only on added blocks)
|
||||
inserts.forEach((op) => {
|
||||
const deps = dependencies.get(op.block_id)!
|
||||
if (deps.size === 0) {
|
||||
queue.push(op.block_id)
|
||||
}
|
||||
})
|
||||
|
||||
while (queue.length > 0) {
|
||||
const blockId = queue.shift()!
|
||||
const op = insertMap.get(blockId)
|
||||
if (op) {
|
||||
sorted.push(op)
|
||||
}
|
||||
|
||||
// Remove this node from dependencies of others
|
||||
const children = dependents.get(blockId)
|
||||
if (children) {
|
||||
children.forEach((childId) => {
|
||||
const childDeps = dependencies.get(childId)!
|
||||
childDeps.delete(blockId)
|
||||
if (childDeps.size === 0) {
|
||||
queue.push(childId)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// If sorted length doesn't match input, there's a cycle (shouldn't happen with valid operations)
|
||||
// Just append remaining operations
|
||||
if (sorted.length < inserts.length) {
|
||||
inserts.forEach((op) => {
|
||||
if (!sorted.includes(op)) {
|
||||
sorted.push(op)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return sorted
|
||||
}
|
||||
|
||||
function orderOperations(operations: EditWorkflowOperation[]): EditWorkflowOperation[] {
|
||||
/**
|
||||
* Reorder operations to ensure correct execution sequence:
|
||||
* 1. delete - Remove blocks first to free up IDs and clean state
|
||||
* 2. extract_from_subflow - Extract blocks from subflows before modifications
|
||||
* 3. add - Create new blocks (sorted by connection dependencies)
|
||||
* 4. insert_into_subflow - Insert blocks into subflows (sorted by parent dependency)
|
||||
* 5. edit - Edit existing blocks last, so connections to newly added blocks work
|
||||
*/
|
||||
const deletes = operations.filter((op) => op.operation_type === 'delete')
|
||||
const extracts = operations.filter((op) => op.operation_type === 'extract_from_subflow')
|
||||
const adds = operations.filter((op) => op.operation_type === 'add')
|
||||
const inserts = operations.filter((op) => op.operation_type === 'insert_into_subflow')
|
||||
const edits = operations.filter((op) => op.operation_type === 'edit')
|
||||
|
||||
// Sort insert operations to ensure parents are inserted before children
|
||||
const sortedInserts = topologicalSortInserts(inserts, adds)
|
||||
|
||||
return [...deletes, ...extracts, ...adds, ...sortedInserts, ...edits]
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply operations directly to the workflow JSON state
|
||||
*/
|
||||
export function applyOperationsToWorkflowState(
|
||||
workflowState: Record<string, unknown>,
|
||||
operations: EditWorkflowOperation[],
|
||||
permissionConfig: PermissionGroupConfig | null = null
|
||||
): ApplyOperationsResult {
|
||||
// Deep clone the workflow state to avoid mutations
|
||||
const modifiedState = structuredClone(workflowState)
|
||||
|
||||
// Collect validation errors across all operations
|
||||
const validationErrors: ValidationError[] = []
|
||||
|
||||
// Collect skipped items across all operations
|
||||
const skippedItems: SkippedItem[] = []
|
||||
|
||||
// Normalize block IDs to UUIDs before processing
|
||||
const { normalizedOperations } = normalizeBlockIdsInOperations(operations)
|
||||
|
||||
// Order operations for deterministic application
|
||||
const orderedOperations = orderOperations(normalizedOperations)
|
||||
|
||||
logger.info('Applying operations to workflow:', {
|
||||
totalOperations: orderedOperations.length,
|
||||
operationTypes: orderedOperations.reduce((acc: Record<string, number>, op) => {
|
||||
acc[op.operation_type] = (acc[op.operation_type] || 0) + 1
|
||||
return acc
|
||||
}, {}),
|
||||
initialBlockCount: Object.keys((modifiedState as any).blocks || {}).length,
|
||||
})
|
||||
|
||||
const ctx: OperationContext = {
|
||||
modifiedState,
|
||||
skippedItems,
|
||||
validationErrors,
|
||||
permissionConfig,
|
||||
deferredConnections: [],
|
||||
}
|
||||
|
||||
for (const operation of orderedOperations) {
|
||||
const { operation_type, block_id } = operation
|
||||
|
||||
// CRITICAL: Validate block_id is a valid string and not "undefined"
|
||||
// This prevents undefined keys from being set in the workflow state
|
||||
if (!isValidKey(block_id)) {
|
||||
logSkippedItem(skippedItems, {
|
||||
type: 'missing_required_params',
|
||||
operationType: operation_type,
|
||||
blockId: String(block_id || 'invalid'),
|
||||
reason: `Invalid block_id "${block_id}" (type: ${typeof block_id}) - operation skipped. Block IDs must be valid non-empty strings.`,
|
||||
})
|
||||
logger.error('Invalid block_id detected in operation', {
|
||||
operation_type,
|
||||
block_id,
|
||||
block_id_type: typeof block_id,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const handler = OPERATION_HANDLERS[operation_type]
|
||||
if (!handler) continue
|
||||
|
||||
logger.debug(`Executing operation: ${operation_type} for block ${block_id}`, {
|
||||
params: operation.params ? Object.keys(operation.params) : [],
|
||||
currentBlockCount: Object.keys((modifiedState as any).blocks || {}).length,
|
||||
})
|
||||
|
||||
handler(operation, ctx)
|
||||
}
|
||||
|
||||
// Pass 2: Create all edges from deferred connections
|
||||
// All blocks exist at this point, so forward references resolve correctly
|
||||
if (ctx.deferredConnections.length > 0) {
|
||||
logger.info('Processing deferred connections from add/insert operations', {
|
||||
deferredConnectionCount: ctx.deferredConnections.length,
|
||||
totalBlocks: Object.keys((modifiedState as any).blocks || {}).length,
|
||||
})
|
||||
|
||||
for (const { blockId, connections } of ctx.deferredConnections) {
|
||||
// Verify the source block still exists (it might have been deleted by a later operation)
|
||||
if (!(modifiedState as any).blocks[blockId]) {
|
||||
logger.warn('Source block no longer exists for deferred connection', {
|
||||
blockId,
|
||||
availableBlocks: Object.keys((modifiedState as any).blocks || {}),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
addConnectionsAsEdges(modifiedState, blockId, connections, logger, skippedItems)
|
||||
}
|
||||
|
||||
logger.info('Finished processing deferred connections', {
|
||||
totalEdges: (modifiedState as any).edges?.length,
|
||||
})
|
||||
}
|
||||
|
||||
// Pass 3: resolve pending connections whose target block now exists. These are
|
||||
// forward-reference connections that were recorded (on block.data) when their
|
||||
// target didn't exist yet — possibly in an earlier edit_workflow call. Now
|
||||
// that this batch's blocks are all created, create the edges that resolve.
|
||||
resolvePendingConnections(modifiedState, skippedItems)
|
||||
|
||||
// Remove edges that cross scope boundaries. This runs after all operations
|
||||
// and deferred connections are applied so that every block has its final
|
||||
// parentId. Running it per-operation would incorrectly drop edges between
|
||||
// blocks that are both being moved into the same subflow in one batch.
|
||||
removeInvalidScopeEdges(modifiedState, skippedItems)
|
||||
|
||||
// Regenerate loops and parallels after modifications
|
||||
|
||||
;(modifiedState as any).loops = generateLoopBlocks((modifiedState as any).blocks)
|
||||
;(modifiedState as any).parallels = generateParallelBlocks((modifiedState as any).blocks)
|
||||
|
||||
// Validate all blocks have types before returning
|
||||
const blocksWithoutType = Object.entries((modifiedState as any).blocks || {})
|
||||
.filter(([_, block]: [string, any]) => !block.type || block.type === undefined)
|
||||
.map(([id, block]: [string, any]) => ({ id, block }))
|
||||
|
||||
if (blocksWithoutType.length > 0) {
|
||||
logger.error('Blocks without type after operations:', {
|
||||
blocksWithoutType: blocksWithoutType.map(({ id, block }) => ({
|
||||
id,
|
||||
type: block.type,
|
||||
name: block.name,
|
||||
keys: Object.keys(block),
|
||||
})),
|
||||
})
|
||||
|
||||
// Attempt to fix by removing type-less blocks
|
||||
blocksWithoutType.forEach(({ id }) => {
|
||||
delete (modifiedState as any).blocks[id]
|
||||
})
|
||||
|
||||
// Remove edges connected to removed blocks
|
||||
const removedIds = new Set(blocksWithoutType.map(({ id }) => id))
|
||||
;(modifiedState as any).edges = ((modifiedState as any).edges || []).filter(
|
||||
(edge: any) => !removedIds.has(edge.source) && !removedIds.has(edge.target)
|
||||
)
|
||||
}
|
||||
|
||||
return { state: modifiedState, validationErrors, skippedItems }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves pending forward-reference connections recorded on block.data.
|
||||
*
|
||||
* When a connection references a target block that does not exist yet, the edge
|
||||
* is recorded as pending on the source block instead of being dropped. This runs
|
||||
* after all blocks for the current batch exist (and picks up pending entries
|
||||
* persisted from earlier edit_workflow calls), creating any edges whose target
|
||||
* now exists and leaving still-unresolved entries pending.
|
||||
*/
|
||||
function resolvePendingConnections(modifiedState: any, skippedItems: SkippedItem[]): void {
|
||||
const blocks = modifiedState.blocks || {}
|
||||
for (const [sourceId, block] of Object.entries(blocks) as [string, any][]) {
|
||||
const pending = block?.data?.pendingConnections as
|
||||
| Record<string, Array<{ target: string; targetHandle: string }>>
|
||||
| undefined
|
||||
if (!pending) continue
|
||||
|
||||
for (const [handle, targets] of Object.entries(pending)) {
|
||||
const stillPending: Array<{ target: string; targetHandle: string }> = []
|
||||
for (const { target, targetHandle } of targets) {
|
||||
if (blocks[target]) {
|
||||
createValidatedEdge(
|
||||
modifiedState,
|
||||
sourceId,
|
||||
target,
|
||||
handle,
|
||||
targetHandle || 'target',
|
||||
'resolve_pending_connection',
|
||||
logger,
|
||||
skippedItems
|
||||
)
|
||||
} else {
|
||||
stillPending.push({ target, targetHandle })
|
||||
}
|
||||
}
|
||||
if (stillPending.length > 0) {
|
||||
pending[handle] = stillPending
|
||||
} else {
|
||||
delete pending[handle]
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(pending).length === 0) {
|
||||
block.data.pendingConnections = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes edges that cross scope boundaries after all operations are applied.
|
||||
* An edge is invalid if:
|
||||
* - Either endpoint no longer exists (dangling reference)
|
||||
* - The source and target are in incompatible scopes
|
||||
* - A child block connects to its own parent container (non-handle edge)
|
||||
*
|
||||
* Valid scope relationships:
|
||||
* - Same scope: both blocks share the same parentId
|
||||
* - Container→child: source is the parent container of the target (start handles)
|
||||
* - Child→container: target is the parent container of the source (end handles)
|
||||
*/
|
||||
function removeInvalidScopeEdges(modifiedState: any, skippedItems: SkippedItem[]): void {
|
||||
const { valid, dropped } = validateEdges(modifiedState.edges || [], modifiedState.blocks || {})
|
||||
modifiedState.edges = valid
|
||||
|
||||
if (dropped.length > 0) {
|
||||
for (const { edge, reason } of dropped) {
|
||||
logSkippedItem(skippedItems, {
|
||||
type: 'invalid_edge_scope',
|
||||
operationType: 'add_edge',
|
||||
blockId: edge.source,
|
||||
reason: `Edge from "${edge.source}" to "${edge.target}" skipped - ${reason}`,
|
||||
details: {
|
||||
edgeId: edge.id,
|
||||
sourceHandle: edge.sourceHandle,
|
||||
targetHandle: edge.targetHandle,
|
||||
targetId: edge.target,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
logger.info('Removed invalid workflow edges', {
|
||||
removed: dropped.length,
|
||||
reasons: dropped.map(({ reason }) => reason),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflow as workflowTable } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import {
|
||||
assertWorkflowMutable,
|
||||
authorizeWorkflowByWorkspacePermission,
|
||||
} from '@sim/platform-authz/workflow'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { EditWorkflow } from '@/lib/copilot/generated/tool-catalog-v1'
|
||||
import {
|
||||
assertServerToolNotAborted,
|
||||
type BaseServerTool,
|
||||
type ServerToolContext,
|
||||
} from '@/lib/copilot/tools/server/base-tool'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { getSocketServerUrl } from '@/lib/core/utils/urls'
|
||||
import {
|
||||
applyTargetedLayout,
|
||||
getTargetedLayoutImpact,
|
||||
transferBlockHeights,
|
||||
} from '@/lib/workflows/autolayout'
|
||||
import {
|
||||
DEFAULT_HORIZONTAL_SPACING,
|
||||
DEFAULT_VERTICAL_SPACING,
|
||||
} from '@/lib/workflows/autolayout/constants'
|
||||
import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence'
|
||||
import {
|
||||
loadWorkflowFromNormalizedTables,
|
||||
saveWorkflowToNormalizedTables,
|
||||
} from '@/lib/workflows/persistence/utils'
|
||||
import { validateWorkflowState } from '@/lib/workflows/sanitization/validation'
|
||||
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
|
||||
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
|
||||
import { normalizeWorkflowState } from '@/stores/workflows/workflow/validation'
|
||||
import { applyOperationsToWorkflowState } from './engine'
|
||||
import {
|
||||
collectWorkflowFieldIssues,
|
||||
formatWorkflowLintMessage,
|
||||
hasWorkflowLintIssues,
|
||||
lintEditedWorkflowState,
|
||||
type WorkflowLintReport,
|
||||
type WorkflowLintUnresolvedReference,
|
||||
} from './lint'
|
||||
import { type EditWorkflowParams, isDeferredSkippedItem, type ValidationError } from './types'
|
||||
import {
|
||||
collectUnresolvedAgentToolReferences,
|
||||
collectUnresolvedReferences,
|
||||
preValidateCredentialInputs,
|
||||
UNRESOLVABLE_AT_LINT_NOTE,
|
||||
} from './validation'
|
||||
|
||||
async function getCurrentWorkflowStateFromDb(
|
||||
workflowId: string
|
||||
): Promise<{ workflowState: any; subBlockValues: Record<string, Record<string, any>> }> {
|
||||
const logger = createLogger('EditWorkflowServerTool')
|
||||
const [workflowRecord] = await db
|
||||
.select()
|
||||
.from(workflowTable)
|
||||
.where(eq(workflowTable.id, workflowId))
|
||||
.limit(1)
|
||||
if (!workflowRecord) throw new Error(`Workflow ${workflowId} not found in database`)
|
||||
const normalized = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
if (!normalized) throw new Error('Workflow has no normalized data')
|
||||
|
||||
const { state: validatedState, warnings } = normalizeWorkflowState({
|
||||
blocks: normalized.blocks,
|
||||
edges: normalized.edges,
|
||||
loops: normalized.loops || {},
|
||||
parallels: normalized.parallels || {},
|
||||
})
|
||||
|
||||
if (warnings.length > 0) {
|
||||
logger.warn('Normalized workflow state loaded from DB for copilot', {
|
||||
workflowId,
|
||||
warningCount: warnings.length,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
const subBlockValues: Record<string, Record<string, any>> = {}
|
||||
Object.entries(validatedState.blocks).forEach(([blockId, block]) => {
|
||||
subBlockValues[blockId] = {}
|
||||
Object.entries((block as any).subBlocks || {}).forEach(([subId, sub]) => {
|
||||
if ((sub as any).value !== undefined) subBlockValues[blockId][subId] = (sub as any).value
|
||||
})
|
||||
})
|
||||
return { workflowState: validatedState, subBlockValues }
|
||||
}
|
||||
|
||||
export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, unknown> = {
|
||||
name: EditWorkflow.id,
|
||||
async execute(params: EditWorkflowParams, context?: ServerToolContext): Promise<unknown> {
|
||||
const logger = createLogger('EditWorkflowServerTool')
|
||||
const { operations, workflowId, currentUserWorkflow } = params
|
||||
if (!Array.isArray(operations) || operations.length === 0) {
|
||||
throw new Error('operations are required and must be an array')
|
||||
}
|
||||
if (!workflowId) throw new Error('workflowId is required')
|
||||
if (!context?.userId) {
|
||||
throw new Error('Unauthorized workflow access')
|
||||
}
|
||||
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId: context.userId,
|
||||
action: 'write',
|
||||
})
|
||||
if (!authorization.allowed) {
|
||||
throw new Error(authorization.message || 'Unauthorized workflow access')
|
||||
}
|
||||
|
||||
await assertWorkflowMutable(workflowId)
|
||||
|
||||
const workspaceId = authorization.workflow?.workspaceId ?? undefined
|
||||
const workflowName = authorization.workflow?.name ?? undefined
|
||||
|
||||
logger.info('Executing edit_workflow', {
|
||||
operationCount: operations.length,
|
||||
workflowId,
|
||||
hasCurrentUserWorkflow: !!currentUserWorkflow,
|
||||
chatId: context.chatId,
|
||||
})
|
||||
|
||||
assertServerToolNotAborted(context)
|
||||
|
||||
let workflowState: any
|
||||
if (currentUserWorkflow) {
|
||||
try {
|
||||
workflowState = JSON.parse(currentUserWorkflow)
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse currentUserWorkflow', error)
|
||||
throw new Error('Invalid currentUserWorkflow format')
|
||||
}
|
||||
} else {
|
||||
const fromDb = await getCurrentWorkflowStateFromDb(workflowId)
|
||||
workflowState = fromDb.workflowState
|
||||
}
|
||||
|
||||
const permissionConfig =
|
||||
context?.userId && workspaceId
|
||||
? await getUserPermissionConfig(context.userId, workspaceId)
|
||||
: null
|
||||
|
||||
// Pre-validate credential and apiKey inputs before applying operations
|
||||
// This filters out invalid credentials and apiKeys for hosted models
|
||||
let operationsToApply = operations
|
||||
const credentialErrors: ValidationError[] = []
|
||||
if (context?.userId) {
|
||||
const { filteredOperations, errors: credErrors } = await preValidateCredentialInputs(
|
||||
operations,
|
||||
{ userId: context.userId, workspaceId },
|
||||
workflowState
|
||||
)
|
||||
operationsToApply = filteredOperations
|
||||
credentialErrors.push(...credErrors)
|
||||
}
|
||||
|
||||
// Apply operations directly to the workflow state
|
||||
const {
|
||||
state: modifiedWorkflowState,
|
||||
validationErrors,
|
||||
skippedItems,
|
||||
} = applyOperationsToWorkflowState(workflowState, operationsToApply, permissionConfig)
|
||||
|
||||
// Add credential validation errors
|
||||
validationErrors.push(...credentialErrors)
|
||||
|
||||
// Resolve credential/resource references against the workspace (Tier 2).
|
||||
// Includes oauth-input credentials and only the active canonical member, so a
|
||||
// credential "set in basic mode but unresolved in the dropdown" is caught.
|
||||
let unresolvedReferences: WorkflowLintUnresolvedReference[] = []
|
||||
if (context?.userId) {
|
||||
try {
|
||||
unresolvedReferences = await collectUnresolvedReferences(modifiedWorkflowState, {
|
||||
userId: context.userId,
|
||||
workspaceId,
|
||||
})
|
||||
// Back-compat: also surface unresolved references through the input-validation channel.
|
||||
validationErrors.push(
|
||||
...unresolvedReferences.map((ref) => ({
|
||||
blockId: ref.blockId,
|
||||
blockType: ref.blockType ?? 'unknown',
|
||||
field: ref.field,
|
||||
value: ref.value,
|
||||
error: ref.reason,
|
||||
}))
|
||||
)
|
||||
} catch (error) {
|
||||
logger.warn('Selector ID validation failed', {
|
||||
error: toError(error).message,
|
||||
})
|
||||
}
|
||||
|
||||
// Resolve agent-block tool/skill references (custom tools, MCP servers,
|
||||
// skills). A well-shaped entry whose id does not resolve is dropped at
|
||||
// runtime, so the agent silently loses the tool/skill - surface it through
|
||||
// the same lint + input-validation channels as credential/resource refs.
|
||||
try {
|
||||
const toolReferences = await collectUnresolvedAgentToolReferences(modifiedWorkflowState, {
|
||||
userId: context.userId,
|
||||
workspaceId,
|
||||
})
|
||||
unresolvedReferences.push(...toolReferences)
|
||||
validationErrors.push(
|
||||
...toolReferences.map((ref) => ({
|
||||
blockId: ref.blockId,
|
||||
blockType: ref.blockType ?? 'agent',
|
||||
field: ref.field,
|
||||
value: ref.value,
|
||||
error: ref.reason,
|
||||
}))
|
||||
)
|
||||
} catch (error) {
|
||||
logger.warn('Agent tool/skill reference validation failed', {
|
||||
error: toError(error).message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the workflow state
|
||||
const validation = validateWorkflowState(modifiedWorkflowState, { sanitize: true })
|
||||
|
||||
if (!validation.valid) {
|
||||
logger.error('Edited workflow state is invalid', {
|
||||
errors: validation.errors,
|
||||
warnings: validation.warnings,
|
||||
})
|
||||
throw new Error(`Invalid edited workflow: ${validation.errors.join('; ')}`)
|
||||
}
|
||||
|
||||
if (validation.warnings.length > 0) {
|
||||
logger.warn('Edited workflow validation warnings', {
|
||||
warnings: validation.warnings,
|
||||
})
|
||||
}
|
||||
|
||||
// Extract and persist custom tools to database (reuse workspaceId from selector validation)
|
||||
if (context?.userId && workspaceId) {
|
||||
try {
|
||||
assertServerToolNotAborted(context)
|
||||
const finalWorkflowState = validation.sanitizedState || modifiedWorkflowState
|
||||
const { saved, errors } = await extractAndPersistCustomTools(
|
||||
finalWorkflowState,
|
||||
workspaceId,
|
||||
context.userId
|
||||
)
|
||||
|
||||
if (saved > 0) {
|
||||
logger.info(`Persisted ${saved} custom tool(s) to database`, { workflowId })
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
logger.warn('Some custom tools failed to persist', { errors, workflowId })
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to persist custom tools', { error, workflowId })
|
||||
}
|
||||
} else if (context?.userId && !workspaceId) {
|
||||
logger.warn('Workflow has no workspaceId, skipping custom tools persistence', {
|
||||
workflowId,
|
||||
})
|
||||
} else {
|
||||
logger.warn('No userId in context - skipping custom tools persistence', { workflowId })
|
||||
}
|
||||
|
||||
logger.info('edit_workflow successfully applied operations', {
|
||||
operationCount: operations.length,
|
||||
blocksCount: Object.keys(modifiedWorkflowState.blocks).length,
|
||||
edgesCount: modifiedWorkflowState.edges.length,
|
||||
inputValidationErrors: validationErrors.length,
|
||||
skippedItemsCount: skippedItems.length,
|
||||
schemaValidationErrors: validation.errors.length,
|
||||
validationWarnings: validation.warnings.length,
|
||||
})
|
||||
|
||||
// Format validation errors for LLM feedback
|
||||
const inputErrors =
|
||||
validationErrors.length > 0
|
||||
? validationErrors.map((e) => `Block "${e.blockId}" (${e.blockType}): ${e.error}`)
|
||||
: undefined
|
||||
|
||||
// Split engine skipped items into genuine failures vs benign, self-healing
|
||||
// deferrals. A deferred forward-reference edge (invalid_edge_target) is NOT
|
||||
// a failure: the engine wires it automatically once its target block exists
|
||||
// (this call or a later one) via pendingConnections. Surfacing it through
|
||||
// the same "skipped" failure channel as real skips makes a literal model
|
||||
// thrash (re-issuing a self-healing op). Keep them in separate result fields
|
||||
// and preserve item.type/details so the prompt can branch on a
|
||||
// machine-readable category instead of pattern-matching prose.
|
||||
const mapSkippedItem = (item: (typeof skippedItems)[number]) => ({
|
||||
type: item.type,
|
||||
operationType: item.operationType,
|
||||
blockId: item.blockId,
|
||||
reason: item.reason,
|
||||
...(item.details && { details: item.details }),
|
||||
})
|
||||
|
||||
const genuineSkippedItems = skippedItems.filter((item) => !isDeferredSkippedItem(item))
|
||||
const deferredItems = skippedItems.filter((item) => isDeferredSkippedItem(item))
|
||||
|
||||
const skippedDetails =
|
||||
genuineSkippedItems.length > 0 ? genuineSkippedItems.map(mapSkippedItem) : undefined
|
||||
const deferredDetails = deferredItems.length > 0 ? deferredItems.map(mapSkippedItem) : undefined
|
||||
|
||||
// Persist the workflow state to the database
|
||||
const finalWorkflowState = validation.sanitizedState || modifiedWorkflowState
|
||||
|
||||
const { layoutBlockIds, resizedBlockIds, shiftSourceBlockIds } = getTargetedLayoutImpact({
|
||||
before: workflowState,
|
||||
after: finalWorkflowState,
|
||||
})
|
||||
|
||||
let layoutedBlocks = finalWorkflowState.blocks
|
||||
|
||||
if (layoutBlockIds.length > 0 || resizedBlockIds.length > 0 || shiftSourceBlockIds.length > 0) {
|
||||
try {
|
||||
transferBlockHeights(workflowState.blocks, finalWorkflowState.blocks)
|
||||
layoutedBlocks = applyTargetedLayout(finalWorkflowState.blocks, finalWorkflowState.edges, {
|
||||
changedBlockIds: layoutBlockIds,
|
||||
resizedBlockIds,
|
||||
shiftSourceBlockIds,
|
||||
horizontalSpacing: DEFAULT_HORIZONTAL_SPACING,
|
||||
verticalSpacing: DEFAULT_VERTICAL_SPACING,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.warn('Targeted autolayout failed, using default positions', {
|
||||
workflowId,
|
||||
error: toError(error).message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const workflowStateForDb = {
|
||||
blocks: layoutedBlocks,
|
||||
edges: finalWorkflowState.edges,
|
||||
loops: generateLoopBlocks(layoutedBlocks as any),
|
||||
parallels: generateParallelBlocks(layoutedBlocks as any),
|
||||
lastSaved: Date.now(),
|
||||
isDeployed: false,
|
||||
}
|
||||
|
||||
// Aggregate lint report: graph (sources/sinks/orphans/ports) + Tier-1 config
|
||||
// (required + canonical-mode) + Tier-2 resolution (credential/resource IDs).
|
||||
const graphLint = lintEditedWorkflowState(workflowStateForDb as any)
|
||||
const fieldIssues = collectWorkflowFieldIssues(workflowStateForDb.blocks as any)
|
||||
const workflowLint: WorkflowLintReport = {
|
||||
...graphLint,
|
||||
fieldIssues,
|
||||
unresolvedReferences,
|
||||
notes: unresolvedReferences.length > 0 ? [UNRESOLVABLE_AT_LINT_NOTE] : [],
|
||||
}
|
||||
const workflowLintMessage = hasWorkflowLintIssues(workflowLint)
|
||||
? formatWorkflowLintMessage(workflowLint)
|
||||
: undefined
|
||||
|
||||
assertServerToolNotAborted(context)
|
||||
const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowStateForDb as any)
|
||||
if (!saveResult.success) {
|
||||
logger.error('Failed to persist workflow state to database', {
|
||||
workflowId,
|
||||
error: saveResult.error,
|
||||
})
|
||||
throw new Error(`Failed to save workflow: ${saveResult.error}`)
|
||||
}
|
||||
|
||||
// Update workflow's lastSynced timestamp
|
||||
assertServerToolNotAborted(context)
|
||||
await db
|
||||
.update(workflowTable)
|
||||
.set({
|
||||
lastSynced: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workflowTable.id, workflowId))
|
||||
|
||||
logger.info('Workflow state persisted to database', { workflowId })
|
||||
|
||||
fetch(`${getSocketServerUrl()}/api/workflow-updated`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': env.INTERNAL_API_SECRET,
|
||||
},
|
||||
body: JSON.stringify({ workflowId }),
|
||||
}).catch((error) => {
|
||||
logger.warn('Failed to notify socket server of workflow update', { workflowId, error })
|
||||
})
|
||||
|
||||
const sanitizationWarnings = validation.warnings.length > 0 ? validation.warnings : undefined
|
||||
|
||||
return {
|
||||
success: true,
|
||||
workflowId,
|
||||
workflowName: workflowName ?? 'Workflow',
|
||||
workflowState: { ...finalWorkflowState, blocks: layoutedBlocks },
|
||||
workflowLint,
|
||||
...(workflowLintMessage && { workflowLintMessage }),
|
||||
...(inputErrors && {
|
||||
inputValidationErrors: inputErrors,
|
||||
inputValidationMessage: `${inputErrors.length} input(s) were rejected due to validation errors. The workflow was still updated with valid inputs only. Errors: ${inputErrors.join('; ')}`,
|
||||
}),
|
||||
...(skippedDetails && {
|
||||
skippedItems: skippedDetails,
|
||||
skippedItemsMessage: `${skippedDetails.length} operation(s) were skipped (not applied) and need attention. Each item includes a machine-readable "type" (e.g. block_not_found, block_locked, duplicate_block_name, invalid_block_type, invalid_source_handle, invalid_target_handle, invalid_edge_scope). Details: ${skippedDetails.map((item) => item.reason).join('; ')}`,
|
||||
}),
|
||||
...(deferredDetails && {
|
||||
deferredConnections: deferredDetails,
|
||||
deferredMessage: `${deferredDetails.length} edge(s) were deferred because their target block does not exist yet. This is NOT a failure and does NOT need fixing: the engine wires these edges automatically once the target block exists (in this edit or a later one). Do not re-issue them. Only act on a deferred edge if its target id was a typo or hallucination that you do not intend to create. Details: ${deferredDetails.map((item) => item.reason).join('; ')}`,
|
||||
}),
|
||||
...(sanitizationWarnings && {
|
||||
sanitizationWarnings,
|
||||
sanitizationMessage: `${sanitizationWarnings.length} field(s) were automatically sanitized: ${sanitizationWarnings.join('; ')}`,
|
||||
}),
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { hasWorkflowLintIssues, lintEditedWorkflowState } from './lint'
|
||||
|
||||
function baseBlock(id: string, type: string, name: string, subBlocks: Record<string, any> = {}) {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
enabled: true,
|
||||
position: { x: 0, y: 0 },
|
||||
subBlocks,
|
||||
outputs: {},
|
||||
}
|
||||
}
|
||||
|
||||
describe('lintEditedWorkflowState', () => {
|
||||
it('reports orphan blocks but allows unconnected condition/router branches', () => {
|
||||
const workflowState = {
|
||||
blocks: {
|
||||
start: baseBlock('start', 'starter', 'Start'),
|
||||
condition: baseBlock('condition', 'condition', 'Condition', {
|
||||
conditions: {
|
||||
value: JSON.stringify([
|
||||
{ id: 'condition-if', title: 'if', value: 'true' },
|
||||
{ id: 'condition-else', title: 'else', value: '' },
|
||||
]),
|
||||
},
|
||||
}),
|
||||
router: baseBlock('router', 'router_v2', 'Router', {
|
||||
routes: {
|
||||
value: [
|
||||
{ id: 'route-1', title: 'Route 1', value: 'support' },
|
||||
{ id: 'route-2', title: 'Route 2', value: 'sales' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
agent: baseBlock('agent', 'agent', 'Agent'),
|
||||
function: baseBlock('function', 'function', 'Orphan Function'),
|
||||
note: baseBlock('note', 'note', 'Note'),
|
||||
},
|
||||
edges: [
|
||||
{
|
||||
id: 'edge-start-condition',
|
||||
source: 'start',
|
||||
sourceHandle: 'source',
|
||||
target: 'condition',
|
||||
targetHandle: 'target',
|
||||
},
|
||||
{
|
||||
id: 'edge-start-router',
|
||||
source: 'start',
|
||||
sourceHandle: 'source',
|
||||
target: 'router',
|
||||
targetHandle: 'target',
|
||||
},
|
||||
{
|
||||
id: 'edge-condition-agent',
|
||||
source: 'condition',
|
||||
sourceHandle: 'if',
|
||||
target: 'agent',
|
||||
targetHandle: 'target',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const lint = lintEditedWorkflowState(workflowState as any)
|
||||
|
||||
expect(lint.orphanBlocks).toEqual([
|
||||
{ blockId: 'function', blockName: 'Orphan Function', blockType: 'function' },
|
||||
])
|
||||
expect(lint.emptyOutgoingPorts).toEqual([])
|
||||
expect(lint.invalidBranchPorts).toEqual([])
|
||||
expect(hasWorkflowLintIssues(lint)).toBe(true)
|
||||
})
|
||||
|
||||
it('reports invalid branch handles and missing connection targets', () => {
|
||||
const workflowState = {
|
||||
blocks: {
|
||||
start: baseBlock('start', 'starter', 'Start'),
|
||||
condition: baseBlock('condition', 'condition', 'Condition', {
|
||||
conditions: {
|
||||
value: [{ id: 'condition-if', title: 'if', value: 'true' }],
|
||||
},
|
||||
}),
|
||||
agent: baseBlock('agent', 'agent', 'Agent'),
|
||||
},
|
||||
edges: [
|
||||
{
|
||||
id: 'edge-start-condition',
|
||||
source: 'start',
|
||||
sourceHandle: 'source',
|
||||
target: 'condition',
|
||||
targetHandle: 'target',
|
||||
},
|
||||
{
|
||||
id: 'edge-condition-agent',
|
||||
source: 'condition',
|
||||
sourceHandle: 'else',
|
||||
target: 'agent',
|
||||
targetHandle: 'target',
|
||||
},
|
||||
{
|
||||
id: 'edge-agent-missing',
|
||||
source: 'agent',
|
||||
sourceHandle: 'source',
|
||||
target: 'missing',
|
||||
targetHandle: 'target',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const lint = lintEditedWorkflowState(workflowState as any)
|
||||
|
||||
expect(lint.invalidBranchPorts).toEqual([
|
||||
expect.objectContaining({
|
||||
blockId: 'condition',
|
||||
sourceHandle: 'else',
|
||||
}),
|
||||
])
|
||||
expect(lint.invalidConnectionTargets).toEqual([
|
||||
expect.objectContaining({
|
||||
sourceBlockId: 'agent',
|
||||
targetBlockId: 'missing',
|
||||
reason: 'Connection target block does not exist',
|
||||
}),
|
||||
])
|
||||
expect(hasWorkflowLintIssues(lint)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns clean result when every active block and dynamic port is connected', () => {
|
||||
const workflowState = {
|
||||
blocks: {
|
||||
start: baseBlock('start', 'starter', 'Start'),
|
||||
router: baseBlock('router', 'router_v2', 'Router', {
|
||||
routes: {
|
||||
value: [{ id: 'route-1', title: 'Route 1', value: 'support' }],
|
||||
},
|
||||
}),
|
||||
agent: baseBlock('agent', 'agent', 'Agent'),
|
||||
},
|
||||
edges: [
|
||||
{
|
||||
id: 'edge-start-router',
|
||||
source: 'start',
|
||||
sourceHandle: 'source',
|
||||
target: 'router',
|
||||
targetHandle: 'target',
|
||||
},
|
||||
{
|
||||
id: 'edge-router-agent',
|
||||
source: 'router',
|
||||
sourceHandle: 'route-0',
|
||||
target: 'agent',
|
||||
targetHandle: 'target',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const lint = lintEditedWorkflowState(workflowState as any)
|
||||
|
||||
expect(lint).toEqual({
|
||||
sources: [{ blockId: 'start', blockName: 'Start', blockType: 'starter' }],
|
||||
sinks: [{ blockId: 'agent', blockName: 'Agent', blockType: 'agent' }],
|
||||
orphanBlocks: [],
|
||||
emptyOutgoingPorts: [],
|
||||
invalidBranchPorts: [],
|
||||
invalidConnectionTargets: [],
|
||||
})
|
||||
expect(hasWorkflowLintIssues(lint)).toBe(false)
|
||||
})
|
||||
|
||||
it('objectively reports multiple sources without turning disconnected islands into an issue', () => {
|
||||
const workflowState = {
|
||||
blocks: {
|
||||
start: baseBlock('start', 'starter', 'Start'),
|
||||
fetch: baseBlock('fetch', 'function', 'FetchCurrentFiles'),
|
||||
normalize: baseBlock('normalize', 'function', 'NormalizeFiles'),
|
||||
slack: { ...baseBlock('slack', 'slack', 'SlackTrigger'), triggerMode: true },
|
||||
filter: baseBlock('filter', 'condition', 'MessageFilter'),
|
||||
},
|
||||
edges: [
|
||||
{
|
||||
id: 'e1',
|
||||
source: 'start',
|
||||
sourceHandle: 'source',
|
||||
target: 'fetch',
|
||||
targetHandle: 'target',
|
||||
},
|
||||
{
|
||||
id: 'e2',
|
||||
source: 'fetch',
|
||||
sourceHandle: 'source',
|
||||
target: 'normalize',
|
||||
targetHandle: 'target',
|
||||
},
|
||||
{
|
||||
id: 'e3',
|
||||
source: 'slack',
|
||||
sourceHandle: 'source',
|
||||
target: 'filter',
|
||||
targetHandle: 'target',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const lint = lintEditedWorkflowState(workflowState as any)
|
||||
|
||||
expect(lint.sources.map((b) => b.blockId).sort()).toEqual(['slack', 'start'])
|
||||
expect(lint.orphanBlocks).toEqual([])
|
||||
expect(lint.emptyOutgoingPorts).toEqual([])
|
||||
expect(hasWorkflowLintIssues(lint)).toBe(false)
|
||||
})
|
||||
|
||||
it('reports sources and sinks (triggers are sources, terminals are sinks, notes excluded)', () => {
|
||||
const workflowState = {
|
||||
blocks: {
|
||||
start: baseBlock('start', 'starter', 'Start'),
|
||||
agent: baseBlock('agent', 'agent', 'Agent'),
|
||||
end: baseBlock('end', 'function', 'End'),
|
||||
note: baseBlock('note', 'note', 'Note'),
|
||||
},
|
||||
edges: [
|
||||
{
|
||||
id: 'e1',
|
||||
source: 'start',
|
||||
sourceHandle: 'source',
|
||||
target: 'agent',
|
||||
targetHandle: 'target',
|
||||
},
|
||||
{
|
||||
id: 'e2',
|
||||
source: 'agent',
|
||||
sourceHandle: 'source',
|
||||
target: 'end',
|
||||
targetHandle: 'target',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const lint = lintEditedWorkflowState(workflowState as any)
|
||||
|
||||
// 'start' has no incoming edge -> a source, even though it is NOT an orphan (trigger).
|
||||
expect(lint.sources).toEqual([{ blockId: 'start', blockName: 'Start', blockType: 'starter' }])
|
||||
expect(lint.orphanBlocks).toEqual([])
|
||||
// 'end' has no outgoing edge -> a sink.
|
||||
expect(lint.sinks).toEqual([{ blockId: 'end', blockName: 'End', blockType: 'function' }])
|
||||
// 'agent' has both in and out edges -> neither source nor sink.
|
||||
expect(lint.sources.map((b) => b.blockId)).not.toContain('agent')
|
||||
expect(lint.sinks.map((b) => b.blockId)).not.toContain('agent')
|
||||
// 'note' is excluded from both even though it has no edges.
|
||||
expect(lint.sources.map((b) => b.blockId)).not.toContain('note')
|
||||
expect(lint.sinks.map((b) => b.blockId)).not.toContain('note')
|
||||
})
|
||||
|
||||
it('warns when loop/parallel start ports are empty', () => {
|
||||
const workflowState = {
|
||||
blocks: {
|
||||
start: baseBlock('start', 'starter', 'Start'),
|
||||
loop: baseBlock('loop', 'loop', 'Loop'),
|
||||
parallel: baseBlock('parallel', 'parallel', 'Parallel'),
|
||||
},
|
||||
edges: [
|
||||
{
|
||||
id: 'e1',
|
||||
source: 'start',
|
||||
sourceHandle: 'source',
|
||||
target: 'loop',
|
||||
targetHandle: 'target',
|
||||
},
|
||||
{
|
||||
id: 'e2',
|
||||
source: 'loop',
|
||||
sourceHandle: 'loop-end-source',
|
||||
target: 'parallel',
|
||||
targetHandle: 'target',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const lint = lintEditedWorkflowState(workflowState as any)
|
||||
|
||||
expect(lint.emptyOutgoingPorts.map((port) => `${port.blockName}.${port.handle}`)).toEqual([
|
||||
'Loop.loop-start-source',
|
||||
'Parallel.parallel-start-source',
|
||||
])
|
||||
expect(hasWorkflowLintIssues(lint)).toBe(true)
|
||||
})
|
||||
|
||||
it('treats loop/parallel start edges as internal so containers can still be sinks', () => {
|
||||
const workflowState = {
|
||||
blocks: {
|
||||
start: baseBlock('start', 'starter', 'Start'),
|
||||
loop: baseBlock('loop', 'loop', 'Loop'),
|
||||
child: baseBlock('child', 'function', 'Loop Child'),
|
||||
},
|
||||
edges: [
|
||||
{
|
||||
id: 'e1',
|
||||
source: 'start',
|
||||
sourceHandle: 'source',
|
||||
target: 'loop',
|
||||
targetHandle: 'target',
|
||||
},
|
||||
{
|
||||
id: 'e2',
|
||||
source: 'loop',
|
||||
sourceHandle: 'loop-start-source',
|
||||
target: 'child',
|
||||
targetHandle: 'target',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const lint = lintEditedWorkflowState(workflowState as any)
|
||||
|
||||
expect(lint.emptyOutgoingPorts).toEqual([])
|
||||
expect(lint.sinks.map((block) => block.blockId).sort()).toEqual(['child', 'loop'])
|
||||
expect(hasWorkflowLintIssues(lint)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,377 @@
|
||||
import { getBlock } from '@/blocks'
|
||||
import { isTriggerBlockType } from '@/executor/constants'
|
||||
import {
|
||||
collectBlockFieldIssues,
|
||||
extractBlockParams,
|
||||
type InactiveModeValue,
|
||||
} from '@/serializer/index'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import { validateConditionHandle, validateRouterHandle } from './validation'
|
||||
|
||||
type BlockState = {
|
||||
id?: string
|
||||
type?: string
|
||||
name?: string
|
||||
triggerMode?: boolean
|
||||
subBlocks?: Record<string, { value?: unknown } | undefined>
|
||||
}
|
||||
|
||||
type EdgeState = {
|
||||
source?: string | null
|
||||
sourceHandle?: string | null
|
||||
target?: string | null
|
||||
}
|
||||
|
||||
export interface WorkflowLintBlockRef {
|
||||
blockId: string
|
||||
blockName?: string
|
||||
blockType?: string
|
||||
}
|
||||
|
||||
export interface WorkflowLintEmptyOutgoingPort extends WorkflowLintBlockRef {
|
||||
handle: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface WorkflowLintInvalidBranchPort extends WorkflowLintBlockRef {
|
||||
sourceHandle: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface WorkflowLintInvalidConnectionTarget {
|
||||
sourceBlockId: string
|
||||
sourceBlockName?: string
|
||||
sourceHandle?: string
|
||||
targetBlockId: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface WorkflowLintResult {
|
||||
/** Every non-note block with no incoming edge (trigger blocks are naturally sources). */
|
||||
sources: WorkflowLintBlockRef[]
|
||||
/** Every non-note block with no outgoing edge. */
|
||||
sinks: WorkflowLintBlockRef[]
|
||||
orphanBlocks: WorkflowLintBlockRef[]
|
||||
emptyOutgoingPorts: WorkflowLintEmptyOutgoingPort[]
|
||||
invalidBranchPorts: WorkflowLintInvalidBranchPort[]
|
||||
invalidConnectionTargets: WorkflowLintInvalidConnectionTarget[]
|
||||
}
|
||||
|
||||
/** Tier-1 (sync, config) field issues for a single block. */
|
||||
export interface WorkflowLintFieldIssue extends WorkflowLintBlockRef {
|
||||
/** Required fields that resolve empty in the active mode. */
|
||||
missingRequiredFields: string[]
|
||||
/** Canonical pairs whose value is stranded on the inactive member (silently dropped). */
|
||||
inactiveModeValues: InactiveModeValue[]
|
||||
}
|
||||
|
||||
/** Tier-2 (async, DB) reference that does not resolve to an accessible entity. */
|
||||
export interface WorkflowLintUnresolvedReference extends WorkflowLintBlockRef {
|
||||
field: string
|
||||
value: string | string[]
|
||||
kind: 'credential' | 'resource' | 'custom-tool' | 'mcp-tool' | 'skill'
|
||||
reason: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate lint report: the graph lint plus the config (Tier 1) and resolution
|
||||
* (Tier 2) checks. Returned in the edit_workflow result and written to lint.json.
|
||||
*/
|
||||
export interface WorkflowLintReport extends WorkflowLintResult {
|
||||
fieldIssues: WorkflowLintFieldIssue[]
|
||||
unresolvedReferences: WorkflowLintUnresolvedReference[]
|
||||
notes: string[]
|
||||
}
|
||||
|
||||
function blockRef(blockId: string, block: BlockState): WorkflowLintBlockRef {
|
||||
return {
|
||||
blockId,
|
||||
blockName: block.name,
|
||||
blockType: block.type,
|
||||
}
|
||||
}
|
||||
|
||||
function isWorkflowEntryBlock(block: BlockState) {
|
||||
return Boolean(block.triggerMode) || isTriggerBlockType(block.type)
|
||||
}
|
||||
|
||||
function requiredSubflowStartPort(block: BlockState) {
|
||||
if (block.type === 'loop') {
|
||||
return { handle: 'loop-start-source', label: 'loop-start-source' }
|
||||
}
|
||||
if (block.type === 'parallel') {
|
||||
return { handle: 'parallel-start-source', label: 'parallel-start-source' }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function countsAsExternalOutgoing(block: BlockState, sourceHandle?: string | null) {
|
||||
if (block.type === 'loop') {
|
||||
return sourceHandle !== 'loop-start-source'
|
||||
}
|
||||
if (block.type === 'parallel') {
|
||||
return sourceHandle !== 'parallel-start-source'
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function lintEditedWorkflowState(workflowState: Pick<WorkflowState, 'blocks' | 'edges'>) {
|
||||
const blocks = (workflowState.blocks || {}) as Record<string, BlockState>
|
||||
const edges = Array.isArray(workflowState.edges)
|
||||
? (workflowState.edges as EdgeState[])
|
||||
: ([] as EdgeState[])
|
||||
|
||||
const incomingEdgesByTarget = new Map<string, number>()
|
||||
const outgoingEdgesBySource = new Set<string>()
|
||||
const connectedDynamicHandles = new Map<string, Set<string>>()
|
||||
const invalidBranchPorts: WorkflowLintInvalidBranchPort[] = []
|
||||
const invalidConnectionTargets: WorkflowLintInvalidConnectionTarget[] = []
|
||||
|
||||
for (const edge of edges) {
|
||||
const sourceBlockId = edge?.source || ''
|
||||
const targetBlockId = edge?.target || ''
|
||||
const sourceBlock = blocks[sourceBlockId]
|
||||
const targetBlock = blocks[targetBlockId]
|
||||
|
||||
if (!sourceBlock || !targetBlock) {
|
||||
invalidConnectionTargets.push({
|
||||
sourceBlockId: sourceBlockId || 'unknown',
|
||||
sourceBlockName: sourceBlock?.name,
|
||||
sourceHandle: edge?.sourceHandle ?? undefined,
|
||||
targetBlockId: targetBlockId || 'unknown',
|
||||
reason: !sourceBlock
|
||||
? 'Connection source block does not exist'
|
||||
: 'Connection target block does not exist',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
incomingEdgesByTarget.set(targetBlockId, (incomingEdgesByTarget.get(targetBlockId) || 0) + 1)
|
||||
if (countsAsExternalOutgoing(sourceBlock, edge?.sourceHandle)) {
|
||||
outgoingEdgesBySource.add(sourceBlockId)
|
||||
}
|
||||
|
||||
const sourceHandle = edge?.sourceHandle
|
||||
if (!sourceHandle || sourceHandle === 'error') continue
|
||||
|
||||
if (sourceBlock.type === 'condition' || sourceBlock.type === 'router_v2') {
|
||||
const validation =
|
||||
sourceBlock.type === 'condition'
|
||||
? validateConditionHandle(
|
||||
sourceHandle,
|
||||
sourceBlockId,
|
||||
sourceBlock.subBlocks?.conditions?.value as string | any[]
|
||||
)
|
||||
: validateRouterHandle(
|
||||
sourceHandle,
|
||||
sourceBlockId,
|
||||
sourceBlock.subBlocks?.routes?.value as string | any[]
|
||||
)
|
||||
|
||||
if (!validation.valid) {
|
||||
invalidBranchPorts.push({
|
||||
...blockRef(sourceBlockId, sourceBlock),
|
||||
sourceHandle,
|
||||
reason: validation.error || `Invalid branch handle "${sourceHandle}"`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const normalizedHandle = validation.normalizedHandle || sourceHandle
|
||||
const handles = connectedDynamicHandles.get(sourceBlockId) || new Set<string>()
|
||||
handles.add(normalizedHandle)
|
||||
connectedDynamicHandles.set(sourceBlockId, handles)
|
||||
continue
|
||||
}
|
||||
|
||||
const handles = connectedDynamicHandles.get(sourceBlockId) || new Set<string>()
|
||||
handles.add(sourceHandle)
|
||||
connectedDynamicHandles.set(sourceBlockId, handles)
|
||||
}
|
||||
|
||||
const orphanBlocks = Object.entries(blocks)
|
||||
.filter(([, block]) => block.type !== 'note' && !isWorkflowEntryBlock(block))
|
||||
.filter(([blockId]) => !incomingEdgesByTarget.has(blockId))
|
||||
.map(([blockId, block]) => blockRef(blockId, block))
|
||||
|
||||
// Structural descriptors (advisory, not "issues"): sources have no incoming
|
||||
// edge (trigger blocks are naturally sources), sinks have no outgoing edge.
|
||||
const sources = Object.entries(blocks)
|
||||
.filter(([, block]) => block.type !== 'note')
|
||||
.filter(([blockId]) => !incomingEdgesByTarget.has(blockId))
|
||||
.map(([blockId, block]) => blockRef(blockId, block))
|
||||
|
||||
const sinks = Object.entries(blocks)
|
||||
.filter(([, block]) => block.type !== 'note')
|
||||
.filter(([blockId]) => !outgoingEdgesBySource.has(blockId))
|
||||
.map(([blockId, block]) => blockRef(blockId, block))
|
||||
|
||||
const emptyOutgoingPorts = Object.entries(blocks).flatMap(([blockId, block]) => {
|
||||
const handles = connectedDynamicHandles.get(blockId) || new Set<string>()
|
||||
const requiredPort = requiredSubflowStartPort(block)
|
||||
const ports = requiredPort ? [requiredPort] : []
|
||||
|
||||
return ports
|
||||
.filter((port) => !handles.has(port.handle))
|
||||
.map((port) => ({
|
||||
...blockRef(blockId, block),
|
||||
handle: port.handle,
|
||||
label: port.label,
|
||||
}))
|
||||
})
|
||||
|
||||
return {
|
||||
sources,
|
||||
sinks,
|
||||
orphanBlocks,
|
||||
emptyOutgoingPorts,
|
||||
invalidBranchPorts,
|
||||
invalidConnectionTargets,
|
||||
} satisfies WorkflowLintResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Tier-1 config lint: per-block required-field and canonical-mode (inactive
|
||||
* member) issues. Pure/sync. Uses the shared collector so results match the
|
||||
* runtime serializer's required-field semantics. Skips notes and subflow
|
||||
* containers, and blocks with no registry config.
|
||||
*/
|
||||
export function collectWorkflowFieldIssues(
|
||||
blocks: WorkflowState['blocks'] | Record<string, unknown> | undefined
|
||||
): WorkflowLintFieldIssue[] {
|
||||
const results: WorkflowLintFieldIssue[] = []
|
||||
for (const [blockId, block] of Object.entries(blocks || {})) {
|
||||
const type = (block as { type?: string })?.type
|
||||
if (!type || type === 'note' || type === 'loop' || type === 'parallel') continue
|
||||
const blockConfig = getBlock(type)
|
||||
if (!blockConfig) continue
|
||||
|
||||
let params: Record<string, any>
|
||||
try {
|
||||
params = extractBlockParams(block as any)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
const { missingRequiredFields, inactiveModeValues } = collectBlockFieldIssues(
|
||||
block as any,
|
||||
blockConfig,
|
||||
params
|
||||
)
|
||||
if (missingRequiredFields.length > 0 || inactiveModeValues.length > 0) {
|
||||
results.push({
|
||||
...blockRef(blockId, block as BlockState),
|
||||
missingRequiredFields,
|
||||
inactiveModeValues,
|
||||
})
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
type WorkflowLintIssueView = WorkflowLintResult & {
|
||||
fieldIssues?: WorkflowLintFieldIssue[]
|
||||
unresolvedReferences?: WorkflowLintUnresolvedReference[]
|
||||
}
|
||||
|
||||
export function hasWorkflowLintIssues(lint: WorkflowLintIssueView) {
|
||||
return (
|
||||
lint.orphanBlocks.length > 0 ||
|
||||
lint.emptyOutgoingPorts.length > 0 ||
|
||||
lint.invalidBranchPorts.length > 0 ||
|
||||
lint.invalidConnectionTargets.length > 0 ||
|
||||
(lint.fieldIssues?.length ?? 0) > 0 ||
|
||||
(lint.unresolvedReferences?.length ?? 0) > 0
|
||||
)
|
||||
}
|
||||
|
||||
export function formatWorkflowLintMessage(lint: WorkflowLintIssueView) {
|
||||
const parts: string[] = []
|
||||
|
||||
if (lint.orphanBlocks.length > 0) {
|
||||
parts.push(
|
||||
`Blocks with no incoming edge: ${lint.orphanBlocks
|
||||
.map((block) => `"${block.blockName || block.blockId}" (${block.blockType || 'unknown'})`)
|
||||
.join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
if (lint.emptyOutgoingPorts.length > 0) {
|
||||
parts.push(
|
||||
`Unconnected required subflow start ports: ${lint.emptyOutgoingPorts
|
||||
.map((port) => `"${port.blockName || port.blockId}".${port.label}`)
|
||||
.join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
if (lint.invalidBranchPorts.length > 0) {
|
||||
parts.push(
|
||||
`Invalid condition/router branch handles: ${lint.invalidBranchPorts
|
||||
.map((port) => `"${port.blockName || port.blockId}" uses "${port.sourceHandle}"`)
|
||||
.join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
if (lint.invalidConnectionTargets.length > 0) {
|
||||
parts.push(
|
||||
`Connections pointing at missing blocks: ${lint.invalidConnectionTargets
|
||||
.map((edge) => `${edge.sourceBlockId} -> ${edge.targetBlockId}`)
|
||||
.join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
const fieldIssues = lint.fieldIssues ?? []
|
||||
const missing = fieldIssues.filter((issue) => issue.missingRequiredFields.length > 0)
|
||||
if (missing.length > 0) {
|
||||
parts.push(
|
||||
`Blocks missing required fields: ${missing
|
||||
.map(
|
||||
(issue) =>
|
||||
`"${issue.blockName || issue.blockId}" (${issue.missingRequiredFields.join(', ')})`
|
||||
)
|
||||
.join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
const inactive = fieldIssues.filter((issue) => issue.inactiveModeValues.length > 0)
|
||||
if (inactive.length > 0) {
|
||||
parts.push(
|
||||
`Values set on the inactive field mode (they will not resolve): ${inactive
|
||||
.map(
|
||||
(issue) =>
|
||||
`"${issue.blockName || issue.blockId}" (${issue.inactiveModeValues
|
||||
.map(
|
||||
(v) =>
|
||||
`${v.inactiveMemberId}: move the value to "${v.activeMemberId ?? v.canonicalId}"`
|
||||
)
|
||||
.join('; ')})`
|
||||
)
|
||||
.join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
const unresolved = lint.unresolvedReferences ?? []
|
||||
const credResourceRefs = unresolved.filter(
|
||||
(ref) => ref.kind === 'credential' || ref.kind === 'resource'
|
||||
)
|
||||
if (credResourceRefs.length > 0) {
|
||||
parts.push(
|
||||
`Credential/resource references that do not resolve: ${credResourceRefs
|
||||
.map((ref) => `"${ref.blockName || ref.blockId}".${ref.field} (${ref.reason})`)
|
||||
.join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
const toolSkillRefs = unresolved.filter(
|
||||
(ref) => ref.kind === 'custom-tool' || ref.kind === 'mcp-tool' || ref.kind === 'skill'
|
||||
)
|
||||
if (toolSkillRefs.length > 0) {
|
||||
parts.push(
|
||||
`Agent tool/skill references that do not resolve (they will not attach at runtime): ${toolSkillRefs
|
||||
.map((ref) => `"${ref.blockName || ref.blockId}".${ref.field} (${ref.reason})`)
|
||||
.join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
return `Workflow lint found issues. Fix these before continuing: ${parts.join('; ')}`
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { applyOperationsToWorkflowState } from './engine'
|
||||
|
||||
vi.mock('@/blocks/registry', () => ({
|
||||
getAllBlocks: () => [
|
||||
{
|
||||
type: 'condition',
|
||||
name: 'Condition',
|
||||
subBlocks: [{ id: 'conditions', type: 'condition-input' }],
|
||||
},
|
||||
{
|
||||
type: 'agent',
|
||||
name: 'Agent',
|
||||
subBlocks: [
|
||||
{ id: 'systemPrompt', type: 'long-input' },
|
||||
{ id: 'model', type: 'combobox' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'Function',
|
||||
subBlocks: [
|
||||
{ id: 'code', type: 'code' },
|
||||
{ id: 'language', type: 'dropdown' },
|
||||
],
|
||||
},
|
||||
],
|
||||
getBlock: (type: string) => {
|
||||
const blocks: Record<string, any> = {
|
||||
condition: {
|
||||
type: 'condition',
|
||||
name: 'Condition',
|
||||
subBlocks: [{ id: 'conditions', type: 'condition-input' }],
|
||||
},
|
||||
agent: {
|
||||
type: 'agent',
|
||||
name: 'Agent',
|
||||
subBlocks: [
|
||||
{ id: 'systemPrompt', type: 'long-input' },
|
||||
{ id: 'model', type: 'combobox' },
|
||||
],
|
||||
},
|
||||
function: {
|
||||
type: 'function',
|
||||
name: 'Function',
|
||||
subBlocks: [
|
||||
{ id: 'code', type: 'code' },
|
||||
{ id: 'language', type: 'dropdown' },
|
||||
],
|
||||
},
|
||||
}
|
||||
return blocks[type] || undefined
|
||||
},
|
||||
}))
|
||||
|
||||
function makeLoopWorkflow() {
|
||||
return {
|
||||
blocks: {
|
||||
'loop-1': {
|
||||
id: 'loop-1',
|
||||
type: 'loop',
|
||||
name: 'Loop 1',
|
||||
position: { x: 0, y: 0 },
|
||||
enabled: true,
|
||||
subBlocks: {},
|
||||
outputs: {},
|
||||
data: { loopType: 'for', count: 5 },
|
||||
},
|
||||
'condition-1': {
|
||||
id: 'condition-1',
|
||||
type: 'condition',
|
||||
name: 'Condition 1',
|
||||
position: { x: 100, y: 100 },
|
||||
enabled: true,
|
||||
subBlocks: {
|
||||
conditions: {
|
||||
id: 'conditions',
|
||||
type: 'condition-input',
|
||||
value: JSON.stringify([
|
||||
{ id: 'condition-1-if', title: 'if', value: 'true' },
|
||||
{ id: 'condition-1-else', title: 'else', value: '' },
|
||||
]),
|
||||
},
|
||||
},
|
||||
outputs: {},
|
||||
data: { parentId: 'loop-1', extent: 'parent' },
|
||||
},
|
||||
'agent-1': {
|
||||
id: 'agent-1',
|
||||
type: 'agent',
|
||||
name: 'Agent 1',
|
||||
position: { x: 300, y: 100 },
|
||||
enabled: true,
|
||||
subBlocks: {
|
||||
systemPrompt: { id: 'systemPrompt', type: 'long-input', value: 'You are helpful' },
|
||||
model: { id: 'model', type: 'combobox', value: 'gpt-4o' },
|
||||
},
|
||||
outputs: {},
|
||||
data: { parentId: 'loop-1', extent: 'parent' },
|
||||
},
|
||||
},
|
||||
edges: [
|
||||
{
|
||||
id: 'edge-1',
|
||||
source: 'loop-1',
|
||||
sourceHandle: 'loop-start-source',
|
||||
target: 'condition-1',
|
||||
targetHandle: 'target',
|
||||
type: 'default',
|
||||
},
|
||||
{
|
||||
id: 'edge-2',
|
||||
source: 'condition-1',
|
||||
sourceHandle: 'condition-condition-1-if',
|
||||
target: 'agent-1',
|
||||
targetHandle: 'target',
|
||||
type: 'default',
|
||||
},
|
||||
],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
}
|
||||
}
|
||||
|
||||
function makeNestedLoopWorkflow() {
|
||||
return {
|
||||
blocks: {
|
||||
'outer-loop': {
|
||||
id: 'outer-loop',
|
||||
type: 'loop',
|
||||
name: 'Outer Loop',
|
||||
position: { x: 0, y: 0 },
|
||||
enabled: true,
|
||||
subBlocks: {},
|
||||
outputs: {},
|
||||
data: { loopType: 'for', count: 2 },
|
||||
},
|
||||
'inner-loop': {
|
||||
id: 'inner-loop',
|
||||
type: 'loop',
|
||||
name: 'Inner Loop',
|
||||
position: { x: 120, y: 80 },
|
||||
enabled: true,
|
||||
subBlocks: {},
|
||||
outputs: {},
|
||||
data: { parentId: 'outer-loop', extent: 'parent', loopType: 'for', count: 3 },
|
||||
},
|
||||
'inner-agent': {
|
||||
id: 'inner-agent',
|
||||
type: 'agent',
|
||||
name: 'Inner Agent',
|
||||
position: { x: 240, y: 120 },
|
||||
enabled: true,
|
||||
subBlocks: {
|
||||
systemPrompt: { id: 'systemPrompt', type: 'long-input', value: 'Original prompt' },
|
||||
model: { id: 'model', type: 'combobox', value: 'gpt-4o' },
|
||||
},
|
||||
outputs: {},
|
||||
data: { parentId: 'inner-loop', extent: 'parent' },
|
||||
},
|
||||
},
|
||||
edges: [
|
||||
{
|
||||
id: 'edge-outer-inner',
|
||||
source: 'outer-loop',
|
||||
sourceHandle: 'loop-start-source',
|
||||
target: 'inner-loop',
|
||||
targetHandle: 'target',
|
||||
type: 'default',
|
||||
},
|
||||
{
|
||||
id: 'edge-inner-agent',
|
||||
source: 'inner-loop',
|
||||
sourceHandle: 'loop-start-source',
|
||||
target: 'inner-agent',
|
||||
targetHandle: 'target',
|
||||
type: 'default',
|
||||
},
|
||||
],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
}
|
||||
}
|
||||
|
||||
describe('handleEditOperation nestedNodes merge', () => {
|
||||
it('preserves existing child block IDs when editing a loop with nestedNodes', () => {
|
||||
const workflow = makeLoopWorkflow()
|
||||
|
||||
const { state } = applyOperationsToWorkflowState(workflow, [
|
||||
{
|
||||
operation_type: 'edit',
|
||||
block_id: 'loop-1',
|
||||
params: {
|
||||
nestedNodes: {
|
||||
'new-condition': {
|
||||
type: 'condition',
|
||||
name: 'Condition 1',
|
||||
inputs: {
|
||||
conditions: [
|
||||
{ id: 'x', title: 'if', value: 'x > 1' },
|
||||
{ id: 'y', title: 'else', value: '' },
|
||||
],
|
||||
},
|
||||
},
|
||||
'new-agent': {
|
||||
type: 'agent',
|
||||
name: 'Agent 1',
|
||||
inputs: { systemPrompt: 'Updated prompt' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
expect(state.blocks['condition-1']).toBeDefined()
|
||||
expect(state.blocks['agent-1']).toBeDefined()
|
||||
expect(state.blocks['new-condition']).toBeUndefined()
|
||||
expect(state.blocks['new-agent']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves edges for matched children when connections are not provided', () => {
|
||||
const workflow = makeLoopWorkflow()
|
||||
|
||||
const { state } = applyOperationsToWorkflowState(workflow, [
|
||||
{
|
||||
operation_type: 'edit',
|
||||
block_id: 'loop-1',
|
||||
params: {
|
||||
nestedNodes: {
|
||||
x: { type: 'condition', name: 'Condition 1' },
|
||||
y: { type: 'agent', name: 'Agent 1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const conditionEdge = state.edges.find((e: any) => e.source === 'condition-1')
|
||||
expect(conditionEdge).toBeDefined()
|
||||
})
|
||||
|
||||
it('removes children not present in incoming nestedNodes', () => {
|
||||
const workflow = makeLoopWorkflow()
|
||||
|
||||
const { state } = applyOperationsToWorkflowState(workflow, [
|
||||
{
|
||||
operation_type: 'edit',
|
||||
block_id: 'loop-1',
|
||||
params: {
|
||||
nestedNodes: {
|
||||
x: { type: 'condition', name: 'Condition 1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
expect(state.blocks['condition-1']).toBeDefined()
|
||||
expect(state.blocks['agent-1']).toBeUndefined()
|
||||
const agentEdges = state.edges.filter(
|
||||
(e: any) => e.source === 'agent-1' || e.target === 'agent-1'
|
||||
)
|
||||
expect(agentEdges).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('creates new children that do not match existing ones', () => {
|
||||
const workflow = makeLoopWorkflow()
|
||||
|
||||
const { state } = applyOperationsToWorkflowState(workflow, [
|
||||
{
|
||||
operation_type: 'edit',
|
||||
block_id: 'loop-1',
|
||||
params: {
|
||||
nestedNodes: {
|
||||
x: { type: 'condition', name: 'Condition 1' },
|
||||
y: { type: 'agent', name: 'Agent 1' },
|
||||
'new-func': { type: 'function', name: 'Function 1', inputs: { code: 'return 1' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
expect(state.blocks['condition-1']).toBeDefined()
|
||||
expect(state.blocks['agent-1']).toBeDefined()
|
||||
const funcBlock = Object.values(state.blocks).find((b: any) => b.name === 'Function 1')
|
||||
expect(funcBlock).toBeDefined()
|
||||
expect((funcBlock as any).data?.parentId).toBe('loop-1')
|
||||
})
|
||||
|
||||
it('updates inputs on matched children without changing their ID', () => {
|
||||
const workflow = makeLoopWorkflow()
|
||||
|
||||
const { state } = applyOperationsToWorkflowState(workflow, [
|
||||
{
|
||||
operation_type: 'edit',
|
||||
block_id: 'loop-1',
|
||||
params: {
|
||||
nestedNodes: {
|
||||
x: {
|
||||
type: 'agent',
|
||||
name: 'Agent 1',
|
||||
inputs: { systemPrompt: 'New prompt' },
|
||||
},
|
||||
y: { type: 'condition', name: 'Condition 1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const agent = state.blocks['agent-1']
|
||||
expect(agent).toBeDefined()
|
||||
expect(agent.subBlocks.systemPrompt.value).toBe('New prompt')
|
||||
})
|
||||
|
||||
it('recursively updates an existing nested loop and preserves grandchild IDs', () => {
|
||||
const workflow = makeNestedLoopWorkflow()
|
||||
|
||||
const { state } = applyOperationsToWorkflowState(workflow, [
|
||||
{
|
||||
operation_type: 'edit',
|
||||
block_id: 'outer-loop',
|
||||
params: {
|
||||
nestedNodes: {
|
||||
'new-inner-loop': {
|
||||
type: 'loop',
|
||||
name: 'Inner Loop',
|
||||
inputs: {
|
||||
loopType: 'forEach',
|
||||
collection: '<start.input.items>',
|
||||
},
|
||||
nestedNodes: {
|
||||
'new-inner-agent': {
|
||||
type: 'agent',
|
||||
name: 'Inner Agent',
|
||||
inputs: { systemPrompt: 'Updated prompt' },
|
||||
},
|
||||
'new-helper': {
|
||||
type: 'function',
|
||||
name: 'Helper',
|
||||
inputs: { code: 'return 1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
expect(state.blocks['inner-loop']).toBeDefined()
|
||||
expect(state.blocks['new-inner-loop']).toBeUndefined()
|
||||
expect(state.blocks['inner-loop'].data.loopType).toBe('forEach')
|
||||
expect(state.blocks['inner-loop'].data.collection).toBe('<start.input.items>')
|
||||
|
||||
expect(state.blocks['inner-agent']).toBeDefined()
|
||||
expect(state.blocks['new-inner-agent']).toBeUndefined()
|
||||
expect(state.blocks['inner-agent'].subBlocks.systemPrompt.value).toBe('Updated prompt')
|
||||
|
||||
const helperBlock = Object.values(state.blocks).find((block: any) => block.name === 'Helper') as
|
||||
| any
|
||||
| undefined
|
||||
expect(helperBlock).toBeDefined()
|
||||
expect(helperBlock?.data?.parentId).toBe('inner-loop')
|
||||
})
|
||||
|
||||
it('removes grandchildren omitted from an existing nested loop update', () => {
|
||||
const workflow = makeNestedLoopWorkflow()
|
||||
|
||||
const { state } = applyOperationsToWorkflowState(workflow, [
|
||||
{
|
||||
operation_type: 'edit',
|
||||
block_id: 'outer-loop',
|
||||
params: {
|
||||
nestedNodes: {
|
||||
'new-inner-loop': {
|
||||
type: 'loop',
|
||||
name: 'Inner Loop',
|
||||
nestedNodes: {
|
||||
'new-helper': {
|
||||
type: 'function',
|
||||
name: 'Helper',
|
||||
inputs: { code: 'return 1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
expect(state.blocks['inner-loop']).toBeDefined()
|
||||
expect(state.blocks['inner-agent']).toBeUndefined()
|
||||
expect(
|
||||
state.edges.some(
|
||||
(edge: any) => edge.source === 'inner-agent' || edge.target === 'inner-agent'
|
||||
)
|
||||
).toBe(false)
|
||||
|
||||
const helperBlock = Object.values(state.blocks).find((block: any) => block.name === 'Helper')
|
||||
expect(helperBlock).toBeDefined()
|
||||
})
|
||||
|
||||
it('removes an unmatched nested container with all descendants and edges', () => {
|
||||
const workflow = makeNestedLoopWorkflow()
|
||||
|
||||
const { state } = applyOperationsToWorkflowState(workflow, [
|
||||
{
|
||||
operation_type: 'edit',
|
||||
block_id: 'outer-loop',
|
||||
params: {
|
||||
nestedNodes: {
|
||||
replacement: {
|
||||
type: 'function',
|
||||
name: 'Replacement',
|
||||
inputs: { code: 'return 2' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
expect(state.blocks['inner-loop']).toBeUndefined()
|
||||
expect(state.blocks['inner-agent']).toBeUndefined()
|
||||
expect(
|
||||
state.edges.some(
|
||||
(edge: any) =>
|
||||
edge.source === 'inner-loop' ||
|
||||
edge.target === 'inner-loop' ||
|
||||
edge.source === 'inner-agent' ||
|
||||
edge.target === 'inner-agent'
|
||||
)
|
||||
).toBe(false)
|
||||
|
||||
const replacementBlock = Object.values(state.blocks).find(
|
||||
(block: any) => block.name === 'Replacement'
|
||||
) as any
|
||||
expect(replacementBlock).toBeDefined()
|
||||
expect(replacementBlock.data?.parentId).toBe('outer-loop')
|
||||
})
|
||||
})
|
||||
|
||||
describe('forward-reference connections (pending resolution)', () => {
|
||||
function makeMinimalWorkflow() {
|
||||
return {
|
||||
blocks: {
|
||||
'start-1': {
|
||||
id: 'start-1',
|
||||
type: 'function',
|
||||
name: 'Start',
|
||||
position: { x: 0, y: 0 },
|
||||
enabled: true,
|
||||
subBlocks: {},
|
||||
outputs: {},
|
||||
data: {},
|
||||
},
|
||||
},
|
||||
edges: [] as any[],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
}
|
||||
}
|
||||
|
||||
// Valid UUIDs so block_ids are not normalized/remapped on add.
|
||||
const BLOCK_A = '11111111-1111-4111-8111-111111111111'
|
||||
const BLOCK_B = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
it('defers a connection to a not-yet-created block and resolves it on a later apply', () => {
|
||||
const workflow = makeMinimalWorkflow()
|
||||
|
||||
// First apply: add block A connecting to block B, which does not exist yet.
|
||||
const first = applyOperationsToWorkflowState(workflow, [
|
||||
{
|
||||
operation_type: 'add',
|
||||
block_id: BLOCK_A,
|
||||
params: {
|
||||
type: 'function',
|
||||
name: 'Block A',
|
||||
inputs: { code: 'return 1' },
|
||||
connections: { source: BLOCK_B },
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
// No edge created yet; the connection is recorded as pending on block A.
|
||||
expect(first.state.edges.some((e: any) => e.target === BLOCK_B)).toBe(false)
|
||||
expect(first.state.blocks[BLOCK_A].data.pendingConnections.source).toEqual([
|
||||
{ target: BLOCK_B, targetHandle: 'target' },
|
||||
])
|
||||
|
||||
// Second apply (simulating a later edit_workflow call): add block B.
|
||||
const second = applyOperationsToWorkflowState(first.state, [
|
||||
{
|
||||
operation_type: 'add',
|
||||
block_id: BLOCK_B,
|
||||
params: { type: 'function', name: 'Block B', inputs: { code: 'return 2' } },
|
||||
},
|
||||
])
|
||||
|
||||
// The pending edge is now created and the pending record cleared.
|
||||
const edge = second.state.edges.find((e: any) => e.source === BLOCK_A && e.target === BLOCK_B)
|
||||
expect(edge).toBeDefined()
|
||||
expect(second.state.blocks[BLOCK_A].data?.pendingConnections).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolves a forward-reference connection within a single apply regardless of operation order', () => {
|
||||
const workflow = makeMinimalWorkflow()
|
||||
|
||||
const { state } = applyOperationsToWorkflowState(workflow, [
|
||||
{
|
||||
operation_type: 'add',
|
||||
block_id: BLOCK_A,
|
||||
params: {
|
||||
type: 'function',
|
||||
name: 'Block A',
|
||||
inputs: { code: 'return 1' },
|
||||
connections: { source: BLOCK_B },
|
||||
},
|
||||
},
|
||||
{
|
||||
operation_type: 'add',
|
||||
block_id: BLOCK_B,
|
||||
params: { type: 'function', name: 'Block B', inputs: { code: 'return 2' } },
|
||||
},
|
||||
])
|
||||
|
||||
const edge = state.edges.find((e: any) => e.source === BLOCK_A && e.target === BLOCK_B)
|
||||
expect(edge).toBeDefined()
|
||||
expect(state.blocks[BLOCK_A].data?.pendingConnections).toBeUndefined()
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,151 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
|
||||
|
||||
/** Selector subblock types that can be validated */
|
||||
export const SELECTOR_TYPES = new Set([
|
||||
'oauth-input',
|
||||
'knowledge-base-selector',
|
||||
'document-selector',
|
||||
'file-selector',
|
||||
'project-selector',
|
||||
'channel-selector',
|
||||
'folder-selector',
|
||||
'mcp-server-selector',
|
||||
'mcp-tool-selector',
|
||||
'workflow-selector',
|
||||
])
|
||||
|
||||
const validationLogger = createLogger('EditWorkflowValidation')
|
||||
|
||||
/**
|
||||
* Validation error for a specific field
|
||||
*/
|
||||
export interface ValidationError {
|
||||
blockId: string
|
||||
blockType: string
|
||||
field: string
|
||||
value: any
|
||||
error: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Types of items that can be skipped during operation application
|
||||
*/
|
||||
export type SkippedItemType =
|
||||
| 'block_not_found'
|
||||
| 'invalid_block_type'
|
||||
| 'block_not_allowed'
|
||||
| 'block_locked'
|
||||
| 'tool_not_allowed'
|
||||
| 'invalid_edge_target'
|
||||
| 'invalid_edge_source'
|
||||
| 'invalid_edge_scope'
|
||||
| 'invalid_source_handle'
|
||||
| 'invalid_target_handle'
|
||||
| 'invalid_subblock_field'
|
||||
| 'missing_required_params'
|
||||
| 'invalid_subflow_parent'
|
||||
| 'nested_subflow_not_allowed'
|
||||
| 'duplicate_block_name'
|
||||
| 'reserved_block_name'
|
||||
| 'duplicate_trigger'
|
||||
| 'duplicate_single_instance_block'
|
||||
|
||||
/**
|
||||
* Represents an item that was skipped during operation application
|
||||
*/
|
||||
export interface SkippedItem {
|
||||
type: SkippedItemType
|
||||
operationType: string
|
||||
blockId: string
|
||||
reason: string
|
||||
details?: Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* Skipped-item types that represent benign, SELF-HEALING deferrals rather than
|
||||
* failures. A deferred forward-reference edge (`invalid_edge_target`) is
|
||||
* recorded as a pending connection and wired automatically once its target
|
||||
* block exists -- possibly on a later edit_workflow call. It must be surfaced to
|
||||
* the model as informational, NOT through the "skipped/failed operation"
|
||||
* channel; otherwise a literal model re-issues the self-healing operation in a
|
||||
* loop. See `createValidatedEdge` (builders.ts) and `resolvePendingConnections`
|
||||
* (engine.ts).
|
||||
*/
|
||||
export const DEFERRED_SKIPPED_ITEM_TYPES: ReadonlySet<SkippedItemType> = new Set([
|
||||
'invalid_edge_target',
|
||||
])
|
||||
|
||||
/** Whether a skipped item is a benign deferral (see DEFERRED_SKIPPED_ITEM_TYPES). */
|
||||
export function isDeferredSkippedItem(item: SkippedItem): boolean {
|
||||
return DEFERRED_SKIPPED_ITEM_TYPES.has(item.type)
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs and records a skipped item
|
||||
*/
|
||||
export function logSkippedItem(skippedItems: SkippedItem[], item: SkippedItem): void {
|
||||
validationLogger.warn(`Skipped ${item.operationType} operation: ${item.reason}`, {
|
||||
type: item.type,
|
||||
operationType: item.operationType,
|
||||
blockId: item.blockId,
|
||||
...(item.details && { details: item.details }),
|
||||
})
|
||||
skippedItems.push(item)
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of input validation
|
||||
*/
|
||||
export interface ValidationResult {
|
||||
validInputs: Record<string, any>
|
||||
errors: ValidationError[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of validating a single value
|
||||
*/
|
||||
export interface ValueValidationResult {
|
||||
valid: boolean
|
||||
value?: any
|
||||
error?: ValidationError
|
||||
}
|
||||
|
||||
export interface EditWorkflowOperation {
|
||||
operation_type: 'add' | 'edit' | 'delete' | 'insert_into_subflow' | 'extract_from_subflow'
|
||||
block_id: string
|
||||
params?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface EditWorkflowParams {
|
||||
operations: EditWorkflowOperation[]
|
||||
workflowId: string
|
||||
currentUserWorkflow?: string
|
||||
}
|
||||
|
||||
export interface EdgeHandleValidationResult {
|
||||
valid: boolean
|
||||
error?: string
|
||||
/** The normalized handle to use (e.g., simple 'if' normalized to 'condition-{uuid}') */
|
||||
normalizedHandle?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of applying operations to workflow state
|
||||
*/
|
||||
export interface ApplyOperationsResult {
|
||||
state: any
|
||||
validationErrors: ValidationError[]
|
||||
skippedItems: SkippedItem[]
|
||||
}
|
||||
|
||||
export interface OperationContext {
|
||||
modifiedState: any
|
||||
skippedItems: SkippedItem[]
|
||||
validationErrors: ValidationError[]
|
||||
permissionConfig: PermissionGroupConfig | null
|
||||
deferredConnections: Array<{
|
||||
blockId: string
|
||||
connections: Record<string, any>
|
||||
}>
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { listLogsMock, fetchLogDetailMock, toOverviewMock, toFullMock, grepSpansMock } = vi.hoisted(
|
||||
() => ({
|
||||
listLogsMock: vi.fn(),
|
||||
fetchLogDetailMock: vi.fn(),
|
||||
toOverviewMock: vi.fn(),
|
||||
toFullMock: vi.fn(),
|
||||
grepSpansMock: vi.fn(),
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/lib/logs/list-logs', () => ({ listLogs: listLogsMock }))
|
||||
vi.mock('@/lib/logs/fetch-log-detail', () => ({ fetchLogDetail: fetchLogDetailMock }))
|
||||
vi.mock('@/lib/logs/log-views', () => ({
|
||||
toOverview: toOverviewMock,
|
||||
toFull: toFullMock,
|
||||
grepSpans: grepSpansMock,
|
||||
}))
|
||||
vi.mock('@/lib/execution/payloads/large-execution-value', () => ({
|
||||
collectLargeValueExecutionIds: vi.fn(() => []),
|
||||
collectLargeValueKeys: vi.fn(() => []),
|
||||
}))
|
||||
|
||||
import { queryLogsServerTool } from './query-logs'
|
||||
|
||||
const ctx = { userId: 'user-1', workspaceId: 'ws-1' }
|
||||
|
||||
function detail(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
executionId: 'exec-1',
|
||||
workflowId: 'wf-1',
|
||||
status: 'success',
|
||||
trigger: 'manual',
|
||||
cost: { total: 0.1 },
|
||||
executionData: { totalDuration: 1234, traceSpans: [{ id: 's1' }] },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('queryLogsServerTool', () => {
|
||||
it('list view delegates to listLogs with workspaceId and no view field', async () => {
|
||||
listLogsMock.mockResolvedValue({ data: [{ id: 'log-1' }], nextCursor: null })
|
||||
|
||||
const result = await queryLogsServerTool.execute(
|
||||
{ view: 'list', sortBy: 'date', sortOrder: 'desc', limit: 100 } as any,
|
||||
ctx
|
||||
)
|
||||
|
||||
expect(listLogsMock).toHaveBeenCalledTimes(1)
|
||||
const [params, userId] = listLogsMock.mock.calls[0]
|
||||
expect(userId).toBe('user-1')
|
||||
expect(params.workspaceId).toBe('ws-1')
|
||||
expect(params).not.toHaveProperty('view')
|
||||
expect(result).toEqual({ data: [{ id: 'log-1' }], nextCursor: null })
|
||||
})
|
||||
|
||||
it('overview view returns the projected span tree', async () => {
|
||||
fetchLogDetailMock.mockResolvedValue(detail())
|
||||
toOverviewMock.mockReturnValue([{ id: 's1', name: 'A' }])
|
||||
|
||||
const result: any = await queryLogsServerTool.execute(
|
||||
{ view: 'overview', executionId: 'exec-1' } as any,
|
||||
ctx
|
||||
)
|
||||
|
||||
expect(result.executionId).toBe('exec-1')
|
||||
expect(result.durationMs).toBe(1234)
|
||||
expect(result.spans).toEqual([{ id: 's1', name: 'A' }])
|
||||
expect(toFullMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('full view returns materialized spans', async () => {
|
||||
fetchLogDetailMock.mockResolvedValue(detail())
|
||||
toFullMock.mockResolvedValue([{ id: 's1', input: { a: 1 } }])
|
||||
|
||||
const result: any = await queryLogsServerTool.execute(
|
||||
{ view: 'full', executionId: 'exec-1', blockId: 'blk-1' } as any,
|
||||
ctx
|
||||
)
|
||||
|
||||
expect(toFullMock).toHaveBeenCalledWith(expect.anything(), expect.anything(), {
|
||||
blockId: 'blk-1',
|
||||
blockName: undefined,
|
||||
})
|
||||
expect(result.spans).toEqual([{ id: 's1', input: { a: 1 } }])
|
||||
expect(result.truncated).toBe(false)
|
||||
})
|
||||
|
||||
it('full view falls back to overview when the result is too large', async () => {
|
||||
fetchLogDetailMock.mockResolvedValue(detail())
|
||||
const huge = 'x'.repeat(600 * 1024)
|
||||
toFullMock.mockResolvedValue([{ id: 's1', output: huge }])
|
||||
toOverviewMock.mockReturnValue([{ id: 's1', name: 'A' }])
|
||||
|
||||
const result: any = await queryLogsServerTool.execute(
|
||||
{ view: 'full', executionId: 'exec-1' } as any,
|
||||
ctx
|
||||
)
|
||||
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(result.note).toContain('too large')
|
||||
expect(result.spans).toEqual([{ id: 's1', name: 'A' }])
|
||||
})
|
||||
|
||||
it('pattern runs grepSpans and returns matches', async () => {
|
||||
fetchLogDetailMock.mockResolvedValue(detail())
|
||||
grepSpansMock.mockResolvedValue({
|
||||
matches: [{ spanId: 's1', name: 'A', field: 'output', snippet: '…timeout…' }],
|
||||
truncated: false,
|
||||
})
|
||||
|
||||
const result: any = await queryLogsServerTool.execute(
|
||||
{ view: 'full', executionId: 'exec-1', pattern: 'timeout' } as any,
|
||||
ctx
|
||||
)
|
||||
|
||||
expect(grepSpansMock).toHaveBeenCalledTimes(1)
|
||||
expect(result.pattern).toBe('timeout')
|
||||
expect(result.matches).toHaveLength(1)
|
||||
expect(toFullMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns not-found for an unknown executionId', async () => {
|
||||
fetchLogDetailMock.mockResolvedValue(null)
|
||||
const result: any = await queryLogsServerTool.execute(
|
||||
{ view: 'overview', executionId: 'missing' } as any,
|
||||
ctx
|
||||
)
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.error).toContain('missing')
|
||||
})
|
||||
|
||||
it('throws when unauthenticated', async () => {
|
||||
await expect(
|
||||
queryLogsServerTool.execute({ view: 'overview', executionId: 'exec-1' } as any, {} as any)
|
||||
).rejects.toThrow('Unauthorized')
|
||||
})
|
||||
|
||||
it('rejects overview/full without executionId via inputSchema', () => {
|
||||
const schema = queryLogsServerTool.inputSchema!
|
||||
expect(schema.safeParse({ view: 'overview', workspaceId: 'ws-1' }).success).toBe(false)
|
||||
expect(schema.safeParse({ view: 'full', workspaceId: 'ws-1' }).success).toBe(false)
|
||||
expect(
|
||||
schema.safeParse({ view: 'overview', workspaceId: 'ws-1', executionId: 'e1' }).success
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,199 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { z } from 'zod'
|
||||
import { QueryLogs } from '@/lib/copilot/generated/tool-catalog-v1'
|
||||
import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool'
|
||||
import {
|
||||
collectLargeValueExecutionIds,
|
||||
collectLargeValueKeys,
|
||||
} from '@/lib/execution/payloads/large-execution-value'
|
||||
import { fetchLogDetail } from '@/lib/logs/fetch-log-detail'
|
||||
import { type ListLogsParams, listLogs } from '@/lib/logs/list-logs'
|
||||
import { grepSpans, type LogViewContext, toFull, toOverview } from '@/lib/logs/log-views'
|
||||
import type { TraceSpan } from '@/lib/logs/types'
|
||||
|
||||
const logger = createLogger('QueryLogsServerTool')
|
||||
|
||||
/**
|
||||
* Max serialized size for a `full` view result before falling back to the
|
||||
* compact overview. Keeps a single tool result inline-able.
|
||||
*/
|
||||
const MAX_FULL_RESULT_BYTES = 512 * 1024
|
||||
|
||||
const comparisonOperator = z.enum(['=', '>', '<', '>=', '<=', '!='])
|
||||
|
||||
const listArgsSchema = z.object({
|
||||
view: z.literal('list'),
|
||||
workspaceId: z.string().optional(),
|
||||
level: z.string().optional(),
|
||||
workflowIds: z.string().optional(),
|
||||
folderIds: z.string().optional(),
|
||||
triggers: z.string().optional(),
|
||||
startDate: z.string().optional(),
|
||||
endDate: z.string().optional(),
|
||||
search: z.string().optional(),
|
||||
workflowName: z.string().optional(),
|
||||
folderName: z.string().optional(),
|
||||
executionId: z.string().optional(),
|
||||
costOperator: comparisonOperator.optional(),
|
||||
costValue: z.coerce.number().optional(),
|
||||
durationOperator: comparisonOperator.optional(),
|
||||
durationValue: z.coerce.number().optional(),
|
||||
cursor: z.string().optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).optional().default(100),
|
||||
sortBy: z.enum(['date', 'duration', 'cost', 'status']).optional().default('date'),
|
||||
sortOrder: z.enum(['asc', 'desc']).optional().default('desc'),
|
||||
})
|
||||
|
||||
const overviewArgsSchema = z.object({
|
||||
view: z.literal('overview'),
|
||||
workspaceId: z.string().optional(),
|
||||
executionId: z.string(),
|
||||
pattern: z.string().optional(),
|
||||
})
|
||||
|
||||
const fullArgsSchema = z.object({
|
||||
view: z.literal('full'),
|
||||
workspaceId: z.string().optional(),
|
||||
executionId: z.string(),
|
||||
blockId: z.string().optional(),
|
||||
blockName: z.string().optional(),
|
||||
pattern: z.string().optional(),
|
||||
})
|
||||
|
||||
const queryLogsArgsSchema = z.discriminatedUnion('view', [
|
||||
listArgsSchema,
|
||||
overviewArgsSchema,
|
||||
fullArgsSchema,
|
||||
])
|
||||
|
||||
type QueryLogsArgs = z.infer<typeof queryLogsArgsSchema>
|
||||
|
||||
function resolveWorkspaceId(args: QueryLogsArgs, context?: ServerToolContext): string {
|
||||
const workspaceId = args.workspaceId ?? context?.workspaceId
|
||||
if (!workspaceId) {
|
||||
throw new Error('workspaceId is required')
|
||||
}
|
||||
return workspaceId
|
||||
}
|
||||
|
||||
function buildLogViewContext(
|
||||
detail: {
|
||||
workflowId: string | null
|
||||
executionId: string
|
||||
executionData?: unknown
|
||||
},
|
||||
workspaceId: string,
|
||||
userId: string
|
||||
): LogViewContext {
|
||||
return {
|
||||
workspaceId,
|
||||
workflowId: detail.workflowId ?? undefined,
|
||||
executionId: detail.executionId,
|
||||
userId,
|
||||
largeValueExecutionIds: collectLargeValueExecutionIds(detail.executionData),
|
||||
largeValueKeys: collectLargeValueKeys(detail.executionData),
|
||||
allowLargeValueWorkflowScope: true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Consolidated execution/log read tool.
|
||||
*
|
||||
* - `view: "list"` — paginated execution summaries with the full Logs-UI filter
|
||||
* set (reuses `listLogs`).
|
||||
* - `view: "overview"` — a single execution's trace-span tree (timing + cost,
|
||||
* no input/output).
|
||||
* - `view: "full"` — a single execution's trace spans with materialized
|
||||
* input/output, optionally scoped to one block via `blockId`/`blockName`.
|
||||
* - `pattern` (with `overview`/`full`) — grep that execution's trace spans,
|
||||
* streaming large values chunk-by-chunk.
|
||||
*/
|
||||
export const queryLogsServerTool: BaseServerTool<QueryLogsArgs, unknown> = {
|
||||
name: QueryLogs.id,
|
||||
inputSchema: queryLogsArgsSchema,
|
||||
outputSchema: z.unknown(),
|
||||
async execute(args: QueryLogsArgs, context?: ServerToolContext): Promise<unknown> {
|
||||
if (!context?.userId) {
|
||||
throw new Error('Unauthorized access')
|
||||
}
|
||||
const userId = context.userId
|
||||
const workspaceId = resolveWorkspaceId(args, context)
|
||||
|
||||
if (args.view === 'list') {
|
||||
const { view: _view, ...rest } = args
|
||||
const params = { ...rest, workspaceId } as ListLogsParams
|
||||
logger.info('query_logs list', { workspaceId, sortBy: params.sortBy })
|
||||
return listLogs(params, userId)
|
||||
}
|
||||
|
||||
// overview / full / grep — single execution by id
|
||||
const detail = await fetchLogDetail({
|
||||
userId,
|
||||
workspaceId,
|
||||
lookupColumn: 'executionId',
|
||||
lookupValue: args.executionId,
|
||||
})
|
||||
if (!detail) {
|
||||
return { ok: false, error: `Execution not found: ${args.executionId}` }
|
||||
}
|
||||
|
||||
const execData = detail.executionData as
|
||||
| { traceSpans?: TraceSpan[]; totalDuration?: number | null }
|
||||
| undefined
|
||||
const traceSpans = (execData?.traceSpans ?? []) as TraceSpan[]
|
||||
const viewCtx = buildLogViewContext(detail, workspaceId, userId)
|
||||
|
||||
if (args.pattern) {
|
||||
logger.info('query_logs grep', { workspaceId, executionId: args.executionId })
|
||||
const { matches, truncated } = await grepSpans(traceSpans, args.pattern, viewCtx)
|
||||
return {
|
||||
executionId: detail.executionId,
|
||||
workflowId: detail.workflowId,
|
||||
status: detail.status,
|
||||
pattern: args.pattern,
|
||||
matches,
|
||||
truncated,
|
||||
}
|
||||
}
|
||||
|
||||
if (args.view === 'overview') {
|
||||
return {
|
||||
executionId: detail.executionId,
|
||||
workflowId: detail.workflowId,
|
||||
status: detail.status,
|
||||
trigger: detail.trigger,
|
||||
durationMs: execData?.totalDuration ?? null,
|
||||
cost: detail.cost ?? null,
|
||||
spans: toOverview(traceSpans),
|
||||
}
|
||||
}
|
||||
|
||||
// full
|
||||
const spans = await toFull(traceSpans, viewCtx, {
|
||||
blockId: args.blockId,
|
||||
blockName: args.blockName,
|
||||
})
|
||||
const result = {
|
||||
executionId: detail.executionId,
|
||||
workflowId: detail.workflowId,
|
||||
status: detail.status,
|
||||
trigger: detail.trigger,
|
||||
cost: detail.cost ?? null,
|
||||
spans,
|
||||
truncated: false,
|
||||
}
|
||||
|
||||
if (JSON.stringify(result).length > MAX_FULL_RESULT_BYTES) {
|
||||
return {
|
||||
executionId: detail.executionId,
|
||||
workflowId: detail.workflowId,
|
||||
status: detail.status,
|
||||
truncated: true,
|
||||
note: 'Full result too large; returning the compact overview. Scope with blockId/blockName, or use pattern to grep.',
|
||||
spans: toOverview(traceSpans),
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user