From 3f3d5b276d58badd2c962a88fb0d51eac90f16fe Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 5 Feb 2026 16:47:57 -0800 Subject: [PATCH] Refactor complete - no testing yet --- ...ex-function-inventory-edit-workflow.ts.txt | 35 - ...-inventory-get-blocks-metadata-tool.ts.txt | 21 - ...function-inventory-process-contents.ts.txt | 13 - apps/sim/lib/copilot/client-sse/handlers.ts | 3 +- apps/sim/lib/copilot/orchestrator/index.ts | 5 +- .../orchestrator/sse-handlers/handlers.ts | 12 +- apps/sim/lib/copilot/process-contents.ts | 40 +- .../sim/lib/copilot/tools/server/base-tool.ts | 22 +- .../tools/server/blocks/get-block-config.ts | 3 + .../tools/server/blocks/get-block-options.ts | 2 + .../server/blocks/get-blocks-and-tools.ts | 2 + .../server/blocks/get-blocks-metadata-tool.ts | 2 + .../tools/server/blocks/get-trigger-blocks.ts | 2 + .../tools/server/other/make-api-request.ts | 60 +- .../tools/server/other/search-online.ts | 114 +- apps/sim/lib/copilot/tools/server/router.ts | 111 +- .../tools/server/workflow/edit-workflow.ts | 3334 ----------------- .../server/workflow/edit-workflow/builders.ts | 633 ++++ .../server/workflow/edit-workflow/engine.ts | 274 ++ .../server/workflow/edit-workflow/index.ts | 284 ++ .../workflow/edit-workflow/operations.ts | 996 +++++ .../server/workflow/edit-workflow/types.ts | 134 + .../workflow/edit-workflow/validation.ts | 1051 ++++++ .../server/workflow/get-workflow-console.ts | 1 + apps/sim/lib/workflows/blocks/index.ts | 2 + .../lib/workflows/blocks/schema-resolver.ts | 201 + apps/sim/lib/workflows/blocks/schema-types.ts | 75 + 27 files changed, 3826 insertions(+), 3606 deletions(-) delete mode 100644 apps/sim/.codex-function-inventory-edit-workflow.ts.txt delete mode 100644 apps/sim/.codex-function-inventory-get-blocks-metadata-tool.ts.txt delete mode 100644 apps/sim/.codex-function-inventory-process-contents.ts.txt delete mode 100644 apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts create mode 100644 apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.ts create mode 100644 apps/sim/lib/copilot/tools/server/workflow/edit-workflow/engine.ts create mode 100644 apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts create mode 100644 apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts create mode 100644 apps/sim/lib/copilot/tools/server/workflow/edit-workflow/types.ts create mode 100644 apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts create mode 100644 apps/sim/lib/workflows/blocks/index.ts create mode 100644 apps/sim/lib/workflows/blocks/schema-resolver.ts create mode 100644 apps/sim/lib/workflows/blocks/schema-types.ts diff --git a/apps/sim/.codex-function-inventory-edit-workflow.ts.txt b/apps/sim/.codex-function-inventory-edit-workflow.ts.txt deleted file mode 100644 index e77b30fbe..000000000 --- a/apps/sim/.codex-function-inventory-edit-workflow.ts.txt +++ /dev/null @@ -1,35 +0,0 @@ -# lib/copilot/tools/server/workflow/edit-workflow.ts - 90-98 ( 9 lines) [function] logSkippedItem - 103-113 ( 11 lines) [function] findBlockWithDuplicateNormalizedName - 127-196 ( 70 lines) [function] validateInputsForBlock - 211-463 ( 253 lines) [function] validateValueForSubBlockType - 481-566 ( 86 lines) [function] topologicalSortInserts - 571-684 ( 114 lines) [function] createBlockFromParams - 686-716 ( 31 lines) [function] updateCanonicalModesForInputs - 721-762 ( 42 lines) [function] normalizeTools - 786-804 ( 19 lines) [function] normalizeArrayWithIds - 809-811 ( 3 lines) [function] shouldNormalizeArrayIds - 818-859 ( 42 lines) [function] normalizeResponseFormat - 834-847 ( 14 lines) [arrow] sortKeys - 871-945 ( 75 lines) [function] validateSourceHandleForBlock - 956-1051 ( 96 lines) [function] validateConditionHandle -1062-1136 ( 75 lines) [function] validateRouterHandle -1141-1149 ( 9 lines) [function] validateTargetHandle -1155-1261 ( 107 lines) [function] createValidatedEdge -1270-1307 ( 38 lines) [function] addConnectionsAsEdges -1280-1291 ( 12 lines) [arrow] addEdgeForTarget -1309-1339 ( 31 lines) [function] applyTriggerConfigToBlockSubblocks -1353-1361 ( 9 lines) [function] isBlockTypeAllowed -1367-1404 ( 38 lines) [function] filterDisallowedTools -1413-1499 ( 87 lines) [function] normalizeBlockIdsInOperations -1441-1444 ( 4 lines) [arrow] replaceId -1504-2676 (1173 lines) [function] applyOperationsToWorkflowState -1649-1656 ( 8 lines) [arrow] findChildren -2055-2059 ( 5 lines) [arrow] mapConnectionTypeToHandle -2063-2074 ( 12 lines) [arrow] addEdgeForTarget -2682-2777 ( 96 lines) [function] validateWorkflowSelectorIds -2786-3066 ( 281 lines) [function] preValidateCredentialInputs -2820-2845 ( 26 lines) [function] collectCredentialInputs -2850-2870 ( 21 lines) [function] collectHostedApiKeyInput -3068-3117 ( 50 lines) [function] getCurrentWorkflowStateFromDb -3121-3333 ( 213 lines) [method] .execute diff --git a/apps/sim/.codex-function-inventory-get-blocks-metadata-tool.ts.txt b/apps/sim/.codex-function-inventory-get-blocks-metadata-tool.ts.txt deleted file mode 100644 index 61d57991b..000000000 --- a/apps/sim/.codex-function-inventory-get-blocks-metadata-tool.ts.txt +++ /dev/null @@ -1,21 +0,0 @@ -# lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts - 108-306 ( 199 lines) [method] .execute - 309-384 ( 76 lines) [function] transformBlockMetadata - 386-459 ( 74 lines) [function] extractInputs - 461-503 ( 43 lines) [function] extractOperationInputs - 505-518 ( 14 lines) [function] extractOutputs - 520-538 ( 19 lines) [function] formatOutputsFromDefinition - 540-563 ( 24 lines) [function] mapSchemaTypeToSimpleType - 565-591 ( 27 lines) [function] generateInputExample - 593-669 ( 77 lines) [function] processSubBlock - 671-679 ( 9 lines) [function] resolveAuthType - 686-702 ( 17 lines) [function] getStaticModelOptions - 712-754 ( 43 lines) [function] callOptionsWithFallback - 756-806 ( 51 lines) [function] resolveSubblockOptions - 808-820 ( 13 lines) [function] removeNullish - 822-832 ( 11 lines) [function] normalizeCondition - 834-872 ( 39 lines) [function] splitParametersByOperation - 874-905 ( 32 lines) [function] computeBlockLevelInputs - 907-935 ( 29 lines) [function] computeOperationLevelInputs - 937-947 ( 11 lines) [function] resolveOperationIds - 949-961 ( 13 lines) [function] resolveToolIdForOperation diff --git a/apps/sim/.codex-function-inventory-process-contents.ts.txt b/apps/sim/.codex-function-inventory-process-contents.ts.txt deleted file mode 100644 index 82e8de18e..000000000 --- a/apps/sim/.codex-function-inventory-process-contents.ts.txt +++ /dev/null @@ -1,13 +0,0 @@ -# lib/copilot/process-contents.ts - 31-81 ( 51 lines) [function] processContexts - 84-161 ( 78 lines) [function] processContextsServer - 163-208 ( 46 lines) [function] sanitizeMessageForDocs - 210-248 ( 39 lines) [function] processPastChatFromDb - 250-281 ( 32 lines) [function] processWorkflowFromDb - 283-316 ( 34 lines) [function] processPastChat - 319-321 ( 3 lines) [function] processPastChatViaApi - 323-362 ( 40 lines) [function] processKnowledgeFromDb - 364-439 ( 76 lines) [function] processBlockMetadata - 441-473 ( 33 lines) [function] processTemplateFromDb - 475-498 ( 24 lines) [function] processWorkflowBlockFromDb - 500-555 ( 56 lines) [function] processExecutionLogFromDb diff --git a/apps/sim/lib/copilot/client-sse/handlers.ts b/apps/sim/lib/copilot/client-sse/handlers.ts index 484543163..bc8968fc0 100644 --- a/apps/sim/lib/copilot/client-sse/handlers.ts +++ b/apps/sim/lib/copilot/client-sse/handlers.ts @@ -11,6 +11,7 @@ import { } from '@/lib/copilot/store-utils' import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import type { CopilotStore, CopilotStreamInfo, CopilotToolCall } from '@/stores/panel/copilot/types' +import type { WorkflowState } from '@/stores/workflows/workflow/types' import { appendTextBlock, beginThinkingBlock, @@ -295,7 +296,7 @@ export const sseHandlers: Record = { }) if (hasWorkflowState) { const diffStore = useWorkflowDiffStore.getState() - diffStore.setProposedChanges(resultPayload.workflowState).catch((err) => { + diffStore.setProposedChanges(resultPayload.workflowState as WorkflowState).catch((err) => { logger.error('[SSE] Failed to apply edit_workflow diff', { error: err instanceof Error ? err.message : String(err), }) diff --git a/apps/sim/lib/copilot/orchestrator/index.ts b/apps/sim/lib/copilot/orchestrator/index.ts index a909fa294..9540027cd 100644 --- a/apps/sim/lib/copilot/orchestrator/index.ts +++ b/apps/sim/lib/copilot/orchestrator/index.ts @@ -14,15 +14,16 @@ export interface OrchestrateStreamOptions extends OrchestratorOptions { } export async function orchestrateCopilotStream( - requestPayload: Record, + requestPayload: Record, options: OrchestrateStreamOptions ): Promise { const { userId, workflowId, chatId } = options const execContext = await prepareExecutionContext(userId, workflowId) + const payloadMsgId = requestPayload?.messageId const context = createStreamingContext({ chatId, - messageId: requestPayload?.messageId || crypto.randomUUID(), + messageId: typeof payloadMsgId === 'string' ? payloadMsgId : crypto.randomUUID(), }) try { diff --git a/apps/sim/lib/copilot/orchestrator/sse-handlers/handlers.ts b/apps/sim/lib/copilot/orchestrator/sse-handlers/handlers.ts index 138b5516b..84da658b9 100644 --- a/apps/sim/lib/copilot/orchestrator/sse-handlers/handlers.ts +++ b/apps/sim/lib/copilot/orchestrator/sse-handlers/handlers.ts @@ -344,7 +344,7 @@ export const subAgentHandlers: Record = { const parentToolCallId = context.subAgentParentToolCallId if (!parentToolCallId) return const data = getEventData(event) - const toolCallId = event.toolCallId || data?.id + const toolCallId = event.toolCallId || (data?.id as string | undefined) if (!toolCallId) return // Update in subAgentToolCalls. @@ -364,14 +364,20 @@ export const subAgentHandlers: Record = { subAgentToolCall.status = status subAgentToolCall.endTime = endTime if (result) subAgentToolCall.result = result - if (hasError) subAgentToolCall.error = data?.error || data?.result?.error + if (hasError) { + const resultObj = asRecord(data?.result) + subAgentToolCall.error = (data?.error || resultObj.error) as string | undefined + } } if (mainToolCall) { mainToolCall.status = status mainToolCall.endTime = endTime if (result) mainToolCall.result = result - if (hasError) mainToolCall.error = data?.error || data?.result?.error + if (hasError) { + const resultObj = asRecord(data?.result) + mainToolCall.error = (data?.error || resultObj.error) as string | undefined + } } }, } diff --git a/apps/sim/lib/copilot/process-contents.ts b/apps/sim/lib/copilot/process-contents.ts index 13a0015f0..6e69e747e 100644 --- a/apps/sim/lib/copilot/process-contents.ts +++ b/apps/sim/lib/copilot/process-contents.ts @@ -44,29 +44,29 @@ export async function processContexts( ctx.kind ) } - if (ctx.kind === 'knowledge' && (ctx as any).knowledgeId) { + if (ctx.kind === 'knowledge' && ctx.knowledgeId) { return await processKnowledgeFromDb( - (ctx as any).knowledgeId, + ctx.knowledgeId, ctx.label ? `@${ctx.label}` : '@' ) } - if (ctx.kind === 'blocks' && (ctx as any).blockId) { - return await processBlockMetadata((ctx as any).blockId, ctx.label ? `@${ctx.label}` : '@') + if (ctx.kind === 'blocks' && ctx.blockIds?.length > 0) { + return await processBlockMetadata(ctx.blockIds[0], ctx.label ? `@${ctx.label}` : '@') } - if (ctx.kind === 'templates' && (ctx as any).templateId) { + if (ctx.kind === 'templates' && ctx.templateId) { return await processTemplateFromDb( - (ctx as any).templateId, + ctx.templateId, ctx.label ? `@${ctx.label}` : '@' ) } - if (ctx.kind === 'logs' && (ctx as any).executionId) { + if (ctx.kind === 'logs' && ctx.executionId) { return await processExecutionLogFromDb( - (ctx as any).executionId, + ctx.executionId, ctx.label ? `@${ctx.label}` : '@' ) } - if (ctx.kind === 'workflow_block' && ctx.workflowId && (ctx as any).blockId) { - return await processWorkflowBlockFromDb(ctx.workflowId, (ctx as any).blockId, ctx.label) + if (ctx.kind === 'workflow_block' && ctx.workflowId && ctx.blockId) { + return await processWorkflowBlockFromDb(ctx.workflowId, ctx.blockId, ctx.label) } // Other kinds can be added here: workflow, blocks, logs, knowledge, templates, docs return null @@ -99,33 +99,33 @@ export async function processContextsServer( ctx.kind ) } - if (ctx.kind === 'knowledge' && (ctx as any).knowledgeId) { + if (ctx.kind === 'knowledge' && ctx.knowledgeId) { return await processKnowledgeFromDb( - (ctx as any).knowledgeId, + ctx.knowledgeId, ctx.label ? `@${ctx.label}` : '@' ) } - if (ctx.kind === 'blocks' && (ctx as any).blockId) { + if (ctx.kind === 'blocks' && ctx.blockIds?.length > 0) { return await processBlockMetadata( - (ctx as any).blockId, + ctx.blockIds[0], ctx.label ? `@${ctx.label}` : '@', userId ) } - if (ctx.kind === 'templates' && (ctx as any).templateId) { + if (ctx.kind === 'templates' && ctx.templateId) { return await processTemplateFromDb( - (ctx as any).templateId, + ctx.templateId, ctx.label ? `@${ctx.label}` : '@' ) } - if (ctx.kind === 'logs' && (ctx as any).executionId) { + if (ctx.kind === 'logs' && ctx.executionId) { return await processExecutionLogFromDb( - (ctx as any).executionId, + ctx.executionId, ctx.label ? `@${ctx.label}` : '@' ) } - if (ctx.kind === 'workflow_block' && ctx.workflowId && (ctx as any).blockId) { - return await processWorkflowBlockFromDb(ctx.workflowId, (ctx as any).blockId, ctx.label) + if (ctx.kind === 'workflow_block' && ctx.workflowId && ctx.blockId) { + return await processWorkflowBlockFromDb(ctx.workflowId, ctx.blockId, ctx.label) } if (ctx.kind === 'docs') { try { diff --git a/apps/sim/lib/copilot/tools/server/base-tool.ts b/apps/sim/lib/copilot/tools/server/base-tool.ts index 40ec3584c..176059734 100644 --- a/apps/sim/lib/copilot/tools/server/base-tool.ts +++ b/apps/sim/lib/copilot/tools/server/base-tool.ts @@ -1,4 +1,20 @@ -export interface BaseServerTool { - name: string - execute(args: TArgs, context?: { userId: string }): Promise +import type { z } from 'zod' + +export interface ServerToolContext { + userId: string +} + +/** + * Base interface for server-side copilot tools. + * + * Tools can optionally declare Zod schemas for input/output validation. + * If provided, the router validates automatically. + */ +export interface BaseServerTool { + name: string + execute(args: TArgs, context?: ServerToolContext): Promise + /** Optional Zod schema for input validation */ + inputSchema?: z.ZodType + /** Optional Zod schema for output validation */ + outputSchema?: z.ZodType } diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-block-config.ts b/apps/sim/lib/copilot/tools/server/blocks/get-block-config.ts index cd95577d7..64021e07c 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-block-config.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-block-config.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' import { + GetBlockConfigInput, type GetBlockConfigInputType, GetBlockConfigResult, type GetBlockConfigResultType, @@ -370,6 +371,8 @@ export const getBlockConfigServerTool: BaseServerTool< GetBlockConfigResultType > = { name: 'get_block_config', + inputSchema: GetBlockConfigInput, + outputSchema: GetBlockConfigResult, async execute( { blockType, operation, trigger }: GetBlockConfigInputType, context?: { userId: string } diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-block-options.ts b/apps/sim/lib/copilot/tools/server/blocks/get-block-options.ts index 177482fc3..c93db8b8a 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-block-options.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-block-options.ts @@ -14,6 +14,8 @@ export const getBlockOptionsServerTool: BaseServerTool< GetBlockOptionsResultType > = { name: 'get_block_options', + inputSchema: GetBlockOptionsInput, + outputSchema: GetBlockOptionsResult, async execute( { blockId }: GetBlockOptionsInputType, context?: { userId: string } diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-and-tools.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-and-tools.ts index 9413dc278..cf32eea70 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-and-tools.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-and-tools.ts @@ -13,6 +13,8 @@ export const getBlocksAndToolsServerTool: BaseServerTool< ReturnType > = { name: 'get_blocks_and_tools', + inputSchema: GetBlocksAndToolsInput, + outputSchema: GetBlocksAndToolsResult, async execute(_args: unknown, context?: { userId: string }) { const logger = createLogger('GetBlocksAndToolsServerTool') logger.debug('Executing get_blocks_and_tools') diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts index 6699496e7..374b47c0d 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts @@ -105,6 +105,8 @@ export const getBlocksMetadataServerTool: BaseServerTool< ReturnType > = { name: 'get_blocks_metadata', + inputSchema: GetBlocksMetadataInput, + outputSchema: GetBlocksMetadataResult, async execute( { blockIds }: ReturnType, context?: { userId: string } diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts b/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts index 5f5820e20..367c61475 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts @@ -15,6 +15,8 @@ export const getTriggerBlocksServerTool: BaseServerTool< ReturnType > = { name: 'get_trigger_blocks', + inputSchema: GetTriggerBlocksInput, + outputSchema: GetTriggerBlocksResult, async execute(_args: unknown, context?: { userId: string }) { const logger = createLogger('GetTriggerBlocksServerTool') logger.debug('Executing get_trigger_blocks') diff --git a/apps/sim/lib/copilot/tools/server/other/make-api-request.ts b/apps/sim/lib/copilot/tools/server/other/make-api-request.ts index 8d47d7c82..3f9546051 100644 --- a/apps/sim/lib/copilot/tools/server/other/make-api-request.ts +++ b/apps/sim/lib/copilot/tools/server/other/make-api-request.ts @@ -3,22 +3,34 @@ import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' import { executeTool } from '@/tools' import type { TableRow } from '@/tools/types' +const RESULT_CHAR_CAP = Number(process.env.COPILOT_TOOL_RESULT_CHAR_CAP || 20000) + interface MakeApiRequestParams { url: string method: 'GET' | 'POST' | 'PUT' queryParams?: Record headers?: Record - body?: any + body?: unknown } -export const makeApiRequestServerTool: BaseServerTool = { +interface ApiResponse { + data: string + status: number + headers: Record + truncated?: boolean + totalChars?: number + previewChars?: number + note?: string +} + +export const makeApiRequestServerTool: BaseServerTool = { name: 'make_api_request', - async execute(params: MakeApiRequestParams): Promise { + async execute(params: MakeApiRequestParams): Promise { const logger = createLogger('MakeApiRequestServerTool') - const { url, method, queryParams, headers, body } = params || ({} as MakeApiRequestParams) + const { url, method, queryParams, headers, body } = params if (!url || !method) throw new Error('url and method are required') - const toTableRows = (obj?: Record): TableRow[] | null => { + const toTableRows = (obj?: Record): TableRow[] | null => { if (!obj || typeof obj !== 'object') return null return Object.entries(obj).map(([key, value]) => ({ id: key, @@ -26,21 +38,22 @@ export const makeApiRequestServerTool: BaseServerTool })) } const headersTable = toTableRows(headers) - const queryParamsTable = toTableRows(queryParams as Record | undefined) + const queryParamsTable = toTableRows(queryParams as Record | undefined) const result = await executeTool( 'http_request', { url, method, params: queryParamsTable, headers: headersTable, body }, true ) - if (!result.success) throw new Error(result.error || 'API request failed') - const output = (result as any).output || result - const data = output.output?.data ?? output.data - const status = output.output?.status ?? output.status ?? 200 - const respHeaders = output.output?.headers ?? output.headers ?? {} + if (!result.success) throw new Error(result.error ?? 'API request failed') - const CAP = Number(process.env.COPILOT_TOOL_RESULT_CHAR_CAP || 20000) - const toStringSafe = (val: any): string => { + const output = result.output as Record | undefined + const nestedOutput = output?.output as Record | undefined + const data = nestedOutput?.data ?? output?.data + const status = (nestedOutput?.status ?? output?.status ?? 200) as number + const respHeaders = (nestedOutput?.headers ?? output?.headers ?? {}) as Record + + const toStringSafe = (val: unknown): string => { if (typeof val === 'string') return val try { return JSON.stringify(val) @@ -53,7 +66,6 @@ export const makeApiRequestServerTool: BaseServerTool try { let text = html let previous: string - do { previous = text text = text.replace(//gi, '') @@ -61,26 +73,21 @@ export const makeApiRequestServerTool: BaseServerTool text = text.replace(/<[^>]*>/g, ' ') text = text.replace(/[<>]/g, ' ') } while (text !== previous) - return text.replace(/\s+/g, ' ').trim() } catch { return html } } + let normalized = toStringSafe(data) const looksLikeHtml = //i.test(normalized) || //i.test(normalized) if (looksLikeHtml) normalized = stripHtml(normalized) + const totalChars = normalized.length - if (totalChars > CAP) { - const preview = normalized.slice(0, CAP) - logger.warn('API response truncated by character cap', { - url, - method, - totalChars, - previewChars: preview.length, - cap: CAP, - }) + if (totalChars > RESULT_CHAR_CAP) { + const preview = normalized.slice(0, RESULT_CHAR_CAP) + logger.warn('API response truncated', { url, method, totalChars, cap: RESULT_CHAR_CAP }) return { data: preview, status, @@ -88,10 +95,11 @@ export const makeApiRequestServerTool: BaseServerTool truncated: true, totalChars, previewChars: preview.length, - note: `Response truncated to ${CAP} characters to avoid large payloads`, + note: `Response truncated to ${RESULT_CHAR_CAP} characters`, } } - logger.info('API request executed', { url, method, status, totalChars }) + + logger.debug('API request executed', { url, method, status, totalChars }) return { data: normalized, status, headers: respHeaders } }, } diff --git a/apps/sim/lib/copilot/tools/server/other/search-online.ts b/apps/sim/lib/copilot/tools/server/other/search-online.ts index e8b725b05..a839d345c 100644 --- a/apps/sim/lib/copilot/tools/server/other/search-online.ts +++ b/apps/sim/lib/copilot/tools/server/other/search-online.ts @@ -11,78 +11,73 @@ interface OnlineSearchParams { hl?: string } -export const searchOnlineServerTool: BaseServerTool = { +interface SearchResult { + title: string + link: string + snippet: string + date?: string + position?: number +} + +interface SearchResponse { + results: SearchResult[] + query: string + type: string + totalResults: number + source: 'exa' | 'serper' +} + +export const searchOnlineServerTool: BaseServerTool = { name: 'search_online', - async execute(params: OnlineSearchParams): Promise { + async execute(params: OnlineSearchParams): Promise { const logger = createLogger('SearchOnlineServerTool') const { query, num = 10, type = 'search', gl, hl } = params if (!query || typeof query !== 'string') throw new Error('query is required') - // Check which API keys are available const hasExaApiKey = Boolean(env.EXA_API_KEY && String(env.EXA_API_KEY).length > 0) const hasSerperApiKey = Boolean(env.SERPER_API_KEY && String(env.SERPER_API_KEY).length > 0) - logger.info('Performing online search', { - queryLength: query.length, - num, - type, - gl, - hl, - hasExaApiKey, - hasSerperApiKey, - }) + logger.debug('Performing online search', { queryLength: query.length, num, type }) // Try Exa first if available if (hasExaApiKey) { try { - logger.debug('Attempting exa_search', { num }) const exaResult = await executeTool('exa_search', { query, numResults: num, type: 'auto', - apiKey: env.EXA_API_KEY || '', + apiKey: env.EXA_API_KEY ?? '', }) - const exaResults = (exaResult as any)?.output?.results || [] - const count = Array.isArray(exaResults) ? exaResults.length : 0 - const firstTitle = count > 0 ? String(exaResults[0]?.title || '') : undefined + const output = exaResult.output as { results?: Array<{ title?: string; url?: string; text?: string; summary?: string; publishedDate?: string }> } | undefined + const exaResults = output?.results ?? [] - logger.info('exa_search completed', { - success: exaResult.success, - resultsCount: count, - firstTitlePreview: firstTitle?.slice(0, 120), - }) - - if (exaResult.success && count > 0) { - // Transform Exa results to match expected format - const transformedResults = exaResults.map((result: any) => ({ - title: result.title || '', - link: result.url || '', - snippet: result.text || result.summary || '', + if (exaResult.success && exaResults.length > 0) { + const transformedResults: SearchResult[] = exaResults.map((result, index) => ({ + title: result.title ?? '', + link: result.url ?? '', + snippet: result.text ?? result.summary ?? '', date: result.publishedDate, - position: exaResults.indexOf(result) + 1, + position: index + 1, })) return { results: transformedResults, query, type, - totalResults: count, + totalResults: transformedResults.length, source: 'exa', } } - logger.warn('exa_search returned no results, falling back to Serper', { - queryLength: query.length, - }) - } catch (exaError: any) { + logger.debug('exa_search returned no results, falling back to Serper') + } catch (exaError) { logger.warn('exa_search failed, falling back to Serper', { - error: exaError?.message, + error: exaError instanceof Error ? exaError.message : String(exaError), }) } } - // Fall back to Serper if Exa failed or wasn't available if (!hasSerperApiKey) { throw new Error('No search API keys available (EXA_API_KEY or SERPER_API_KEY required)') } @@ -93,41 +88,24 @@ export const searchOnlineServerTool: BaseServerTool = { type, gl, hl, - apiKey: env.SERPER_API_KEY || '', + apiKey: env.SERPER_API_KEY ?? '', } - try { - logger.debug('Calling serper_search tool', { type, num, gl, hl }) - const result = await executeTool('serper_search', toolParams) - const results = (result as any)?.output?.searchResults || [] - const count = Array.isArray(results) ? results.length : 0 - const firstTitle = count > 0 ? String(results[0]?.title || '') : undefined + const result = await executeTool('serper_search', toolParams) + const output = result.output as { searchResults?: SearchResult[] } | undefined + const results = output?.searchResults ?? [] - logger.info('serper_search completed', { - success: result.success, - resultsCount: count, - firstTitlePreview: firstTitle?.slice(0, 120), - }) + if (!result.success) { + const errorMsg = (result as { error?: string }).error ?? 'Search failed' + throw new Error(errorMsg) + } - if (!result.success) { - logger.error('serper_search failed', { error: (result as any)?.error }) - throw new Error((result as any)?.error || 'Search failed') - } - - if (count === 0) { - logger.warn('serper_search returned no results', { queryLength: query.length }) - } - - return { - results, - query, - type, - totalResults: count, - source: 'serper', - } - } catch (e: any) { - logger.error('search_online execution error', { message: e?.message }) - throw e + return { + results, + query, + type, + totalResults: results.length, + source: 'serper', } }, } diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index 2c79cff74..e17b1364f 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' +import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' import { getBlockConfigServerTool } from '@/lib/copilot/tools/server/blocks/get-block-config' import { getBlockOptionsServerTool } from '@/lib/copilot/tools/server/blocks/get-block-options' import { getBlocksAndToolsServerTool } from '@/lib/copilot/tools/server/blocks/get-blocks-and-tools' @@ -13,101 +13,52 @@ import { getCredentialsServerTool } from '@/lib/copilot/tools/server/user/get-cr import { setEnvironmentVariablesServerTool } from '@/lib/copilot/tools/server/user/set-environment-variables' import { editWorkflowServerTool } from '@/lib/copilot/tools/server/workflow/edit-workflow' import { getWorkflowConsoleServerTool } from '@/lib/copilot/tools/server/workflow/get-workflow-console' -import { - ExecuteResponseSuccessSchema, - GetBlockConfigInput, - GetBlockConfigResult, - GetBlockOptionsInput, - GetBlockOptionsResult, - GetBlocksAndToolsInput, - GetBlocksAndToolsResult, - GetBlocksMetadataInput, - GetBlocksMetadataResult, - GetTriggerBlocksInput, - GetTriggerBlocksResult, - KnowledgeBaseArgsSchema, -} from '@/lib/copilot/tools/shared/schemas' +import { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas' -// Generic execute response schemas (success path only for this route; errors handled via HTTP status) export { ExecuteResponseSuccessSchema } export type ExecuteResponseSuccess = (typeof ExecuteResponseSuccessSchema)['_type'] -// Define server tool registry for the new copilot runtime -const serverToolRegistry: Record> = {} const logger = createLogger('ServerToolRouter') -// Register tools -serverToolRegistry[getBlocksAndToolsServerTool.name] = getBlocksAndToolsServerTool -serverToolRegistry[getBlocksMetadataServerTool.name] = getBlocksMetadataServerTool -serverToolRegistry[getBlockOptionsServerTool.name] = getBlockOptionsServerTool -serverToolRegistry[getBlockConfigServerTool.name] = getBlockConfigServerTool -serverToolRegistry[getTriggerBlocksServerTool.name] = getTriggerBlocksServerTool -serverToolRegistry[editWorkflowServerTool.name] = editWorkflowServerTool -serverToolRegistry[getWorkflowConsoleServerTool.name] = getWorkflowConsoleServerTool -serverToolRegistry[searchDocumentationServerTool.name] = searchDocumentationServerTool -serverToolRegistry[searchOnlineServerTool.name] = searchOnlineServerTool -serverToolRegistry[setEnvironmentVariablesServerTool.name] = setEnvironmentVariablesServerTool -serverToolRegistry[getCredentialsServerTool.name] = getCredentialsServerTool -serverToolRegistry[makeApiRequestServerTool.name] = makeApiRequestServerTool -serverToolRegistry[knowledgeBaseServerTool.name] = knowledgeBaseServerTool +/** Registry of all server tools. Tools self-declare their validation schemas. */ +const serverToolRegistry: Record = { + [getBlocksAndToolsServerTool.name]: getBlocksAndToolsServerTool, + [getBlocksMetadataServerTool.name]: getBlocksMetadataServerTool, + [getBlockOptionsServerTool.name]: getBlockOptionsServerTool, + [getBlockConfigServerTool.name]: getBlockConfigServerTool, + [getTriggerBlocksServerTool.name]: getTriggerBlocksServerTool, + [editWorkflowServerTool.name]: editWorkflowServerTool, + [getWorkflowConsoleServerTool.name]: getWorkflowConsoleServerTool, + [searchDocumentationServerTool.name]: searchDocumentationServerTool, + [searchOnlineServerTool.name]: searchOnlineServerTool, + [setEnvironmentVariablesServerTool.name]: setEnvironmentVariablesServerTool, + [getCredentialsServerTool.name]: getCredentialsServerTool, + [makeApiRequestServerTool.name]: makeApiRequestServerTool, + [knowledgeBaseServerTool.name]: knowledgeBaseServerTool, +} +/** + * Route a tool execution request to the appropriate server tool. + * Validates input/output using the tool's declared Zod schemas if present. + */ export async function routeExecution( toolName: string, payload: unknown, - context?: { userId: string } -): Promise { + context?: ServerToolContext +): Promise { const tool = serverToolRegistry[toolName] if (!tool) { throw new Error(`Unknown server tool: ${toolName}`) } - logger.debug('Routing to tool', { - toolName, - payloadPreview: (() => { - try { - return JSON.stringify(payload).slice(0, 200) - } catch { - return undefined - } - })(), - }) - let args: any = payload || {} - if (toolName === 'get_blocks_and_tools') { - args = GetBlocksAndToolsInput.parse(args) - } - if (toolName === 'get_blocks_metadata') { - args = GetBlocksMetadataInput.parse(args) - } - if (toolName === 'get_block_options') { - args = GetBlockOptionsInput.parse(args) - } - if (toolName === 'get_block_config') { - args = GetBlockConfigInput.parse(args) - } - if (toolName === 'get_trigger_blocks') { - args = GetTriggerBlocksInput.parse(args) - } - if (toolName === 'knowledge_base') { - args = KnowledgeBaseArgsSchema.parse(args) - } + logger.debug('Routing to tool', { toolName }) + // Validate input if tool declares a schema + const args = tool.inputSchema ? tool.inputSchema.parse(payload ?? {}) : (payload ?? {}) + + // Execute const result = await tool.execute(args, context) - if (toolName === 'get_blocks_and_tools') { - return GetBlocksAndToolsResult.parse(result) - } - if (toolName === 'get_blocks_metadata') { - return GetBlocksMetadataResult.parse(result) - } - if (toolName === 'get_block_options') { - return GetBlockOptionsResult.parse(result) - } - if (toolName === 'get_block_config') { - return GetBlockConfigResult.parse(result) - } - if (toolName === 'get_trigger_blocks') { - return GetTriggerBlocksResult.parse(result) - } - - return result + // Validate output if tool declares a schema + return tool.outputSchema ? tool.outputSchema.parse(result) : result } diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts deleted file mode 100644 index 7a22c8075..000000000 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts +++ /dev/null @@ -1,3334 +0,0 @@ -import crypto from 'crypto' -import { db } from '@sim/db' -import { workflow as workflowTable } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' -import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' -import { validateSelectorIds } from '@/lib/copilot/validation/selector-validator' -import type { PermissionGroupConfig } from '@/lib/permission-groups/types' -import { applyAutoLayout } from '@/lib/workflows/autolayout' -import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs' -import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence' -import { - loadWorkflowFromNormalizedTables, - saveWorkflowToNormalizedTables, -} from '@/lib/workflows/persistence/utils' -import { isValidKey } from '@/lib/workflows/sanitization/key-validation' -import { validateWorkflowState } from '@/lib/workflows/sanitization/validation' -import { buildCanonicalIndex, isCanonicalPair } from '@/lib/workflows/subblocks/visibility' -import { TriggerUtils } from '@/lib/workflows/triggers/triggers' -import { getAllBlocks, getBlock } from '@/blocks/registry' -import type { BlockConfig, SubBlockConfig } from '@/blocks/types' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' -import { EDGE, normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants' -import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' -import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants' - -/** Selector subblock types that can be validated */ -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 - */ -interface ValidationError { - blockId: string - blockType: string - field: string - value: any - error: string -} - -/** - * Types of items that can be skipped during operation application - */ -type SkippedItemType = - | 'block_not_found' - | 'invalid_block_type' - | 'block_not_allowed' - | 'block_locked' - | 'tool_not_allowed' - | 'invalid_edge_target' - | 'invalid_edge_source' - | '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 - */ -interface SkippedItem { - type: SkippedItemType - operationType: string - blockId: string - reason: string - details?: Record -} - -/** - * Logs and records a skipped item - */ -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) -} - -/** - * Finds an existing block with the same normalized name. - */ -function findBlockWithDuplicateNormalizedName( - blocks: Record, - name: string, - excludeBlockId: string -): [string, any] | undefined { - const normalizedName = normalizeName(name) - return Object.entries(blocks).find( - ([blockId, block]: [string, any]) => - blockId !== excludeBlockId && normalizeName(block.name || '') === normalizedName - ) -} - -/** - * Result of input validation - */ -interface ValidationResult { - validInputs: Record - errors: ValidationError[] -} - -/** - * Validates and filters inputs against a block's subBlock configuration - * Returns valid inputs and any validation errors encountered - */ -function validateInputsForBlock( - blockType: string, - inputs: Record, - blockId: string -): ValidationResult { - const errors: ValidationError[] = [] - const blockConfig = getBlock(blockType) - - if (!blockConfig) { - // Unknown block type - return inputs as-is (let it fail later if invalid) - validationLogger.warn(`Unknown block type: ${blockType}, skipping validation`) - return { validInputs: inputs, errors: [] } - } - - const validatedInputs: Record = {} - const subBlockMap = new Map() - - // Build map of subBlock id -> config - for (const subBlock of blockConfig.subBlocks) { - subBlockMap.set(subBlock.id, subBlock) - } - - for (const [key, value] of Object.entries(inputs)) { - // Skip runtime subblock IDs - if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(key)) { - continue - } - - const subBlockConfig = subBlockMap.get(key) - - // If subBlock doesn't exist in config, skip it (unless it's a known dynamic field) - if (!subBlockConfig) { - // Some fields are valid but not in subBlocks (like loop/parallel config) - // Allow these through for special block types - if (blockType === 'loop' || blockType === 'parallel') { - validatedInputs[key] = value - } else { - errors.push({ - blockId, - blockType, - field: key, - value, - error: `Unknown input field "${key}" for block type "${blockType}"`, - }) - } - continue - } - - // Note: We do NOT check subBlockConfig.condition here. - // Conditions are for UI display logic (show/hide fields in the editor). - // For API/Copilot, any valid field in the block schema should be accepted. - // The runtime will use the relevant fields based on the actual operation. - - // Validate value based on subBlock type - const validationResult = validateValueForSubBlockType( - subBlockConfig, - value, - key, - blockType, - blockId - ) - if (validationResult.valid) { - validatedInputs[key] = validationResult.value - } else if (validationResult.error) { - errors.push(validationResult.error) - } - } - - return { validInputs: validatedInputs, errors } -} - -/** - * Result of validating a single value - */ -interface ValueValidationResult { - valid: boolean - value?: any - error?: ValidationError -} - -/** - * Validates a value against its expected subBlock type - * Returns validation result with the value or an error - */ -function validateValueForSubBlockType( - subBlockConfig: SubBlockConfig, - value: any, - fieldName: string, - blockType: string, - blockId: string -): ValueValidationResult { - const { type } = subBlockConfig - - // Handle null/undefined - allow clearing fields - if (value === null || value === undefined) { - return { valid: true, value } - } - - switch (type) { - case 'dropdown': { - // Validate against allowed options - const options = - typeof subBlockConfig.options === 'function' - ? subBlockConfig.options() - : subBlockConfig.options - if (options && Array.isArray(options)) { - const validIds = options.map((opt) => opt.id) - if (!validIds.includes(value)) { - return { - valid: false, - error: { - blockId, - blockType, - field: fieldName, - value, - error: `Invalid dropdown value "${value}" for field "${fieldName}". Valid options: ${validIds.join(', ')}`, - }, - } - } - } - return { valid: true, value } - } - - case 'slider': { - // Validate numeric range - const numValue = typeof value === 'number' ? value : Number(value) - if (Number.isNaN(numValue)) { - return { - valid: false, - error: { - blockId, - blockType, - field: fieldName, - value, - error: `Invalid slider value "${value}" for field "${fieldName}" - must be a number`, - }, - } - } - // Clamp to range (allow but warn) - let clampedValue = numValue - if (subBlockConfig.min !== undefined && numValue < subBlockConfig.min) { - clampedValue = subBlockConfig.min - } - if (subBlockConfig.max !== undefined && numValue > subBlockConfig.max) { - clampedValue = subBlockConfig.max - } - return { - valid: true, - value: subBlockConfig.integer ? Math.round(clampedValue) : clampedValue, - } - } - - case 'switch': { - // Must be boolean - if (typeof value !== 'boolean') { - return { - valid: false, - error: { - blockId, - blockType, - field: fieldName, - value, - error: `Invalid switch value "${value}" for field "${fieldName}" - must be true or false`, - }, - } - } - return { valid: true, value } - } - - case 'file-upload': { - // File upload should be an object with specific properties or null - if (value === null) return { valid: true, value: null } - if (typeof value !== 'object') { - return { - valid: false, - error: { - blockId, - blockType, - field: fieldName, - value, - error: `Invalid file-upload value for field "${fieldName}" - expected object with name and path properties, or null`, - }, - } - } - // Validate file object has required properties - if (value && (!value.name || !value.path)) { - return { - valid: false, - error: { - blockId, - blockType, - field: fieldName, - value, - error: `Invalid file-upload object for field "${fieldName}" - must have "name" and "path" properties`, - }, - } - } - return { valid: true, value } - } - - case 'input-format': - case 'table': { - // Should be an array - if (!Array.isArray(value)) { - return { - valid: false, - error: { - blockId, - blockType, - field: fieldName, - value, - error: `Invalid ${type} value for field "${fieldName}" - expected an array`, - }, - } - } - return { valid: true, value } - } - - case 'tool-input': { - // Should be an array of tool objects - if (!Array.isArray(value)) { - return { - valid: false, - error: { - blockId, - blockType, - field: fieldName, - value, - error: `Invalid tool-input value for field "${fieldName}" - expected an array of tool objects`, - }, - } - } - return { valid: true, value } - } - - case 'code': { - // Code must be a string (content can be JS, Python, JSON, SQL, HTML, etc.) - if (typeof value !== 'string') { - return { - valid: false, - error: { - blockId, - blockType, - field: fieldName, - value, - error: `Invalid code value for field "${fieldName}" - expected a string, got ${typeof value}`, - }, - } - } - return { valid: true, value } - } - - case 'response-format': { - // Allow empty/null - if (value === null || value === undefined || value === '') { - return { valid: true, value } - } - // Allow objects (will be stringified later by normalizeResponseFormat) - if (typeof value === 'object') { - return { valid: true, value } - } - // If string, must be valid JSON - if (typeof value === 'string') { - try { - JSON.parse(value) - return { valid: true, value } - } catch { - return { - valid: false, - error: { - blockId, - blockType, - field: fieldName, - value, - error: `Invalid response-format value for field "${fieldName}" - string must be valid JSON`, - }, - } - } - } - // Reject numbers, booleans, etc. - return { - valid: false, - error: { - blockId, - blockType, - field: fieldName, - value, - error: `Invalid response-format value for field "${fieldName}" - expected a JSON string or object`, - }, - } - } - - case 'short-input': - case 'long-input': - case 'combobox': { - // Should be string (combobox allows custom values) - if (typeof value !== 'string' && typeof value !== 'number') { - // Convert to string but don't error - return { valid: true, value: String(value) } - } - return { valid: true, value } - } - - // Selector types - allow strings (IDs) or arrays of strings - case 'oauth-input': - case 'knowledge-base-selector': - case 'document-selector': - case 'file-selector': - case 'project-selector': - case 'channel-selector': - case 'folder-selector': - case 'mcp-server-selector': - case 'mcp-tool-selector': - case 'workflow-selector': { - if (subBlockConfig.multiSelect && Array.isArray(value)) { - return { valid: true, value } - } - if (typeof value === 'string') { - return { valid: true, value } - } - return { - valid: false, - error: { - blockId, - blockType, - field: fieldName, - value, - error: `Invalid selector value for field "${fieldName}" - expected a string${subBlockConfig.multiSelect ? ' or array of strings' : ''}`, - }, - } - } - - default: - // For unknown types, pass through - return { valid: true, value } - } -} - -interface EditWorkflowOperation { - operation_type: 'add' | 'edit' | 'delete' | 'insert_into_subflow' | 'extract_from_subflow' - block_id: string - params?: Record -} - -interface EditWorkflowParams { - operations: EditWorkflowOperation[] - workflowId: string - currentUserWorkflow?: string -} - -/** - * Topologically sort insert operations to ensure parents are created before children - * Returns sorted array where parent inserts always come before child inserts - */ -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() - 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>() - const dependencies = new Map>() - - 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 -} - -/** - * Helper to create a block state from operation params - */ -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 | 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 - let outputs: Record - - if (params.outputs) { - outputs = params.outputs - } else if (blockConfig) { - const subBlocks: Record = {} - 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 = getBlockOutputs(params.type, subBlocks, triggerMode) - } 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) - } - - // 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) - } - - blockState.subBlocks[key] = { - id: key, - 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, - } - } - }) - - if (validatedInputs) { - updateCanonicalModesForInputs(blockState, Object.keys(validatedInputs), blockConfig) - } - } - - return blockState -} - -function updateCanonicalModesForInputs( - block: { data?: { canonicalModes?: Record } }, - inputKeys: string[], - blockConfig: BlockConfig -): void { - if (!blockConfig.subBlocks?.length) return - - const canonicalIndex = buildCanonicalIndex(blockConfig.subBlocks) - const canonicalModeUpdates: Record = {} - - 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 - */ -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, - } - }) -} - -/** UUID v4 regex pattern for validation */ -const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i - -/** - * 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 -]) - -/** - * 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. - */ -function normalizeArrayWithIds(value: unknown): any[] { - if (!Array.isArray(value)) { - return [] - } - - return value.map((item: any) => { - if (!item || typeof item !== 'object') { - return item - } - - // Check if id is missing or not a valid UUID - const hasValidUUID = typeof item.id === 'string' && UUID_REGEX.test(item.id) - if (!hasValidUUID) { - return { ...item, id: crypto.randomUUID() } - } - - return item - }) -} - -/** - * Checks if a subblock key should have its array items normalized with UUIDs. - */ -function shouldNormalizeArrayIds(key: string): boolean { - return ARRAY_WITH_ID_SUBBLOCK_TYPES.has(key) -} - -/** - * Normalize responseFormat to ensure consistent storage - * Handles both string (JSON) and object formats - * Returns pretty-printed JSON for better UI readability - */ -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') { - // Sort keys recursively for consistent comparison - const sortKeys = (item: any): any => { - if (Array.isArray(item)) { - return item.map(sortKeys) - } - if (item !== null && typeof item === 'object') { - return Object.keys(item) - .sort() - .reduce((result: any, key: string) => { - result[key] = sortKeys(item[key]) - return result - }, {}) - } - return item - } - - // Return pretty-printed with 2-space indentation for UI readability - // The sanitizer will normalize it to minified format for comparison - return JSON.stringify(sortKeys(obj), null, 2) - } - - return String(value) - } catch (error) { - // If parsing fails, return the original value as string - return String(value) - } -} - -interface EdgeHandleValidationResult { - valid: boolean - error?: string - /** The normalized handle to use (e.g., simple 'if' normalized to 'condition-{uuid}') */ - normalizedHandle?: string -} - -/** - * Validates source handle is valid for the block type - */ -function validateSourceHandleForBlock( - sourceHandle: string, - sourceBlockType: string, - sourceBlock: any -): EdgeHandleValidationResult { - if (sourceHandle === 'error') { - return { valid: true } - } - - switch (sourceBlockType) { - case 'loop': - if (sourceHandle === 'loop-start-source' || sourceHandle === 'loop-end-source') { - return { valid: true } - } - return { - valid: false, - error: `Invalid source handle "${sourceHandle}" for loop block. Valid handles: loop-start-source, loop-end-source, error`, - } - - case 'parallel': - if (sourceHandle === 'parallel-start-source' || sourceHandle === 'parallel-end-source') { - return { valid: true } - } - return { - valid: false, - error: `Invalid source handle "${sourceHandle}" for parallel block. Valid handles: parallel-start-source, parallel-end-source, error`, - } - - case 'condition': { - const conditionsValue = sourceBlock?.subBlocks?.conditions?.value - if (!conditionsValue) { - return { - valid: false, - error: `Invalid condition handle "${sourceHandle}" - no conditions defined`, - } - } - - // validateConditionHandle accepts simple format (if, else-if-0, else), - // legacy format (condition-{blockId}-if), and internal ID format (condition-{uuid}) - return validateConditionHandle(sourceHandle, sourceBlock.id, conditionsValue) - } - - case 'router': - if (sourceHandle === 'source' || sourceHandle.startsWith(EDGE.ROUTER_PREFIX)) { - return { valid: true } - } - return { - valid: false, - error: `Invalid source handle "${sourceHandle}" for router block. Valid handles: source, ${EDGE.ROUTER_PREFIX}{targetId}, error`, - } - - case 'router_v2': { - const routesValue = sourceBlock?.subBlocks?.routes?.value - if (!routesValue) { - return { - valid: false, - error: `Invalid router handle "${sourceHandle}" - no routes defined`, - } - } - - // validateRouterHandle accepts simple format (route-0, route-1), - // legacy format (router-{blockId}-route-1), and internal ID format (router-{uuid}) - return validateRouterHandle(sourceHandle, sourceBlock.id, routesValue) - } - - default: - if (sourceHandle === 'source') { - return { valid: true } - } - return { - valid: false, - error: `Invalid source handle "${sourceHandle}" for ${sourceBlockType} block. Valid handles: source, error`, - } - } -} - -/** - * Validates condition handle references a valid condition in the block. - * Accepts multiple formats: - * - Simple format: "if", "else-if-0", "else-if-1", "else" - * - Legacy semantic format: "condition-{blockId}-if", "condition-{blockId}-else-if" - * - Internal ID format: "condition-{conditionId}" - * - * Returns the normalized handle (condition-{conditionId}) for storage. - */ -function validateConditionHandle( - sourceHandle: string, - blockId: string, - conditionsValue: string | any[] -): EdgeHandleValidationResult { - let conditions: any[] - if (typeof conditionsValue === 'string') { - try { - conditions = JSON.parse(conditionsValue) - } catch { - return { - valid: false, - error: `Cannot validate condition handle "${sourceHandle}" - conditions is not valid JSON`, - } - } - } else if (Array.isArray(conditionsValue)) { - conditions = conditionsValue - } else { - return { - valid: false, - error: `Cannot validate condition handle "${sourceHandle}" - conditions is not an array`, - } - } - - if (!Array.isArray(conditions) || conditions.length === 0) { - return { - valid: false, - error: `Invalid condition handle "${sourceHandle}" - no conditions defined`, - } - } - - // Build a map of all valid handle formats -> normalized handle (condition-{conditionId}) - const handleToNormalized = new Map() - const legacySemanticPrefix = `condition-${blockId}-` - let elseIfIndex = 0 - - for (const condition of conditions) { - if (!condition.id) continue - - const normalizedHandle = `condition-${condition.id}` - const title = condition.title?.toLowerCase() - - // Always accept internal ID format - handleToNormalized.set(normalizedHandle, normalizedHandle) - - if (title === 'if') { - // Simple format: "if" - handleToNormalized.set('if', normalizedHandle) - // Legacy format: "condition-{blockId}-if" - handleToNormalized.set(`${legacySemanticPrefix}if`, normalizedHandle) - } else if (title === 'else if') { - // Simple format: "else-if-0", "else-if-1", etc. (0-indexed) - handleToNormalized.set(`else-if-${elseIfIndex}`, normalizedHandle) - // Legacy format: "condition-{blockId}-else-if" for first, "condition-{blockId}-else-if-2" for second - if (elseIfIndex === 0) { - handleToNormalized.set(`${legacySemanticPrefix}else-if`, normalizedHandle) - } else { - handleToNormalized.set( - `${legacySemanticPrefix}else-if-${elseIfIndex + 1}`, - normalizedHandle - ) - } - elseIfIndex++ - } else if (title === 'else') { - // Simple format: "else" - handleToNormalized.set('else', normalizedHandle) - // Legacy format: "condition-{blockId}-else" - handleToNormalized.set(`${legacySemanticPrefix}else`, normalizedHandle) - } - } - - const normalizedHandle = handleToNormalized.get(sourceHandle) - if (normalizedHandle) { - return { valid: true, normalizedHandle } - } - - // Build list of valid simple format options for error message - const simpleOptions: string[] = [] - elseIfIndex = 0 - for (const condition of conditions) { - const title = condition.title?.toLowerCase() - if (title === 'if') { - simpleOptions.push('if') - } else if (title === 'else if') { - simpleOptions.push(`else-if-${elseIfIndex}`) - elseIfIndex++ - } else if (title === 'else') { - simpleOptions.push('else') - } - } - - return { - valid: false, - error: `Invalid condition handle "${sourceHandle}". Valid handles: ${simpleOptions.join(', ')}`, - } -} - -/** - * Validates router handle references a valid route in the block. - * Accepts multiple formats: - * - Simple format: "route-0", "route-1", "route-2" (0-indexed) - * - Legacy semantic format: "router-{blockId}-route-1" (1-indexed) - * - Internal ID format: "router-{routeId}" - * - * Returns the normalized handle (router-{routeId}) for storage. - */ -function validateRouterHandle( - sourceHandle: string, - blockId: string, - routesValue: string | any[] -): EdgeHandleValidationResult { - let routes: any[] - if (typeof routesValue === 'string') { - try { - routes = JSON.parse(routesValue) - } catch { - return { - valid: false, - error: `Cannot validate router handle "${sourceHandle}" - routes is not valid JSON`, - } - } - } else if (Array.isArray(routesValue)) { - routes = routesValue - } else { - return { - valid: false, - error: `Cannot validate router handle "${sourceHandle}" - routes is not an array`, - } - } - - if (!Array.isArray(routes) || routes.length === 0) { - return { - valid: false, - error: `Invalid router handle "${sourceHandle}" - no routes defined`, - } - } - - // Build a map of all valid handle formats -> normalized handle (router-{routeId}) - const handleToNormalized = new Map() - const legacySemanticPrefix = `router-${blockId}-` - - for (let i = 0; i < routes.length; i++) { - const route = routes[i] - if (!route.id) continue - - const normalizedHandle = `router-${route.id}` - - // Always accept internal ID format: router-{uuid} - handleToNormalized.set(normalizedHandle, normalizedHandle) - - // Simple format: route-0, route-1, etc. (0-indexed) - handleToNormalized.set(`route-${i}`, normalizedHandle) - - // Legacy 1-indexed route number format: router-{blockId}-route-1 - handleToNormalized.set(`${legacySemanticPrefix}route-${i + 1}`, normalizedHandle) - - // Accept normalized title format: router-{blockId}-{normalized-title} - if (route.title && typeof route.title === 'string') { - const normalizedTitle = route.title - .toLowerCase() - .replace(/\s+/g, '-') - .replace(/[^a-z0-9-]/g, '') - if (normalizedTitle) { - handleToNormalized.set(`${legacySemanticPrefix}${normalizedTitle}`, normalizedHandle) - } - } - } - - const normalizedHandle = handleToNormalized.get(sourceHandle) - if (normalizedHandle) { - return { valid: true, normalizedHandle } - } - - // Build list of valid simple format options for error message - const simpleOptions = routes.map((_, i) => `route-${i}`) - - return { - valid: false, - error: `Invalid router handle "${sourceHandle}". Valid handles: ${simpleOptions.join(', ')}`, - } -} - -/** - * Validates target handle is valid (must be 'target') - */ -function validateTargetHandle(targetHandle: string): EdgeHandleValidationResult { - if (targetHandle === 'target') { - return { valid: true } - } - return { - valid: false, - error: `Invalid target handle "${targetHandle}". Expected "target"`, - } -} - -/** - * Creates a validated edge between two blocks. - * Returns true if edge was created, false if skipped due to validation errors. - */ -function createValidatedEdge( - modifiedState: any, - sourceBlockId: string, - targetBlockId: string, - sourceHandle: string, - targetHandle: string, - operationType: string, - logger: ReturnType, - skippedItems?: SkippedItem[] -): boolean { - if (!modifiedState.blocks[targetBlockId]) { - logger.warn(`Target block "${targetBlockId}" not found. Edge skipped.`, { - sourceBlockId, - targetBlockId, - sourceHandle, - }) - skippedItems?.push({ - type: 'invalid_edge_target', - operationType, - blockId: sourceBlockId, - reason: `Edge from "${sourceBlockId}" to "${targetBlockId}" skipped - target block does not exist`, - 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 - - modifiedState.edges.push({ - id: crypto.randomUUID(), - 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 - */ -function addConnectionsAsEdges( - modifiedState: any, - blockId: string, - connections: Record, - logger: ReturnType, - skippedItems?: SkippedItem[] -): void { - Object.entries(connections).forEach(([sourceHandle, targets]) => { - if (targets === null) return - - 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) - } - }) -} - -function applyTriggerConfigToBlockSubblocks(block: any, triggerConfig: Record) { - 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, - } - } - }) -} - -/** - * Result of applying operations to workflow state - */ -interface ApplyOperationsResult { - state: any - validationErrors: ValidationError[] - skippedItems: SkippedItem[] -} - -/** - * Checks if a block type is allowed by the permission group config - */ -function isBlockTypeAllowed( - blockType: string, - permissionConfig: PermissionGroupConfig | null -): boolean { - if (!permissionConfig || permissionConfig.allowedIntegrations === null) { - return true - } - return permissionConfig.allowedIntegrations.includes(blockType) -} - -/** - * Filters out tools that are not allowed by the permission group config - * Returns both the allowed tools and any skipped tool items for logging - */ -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. - */ -function normalizeBlockIdsInOperations(operations: EditWorkflowOperation[]): { - normalizedOperations: EditWorkflowOperation[] - idMapping: Map -} { - const logger = createLogger('EditWorkflowServerTool') - const idMapping = new Map() - - // 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 && !UUID_REGEX.test(op.block_id)) { - const newId = crypto.randomUUID() - 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 = {} - 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 = {} - 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 } -} - -/** - * Apply operations directly to the workflow JSON state - */ -function applyOperationsToWorkflowState( - workflowState: any, - operations: EditWorkflowOperation[], - permissionConfig: PermissionGroupConfig | null = null -): ApplyOperationsResult { - // Deep clone the workflow state to avoid mutations - const modifiedState = JSON.parse(JSON.stringify(workflowState)) - - // Collect validation errors across all operations - const validationErrors: ValidationError[] = [] - - // Collect skipped items across all operations - const skippedItems: SkippedItem[] = [] - - // Log initial state - const logger = createLogger('EditWorkflowServerTool') - - // Normalize block IDs to UUIDs before processing - const { normalizedOperations } = normalizeBlockIdsInOperations(operations) - operations = normalizedOperations - - logger.info('Applying operations to workflow:', { - totalOperations: operations.length, - operationTypes: operations.reduce((acc: any, op) => { - acc[op.operation_type] = (acc[op.operation_type] || 0) + 1 - return acc - }, {}), - initialBlockCount: Object.keys(modifiedState.blocks || {}).length, - }) - - /** - * 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 - * - * This ordering is CRITICAL: operations may reference blocks being added/inserted - * in the same batch. Without proper ordering, target blocks wouldn't exist yet. - * - * For add operations, we use a two-pass approach: - * - Pass 1: Create all blocks (without connections) - * - Pass 2: Add all connections (now all blocks exist) - * This ensures that if block A connects to block B, and both are being added, - * B will exist when we try to create the edge from A to B. - */ - 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 - // This handles cases where a loop/parallel is being added along with its children - const sortedInserts = topologicalSortInserts(inserts, adds) - - // We'll process add operations in two passes (handled in the switch statement below) - // This is tracked via a separate flag to know which pass we're in - const orderedOperations: EditWorkflowOperation[] = [ - ...deletes, - ...extracts, - ...adds, - ...sortedInserts, - ...edits, - ] - - logger.info('Operations after reordering:', { - totalOperations: orderedOperations.length, - deleteCount: deletes.length, - extractCount: extracts.length, - addCount: adds.length, - insertCount: sortedInserts.length, - editCount: edits.length, - operationOrder: orderedOperations.map( - (op) => - `${op.operation_type}:${op.block_id}${op.params?.subflowId ? `(parent:${op.params.subflowId})` : ''}` - ), - }) - - // Two-pass processing for add operations: - // Pass 1: Create all blocks (without connections) - // Pass 2: Add all connections (all blocks now exist) - const addOperationsWithConnections: Array<{ - blockId: string - connections: Record - }> = [] - - for (const operation of orderedOperations) { - const { operation_type, block_id, params } = 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 - } - - logger.debug(`Executing operation: ${operation_type} for block ${block_id}`, { - params: params ? Object.keys(params) : [], - currentBlockCount: Object.keys(modifiedState.blocks).length, - }) - - switch (operation_type) { - case 'delete': { - if (!modifiedState.blocks[block_id]) { - logSkippedItem(skippedItems, { - type: 'block_not_found', - operationType: 'delete', - blockId: block_id, - reason: `Block "${block_id}" does not exist and cannot be deleted`, - }) - break - } - - // Check if block is locked or inside a locked container - const deleteBlock = modifiedState.blocks[block_id] - const deleteParentId = deleteBlock.data?.parentId as string | undefined - const deleteParentLocked = deleteParentId - ? modifiedState.blocks[deleteParentId]?.locked - : false - if (deleteBlock.locked || deleteParentLocked) { - logSkippedItem(skippedItems, { - type: 'block_locked', - operationType: 'delete', - blockId: block_id, - reason: deleteParentLocked - ? `Block "${block_id}" is inside locked container "${deleteParentId}" and cannot be deleted` - : `Block "${block_id}" is locked and cannot be deleted`, - }) - break - } - - // Find all child blocks to remove - const blocksToRemove = new Set([block_id]) - const findChildren = (parentId: string) => { - Object.entries(modifiedState.blocks).forEach(([childId, child]: [string, any]) => { - if (child.data?.parentId === parentId) { - blocksToRemove.add(childId) - findChildren(childId) - } - }) - } - findChildren(block_id) - - // Remove blocks - blocksToRemove.forEach((id) => delete modifiedState.blocks[id]) - - // Remove edges connected to deleted blocks - modifiedState.edges = modifiedState.edges.filter( - (edge: any) => !blocksToRemove.has(edge.source) && !blocksToRemove.has(edge.target) - ) - break - } - - case 'edit': { - if (!modifiedState.blocks[block_id]) { - logSkippedItem(skippedItems, { - type: 'block_not_found', - operationType: 'edit', - blockId: block_id, - reason: `Block "${block_id}" does not exist and cannot be edited`, - }) - break - } - - const block = modifiedState.blocks[block_id] - - // Check if block is locked or inside a locked container - const editParentId = block.data?.parentId as string | undefined - const editParentLocked = editParentId ? modifiedState.blocks[editParentId]?.locked : false - if (block.locked || editParentLocked) { - logSkippedItem(skippedItems, { - type: 'block_locked', - operationType: 'edit', - blockId: block_id, - reason: editParentLocked - ? `Block "${block_id}" is inside locked container "${editParentId}" and cannot be edited` - : `Block "${block_id}" is locked and cannot be edited`, - }) - break - } - - // Ensure block has essential properties - if (!block.type) { - logger.warn(`Block ${block_id} missing type property, skipping edit`, { - blockKeys: Object.keys(block), - blockData: JSON.stringify(block), - }) - logSkippedItem(skippedItems, { - type: 'block_not_found', - operationType: 'edit', - blockId: block_id, - reason: `Block "${block_id}" exists but has no type property`, - }) - break - } - - // Update inputs (convert to subBlocks format) - if (params?.inputs) { - if (!block.subBlocks) block.subBlocks = {} - - // Validate inputs against block configuration - const validationResult = validateInputsForBlock(block.type, params.inputs, block_id) - validationErrors.push(...validationResult.errors) - - Object.entries(validationResult.validInputs).forEach(([inputKey, value]) => { - // Normalize common field name variations (LLM may use plural/singular inconsistently) - let key = inputKey - if ( - key === 'credentials' && - !block.subBlocks.credentials && - block.subBlocks.credential - ) { - key = 'credential' - } - - 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) - } - - // Special handling for tools - normalize and filter disallowed - if (key === 'tools' && Array.isArray(value)) { - sanitizedValue = filterDisallowedTools( - normalizeTools(value), - permissionConfig, - block_id, - skippedItems - ) - } - - // Special handling for responseFormat - normalize to ensure consistent format - if (key === 'responseFormat' && value) { - sanitizedValue = normalizeResponseFormat(value) - } - - if (!block.subBlocks[key]) { - block.subBlocks[key] = { - id: key, - type: 'short-input', - value: sanitizedValue, - } - } else { - const existingValue = block.subBlocks[key].value - const valuesEqual = - typeof existingValue === 'object' || typeof sanitizedValue === 'object' - ? JSON.stringify(existingValue) === JSON.stringify(sanitizedValue) - : existingValue === sanitizedValue - - if (!valuesEqual) { - block.subBlocks[key].value = sanitizedValue - } - } - }) - - if ( - Object.hasOwn(params.inputs, 'triggerConfig') && - block.subBlocks.triggerConfig && - typeof block.subBlocks.triggerConfig.value === 'object' - ) { - applyTriggerConfigToBlockSubblocks(block, block.subBlocks.triggerConfig.value) - } - - // Update loop/parallel configuration in block.data (strict validation) - if (block.type === 'loop') { - block.data = block.data || {} - // loopType is always valid - if (params.inputs.loopType !== undefined) { - const validLoopTypes = ['for', 'forEach', 'while', 'doWhile'] - if (validLoopTypes.includes(params.inputs.loopType)) { - block.data.loopType = params.inputs.loopType - } - } - const effectiveLoopType = params.inputs.loopType ?? block.data.loopType ?? 'for' - // iterations only valid for 'for' loopType - if (params.inputs.iterations !== undefined && effectiveLoopType === 'for') { - block.data.count = params.inputs.iterations - } - // collection only valid for 'forEach' loopType - if (params.inputs.collection !== undefined && effectiveLoopType === 'forEach') { - block.data.collection = params.inputs.collection - } - // condition only valid for 'while' or 'doWhile' loopType - if ( - params.inputs.condition !== undefined && - (effectiveLoopType === 'while' || effectiveLoopType === 'doWhile') - ) { - if (effectiveLoopType === 'doWhile') { - block.data.doWhileCondition = params.inputs.condition - } else { - block.data.whileCondition = params.inputs.condition - } - } - } else if (block.type === 'parallel') { - block.data = block.data || {} - // parallelType is always valid - if (params.inputs.parallelType !== undefined) { - const validParallelTypes = ['count', 'collection'] - if (validParallelTypes.includes(params.inputs.parallelType)) { - block.data.parallelType = params.inputs.parallelType - } - } - const effectiveParallelType = - params.inputs.parallelType ?? block.data.parallelType ?? 'count' - // count only valid for 'count' parallelType - if (params.inputs.count !== undefined && effectiveParallelType === 'count') { - block.data.count = params.inputs.count - } - // collection only valid for 'collection' parallelType - if (params.inputs.collection !== undefined && effectiveParallelType === 'collection') { - block.data.collection = params.inputs.collection - } - } - - const editBlockConfig = getBlock(block.type) - if (editBlockConfig) { - updateCanonicalModesForInputs( - block, - Object.keys(validationResult.validInputs), - editBlockConfig - ) - } - } - - // Update basic properties - if (params?.type !== undefined) { - // Special container types (loop, parallel) are not in the block registry but are valid - const isContainerType = params.type === 'loop' || params.type === 'parallel' - - // Validate type before setting (skip validation for container types) - const blockConfig = getBlock(params.type) - if (!blockConfig && !isContainerType) { - logSkippedItem(skippedItems, { - type: 'invalid_block_type', - operationType: 'edit', - blockId: block_id, - reason: `Invalid block type "${params.type}" - type change skipped`, - details: { requestedType: params.type }, - }) - } else if (!isContainerType && !isBlockTypeAllowed(params.type, permissionConfig)) { - logSkippedItem(skippedItems, { - type: 'block_not_allowed', - operationType: 'edit', - blockId: block_id, - reason: `Block type "${params.type}" is not allowed by permission group - type change skipped`, - details: { requestedType: params.type }, - }) - } else { - block.type = params.type - } - } - if (params?.name !== undefined) { - const normalizedName = normalizeName(params.name) - if (!normalizedName) { - logSkippedItem(skippedItems, { - type: 'missing_required_params', - operationType: 'edit', - blockId: block_id, - reason: `Cannot rename to empty name`, - details: { requestedName: params.name }, - }) - } else if ((RESERVED_BLOCK_NAMES as readonly string[]).includes(normalizedName)) { - logSkippedItem(skippedItems, { - type: 'reserved_block_name', - operationType: 'edit', - blockId: block_id, - reason: `Cannot rename to "${params.name}" - this is a reserved name`, - details: { requestedName: params.name }, - }) - } else { - const conflictingBlock = findBlockWithDuplicateNormalizedName( - modifiedState.blocks, - params.name, - block_id - ) - - if (conflictingBlock) { - logSkippedItem(skippedItems, { - type: 'duplicate_block_name', - operationType: 'edit', - blockId: block_id, - reason: `Cannot rename to "${params.name}" - conflicts with "${conflictingBlock[1].name}"`, - details: { - requestedName: params.name, - conflictingBlockId: conflictingBlock[0], - conflictingBlockName: conflictingBlock[1].name, - }, - }) - } else { - block.name = params.name - } - } - } - - // Handle trigger mode toggle - if (typeof params?.triggerMode === 'boolean') { - block.triggerMode = params.triggerMode - - if (params.triggerMode === true) { - // Remove all incoming edges when enabling trigger mode - modifiedState.edges = modifiedState.edges.filter( - (edge: any) => edge.target !== block_id - ) - } - } - - // Handle advanced mode toggle - if (typeof params?.advancedMode === 'boolean') { - block.advancedMode = params.advancedMode - } - - // Handle nested nodes update (for loops/parallels) - if (params?.nestedNodes) { - // Remove all existing child blocks - const existingChildren = Object.keys(modifiedState.blocks).filter( - (id) => modifiedState.blocks[id].data?.parentId === block_id - ) - existingChildren.forEach((childId) => delete modifiedState.blocks[childId]) - - // Remove edges to/from removed children - modifiedState.edges = modifiedState.edges.filter( - (edge: any) => - !existingChildren.includes(edge.source) && !existingChildren.includes(edge.target) - ) - - // Add new nested blocks - Object.entries(params.nestedNodes).forEach(([childId, childBlock]: [string, any]) => { - // Validate childId is a valid string - if (!isValidKey(childId)) { - logSkippedItem(skippedItems, { - type: 'missing_required_params', - operationType: 'add_nested_node', - blockId: String(childId || 'invalid'), - reason: `Invalid childId "${childId}" in nestedNodes - child block skipped`, - }) - logger.error('Invalid childId detected in nestedNodes', { - parentBlockId: block_id, - childId, - childId_type: typeof childId, - }) - return - } - - if (childBlock.type === 'loop' || childBlock.type === 'parallel') { - logSkippedItem(skippedItems, { - type: 'nested_subflow_not_allowed', - operationType: 'edit_nested_node', - blockId: childId, - reason: `Cannot nest ${childBlock.type} inside ${block.type} - nested subflows are not supported`, - details: { parentType: block.type, childType: childBlock.type }, - }) - return - } - - const childBlockState = createBlockFromParams( - childId, - childBlock, - block_id, - validationErrors, - permissionConfig, - skippedItems - ) - modifiedState.blocks[childId] = childBlockState - - // Add connections for child block - if (childBlock.connections) { - addConnectionsAsEdges( - modifiedState, - childId, - childBlock.connections, - logger, - skippedItems - ) - } - }) - - // Update loop/parallel configuration based on type (strict validation) - if (block.type === 'loop') { - block.data = block.data || {} - // loopType is always valid - if (params.inputs?.loopType) { - const validLoopTypes = ['for', 'forEach', 'while', 'doWhile'] - if (validLoopTypes.includes(params.inputs.loopType)) { - block.data.loopType = params.inputs.loopType - } - } - const effectiveLoopType = params.inputs?.loopType ?? block.data.loopType ?? 'for' - // iterations only valid for 'for' loopType - if (params.inputs?.iterations && effectiveLoopType === 'for') { - block.data.count = params.inputs.iterations - } - // collection only valid for 'forEach' loopType - if (params.inputs?.collection && effectiveLoopType === 'forEach') { - block.data.collection = params.inputs.collection - } - // condition only valid for 'while' or 'doWhile' loopType - if ( - params.inputs?.condition && - (effectiveLoopType === 'while' || effectiveLoopType === 'doWhile') - ) { - if (effectiveLoopType === 'doWhile') { - block.data.doWhileCondition = params.inputs.condition - } else { - block.data.whileCondition = params.inputs.condition - } - } - } else if (block.type === 'parallel') { - block.data = block.data || {} - // parallelType is always valid - if (params.inputs?.parallelType) { - const validParallelTypes = ['count', 'collection'] - if (validParallelTypes.includes(params.inputs.parallelType)) { - block.data.parallelType = params.inputs.parallelType - } - } - const effectiveParallelType = - params.inputs?.parallelType ?? block.data.parallelType ?? 'count' - // count only valid for 'count' parallelType - if (params.inputs?.count && effectiveParallelType === 'count') { - block.data.count = params.inputs.count - } - // collection only valid for 'collection' parallelType - if (params.inputs?.collection && effectiveParallelType === 'collection') { - block.data.collection = params.inputs.collection - } - } - } - - // Handle connections update (convert to edges) - if (params?.connections) { - modifiedState.edges = modifiedState.edges.filter((edge: any) => edge.source !== block_id) - - Object.entries(params.connections).forEach(([connectionType, targets]) => { - if (targets === null) return - - const mapConnectionTypeToHandle = (type: string): string => { - if (type === 'success') return 'source' - if (type === 'error') return 'error' - return type - } - - const sourceHandle = mapConnectionTypeToHandle(connectionType) - - const addEdgeForTarget = (targetBlock: string, targetHandle?: string) => { - createValidatedEdge( - modifiedState, - block_id, - targetBlock, - sourceHandle, - targetHandle || 'target', - 'edit', - 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 as any)?.block) { - addEdgeForTarget((targets as any).block, (targets as any).handle) - } - }) - } - - // Handle edge removal - if (params?.removeEdges && Array.isArray(params.removeEdges)) { - params.removeEdges.forEach(({ targetBlockId, sourceHandle = 'source' }) => { - modifiedState.edges = modifiedState.edges.filter( - (edge: any) => - !( - edge.source === block_id && - edge.target === targetBlockId && - edge.sourceHandle === sourceHandle - ) - ) - }) - } - break - } - - case 'add': { - const addNormalizedName = params?.name ? normalizeName(params.name) : '' - if (!params?.type || !params?.name || !addNormalizedName) { - logSkippedItem(skippedItems, { - type: 'missing_required_params', - operationType: 'add', - blockId: block_id, - reason: `Missing required params (type or name) for adding block "${block_id}"`, - details: { hasType: !!params?.type, hasName: !!params?.name }, - }) - break - } - - if ((RESERVED_BLOCK_NAMES as readonly string[]).includes(addNormalizedName)) { - logSkippedItem(skippedItems, { - type: 'reserved_block_name', - operationType: 'add', - blockId: block_id, - reason: `Block name "${params.name}" is a reserved name and cannot be used`, - details: { requestedName: params.name }, - }) - break - } - - const conflictingBlock = findBlockWithDuplicateNormalizedName( - modifiedState.blocks, - params.name, - block_id - ) - - if (conflictingBlock) { - logSkippedItem(skippedItems, { - type: 'duplicate_block_name', - operationType: 'add', - blockId: block_id, - reason: `Block name "${params.name}" conflicts with existing block "${conflictingBlock[1].name}"`, - details: { - requestedName: params.name, - conflictingBlockId: conflictingBlock[0], - conflictingBlockName: conflictingBlock[1].name, - }, - }) - break - } - - // Special container types (loop, parallel) are not in the block registry but are valid - const isContainerType = params.type === 'loop' || params.type === 'parallel' - - // Validate block type before adding (skip validation for container types) - const addBlockConfig = getBlock(params.type) - if (!addBlockConfig && !isContainerType) { - logSkippedItem(skippedItems, { - type: 'invalid_block_type', - operationType: 'add', - blockId: block_id, - reason: `Invalid block type "${params.type}" - block not added`, - details: { requestedType: params.type }, - }) - break - } - - // Check if block type is allowed by permission group - if (!isContainerType && !isBlockTypeAllowed(params.type, permissionConfig)) { - logSkippedItem(skippedItems, { - type: 'block_not_allowed', - operationType: 'add', - blockId: block_id, - reason: `Block type "${params.type}" is not allowed by permission group - block not added`, - details: { requestedType: params.type }, - }) - break - } - - const triggerIssue = TriggerUtils.getTriggerAdditionIssue(modifiedState.blocks, params.type) - if (triggerIssue) { - logSkippedItem(skippedItems, { - type: 'duplicate_trigger', - operationType: 'add', - blockId: block_id, - reason: `Cannot add ${triggerIssue.triggerName} - a workflow can only have one`, - details: { requestedType: params.type, issue: triggerIssue.issue }, - }) - break - } - - // Check single-instance block constraints (e.g., Response block) - const singleInstanceIssue = TriggerUtils.getSingleInstanceBlockIssue( - modifiedState.blocks, - params.type - ) - if (singleInstanceIssue) { - logSkippedItem(skippedItems, { - type: 'duplicate_single_instance_block', - operationType: 'add', - blockId: block_id, - reason: `Cannot add ${singleInstanceIssue.blockName} - a workflow can only have one`, - details: { requestedType: params.type }, - }) - break - } - - // Create new block with proper structure - const newBlock = createBlockFromParams( - block_id, - params, - undefined, - validationErrors, - permissionConfig, - skippedItems - ) - - // Set loop/parallel data on parent block BEFORE adding to blocks (strict validation) - if (params.nestedNodes) { - if (params.type === 'loop') { - const validLoopTypes = ['for', 'forEach', 'while', 'doWhile'] - const loopType = - params.inputs?.loopType && validLoopTypes.includes(params.inputs.loopType) - ? params.inputs.loopType - : 'for' - newBlock.data = { - ...newBlock.data, - loopType, - // Only include type-appropriate fields - ...(loopType === 'forEach' && - params.inputs?.collection && { collection: params.inputs.collection }), - ...(loopType === 'for' && - params.inputs?.iterations && { count: params.inputs.iterations }), - ...(loopType === 'while' && - params.inputs?.condition && { whileCondition: params.inputs.condition }), - ...(loopType === 'doWhile' && - params.inputs?.condition && { doWhileCondition: params.inputs.condition }), - } - } else if (params.type === 'parallel') { - const validParallelTypes = ['count', 'collection'] - const parallelType = - params.inputs?.parallelType && validParallelTypes.includes(params.inputs.parallelType) - ? params.inputs.parallelType - : 'count' - newBlock.data = { - ...newBlock.data, - parallelType, - // Only include type-appropriate fields - ...(parallelType === 'collection' && - params.inputs?.collection && { collection: params.inputs.collection }), - ...(parallelType === 'count' && - params.inputs?.count && { count: params.inputs.count }), - } - } - } - - // Add parent block FIRST before adding children - // This ensures children can reference valid parentId - modifiedState.blocks[block_id] = newBlock - - // Handle nested nodes (for loops/parallels created from scratch) - if (params.nestedNodes) { - // Defensive check: verify parent is not locked before adding children - // (Parent was just created with locked: false, but check for consistency) - const parentBlock = modifiedState.blocks[block_id] - if (parentBlock?.locked) { - logSkippedItem(skippedItems, { - type: 'block_locked', - operationType: 'add_nested_nodes', - blockId: block_id, - reason: `Container "${block_id}" is locked - cannot add nested nodes`, - }) - break - } - - Object.entries(params.nestedNodes).forEach(([childId, childBlock]: [string, any]) => { - // Validate childId is a valid string - if (!isValidKey(childId)) { - logSkippedItem(skippedItems, { - type: 'missing_required_params', - operationType: 'add_nested_node', - blockId: String(childId || 'invalid'), - reason: `Invalid childId "${childId}" in nestedNodes - child block skipped`, - }) - logger.error('Invalid childId detected in nestedNodes', { - parentBlockId: block_id, - childId, - childId_type: typeof childId, - }) - return - } - - if (childBlock.type === 'loop' || childBlock.type === 'parallel') { - logSkippedItem(skippedItems, { - type: 'nested_subflow_not_allowed', - operationType: 'add_nested_node', - blockId: childId, - reason: `Cannot nest ${childBlock.type} inside ${params.type} - nested subflows are not supported`, - details: { parentType: params.type, childType: childBlock.type }, - }) - return - } - - const childBlockState = createBlockFromParams( - childId, - childBlock, - block_id, - validationErrors, - permissionConfig, - skippedItems - ) - modifiedState.blocks[childId] = childBlockState - - // Defer connection processing to ensure all blocks exist first - if (childBlock.connections) { - addOperationsWithConnections.push({ - blockId: childId, - connections: childBlock.connections, - }) - } - }) - } - - // Defer connection processing to ensure all blocks exist first (pass 2) - if (params.connections) { - addOperationsWithConnections.push({ - blockId: block_id, - connections: params.connections, - }) - } - break - } - - case 'insert_into_subflow': { - const subflowId = params?.subflowId - if (!subflowId || !params?.type || !params?.name) { - logSkippedItem(skippedItems, { - type: 'missing_required_params', - operationType: 'insert_into_subflow', - blockId: block_id, - reason: `Missing required params (subflowId, type, or name) for inserting block "${block_id}"`, - details: { - hasSubflowId: !!subflowId, - hasType: !!params?.type, - hasName: !!params?.name, - }, - }) - break - } - - const subflowBlock = modifiedState.blocks[subflowId] - if (!subflowBlock) { - logSkippedItem(skippedItems, { - type: 'invalid_subflow_parent', - operationType: 'insert_into_subflow', - blockId: block_id, - reason: `Subflow block "${subflowId}" not found - block "${block_id}" not inserted`, - details: { subflowId }, - }) - break - } - - // Check if subflow is locked - if (subflowBlock.locked) { - logSkippedItem(skippedItems, { - type: 'block_locked', - operationType: 'insert_into_subflow', - blockId: block_id, - reason: `Subflow "${subflowId}" is locked - cannot insert block "${block_id}"`, - details: { subflowId }, - }) - break - } - - if (subflowBlock.type !== 'loop' && subflowBlock.type !== 'parallel') { - logger.error('Subflow block has invalid type', { - subflowId, - type: subflowBlock.type, - block_id, - }) - break - } - - if (params.type === 'loop' || params.type === 'parallel') { - logSkippedItem(skippedItems, { - type: 'nested_subflow_not_allowed', - operationType: 'insert_into_subflow', - blockId: block_id, - reason: `Cannot nest ${params.type} inside ${subflowBlock.type} - nested subflows are not supported`, - details: { parentType: subflowBlock.type, childType: params.type }, - }) - break - } - - // Get block configuration - const blockConfig = getAllBlocks().find((block) => block.type === params.type) - - // Check if block already exists (moving into subflow) or is new - const existingBlock = modifiedState.blocks[block_id] - - if (existingBlock) { - if (existingBlock.type === 'loop' || existingBlock.type === 'parallel') { - logSkippedItem(skippedItems, { - type: 'nested_subflow_not_allowed', - operationType: 'insert_into_subflow', - blockId: block_id, - reason: `Cannot move ${existingBlock.type} into ${subflowBlock.type} - nested subflows are not supported`, - details: { parentType: subflowBlock.type, childType: existingBlock.type }, - }) - break - } - - // Check if existing block is locked - if (existingBlock.locked) { - logSkippedItem(skippedItems, { - type: 'block_locked', - operationType: 'insert_into_subflow', - blockId: block_id, - reason: `Block "${block_id}" is locked and cannot be moved into a subflow`, - }) - break - } - - // Moving existing block into subflow - just update parent - existingBlock.data = { - ...existingBlock.data, - parentId: subflowId, - extent: 'parent' as const, - } - - // Update inputs if provided (with validation) - if (params.inputs) { - // Validate inputs against block configuration - const validationResult = validateInputsForBlock( - existingBlock.type, - params.inputs, - block_id - ) - validationErrors.push(...validationResult.errors) - - Object.entries(validationResult.validInputs).forEach(([key, value]) => { - // Skip runtime subblock IDs (webhookId, triggerPath) - 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) - } - - // Special handling for tools - normalize and filter disallowed - if (key === 'tools' && Array.isArray(value)) { - sanitizedValue = filterDisallowedTools( - normalizeTools(value), - permissionConfig, - block_id, - skippedItems - ) - } - - // Special handling for responseFormat - normalize to ensure consistent format - if (key === 'responseFormat' && value) { - sanitizedValue = normalizeResponseFormat(value) - } - - if (!existingBlock.subBlocks[key]) { - existingBlock.subBlocks[key] = { - id: key, - type: 'short-input', - value: sanitizedValue, - } - } else { - existingBlock.subBlocks[key].value = sanitizedValue - } - }) - - const existingBlockConfig = getBlock(existingBlock.type) - if (existingBlockConfig) { - updateCanonicalModesForInputs( - existingBlock, - Object.keys(validationResult.validInputs), - existingBlockConfig - ) - } - } - } else { - // Special container types (loop, parallel) are not in the block registry but are valid - const isContainerType = params.type === 'loop' || params.type === 'parallel' - - // Validate block type before creating (skip validation for container types) - const insertBlockConfig = getBlock(params.type) - if (!insertBlockConfig && !isContainerType) { - logSkippedItem(skippedItems, { - type: 'invalid_block_type', - operationType: 'insert_into_subflow', - blockId: block_id, - reason: `Invalid block type "${params.type}" - block not inserted into subflow`, - details: { requestedType: params.type, subflowId }, - }) - break - } - - // Check if block type is allowed by permission group - if (!isContainerType && !isBlockTypeAllowed(params.type, permissionConfig)) { - logSkippedItem(skippedItems, { - type: 'block_not_allowed', - operationType: 'insert_into_subflow', - blockId: block_id, - reason: `Block type "${params.type}" is not allowed by permission group - block not inserted`, - details: { requestedType: params.type, subflowId }, - }) - break - } - - // Create new block as child of subflow - const newBlock = createBlockFromParams( - block_id, - params, - subflowId, - validationErrors, - permissionConfig, - skippedItems - ) - modifiedState.blocks[block_id] = newBlock - } - - // Defer connection processing to ensure all blocks exist first - // This is particularly important when multiple blocks are being inserted - // and they have connections to each other - if (params.connections) { - // Remove existing edges from this block first - modifiedState.edges = modifiedState.edges.filter((edge: any) => edge.source !== block_id) - - // Add to deferred connections list - addOperationsWithConnections.push({ - blockId: block_id, - connections: params.connections, - }) - } - break - } - - case 'extract_from_subflow': { - const subflowId = params?.subflowId - if (!subflowId) { - logSkippedItem(skippedItems, { - type: 'missing_required_params', - operationType: 'extract_from_subflow', - blockId: block_id, - reason: `Missing subflowId for extracting block "${block_id}"`, - }) - break - } - - const block = modifiedState.blocks[block_id] - if (!block) { - logSkippedItem(skippedItems, { - type: 'block_not_found', - operationType: 'extract_from_subflow', - blockId: block_id, - reason: `Block "${block_id}" not found for extraction`, - }) - break - } - - // Check if block is locked - if (block.locked) { - logSkippedItem(skippedItems, { - type: 'block_locked', - operationType: 'extract_from_subflow', - blockId: block_id, - reason: `Block "${block_id}" is locked and cannot be extracted from subflow`, - }) - break - } - - // Check if parent subflow is locked - const parentSubflow = modifiedState.blocks[subflowId] - if (parentSubflow?.locked) { - logSkippedItem(skippedItems, { - type: 'block_locked', - operationType: 'extract_from_subflow', - blockId: block_id, - reason: `Subflow "${subflowId}" is locked - cannot extract block "${block_id}"`, - details: { subflowId }, - }) - break - } - - // Verify it's actually a child of this subflow - if (block.data?.parentId !== subflowId) { - logger.warn('Block is not a child of specified subflow', { - block_id, - actualParent: block.data?.parentId, - specifiedParent: subflowId, - }) - } - - // Remove parent relationship - if (block.data) { - block.data.parentId = undefined - block.data.extent = undefined - } - - // Note: We keep the block and its edges, just remove parent relationship - // The block becomes a root-level block - break - } - } - } - - // Pass 2: Add all deferred connections from add/insert operations - // Now all blocks exist (from add, insert, and edit operations), so connections can be safely created - // This ensures that if block A connects to block B, and both are being added/inserted, - // B will exist when we create the edge from A to B - if (addOperationsWithConnections.length > 0) { - logger.info('Processing deferred connections from add/insert operations', { - deferredConnectionCount: addOperationsWithConnections.length, - totalBlocks: Object.keys(modifiedState.blocks).length, - }) - - for (const { blockId, connections } of addOperationsWithConnections) { - // Verify the source block still exists (it might have been deleted by a later operation) - if (!modifiedState.blocks[blockId]) { - logger.warn('Source block no longer exists for deferred connection', { - blockId, - availableBlocks: Object.keys(modifiedState.blocks), - }) - continue - } - - addConnectionsAsEdges(modifiedState, blockId, connections, logger, skippedItems) - } - - logger.info('Finished processing deferred connections', { - totalEdges: modifiedState.edges.length, - }) - } - - // Regenerate loops and parallels after modifications - modifiedState.loops = generateLoopBlocks(modifiedState.blocks) - modifiedState.parallels = generateParallelBlocks(modifiedState.blocks) - - // Validate all blocks have types before returning - const blocksWithoutType = Object.entries(modifiedState.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.blocks[id] - }) - - // Remove edges connected to removed blocks - const removedIds = new Set(blocksWithoutType.map(({ id }) => id)) - modifiedState.edges = modifiedState.edges.filter( - (edge: any) => !removedIds.has(edge.source) && !removedIds.has(edge.target) - ) - } - - return { state: modifiedState, validationErrors, skippedItems } -} - -/** - * Validates selector IDs in the workflow state exist in the database - * Returns validation errors for any invalid selector IDs - */ -async function validateWorkflowSelectorIds( - workflowState: any, - context: { userId: string; workspaceId?: string } -): Promise { - const logger = createLogger('EditWorkflowSelectorValidation') - const errors: ValidationError[] = [] - - // Collect all selector fields from all blocks - const selectorsToValidate: Array<{ - blockId: string - blockType: string - fieldName: string - selectorType: string - value: string | string[] - }> = [] - - for (const [blockId, block] of Object.entries(workflowState.blocks || {})) { - const blockData = block as any - const blockType = blockData.type - if (!blockType) continue - - const blockConfig = getBlock(blockType) - if (!blockConfig) continue - - // Check each subBlock for selector types - for (const subBlockConfig of blockConfig.subBlocks) { - if (!SELECTOR_TYPES.has(subBlockConfig.type)) continue - - // Skip oauth-input - credentials are pre-validated before edit application - // This allows existing collaborator credentials to remain untouched - if (subBlockConfig.type === 'oauth-input') continue - - const subBlockValue = blockData.subBlocks?.[subBlockConfig.id]?.value - if (!subBlockValue) continue - - // Handle comma-separated values for multi-select - let values: string | string[] = subBlockValue - if (typeof subBlockValue === 'string' && subBlockValue.includes(',')) { - values = subBlockValue - .split(',') - .map((v: string) => v.trim()) - .filter(Boolean) - } - - selectorsToValidate.push({ - blockId, - blockType, - fieldName: subBlockConfig.id, - selectorType: subBlockConfig.type, - value: values, - }) - } - } - - if (selectorsToValidate.length === 0) { - return errors - } - - logger.info('Validating selector IDs', { - selectorCount: selectorsToValidate.length, - userId: context.userId, - workspaceId: context.workspaceId, - }) - - // Validate each selector field - for (const selector of selectorsToValidate) { - const result = await validateSelectorIds(selector.selectorType, selector.value, context) - - if (result.invalid.length > 0) { - // Include warning info (like available credentials) in the error message for better LLM feedback - const warningInfo = result.warning ? `. ${result.warning}` : '' - errors.push({ - blockId: selector.blockId, - blockType: selector.blockType, - field: selector.fieldName, - value: selector.value, - error: `Invalid ${selector.selectorType} ID(s): ${result.invalid.join(', ')} - ID(s) do not exist or user doesn't have access${warningInfo}`, - }) - } else if (result.warning) { - // Log warnings that don't have errors (shouldn't happen for credentials but may for other selectors) - logger.warn(result.warning, { - blockId: selector.blockId, - fieldName: selector.fieldName, - }) - } - } - - if (errors.length > 0) { - logger.warn('Found invalid selector IDs', { - errorCount: errors.length, - errors: errors.map((e) => ({ blockId: e.blockId, field: e.field, error: e.error })), - }) - } - - return errors -} - -/** - * Pre-validates credential and apiKey inputs in operations before they are applied. - * - Validates oauth-input (credential) IDs belong to the user - * - Filters out apiKey inputs for hosted models when isHosted is true - * - Also validates credentials and apiKeys in nestedNodes (blocks inside loop/parallel) - * Returns validation errors for any removed inputs. - */ -async function preValidateCredentialInputs( - operations: EditWorkflowOperation[], - context: { userId: string }, - workflowState?: Record -): Promise<{ filteredOperations: EditWorkflowOperation[]; errors: ValidationError[] }> { - const { isHosted } = await import('@/lib/core/config/feature-flags') - const { getHostedModels } = await import('@/providers/utils') - - const logger = createLogger('PreValidateCredentials') - const errors: ValidationError[] = [] - - // Collect credential and apiKey inputs that need validation/filtering - const credentialInputs: Array<{ - operationIndex: number - blockId: string - blockType: string - fieldName: string - value: string - nestedBlockId?: string - }> = [] - - const hostedApiKeyInputs: Array<{ - operationIndex: number - blockId: string - blockType: string - model: string - nestedBlockId?: string - }> = [] - - const hostedModelsLower = isHosted ? new Set(getHostedModels().map((m) => m.toLowerCase())) : null - - /** - * Collect credential inputs from a block's inputs based on its block config - */ - function collectCredentialInputs( - blockConfig: ReturnType, - inputs: Record, - opIndex: number, - blockId: string, - blockType: string, - nestedBlockId?: string - ) { - if (!blockConfig) return - - for (const subBlockConfig of blockConfig.subBlocks) { - if (subBlockConfig.type !== 'oauth-input') continue - - const inputValue = inputs[subBlockConfig.id] - if (!inputValue || typeof inputValue !== 'string' || inputValue.trim() === '') continue - - credentialInputs.push({ - operationIndex: opIndex, - blockId, - blockType, - fieldName: subBlockConfig.id, - value: inputValue, - nestedBlockId, - }) - } - } - - /** - * Check if apiKey should be filtered for a block with the given model - */ - function collectHostedApiKeyInput( - inputs: Record, - modelValue: string | undefined, - opIndex: number, - blockId: string, - blockType: string, - nestedBlockId?: string - ) { - if (!hostedModelsLower || !inputs.apiKey) return - if (!modelValue || typeof modelValue !== 'string') return - - if (hostedModelsLower.has(modelValue.toLowerCase())) { - hostedApiKeyInputs.push({ - operationIndex: opIndex, - blockId, - blockType, - model: modelValue, - nestedBlockId, - }) - } - } - - operations.forEach((op, opIndex) => { - // Process main block inputs - if (op.params?.inputs && op.params?.type) { - const blockConfig = getBlock(op.params.type) - if (blockConfig) { - // Collect credentials from main block - collectCredentialInputs( - blockConfig, - op.params.inputs as Record, - opIndex, - op.block_id, - op.params.type - ) - - // Check for apiKey inputs on hosted models - let modelValue = (op.params.inputs as Record).model as string | undefined - - // For edit operations, if model is not being changed, check existing block's model - if ( - !modelValue && - op.operation_type === 'edit' && - (op.params.inputs as Record).apiKey && - workflowState - ) { - const existingBlock = (workflowState.blocks as Record)?.[op.block_id] as - | Record - | undefined - const existingSubBlocks = existingBlock?.subBlocks as Record | undefined - const existingModelSubBlock = existingSubBlocks?.model as - | Record - | undefined - modelValue = existingModelSubBlock?.value as string | undefined - } - - collectHostedApiKeyInput( - op.params.inputs as Record, - modelValue, - opIndex, - op.block_id, - op.params.type - ) - } - } - - // Process nested nodes (blocks inside loop/parallel containers) - const nestedNodes = op.params?.nestedNodes as - | Record> - | undefined - if (nestedNodes) { - Object.entries(nestedNodes).forEach(([childId, childBlock]) => { - const childType = childBlock.type as string | undefined - const childInputs = childBlock.inputs as Record | undefined - if (!childType || !childInputs) return - - const childBlockConfig = getBlock(childType) - if (!childBlockConfig) return - - // Collect credentials from nested block - collectCredentialInputs( - childBlockConfig, - childInputs, - opIndex, - op.block_id, - childType, - childId - ) - - // Check for apiKey inputs on hosted models in nested block - const modelValue = childInputs.model as string | undefined - collectHostedApiKeyInput(childInputs, modelValue, opIndex, op.block_id, childType, childId) - }) - } - }) - - const hasCredentialsToValidate = credentialInputs.length > 0 - const hasHostedApiKeysToFilter = hostedApiKeyInputs.length > 0 - - if (!hasCredentialsToValidate && !hasHostedApiKeysToFilter) { - return { filteredOperations: operations, errors } - } - - // Deep clone operations so we can modify them - const filteredOperations = structuredClone(operations) - - // Filter out apiKey inputs for hosted models and add validation errors - if (hasHostedApiKeysToFilter) { - logger.info('Filtering apiKey inputs for hosted models', { count: hostedApiKeyInputs.length }) - - for (const apiKeyInput of hostedApiKeyInputs) { - const op = filteredOperations[apiKeyInput.operationIndex] - - // Handle nested block apiKey filtering - if (apiKeyInput.nestedBlockId) { - const nestedNodes = op.params?.nestedNodes as - | Record> - | undefined - const nestedBlock = nestedNodes?.[apiKeyInput.nestedBlockId] - const nestedInputs = nestedBlock?.inputs as Record | undefined - if (nestedInputs?.apiKey) { - nestedInputs.apiKey = undefined - logger.debug('Filtered apiKey for hosted model in nested block', { - parentBlockId: apiKeyInput.blockId, - nestedBlockId: apiKeyInput.nestedBlockId, - model: apiKeyInput.model, - }) - - errors.push({ - blockId: apiKeyInput.nestedBlockId, - blockType: apiKeyInput.blockType, - field: 'apiKey', - value: '[redacted]', - error: `Cannot set API key for hosted model "${apiKeyInput.model}" - API keys are managed by the platform when using hosted models`, - }) - } - } else if (op.params?.inputs?.apiKey) { - // Handle main block apiKey filtering - op.params.inputs.apiKey = undefined - logger.debug('Filtered apiKey for hosted model', { - blockId: apiKeyInput.blockId, - model: apiKeyInput.model, - }) - - errors.push({ - blockId: apiKeyInput.blockId, - blockType: apiKeyInput.blockType, - field: 'apiKey', - value: '[redacted]', - error: `Cannot set API key for hosted model "${apiKeyInput.model}" - API keys are managed by the platform when using hosted models`, - }) - } - } - } - - // Validate credential inputs - if (hasCredentialsToValidate) { - logger.info('Pre-validating credential inputs', { - credentialCount: credentialInputs.length, - userId: context.userId, - }) - - const allCredentialIds = credentialInputs.map((c) => c.value) - const validationResult = await validateSelectorIds('oauth-input', allCredentialIds, context) - const invalidSet = new Set(validationResult.invalid) - - if (invalidSet.size > 0) { - for (const credInput of credentialInputs) { - if (!invalidSet.has(credInput.value)) continue - - const op = filteredOperations[credInput.operationIndex] - - // Handle nested block credential removal - if (credInput.nestedBlockId) { - const nestedNodes = op.params?.nestedNodes as - | Record> - | undefined - const nestedBlock = nestedNodes?.[credInput.nestedBlockId] - const nestedInputs = nestedBlock?.inputs as Record | undefined - if (nestedInputs?.[credInput.fieldName]) { - delete nestedInputs[credInput.fieldName] - logger.info('Removed invalid credential from nested block', { - parentBlockId: credInput.blockId, - nestedBlockId: credInput.nestedBlockId, - field: credInput.fieldName, - invalidValue: credInput.value, - }) - } - } else if (op.params?.inputs?.[credInput.fieldName]) { - // Handle main block credential removal - delete op.params.inputs[credInput.fieldName] - logger.info('Removed invalid credential from operation', { - blockId: credInput.blockId, - field: credInput.fieldName, - invalidValue: credInput.value, - }) - } - - const warningInfo = validationResult.warning ? `. ${validationResult.warning}` : '' - const errorBlockId = credInput.nestedBlockId ?? credInput.blockId - errors.push({ - blockId: errorBlockId, - blockType: credInput.blockType, - field: credInput.fieldName, - value: credInput.value, - error: `Invalid credential ID "${credInput.value}" - credential does not exist or user doesn't have access${warningInfo}`, - }) - } - - logger.warn('Filtered out invalid credentials', { - invalidCount: invalidSet.size, - }) - } - } - - return { filteredOperations, errors } -} - -async function getCurrentWorkflowStateFromDb( - workflowId: string -): Promise<{ workflowState: any; subBlockValues: Record> }> { - 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') - - // Validate and fix blocks without types - const blocks = { ...normalized.blocks } - const invalidBlocks: string[] = [] - - Object.entries(blocks).forEach(([id, block]: [string, any]) => { - if (!block.type) { - logger.warn(`Block ${id} loaded without type from database`, { - blockKeys: Object.keys(block), - blockName: block.name, - }) - invalidBlocks.push(id) - } - }) - - // Remove invalid blocks - invalidBlocks.forEach((id) => delete blocks[id]) - - // Remove edges connected to invalid blocks - const edges = normalized.edges.filter( - (edge: any) => !invalidBlocks.includes(edge.source) && !invalidBlocks.includes(edge.target) - ) - - const workflowState: any = { - blocks, - edges, - loops: normalized.loops || {}, - parallels: normalized.parallels || {}, - } - const subBlockValues: Record> = {} - Object.entries(normalized.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, subBlockValues } -} - -export const editWorkflowServerTool: BaseServerTool = { - name: 'edit_workflow', - async execute(params: EditWorkflowParams, context?: { userId: string }): Promise { - 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') - - logger.info('Executing edit_workflow', { - operationCount: operations.length, - workflowId, - hasCurrentUserWorkflow: !!currentUserWorkflow, - }) - - // Get current workflow state - 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 - } - - // Get permission config for the user - const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : 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 }, - 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) - - // Get workspaceId for selector validation - let workspaceId: string | undefined - try { - const [workflowRecord] = await db - .select({ workspaceId: workflowTable.workspaceId }) - .from(workflowTable) - .where(eq(workflowTable.id, workflowId)) - .limit(1) - workspaceId = workflowRecord?.workspaceId ?? undefined - } catch (error) { - logger.warn('Failed to get workspaceId for selector validation', { error, workflowId }) - } - - // Validate selector IDs exist in the database - if (context?.userId) { - try { - const selectorErrors = await validateWorkflowSelectorIds(modifiedWorkflowState, { - userId: context.userId, - workspaceId, - }) - validationErrors.push(...selectorErrors) - } catch (error) { - logger.warn('Selector ID validation failed', { - error: error instanceof Error ? error.message : String(error), - }) - } - } - - // 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 { - 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 - - // Format skipped items for LLM feedback - const skippedMessages = - skippedItems.length > 0 ? skippedItems.map((item) => item.reason) : undefined - - // Persist the workflow state to the database - const finalWorkflowState = validation.sanitizedState || modifiedWorkflowState - - // Apply autolayout to position blocks properly - const layoutResult = applyAutoLayout(finalWorkflowState.blocks, finalWorkflowState.edges, { - horizontalSpacing: 250, - verticalSpacing: 100, - padding: { x: 100, y: 100 }, - }) - - const layoutedBlocks = - layoutResult.success && layoutResult.blocks ? layoutResult.blocks : finalWorkflowState.blocks - - if (!layoutResult.success) { - logger.warn('Autolayout failed, using default positions', { - workflowId, - error: layoutResult.error, - }) - } - - const workflowStateForDb = { - blocks: layoutedBlocks, - edges: finalWorkflowState.edges, - loops: generateLoopBlocks(layoutedBlocks as any), - parallels: generateParallelBlocks(layoutedBlocks as any), - lastSaved: Date.now(), - isDeployed: false, - } - - 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 - await db - .update(workflowTable) - .set({ - lastSynced: new Date(), - updatedAt: new Date(), - }) - .where(eq(workflowTable.id, workflowId)) - - logger.info('Workflow state persisted to database', { workflowId }) - - // Return the modified workflow state with autolayout applied - return { - success: true, - workflowState: { ...finalWorkflowState, blocks: layoutedBlocks }, - // Include input validation errors so the LLM can see what was rejected - ...(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('; ')}`, - }), - // Include skipped items so the LLM can see what operations were skipped - ...(skippedMessages && { - skippedItems: skippedMessages, - skippedItemsMessage: `${skippedItems.length} operation(s) were skipped due to invalid references. Details: ${skippedMessages.join('; ')}`, - }), - } - }, -} diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.ts new file mode 100644 index 000000000..7f46294f0 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.ts @@ -0,0 +1,633 @@ +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import type { PermissionGroupConfig } from '@/lib/permission-groups/types' +import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs' +import { buildCanonicalIndex, isCanonicalPair } from '@/lib/workflows/subblocks/visibility' +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 { UUID_REGEX, 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 | 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 + let outputs: Record + + if (params.outputs) { + outputs = params.outputs + } else if (blockConfig) { + const subBlocks: Record = {} + 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 = getBlockOutputs(params.type, subBlocks, triggerMode) + } 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) + } + + // 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) + } + + blockState.subBlocks[key] = { + id: key, + 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, + } + } + }) + + if (validatedInputs) { + updateCanonicalModesForInputs(blockState, Object.keys(validatedInputs), blockConfig) + } + } + + return blockState +} + +export function updateCanonicalModesForInputs( + block: { data?: { canonicalModes?: Record } }, + inputKeys: string[], + blockConfig: BlockConfig +): void { + if (!blockConfig.subBlocks?.length) return + + const canonicalIndex = buildCanonicalIndex(blockConfig.subBlocks) + const canonicalModeUpdates: Record = {} + + 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 +]) + +/** + * 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[] { + if (!Array.isArray(value)) { + return [] + } + + return value.map((item: any) => { + if (!item || typeof item !== 'object') { + return item + } + + // Check if id is missing or not a valid UUID + const hasValidUUID = typeof item.id === 'string' && UUID_REGEX.test(item.id) + if (!hasValidUUID) { + return { ...item, id: crypto.randomUUID() } + } + + 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) +} + +/** + * 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') { + // Sort keys recursively for consistent comparison + const sortKeys = (item: any): any => { + if (Array.isArray(item)) { + return item.map(sortKeys) + } + if (item !== null && typeof item === 'object') { + return Object.keys(item) + .sort() + .reduce((result: any, key: string) => { + result[key] = sortKeys(item[key]) + return result + }, {}) + } + return item + } + + // Return pretty-printed with 2-space indentation for UI readability + // The sanitizer will normalize it to minified format for comparison + return JSON.stringify(sortKeys(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, + skippedItems?: SkippedItem[] +): boolean { + if (!modifiedState.blocks[targetBlockId]) { + logger.warn(`Target block "${targetBlockId}" not found. Edge skipped.`, { + sourceBlockId, + targetBlockId, + sourceHandle, + }) + skippedItems?.push({ + type: 'invalid_edge_target', + operationType, + blockId: sourceBlockId, + reason: `Edge from "${sourceBlockId}" to "${targetBlockId}" skipped - target block does not exist`, + 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 + + modifiedState.edges.push({ + id: crypto.randomUUID(), + 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, + logger: ReturnType, + skippedItems?: SkippedItem[] +): void { + Object.entries(connections).forEach(([sourceHandle, targets]) => { + if (targets === null) return + + 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) { + 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 +} { + const logger = createLogger('EditWorkflowServerTool') + const idMapping = new Map() + + // 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 && !UUID_REGEX.test(op.block_id)) { + const newId = crypto.randomUUID() + 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 = {} + 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 = {} + 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 } +} diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/engine.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/engine.ts new file mode 100644 index 000000000..6a5c47246 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/engine.ts @@ -0,0 +1,274 @@ +import { createLogger } from '@sim/logger' +import type { PermissionGroupConfig } from '@/lib/permission-groups/types' +import { isValidKey } from '@/lib/workflows/sanitization/key-validation' +import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' +import { addConnectionsAsEdges, 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 = { + 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() + 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>() + const dependencies = new Map>() + + 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, + operations: EditWorkflowOperation[], + permissionConfig: PermissionGroupConfig | null = null +): ApplyOperationsResult { + // Deep clone the workflow state to avoid mutations + const modifiedState = JSON.parse(JSON.stringify(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, 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: Add all deferred connections from add/insert operations + // Now all blocks exist, so connections can be safely created + 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, + }) + } + + // 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 } +} diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts new file mode 100644 index 000000000..4910094ae --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts @@ -0,0 +1,284 @@ +import { db } from '@sim/db' +import { workflow as workflowTable } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' +import { applyAutoLayout } from '@/lib/workflows/autolayout' +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 { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' +import { applyOperationsToWorkflowState } from './engine' +import type { EditWorkflowParams, ValidationError } from './types' +import { preValidateCredentialInputs, validateWorkflowSelectorIds } from './validation' + +async function getCurrentWorkflowStateFromDb( + workflowId: string +): Promise<{ workflowState: any; subBlockValues: Record> }> { + 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') + + // Validate and fix blocks without types + const blocks = { ...normalized.blocks } + const invalidBlocks: string[] = [] + + Object.entries(blocks).forEach(([id, block]: [string, any]) => { + if (!block.type) { + logger.warn(`Block ${id} loaded without type from database`, { + blockKeys: Object.keys(block), + blockName: block.name, + }) + invalidBlocks.push(id) + } + }) + + // Remove invalid blocks + invalidBlocks.forEach((id) => delete blocks[id]) + + // Remove edges connected to invalid blocks + const edges = normalized.edges.filter( + (edge: any) => !invalidBlocks.includes(edge.source) && !invalidBlocks.includes(edge.target) + ) + + const workflowState: any = { + blocks, + edges, + loops: normalized.loops || {}, + parallels: normalized.parallels || {}, + } + const subBlockValues: Record> = {} + Object.entries(normalized.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, subBlockValues } +} + +export const editWorkflowServerTool: BaseServerTool = { + name: 'edit_workflow', + async execute(params: EditWorkflowParams, context?: { userId: string }): Promise { + 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') + + logger.info('Executing edit_workflow', { + operationCount: operations.length, + workflowId, + hasCurrentUserWorkflow: !!currentUserWorkflow, + }) + + // Get current workflow state + 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 + } + + // Get permission config for the user + const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : 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 }, + 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) + + // Get workspaceId for selector validation + let workspaceId: string | undefined + try { + const [workflowRecord] = await db + .select({ workspaceId: workflowTable.workspaceId }) + .from(workflowTable) + .where(eq(workflowTable.id, workflowId)) + .limit(1) + workspaceId = workflowRecord?.workspaceId ?? undefined + } catch (error) { + logger.warn('Failed to get workspaceId for selector validation', { error, workflowId }) + } + + // Validate selector IDs exist in the database + if (context?.userId) { + try { + const selectorErrors = await validateWorkflowSelectorIds(modifiedWorkflowState, { + userId: context.userId, + workspaceId, + }) + validationErrors.push(...selectorErrors) + } catch (error) { + logger.warn('Selector ID validation failed', { + error: error instanceof Error ? error.message : String(error), + }) + } + } + + // 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 { + 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 + + // Format skipped items for LLM feedback + const skippedMessages = skippedItems.length > 0 ? skippedItems.map((item) => item.reason) : undefined + + // Persist the workflow state to the database + const finalWorkflowState = validation.sanitizedState || modifiedWorkflowState + + // Apply autolayout to position blocks properly + const layoutResult = applyAutoLayout(finalWorkflowState.blocks, finalWorkflowState.edges, { + horizontalSpacing: 250, + verticalSpacing: 100, + padding: { x: 100, y: 100 }, + }) + + const layoutedBlocks = + layoutResult.success && layoutResult.blocks ? layoutResult.blocks : finalWorkflowState.blocks + + if (!layoutResult.success) { + logger.warn('Autolayout failed, using default positions', { + workflowId, + error: layoutResult.error, + }) + } + + const workflowStateForDb = { + blocks: layoutedBlocks, + edges: finalWorkflowState.edges, + loops: generateLoopBlocks(layoutedBlocks as any), + parallels: generateParallelBlocks(layoutedBlocks as any), + lastSaved: Date.now(), + isDeployed: false, + } + + 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 + await db + .update(workflowTable) + .set({ + lastSynced: new Date(), + updatedAt: new Date(), + }) + .where(eq(workflowTable.id, workflowId)) + + logger.info('Workflow state persisted to database', { workflowId }) + + // Return the modified workflow state with autolayout applied + return { + success: true, + workflowState: { ...finalWorkflowState, blocks: layoutedBlocks }, + // Include input validation errors so the LLM can see what was rejected + ...(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('; ')}`, + }), + // Include skipped items so the LLM can see what operations were skipped + ...(skippedMessages && { + skippedItems: skippedMessages, + skippedItemsMessage: `${skippedItems.length} operation(s) were skipped due to invalid references. Details: ${skippedMessages.join('; ')}`, + }), + } + }, +} diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts new file mode 100644 index 000000000..72155d1dd --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts @@ -0,0 +1,996 @@ +import { createLogger } from '@sim/logger' +import { TriggerUtils } from '@/lib/workflows/triggers/triggers' +import { getBlock } from '@/blocks/registry' +import { isValidKey } from '@/lib/workflows/sanitization/key-validation' +import { RESERVED_BLOCK_NAMES, normalizeName } from '@/executor/constants' +import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants' +import { + addConnectionsAsEdges, + applyTriggerConfigToBlockSubblocks, + createBlockFromParams, + createValidatedEdge, + filterDisallowedTools, + normalizeArrayWithIds, + normalizeResponseFormat, + normalizeTools, + shouldNormalizeArrayIds, + updateCanonicalModesForInputs, +} from './builders' +import type { EditWorkflowOperation, OperationContext } from './types' +import { logSkippedItem } from './types' +import { + findBlockWithDuplicateNormalizedName, + isBlockTypeAllowed, + validateInputsForBlock, +} from './validation' + +const logger = createLogger('EditWorkflowServerTool') + +export function handleDeleteOperation(op: EditWorkflowOperation, ctx: OperationContext): void { + const { modifiedState, skippedItems } = ctx + const { block_id } = op + + if (!modifiedState.blocks[block_id]) { + logSkippedItem(skippedItems, { + type: 'block_not_found', + operationType: 'delete', + blockId: block_id, + reason: `Block "${block_id}" does not exist and cannot be deleted`, + }) + return + } + + // Check if block is locked or inside a locked container + const deleteBlock = modifiedState.blocks[block_id] + const deleteParentId = deleteBlock.data?.parentId as string | undefined + const deleteParentLocked = deleteParentId ? modifiedState.blocks[deleteParentId]?.locked : false + if (deleteBlock.locked || deleteParentLocked) { + logSkippedItem(skippedItems, { + type: 'block_locked', + operationType: 'delete', + blockId: block_id, + reason: deleteParentLocked + ? `Block "${block_id}" is inside locked container "${deleteParentId}" and cannot be deleted` + : `Block "${block_id}" is locked and cannot be deleted`, + }) + return + } + + // Find all child blocks to remove + const blocksToRemove = new Set([block_id]) + const findChildren = (parentId: string) => { + Object.entries(modifiedState.blocks).forEach(([childId, child]: [string, any]) => { + if (child.data?.parentId === parentId) { + blocksToRemove.add(childId) + findChildren(childId) + } + }) + } + findChildren(block_id) + + // Remove blocks + blocksToRemove.forEach((id) => delete modifiedState.blocks[id]) + + // Remove edges connected to deleted blocks + modifiedState.edges = modifiedState.edges.filter( + (edge: any) => !blocksToRemove.has(edge.source) && !blocksToRemove.has(edge.target) + ) +} + +export function handleEditOperation(op: EditWorkflowOperation, ctx: OperationContext): void { + const { modifiedState, skippedItems, validationErrors, permissionConfig } = ctx + const { block_id, params } = op + + if (!modifiedState.blocks[block_id]) { + logSkippedItem(skippedItems, { + type: 'block_not_found', + operationType: 'edit', + blockId: block_id, + reason: `Block "${block_id}" does not exist and cannot be edited`, + }) + return + } + + const block = modifiedState.blocks[block_id] + + // Check if block is locked or inside a locked container + const editParentId = block.data?.parentId as string | undefined + const editParentLocked = editParentId ? modifiedState.blocks[editParentId]?.locked : false + if (block.locked || editParentLocked) { + logSkippedItem(skippedItems, { + type: 'block_locked', + operationType: 'edit', + blockId: block_id, + reason: editParentLocked + ? `Block "${block_id}" is inside locked container "${editParentId}" and cannot be edited` + : `Block "${block_id}" is locked and cannot be edited`, + }) + return + } + + // Ensure block has essential properties + if (!block.type) { + logger.warn(`Block ${block_id} missing type property, skipping edit`, { + blockKeys: Object.keys(block), + blockData: JSON.stringify(block), + }) + logSkippedItem(skippedItems, { + type: 'block_not_found', + operationType: 'edit', + blockId: block_id, + reason: `Block "${block_id}" exists but has no type property`, + }) + return + } + + // Update inputs (convert to subBlocks format) + if (params?.inputs) { + if (!block.subBlocks) block.subBlocks = {} + + // Validate inputs against block configuration + const validationResult = validateInputsForBlock(block.type, params.inputs, block_id) + validationErrors.push(...validationResult.errors) + + Object.entries(validationResult.validInputs).forEach(([inputKey, value]) => { + // Normalize common field name variations (LLM may use plural/singular inconsistently) + let key = inputKey + if (key === 'credentials' && !block.subBlocks.credentials && block.subBlocks.credential) { + key = 'credential' + } + + 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) + } + + // Special handling for tools - normalize and filter disallowed + if (key === 'tools' && Array.isArray(value)) { + sanitizedValue = filterDisallowedTools( + normalizeTools(value), + permissionConfig, + block_id, + skippedItems + ) + } + + // Special handling for responseFormat - normalize to ensure consistent format + if (key === 'responseFormat' && value) { + sanitizedValue = normalizeResponseFormat(value) + } + + if (!block.subBlocks[key]) { + block.subBlocks[key] = { + id: key, + type: 'short-input', + value: sanitizedValue, + } + } else { + const existingValue = block.subBlocks[key].value + const valuesEqual = + typeof existingValue === 'object' || typeof sanitizedValue === 'object' + ? JSON.stringify(existingValue) === JSON.stringify(sanitizedValue) + : existingValue === sanitizedValue + + if (!valuesEqual) { + block.subBlocks[key].value = sanitizedValue + } + } + }) + + if ( + Object.hasOwn(params.inputs, 'triggerConfig') && + block.subBlocks.triggerConfig && + typeof block.subBlocks.triggerConfig.value === 'object' + ) { + applyTriggerConfigToBlockSubblocks(block, block.subBlocks.triggerConfig.value) + } + + // Update loop/parallel configuration in block.data (strict validation) + if (block.type === 'loop') { + block.data = block.data || {} + // loopType is always valid + if (params.inputs.loopType !== undefined) { + const validLoopTypes = ['for', 'forEach', 'while', 'doWhile'] + if (validLoopTypes.includes(params.inputs.loopType)) { + block.data.loopType = params.inputs.loopType + } + } + const effectiveLoopType = params.inputs.loopType ?? block.data.loopType ?? 'for' + // iterations only valid for 'for' loopType + if (params.inputs.iterations !== undefined && effectiveLoopType === 'for') { + block.data.count = params.inputs.iterations + } + // collection only valid for 'forEach' loopType + if (params.inputs.collection !== undefined && effectiveLoopType === 'forEach') { + block.data.collection = params.inputs.collection + } + // condition only valid for 'while' or 'doWhile' loopType + if ( + params.inputs.condition !== undefined && + (effectiveLoopType === 'while' || effectiveLoopType === 'doWhile') + ) { + if (effectiveLoopType === 'doWhile') { + block.data.doWhileCondition = params.inputs.condition + } else { + block.data.whileCondition = params.inputs.condition + } + } + } else if (block.type === 'parallel') { + block.data = block.data || {} + // parallelType is always valid + if (params.inputs.parallelType !== undefined) { + const validParallelTypes = ['count', 'collection'] + if (validParallelTypes.includes(params.inputs.parallelType)) { + block.data.parallelType = params.inputs.parallelType + } + } + const effectiveParallelType = params.inputs.parallelType ?? block.data.parallelType ?? 'count' + // count only valid for 'count' parallelType + if (params.inputs.count !== undefined && effectiveParallelType === 'count') { + block.data.count = params.inputs.count + } + // collection only valid for 'collection' parallelType + if (params.inputs.collection !== undefined && effectiveParallelType === 'collection') { + block.data.collection = params.inputs.collection + } + } + + const editBlockConfig = getBlock(block.type) + if (editBlockConfig) { + updateCanonicalModesForInputs(block, Object.keys(validationResult.validInputs), editBlockConfig) + } + } + + // Update basic properties + if (params?.type !== undefined) { + // Special container types (loop, parallel) are not in the block registry but are valid + const isContainerType = params.type === 'loop' || params.type === 'parallel' + + // Validate type before setting (skip validation for container types) + const blockConfig = getBlock(params.type) + if (!blockConfig && !isContainerType) { + logSkippedItem(skippedItems, { + type: 'invalid_block_type', + operationType: 'edit', + blockId: block_id, + reason: `Invalid block type "${params.type}" - type change skipped`, + details: { requestedType: params.type }, + }) + } else if (!isContainerType && !isBlockTypeAllowed(params.type, permissionConfig)) { + logSkippedItem(skippedItems, { + type: 'block_not_allowed', + operationType: 'edit', + blockId: block_id, + reason: `Block type "${params.type}" is not allowed by permission group - type change skipped`, + details: { requestedType: params.type }, + }) + } else { + block.type = params.type + } + } + if (params?.name !== undefined) { + const normalizedName = normalizeName(params.name) + if (!normalizedName) { + logSkippedItem(skippedItems, { + type: 'missing_required_params', + operationType: 'edit', + blockId: block_id, + reason: `Cannot rename to empty name`, + details: { requestedName: params.name }, + }) + } else if ((RESERVED_BLOCK_NAMES as readonly string[]).includes(normalizedName)) { + logSkippedItem(skippedItems, { + type: 'reserved_block_name', + operationType: 'edit', + blockId: block_id, + reason: `Cannot rename to "${params.name}" - this is a reserved name`, + details: { requestedName: params.name }, + }) + } else { + const conflictingBlock = findBlockWithDuplicateNormalizedName( + modifiedState.blocks, + params.name, + block_id + ) + + if (conflictingBlock) { + logSkippedItem(skippedItems, { + type: 'duplicate_block_name', + operationType: 'edit', + blockId: block_id, + reason: `Cannot rename to "${params.name}" - conflicts with "${conflictingBlock[1].name}"`, + details: { + requestedName: params.name, + conflictingBlockId: conflictingBlock[0], + conflictingBlockName: conflictingBlock[1].name, + }, + }) + } else { + block.name = params.name + } + } + } + + // Handle trigger mode toggle + if (typeof params?.triggerMode === 'boolean') { + block.triggerMode = params.triggerMode + + if (params.triggerMode === true) { + // Remove all incoming edges when enabling trigger mode + modifiedState.edges = modifiedState.edges.filter((edge: any) => edge.target !== block_id) + } + } + + // Handle advanced mode toggle + if (typeof params?.advancedMode === 'boolean') { + block.advancedMode = params.advancedMode + } + + // Handle nested nodes update (for loops/parallels) + if (params?.nestedNodes) { + // Remove all existing child blocks + const existingChildren = Object.keys(modifiedState.blocks).filter( + (id) => modifiedState.blocks[id].data?.parentId === block_id + ) + existingChildren.forEach((childId) => delete modifiedState.blocks[childId]) + + // Remove edges to/from removed children + modifiedState.edges = modifiedState.edges.filter( + (edge: any) => !existingChildren.includes(edge.source) && !existingChildren.includes(edge.target) + ) + + // Add new nested blocks + Object.entries(params.nestedNodes).forEach(([childId, childBlock]: [string, any]) => { + // Validate childId is a valid string + if (!isValidKey(childId)) { + logSkippedItem(skippedItems, { + type: 'missing_required_params', + operationType: 'add_nested_node', + blockId: String(childId || 'invalid'), + reason: `Invalid childId "${childId}" in nestedNodes - child block skipped`, + }) + logger.error('Invalid childId detected in nestedNodes', { + parentBlockId: block_id, + childId, + childId_type: typeof childId, + }) + return + } + + if (childBlock.type === 'loop' || childBlock.type === 'parallel') { + logSkippedItem(skippedItems, { + type: 'nested_subflow_not_allowed', + operationType: 'edit_nested_node', + blockId: childId, + reason: `Cannot nest ${childBlock.type} inside ${block.type} - nested subflows are not supported`, + details: { parentType: block.type, childType: childBlock.type }, + }) + return + } + + const childBlockState = createBlockFromParams( + childId, + childBlock, + block_id, + validationErrors, + permissionConfig, + skippedItems + ) + modifiedState.blocks[childId] = childBlockState + + // Add connections for child block + if (childBlock.connections) { + addConnectionsAsEdges(modifiedState, childId, childBlock.connections, logger, skippedItems) + } + }) + + // Update loop/parallel configuration based on type (strict validation) + if (block.type === 'loop') { + block.data = block.data || {} + // loopType is always valid + if (params.inputs?.loopType) { + const validLoopTypes = ['for', 'forEach', 'while', 'doWhile'] + if (validLoopTypes.includes(params.inputs.loopType)) { + block.data.loopType = params.inputs.loopType + } + } + const effectiveLoopType = params.inputs?.loopType ?? block.data.loopType ?? 'for' + // iterations only valid for 'for' loopType + if (params.inputs?.iterations && effectiveLoopType === 'for') { + block.data.count = params.inputs.iterations + } + // collection only valid for 'forEach' loopType + if (params.inputs?.collection && effectiveLoopType === 'forEach') { + block.data.collection = params.inputs.collection + } + // condition only valid for 'while' or 'doWhile' loopType + if ( + params.inputs?.condition && + (effectiveLoopType === 'while' || effectiveLoopType === 'doWhile') + ) { + if (effectiveLoopType === 'doWhile') { + block.data.doWhileCondition = params.inputs.condition + } else { + block.data.whileCondition = params.inputs.condition + } + } + } else if (block.type === 'parallel') { + block.data = block.data || {} + // parallelType is always valid + if (params.inputs?.parallelType) { + const validParallelTypes = ['count', 'collection'] + if (validParallelTypes.includes(params.inputs.parallelType)) { + block.data.parallelType = params.inputs.parallelType + } + } + const effectiveParallelType = params.inputs?.parallelType ?? block.data.parallelType ?? 'count' + // count only valid for 'count' parallelType + if (params.inputs?.count && effectiveParallelType === 'count') { + block.data.count = params.inputs.count + } + // collection only valid for 'collection' parallelType + if (params.inputs?.collection && effectiveParallelType === 'collection') { + block.data.collection = params.inputs.collection + } + } + } + + // Handle connections update (convert to edges) + if (params?.connections) { + modifiedState.edges = modifiedState.edges.filter((edge: any) => edge.source !== block_id) + + Object.entries(params.connections).forEach(([connectionType, targets]) => { + if (targets === null) return + + const mapConnectionTypeToHandle = (type: string): string => { + if (type === 'success') return 'source' + if (type === 'error') return 'error' + return type + } + + const sourceHandle = mapConnectionTypeToHandle(connectionType) + + const addEdgeForTarget = (targetBlock: string, targetHandle?: string) => { + createValidatedEdge( + modifiedState, + block_id, + targetBlock, + sourceHandle, + targetHandle || 'target', + 'edit', + 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 as any)?.block) { + addEdgeForTarget((targets as any).block, (targets as any).handle) + } + }) + } + + // Handle edge removal + if (params?.removeEdges && Array.isArray(params.removeEdges)) { + params.removeEdges.forEach(({ targetBlockId, sourceHandle = 'source' }) => { + modifiedState.edges = modifiedState.edges.filter( + (edge: any) => + !(edge.source === block_id && edge.target === targetBlockId && edge.sourceHandle === sourceHandle) + ) + }) + } +} + +export function handleAddOperation(op: EditWorkflowOperation, ctx: OperationContext): void { + const { modifiedState, skippedItems, validationErrors, permissionConfig, deferredConnections } = ctx + const { block_id, params } = op + + const addNormalizedName = params?.name ? normalizeName(params.name) : '' + if (!params?.type || !params?.name || !addNormalizedName) { + logSkippedItem(skippedItems, { + type: 'missing_required_params', + operationType: 'add', + blockId: block_id, + reason: `Missing required params (type or name) for adding block "${block_id}"`, + details: { hasType: !!params?.type, hasName: !!params?.name }, + }) + return + } + + if ((RESERVED_BLOCK_NAMES as readonly string[]).includes(addNormalizedName)) { + logSkippedItem(skippedItems, { + type: 'reserved_block_name', + operationType: 'add', + blockId: block_id, + reason: `Block name "${params.name}" is a reserved name and cannot be used`, + details: { requestedName: params.name }, + }) + return + } + + const conflictingBlock = findBlockWithDuplicateNormalizedName(modifiedState.blocks, params.name, block_id) + + if (conflictingBlock) { + logSkippedItem(skippedItems, { + type: 'duplicate_block_name', + operationType: 'add', + blockId: block_id, + reason: `Block name "${params.name}" conflicts with existing block "${conflictingBlock[1].name}"`, + details: { + requestedName: params.name, + conflictingBlockId: conflictingBlock[0], + conflictingBlockName: conflictingBlock[1].name, + }, + }) + return + } + + // Special container types (loop, parallel) are not in the block registry but are valid + const isContainerType = params.type === 'loop' || params.type === 'parallel' + + // Validate block type before adding (skip validation for container types) + const addBlockConfig = getBlock(params.type) + if (!addBlockConfig && !isContainerType) { + logSkippedItem(skippedItems, { + type: 'invalid_block_type', + operationType: 'add', + blockId: block_id, + reason: `Invalid block type "${params.type}" - block not added`, + details: { requestedType: params.type }, + }) + return + } + + // Check if block type is allowed by permission group + if (!isContainerType && !isBlockTypeAllowed(params.type, permissionConfig)) { + logSkippedItem(skippedItems, { + type: 'block_not_allowed', + operationType: 'add', + blockId: block_id, + reason: `Block type "${params.type}" is not allowed by permission group - block not added`, + details: { requestedType: params.type }, + }) + return + } + + const triggerIssue = TriggerUtils.getTriggerAdditionIssue(modifiedState.blocks, params.type) + if (triggerIssue) { + logSkippedItem(skippedItems, { + type: 'duplicate_trigger', + operationType: 'add', + blockId: block_id, + reason: `Cannot add ${triggerIssue.triggerName} - a workflow can only have one`, + details: { requestedType: params.type, issue: triggerIssue.issue }, + }) + return + } + + // Check single-instance block constraints (e.g., Response block) + const singleInstanceIssue = TriggerUtils.getSingleInstanceBlockIssue(modifiedState.blocks, params.type) + if (singleInstanceIssue) { + logSkippedItem(skippedItems, { + type: 'duplicate_single_instance_block', + operationType: 'add', + blockId: block_id, + reason: `Cannot add ${singleInstanceIssue.blockName} - a workflow can only have one`, + details: { requestedType: params.type }, + }) + return + } + + // Create new block with proper structure + const newBlock = createBlockFromParams( + block_id, + params, + undefined, + validationErrors, + permissionConfig, + skippedItems + ) + + // Set loop/parallel data on parent block BEFORE adding to blocks (strict validation) + if (params.nestedNodes) { + if (params.type === 'loop') { + const validLoopTypes = ['for', 'forEach', 'while', 'doWhile'] + const loopType = + params.inputs?.loopType && validLoopTypes.includes(params.inputs.loopType) + ? params.inputs.loopType + : 'for' + newBlock.data = { + ...newBlock.data, + loopType, + // Only include type-appropriate fields + ...(loopType === 'forEach' && params.inputs?.collection && { collection: params.inputs.collection }), + ...(loopType === 'for' && params.inputs?.iterations && { count: params.inputs.iterations }), + ...(loopType === 'while' && params.inputs?.condition && { whileCondition: params.inputs.condition }), + ...(loopType === 'doWhile' && + params.inputs?.condition && { doWhileCondition: params.inputs.condition }), + } + } else if (params.type === 'parallel') { + const validParallelTypes = ['count', 'collection'] + const parallelType = + params.inputs?.parallelType && validParallelTypes.includes(params.inputs.parallelType) + ? params.inputs.parallelType + : 'count' + newBlock.data = { + ...newBlock.data, + parallelType, + // Only include type-appropriate fields + ...(parallelType === 'collection' && + params.inputs?.collection && { collection: params.inputs.collection }), + ...(parallelType === 'count' && params.inputs?.count && { count: params.inputs.count }), + } + } + } + + // Add parent block FIRST before adding children + // This ensures children can reference valid parentId + modifiedState.blocks[block_id] = newBlock + + // Handle nested nodes (for loops/parallels created from scratch) + if (params.nestedNodes) { + // Defensive check: verify parent is not locked before adding children + // (Parent was just created with locked: false, but check for consistency) + const parentBlock = modifiedState.blocks[block_id] + if (parentBlock?.locked) { + logSkippedItem(skippedItems, { + type: 'block_locked', + operationType: 'add_nested_nodes', + blockId: block_id, + reason: `Container "${block_id}" is locked - cannot add nested nodes`, + }) + return + } + + Object.entries(params.nestedNodes).forEach(([childId, childBlock]: [string, any]) => { + // Validate childId is a valid string + if (!isValidKey(childId)) { + logSkippedItem(skippedItems, { + type: 'missing_required_params', + operationType: 'add_nested_node', + blockId: String(childId || 'invalid'), + reason: `Invalid childId "${childId}" in nestedNodes - child block skipped`, + }) + logger.error('Invalid childId detected in nestedNodes', { + parentBlockId: block_id, + childId, + childId_type: typeof childId, + }) + return + } + + if (childBlock.type === 'loop' || childBlock.type === 'parallel') { + logSkippedItem(skippedItems, { + type: 'nested_subflow_not_allowed', + operationType: 'add_nested_node', + blockId: childId, + reason: `Cannot nest ${childBlock.type} inside ${params.type} - nested subflows are not supported`, + details: { parentType: params.type, childType: childBlock.type }, + }) + return + } + + const childBlockState = createBlockFromParams( + childId, + childBlock, + block_id, + validationErrors, + permissionConfig, + skippedItems + ) + modifiedState.blocks[childId] = childBlockState + + // Defer connection processing to ensure all blocks exist first + if (childBlock.connections) { + deferredConnections.push({ + blockId: childId, + connections: childBlock.connections, + }) + } + }) + } + + // Defer connection processing to ensure all blocks exist first (pass 2) + if (params.connections) { + deferredConnections.push({ + blockId: block_id, + connections: params.connections, + }) + } +} + +export function handleInsertIntoSubflowOperation( + op: EditWorkflowOperation, + ctx: OperationContext +): void { + const { modifiedState, skippedItems, validationErrors, permissionConfig, deferredConnections } = ctx + const { block_id, params } = op + + const subflowId = params?.subflowId + if (!subflowId || !params?.type || !params?.name) { + logSkippedItem(skippedItems, { + type: 'missing_required_params', + operationType: 'insert_into_subflow', + blockId: block_id, + reason: `Missing required params (subflowId, type, or name) for inserting block "${block_id}"`, + details: { + hasSubflowId: !!subflowId, + hasType: !!params?.type, + hasName: !!params?.name, + }, + }) + return + } + + const subflowBlock = modifiedState.blocks[subflowId] + if (!subflowBlock) { + logSkippedItem(skippedItems, { + type: 'invalid_subflow_parent', + operationType: 'insert_into_subflow', + blockId: block_id, + reason: `Subflow block "${subflowId}" not found - block "${block_id}" not inserted`, + details: { subflowId }, + }) + return + } + + // Check if subflow is locked + if (subflowBlock.locked) { + logSkippedItem(skippedItems, { + type: 'block_locked', + operationType: 'insert_into_subflow', + blockId: block_id, + reason: `Subflow "${subflowId}" is locked - cannot insert block "${block_id}"`, + details: { subflowId }, + }) + return + } + + if (subflowBlock.type !== 'loop' && subflowBlock.type !== 'parallel') { + logger.error('Subflow block has invalid type', { + subflowId, + type: subflowBlock.type, + block_id, + }) + return + } + + if (params.type === 'loop' || params.type === 'parallel') { + logSkippedItem(skippedItems, { + type: 'nested_subflow_not_allowed', + operationType: 'insert_into_subflow', + blockId: block_id, + reason: `Cannot nest ${params.type} inside ${subflowBlock.type} - nested subflows are not supported`, + details: { parentType: subflowBlock.type, childType: params.type }, + }) + return + } + + // Check if block already exists (moving into subflow) or is new + const existingBlock = modifiedState.blocks[block_id] + + if (existingBlock) { + if (existingBlock.type === 'loop' || existingBlock.type === 'parallel') { + logSkippedItem(skippedItems, { + type: 'nested_subflow_not_allowed', + operationType: 'insert_into_subflow', + blockId: block_id, + reason: `Cannot move ${existingBlock.type} into ${subflowBlock.type} - nested subflows are not supported`, + details: { parentType: subflowBlock.type, childType: existingBlock.type }, + }) + return + } + + // Check if existing block is locked + if (existingBlock.locked) { + logSkippedItem(skippedItems, { + type: 'block_locked', + operationType: 'insert_into_subflow', + blockId: block_id, + reason: `Block "${block_id}" is locked and cannot be moved into a subflow`, + }) + return + } + + // Moving existing block into subflow - just update parent + existingBlock.data = { + ...existingBlock.data, + parentId: subflowId, + extent: 'parent' as const, + } + + // Update inputs if provided (with validation) + if (params.inputs) { + // Validate inputs against block configuration + const validationResult = validateInputsForBlock(existingBlock.type, params.inputs, block_id) + validationErrors.push(...validationResult.errors) + + Object.entries(validationResult.validInputs).forEach(([key, value]) => { + // Skip runtime subblock IDs (webhookId, triggerPath) + 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) + } + + // Special handling for tools - normalize and filter disallowed + if (key === 'tools' && Array.isArray(value)) { + sanitizedValue = filterDisallowedTools( + normalizeTools(value), + permissionConfig, + block_id, + skippedItems + ) + } + + // Special handling for responseFormat - normalize to ensure consistent format + if (key === 'responseFormat' && value) { + sanitizedValue = normalizeResponseFormat(value) + } + + if (!existingBlock.subBlocks[key]) { + existingBlock.subBlocks[key] = { + id: key, + type: 'short-input', + value: sanitizedValue, + } + } else { + existingBlock.subBlocks[key].value = sanitizedValue + } + }) + + const existingBlockConfig = getBlock(existingBlock.type) + if (existingBlockConfig) { + updateCanonicalModesForInputs( + existingBlock, + Object.keys(validationResult.validInputs), + existingBlockConfig + ) + } + } + } else { + // Special container types (loop, parallel) are not in the block registry but are valid + const isContainerType = params.type === 'loop' || params.type === 'parallel' + + // Validate block type before creating (skip validation for container types) + const insertBlockConfig = getBlock(params.type) + if (!insertBlockConfig && !isContainerType) { + logSkippedItem(skippedItems, { + type: 'invalid_block_type', + operationType: 'insert_into_subflow', + blockId: block_id, + reason: `Invalid block type "${params.type}" - block not inserted into subflow`, + details: { requestedType: params.type, subflowId }, + }) + return + } + + // Check if block type is allowed by permission group + if (!isContainerType && !isBlockTypeAllowed(params.type, permissionConfig)) { + logSkippedItem(skippedItems, { + type: 'block_not_allowed', + operationType: 'insert_into_subflow', + blockId: block_id, + reason: `Block type "${params.type}" is not allowed by permission group - block not inserted`, + details: { requestedType: params.type, subflowId }, + }) + return + } + + // Create new block as child of subflow + const newBlock = createBlockFromParams( + block_id, + params, + subflowId, + validationErrors, + permissionConfig, + skippedItems + ) + modifiedState.blocks[block_id] = newBlock + } + + // Defer connection processing to ensure all blocks exist first + // This is particularly important when multiple blocks are being inserted + // and they have connections to each other + if (params.connections) { + // Remove existing edges from this block first + modifiedState.edges = modifiedState.edges.filter((edge: any) => edge.source !== block_id) + + // Add to deferred connections list + deferredConnections.push({ + blockId: block_id, + connections: params.connections, + }) + } +} + +export function handleExtractFromSubflowOperation( + op: EditWorkflowOperation, + ctx: OperationContext +): void { + const { modifiedState, skippedItems } = ctx + const { block_id, params } = op + + const subflowId = params?.subflowId + if (!subflowId) { + logSkippedItem(skippedItems, { + type: 'missing_required_params', + operationType: 'extract_from_subflow', + blockId: block_id, + reason: `Missing subflowId for extracting block "${block_id}"`, + }) + return + } + + const block = modifiedState.blocks[block_id] + if (!block) { + logSkippedItem(skippedItems, { + type: 'block_not_found', + operationType: 'extract_from_subflow', + blockId: block_id, + reason: `Block "${block_id}" not found for extraction`, + }) + return + } + + // Check if block is locked + if (block.locked) { + logSkippedItem(skippedItems, { + type: 'block_locked', + operationType: 'extract_from_subflow', + blockId: block_id, + reason: `Block "${block_id}" is locked and cannot be extracted from subflow`, + }) + return + } + + // Check if parent subflow is locked + const parentSubflow = modifiedState.blocks[subflowId] + if (parentSubflow?.locked) { + logSkippedItem(skippedItems, { + type: 'block_locked', + operationType: 'extract_from_subflow', + blockId: block_id, + reason: `Subflow "${subflowId}" is locked - cannot extract block "${block_id}"`, + details: { subflowId }, + }) + return + } + + // Verify it's actually a child of this subflow + if (block.data?.parentId !== subflowId) { + logger.warn('Block is not a child of specified subflow', { + block_id, + actualParent: block.data?.parentId, + specifiedParent: subflowId, + }) + } + + // Remove parent relationship + if (block.data) { + block.data.parentId = undefined + block.data.extent = undefined + } + + // Note: We keep the block and its edges, just remove parent relationship + // The block becomes a root-level block +} diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/types.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/types.ts new file mode 100644 index 000000000..09b766e06 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/types.ts @@ -0,0 +1,134 @@ +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') + +/** UUID v4 regex pattern for validation */ +export const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +/** + * 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_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 +} + +/** + * 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 + 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 +} + +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 + }> +} diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts new file mode 100644 index 000000000..424be9d25 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts @@ -0,0 +1,1051 @@ +import { createLogger } from '@sim/logger' +import { validateSelectorIds } from '@/lib/copilot/validation/selector-validator' +import type { PermissionGroupConfig } from '@/lib/permission-groups/types' +import { getBlock } from '@/blocks/registry' +import type { SubBlockConfig } from '@/blocks/types' +import { EDGE, normalizeName } from '@/executor/constants' +import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants' +import type { + EdgeHandleValidationResult, + EditWorkflowOperation, + ValidationError, + ValidationResult, + ValueValidationResult, +} from './types' +import { SELECTOR_TYPES } from './types' + +const validationLogger = createLogger('EditWorkflowValidation') + +/** + * Finds an existing block with the same normalized name. + */ +export function findBlockWithDuplicateNormalizedName( + blocks: Record, + name: string, + excludeBlockId: string +): [string, any] | undefined { + const normalizedName = normalizeName(name) + return Object.entries(blocks).find( + ([blockId, block]: [string, any]) => + blockId !== excludeBlockId && normalizeName(block.name || '') === normalizedName + ) +} + +/** + * Validates and filters inputs against a block's subBlock configuration + * Returns valid inputs and any validation errors encountered + */ +export function validateInputsForBlock( + blockType: string, + inputs: Record, + blockId: string +): ValidationResult { + const errors: ValidationError[] = [] + const blockConfig = getBlock(blockType) + + if (!blockConfig) { + // Unknown block type - return inputs as-is (let it fail later if invalid) + validationLogger.warn(`Unknown block type: ${blockType}, skipping validation`) + return { validInputs: inputs, errors: [] } + } + + const validatedInputs: Record = {} + const subBlockMap = new Map() + + // Build map of subBlock id -> config + for (const subBlock of blockConfig.subBlocks) { + subBlockMap.set(subBlock.id, subBlock) + } + + for (const [key, value] of Object.entries(inputs)) { + // Skip runtime subblock IDs + if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(key)) { + continue + } + + const subBlockConfig = subBlockMap.get(key) + + // If subBlock doesn't exist in config, skip it (unless it's a known dynamic field) + if (!subBlockConfig) { + // Some fields are valid but not in subBlocks (like loop/parallel config) + // Allow these through for special block types + if (blockType === 'loop' || blockType === 'parallel') { + validatedInputs[key] = value + } else { + errors.push({ + blockId, + blockType, + field: key, + value, + error: `Unknown input field "${key}" for block type "${blockType}"`, + }) + } + continue + } + + // Note: We do NOT check subBlockConfig.condition here. + // Conditions are for UI display logic (show/hide fields in the editor). + // For API/Copilot, any valid field in the block schema should be accepted. + // The runtime will use the relevant fields based on the actual operation. + + // Validate value based on subBlock type + const validationResult = validateValueForSubBlockType( + subBlockConfig, + value, + key, + blockType, + blockId + ) + if (validationResult.valid) { + validatedInputs[key] = validationResult.value + } else if (validationResult.error) { + errors.push(validationResult.error) + } + } + + return { validInputs: validatedInputs, errors } +} + +/** + * Validates a value against its expected subBlock type + * Returns validation result with the value or an error + */ +export function validateValueForSubBlockType( + subBlockConfig: SubBlockConfig, + value: any, + fieldName: string, + blockType: string, + blockId: string +): ValueValidationResult { + const { type } = subBlockConfig + + // Handle null/undefined - allow clearing fields + if (value === null || value === undefined) { + return { valid: true, value } + } + + switch (type) { + case 'dropdown': { + // Validate against allowed options + const options = + typeof subBlockConfig.options === 'function' + ? subBlockConfig.options() + : subBlockConfig.options + if (options && Array.isArray(options)) { + const validIds = options.map((opt) => opt.id) + if (!validIds.includes(value)) { + return { + valid: false, + error: { + blockId, + blockType, + field: fieldName, + value, + error: `Invalid dropdown value "${value}" for field "${fieldName}". Valid options: ${validIds.join(', ')}`, + }, + } + } + } + return { valid: true, value } + } + + case 'slider': { + // Validate numeric range + const numValue = typeof value === 'number' ? value : Number(value) + if (Number.isNaN(numValue)) { + return { + valid: false, + error: { + blockId, + blockType, + field: fieldName, + value, + error: `Invalid slider value "${value}" for field "${fieldName}" - must be a number`, + }, + } + } + // Clamp to range (allow but warn) + let clampedValue = numValue + if (subBlockConfig.min !== undefined && numValue < subBlockConfig.min) { + clampedValue = subBlockConfig.min + } + if (subBlockConfig.max !== undefined && numValue > subBlockConfig.max) { + clampedValue = subBlockConfig.max + } + return { + valid: true, + value: subBlockConfig.integer ? Math.round(clampedValue) : clampedValue, + } + } + + case 'switch': { + // Must be boolean + if (typeof value !== 'boolean') { + return { + valid: false, + error: { + blockId, + blockType, + field: fieldName, + value, + error: `Invalid switch value "${value}" for field "${fieldName}" - must be true or false`, + }, + } + } + return { valid: true, value } + } + + case 'file-upload': { + // File upload should be an object with specific properties or null + if (value === null) return { valid: true, value: null } + if (typeof value !== 'object') { + return { + valid: false, + error: { + blockId, + blockType, + field: fieldName, + value, + error: `Invalid file-upload value for field "${fieldName}" - expected object with name and path properties, or null`, + }, + } + } + // Validate file object has required properties + if (value && (!value.name || !value.path)) { + return { + valid: false, + error: { + blockId, + blockType, + field: fieldName, + value, + error: `Invalid file-upload object for field "${fieldName}" - must have "name" and "path" properties`, + }, + } + } + return { valid: true, value } + } + + case 'input-format': + case 'table': { + // Should be an array + if (!Array.isArray(value)) { + return { + valid: false, + error: { + blockId, + blockType, + field: fieldName, + value, + error: `Invalid ${type} value for field "${fieldName}" - expected an array`, + }, + } + } + return { valid: true, value } + } + + case 'tool-input': { + // Should be an array of tool objects + if (!Array.isArray(value)) { + return { + valid: false, + error: { + blockId, + blockType, + field: fieldName, + value, + error: `Invalid tool-input value for field "${fieldName}" - expected an array of tool objects`, + }, + } + } + return { valid: true, value } + } + + case 'code': { + // Code must be a string (content can be JS, Python, JSON, SQL, HTML, etc.) + if (typeof value !== 'string') { + return { + valid: false, + error: { + blockId, + blockType, + field: fieldName, + value, + error: `Invalid code value for field "${fieldName}" - expected a string, got ${typeof value}`, + }, + } + } + return { valid: true, value } + } + + case 'response-format': { + // Allow empty/null + if (value === null || value === undefined || value === '') { + return { valid: true, value } + } + // Allow objects (will be stringified later by normalizeResponseFormat) + if (typeof value === 'object') { + return { valid: true, value } + } + // If string, must be valid JSON + if (typeof value === 'string') { + try { + JSON.parse(value) + return { valid: true, value } + } catch { + return { + valid: false, + error: { + blockId, + blockType, + field: fieldName, + value, + error: `Invalid response-format value for field "${fieldName}" - string must be valid JSON`, + }, + } + } + } + // Reject numbers, booleans, etc. + return { + valid: false, + error: { + blockId, + blockType, + field: fieldName, + value, + error: `Invalid response-format value for field "${fieldName}" - expected a JSON string or object`, + }, + } + } + + case 'short-input': + case 'long-input': + case 'combobox': { + // Should be string (combobox allows custom values) + if (typeof value !== 'string' && typeof value !== 'number') { + // Convert to string but don't error + return { valid: true, value: String(value) } + } + return { valid: true, value } + } + + // Selector types - allow strings (IDs) or arrays of strings + case 'oauth-input': + case 'knowledge-base-selector': + case 'document-selector': + case 'file-selector': + case 'project-selector': + case 'channel-selector': + case 'folder-selector': + case 'mcp-server-selector': + case 'mcp-tool-selector': + case 'workflow-selector': { + if (subBlockConfig.multiSelect && Array.isArray(value)) { + return { valid: true, value } + } + if (typeof value === 'string') { + return { valid: true, value } + } + return { + valid: false, + error: { + blockId, + blockType, + field: fieldName, + value, + error: `Invalid selector value for field "${fieldName}" - expected a string${subBlockConfig.multiSelect ? ' or array of strings' : ''}`, + }, + } + } + + default: + // For unknown types, pass through + return { valid: true, value } + } +} + +/** + * Validates source handle is valid for the block type + */ +export function validateSourceHandleForBlock( + sourceHandle: string, + sourceBlockType: string, + sourceBlock: any +): EdgeHandleValidationResult { + if (sourceHandle === 'error') { + return { valid: true } + } + + switch (sourceBlockType) { + case 'loop': + if (sourceHandle === 'loop-start-source' || sourceHandle === 'loop-end-source') { + return { valid: true } + } + return { + valid: false, + error: `Invalid source handle "${sourceHandle}" for loop block. Valid handles: loop-start-source, loop-end-source, error`, + } + + case 'parallel': + if (sourceHandle === 'parallel-start-source' || sourceHandle === 'parallel-end-source') { + return { valid: true } + } + return { + valid: false, + error: `Invalid source handle "${sourceHandle}" for parallel block. Valid handles: parallel-start-source, parallel-end-source, error`, + } + + case 'condition': { + const conditionsValue = sourceBlock?.subBlocks?.conditions?.value + if (!conditionsValue) { + return { + valid: false, + error: `Invalid condition handle "${sourceHandle}" - no conditions defined`, + } + } + + // validateConditionHandle accepts simple format (if, else-if-0, else), + // legacy format (condition-{blockId}-if), and internal ID format (condition-{uuid}) + return validateConditionHandle(sourceHandle, sourceBlock.id, conditionsValue) + } + + case 'router': + if (sourceHandle === 'source' || sourceHandle.startsWith(EDGE.ROUTER_PREFIX)) { + return { valid: true } + } + return { + valid: false, + error: `Invalid source handle "${sourceHandle}" for router block. Valid handles: source, ${EDGE.ROUTER_PREFIX}{targetId}, error`, + } + + case 'router_v2': { + const routesValue = sourceBlock?.subBlocks?.routes?.value + if (!routesValue) { + return { + valid: false, + error: `Invalid router handle "${sourceHandle}" - no routes defined`, + } + } + + // validateRouterHandle accepts simple format (route-0, route-1), + // legacy format (router-{blockId}-route-1), and internal ID format (router-{uuid}) + return validateRouterHandle(sourceHandle, sourceBlock.id, routesValue) + } + + default: + if (sourceHandle === 'source') { + return { valid: true } + } + return { + valid: false, + error: `Invalid source handle "${sourceHandle}" for ${sourceBlockType} block. Valid handles: source, error`, + } + } +} + +/** + * Validates condition handle references a valid condition in the block. + * Accepts multiple formats: + * - Simple format: "if", "else-if-0", "else-if-1", "else" + * - Legacy semantic format: "condition-{blockId}-if", "condition-{blockId}-else-if" + * - Internal ID format: "condition-{conditionId}" + * + * Returns the normalized handle (condition-{conditionId}) for storage. + */ +export function validateConditionHandle( + sourceHandle: string, + blockId: string, + conditionsValue: string | any[] +): EdgeHandleValidationResult { + let conditions: any[] + if (typeof conditionsValue === 'string') { + try { + conditions = JSON.parse(conditionsValue) + } catch { + return { + valid: false, + error: `Cannot validate condition handle "${sourceHandle}" - conditions is not valid JSON`, + } + } + } else if (Array.isArray(conditionsValue)) { + conditions = conditionsValue + } else { + return { + valid: false, + error: `Cannot validate condition handle "${sourceHandle}" - conditions is not an array`, + } + } + + if (!Array.isArray(conditions) || conditions.length === 0) { + return { + valid: false, + error: `Invalid condition handle "${sourceHandle}" - no conditions defined`, + } + } + + // Build a map of all valid handle formats -> normalized handle (condition-{conditionId}) + const handleToNormalized = new Map() + const legacySemanticPrefix = `condition-${blockId}-` + let elseIfIndex = 0 + + for (const condition of conditions) { + if (!condition.id) continue + + const normalizedHandle = `condition-${condition.id}` + const title = condition.title?.toLowerCase() + + // Always accept internal ID format + handleToNormalized.set(normalizedHandle, normalizedHandle) + + if (title === 'if') { + // Simple format: "if" + handleToNormalized.set('if', normalizedHandle) + // Legacy format: "condition-{blockId}-if" + handleToNormalized.set(`${legacySemanticPrefix}if`, normalizedHandle) + } else if (title === 'else if') { + // Simple format: "else-if-0", "else-if-1", etc. (0-indexed) + handleToNormalized.set(`else-if-${elseIfIndex}`, normalizedHandle) + // Legacy format: "condition-{blockId}-else-if" for first, "condition-{blockId}-else-if-2" for second + if (elseIfIndex === 0) { + handleToNormalized.set(`${legacySemanticPrefix}else-if`, normalizedHandle) + } else { + handleToNormalized.set( + `${legacySemanticPrefix}else-if-${elseIfIndex + 1}`, + normalizedHandle + ) + } + elseIfIndex++ + } else if (title === 'else') { + // Simple format: "else" + handleToNormalized.set('else', normalizedHandle) + // Legacy format: "condition-{blockId}-else" + handleToNormalized.set(`${legacySemanticPrefix}else`, normalizedHandle) + } + } + + const normalizedHandle = handleToNormalized.get(sourceHandle) + if (normalizedHandle) { + return { valid: true, normalizedHandle } + } + + // Build list of valid simple format options for error message + const simpleOptions: string[] = [] + elseIfIndex = 0 + for (const condition of conditions) { + const title = condition.title?.toLowerCase() + if (title === 'if') { + simpleOptions.push('if') + } else if (title === 'else if') { + simpleOptions.push(`else-if-${elseIfIndex}`) + elseIfIndex++ + } else if (title === 'else') { + simpleOptions.push('else') + } + } + + return { + valid: false, + error: `Invalid condition handle "${sourceHandle}". Valid handles: ${simpleOptions.join(', ')}`, + } +} + +/** + * Validates router handle references a valid route in the block. + * Accepts multiple formats: + * - Simple format: "route-0", "route-1", "route-2" (0-indexed) + * - Legacy semantic format: "router-{blockId}-route-1" (1-indexed) + * - Internal ID format: "router-{routeId}" + * + * Returns the normalized handle (router-{routeId}) for storage. + */ +export function validateRouterHandle( + sourceHandle: string, + blockId: string, + routesValue: string | any[] +): EdgeHandleValidationResult { + let routes: any[] + if (typeof routesValue === 'string') { + try { + routes = JSON.parse(routesValue) + } catch { + return { + valid: false, + error: `Cannot validate router handle "${sourceHandle}" - routes is not valid JSON`, + } + } + } else if (Array.isArray(routesValue)) { + routes = routesValue + } else { + return { + valid: false, + error: `Cannot validate router handle "${sourceHandle}" - routes is not an array`, + } + } + + if (!Array.isArray(routes) || routes.length === 0) { + return { + valid: false, + error: `Invalid router handle "${sourceHandle}" - no routes defined`, + } + } + + // Build a map of all valid handle formats -> normalized handle (router-{routeId}) + const handleToNormalized = new Map() + const legacySemanticPrefix = `router-${blockId}-` + + for (let i = 0; i < routes.length; i++) { + const route = routes[i] + if (!route.id) continue + + const normalizedHandle = `router-${route.id}` + + // Always accept internal ID format: router-{uuid} + handleToNormalized.set(normalizedHandle, normalizedHandle) + + // Simple format: route-0, route-1, etc. (0-indexed) + handleToNormalized.set(`route-${i}`, normalizedHandle) + + // Legacy 1-indexed route number format: router-{blockId}-route-1 + handleToNormalized.set(`${legacySemanticPrefix}route-${i + 1}`, normalizedHandle) + + // Accept normalized title format: router-{blockId}-{normalized-title} + if (route.title && typeof route.title === 'string') { + const normalizedTitle = route.title + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/g, '') + if (normalizedTitle) { + handleToNormalized.set(`${legacySemanticPrefix}${normalizedTitle}`, normalizedHandle) + } + } + } + + const normalizedHandle = handleToNormalized.get(sourceHandle) + if (normalizedHandle) { + return { valid: true, normalizedHandle } + } + + // Build list of valid simple format options for error message + const simpleOptions = routes.map((_, i) => `route-${i}`) + + return { + valid: false, + error: `Invalid router handle "${sourceHandle}". Valid handles: ${simpleOptions.join(', ')}`, + } +} + +/** + * Validates target handle is valid (must be 'target') + */ +export function validateTargetHandle(targetHandle: string): EdgeHandleValidationResult { + if (targetHandle === 'target') { + return { valid: true } + } + return { + valid: false, + error: `Invalid target handle "${targetHandle}". Expected "target"`, + } +} + +/** + * Checks if a block type is allowed by the permission group config + */ +export function isBlockTypeAllowed( + blockType: string, + permissionConfig: PermissionGroupConfig | null +): boolean { + if (!permissionConfig || permissionConfig.allowedIntegrations === null) { + return true + } + return permissionConfig.allowedIntegrations.includes(blockType) +} + +/** + * Validates selector IDs in the workflow state exist in the database + * Returns validation errors for any invalid selector IDs + */ +export async function validateWorkflowSelectorIds( + workflowState: any, + context: { userId: string; workspaceId?: string } +): Promise { + const logger = createLogger('EditWorkflowSelectorValidation') + const errors: ValidationError[] = [] + + // Collect all selector fields from all blocks + const selectorsToValidate: Array<{ + blockId: string + blockType: string + fieldName: string + selectorType: string + value: string | string[] + }> = [] + + for (const [blockId, block] of Object.entries(workflowState.blocks || {})) { + const blockData = block as any + const blockType = blockData.type + if (!blockType) continue + + const blockConfig = getBlock(blockType) + if (!blockConfig) continue + + // Check each subBlock for selector types + for (const subBlockConfig of blockConfig.subBlocks) { + if (!SELECTOR_TYPES.has(subBlockConfig.type)) continue + + // Skip oauth-input - credentials are pre-validated before edit application + // This allows existing collaborator credentials to remain untouched + if (subBlockConfig.type === 'oauth-input') continue + + const subBlockValue = blockData.subBlocks?.[subBlockConfig.id]?.value + if (!subBlockValue) continue + + // Handle comma-separated values for multi-select + let values: string | string[] = subBlockValue + if (typeof subBlockValue === 'string' && subBlockValue.includes(',')) { + values = subBlockValue + .split(',') + .map((v: string) => v.trim()) + .filter(Boolean) + } + + selectorsToValidate.push({ + blockId, + blockType, + fieldName: subBlockConfig.id, + selectorType: subBlockConfig.type, + value: values, + }) + } + } + + if (selectorsToValidate.length === 0) { + return errors + } + + logger.info('Validating selector IDs', { + selectorCount: selectorsToValidate.length, + userId: context.userId, + workspaceId: context.workspaceId, + }) + + // Validate each selector field + for (const selector of selectorsToValidate) { + const result = await validateSelectorIds(selector.selectorType, selector.value, context) + + if (result.invalid.length > 0) { + // Include warning info (like available credentials) in the error message for better LLM feedback + const warningInfo = result.warning ? `. ${result.warning}` : '' + errors.push({ + blockId: selector.blockId, + blockType: selector.blockType, + field: selector.fieldName, + value: selector.value, + error: `Invalid ${selector.selectorType} ID(s): ${result.invalid.join(', ')} - ID(s) do not exist or user doesn't have access${warningInfo}`, + }) + } else if (result.warning) { + // Log warnings that don't have errors (shouldn't happen for credentials but may for other selectors) + logger.warn(result.warning, { + blockId: selector.blockId, + fieldName: selector.fieldName, + }) + } + } + + if (errors.length > 0) { + logger.warn('Found invalid selector IDs', { + errorCount: errors.length, + errors: errors.map((e) => ({ blockId: e.blockId, field: e.field, error: e.error })), + }) + } + + return errors +} + +/** + * Pre-validates credential and apiKey inputs in operations before they are applied. + * - Validates oauth-input (credential) IDs belong to the user + * - Filters out apiKey inputs for hosted models when isHosted is true + * - Also validates credentials and apiKeys in nestedNodes (blocks inside loop/parallel) + * Returns validation errors for any removed inputs. + */ +export async function preValidateCredentialInputs( + operations: EditWorkflowOperation[], + context: { userId: string }, + workflowState?: Record +): Promise<{ filteredOperations: EditWorkflowOperation[]; errors: ValidationError[] }> { + const { isHosted } = await import('@/lib/core/config/feature-flags') + const { getHostedModels } = await import('@/providers/utils') + + const logger = createLogger('PreValidateCredentials') + const errors: ValidationError[] = [] + + // Collect credential and apiKey inputs that need validation/filtering + const credentialInputs: Array<{ + operationIndex: number + blockId: string + blockType: string + fieldName: string + value: string + nestedBlockId?: string + }> = [] + + const hostedApiKeyInputs: Array<{ + operationIndex: number + blockId: string + blockType: string + model: string + nestedBlockId?: string + }> = [] + + const hostedModelsLower = isHosted ? new Set(getHostedModels().map((m) => m.toLowerCase())) : null + + /** + * Collect credential inputs from a block's inputs based on its block config + */ + function collectCredentialInputs( + blockConfig: ReturnType, + inputs: Record, + opIndex: number, + blockId: string, + blockType: string, + nestedBlockId?: string + ) { + if (!blockConfig) return + + for (const subBlockConfig of blockConfig.subBlocks) { + if (subBlockConfig.type !== 'oauth-input') continue + + const inputValue = inputs[subBlockConfig.id] + if (!inputValue || typeof inputValue !== 'string' || inputValue.trim() === '') continue + + credentialInputs.push({ + operationIndex: opIndex, + blockId, + blockType, + fieldName: subBlockConfig.id, + value: inputValue, + nestedBlockId, + }) + } + } + + /** + * Check if apiKey should be filtered for a block with the given model + */ + function collectHostedApiKeyInput( + inputs: Record, + modelValue: string | undefined, + opIndex: number, + blockId: string, + blockType: string, + nestedBlockId?: string + ) { + if (!hostedModelsLower || !inputs.apiKey) return + if (!modelValue || typeof modelValue !== 'string') return + + if (hostedModelsLower.has(modelValue.toLowerCase())) { + hostedApiKeyInputs.push({ + operationIndex: opIndex, + blockId, + blockType, + model: modelValue, + nestedBlockId, + }) + } + } + + operations.forEach((op, opIndex) => { + // Process main block inputs + if (op.params?.inputs && op.params?.type) { + const blockConfig = getBlock(op.params.type) + if (blockConfig) { + // Collect credentials from main block + collectCredentialInputs( + blockConfig, + op.params.inputs as Record, + opIndex, + op.block_id, + op.params.type + ) + + // Check for apiKey inputs on hosted models + let modelValue = (op.params.inputs as Record).model as string | undefined + + // For edit operations, if model is not being changed, check existing block's model + if ( + !modelValue && + op.operation_type === 'edit' && + (op.params.inputs as Record).apiKey && + workflowState + ) { + const existingBlock = (workflowState.blocks as Record)?.[op.block_id] as + | Record + | undefined + const existingSubBlocks = existingBlock?.subBlocks as Record | undefined + const existingModelSubBlock = existingSubBlocks?.model as + | Record + | undefined + modelValue = existingModelSubBlock?.value as string | undefined + } + + collectHostedApiKeyInput( + op.params.inputs as Record, + modelValue, + opIndex, + op.block_id, + op.params.type + ) + } + } + + // Process nested nodes (blocks inside loop/parallel containers) + const nestedNodes = op.params?.nestedNodes as + | Record> + | undefined + if (nestedNodes) { + Object.entries(nestedNodes).forEach(([childId, childBlock]) => { + const childType = childBlock.type as string | undefined + const childInputs = childBlock.inputs as Record | undefined + if (!childType || !childInputs) return + + const childBlockConfig = getBlock(childType) + if (!childBlockConfig) return + + // Collect credentials from nested block + collectCredentialInputs( + childBlockConfig, + childInputs, + opIndex, + op.block_id, + childType, + childId + ) + + // Check for apiKey inputs on hosted models in nested block + const modelValue = childInputs.model as string | undefined + collectHostedApiKeyInput(childInputs, modelValue, opIndex, op.block_id, childType, childId) + }) + } + }) + + const hasCredentialsToValidate = credentialInputs.length > 0 + const hasHostedApiKeysToFilter = hostedApiKeyInputs.length > 0 + + if (!hasCredentialsToValidate && !hasHostedApiKeysToFilter) { + return { filteredOperations: operations, errors } + } + + // Deep clone operations so we can modify them + const filteredOperations = structuredClone(operations) + + // Filter out apiKey inputs for hosted models and add validation errors + if (hasHostedApiKeysToFilter) { + logger.info('Filtering apiKey inputs for hosted models', { count: hostedApiKeyInputs.length }) + + for (const apiKeyInput of hostedApiKeyInputs) { + const op = filteredOperations[apiKeyInput.operationIndex] + + // Handle nested block apiKey filtering + if (apiKeyInput.nestedBlockId) { + const nestedNodes = op.params?.nestedNodes as + | Record> + | undefined + const nestedBlock = nestedNodes?.[apiKeyInput.nestedBlockId] + const nestedInputs = nestedBlock?.inputs as Record | undefined + if (nestedInputs?.apiKey) { + nestedInputs.apiKey = undefined + logger.debug('Filtered apiKey for hosted model in nested block', { + parentBlockId: apiKeyInput.blockId, + nestedBlockId: apiKeyInput.nestedBlockId, + model: apiKeyInput.model, + }) + + errors.push({ + blockId: apiKeyInput.nestedBlockId, + blockType: apiKeyInput.blockType, + field: 'apiKey', + value: '[redacted]', + error: `Cannot set API key for hosted model "${apiKeyInput.model}" - API keys are managed by the platform when using hosted models`, + }) + } + } else if (op.params?.inputs?.apiKey) { + // Handle main block apiKey filtering + op.params.inputs.apiKey = undefined + logger.debug('Filtered apiKey for hosted model', { + blockId: apiKeyInput.blockId, + model: apiKeyInput.model, + }) + + errors.push({ + blockId: apiKeyInput.blockId, + blockType: apiKeyInput.blockType, + field: 'apiKey', + value: '[redacted]', + error: `Cannot set API key for hosted model "${apiKeyInput.model}" - API keys are managed by the platform when using hosted models`, + }) + } + } + } + + // Validate credential inputs + if (hasCredentialsToValidate) { + logger.info('Pre-validating credential inputs', { + credentialCount: credentialInputs.length, + userId: context.userId, + }) + + const allCredentialIds = credentialInputs.map((c) => c.value) + const validationResult = await validateSelectorIds('oauth-input', allCredentialIds, context) + const invalidSet = new Set(validationResult.invalid) + + if (invalidSet.size > 0) { + for (const credInput of credentialInputs) { + if (!invalidSet.has(credInput.value)) continue + + const op = filteredOperations[credInput.operationIndex] + + // Handle nested block credential removal + if (credInput.nestedBlockId) { + const nestedNodes = op.params?.nestedNodes as + | Record> + | undefined + const nestedBlock = nestedNodes?.[credInput.nestedBlockId] + const nestedInputs = nestedBlock?.inputs as Record | undefined + if (nestedInputs?.[credInput.fieldName]) { + delete nestedInputs[credInput.fieldName] + logger.info('Removed invalid credential from nested block', { + parentBlockId: credInput.blockId, + nestedBlockId: credInput.nestedBlockId, + field: credInput.fieldName, + invalidValue: credInput.value, + }) + } + } else if (op.params?.inputs?.[credInput.fieldName]) { + // Handle main block credential removal + delete op.params.inputs[credInput.fieldName] + logger.info('Removed invalid credential from operation', { + blockId: credInput.blockId, + field: credInput.fieldName, + invalidValue: credInput.value, + }) + } + + const warningInfo = validationResult.warning ? `. ${validationResult.warning}` : '' + const errorBlockId = credInput.nestedBlockId ?? credInput.blockId + errors.push({ + blockId: errorBlockId, + blockType: credInput.blockType, + field: credInput.fieldName, + value: credInput.value, + error: `Invalid credential ID "${credInput.value}" - credential does not exist or user doesn't have access${warningInfo}`, + }) + } + + logger.warn('Filtered out invalid credentials', { + invalidCount: invalidSet.size, + }) + } + } + + return { filteredOperations, errors } +} diff --git a/apps/sim/lib/copilot/tools/server/workflow/get-workflow-console.ts b/apps/sim/lib/copilot/tools/server/workflow/get-workflow-console.ts index 06cfb1c82..e71496cc4 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/get-workflow-console.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/get-workflow-console.ts @@ -96,6 +96,7 @@ function normalizeErrorMessage(errorValue: unknown): string | undefined { try { return String(errorValue) } catch { + // JSON.stringify failed for error value; fall back to undefined return undefined } } diff --git a/apps/sim/lib/workflows/blocks/index.ts b/apps/sim/lib/workflows/blocks/index.ts new file mode 100644 index 000000000..878f92747 --- /dev/null +++ b/apps/sim/lib/workflows/blocks/index.ts @@ -0,0 +1,2 @@ +export { BlockSchemaResolver, blockSchemaResolver } from './schema-resolver' +export type { ResolvedBlock, ResolvedSubBlock, ResolvedOption, ResolvedOutput } from './schema-types' diff --git a/apps/sim/lib/workflows/blocks/schema-resolver.ts b/apps/sim/lib/workflows/blocks/schema-resolver.ts new file mode 100644 index 000000000..3a340a5ad --- /dev/null +++ b/apps/sim/lib/workflows/blocks/schema-resolver.ts @@ -0,0 +1,201 @@ +import { createLogger } from '@sim/logger' +import { getAllBlocks, getBlock } from '@/blocks/registry' +import type { BlockConfig, SubBlockConfig } from '@/blocks/types' +import type { ResolvedBlock, ResolvedOption, ResolvedOutput, ResolvedSubBlock } from './schema-types' + +const logger = createLogger('BlockSchemaResolver') + +/** + * BlockSchemaResolver provides typed access to block configurations. + * + * It wraps the raw block registry and returns resolved, typed schemas + * that consumers can use without any type assertions. + */ +export class BlockSchemaResolver { + private cache = new Map() + + /** Resolve a single block by type */ + resolveBlock(type: string): ResolvedBlock | null { + const cached = this.cache.get(type) + if (cached) return cached + + const config = getBlock(type) + if (!config) return null + + const resolved = this.buildResolvedBlock(config) + this.cache.set(type, resolved) + return resolved + } + + /** Resolve all available blocks */ + resolveAllBlocks(options?: { includeHidden?: boolean }): ResolvedBlock[] { + const configs = getAllBlocks() + return configs + .filter((config) => options?.includeHidden || !config.hideFromToolbar) + .map((config) => this.resolveBlock(config.type)) + .filter((block): block is ResolvedBlock => block !== null) + } + + /** Clear the cache (call when block registry changes) */ + clearCache(): void { + this.cache.clear() + } + + private buildResolvedBlock(config: BlockConfig): ResolvedBlock { + return { + type: config.type, + name: config.name, + description: config.description, + category: config.category, + icon: config.icon as unknown as ResolvedBlock['icon'], + isTrigger: this.isTriggerBlock(config), + hideFromToolbar: config.hideFromToolbar ?? false, + subBlocks: config.subBlocks.map((subBlock) => this.resolveSubBlock(subBlock)), + outputs: this.resolveOutputs(config), + supportsTriggerMode: this.supportsTriggerMode(config), + hasAdvancedMode: config.subBlocks.some((subBlock) => subBlock.mode === 'advanced'), + raw: config, + } + } + + private resolveSubBlock(sb: SubBlockConfig): ResolvedSubBlock { + const resolved: ResolvedSubBlock = { + id: sb.id, + type: sb.type, + label: sb.title, + placeholder: sb.placeholder, + required: typeof sb.required === 'boolean' ? sb.required : undefined, + password: sb.password, + hasCondition: Boolean(sb.condition), + defaultValue: sb.defaultValue, + validation: { + min: sb.min, + max: sb.max, + pattern: this.resolvePattern(sb), + }, + } + + const condition = this.resolveCondition(sb) + if (condition) { + resolved.condition = condition + } + + const options = this.resolveOptions(sb) + if (options.length > 0) { + resolved.options = options + } + + if (!resolved.validation?.min && !resolved.validation?.max && !resolved.validation?.pattern) { + delete resolved.validation + } + + return resolved + } + + private resolveCondition(sb: SubBlockConfig): ResolvedSubBlock['condition'] | undefined { + try { + const condition = typeof sb.condition === 'function' ? sb.condition() : sb.condition + if (!condition || typeof condition !== 'object') { + return undefined + } + + return { + field: String(condition.field), + value: condition.value, + } + } catch (error) { + logger.warn('Failed to resolve sub-block condition', { + subBlockId: sb.id, + error: error instanceof Error ? error.message : String(error), + }) + return undefined + } + } + + private resolveOptions(sb: SubBlockConfig): ResolvedOption[] { + try { + if (Array.isArray(sb.options)) { + return sb.options.map((opt) => { + if (typeof opt === 'string') { + return { label: opt, value: opt } + } + + const label = String(opt.label || opt.id || '') + const value = String(opt.id || opt.label || '') + + return { + label, + value, + id: opt.id, + } + }) + } + + // For function-based or dynamic options, return empty. + // Consumers can evaluate these options if they need runtime resolution. + return [] + } catch (error) { + logger.warn('Failed to resolve sub-block options', { + subBlockId: sb.id, + error: error instanceof Error ? error.message : String(error), + }) + return [] + } + } + + private resolveOutputs(config: BlockConfig): ResolvedOutput[] { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const blockOutputs = require('@/lib/workflows/blocks/block-outputs') as { + getBlockOutputPaths: ( + blockType: string, + subBlocks?: Record, + triggerMode?: boolean + ) => string[] + } + + const paths = blockOutputs.getBlockOutputPaths(config.type, {}, false) + return paths.map((path) => ({ + name: path, + type: 'string', + })) + } catch (error) { + logger.warn('Failed to resolve block outputs, using fallback', { + blockType: config.type, + error: error instanceof Error ? error.message : String(error), + }) + return [{ name: 'result', type: 'string' }] + } + } + + private isTriggerBlock(config: BlockConfig): boolean { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const triggerUtils = require('@/lib/workflows/triggers/input-definition-triggers') as { + isInputDefinitionTrigger: (blockType: string) => boolean + } + return triggerUtils.isInputDefinitionTrigger(config.type) + } catch (error) { + logger.warn('Failed to detect trigger block, using fallback', { + blockType: config.type, + error: error instanceof Error ? error.message : String(error), + }) + return config.type === 'starter' + } + } + + private supportsTriggerMode(config: BlockConfig): boolean { + return Boolean( + config.triggerAllowed || + config.subBlocks.some((subBlock) => subBlock.id === 'triggerMode' || subBlock.mode === 'trigger') + ) + } + + private resolvePattern(sb: SubBlockConfig): string | undefined { + const maybePattern = (sb as SubBlockConfig & { pattern?: string }).pattern + return typeof maybePattern === 'string' ? maybePattern : undefined + } +} + +/** Singleton resolver instance */ +export const blockSchemaResolver = new BlockSchemaResolver() diff --git a/apps/sim/lib/workflows/blocks/schema-types.ts b/apps/sim/lib/workflows/blocks/schema-types.ts new file mode 100644 index 000000000..068b3b937 --- /dev/null +++ b/apps/sim/lib/workflows/blocks/schema-types.ts @@ -0,0 +1,75 @@ +import type { LucideIcon } from 'lucide-react' + +/** A fully resolved block schema with all sub-blocks expanded */ +export interface ResolvedBlock { + type: string + name: string + description?: string + category: string + icon?: LucideIcon + isTrigger: boolean + hideFromToolbar: boolean + + /** Resolved sub-blocks with options, conditions, and validation info */ + subBlocks: ResolvedSubBlock[] + + /** Block-level outputs */ + outputs: ResolvedOutput[] + + /** Whether this block supports trigger mode */ + supportsTriggerMode: boolean + + /** Whether this block has advanced mode */ + hasAdvancedMode: boolean + + /** Raw config reference for consumers that need it */ + raw: unknown +} + +/** A resolved sub-block with options and metadata */ +export interface ResolvedSubBlock { + id: string + type: string + label?: string + placeholder?: string + required?: boolean + password?: boolean + + /** Resolved options (for dropdowns/selectors, etc.) */ + options?: ResolvedOption[] + + /** Whether this sub-block has a condition that controls visibility */ + hasCondition: boolean + + /** Condition details if present */ + condition?: { + field: string + value: unknown + /** Whether condition is currently met (if evaluable statically) */ + met?: boolean + } + + /** Validation constraints */ + validation?: { + min?: number + max?: number + pattern?: string + } + + /** Default value */ + defaultValue?: unknown +} + +/** A resolved option for dropdowns/selectors */ +export interface ResolvedOption { + label: string + value: string + id?: string +} + +/** A resolved output definition */ +export interface ResolvedOutput { + name: string + type: string + description?: string +}