import { type AgentJsonConfig, EVAL_VENDOR_SDK_INTERCEPTION_FLAG, type InstanceAiEvalExecutionRequest, type InstanceAiEvalInterceptedRequest, type InstanceAiEvalNodeResult, type InstanceAiEvalExecutionResult, } from '@n8n/api-types'; import { Logger, ModuleRegistry } from '@n8n/backend-common'; import { ensureHostsBypassProxy } from '@n8n/backend-network/proxy'; import { ExecutionsConfig, InstanceAiConfig } from '@n8n/config'; import { ProcessedDataRepository, type User } from '@n8n/db'; import { Service } from '@n8n/di'; import { sleep } from '@n8n/utils/sleep'; import type { DataTableColumnInfo, WorkflowJSON } from '@n8n/workflow-sdk'; import { normalizePinData } from '@n8n/workflow-sdk'; import { BinaryDataService, type EvalLlmMockHandler, type EvalMockHttpResponse, synthesizeBinaryFixture, WorkflowHasIssuesError, } from 'n8n-core'; import { type IBinaryData, type IBinaryKeyData, type IDataObject, type IHttpRequestOptions, type INode, type INodeExecutionData, type INodeParameters, type IPinData, type IRun, type IRunExecutionData, type IWorkflowBase, type IWorkflowExecuteAdditionalData, type IWorkflowExecutionDataProcess, createRunExecutionData, fileTypeFromMimeType, MANUAL_TRIGGER_NODE_TYPE, NodeHelpers, TimeoutExecutionCancelledError, UnexpectedError, UserError, Workflow, type IWorkflowIssues, } from 'n8n-workflow'; import { randomUUID } from 'node:crypto'; import { ActiveExecutions } from '@/active-executions'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import type { AgentRuntimeInstrumentation, PrepareWorkflowAgentForEval, } from '@/modules/agents/agent-runtime-instrumentation'; import { DataTableService } from '@/modules/data-table/data-table.service'; import { NodeTypes } from '@/node-types'; import { PostHogClient } from '@/posthog'; import { OwnershipService } from '@/services/ownership.service'; import { executeAgent } from '@/workflow-execute-additional-data'; import { WorkflowRunner } from '@/workflow-runner'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service'; import { pruneConfigForEval, resolveCanonicalMcpCatalogs } from './agent-execution.service'; import { createLlmCompletionMockHandler } from './llm-completion-mock'; import { EvalMockedCredentialsHelper } from './eval-mocked-credentials-helper'; import { createMcpMockFetch } from './mcp-mock-fetch'; import { createWebSearchMock } from './web-search-mock'; import { EvalTimings } from './eval-timings'; import { snapshotLedgerBody } from './ledger-snapshot'; import { type InterceptedTurn, LlmWireServer } from './llm-wire-server'; import { createLlmMockHandler } from './mock-handler'; import { extractResponsesRequestModel, isOpenAiResponsesUrl, normalizeOpenAiResponsesMockResponse, } from './openai-responses-envelope'; import { applyDataTableReadParameters } from './data-table-pin-filter'; import { generatePinData } from './pin-data-generator'; import { buildVendorLlmRouting, detectBinaryDependencies, emitsDataTableRows, generateMockHints, identifyNodesForHints, identifyNodesForPinData, isDataTableRead, type MockHints, partitionAiRoots, type TriggerBinaryRequirement, type VendorLlmRouting, } from './workflow-analysis'; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- /** Max output items per branch kept in the artifact. The full count lives in `outputCount`. */ const MAX_OUTPUT_ITEMS_PER_BRANCH = 10; /** A caller's run budget as a wall-clock deadline; setup counts against it, so * the run gets what is left. `totalMs` is kept for the message. */ interface RunBudget { totalMs: number; deadlineAt: number; } type CalledAgentMocks = Pick; /** What the agents a workflow calls share during one eval run. */ interface CalledAgentRun { mockHandler: EvalLlmMockHandler; nodeResults: Record; timings: EvalTimings; hints: MockHints; scenarioHints?: string; credentialsHelper: EvalMockedCredentialsHelper; /** MCP and web-search fakes by calling node and agent config. */ mocks: Map>; /** Agent sessions this run has already called. */ sessions: Set; } /** A Data Table node's locator: the id it carries, or the name when it is in `name` mode. */ function dataTableLocator(node: INode): { mode: 'name' | 'id'; value: string } | undefined { const locator = node.parameters?.dataTableId as | { mode?: unknown; value?: unknown } | string | undefined; const value = typeof locator === 'string' ? locator : locator?.value; if (typeof value !== 'string' || value.length === 0) return undefined; const byName = typeof locator !== 'string' && locator?.mode === 'name'; return { mode: byName ? 'name' : 'id', value }; } // --------------------------------------------------------------------------- // Service // --------------------------------------------------------------------------- // Executes workflows with LLM-based HTTP mocking. Phase 1 generates per-node // mock hints (one LLM call); Phase 2 runs the workflow with a per-execution // mock handler — additionalData is fresh, no global state mutated. @Service() export class EvalExecutionService { constructor( private readonly workflowFinderService: WorkflowFinderService, private readonly nodeTypes: NodeTypes, private readonly logger: Logger, private readonly postHogClient: PostHogClient, private readonly workflowRunner: WorkflowRunner, private readonly activeExecutions: ActiveExecutions, private readonly executionsConfig: ExecutionsConfig, private readonly binaryDataService: BinaryDataService, private readonly workflowStaticDataService: WorkflowStaticDataService, private readonly loadNodesAndCredentials: LoadNodesAndCredentials, private readonly ownershipService: OwnershipService, private readonly dataTableService: DataTableService, private readonly processedDataRepository: ProcessedDataRepository, private readonly instanceAiConfig: InstanceAiConfig, private readonly moduleRegistry: ModuleRegistry, ) {} async executeWithLlmMock( workflowId: string, user: User, options: InstanceAiEvalExecutionRequest = {}, ): Promise { // Anchored at receipt, before any setup: setup is LLM calls, so a budget // anchored later would expire after the caller's own deadline. const budget: RunBudget | undefined = options.timeoutMs === undefined ? undefined : { totalMs: options.timeoutMs, deadlineAt: Date.now() + options.timeoutMs }; // Eval routes through WorkflowRunner with a configureAdditionalData closure // that doesn't survive queue serialization. Refuse upfront so vendor calls // can never leak to real providers from a worker that never wires the mock. if (this.executionsConfig.mode === 'queue') { return this.errorResult( randomUUID(), 'Eval execution requires main process mode — queue mode is not supported.', ); } // Retry transient lookup misses from concurrent eval runs. let workflowEntity = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [ 'workflow:execute', ]); if (!workflowEntity) { for (const delayMs of [200, 500, 1000]) { await sleep(delayMs); workflowEntity = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [ 'workflow:execute', ]); if (workflowEntity) break; } } if (!workflowEntity) { return this.errorResult(randomUUID(), `Workflow ${workflowId} not found or not accessible`); } // Partition AI roots into "intercept via wire server" vs "leave pinned". // Default-on: every root with compatible sub-nodes gets intercepted; // callers can opt specific roots out via `pinNodes` (e.g. for A/B // comparison). Roots whose sub-nodes are incompatible auto-pin. let partitioned: ReturnType; try { partitioned = partitionAiRoots(workflowEntity, options.pinNodes ?? []); } catch (error) { if (error instanceof UserError) { return this.errorResult(randomUUID(), error.message); } throw error; } for (const entry of partitioned.autoPinned) { this.logger.debug( `[EvalMock] Auto-pinning AI root "${entry.root}" — sub-node "${entry.subNode}" (${entry.subNodeType}) is ${entry.reason}`, ); } // Kill-switch: when interception is disabled, every root falls back to // the pinned path regardless of partition or explicit `pinNodes`. let interceptionEnabled = false; let unpinNodes = partitioned.unpinNodes; if (unpinNodes.length > 0) { interceptionEnabled = await this.isInterceptionEnabled(user); if (!interceptionEnabled) { this.logger.warn( '[EvalMock] Vendor SDK interception disabled by kill-switch — pinning all AI roots', ); unpinNodes = []; } } const unpinSet = unpinNodes.length > 0 ? new Set(unpinNodes) : undefined; const timings = new EvalTimings(); let hints: MockHints; try { hints = await this.analyzeWorkflow( workflowEntity, timings, options.scenarioHints, unpinSet, options.seededDataTableIds, ); } catch (error) { const message = error instanceof Error ? error.message : String(error); return this.errorResult( randomUUID(), message.startsWith('FRAMEWORK ISSUE:') ? message : `FRAMEWORK ISSUE: ${message}`, ); } const vendorLlmRouting = interceptionEnabled ? buildVendorLlmRouting(workflowEntity, unpinNodes) : undefined; return await this.execute( workflowEntity, user, hints, timings, options.scenarioHints, interceptionEnabled, vendorLlmRouting, budget, ); } // Default-on kill-switch: unset → enabled, explicit `false` → disabled, resolution error → disabled. private async isInterceptionEnabled(user: User): Promise { try { const flags = await this.postHogClient.getFeatureFlags(user); return flags?.[EVAL_VENDOR_SDK_INTERCEPTION_FLAG] !== false; } catch (error) { this.logger.warn('[EvalMock] Failed to resolve vendor-SDK interception flag', { error: error instanceof Error ? error.message : String(error), }); return false; } } // ── Phase 1: Workflow analysis ───────────────────────────────────────── private async analyzeWorkflow( workflowEntity: IWorkflowBase, timings: EvalTimings, scenarioHints?: string, unpinSet?: Set, seededDataTableIds?: string[], ): Promise { // Phase 1: Generate mock hints for HTTP-interceptible nodes const hintNodes = identifyNodesForHints(workflowEntity); const nodeNames = hintNodes.map((n) => n.name); this.logger.debug( `[EvalMock] Generating hints for ${nodeNames.length} nodes: ${nodeNames.join(', ')}`, ); const hints = await timings.time( 'hints', undefined, async () => await generateMockHints({ workflow: workflowEntity, nodeNames, scenarioHints, defaultStartNodeName: this.findStartNode(this.buildWorkflow(workflowEntity))?.name, }), ); // A trigger pinned without content runs with no items and blames every downstream miss on the builder. // A Manual Trigger is the exception: it emits one empty item (see buildTriggerPinData). const triggerStart = this.triggerStartNode(workflowEntity, hints); if ( triggerStart && triggerStart.type !== MANUAL_TRIGGER_NODE_TYPE && lacksTriggerContent(hints) ) { throw new Error( `FRAMEWORK ISSUE: Phase 1 produced no trigger content for start node "${triggerStart.name}" (${hints.warnings.join('; ') || 'no details'}); the scenario cannot run without a trigger event`, ); } if (!hints.globalContext && nodeNames.length > 0) { this.logger.warn( '[EvalMock] Phase 1 hint generation returned empty — mock responses will lack cross-node consistency', ); } this.logger.debug( `[EvalMock] Phase 1 result — globalContext: ${hints.globalContext ? 'present' : 'EMPTY'}, triggerContent keys: ${JSON.stringify(Object.keys(hints.triggerContent))}, nodeHints: ${Object.keys(hints.nodeHints).join(', ')}`, ); // Phase 1.5: Generate pin data for nodes that bypass the HTTP mock layer const liveReads = await this.liveDataTableReads(workflowEntity, seededDataTableIds); if (liveReads.size > 0) { this.logger.debug(`[EvalMock] Reading seeded Data Tables live: ${[...liveReads].join(', ')}`); } const bypassNodes = identifyNodesForPinData(workflowEntity, unpinSet, liveReads); const bypassNodeNames = bypassNodes.map((n) => n.name); if (bypassNodeNames.length > 0) { this.logger.debug( `[EvalMock] Generating pin data for ${bypassNodeNames.length} bypass nodes: ${bypassNodeNames.join(', ')}`, ); hints.bypassPinData = await this.generateBypassPinData( workflowEntity, bypassNodeNames, hints.globalContext, timings, hints.warnings, scenarioHints, ); this.logger.debug( `[EvalMock] Phase 1.5 result — pinned nodes: ${Object.keys(hints.bypassPinData).join(', ') || 'none'}`, ); } return hints; } // ── Phase 1.5: Pin data for bypass nodes ───────────────────────────── /** * Generate pin data for nodes that bypass the HTTP mock layer. * Uses the existing LLM-based pin data generator with Phase 1's globalContext * for cross-node data consistency. */ private async generateBypassPinData( workflowEntity: IWorkflowBase, bypassNodeNames: string[], globalContext: string, timings: EvalTimings, warnings: string[], scenarioHints?: string, ): Promise { if (bypassNodeNames.length === 0) return {}; try { const dataTableColumns = await this.resolveDataTableColumns(workflowEntity, bypassNodeNames); // Keep the scenario separate from the general context: the pin generator // treats "Test Scenario" as authoritative, and merging them into one blob // lets invented context override scenario-specified stored state. const result = await timings.time( 'bypass-pin', undefined, async () => await generatePinData({ workflow: workflowEntity as unknown as WorkflowJSON, nodeNames: bypassNodeNames, instructions: globalContext || scenarioHints ? { dataDescription: globalContext, testScenario: scenarioHints } : undefined, outputSchemaLookup: this.loadNodesAndCredentials.createOutputSchemaLookup(), dataTableColumns, }), ); const normalized = normalizePinData(result as unknown as IPinData); // A MISSING bypass entry would let the node execute for real — for AI // roots the vendor SDK then makes real network calls (observed in CI: // un-mocked Anthropic request → "Authorization failed"). An EMPTY array // is different: the execution engine honors it as "pinned, zero items" // (presence check, not length), and zero-item scenario premises depend // on it — never replace [] with a phantom item (TRUST-343). for (const nodeName of bypassNodeNames) { if (!normalized[nodeName]) { this.logger.warn( `[EvalMock] Phase 1.5 produced no pin data for bypass node "${nodeName}" — pinning empty to prevent real execution`, ); normalized[nodeName] = []; } } const bypassSet = new Set(bypassNodeNames); for (const node of workflowEntity.nodes) { if (!bypassSet.has(node.name) || !emitsDataTableRows(node)) continue; const filtered = applyDataTableReadParameters(node, normalized[node.name]); normalized[node.name] = filtered.items; for (const warning of filtered.warnings) this.logger.warn(`[EvalMock] ${warning}`); warnings.push(...filtered.flags); } return normalized; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); this.logger.error(`[EvalMock] Phase 1.5 pin data generation failed: ${errorMsg}`); throw new Error(`FRAMEWORK ISSUE: Phase 1.5 pin data generation failed: ${errorMsg}`); } } /** * Real column names for each pinned dataTable-read node, read from the * builder-created table itself. They are the authoritative row shape — * without them the pin generator invents plausible-but-wrong column names * (`email` where the table says `contact_email`) and correctly-built * downstream expressions resolve undefined. Best-effort: a missing table or * unresolved id degrades that node to prompt-only generation. * * Only row-emitting reads qualify — `rowExists`/`rowNotExists` pass the input * item through, so enforcing table columns on them would demand a fixture the * real node never emits. */ private async resolveDataTableColumns( workflowEntity: IWorkflowBase, bypassNodeNames: string[], ): Promise | undefined> { const bypassSet = new Set(bypassNodeNames); const readNodes = workflowEntity.nodes.filter( (node) => bypassSet.has(node.name) && emitsDataTableRows(node), ); if (readNodes.length === 0) return undefined; const columnsByNode: Record = {}; let projectId: string | undefined; for (const node of readNodes) { try { projectId ??= (await this.ownershipService.getWorkflowProjectCached(workflowEntity.id)).id; const tableId = await this.resolveDataTableNodeId(node, projectId); if (!tableId) { this.logger.warn( `[EvalMock] No Data Table found for node "${node.name}" — pinned rows fall back to prompt-only generation`, ); continue; } const columns = await this.dataTableService.getColumns(tableId, projectId); columnsByNode[node.name] = columns.map(({ name, type }) => ({ name, type })); } catch (error) { this.logger.warn( `[EvalMock] Could not resolve Data Table columns for node "${node.name}" — pinned rows fall back to prompt-only generation`, { error: error instanceof Error ? error.message : String(error) }, ); } } return Object.keys(columnsByNode).length > 0 ? columnsByNode : undefined; } /** Data Table reads bound to a table the caller reseeded for this scenario. That * table holds the scenario's rows, so the read runs live and also sees the * writes the run makes before it. */ private async liveDataTableReads( workflowEntity: IWorkflowBase, seededDataTableIds: string[] | undefined, ): Promise> { const live = new Set(); if (!seededDataTableIds?.length) return live; const seeded = new Set(seededDataTableIds); // The node resolves a `name` locator case-insensitively at run time // (`LOWER(name) LIKE LOWER(:name)`), so the seeded tables are matched the // same way, or a read spelt in another case would stay pinned. const seededByLowerName = new Map(); for (const table of await this.dataTableService.findDataTablesByIds(seededDataTableIds)) { seededByLowerName.set(table.name.toLowerCase(), table.id); } for (const node of workflowEntity.nodes) { if (!isDataTableRead(node)) continue; const locator = dataTableLocator(node); if (!locator) continue; const tableId = locator.mode === 'name' ? seededByLowerName.get(locator.value.toLowerCase()) : locator.value; if (tableId !== undefined && seeded.has(tableId)) live.add(node.name); } return live; } /** The table a Data Table node binds, for the column shapes. `name` mode is * looked up in the project the way the node does, case-insensitively. */ private async resolveDataTableNodeId( node: INode, projectId: string, ): Promise { const locator = dataTableLocator(node); if (!locator) return undefined; if (locator.mode !== 'name') return locator.value; const matches = await this.dataTableService.findDataTablesByNamesInProject(projectId, [ locator.value, ]); const wanted = locator.value.toLowerCase(); return (matches.find((m) => m.name.toLowerCase() === wanted) ?? matches.at(0))?.id; } // ── Phase 2: Mock execution ──────────────────────────────────────────── private async execute( workflowEntity: IWorkflowBase, user: User, hints: MockHints, timings: EvalTimings, scenarioHints?: string, interceptionEnabled = false, vendorLlmRouting?: VendorLlmRouting, /** Caller's budget; unbounded when omitted. See awaitRunWithinBudget. */ budget?: RunBudget, ): Promise { // Null-prototype map: node names are the keys here and come from workflow // input, so a reserved name (`__proto__`, `constructor`, ...) must land as an // own key instead of resolving up the prototype chain. Without this, the // `nodeResults[name] ??= {}` + nested-write sinks below would assign onto // `Object.prototype`. const nodeResults: Record = Object.create(null); // Fill setup-pending resource locators BEFORE the first normalization pass: // Workflow construction runs getNodeParameters(returnNoneDisplayed=false), // which STRIPS params whose displayOptions depend on a selected resource // (e.g. Google Sheets `columns` vanishes while documentId/sheetName are // empty) — destroying the builder's full column mapping before the patcher // runs; the engine later re-adds such params from bare defaults and the // node crashes. Production never executes in this state — users complete // setup first — so filling here mirrors a completed setup. for (const node of workflowEntity.nodes) { if (node.disabled || !node.parameters) continue; fillSetupPendingResourceLocators(node.parameters); } // Time-based Wait nodes park the execution until a future timestamp // (specificTime) or sleep away the scenario budget (timeInterval), so // downstream nodes never run inside the eval window even when the built // workflow is correct. Zero them — execution order and branch structure // stay observable, and the verifier still sees the builder's original // wait config in the workflow JSON. Webhook/form-resume waits are left // untouched (they model an external event, not the passage of time). for (const node of workflowEntity.nodes) { if (node.disabled || node.type !== 'n8n-nodes-base.wait') continue; const resume = node.parameters?.resume; if (resume === 'webhook' || resume === 'form') continue; node.parameters = { ...node.parameters, resume: 'timeInterval', amount: 0, unit: 'seconds' }; } const workflow = this.buildWorkflow(workflowEntity); // Multi-trigger workflows: Phase 1 picks the trigger the scenario targets // (firing the wrong one leaves the scenario's branch dormant and fails it). const hintedStart = hints.startNodeName ? this.asTriggerNode(workflow.nodes[hints.startNodeName]) : undefined; const startNode = hintedStart ?? this.findStartNode(workflow); if (!startNode) { return this.errorResult(randomUUID(), 'No trigger or start node found in the workflow'); } // Aborted when the run ends: a stopped execution can still be looping inside a node. const mockAbort = new AbortController(); const mockHandler = createLlmMockHandler({ scenarioHints, globalContext: hints.globalContext, nodeHints: hints.nodeHints, pinnedOutputs: summarizePinnedOutputs(hints.bypassPinData), signal: mockAbort.signal, }); const binaryRequirement = detectBinaryDependencies(workflowEntity); const triggerPinData = this.buildTriggerPinData( startNode, hints.triggerContent, binaryRequirement, hints.triggerEmitsNoItems, ); const pinData: IPinData = { ...triggerPinData, ...hints.bypassPinData }; const pinDataNodeNames = Object.keys(pinData); // Patch setup-pending params first, THEN record remaining config issues. // Issues the patcher resolves (empty locators awaiting user setup, eval // placeholders) must not fail the scenario — collectConfigIssueErrors folds // recorded issues into errors and flips success:false, so recording // pre-patch state failed runs the harness had already made executable. // Genuinely unresolved misconfigurations are still recorded and still fail. this.patchParameterIssuesForEval(workflow, pinDataNodeNames); this.checkNodeConfig(workflow, nodeResults, pinDataNodeNames); // The engine drops a refused execution before the runner can await it, so report the refusal first. const blockingIssues = this.issuesBlockingRun(workflow, startNode, pinDataNodeNames); if (blockingIssues) { const reason = new WorkflowHasIssuesError(blockingIssues, workflow.nodes).message; this.logger.warn(`[EvalMock] Workflow cannot start: ${reason}`); return this.buildPartialFailureResult( randomUUID(), new Error(`n8n refused to start the workflow: ${reason}`), nodeResults, hints, undefined, ); } const executionData = this.buildExecutionData(startNode, pinData); // Mark the trigger node as pinned (it gets its output from pin data, not execution). if (Object.keys(triggerPinData).length > 0) { this.markNodeAsPinned(startNode.name, nodeResults); } for (const nodeName of Object.keys(hints.bypassPinData)) { this.markNodeAsPinned(nodeName, nodeResults); } // try/finally wraps boot so a throw never leaks the server or NO_PROXY patch. let wireServer: LlmWireServer | undefined; let restoreNoProxy: (() => void) | undefined; let credentialsHelper: EvalMockedCredentialsHelper | undefined; let dbExecutionId: string | undefined; try { let serverUrl: string | undefined; if (interceptionEnabled) { // Wire server mocks the agent's own model turns (not HTTP APIs) → LLM completion mock. const llmCompletionMockHandler = createLlmCompletionMockHandler({ scenarioHints, globalContext: hints.globalContext, nodeHints: hints.nodeHints, }); const timedAiTurnHandler: EvalLlmMockHandler = async (request, node) => await timings.time( 'ai-turn', node.type, async () => await llmCompletionMockHandler(request, node), ); wireServer = new LlmWireServer({ mockHandler: timedAiTurnHandler, rootToSubNode: vendorLlmRouting?.rootToSubNode, onIntercept: (turn) => this.recordWireServerTurn(turn, nodeResults), logger: this.logger, }); serverUrl = await wireServer.start(); restoreNoProxy = ensureHostsBypassProxy(['127.0.0.1', 'localhost']); this.logger.debug(`[EvalMock] Wire server listening at ${serverUrl}`); } const runData: IWorkflowExecutionDataProcess = { executionMode: 'evaluation', // Builder-verify runs persist staticData (e.g. dedup cursors); scenarios assume it starts empty. workflowData: { ...workflowEntity, staticData: undefined }, userId: user.id, executionData, pinData, configureAdditionalData: (additionalData: IWorkflowExecuteAdditionalData) => { const runCredentialsHelper = new EvalMockedCredentialsHelper( additionalData.credentialsHelper, serverUrl, this.logger, vendorLlmRouting?.subNodeToRoot, ); credentialsHelper = runCredentialsHelper; additionalData.credentialsHelper = runCredentialsHelper; additionalData.evalLlmMockHandler = this.createInterceptingHandler( mockHandler, nodeResults, timings, ); additionalData.executeAgent = this.createEvalExecuteAgent({ mockHandler, nodeResults, timings, hints, scenarioHints, credentialsHelper: runCredentialsHelper, mocks: new Map(), sessions: new Set(), }); }, }; // Builder-verify runs and earlier scenarios can leave Remove Duplicates keys behind. await this.clearDeduplicationState(workflowEntity.id); dbExecutionId = await this.workflowRunner.run(runData); const runResult = await this.awaitRunWithinBudget(dbExecutionId, budget); if (!runResult) { return this.buildPartialFailureResult( dbExecutionId, new Error('Execution finished with no run data'), nodeResults, hints, credentialsHelper, ); } return await this.buildResult( dbExecutionId, runResult, nodeResults, hints, credentialsHelper, ); } catch (error: unknown) { return this.buildPartialFailureResult( dbExecutionId ?? randomUUID(), error, nodeResults, hints, credentialsHelper, ); } finally { mockAbort.abort(); if (restoreNoProxy) restoreNoProxy(); if (wireServer) { try { await wireServer.stop(); } catch (error) { this.logger.warn('[EvalMock] Wire server teardown failed', { error: error instanceof Error ? error.message : String(error), }); } } await this.blankPersistedStaticData(workflowEntity.id); await this.clearDeduplicationState(workflowEntity.id); timings.summary(this.logger); } } /** * 'evaluation'-mode runs persist getWorkflowStaticData() writes back to the * workflow row — blank it after the run so scenarios leave no state behind. */ private async blankPersistedStaticData(workflowId: string): Promise { try { await this.workflowStaticDataService.saveStaticDataById(workflowId, {}); } catch (error) { this.logger.warn('[EvalMock] Failed to blank workflow staticData after run', { workflowId, error: error instanceof Error ? error.message : String(error), }); } } /** Remove Duplicates keeps the keys it has seen in processed_data, per workflow, * so they outlive the run; clear them so each scenario starts from its own state. */ private async clearDeduplicationState(workflowId: string): Promise { // Only an instance that declares itself an eval instance may erase a workflow's // history: on a normal instance the workflow is real and its cursors stay. if (!this.instanceAiConfig.evalInstance) return; try { await this.processedDataRepository.deleteForWorkflow(workflowId); } catch (error) { this.logger.warn('[EvalMock] Failed to clear workflow deduplication state', { workflowId, error: error instanceof Error ? error.message : String(error), }); } } // ── Workflow construction ────────────────────────────────────────────── private buildWorkflow(workflowEntity: IWorkflowBase): Workflow { return new Workflow({ id: workflowEntity.id, name: workflowEntity.name, nodes: workflowEntity.nodes, connections: workflowEntity.connections, active: false, nodeTypes: this.nodeTypes, staticData: workflowEntity.staticData, settings: workflowEntity.settings ?? {}, }); } /** * Find the workflow's trigger/start node. * Uses Workflow.getStartNode() first (handles trigger, poll, and STARTING_NODE_TYPES), * then falls back to checking for webhook nodes which getStartNode() doesn't cover. */ private findStartNode(workflow: Workflow): INode | undefined { return workflow.getStartNode() ?? this.findWebhookNode(workflow); } private triggerStartNode(workflowEntity: IWorkflowBase, hints: MockHints): INode | undefined { const hinted = hints.startNodeName ? workflowEntity.nodes.find((node) => node.name === hints.startNodeName) : undefined; return ( this.asTriggerNode(hinted) ?? this.asTriggerNode(this.findStartNode(this.buildWorkflow(workflowEntity))) ); } // Same rule as `WorkflowExecute.checkReadyForExecution`. private issuesBlockingRun( workflow: Workflow, startNode: INode, pinDataNodeNames: string[], ): IWorkflowIssues | null { const issues: IWorkflowIssues = {}; for (const nodeName of [...workflow.getChildNodes(startNode.name), startNode.name]) { const node = workflow.nodes[nodeName]; if (!node || node.disabled) continue; const nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); const nodeIssues = nodeType ? NodeHelpers.getNodeParametersIssues( nodeType.description.properties, node, nodeType.description, pinDataNodeNames, ) : { typeUnknown: true }; if (nodeIssues) issues[node.name] = nodeIssues; } return Object.keys(issues).length > 0 ? issues : null; } /** Accept a Phase-1 start-node hint only when it names a real, enabled trigger-capable node. */ private asTriggerNode(node: INode | undefined): INode | undefined { if (!node || node.disabled) return undefined; const nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); if (!nodeType) return undefined; const isTriggerCapable = 'trigger' in nodeType || 'poll' in nodeType || 'webhook' in nodeType; return isTriggerCapable ? node : undefined; } private findWebhookNode(workflow: Workflow): INode | undefined { return Object.values(workflow.nodes).find((node) => { if (node.disabled) return false; const nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); return nodeType !== undefined && 'webhook' in nodeType; }); } /** * Check each node for missing required parameters and record issues * in nodeResults. This runs before execution so the report shows * configuration completeness regardless of whether the node crashes. */ private checkNodeConfig( workflow: Workflow, nodeResults: Record, pinDataNodeNames: string[], ): void { for (const node of Object.values(workflow.nodes)) { if (node.disabled) continue; const nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); if (!nodeType) continue; const issues = NodeHelpers.getNodeParametersIssues( nodeType.description.properties, node, nodeType.description, pinDataNodeNames, ); const parameterIssues = { ...(issues?.parameters ?? {}) }; // '__evalMockValue' marks a required param the patcher could only blind-fill — still a genuine config issue. for (const [paramName, value] of Object.entries(node.parameters ?? {})) { if (value === '__evalMockValue') { parameterIssues[paramName] ??= [`Parameter "${paramName}" is required.`]; } } if (Object.keys(parameterIssues).length > 0) { const entry = (nodeResults[node.name] ??= { outputs: {}, outputCount: 0, iterationCount: 0, interceptedRequests: [], executionMode: 'real', }); entry.configIssues = parameterIssues; } } } /** * Keep recorded config issues, but patch eval-only placeholders and empty * params so one incomplete node does not stop the entire mocked execution. */ private patchParameterIssuesForEval(workflow: Workflow, pinDataNodeNames: string[]): void { for (const node of Object.values(workflow.nodes)) { if (node.disabled) continue; if (pinDataNodeNames.includes(node.name)) continue; if (node.parameters) { node.parameters = scrubPlaceholderValues(node.parameters) as INodeParameters; for (const change of patchSetupPendingResourceMappers(node.parameters)) { this.logger.info(`[EvalMock] resourceMapper patch on "${node.name}": ${change}`); } } const nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); if (!nodeType) continue; const issues = NodeHelpers.getNodeParametersIssues( nodeType.description.properties, node, nodeType.description, pinDataNodeNames, ); const paramIssues = issues?.parameters; if (!paramIssues || Object.keys(paramIssues).length === 0) continue; const params = node.parameters ?? {}; for (const paramName of Object.keys(paramIssues)) { params[paramName] = synthesizeMissingParamValue( params[paramName], paramName, ) as INodeParameters[string]; } node.parameters = params; } } // ── Execution data ──────────────────────────────────────────────────── /** * Build pin data for the trigger/start node from LLM-generated content. * Pin data provides the trigger's output — the node doesn't execute, * since trigger nodes receive external events that don't fire in eval mode. */ private buildTriggerPinData( startNode: INode, triggerContent: Record, binaryRequirement?: TriggerBinaryRequirement, triggerEmitsNoItems = false, ): IPinData { // A pinned empty array is "this node emitted nothing": downstream nodes stay idle, // which is the point of a "no new items" scenario. No pin at all would instead // start the trigger with one injected empty item. if (triggerEmitsNoItems) return { [startNode.name]: [] }; if (Object.keys(triggerContent).length === 0 && !binaryRequirement) { // A Manual Trigger's real output is one empty item. return startNode.type === MANUAL_TRIGGER_NODE_TYPE ? { [startNode.name]: [{ json: {} }] } : {}; } // Mirror any LLM-embedded binary map as real item-level binary; json stays // untouched so $json.binary.* references keep resolving. const embedded = readEmbeddedBinaryMeta(triggerContent); const item: INodeExecutionData = { json: triggerContent as IDataObject }; const binary: IBinaryKeyData = {}; // The embedded files are what the trigger emits; the requirement only fills in when they name none. const embeddedKeys = Object.keys(embedded); if ( binaryRequirement && (embeddedKeys.length === 0 || embeddedKeys.includes(binaryRequirement.propertyName)) ) { // Requirement wins for its key, except embedded meta beats the generic fallback. const embeddedMeta = embedded[binaryRequirement.propertyName]; const isGenericFallback = binaryRequirement.contentType === 'application/octet-stream' && binaryRequirement.filename === 'input.bin'; binary[binaryRequirement.propertyName] = synthesizeBinaryEntry( (isGenericFallback && embeddedMeta?.mimeType) || binaryRequirement.contentType, (isGenericFallback && embeddedMeta?.fileName) || binaryRequirement.filename, embeddedMeta?.text, ); } for (const [key, meta] of Object.entries(embedded)) { if (binary[key]) continue; binary[key] = synthesizeBinaryEntry( meta.mimeType ?? 'application/octet-stream', meta.fileName ?? 'input.bin', meta.text, ); } if (Object.keys(binary).length > 0) item.binary = binary; return { [startNode.name]: [item] }; } /** * Build execution data with the trigger node on the execution stack so * webhook-style triggers (which don't define `trigger`) get a known start. */ private buildExecutionData(startNode: INode, pinData: IPinData): IRunExecutionData { return createRunExecutionData({ startData: {}, resultData: { pinData, runData: {} }, executionData: { contextData: {}, metadata: {}, nodeExecutionStack: [ { node: startNode, data: { main: [[{ json: {} }]] }, source: null, }, ], waitingExecution: {}, waitingExecutionSource: {}, }, }); } // ── Request interception ───────────────────────────────────────────── /** * Record a wire-server model turn against the AI root in `nodeResults`. * Attribution mirrors `createInterceptingHandler` so vendor-SDK traffic * and HTTP-helper traffic land in the same ledger shape — downstream * consumers (eval UI, graders) don't need to special-case the two. */ private recordWireServerTurn( turn: InterceptedTurn, nodeResults: Record, ): void { const entry = (nodeResults[turn.rootName] ??= { outputs: {}, outputCount: 0, iterationCount: 0, interceptedRequests: [], executionMode: 'mocked', }); // Preserve a pre-set 'pinned' (bypass pass owns that classification); // otherwise the turn IS mocked, so upgrade from any other prior value // (e.g. 'real' from checkNodeConfig() pre-marking config-issue nodes). if (entry.executionMode !== 'pinned') { entry.executionMode = 'mocked'; } entry.interceptedRequests.push({ url: turn.url, method: turn.method, nodeType: turn.nodeType, requestBody: turn.requestBody, mockResponse: turn.mockResponse, }); this.logger.debug( `[EvalMock] Wire server intercepted ${turn.method} ${turn.url} attributed to root "${turn.rootName}"`, ); } /** * Replaces `executeAgent` for a run. `attributeTo` records the calls of an * agent that a called agent's tool runs under that called agent's node. */ private createEvalExecuteAgent( run: CalledAgentRun, attributeTo?: string, ): NonNullable { return async ( source, message, executionId, threadId, agentAdditionalData, mode, outputSchema, workflowContext, invocationContext, ) => { if (!workflowContext) { throw new UnexpectedError('An eval run cannot attribute an agent call without its node'); } const callingNode = attributeTo ?? workflowContext.callingNodeName; const session = `${source.agentId ?? `inline:${workflowContext.callingNodeName}`}:${threadId}`; const continuesSession = workflowContext.hasCallerSessionId === true && run.sessions.has(session); run.sessions.add(session); return await executeAgent( source, message, executionId, threadId, agentAdditionalData, mode, outputSchema, workflowContext, invocationContext, this.prepareCalledAgent(callingNode, run, continuesSession), ); }; } /** * Fakes the I/O of an agent the workflow calls, as the Agent eval does: tools, * MCP servers and web search are mocked; the model call stays real. */ private prepareCalledAgent( callingNode: string, run: CalledAgentRun, continuesSession: boolean, ): PrepareWorkflowAgentForEval { const warn = (feature: string, reason: string) => { const warning = `"${callingNode}" calls an Agent; the eval turned off its ${feature}: ${reason}`; if (!run.hints.warnings.includes(warning)) run.hints.warnings.push(warning); }; return async (original: AgentJsonConfig, { hasChatIntegrations } = {}) => { const { config, skippedFeatures } = pruneConfigForEval(original); if (hasChatIntegrations) { warn('chat integrations', 'an eval run cannot fake the chat SDKs they post through.'); } for (const { feature, reason } of skippedFeatures) { // Memory matters only to a call that continues an earlier session of this run. if (feature !== 'memory') warn(feature, reason); else if (continuesSession) warn(feature, 'this call does not see its earlier turns.'); } return { config, instrumentation: { ...(await this.calledAgentMocks(callingNode, config, run)), configureToolAdditionalData: (toolData) => { toolData.credentialsHelper = run.credentialsHelper; toolData.evalLlmMockHandler = this.createInterceptingHandler( run.mockHandler, run.nodeResults, run.timings, callingNode, ); toolData.executeAgent = this.createEvalExecuteAgent(run, callingNode); }, transformDelegatedAgentConfig: (childConfig, { subAgentId }) => { const pruned = pruneConfigForEval(childConfig); for (const { feature, reason } of pruned.skippedFeatures) { if (feature !== 'memory') warn(`sub-agent ${subAgentId}'s ${feature}`, reason); } // The MCP mock knows only the calling agent's servers; another server // would get a catalog invented without its own description. const mocked = new Set((config.mcpServers ?? []).map((server) => server.url)); const mcpServers = pruned.config.mcpServers?.filter((server) => { if (mocked.has(server.url)) return true; warn( `sub-agent ${subAgentId}'s MCP server "${server.name}"`, 'the eval mocks only the MCP servers of the agent the workflow calls.', ); return false; }); return mcpServers ? { ...pruned.config, mcpServers } : pruned.config; }, }, }; }; } /** One MCP and web-search fake per node and agent config, so repeated calls share one MCP catalog. */ private async calledAgentMocks( callingNode: string, config: AgentJsonConfig, run: CalledAgentRun, ): Promise { const mcpServers = config.mcpServers ?? []; const key = JSON.stringify([callingNode, config.instructions, mcpServers]); const cached = run.mocks.get(key); if (cached) return await cached; const record = (request: InstanceAiEvalInterceptedRequest) => { this.mockedEntry(run.nodeResults, callingNode).interceptedRequests.push(request); }; const mockContext = { agentInstructions: config.instructions, scenarioHints: run.scenarioHints, globalContext: run.hints.globalContext, logger: this.logger, }; const mocks = (async (): Promise => ({ mcpFetch: createMcpMockFetch({ ...mockContext, servers: mcpServers.map(({ name, url, description }) => ({ name, url, description })), knownToolsByServer: await resolveCanonicalMcpCatalogs( mcpServers, this.moduleRegistry, this.logger, ), onToolCall: (call) => record({ url: mcpServers.find((server) => server.name === call.serverName)?.url ?? call.serverName, method: 'POST', nodeType: `mcp:${call.serverName}`, requestBody: call.args, mockResponse: call.result, }), }), webSearch: createWebSearchMock({ ...mockContext, onSearch: (args, result) => record({ url: 'mock://web-search', method: 'POST', nodeType: 'web-search:fallback', requestBody: args, mockResponse: result, }), }), }))(); run.mocks.set(key, mocks); return await mocks; } /** A node's entry, marked mocked: `checkNodeConfig` may have pre-created it as 'real'. */ private mockedEntry( nodeResults: Record, nodeName: string, ): InstanceAiEvalNodeResult { const entry = (nodeResults[nodeName] ??= { outputs: {}, outputCount: 0, iterationCount: 0, interceptedRequests: [], executionMode: 'mocked', }); entry.executionMode = 'mocked'; return entry; } /** * Wraps the mock handler to collect intercepted request metadata for diagnostics. * `attributeTo` records a called agent's tool requests under the calling node. */ private createInterceptingHandler( mockHandler: EvalLlmMockHandler, nodeResults: Record, timings: EvalTimings, attributeTo?: string, ): EvalLlmMockHandler { return async ( requestOptions: IHttpRequestOptions, node: INode, ): Promise => { const entry = this.mockedEntry(nodeResults, attributeTo ?? node.name); let response = await timings.time( 'http-mock', attributeTo ?? node.name, async () => await mockHandler(requestOptions, node), ); // Responses-API calls from the openAi node bypass the wire-server // protocol adapters — coerce the generated body to the canonical // envelope so the node's real parser accepts it. if (response && response.statusCode < 400 && isOpenAiResponsesUrl(requestOptions.url)) { const normalized = normalizeOpenAiResponsesMockResponse( response, extractResponsesRequestModel(requestOptions.body), ); if (normalized !== response) { // Triage breadcrumb: the recorded mockResponse below is the coerced // body, not the generator's raw output. this.logger.debug( `[EvalMock] Applied Responses-envelope normalization for "${node.name}"`, ); } response = normalized; } entry.interceptedRequests.push({ // Broken routing (resource/operation missing on the node type) emits a // request with no URL — store a readable marker; the verifier prompt // and the HTML report both key on it, and undefined crashes the report. url: requestOptions.url ?? '(no URL)', method: requestOptions.method ?? 'GET', nodeType: node.type, requestBody: requestOptions.body, mockResponse: snapshotLedgerBody(response?.body), }); this.logger.debug( `[EvalMock] Intercepted ${requestOptions.method ?? 'GET'} ${requestOptions.url} from "${node.name}" (${node.type})`, ); return response; }; } /** * Mark a node entry as pinned, preserving any config issues that * `checkNodeConfig` may have already recorded on it. Used both for the * trigger node (which receives its output from `triggerPinData`) and for * each bypass node — the shape of the entry is identical, just the trigger * is gated by the trigger-has-content branch above. */ private markNodeAsPinned( nodeName: string, nodeResults: Record, ): void { const existing = nodeResults[nodeName]; nodeResults[nodeName] = { outputs: {}, outputCount: 0, iterationCount: 0, interceptedRequests: [], executionMode: 'pinned', ...(existing?.configIssues ? { configIssues: existing.configIssues } : {}), }; } /** * Await the execution, stopping it once the budget elapses. Eval mode skips * concurrency reservation (see `ActiveExecutions.add`), so nothing else would, * and an abandoned run keeps burning CPU on a shared instance. Omit to wait. */ private async awaitRunWithinBudget( executionId: string, budget: RunBudget | undefined, ): Promise { const postExecute = this.activeExecutions.getPostExecutePromise(executionId); if (!budget) return await postExecute; // Race loser: our timer's cancellation must not surface as unhandled. postExecute.catch(() => {}); // Clamped: setup may have eaten the budget, which stops the run at once. const remainingMs = Math.max(budget.deadlineAt - Date.now(), 0); const seconds = Math.round(budget.totalMs / 1000); const budgetError = () => new Error(`Execution exceeded its ${seconds}s eval budget and was stopped`); // `stopExecution` rejects the promise being raced, so report off this flag // rather than whichever arm won — else a budget stop reads as any other // cancellation. let stoppedForBudget = false; let deadline: NodeJS.Timeout | undefined; try { return await Promise.race([ postExecute, new Promise((_resolve, reject) => { deadline = setTimeout(() => { stoppedForBudget = true; this.logger.warn( `[EvalMock] Execution ${executionId} exceeded its ${seconds}s budget — stopping it`, ); // No try/catch: `stopExecution` returns early for an unknown id // rather than throwing, so an execution that finished between the // timer firing and this call needs no handling here. this.activeExecutions.stopExecution( executionId, new TimeoutExecutionCancelledError(executionId), ); reject(budgetError()); }, remainingMs); }), ]); } catch (error) { throw stoppedForBudget ? budgetError() : error; } finally { if (deadline) clearTimeout(deadline); } } /** * Build the failure result returned when execution threw partway through — * preserves the accumulated `nodeResults`, `hints`, and credential * diagnostics rather than discarding them like `errorResult` does. Lifted * out of the `execute()` catch block so the inline expression count there * stays within complexity bounds. */ private buildPartialFailureResult( executionId: string, error: unknown, nodeResults: Record, hints: MockHints, credentialsHelper: EvalMockedCredentialsHelper | undefined, ): InstanceAiEvalExecutionResult { const message = error instanceof Error ? error.message : String(error); this.logger.error(`[EvalMock] Workflow execution failed: ${message}`); return { executionId, success: false, nodeResults, errors: [`Execution failed: ${message}`], hints: withInterceptionGaps(hints, credentialsHelper), mockedCredentials: credentialsHelper?.mockedCredentials ?? [], rewrittenCredentials: credentialsHelper?.rewrittenCredentials ?? [], }; } // ── Result extraction ───────────────────────────────────────────────── /** * When binary data storage is filesystem/s3/db, `binary..data` is the * mode marker (e.g. `'filesystem-v2'`) and the actual bytes live behind * `binary..id`. Verifiers compare against the base64 payload, so read * the stored bytes back and inline them on a shallow copy. */ private async hydrateBinaryData(items: INodeExecutionData[]): Promise { return await Promise.all( items.map(async (item) => { if (!item.binary) return item; const hydratedBinary: IBinaryKeyData = {}; for (const [key, entry] of Object.entries(item.binary)) { if (entry.id) { try { const buffer = await this.binaryDataService.getAsBuffer(entry); hydratedBinary[key] = { ...entry, data: buffer.toString('base64') }; continue; } catch (error) { this.logger.warn( `[EvalMock] Failed to hydrate binary "${key}" (${entry.id}): ${error instanceof Error ? error.message : String(error)}`, ); } } hydratedBinary[key] = entry; } return { ...item, binary: hydratedBinary }; }), ); } private async buildResult( executionId: string, result: IRun, nodeResults: Record, hints: MockHints, credentialsHelper: EvalMockedCredentialsHelper | undefined, ): Promise { const errors: string[] = []; const runData = result.data?.resultData?.runData ?? {}; for (const [nodeName, nodeRuns] of Object.entries(runData)) { // Nodes already in nodeResults were intercepted (mocked) or pinned. // Nodes appearing here for the first time executed for real (logic nodes). const entry = (nodeResults[nodeName] ??= { outputs: {}, outputCount: 0, iterationCount: 0, interceptedRequests: [], executionMode: 'real', }); entry.iterationCount = nodeRuns.length; const firstErrorIdx = nodeRuns.findIndex((run) => run?.error !== undefined); if (firstErrorIdx !== -1) { entry.firstErrorIteration = firstErrorIdx; } const lastRun = nodeRuns[nodeRuns.length - 1]; if (lastRun?.startTime) { entry.startTime = lastRun.startTime; } if (lastRun?.data) { // Preserve per-connection-type, per-output-port structure so verifiers can // distinguish Filter/IF/Switch branches and AI sub-node outputs. let totalCount = 0; let truncated = false; const outputs: Record = {}; for (const [connectionType, branches] of Object.entries(lastRun.data)) { if (!Array.isArray(branches)) continue; outputs[connectionType] = await Promise.all( branches.map(async (branch) => { if (!Array.isArray(branch)) return []; totalCount += branch.length; let kept = branch; if (branch.length > MAX_OUTPUT_ITEMS_PER_BRANCH) { truncated = true; kept = branch.slice(0, MAX_OUTPUT_ITEMS_PER_BRANCH); } return await this.hydrateBinaryData(kept); }), ); } entry.outputs = outputs; entry.outputCount = totalCount; if (truncated) entry.truncated = true; } if (lastRun?.error) { errors.push(`Node "${nodeName}": ${lastRun.error.message}`); } } const executionError = result.data?.resultData?.error; if (executionError) { errors.push(`Workflow error: ${executionError.message}`); } const configIssueErrors = collectConfigIssueErrors(nodeResults); const allErrors = [...errors, ...configIssueErrors]; return { executionId, success: allErrors.length === 0, nodeResults, errors: allErrors, hints: withInterceptionGaps(hints, credentialsHelper), mockedCredentials: credentialsHelper?.mockedCredentials ?? [], rewrittenCredentials: credentialsHelper?.rewrittenCredentials ?? [], }; } private errorResult(executionId: string, message: string): InstanceAiEvalExecutionResult { return { executionId, success: false, nodeResults: {}, errors: [message], hints: { globalContext: '', triggerContent: {}, nodeHints: {}, warnings: [], bypassPinData: {}, }, mockedCredentials: [], rewrittenCredentials: [], }; } } function lacksTriggerContent(hints: MockHints): boolean { return !hints.triggerEmitsNoItems && Object.keys(hints.triggerContent).length === 0; } /** `warnings` is the channel the verification artifact renders as FRAMEWORK ISSUE flags. */ function withInterceptionGaps( hints: MockHints, credentialsHelper: EvalMockedCredentialsHelper | undefined, ): MockHints { const gaps = credentialsHelper?.interceptionGaps ?? []; if (gaps.length === 0) return hints; return { ...hints, warnings: [...hints.warnings, ...gaps] }; } /** Synthesize a structurally valid binary entry (real bytes, base64-inlined). */ function synthesizeBinaryEntry(contentType: string, filename: string, text?: string): IBinaryData { const bytes = synthesizeBinaryFixture(contentType, filename, { text }); const extension = filename.includes('.') ? filename.slice(filename.lastIndexOf('.') + 1) : 'bin'; return { mimeType: contentType, fileName: filename, fileExtension: extension, fileType: fileTypeFromMimeType(contentType.toLowerCase()), data: bytes.toString('base64'), }; } interface EmbeddedBinaryMeta { mimeType?: string; fileName?: string; /** The document's text, written into the synthesized file. */ text?: string; } function nonEmptyString(value: unknown): string | undefined { return typeof value === 'string' && value.trim().length > 0 ? value : undefined; } /** * Read an LLM-embedded binary map from the trigger json. Qualifies only when * EVERY entry under a top-level `binary` key carries real file metadata * (mime type or file name — a bare `name` alone doesn't count). */ function readEmbeddedBinaryMeta( triggerContent: Record, ): Record { const candidate = triggerContent.binary; if (candidate === null || typeof candidate !== 'object' || Array.isArray(candidate)) { return {}; } const entries = Object.entries(candidate as Record); if (entries.length === 0) return {}; const embedded: Record = {}; for (const [key, value] of entries) { if (value === null || typeof value !== 'object' || Array.isArray(value)) return {}; const v = value as Record; const mimeType = nonEmptyString(v.mimeType) ?? nonEmptyString(v.mimetype) ?? nonEmptyString(v.contentType); const strictFileName = nonEmptyString(v.fileName) ?? nonEmptyString(v.filename); if (!mimeType && !strictFileName) return {}; embedded[key] = { mimeType, // `name` is a fileName fallback, not qualification evidence. fileName: strictFileName ?? nonEmptyString(v.name), text: nonEmptyString(v.text), }; } return embedded; } const PLACEHOLDER_PREFIX = '<__PLACEHOLDER_VALUE__'; const PLACEHOLDER_SUFFIX = '__>'; function synthesizePlaceholderValue(hint: string): string { const h = hint.toLowerCase(); if (h.includes('email')) return 'eval-mock@example.com'; if (h.includes('url') || h.includes('endpoint') || h.includes('webhook')) { return 'https://eval-mock.invalid/'; } if (h.includes('phone')) return '+10000000000'; if (h.includes('slack channel') || h.includes('channel')) return 'C00000000EVAL'; if (h.includes('chat') && h.includes('id')) return '100000000'; if (h.includes('telegram')) return '100000000'; // Page, account and object ids: the node validates the shape, so a word is rejected. if (/\bid\b/.test(h) || h.includes('account')) return '100000000'; const selectedResourceValue = synthesizeSelectedResourcePlaceholderValue(h); if (selectedResourceValue) return selectedResourceValue; return '__evalMockValue'; } function synthesizeSelectedResourcePlaceholderValue(hint: string): string | undefined { if (!hint.includes('select')) return undefined; if (hint.includes('spreadsheet') || hint.includes('document')) return 'eval-spreadsheet-id'; if (hint.includes('sheet')) return '0'; if (hint.includes('calendar')) return 'eval-calendar-id'; if (hint.includes('folder')) return 'eval-folder-id'; if (hint.includes('file')) return 'eval-file-id'; if (hint.includes('drive')) return 'eval-drive-id'; return undefined; } function scrubPlaceholderValues(value: unknown): unknown { if (typeof value === 'string') { if (!value.startsWith(PLACEHOLDER_PREFIX) || !value.endsWith(PLACEHOLDER_SUFFIX)) { return value; } const hint = value.slice(PLACEHOLDER_PREFIX.length, -PLACEHOLDER_SUFFIX.length); return synthesizePlaceholderValue(hint); } if (Array.isArray(value)) return value.map(scrubPlaceholderValues); if (value !== null && typeof value === 'object') { const out: Record = {}; for (const [key, child] of Object.entries(value as Record)) { out[key] = scrubPlaceholderValues(child); } return out; } return value; } /** * Empty resource locators are the builder's correct "user picks at setup" * state — filling them with one opaque token crashes nodes that resolve the * value client-side against mock metadata (e.g. Google Sheets resolves the * sheet gid locally; '__evalMockResource' can never match the mock's sheetId * 0). Pick the value the mock environment consistently serves, keyed off the * parameter name; fall back to the generic token. */ function synthesizeResourceLocatorValue(paramName: string): string { const h = paramName.toLowerCase(); if (h.includes('spreadsheet') || h.includes('document')) return 'eval-spreadsheet-id'; if (h.includes('sheet')) return '0'; if (h.includes('calendar')) return 'eval-calendar-id'; if (h.includes('folder')) return 'eval-folder-id'; if (h.includes('file')) return 'eval-file-id'; if (h.includes('drive')) return 'eval-drive-id'; if (h.includes('channel')) return 'C00000000EVAL'; return '__evalMockResource'; } /** * Fill empty resource-locator values on the raw entity nodes. Must run before * any Workflow construction — see the call site in execute() for why. */ function fillSetupPendingResourceLocators(parameters: INodeParameters): void { for (const [key, raw] of Object.entries(parameters)) { if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) continue; const rl = raw as Record; if (!('__rl' in rl)) continue; const value = rl.value; const isEmpty = value === undefined || value === null || value === ''; if (!isEmpty) continue; parameters[key] = { ...rl, value: synthesizeResourceLocatorValue(key), }; } } function synthesizeMissingParamValue(current: unknown, paramName = ''): unknown { if ( current !== null && typeof current === 'object' && !Array.isArray(current) && '__rl' in (current as Record) ) { const rl = current as Record; const mode = typeof rl.mode === 'string' && rl.mode.length > 0 ? rl.mode : 'id'; const rawValue = rl.value; const hasValue = (typeof rawValue === 'string' && rawValue.length > 0) || (typeof rawValue === 'number' && Number.isFinite(rawValue)); return { ...rl, mode, value: hasValue ? rawValue : synthesizeResourceLocatorValue(paramName), }; } if (typeof current === 'string' && current.length === 0) return '__evalMockValue'; if (current === null || current === undefined) return '__evalMockValue'; return current; } /** * Resource-mapper params (e.g. Google Sheets `columns`) crash at runtime when * `mappingMode: 'defineBelow'` lacks `schema` — an artifact only the * setup-time schema fetch can supply once the real resource exists. The * defined mapping IS the builder's content: synthesize the schema from its * keys, mirroring what the fetch would return. With no mappings at all, fall * back to automatic input mapping. The node raises this at runtime, so * pre-execution paramIssues never flags it — scan every node's params * directly. */ function patchSetupPendingResourceMappers(parameters: INodeParameters): string[] { const changes: string[] = []; for (const [key, raw] of Object.entries(parameters)) { if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) continue; const mapper = raw as Record; if (!('mappingMode' in mapper) || mapper.mappingMode !== 'defineBelow') continue; const value = mapper.value; const mappingKeys = value !== null && typeof value === 'object' && !Array.isArray(value) ? Object.keys(value) : []; if (mappingKeys.length === 0) { // Keep a schema key even when empty: Google Sheets appendOrUpdate // (v4.4+) reads `columns.schema` without a fallback regardless of // mapping mode — an absent key crashes with "Could not get parameter". parameters[key] = { ...mapper, mappingMode: 'autoMapInputData', value: null, schema: Array.isArray(mapper.schema) ? mapper.schema : [], }; changes.push(`${key}: defineBelow without mappings → autoMapInputData`); continue; } const schema = mapper.schema; if (Array.isArray(schema) && schema.length > 0) continue; changes.push(`${key}: synthesized schema from ${String(mappingKeys.length)} mapping keys`); parameters[key] = { ...mapper, schema: mappingKeys.map((id) => ({ id, displayName: id, required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true, })), }; } return changes; } /** * Compact per-node summary of Phase-1.5 pinned outputs for the runtime mock * prompt — HTTP mocks must stay consistent with pinned stored state (same * entities/timestamps), else "stored matches current" scenarios can never pass. */ function summarizePinnedOutputs(pinData: IPinData | undefined): string | undefined { if (!pinData) return undefined; const lines: string[] = []; for (const [nodeName, items] of Object.entries(pinData)) { // Guaranteed-pin placeholders ({json:{}}) are execution guards, not // scenario data — presenting them to the mock LLM as authoritative // facts would bias HTTP mocks toward empty data exactly in the runs // where pin generation under-delivered. const meaningful = items.filter( (item) => Object.keys(item.json ?? {}).length > 0 || item.binary !== undefined, ); if (meaningful.length === 0) continue; let json = ''; try { json = JSON.stringify(meaningful); } catch { continue; } if (json.length > 1500) json = json.slice(0, 1500) + '…'; lines.push(`- ${nodeName}: ${json}`); } return lines.length > 0 ? lines.join('\n') : undefined; } function collectConfigIssueErrors(nodeResults: Record): string[] { const errors: string[] = []; for (const [nodeName, result] of Object.entries(nodeResults)) { const issues = result.configIssues; if (!issues || Object.keys(issues).length === 0) continue; const issueMessages = Object.values(issues).flat(); if (issueMessages.length === 0) continue; errors.push( `Node "${nodeName}" has missing or invalid configuration: ${issueMessages.join('; ')}`, ); } return errors; }