UNPKG

@n8n-plus/n8n-plus

Version:

n8n Workflow Automation Tool (plus edition)

400 lines (399 loc) 18.8 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.identifyNodesForPinData = identifyNodesForPinData; exports.buildVendorLlmRouting = buildVendorLlmRouting; exports.assertUnpinCompatibility = assertUnpinCompatibility; exports.identifyNodesForHints = identifyNodesForHints; exports.generateMockHints = generateMockHints; const backend_common_1 = require("@n8n/backend-common"); const di_1 = require("@n8n/di"); const instance_ai_1 = require("@n8n/instance-ai"); const n8n_workflow_1 = require("n8n-workflow"); const node_config_1 = require("./node-config"); function findAiRootNodeNames(workflow) { const roots = new Set(); for (const nodeConns of Object.values(workflow.connections)) { for (const [connType, outputs] of Object.entries(nodeConns)) { if (!connType.startsWith('ai_') || !Array.isArray(outputs)) continue; for (const group of outputs) { if (!Array.isArray(group)) continue; for (const conn of group) { if (typeof conn === 'object' && conn !== null && 'node' in conn) { roots.add(conn.node); } } } } } return roots; } const AI_ROOT_NODE_TYPES = new Set([ '@n8n/n8n-nodes-langchain.agent', '@n8n/n8n-nodes-langchain.chainLlm', '@n8n/n8n-nodes-langchain.chainRetrievalQa', '@n8n/n8n-nodes-langchain.chainSummarization', ]); function isAiRootNodeType(nodeType) { return AI_ROOT_NODE_TYPES.has(nodeType); } function findAiSubNodeNames(workflow) { const subNodes = new Set(); for (const [sourceName, nodeConns] of Object.entries(workflow.connections)) { for (const connType of Object.keys(nodeConns)) { if (connType.startsWith('ai_')) { subNodes.add(sourceName); } } } return subNodes; } const BYPASS_NODE_TYPES = new Set([ 'n8n-nodes-base.redis', 'n8n-nodes-base.mongoDb', 'n8n-nodes-base.mySql', 'n8n-nodes-base.postgres', 'n8n-nodes-base.microsoftSql', 'n8n-nodes-base.snowflake', 'n8n-nodes-base.kafka', 'n8n-nodes-base.rabbitmq', 'n8n-nodes-base.mqtt', 'n8n-nodes-base.amqp', 'n8n-nodes-base.ftp', 'n8n-nodes-base.ssh', 'n8n-nodes-base.ldap', 'n8n-nodes-base.emailSend', 'n8n-nodes-base.rssFeedRead', 'n8n-nodes-base.git', ]); const SUPPORTED_VENDOR_LLM_SUB_NODE_TYPES = new Set(['@n8n/n8n-nodes-langchain.lmChatOpenAi']); function isVendorLlmSubNode(nodeType) { return nodeType.startsWith('@n8n/n8n-nodes-langchain.lm'); } function hasUnsafeBaseUrlOverride(node) { if (node.type === '@n8n/n8n-nodes-langchain.lmChatOpenAi') { const options = (node.parameters?.options ?? {}); const baseURL = options.baseURL; return typeof baseURL === 'string' && baseURL.trim().length > 0; } return false; } const PROTOCOL_BINARY_SUB_NODE_TYPES = new Set([ '@n8n/n8n-nodes-langchain.memoryPostgresChat', '@n8n/n8n-nodes-langchain.memoryRedisChat', '@n8n/n8n-nodes-langchain.memoryMongoDbChat', '@n8n/n8n-nodes-langchain.vectorStorePGVector', '@n8n/n8n-nodes-langchain.vectorStoreMongoDBAtlas', '@n8n/n8n-nodes-langchain.vectorStoreRedis', '@n8n/n8n-nodes-langchain.vectorStoreMilvus', '@n8n/n8n-nodes-langchain.chatHubVectorStorePGVector', ]); function identifyNodesForPinData(workflow, exclusionSet) { const aiRootNodes = findAiRootNodeNames(workflow); return workflow.nodes.filter((node) => { if (node.disabled) return false; if (aiRootNodes.has(node.name) && !exclusionSet?.has(node.name)) return true; if (BYPASS_NODE_TYPES.has(node.type)) return true; return false; }); } function buildVendorLlmRouting(workflow, unpinNodes) { const subNodeToRoot = new Map(); const rootToSubNode = new Map(); if (unpinNodes.length === 0) return { subNodeToRoot, rootToSubNode }; const nodesByName = new Map(workflow.nodes.map((n) => [n.name, n])); const connectionsByDestination = (0, n8n_workflow_1.mapConnectionsByDestination)(workflow.connections); for (const rootName of unpinNodes) { const inbound = connectionsByDestination[rootName]; if (!inbound) continue; for (const [connType, groups] of Object.entries(inbound)) { if (connType !== 'ai_languageModel' || !Array.isArray(groups)) continue; for (const group of groups) { if (!Array.isArray(group)) continue; for (const conn of group) { const subNode = nodesByName.get(conn.node); if (!subNode || subNode.disabled) continue; if (!SUPPORTED_VENDOR_LLM_SUB_NODE_TYPES.has(subNode.type)) continue; if (!subNodeToRoot.has(subNode.name)) { subNodeToRoot.set(subNode.name, rootName); } if (!rootToSubNode.has(rootName)) { rootToSubNode.set(rootName, subNode); } } } } } return { subNodeToRoot, rootToSubNode }; } function assertUnpinCompatibility(workflow, unpinNodes) { if (unpinNodes.length === 0) return; const nodesByName = new Map(workflow.nodes.map((n) => [n.name, n])); const connectionsByDestination = (0, n8n_workflow_1.mapConnectionsByDestination)(workflow.connections); const aiRootNodes = findAiRootNodeNames(workflow); const unknownRoots = []; const disabledRoots = []; const nonAiRoots = []; for (const rootName of unpinNodes) { const node = nodesByName.get(rootName); if (!node) unknownRoots.push(rootName); else if (node.disabled) disabledRoots.push(rootName); else if (!aiRootNodes.has(rootName) && !isAiRootNodeType(node.type)) { nonAiRoots.push(rootName); } } if (unknownRoots.length || disabledRoots.length || nonAiRoots.length) { const formatNames = (names) => names.map((n) => `"${n}"`).join(', '); const parts = []; if (unknownRoots.length) parts.push(`not found in workflow: ${formatNames(unknownRoots)}`); if (disabledRoots.length) parts.push(`disabled: ${formatNames(disabledRoots)}`); if (nonAiRoots.length) parts.push(`not AI root nodes: ${formatNames(nonAiRoots)}`); throw new n8n_workflow_1.UserError(`Cannot unpin — ${parts.join('; ')}.`); } const refusals = []; const sharedSupportedSubNodes = new Map(); for (const rootName of unpinNodes) { const inbound = connectionsByDestination[rootName]; if (!inbound) continue; for (const [connType, groups] of Object.entries(inbound)) { if (!connType.startsWith('ai_') || !Array.isArray(groups)) continue; for (const group of groups) { if (!Array.isArray(group)) continue; for (const conn of group) { const sourceNode = nodesByName.get(conn.node); if (!sourceNode || sourceNode.disabled) continue; if (SUPPORTED_VENDOR_LLM_SUB_NODE_TYPES.has(sourceNode.type)) { const tracked = sharedSupportedSubNodes.get(sourceNode.name) ?? { type: sourceNode.type, roots: new Set(), }; tracked.roots.add(rootName); sharedSupportedSubNodes.set(sourceNode.name, tracked); } const reason = categorizeSubNodeRefusal(sourceNode); if (reason === null) continue; refusals.push({ root: rootName, subNode: sourceNode.name, subNodeType: sourceNode.type, reason, }); } } } } for (const [subNodeName, { type, roots }] of sharedSupportedSubNodes) { if (roots.size < 2) continue; for (const rootName of roots) { refusals.push({ root: rootName, subNode: subNodeName, subNodeType: type, reason: 'shared_vendor_llm_subnode', }); } } if (refusals.length === 0) return; const segments = [ formatRefusalSegment(refusals, 'protocol_binary', 'protocol-binary sub-nodes (cannot be intercepted via HTTP)'), formatRefusalSegment(refusals, 'unsupported_vendor_llm', 'unsupported vendor LLM sub-nodes (no eval URL-rewrite mapping yet)'), formatRefusalSegment(refusals, 'unsafe_baseurl_override', 'vendor LLM sub-nodes with a configured options.baseURL that bypasses the credential rewrite'), formatRefusalSegment(refusals, 'shared_vendor_llm_subnode', 'vendor LLM sub-nodes shared by multiple unpinned roots (attribution would be ambiguous)'), ].filter((s) => s !== undefined); throw new n8n_workflow_1.UserError(`Cannot unpin AI root nodes — ${segments.join('; ')}. ` + 'Leave these roots pinned, remove the parameter override, or replace the sub-node with one that has interception support.'); } function categorizeSubNodeRefusal(sourceNode) { if (PROTOCOL_BINARY_SUB_NODE_TYPES.has(sourceNode.type)) return 'protocol_binary'; if (SUPPORTED_VENDOR_LLM_SUB_NODE_TYPES.has(sourceNode.type)) { return hasUnsafeBaseUrlOverride(sourceNode) ? 'unsafe_baseurl_override' : null; } if (isVendorLlmSubNode(sourceNode.type)) return 'unsupported_vendor_llm'; return null; } function formatRefusalSegment(refusals, reason, label) { const matching = refusals.filter((r) => r.reason === reason); if (matching.length === 0) return undefined; const pairs = matching.map((r) => `"${r.subNode}" (${r.subNodeType}) → "${r.root}"`).join(', '); return `${label}: ${pairs}`; } function identifyNodesForHints(workflow) { const aiSubNodes = findAiSubNodeNames(workflow); const aiRootNodes = findAiRootNodeNames(workflow); const pinnedNodeNames = new Set(identifyNodesForPinData(workflow).map((n) => n.name)); return workflow.nodes.filter((node) => { if (node.disabled) return false; if (aiSubNodes.has(node.name)) return false; if (aiRootNodes.has(node.name)) return false; if (pinnedNodeNames.has(node.name)) return false; return true; }); } const SYSTEM_PROMPT = `You are a test data planner for n8n workflow automation. Your job is to create a consistent data context, trigger output data, and per-node hints that will guide an API mock server to generate realistic, coherent responses across all nodes in a workflow. RULES: 1. Create a "globalContext" that defines the shared world — user IDs, entity names, channel names, email addresses, and relationships that ALL nodes should reference consistently. 2. Create a "triggerContent" object that represents the exact output the workflow's trigger/start node would produce. This is used as pin data (the node's output), so it must match what downstream nodes reference: - Look at the trigger node's type to determine the output structure - For webhook triggers: include { headers: {}, query: {}, body: { ...fields } } since downstream nodes reference $json.body.fieldName - For service-specific triggers (Gmail Trigger, Slack Trigger, etc.): match the service's real event/message output format - For schedule triggers: include timestamp fields - For manual triggers: include the fields that downstream nodes reference - CRITICAL: triggerContent must NEVER be an empty object ({}). Even for scenarios that test empty payloads ("empty submission", "no data", "missing fields"), emit the trigger envelope with empty *nested* fields — an empty webhook is { headers: {}, query: {}, body: {} }, a schedule with no context is { timestamp: "..." }. The workflow cannot execute without trigger output. - CRITICAL: check what downstream nodes reference (e.g., $json.body.email, $json.subject, $json.text) and ensure those paths exist in triggerContent 3. Create a "nodeHints" object with one entry per node. Each hint describes what data that specific node's API response should contain, referencing entities from the global context. 4. Hints should describe the DATA CONTENT, not the API response format. The mock server already knows the API schema. 5. Ensure data flows logically through the workflow. If node A fetches items that node B processes, the items in A's hint should match what B expects. 6. Use realistic but clearly fake values (e.g., "jane@example.com", "U_abc123"). 7. **If a "Test Scenario" section is provided, it OVERRIDES your default data generation.** Use the exact names, emails, values, and conditions described in the scenario. If the scenario says "no name field", do NOT include a name. If it says "email is not-an-email", use that exact value. The scenario defines the test — follow it precisely. 8. Return ONLY valid JSON, no explanation or markdown fencing.`; function buildUserPrompt(workflow, nodeNames, scenarioHints) { const sections = [ 'Generate a consistent data context and per-node mock hints for this workflow.', ]; if (scenarioHints) { sections.push('', '## Test Scenario', '', scenarioHints); } sections.push('', '## Workflow Nodes', ''); for (const node of workflow.nodes) { let line = `- ${node.name} (${node.type})`; const config = (0, node_config_1.extractNodeConfig)(node); if (config) { line += ` ${config}`; } sections.push(line); } sections.push('', '## Connections', ''); for (const [sourceName, nodeConns] of Object.entries(workflow.connections)) { for (const [connType, outputs] of Object.entries(nodeConns)) { if (!Array.isArray(outputs)) continue; for (const group of outputs) { if (!Array.isArray(group)) continue; for (const conn of group) { if (typeof conn === 'object' && conn !== null && 'node' in conn) { sections.push(` ${sourceName} -[${connType}]-> ${conn.node}`); } } } } } sections.push('', '## Expected Output', '', '```json', '{'); sections.push(' "globalContext": "Shared entities: ...",'); sections.push(' "triggerContent": { "...exact output the trigger node would produce..." },'); sections.push(' "nodeHints": {'); for (let i = 0; i < Math.min(nodeNames.length, 3); i++) { const comma = i < Math.min(nodeNames.length, 3) - 1 ? ',' : ''; sections.push(` "${nodeNames[i]}": "What data to return..."${comma}`); } if (nodeNames.length > 3) sections.push(' ...'); sections.push(' }', '}', '```'); return sections.join('\n'); } const MAX_HINT_ATTEMPTS = 2; async function generateMockHints(options) { const { workflow, nodeNames, scenarioHints } = options; const emptyResult = { globalContext: '', nodeHints: {}, triggerContent: {}, warnings: [], bypassPinData: {}, }; if (nodeNames.length === 0) return emptyResult; const userPrompt = buildUserPrompt(workflow, nodeNames, scenarioHints); const warnings = []; for (let attempt = 1; attempt <= MAX_HINT_ATTEMPTS; attempt++) { let reason = ''; try { const agent = (0, instance_ai_1.createEvalAgent)('eval-hint-generator', { instructions: SYSTEM_PROMPT, }); const result = await agent.generate(userPrompt, { providerOptions: { anthropic: { maxTokens: 4096 } }, }); const text = (0, instance_ai_1.extractText)(result) .replace(/^```(?:json)?\s*\n?/i, '') .replace(/\n?\s*```\s*$/i, '') .trim(); const parsed = (0, n8n_workflow_1.jsonParse)(text); let globalContext = ''; if (typeof parsed.globalContext === 'string') { globalContext = parsed.globalContext; } else if (typeof parsed.globalContext === 'object' && parsed.globalContext !== null) { globalContext = JSON.stringify(parsed.globalContext); } if (typeof parsed.nodeHints !== 'object' || parsed.nodeHints === null || Array.isArray(parsed.nodeHints)) { reason = `invalid nodeHints structure (raw: ${text.slice(0, 200)})`; } else { const triggerContent = typeof parsed.triggerContent === 'object' && parsed.triggerContent !== null && !Array.isArray(parsed.triggerContent) ? parsed.triggerContent : {}; if (Object.keys(triggerContent).length === 0) { reason = 'empty triggerContent'; } else { const nodeHints = {}; for (const [key, value] of Object.entries(parsed.nodeHints)) { nodeHints[key] = typeof value === 'string' ? value : JSON.stringify(value); } return { globalContext, nodeHints, triggerContent: triggerContent, warnings, bypassPinData: {}, }; } } } catch (error) { reason = error instanceof Error ? error.message : String(error); } warnings.push(`Phase 1 attempt ${attempt}/${MAX_HINT_ATTEMPTS}: ${reason}`); if (attempt < MAX_HINT_ATTEMPTS) { di_1.Container.get(backend_common_1.Logger).warn(`[EvalMock] Phase 1 attempt ${attempt}/${MAX_HINT_ATTEMPTS} unusable (${reason}) — retrying`); } } di_1.Container.get(backend_common_1.Logger).error(`[EvalMock] Phase 1 exhausted ${MAX_HINT_ATTEMPTS} attempts — ${warnings.join('; ')}`); return { ...emptyResult, warnings }; } //# sourceMappingURL=workflow-analysis.js.map