refactor(core): Break up more code in the execution engine, and add tests (no-changelog) (#12320)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2024-12-20 14:40:06 +01:00 committed by GitHub
parent 2f21404987
commit a8dd35b0f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1492 additions and 764 deletions

View file

@ -31,7 +31,6 @@ import pick from 'lodash/pick';
import { extension, lookup } from 'mime-types';
import type {
BinaryHelperFunctions,
CloseFunction,
FileSystemHelperFunctions,
GenericValue,
IAdditionalCredentialOptions,
@ -47,7 +46,6 @@ import type {
IN8nHttpResponse,
INode,
INodeExecutionData,
INodeInputConfiguration,
IOAuth2Options,
IPairedItemData,
IPollFunctions,
@ -76,11 +74,8 @@ import type {
ICheckProcessedContextData,
WebhookType,
SchedulingFunctions,
SupplyData,
AINodeConnectionType,
} from 'n8n-workflow';
import {
NodeConnectionType,
LoggerProxy as Logger,
NodeApiError,
NodeHelpers,
@ -114,12 +109,11 @@ import {
UM_EMAIL_TEMPLATES_INVITE,
UM_EMAIL_TEMPLATES_PWRESET,
} from './Constants';
import { createNodeAsTool } from './CreateNodeAsTool';
import { DataDeduplicationService } from './data-deduplication-service';
import { InstanceSettings } from './InstanceSettings';
import type { IResponseError } from './Interfaces';
// eslint-disable-next-line import/no-cycle
import { PollContext, SupplyDataContext, TriggerContext } from './node-execution-context';
import { PollContext, TriggerContext } from './node-execution-context';
import { ScheduledTaskManager } from './ScheduledTaskManager';
import { SSHClientsManager } from './SSHClientsManager';
@ -2013,185 +2007,6 @@ export function getWebhookDescription(
return undefined;
}
export async function getInputConnectionData(
this: IAllExecuteFunctions,
workflow: Workflow,
runExecutionData: IRunExecutionData,
parentRunIndex: number,
connectionInputData: INodeExecutionData[],
parentInputData: ITaskDataConnections,
additionalData: IWorkflowExecuteAdditionalData,
executeData: IExecuteData,
mode: WorkflowExecuteMode,
closeFunctions: CloseFunction[],
connectionType: AINodeConnectionType,
itemIndex: number,
abortSignal?: AbortSignal,
): Promise<unknown> {
const parentNode = this.getNode();
const parentNodeType = workflow.nodeTypes.getByNameAndVersion(
parentNode.type,
parentNode.typeVersion,
);
const inputs = NodeHelpers.getNodeInputs(workflow, parentNode, parentNodeType.description);
let inputConfiguration = inputs.find((input) => {
if (typeof input === 'string') {
return input === connectionType;
}
return input.type === connectionType;
});
if (inputConfiguration === undefined) {
throw new ApplicationError('Node does not have input of type', {
extra: { nodeName: parentNode.name, connectionType },
});
}
if (typeof inputConfiguration === 'string') {
inputConfiguration = {
type: inputConfiguration,
} as INodeInputConfiguration;
}
const connectedNodes = workflow
.getParentNodes(parentNode.name, connectionType, 1)
.map((nodeName) => workflow.getNode(nodeName) as INode)
.filter((connectedNode) => connectedNode.disabled !== true);
if (connectedNodes.length === 0) {
if (inputConfiguration.required) {
throw new NodeOperationError(
parentNode,
`A ${inputConfiguration?.displayName ?? connectionType} sub-node must be connected and enabled`,
);
}
return inputConfiguration.maxConnections === 1 ? undefined : [];
}
if (
inputConfiguration.maxConnections !== undefined &&
connectedNodes.length > inputConfiguration.maxConnections
) {
throw new NodeOperationError(
parentNode,
`Only ${inputConfiguration.maxConnections} ${connectionType} sub-nodes are/is allowed to be connected`,
);
}
const nodes: SupplyData[] = [];
for (const connectedNode of connectedNodes) {
const connectedNodeType = workflow.nodeTypes.getByNameAndVersion(
connectedNode.type,
connectedNode.typeVersion,
);
const contextFactory = (runIndex: number, inputData: ITaskDataConnections) =>
new SupplyDataContext(
workflow,
connectedNode,
additionalData,
mode,
runExecutionData,
runIndex,
connectionInputData,
inputData,
connectionType,
executeData,
closeFunctions,
abortSignal,
);
if (!connectedNodeType.supplyData) {
if (connectedNodeType.description.outputs.includes(NodeConnectionType.AiTool)) {
/**
* This keeps track of how many times this specific AI tool node has been invoked.
* It is incremented on every invocation of the tool to keep the output of each invocation separate from each other.
*/
let toolRunIndex = 0;
const supplyData = createNodeAsTool({
node: connectedNode,
nodeType: connectedNodeType,
handleToolInvocation: async (toolArgs) => {
const runIndex = toolRunIndex++;
const context = contextFactory(runIndex, {});
context.addInputData(NodeConnectionType.AiTool, [[{ json: toolArgs }]]);
try {
// Execute the sub-node with the proxied context
const result = await connectedNodeType.execute?.call(
context as unknown as IExecuteFunctions,
);
// Process and map the results
const mappedResults = result?.[0]?.flatMap((item) => item.json);
// Add output data to the context
context.addOutputData(NodeConnectionType.AiTool, runIndex, [
[{ json: { response: mappedResults } }],
]);
// Return the stringified results
return JSON.stringify(mappedResults);
} catch (error) {
const nodeError = new NodeOperationError(connectedNode, error as Error);
context.addOutputData(NodeConnectionType.AiTool, runIndex, nodeError);
return 'Error during node execution: ' + nodeError.description;
}
},
});
nodes.push(supplyData);
} else {
throw new ApplicationError('Node does not have a `supplyData` method defined', {
extra: { nodeName: connectedNode.name },
});
}
} else {
const context = contextFactory(parentRunIndex, parentInputData);
try {
const supplyData = await connectedNodeType.supplyData.call(context, itemIndex);
if (supplyData.closeFunction) {
closeFunctions.push(supplyData.closeFunction);
}
nodes.push(supplyData);
} catch (error) {
// Propagate errors from sub-nodes
if (error.functionality === 'configuration-node') throw error;
if (!(error instanceof ExecutionBaseError)) {
error = new NodeOperationError(connectedNode, error, {
itemIndex,
});
}
let currentNodeRunIndex = 0;
if (runExecutionData.resultData.runData.hasOwnProperty(parentNode.name)) {
currentNodeRunIndex = runExecutionData.resultData.runData[parentNode.name].length;
}
// Display the error on the node which is causing it
await context.addExecutionDataFunctions(
'input',
error,
connectionType,
parentNode.name,
currentNodeRunIndex,
);
// Display on the calling node which node has the error
throw new NodeOperationError(connectedNode, `Error in sub-node ${connectedNode.name}`, {
itemIndex,
functionality: 'configuration-node',
description: error.message,
});
}
}
}
return inputConfiguration.maxConnections === 1
? (nodes || [])[0]?.response
: nodes.map((node) => node.response);
}
export const getRequestHelperFunctions = (
workflow: Workflow,
node: INode,
@ -2254,7 +2069,7 @@ export const getRequestHelperFunctions = (
const runIndex = 0;
const additionalKeys = {
const additionalKeys: IWorkflowDataProxyAdditionalKeys = {
$request: requestOptions,
$response: {} as IN8nHttpFullResponse,
$version: node.typeVersion,
@ -2379,7 +2194,7 @@ export const getRequestHelperFunctions = (
responseData.push(tempResponseData);
additionalKeys.$response = newResponse;
additionalKeys.$pageCount = additionalKeys.$pageCount + 1;
additionalKeys.$pageCount = (additionalKeys.$pageCount ?? 0) + 1;
const maxRequests = getResolvedValue(
paginationOptions.maxRequests,

View file

@ -1,11 +1,15 @@
import { mock } from 'jest-mock-extended';
import type {
Expression,
INode,
INodeType,
INodeTypes,
INodeExecutionData,
IWorkflowExecuteAdditionalData,
Workflow,
WorkflowExecuteMode,
} from 'n8n-workflow';
import { NodeConnectionType } from 'n8n-workflow';
import { Container } from 'typedi';
import { InstanceSettings } from '@/InstanceSettings';
@ -18,23 +22,29 @@ describe('NodeExecutionContext', () => {
const instanceSettings = mock<InstanceSettings>({ instanceId: 'abc123' });
Container.set(InstanceSettings, instanceSettings);
const node = mock<INode>();
const nodeType = mock<INodeType>({ description: mock() });
const nodeTypes = mock<INodeTypes>();
const expression = mock<Expression>();
const workflow = mock<Workflow>({
id: '123',
name: 'Test Workflow',
active: true,
nodeTypes: mock(),
nodeTypes,
timezone: 'UTC',
expression,
});
const node = mock<INode>();
let additionalData = mock<IWorkflowExecuteAdditionalData>({
credentialsHelper: mock(),
});
const mode: WorkflowExecuteMode = 'manual';
const testContext = new TestContext(workflow, node, additionalData, mode);
let testContext: TestContext;
beforeEach(() => {
jest.clearAllMocks();
testContext = new TestContext(workflow, node, additionalData, mode);
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
});
describe('getNode', () => {
@ -106,9 +116,9 @@ describe('NodeExecutionContext', () => {
});
describe('getKnownNodeTypes', () => {
it('should call getKnownTypes method of workflow.nodeTypes', () => {
it('should call getKnownTypes method of nodeTypes', () => {
testContext.getKnownNodeTypes();
expect(workflow.nodeTypes.getKnownTypes).toHaveBeenCalled();
expect(nodeTypes.getKnownTypes).toHaveBeenCalled();
});
});
@ -165,4 +175,164 @@ describe('NodeExecutionContext', () => {
expect(result).toEqual([outputData]);
});
});
describe('getNodeInputs', () => {
it('should return static inputs array when inputs is an array', () => {
nodeType.description.inputs = [NodeConnectionType.Main, NodeConnectionType.AiLanguageModel];
const result = testContext.getNodeInputs();
expect(result).toEqual([
{ type: NodeConnectionType.Main },
{ type: NodeConnectionType.AiLanguageModel },
]);
});
it('should return input objects when inputs contains configurations', () => {
nodeType.description.inputs = [
{ type: NodeConnectionType.Main },
{ type: NodeConnectionType.AiLanguageModel, required: true },
];
const result = testContext.getNodeInputs();
expect(result).toEqual([
{ type: NodeConnectionType.Main },
{ type: NodeConnectionType.AiLanguageModel, required: true },
]);
});
it('should evaluate dynamic inputs when inputs is a function', () => {
const inputsExpressions = '={{ ["main", "ai_languageModel"] }}';
nodeType.description.inputs = inputsExpressions;
expression.getSimpleParameterValue.mockReturnValue([
NodeConnectionType.Main,
NodeConnectionType.AiLanguageModel,
]);
const result = testContext.getNodeInputs();
expect(result).toEqual([
{ type: NodeConnectionType.Main },
{ type: NodeConnectionType.AiLanguageModel },
]);
expect(expression.getSimpleParameterValue).toHaveBeenCalledWith(
node,
inputsExpressions,
'internal',
{},
);
});
});
describe('getNodeOutputs', () => {
it('should return static outputs array when outputs is an array', () => {
nodeType.description.outputs = [NodeConnectionType.Main, NodeConnectionType.AiLanguageModel];
const result = testContext.getNodeOutputs();
expect(result).toEqual([
{ type: NodeConnectionType.Main },
{ type: NodeConnectionType.AiLanguageModel },
]);
});
it('should return output objects when outputs contains configurations', () => {
nodeType.description.outputs = [
{ type: NodeConnectionType.Main },
{ type: NodeConnectionType.AiLanguageModel, required: true },
];
const result = testContext.getNodeOutputs();
expect(result).toEqual([
{ type: NodeConnectionType.Main },
{ type: NodeConnectionType.AiLanguageModel, required: true },
]);
});
it('should evaluate dynamic outputs when outputs is a function', () => {
const outputsExpressions = '={{ ["main", "ai_languageModel"] }}';
nodeType.description.outputs = outputsExpressions;
expression.getSimpleParameterValue.mockReturnValue([
NodeConnectionType.Main,
NodeConnectionType.AiLanguageModel,
]);
const result = testContext.getNodeOutputs();
expect(result).toEqual([
{ type: NodeConnectionType.Main },
{ type: NodeConnectionType.AiLanguageModel },
]);
expect(expression.getSimpleParameterValue).toHaveBeenCalledWith(
node,
outputsExpressions,
'internal',
{},
);
});
it('should add error output when node has continueOnFail error handling', () => {
const nodeWithError = mock<INode>({ onError: 'continueErrorOutput' });
const contextWithError = new TestContext(workflow, nodeWithError, additionalData, mode);
nodeType.description.outputs = [NodeConnectionType.Main];
const result = contextWithError.getNodeOutputs();
expect(result).toEqual([
{ type: NodeConnectionType.Main, displayName: 'Success' },
{ type: NodeConnectionType.Main, displayName: 'Error', category: 'error' },
]);
});
});
describe('getConnectedNodes', () => {
it('should return connected nodes of given type', () => {
const node1 = mock<INode>({ name: 'Node 1', type: 'test', disabled: false });
const node2 = mock<INode>({ name: 'Node 2', type: 'test', disabled: false });
workflow.getParentNodes.mockReturnValue(['Node 1', 'Node 2']);
workflow.getNode.mockImplementation((name) => {
if (name === 'Node 1') return node1;
if (name === 'Node 2') return node2;
return null;
});
const result = testContext.getConnectedNodes(NodeConnectionType.Main);
expect(result).toEqual([node1, node2]);
expect(workflow.getParentNodes).toHaveBeenCalledWith(node.name, NodeConnectionType.Main, 1);
});
it('should filter out disabled nodes', () => {
const node1 = mock<INode>({ name: 'Node 1', type: 'test', disabled: false });
const node2 = mock<INode>({ name: 'Node 2', type: 'test', disabled: true });
workflow.getParentNodes.mockReturnValue(['Node 1', 'Node 2']);
workflow.getNode.mockImplementation((name) => {
if (name === 'Node 1') return node1;
if (name === 'Node 2') return node2;
return null;
});
const result = testContext.getConnectedNodes(NodeConnectionType.Main);
expect(result).toEqual([node1]);
});
it('should filter out non-existent nodes', () => {
const node1 = mock<INode>({ name: 'Node 1', type: 'test', disabled: false });
workflow.getParentNodes.mockReturnValue(['Node 1', 'NonExistent']);
workflow.getNode.mockImplementation((name) => {
if (name === 'Node 1') return node1;
return null;
});
const result = testContext.getConnectedNodes(NodeConnectionType.Main);
expect(result).toEqual([node1]);
});
});
});

View file

@ -16,8 +16,6 @@ import type {
ITaskMetadata,
ContextType,
IContextObject,
INodeInputConfiguration,
INodeOutputConfiguration,
IWorkflowDataProxyData,
ISourceData,
AiEvent,
@ -161,26 +159,6 @@ export class BaseExecuteContext extends NodeExecutionContext {
return allItems;
}
getNodeInputs(): INodeInputConfiguration[] {
const nodeType = this.workflow.nodeTypes.getByNameAndVersion(
this.node.type,
this.node.typeVersion,
);
return NodeHelpers.getNodeInputs(this.workflow, this.node, nodeType.description).map((input) =>
typeof input === 'string' ? { type: input } : input,
);
}
getNodeOutputs(): INodeOutputConfiguration[] {
const nodeType = this.workflow.nodeTypes.getByNameAndVersion(
this.node.type,
this.node.typeVersion,
);
return NodeHelpers.getNodeOutputs(this.workflow, this.node, nodeType.description).map(
(output) => (typeof output === 'string' ? { type: output } : output),
);
}
getInputSourceData(inputIndex = 0, connectionType = NodeConnectionType.Main): ISourceData {
if (this.executeData?.source === null) {
// Should never happen as n8n sets it automatically

View file

@ -28,7 +28,6 @@ import {
copyInputItems,
normalizeItems,
constructExecutionMetaData,
getInputConnectionData,
assertBinaryData,
getBinaryDataBuffer,
copyBinaryFile,
@ -41,6 +40,7 @@ import {
} from '@/NodeExecuteFunctions';
import { BaseExecuteContext } from './base-execute-context';
import { getInputConnectionData } from './utils/getInputConnectionData';
export class ExecuteContext extends BaseExecuteContext implements IExecuteFunctions {
readonly helpers: IExecuteFunctions['helpers'];

View file

@ -4,8 +4,9 @@ export { ExecuteSingleContext } from './execute-single-context';
export { HookContext } from './hook-context';
export { LoadOptionsContext } from './load-options-context';
export { PollContext } from './poll-context';
// eslint-disable-next-line import/no-cycle
export { SupplyDataContext } from './supply-data-context';
export { TriggerContext } from './trigger-context';
export { WebhookContext } from './webhook-context';
export { getAdditionalKeys } from './utils';
export { getAdditionalKeys } from './utils/getAdditionalKeys';

View file

@ -9,8 +9,11 @@ import type {
INodeCredentialDescription,
INodeCredentialsDetails,
INodeExecutionData,
INodeInputConfiguration,
INodeOutputConfiguration,
IRunExecutionData,
IWorkflowExecuteAdditionalData,
NodeConnectionType,
NodeParameterValueType,
NodeTypeAndVersion,
Workflow,
@ -27,15 +30,14 @@ import {
import { Container } from 'typedi';
import { HTTP_REQUEST_NODE_TYPE, HTTP_REQUEST_TOOL_NODE_TYPE } from '@/Constants';
import { Memoized } from '@/decorators';
import { extractValue } from '@/ExtractValue';
import { InstanceSettings } from '@/InstanceSettings';
import {
cleanupParameterData,
ensureType,
getAdditionalKeys,
validateValueAgainstSchema,
} from './utils';
import { cleanupParameterData } from './utils/cleanupParameterData';
import { ensureType } from './utils/ensureType';
import { getAdditionalKeys } from './utils/getAdditionalKeys';
import { validateValueAgainstSchema } from './utils/validateValueAgainstSchema';
export abstract class NodeExecutionContext implements Omit<FunctionsBase, 'getCredentials'> {
protected readonly instanceSettings = Container.get(InstanceSettings);
@ -108,6 +110,42 @@ export abstract class NodeExecutionContext implements Omit<FunctionsBase, 'getCr
return output;
}
@Memoized
get nodeType() {
const { type, typeVersion } = this.node;
return this.workflow.nodeTypes.getByNameAndVersion(type, typeVersion);
}
@Memoized
get nodeInputs() {
return NodeHelpers.getNodeInputs(this.workflow, this.node, this.nodeType.description).map(
(input) => (typeof input === 'string' ? { type: input } : input),
);
}
getNodeInputs(): INodeInputConfiguration[] {
return this.nodeInputs;
}
@Memoized
get nodeOutputs() {
return NodeHelpers.getNodeOutputs(this.workflow, this.node, this.nodeType.description).map(
(output) => (typeof output === 'string' ? { type: output } : output),
);
}
getConnectedNodes(connectionType: NodeConnectionType): INode[] {
return this.workflow
.getParentNodes(this.node.name, connectionType, 1)
.map((nodeName) => this.workflow.getNode(nodeName))
.filter((node) => !!node)
.filter((node) => node.disabled !== true);
}
getNodeOutputs(): INodeOutputConfiguration[] {
return this.nodeOutputs;
}
getKnownNodeTypes() {
return this.workflow.nodeTypes.getKnownTypes();
}
@ -260,6 +298,7 @@ export abstract class NodeExecutionContext implements Omit<FunctionsBase, 'getCr
return decryptedDataObject as T;
}
@Memoized
protected get additionalKeys() {
return getAdditionalKeys(this.additionalData, this.mode, this.runExecutionData);
}

View file

@ -33,10 +33,10 @@ import {
getSSHTunnelFunctions,
normalizeItems,
returnJsonArray,
getInputConnectionData,
} from '@/NodeExecuteFunctions';
import { BaseExecuteContext } from './base-execute-context';
import { getInputConnectionData } from './utils/getInputConnectionData';
export class SupplyDataContext extends BaseExecuteContext implements ISupplyDataFunctions {
readonly helpers: ISupplyDataFunctions['helpers'];

View file

@ -1,423 +0,0 @@
import { DateTime } from 'luxon';
import type {
EnsureTypeOptions,
FieldType,
IDataObject,
INode,
INodeParameters,
INodeProperties,
INodePropertyCollection,
INodePropertyOptions,
INodeType,
IRunExecutionData,
IWorkflowDataProxyAdditionalKeys,
IWorkflowExecuteAdditionalData,
NodeParameterValueType,
WorkflowExecuteMode,
} from 'n8n-workflow';
import {
ExpressionError,
isResourceMapperValue,
LoggerProxy,
NodeHelpers,
validateFieldType,
} from 'n8n-workflow';
import { PLACEHOLDER_EMPTY_EXECUTION_ID } from '@/Constants';
import {
setWorkflowExecutionMetadata,
setAllWorkflowExecutionMetadata,
getWorkflowExecutionMetadata,
getAllWorkflowExecutionMetadata,
} from '@/ExecutionMetadata';
import type { ExtendedValidationResult } from '@/Interfaces';
import { getSecretsProxy } from '@/Secrets';
/**
* Clean up parameter data to make sure that only valid data gets returned
* INFO: Currently only converts Luxon Dates as we know for sure it will not be breaking
*/
export function cleanupParameterData(inputData: NodeParameterValueType): void {
if (typeof inputData !== 'object' || inputData === null) {
return;
}
if (Array.isArray(inputData)) {
inputData.forEach((value) => cleanupParameterData(value as NodeParameterValueType));
return;
}
if (typeof inputData === 'object') {
Object.keys(inputData).forEach((key) => {
const value = (inputData as INodeParameters)[key];
if (typeof value === 'object') {
if (DateTime.isDateTime(value)) {
// Is a special luxon date so convert to string
(inputData as INodeParameters)[key] = value.toString();
} else {
cleanupParameterData(value);
}
}
});
}
}
const validateResourceMapperValue = (
parameterName: string,
paramValues: { [key: string]: unknown },
node: INode,
skipRequiredCheck = false,
): ExtendedValidationResult => {
const result: ExtendedValidationResult = { valid: true, newValue: paramValues };
const paramNameParts = parameterName.split('.');
if (paramNameParts.length !== 2) {
return result;
}
const resourceMapperParamName = paramNameParts[0];
const resourceMapperField = node.parameters[resourceMapperParamName];
if (!resourceMapperField || !isResourceMapperValue(resourceMapperField)) {
return result;
}
const schema = resourceMapperField.schema;
const paramValueNames = Object.keys(paramValues);
for (let i = 0; i < paramValueNames.length; i++) {
const key = paramValueNames[i];
const resolvedValue = paramValues[key];
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call
const schemaEntry = schema.find((s) => s.id === key);
if (
!skipRequiredCheck &&
schemaEntry?.required === true &&
schemaEntry.type !== 'boolean' &&
!resolvedValue
) {
return {
valid: false,
errorMessage: `The value "${String(key)}" is required but not set`,
fieldName: key,
};
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (schemaEntry?.type) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const validationResult = validateFieldType(key, resolvedValue, schemaEntry.type, {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
valueOptions: schemaEntry.options,
});
if (!validationResult.valid) {
return { ...validationResult, fieldName: key };
} else {
// If it's valid, set the casted value
paramValues[key] = validationResult.newValue;
}
}
}
return result;
};
const validateCollection = (
node: INode,
runIndex: number,
itemIndex: number,
propertyDescription: INodeProperties,
parameterPath: string[],
validationResult: ExtendedValidationResult,
): ExtendedValidationResult => {
let nestedDescriptions: INodeProperties[] | undefined;
if (propertyDescription.type === 'fixedCollection') {
nestedDescriptions = (propertyDescription.options as INodePropertyCollection[]).find(
(entry) => entry.name === parameterPath[1],
)?.values;
}
if (propertyDescription.type === 'collection') {
nestedDescriptions = propertyDescription.options as INodeProperties[];
}
if (!nestedDescriptions) {
return validationResult;
}
const validationMap: {
[key: string]: { type: FieldType; displayName: string; options?: INodePropertyOptions[] };
} = {};
for (const prop of nestedDescriptions) {
if (!prop.validateType || prop.ignoreValidationDuringExecution) continue;
validationMap[prop.name] = {
type: prop.validateType,
displayName: prop.displayName,
options:
prop.validateType === 'options' ? (prop.options as INodePropertyOptions[]) : undefined,
};
}
if (!Object.keys(validationMap).length) {
return validationResult;
}
if (validationResult.valid) {
for (const value of Array.isArray(validationResult.newValue)
? (validationResult.newValue as IDataObject[])
: [validationResult.newValue as IDataObject]) {
for (const key of Object.keys(value)) {
if (!validationMap[key]) continue;
const fieldValidationResult = validateFieldType(key, value[key], validationMap[key].type, {
valueOptions: validationMap[key].options,
});
if (!fieldValidationResult.valid) {
throw new ExpressionError(
`Invalid input for field '${validationMap[key].displayName}' inside '${propertyDescription.displayName}' in [item ${itemIndex}]`,
{
description: fieldValidationResult.errorMessage,
runIndex,
itemIndex,
nodeCause: node.name,
},
);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
value[key] = fieldValidationResult.newValue;
}
}
}
return validationResult;
};
export const validateValueAgainstSchema = (
node: INode,
nodeType: INodeType,
parameterValue: string | number | boolean | object | null | undefined,
parameterName: string,
runIndex: number,
itemIndex: number,
) => {
const parameterPath = parameterName.split('.');
const propertyDescription = nodeType.description.properties.find(
(prop) =>
parameterPath[0] === prop.name && NodeHelpers.displayParameter(node.parameters, prop, node),
);
if (!propertyDescription) {
return parameterValue;
}
let validationResult: ExtendedValidationResult = { valid: true, newValue: parameterValue };
if (
parameterPath.length === 1 &&
propertyDescription.validateType &&
!propertyDescription.ignoreValidationDuringExecution
) {
validationResult = validateFieldType(
parameterName,
parameterValue,
propertyDescription.validateType,
);
} else if (
propertyDescription.type === 'resourceMapper' &&
parameterPath[1] === 'value' &&
typeof parameterValue === 'object'
) {
validationResult = validateResourceMapperValue(
parameterName,
parameterValue as { [key: string]: unknown },
node,
propertyDescription.typeOptions?.resourceMapper?.mode !== 'add',
);
} else if (['fixedCollection', 'collection'].includes(propertyDescription.type)) {
validationResult = validateCollection(
node,
runIndex,
itemIndex,
propertyDescription,
parameterPath,
validationResult,
);
}
if (!validationResult.valid) {
throw new ExpressionError(
`Invalid input for '${
validationResult.fieldName
? String(validationResult.fieldName)
: propertyDescription.displayName
}' [item ${itemIndex}]`,
{
description: validationResult.errorMessage,
runIndex,
itemIndex,
nodeCause: node.name,
},
);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return validationResult.newValue;
};
export function ensureType(
toType: EnsureTypeOptions,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
parameterValue: any,
parameterName: string,
errorOptions?: { itemIndex?: number; runIndex?: number; nodeCause?: string },
): string | number | boolean | object {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
let returnData = parameterValue;
if (returnData === null) {
throw new ExpressionError(`Parameter '${parameterName}' must not be null`, errorOptions);
}
if (returnData === undefined) {
throw new ExpressionError(
`Parameter '${parameterName}' could not be 'undefined'`,
errorOptions,
);
}
if (['object', 'array', 'json'].includes(toType)) {
if (typeof returnData !== 'object') {
// if value is not an object and is string try to parse it, else throw an error
if (typeof returnData === 'string' && returnData.length) {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const parsedValue = JSON.parse(returnData);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
returnData = parsedValue;
} catch (error) {
throw new ExpressionError(`Parameter '${parameterName}' could not be parsed`, {
...errorOptions,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
description: error.message,
});
}
} else {
throw new ExpressionError(
`Parameter '${parameterName}' must be an ${toType}, but we got '${String(parameterValue)}'`,
errorOptions,
);
}
} else if (toType === 'json') {
// value is an object, make sure it is valid JSON
try {
JSON.stringify(returnData);
} catch (error) {
throw new ExpressionError(`Parameter '${parameterName}' is not valid JSON`, {
...errorOptions,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
description: error.message,
});
}
}
if (toType === 'array' && !Array.isArray(returnData)) {
// value is not an array, but has to be
throw new ExpressionError(
`Parameter '${parameterName}' must be an array, but we got object`,
errorOptions,
);
}
}
try {
if (toType === 'string') {
if (typeof returnData === 'object') {
returnData = JSON.stringify(returnData);
} else {
returnData = String(returnData);
}
}
if (toType === 'number') {
returnData = Number(returnData);
if (Number.isNaN(returnData)) {
throw new ExpressionError(
`Parameter '${parameterName}' must be a number, but we got '${parameterValue}'`,
errorOptions,
);
}
}
if (toType === 'boolean') {
returnData = Boolean(returnData);
}
} catch (error) {
if (error instanceof ExpressionError) throw error;
throw new ExpressionError(`Parameter '${parameterName}' could not be converted to ${toType}`, {
...errorOptions,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
description: error.message,
});
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return returnData;
}
/** Returns the additional keys for Expressions and Function-Nodes */
export function getAdditionalKeys(
additionalData: IWorkflowExecuteAdditionalData,
mode: WorkflowExecuteMode,
runExecutionData: IRunExecutionData | null,
options?: { secretsEnabled?: boolean },
): IWorkflowDataProxyAdditionalKeys {
const executionId = additionalData.executionId ?? PLACEHOLDER_EMPTY_EXECUTION_ID;
const resumeUrl = `${additionalData.webhookWaitingBaseUrl}/${executionId}`;
const resumeFormUrl = `${additionalData.formWaitingBaseUrl}/${executionId}`;
return {
$execution: {
id: executionId,
mode: mode === 'manual' ? 'test' : 'production',
resumeUrl,
resumeFormUrl,
customData: runExecutionData
? {
set(key: string, value: string): void {
try {
setWorkflowExecutionMetadata(runExecutionData, key, value);
} catch (e) {
if (mode === 'manual') {
throw e;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
LoggerProxy.debug(e.message);
}
},
setAll(obj: Record<string, string>): void {
try {
setAllWorkflowExecutionMetadata(runExecutionData, obj);
} catch (e) {
if (mode === 'manual') {
throw e;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
LoggerProxy.debug(e.message);
}
},
get(key: string): string {
return getWorkflowExecutionMetadata(runExecutionData, key);
},
getAll(): Record<string, string> {
return getAllWorkflowExecutionMetadata(runExecutionData);
},
}
: undefined,
},
$vars: additionalData.variables,
$secrets: options?.secretsEnabled ? getSecretsProxy(additionalData) : undefined,
// deprecated
$executionId: executionId,
$resumeWebhookUrl: resumeUrl,
};
}

View file

@ -0,0 +1,38 @@
import toPlainObject from 'lodash/toPlainObject';
import { DateTime } from 'luxon';
import type { NodeParameterValue } from 'n8n-workflow';
import { cleanupParameterData } from '../cleanupParameterData';
describe('cleanupParameterData', () => {
it('should stringify Luxon dates in-place', () => {
const input = { x: 1, y: DateTime.now() as unknown as NodeParameterValue };
expect(typeof input.y).toBe('object');
cleanupParameterData(input);
expect(typeof input.y).toBe('string');
});
it('should stringify plain Luxon dates in-place', () => {
const input = {
x: 1,
y: toPlainObject(DateTime.now()),
};
expect(typeof input.y).toBe('object');
cleanupParameterData(input);
expect(typeof input.y).toBe('string');
});
it('should handle objects with nameless constructors', () => {
const input = { x: 1, y: { constructor: {} } as NodeParameterValue };
expect(typeof input.y).toBe('object');
cleanupParameterData(input);
expect(typeof input.y).toBe('object');
});
it('should handle objects without a constructor', () => {
const input = { x: 1, y: { constructor: undefined } as unknown as NodeParameterValue };
expect(typeof input.y).toBe('object');
cleanupParameterData(input);
expect(typeof input.y).toBe('object');
});
});

View file

@ -0,0 +1,80 @@
import { ExpressionError } from 'n8n-workflow';
import { ensureType } from '../ensureType';
describe('ensureType', () => {
it('throws error for null value', () => {
expect(() => ensureType('string', null, 'myParam')).toThrowError(
new ExpressionError("Parameter 'myParam' must not be null"),
);
});
it('throws error for undefined value', () => {
expect(() => ensureType('string', undefined, 'myParam')).toThrowError(
new ExpressionError("Parameter 'myParam' could not be 'undefined'"),
);
});
it('returns string value without modification', () => {
const value = 'hello';
const expectedValue = value;
const result = ensureType('string', value, 'myParam');
expect(result).toBe(expectedValue);
});
it('returns number value without modification', () => {
const value = 42;
const expectedValue = value;
const result = ensureType('number', value, 'myParam');
expect(result).toBe(expectedValue);
});
it('returns boolean value without modification', () => {
const value = true;
const expectedValue = value;
const result = ensureType('boolean', value, 'myParam');
expect(result).toBe(expectedValue);
});
it('converts object to string if toType is string', () => {
const value = { name: 'John' };
const expectedValue = JSON.stringify(value);
const result = ensureType('string', value, 'myParam');
expect(result).toBe(expectedValue);
});
it('converts string to number if toType is number', () => {
const value = '10';
const expectedValue = 10;
const result = ensureType('number', value, 'myParam');
expect(result).toBe(expectedValue);
});
it('throws error for invalid conversion to number', () => {
const value = 'invalid';
expect(() => ensureType('number', value, 'myParam')).toThrowError(
new ExpressionError("Parameter 'myParam' must be a number, but we got 'invalid'"),
);
});
it('parses valid JSON string to object if toType is object', () => {
const value = '{"name": "Alice"}';
const expectedValue = JSON.parse(value);
const result = ensureType('object', value, 'myParam');
expect(result).toEqual(expectedValue);
});
it('throws error for invalid JSON string to object conversion', () => {
const value = 'invalid_json';
expect(() => ensureType('object', value, 'myParam')).toThrowError(
new ExpressionError("Parameter 'myParam' could not be parsed"),
);
});
it('throws error for non-array value if toType is array', () => {
const value = { name: 'Alice' };
expect(() => ensureType('array', value, 'myParam')).toThrowError(
new ExpressionError("Parameter 'myParam' must be an array, but we got object"),
);
});
});

View file

@ -0,0 +1,146 @@
import { mock } from 'jest-mock-extended';
import { LoggerProxy } from 'n8n-workflow';
import type {
IDataObject,
IRunExecutionData,
IWorkflowExecuteAdditionalData,
SecretsHelpersBase,
} from 'n8n-workflow';
import { PLACEHOLDER_EMPTY_EXECUTION_ID } from '@/Constants';
import { getAdditionalKeys } from '../getAdditionalKeys';
describe('getAdditionalKeys', () => {
const secretsHelpers = mock<SecretsHelpersBase>();
const additionalData = mock<IWorkflowExecuteAdditionalData>({
executionId: '123',
webhookWaitingBaseUrl: 'https://webhook.test',
formWaitingBaseUrl: 'https://form.test',
variables: { testVar: 'value' },
secretsHelpers,
});
const runExecutionData = mock<IRunExecutionData>({
resultData: {
runData: {},
metadata: {},
},
});
beforeAll(() => {
LoggerProxy.init(mock());
secretsHelpers.hasProvider.mockReturnValue(true);
secretsHelpers.hasSecret.mockReturnValue(true);
secretsHelpers.getSecret.mockReturnValue('secret-value');
secretsHelpers.listSecrets.mockReturnValue(['secret1']);
secretsHelpers.listProviders.mockReturnValue(['provider1']);
});
it('should use placeholder execution ID when none provided', () => {
const noIdData = { ...additionalData, executionId: undefined };
const result = getAdditionalKeys(noIdData, 'manual', null);
expect(result.$execution?.id).toBe(PLACEHOLDER_EMPTY_EXECUTION_ID);
});
it('should return production mode when not manual', () => {
const result = getAdditionalKeys(additionalData, 'internal', null);
expect(result.$execution?.mode).toBe('production');
});
it('should include customData methods when runExecutionData is provided', () => {
const result = getAdditionalKeys(additionalData, 'manual', runExecutionData);
expect(result.$execution?.customData).toBeDefined();
expect(typeof result.$execution?.customData?.set).toBe('function');
expect(typeof result.$execution?.customData?.setAll).toBe('function');
expect(typeof result.$execution?.customData?.get).toBe('function');
expect(typeof result.$execution?.customData?.getAll).toBe('function');
});
it('should handle customData operations correctly', () => {
const result = getAdditionalKeys(additionalData, 'manual', runExecutionData);
const customData = result.$execution?.customData;
customData?.set('testKey', 'testValue');
expect(customData?.get('testKey')).toBe('testValue');
customData?.setAll({ key1: 'value1', key2: 'value2' });
const allData = customData?.getAll();
expect(allData).toEqual({
testKey: 'testValue',
key1: 'value1',
key2: 'value2',
});
});
it('should include secrets when enabled', () => {
const result = getAdditionalKeys(additionalData, 'manual', null, { secretsEnabled: true });
expect(result.$secrets).toBeDefined();
expect((result.$secrets?.provider1 as IDataObject).secret1).toEqual('secret-value');
});
it('should not include secrets when disabled', () => {
const result = getAdditionalKeys(additionalData, 'manual', null, { secretsEnabled: false });
expect(result.$secrets).toBeUndefined();
});
it('should throw errors in manual mode', () => {
const result = getAdditionalKeys(additionalData, 'manual', runExecutionData);
expect(() => {
result.$execution?.customData?.set('invalid*key', 'value');
}).toThrow();
});
it('should correctly set resume URLs', () => {
const result = getAdditionalKeys(additionalData, 'manual', null);
expect(result.$execution?.resumeUrl).toBe('https://webhook.test/123');
expect(result.$execution?.resumeFormUrl).toBe('https://form.test/123');
expect(result.$resumeWebhookUrl).toBe('https://webhook.test/123'); // Test deprecated property
});
it('should return test mode when manual', () => {
const result = getAdditionalKeys(additionalData, 'manual', null);
expect(result.$execution?.mode).toBe('test');
});
it('should return variables from additionalData', () => {
const result = getAdditionalKeys(additionalData, 'manual', null);
expect(result.$vars?.testVar).toEqual('value');
});
it('should handle errors in non-manual mode without throwing', () => {
const result = getAdditionalKeys(additionalData, 'internal', runExecutionData);
const customData = result.$execution?.customData;
expect(() => {
customData?.set('invalid*key', 'value');
}).not.toThrow();
});
it('should return undefined customData when runExecutionData is null', () => {
const result = getAdditionalKeys(additionalData, 'manual', null);
expect(result.$execution?.customData).toBeUndefined();
});
it('should respect metadata KV limit', () => {
const result = getAdditionalKeys(additionalData, 'manual', runExecutionData);
const customData = result.$execution?.customData;
// Add 11 key-value pairs (exceeding the limit of 10)
for (let i = 0; i < 11; i++) {
customData?.set(`key${i}`, `value${i}`);
}
const allData = customData?.getAll() ?? {};
expect(Object.keys(allData)).toHaveLength(10);
});
});

View file

@ -0,0 +1,366 @@
import type { Tool } from '@langchain/core/tools';
import { mock } from 'jest-mock-extended';
import type {
INode,
ITaskDataConnections,
IRunExecutionData,
INodeExecutionData,
IExecuteData,
IWorkflowExecuteAdditionalData,
Workflow,
INodeType,
INodeTypes,
} from 'n8n-workflow';
import { NodeConnectionType, NodeOperationError } from 'n8n-workflow';
import { ExecuteContext } from '../../execute-context';
describe('getInputConnectionData', () => {
const agentNode = mock<INode>({
name: 'Test Agent',
type: 'test.agent',
parameters: {},
});
const agentNodeType = mock<INodeType>({
description: {
inputs: [],
},
});
const nodeTypes = mock<INodeTypes>();
const workflow = mock<Workflow>({
id: 'test-workflow',
active: false,
nodeTypes,
});
const runExecutionData = mock<IRunExecutionData>({
resultData: { runData: {} },
});
const connectionInputData = [] as INodeExecutionData[];
const inputData = {} as ITaskDataConnections;
const executeData = {} as IExecuteData;
const hooks = mock<Required<IWorkflowExecuteAdditionalData['hooks']>>();
const additionalData = mock<IWorkflowExecuteAdditionalData>({ hooks });
let executeContext: ExecuteContext;
beforeEach(() => {
jest.clearAllMocks();
executeContext = new ExecuteContext(
workflow,
agentNode,
additionalData,
'internal',
runExecutionData,
0,
connectionInputData,
inputData,
executeData,
[],
);
jest.spyOn(executeContext, 'getNode').mockReturnValue(agentNode);
nodeTypes.getByNameAndVersion
.calledWith(agentNode.type, expect.anything())
.mockReturnValue(agentNodeType);
});
describe.each([
NodeConnectionType.AiAgent,
NodeConnectionType.AiChain,
NodeConnectionType.AiDocument,
NodeConnectionType.AiEmbedding,
NodeConnectionType.AiLanguageModel,
NodeConnectionType.AiMemory,
NodeConnectionType.AiOutputParser,
NodeConnectionType.AiRetriever,
NodeConnectionType.AiTextSplitter,
NodeConnectionType.AiVectorStore,
] as const)('%s', (connectionType) => {
const response = mock();
const node = mock<INode>({
name: 'First Node',
type: 'test.type',
disabled: false,
});
const secondNode = mock<INode>({ name: 'Second Node', disabled: false });
const supplyData = jest.fn().mockResolvedValue({ response });
const nodeType = mock<INodeType>({ supplyData });
beforeEach(() => {
nodeTypes.getByNameAndVersion
.calledWith(node.type, expect.anything())
.mockReturnValue(nodeType);
workflow.getParentNodes
.calledWith(agentNode.name, connectionType)
.mockReturnValue([node.name]);
workflow.getNode.calledWith(node.name).mockReturnValue(node);
workflow.getNode.calledWith(secondNode.name).mockReturnValue(secondNode);
});
it('should throw when no inputs are defined', async () => {
agentNodeType.description.inputs = [];
await expect(executeContext.getInputConnectionData(connectionType, 0)).rejects.toThrow(
'Node does not have input of type',
);
expect(supplyData).not.toHaveBeenCalled();
});
it('should return undefined when no nodes are connected and input is not required', async () => {
agentNodeType.description.inputs = [
{
type: connectionType,
maxConnections: 1,
required: false,
},
];
workflow.getParentNodes.mockReturnValueOnce([]);
const result = await executeContext.getInputConnectionData(connectionType, 0);
expect(result).toBeUndefined();
expect(supplyData).not.toHaveBeenCalled();
});
it('should throw when too many nodes are connected', async () => {
agentNodeType.description.inputs = [
{
type: connectionType,
maxConnections: 1,
required: true,
},
];
workflow.getParentNodes.mockReturnValueOnce([node.name, secondNode.name]);
await expect(executeContext.getInputConnectionData(connectionType, 0)).rejects.toThrow(
`Only 1 ${connectionType} sub-nodes are/is allowed to be connected`,
);
expect(supplyData).not.toHaveBeenCalled();
});
it('should throw when required node is not connected', async () => {
agentNodeType.description.inputs = [
{
type: connectionType,
required: true,
},
];
workflow.getParentNodes.mockReturnValueOnce([]);
await expect(executeContext.getInputConnectionData(connectionType, 0)).rejects.toThrow(
'must be connected and enabled',
);
expect(supplyData).not.toHaveBeenCalled();
});
it('should handle disabled nodes', async () => {
agentNodeType.description.inputs = [
{
type: connectionType,
required: true,
},
];
const disabledNode = mock<INode>({
name: 'Disabled Node',
type: 'test.type',
disabled: true,
});
workflow.getParentNodes.mockReturnValueOnce([disabledNode.name]);
workflow.getNode.calledWith(disabledNode.name).mockReturnValue(disabledNode);
await expect(executeContext.getInputConnectionData(connectionType, 0)).rejects.toThrow(
'must be connected and enabled',
);
expect(supplyData).not.toHaveBeenCalled();
});
it('should handle node execution errors', async () => {
agentNodeType.description.inputs = [
{
type: connectionType,
required: true,
},
];
supplyData.mockRejectedValueOnce(new Error('supplyData error'));
await expect(executeContext.getInputConnectionData(connectionType, 0)).rejects.toThrow(
`Error in sub-node ${node.name}`,
);
expect(supplyData).toHaveBeenCalled();
});
it('should propagate configuration errors', async () => {
agentNodeType.description.inputs = [
{
type: connectionType,
required: true,
},
];
const configError = new NodeOperationError(node, 'Config Error in node', {
functionality: 'configuration-node',
});
supplyData.mockRejectedValueOnce(configError);
await expect(executeContext.getInputConnectionData(connectionType, 0)).rejects.toThrow(
configError.message,
);
expect(nodeType.supplyData).toHaveBeenCalled();
});
it('should handle close functions', async () => {
agentNodeType.description.inputs = [
{
type: connectionType,
maxConnections: 1,
required: true,
},
];
const closeFunction = jest.fn();
supplyData.mockResolvedValueOnce({ response, closeFunction });
const result = await executeContext.getInputConnectionData(connectionType, 0);
expect(result).toBe(response);
expect(supplyData).toHaveBeenCalled();
// @ts-expect-error private property
expect(executeContext.closeFunctions).toContain(closeFunction);
});
});
describe(NodeConnectionType.AiTool, () => {
const mockTool = mock<Tool>();
const toolNode = mock<INode>({
name: 'Test Tool',
type: 'test.tool',
disabled: false,
});
const supplyData = jest.fn().mockResolvedValue({ response: mockTool });
const toolNodeType = mock<INodeType>({ supplyData });
const secondToolNode = mock<INode>({ name: 'test.secondTool', disabled: false });
const secondMockTool = mock<Tool>();
const secondToolNodeType = mock<INodeType>({
supplyData: jest.fn().mockResolvedValue({ response: secondMockTool }),
});
beforeEach(() => {
nodeTypes.getByNameAndVersion
.calledWith(toolNode.type, expect.anything())
.mockReturnValue(toolNodeType);
workflow.getParentNodes
.calledWith(agentNode.name, NodeConnectionType.AiTool)
.mockReturnValue([toolNode.name]);
workflow.getNode.calledWith(toolNode.name).mockReturnValue(toolNode);
workflow.getNode.calledWith(secondToolNode.name).mockReturnValue(secondToolNode);
});
it('should return empty array when no tools are connected and input is not required', async () => {
agentNodeType.description.inputs = [
{
type: NodeConnectionType.AiTool,
required: false,
},
];
workflow.getParentNodes.mockReturnValueOnce([]);
const result = await executeContext.getInputConnectionData(NodeConnectionType.AiTool, 0);
expect(result).toEqual([]);
expect(supplyData).not.toHaveBeenCalled();
});
it('should throw when required tool node is not connected', async () => {
agentNodeType.description.inputs = [
{
type: NodeConnectionType.AiTool,
required: true,
},
];
workflow.getParentNodes.mockReturnValueOnce([]);
await expect(
executeContext.getInputConnectionData(NodeConnectionType.AiTool, 0),
).rejects.toThrow('must be connected and enabled');
expect(supplyData).not.toHaveBeenCalled();
});
it('should handle disabled tool nodes', async () => {
const disabledToolNode = mock<INode>({
name: 'Disabled Tool',
type: 'test.tool',
disabled: true,
});
agentNodeType.description.inputs = [
{
type: NodeConnectionType.AiTool,
required: true,
},
];
workflow.getParentNodes
.calledWith(agentNode.name, NodeConnectionType.AiTool)
.mockReturnValue([disabledToolNode.name]);
workflow.getNode.calledWith(disabledToolNode.name).mockReturnValue(disabledToolNode);
await expect(
executeContext.getInputConnectionData(NodeConnectionType.AiTool, 0),
).rejects.toThrow('must be connected and enabled');
expect(supplyData).not.toHaveBeenCalled();
});
it('should handle multiple connected tools', async () => {
agentNodeType.description.inputs = [
{
type: NodeConnectionType.AiTool,
required: true,
},
];
nodeTypes.getByNameAndVersion
.calledWith(secondToolNode.type, expect.anything())
.mockReturnValue(secondToolNodeType);
workflow.getParentNodes
.calledWith(agentNode.name, NodeConnectionType.AiTool)
.mockReturnValue([toolNode.name, secondToolNode.name]);
const result = await executeContext.getInputConnectionData(NodeConnectionType.AiTool, 0);
expect(result).toEqual([mockTool, secondMockTool]);
expect(supplyData).toHaveBeenCalled();
expect(secondToolNodeType.supplyData).toHaveBeenCalled();
});
it('should handle tool execution errors', async () => {
supplyData.mockRejectedValueOnce(new Error('Tool execution error'));
agentNodeType.description.inputs = [
{
type: NodeConnectionType.AiTool,
required: true,
},
];
await expect(
executeContext.getInputConnectionData(NodeConnectionType.AiTool, 0),
).rejects.toThrow(`Error in sub-node ${toolNode.name}`);
expect(supplyData).toHaveBeenCalled();
});
it('should return the tool when there are no issues', async () => {
agentNodeType.description.inputs = [
{
type: NodeConnectionType.AiTool,
required: true,
},
];
const result = await executeContext.getInputConnectionData(NodeConnectionType.AiTool, 0);
expect(result).toEqual([mockTool]);
expect(supplyData).toHaveBeenCalled();
});
});
});

View file

@ -1,119 +1,6 @@
import toPlainObject from 'lodash/toPlainObject';
import { DateTime } from 'luxon';
import type { IDataObject, INode, INodeType, NodeParameterValue } from 'n8n-workflow';
import { ExpressionError } from 'n8n-workflow';
import type { IDataObject, INode, INodeType } from 'n8n-workflow';
import { cleanupParameterData, ensureType, validateValueAgainstSchema } from '../utils';
describe('cleanupParameterData', () => {
it('should stringify Luxon dates in-place', () => {
const input = { x: 1, y: DateTime.now() as unknown as NodeParameterValue };
expect(typeof input.y).toBe('object');
cleanupParameterData(input);
expect(typeof input.y).toBe('string');
});
it('should stringify plain Luxon dates in-place', () => {
const input = {
x: 1,
y: toPlainObject(DateTime.now()),
};
expect(typeof input.y).toBe('object');
cleanupParameterData(input);
expect(typeof input.y).toBe('string');
});
it('should handle objects with nameless constructors', () => {
const input = { x: 1, y: { constructor: {} } as NodeParameterValue };
expect(typeof input.y).toBe('object');
cleanupParameterData(input);
expect(typeof input.y).toBe('object');
});
it('should handle objects without a constructor', () => {
const input = { x: 1, y: { constructor: undefined } as unknown as NodeParameterValue };
expect(typeof input.y).toBe('object');
cleanupParameterData(input);
expect(typeof input.y).toBe('object');
});
});
describe('ensureType', () => {
it('throws error for null value', () => {
expect(() => ensureType('string', null, 'myParam')).toThrowError(
new ExpressionError("Parameter 'myParam' must not be null"),
);
});
it('throws error for undefined value', () => {
expect(() => ensureType('string', undefined, 'myParam')).toThrowError(
new ExpressionError("Parameter 'myParam' could not be 'undefined'"),
);
});
it('returns string value without modification', () => {
const value = 'hello';
const expectedValue = value;
const result = ensureType('string', value, 'myParam');
expect(result).toBe(expectedValue);
});
it('returns number value without modification', () => {
const value = 42;
const expectedValue = value;
const result = ensureType('number', value, 'myParam');
expect(result).toBe(expectedValue);
});
it('returns boolean value without modification', () => {
const value = true;
const expectedValue = value;
const result = ensureType('boolean', value, 'myParam');
expect(result).toBe(expectedValue);
});
it('converts object to string if toType is string', () => {
const value = { name: 'John' };
const expectedValue = JSON.stringify(value);
const result = ensureType('string', value, 'myParam');
expect(result).toBe(expectedValue);
});
it('converts string to number if toType is number', () => {
const value = '10';
const expectedValue = 10;
const result = ensureType('number', value, 'myParam');
expect(result).toBe(expectedValue);
});
it('throws error for invalid conversion to number', () => {
const value = 'invalid';
expect(() => ensureType('number', value, 'myParam')).toThrowError(
new ExpressionError("Parameter 'myParam' must be a number, but we got 'invalid'"),
);
});
it('parses valid JSON string to object if toType is object', () => {
const value = '{"name": "Alice"}';
const expectedValue = JSON.parse(value);
const result = ensureType('object', value, 'myParam');
expect(result).toEqual(expectedValue);
});
it('throws error for invalid JSON string to object conversion', () => {
const value = 'invalid_json';
expect(() => ensureType('object', value, 'myParam')).toThrowError(
new ExpressionError("Parameter 'myParam' could not be parsed"),
);
});
it('throws error for non-array value if toType is array', () => {
const value = { name: 'Alice' };
expect(() => ensureType('array', value, 'myParam')).toThrowError(
new ExpressionError("Parameter 'myParam' must be an array, but we got object"),
);
});
});
import { validateValueAgainstSchema } from '../validateValueAgainstSchema';
describe('validateValueAgainstSchema', () => {
test('should validate fixedCollection values parameter', () => {

View file

@ -0,0 +1,31 @@
import { DateTime } from 'luxon';
import type { INodeParameters, NodeParameterValueType } from 'n8n-workflow';
/**
* Clean up parameter data to make sure that only valid data gets returned
* INFO: Currently only converts Luxon Dates as we know for sure it will not be breaking
*/
export function cleanupParameterData(inputData: NodeParameterValueType): void {
if (typeof inputData !== 'object' || inputData === null) {
return;
}
if (Array.isArray(inputData)) {
inputData.forEach((value) => cleanupParameterData(value as NodeParameterValueType));
return;
}
if (typeof inputData === 'object') {
Object.keys(inputData).forEach((key) => {
const value = (inputData as INodeParameters)[key];
if (typeof value === 'object') {
if (DateTime.isDateTime(value)) {
// Is a special luxon date so convert to string
(inputData as INodeParameters)[key] = value.toString();
} else {
cleanupParameterData(value);
}
}
});
}
}

View file

@ -0,0 +1,103 @@
import type { EnsureTypeOptions } from 'n8n-workflow';
import { ExpressionError } from 'n8n-workflow';
export function ensureType(
toType: EnsureTypeOptions,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
parameterValue: any,
parameterName: string,
errorOptions?: { itemIndex?: number; runIndex?: number; nodeCause?: string },
): string | number | boolean | object {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
let returnData = parameterValue;
if (returnData === null) {
throw new ExpressionError(`Parameter '${parameterName}' must not be null`, errorOptions);
}
if (returnData === undefined) {
throw new ExpressionError(
`Parameter '${parameterName}' could not be 'undefined'`,
errorOptions,
);
}
if (['object', 'array', 'json'].includes(toType)) {
if (typeof returnData !== 'object') {
// if value is not an object and is string try to parse it, else throw an error
if (typeof returnData === 'string' && returnData.length) {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const parsedValue = JSON.parse(returnData);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
returnData = parsedValue;
} catch (error) {
throw new ExpressionError(`Parameter '${parameterName}' could not be parsed`, {
...errorOptions,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
description: error.message,
});
}
} else {
throw new ExpressionError(
`Parameter '${parameterName}' must be an ${toType}, but we got '${String(parameterValue)}'`,
errorOptions,
);
}
} else if (toType === 'json') {
// value is an object, make sure it is valid JSON
try {
JSON.stringify(returnData);
} catch (error) {
throw new ExpressionError(`Parameter '${parameterName}' is not valid JSON`, {
...errorOptions,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
description: error.message,
});
}
}
if (toType === 'array' && !Array.isArray(returnData)) {
// value is not an array, but has to be
throw new ExpressionError(
`Parameter '${parameterName}' must be an array, but we got object`,
errorOptions,
);
}
}
try {
if (toType === 'string') {
if (typeof returnData === 'object') {
returnData = JSON.stringify(returnData);
} else {
returnData = String(returnData);
}
}
if (toType === 'number') {
returnData = Number(returnData);
if (Number.isNaN(returnData)) {
throw new ExpressionError(
`Parameter '${parameterName}' must be a number, but we got '${parameterValue}'`,
errorOptions,
);
}
}
if (toType === 'boolean') {
returnData = Boolean(returnData);
}
} catch (error) {
if (error instanceof ExpressionError) throw error;
throw new ExpressionError(`Parameter '${parameterName}' could not be converted to ${toType}`, {
...errorOptions,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
description: error.message,
});
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return returnData;
}

View file

@ -0,0 +1,74 @@
import type {
IRunExecutionData,
IWorkflowDataProxyAdditionalKeys,
IWorkflowExecuteAdditionalData,
WorkflowExecuteMode,
} from 'n8n-workflow';
import { LoggerProxy } from 'n8n-workflow';
import { PLACEHOLDER_EMPTY_EXECUTION_ID } from '@/Constants';
import {
setWorkflowExecutionMetadata,
setAllWorkflowExecutionMetadata,
getWorkflowExecutionMetadata,
getAllWorkflowExecutionMetadata,
} from '@/ExecutionMetadata';
import { getSecretsProxy } from '@/Secrets';
/** Returns the additional keys for Expressions and Function-Nodes */
export function getAdditionalKeys(
additionalData: IWorkflowExecuteAdditionalData,
mode: WorkflowExecuteMode,
runExecutionData: IRunExecutionData | null,
options?: { secretsEnabled?: boolean },
): IWorkflowDataProxyAdditionalKeys {
const executionId = additionalData.executionId ?? PLACEHOLDER_EMPTY_EXECUTION_ID;
const resumeUrl = `${additionalData.webhookWaitingBaseUrl}/${executionId}`;
const resumeFormUrl = `${additionalData.formWaitingBaseUrl}/${executionId}`;
return {
$execution: {
id: executionId,
mode: mode === 'manual' ? 'test' : 'production',
resumeUrl,
resumeFormUrl,
customData: runExecutionData
? {
set(key: string, value: string): void {
try {
setWorkflowExecutionMetadata(runExecutionData, key, value);
} catch (e) {
if (mode === 'manual') {
throw e;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
LoggerProxy.debug(e.message);
}
},
setAll(obj: Record<string, string>): void {
try {
setAllWorkflowExecutionMetadata(runExecutionData, obj);
} catch (e) {
if (mode === 'manual') {
throw e;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
LoggerProxy.debug(e.message);
}
},
get(key: string): string {
return getWorkflowExecutionMetadata(runExecutionData, key);
},
getAll(): Record<string, string> {
return getAllWorkflowExecutionMetadata(runExecutionData);
},
}
: undefined,
},
$vars: additionalData.variables,
$secrets: options?.secretsEnabled ? getSecretsProxy(additionalData) : undefined,
// deprecated
$executionId: executionId,
$resumeWebhookUrl: resumeUrl,
};
}

View file

@ -0,0 +1,184 @@
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import type {
CloseFunction,
IExecuteData,
IExecuteFunctions,
INodeExecutionData,
IRunExecutionData,
ITaskDataConnections,
IWorkflowExecuteAdditionalData,
Workflow,
WorkflowExecuteMode,
SupplyData,
AINodeConnectionType,
} from 'n8n-workflow';
import {
NodeConnectionType,
NodeOperationError,
ExecutionBaseError,
ApplicationError,
} from 'n8n-workflow';
import { createNodeAsTool } from '@/CreateNodeAsTool';
// eslint-disable-next-line import/no-cycle
import { SupplyDataContext } from '@/node-execution-context';
import type { ExecuteContext, WebhookContext } from '@/node-execution-context';
export async function getInputConnectionData(
this: ExecuteContext | WebhookContext | SupplyDataContext,
workflow: Workflow,
runExecutionData: IRunExecutionData,
parentRunIndex: number,
connectionInputData: INodeExecutionData[],
parentInputData: ITaskDataConnections,
additionalData: IWorkflowExecuteAdditionalData,
executeData: IExecuteData,
mode: WorkflowExecuteMode,
closeFunctions: CloseFunction[],
connectionType: AINodeConnectionType,
itemIndex: number,
abortSignal?: AbortSignal,
): Promise<unknown> {
const parentNode = this.getNode();
const inputConfiguration = this.nodeInputs.find((input) => input.type === connectionType);
if (inputConfiguration === undefined) {
throw new ApplicationError('Node does not have input of type', {
extra: { nodeName: parentNode.name, connectionType },
});
}
const connectedNodes = this.getConnectedNodes(connectionType);
if (connectedNodes.length === 0) {
if (inputConfiguration.required) {
throw new NodeOperationError(
parentNode,
`A ${inputConfiguration?.displayName ?? connectionType} sub-node must be connected and enabled`,
);
}
return inputConfiguration.maxConnections === 1 ? undefined : [];
}
if (
inputConfiguration.maxConnections !== undefined &&
connectedNodes.length > inputConfiguration.maxConnections
) {
throw new NodeOperationError(
parentNode,
`Only ${inputConfiguration.maxConnections} ${connectionType} sub-nodes are/is allowed to be connected`,
);
}
const nodes: SupplyData[] = [];
for (const connectedNode of connectedNodes) {
const connectedNodeType = workflow.nodeTypes.getByNameAndVersion(
connectedNode.type,
connectedNode.typeVersion,
);
const contextFactory = (runIndex: number, inputData: ITaskDataConnections) =>
new SupplyDataContext(
workflow,
connectedNode,
additionalData,
mode,
runExecutionData,
runIndex,
connectionInputData,
inputData,
connectionType,
executeData,
closeFunctions,
abortSignal,
);
if (!connectedNodeType.supplyData) {
if (connectedNodeType.description.outputs.includes(NodeConnectionType.AiTool)) {
/**
* This keeps track of how many times this specific AI tool node has been invoked.
* It is incremented on every invocation of the tool to keep the output of each invocation separate from each other.
*/
let toolRunIndex = 0;
const supplyData = createNodeAsTool({
node: connectedNode,
nodeType: connectedNodeType,
handleToolInvocation: async (toolArgs) => {
const runIndex = toolRunIndex++;
const context = contextFactory(runIndex, {});
context.addInputData(NodeConnectionType.AiTool, [[{ json: toolArgs }]]);
try {
// Execute the sub-node with the proxied context
const result = await connectedNodeType.execute?.call(
context as unknown as IExecuteFunctions,
);
// Process and map the results
const mappedResults = result?.[0]?.flatMap((item) => item.json);
// Add output data to the context
context.addOutputData(NodeConnectionType.AiTool, runIndex, [
[{ json: { response: mappedResults } }],
]);
// Return the stringified results
return JSON.stringify(mappedResults);
} catch (error) {
const nodeError = new NodeOperationError(connectedNode, error as Error);
context.addOutputData(NodeConnectionType.AiTool, runIndex, nodeError);
return 'Error during node execution: ' + nodeError.description;
}
},
});
nodes.push(supplyData);
} else {
throw new ApplicationError('Node does not have a `supplyData` method defined', {
extra: { nodeName: connectedNode.name },
});
}
} else {
const context = contextFactory(parentRunIndex, parentInputData);
try {
const supplyData = await connectedNodeType.supplyData.call(context, itemIndex);
if (supplyData.closeFunction) {
closeFunctions.push(supplyData.closeFunction);
}
nodes.push(supplyData);
} catch (error) {
// Propagate errors from sub-nodes
if (error instanceof ExecutionBaseError) {
if (error.functionality === 'configuration-node') throw error;
} else {
error = new NodeOperationError(connectedNode, error, {
itemIndex,
});
}
let currentNodeRunIndex = 0;
if (runExecutionData.resultData.runData.hasOwnProperty(parentNode.name)) {
currentNodeRunIndex = runExecutionData.resultData.runData[parentNode.name].length;
}
// Display the error on the node which is causing it
await context.addExecutionDataFunctions(
'input',
error,
connectionType,
parentNode.name,
currentNodeRunIndex,
);
// Display on the calling node which node has the error
throw new NodeOperationError(connectedNode, `Error in sub-node ${connectedNode.name}`, {
itemIndex,
functionality: 'configuration-node',
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
description: error.message,
});
}
}
}
return inputConfiguration.maxConnections === 1
? (nodes || [])[0]?.response
: nodes.map((node) => node.response);
}

View file

@ -0,0 +1,218 @@
import type {
FieldType,
IDataObject,
INode,
INodeProperties,
INodePropertyCollection,
INodePropertyOptions,
INodeType,
} from 'n8n-workflow';
import {
ExpressionError,
isResourceMapperValue,
NodeHelpers,
validateFieldType,
} from 'n8n-workflow';
import type { ExtendedValidationResult } from '@/Interfaces';
const validateResourceMapperValue = (
parameterName: string,
paramValues: { [key: string]: unknown },
node: INode,
skipRequiredCheck = false,
): ExtendedValidationResult => {
const result: ExtendedValidationResult = { valid: true, newValue: paramValues };
const paramNameParts = parameterName.split('.');
if (paramNameParts.length !== 2) {
return result;
}
const resourceMapperParamName = paramNameParts[0];
const resourceMapperField = node.parameters[resourceMapperParamName];
if (!resourceMapperField || !isResourceMapperValue(resourceMapperField)) {
return result;
}
const schema = resourceMapperField.schema;
const paramValueNames = Object.keys(paramValues);
for (let i = 0; i < paramValueNames.length; i++) {
const key = paramValueNames[i];
const resolvedValue = paramValues[key];
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call
const schemaEntry = schema.find((s) => s.id === key);
if (
!skipRequiredCheck &&
schemaEntry?.required === true &&
schemaEntry.type !== 'boolean' &&
!resolvedValue
) {
return {
valid: false,
errorMessage: `The value "${String(key)}" is required but not set`,
fieldName: key,
};
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (schemaEntry?.type) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const validationResult = validateFieldType(key, resolvedValue, schemaEntry.type, {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
valueOptions: schemaEntry.options,
});
if (!validationResult.valid) {
return { ...validationResult, fieldName: key };
} else {
// If it's valid, set the casted value
paramValues[key] = validationResult.newValue;
}
}
}
return result;
};
const validateCollection = (
node: INode,
runIndex: number,
itemIndex: number,
propertyDescription: INodeProperties,
parameterPath: string[],
validationResult: ExtendedValidationResult,
): ExtendedValidationResult => {
let nestedDescriptions: INodeProperties[] | undefined;
if (propertyDescription.type === 'fixedCollection') {
nestedDescriptions = (propertyDescription.options as INodePropertyCollection[]).find(
(entry) => entry.name === parameterPath[1],
)?.values;
}
if (propertyDescription.type === 'collection') {
nestedDescriptions = propertyDescription.options as INodeProperties[];
}
if (!nestedDescriptions) {
return validationResult;
}
const validationMap: {
[key: string]: { type: FieldType; displayName: string; options?: INodePropertyOptions[] };
} = {};
for (const prop of nestedDescriptions) {
if (!prop.validateType || prop.ignoreValidationDuringExecution) continue;
validationMap[prop.name] = {
type: prop.validateType,
displayName: prop.displayName,
options:
prop.validateType === 'options' ? (prop.options as INodePropertyOptions[]) : undefined,
};
}
if (!Object.keys(validationMap).length) {
return validationResult;
}
if (validationResult.valid) {
for (const value of Array.isArray(validationResult.newValue)
? (validationResult.newValue as IDataObject[])
: [validationResult.newValue as IDataObject]) {
for (const key of Object.keys(value)) {
if (!validationMap[key]) continue;
const fieldValidationResult = validateFieldType(key, value[key], validationMap[key].type, {
valueOptions: validationMap[key].options,
});
if (!fieldValidationResult.valid) {
throw new ExpressionError(
`Invalid input for field '${validationMap[key].displayName}' inside '${propertyDescription.displayName}' in [item ${itemIndex}]`,
{
description: fieldValidationResult.errorMessage,
runIndex,
itemIndex,
nodeCause: node.name,
},
);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
value[key] = fieldValidationResult.newValue;
}
}
}
return validationResult;
};
export const validateValueAgainstSchema = (
node: INode,
nodeType: INodeType,
parameterValue: string | number | boolean | object | null | undefined,
parameterName: string,
runIndex: number,
itemIndex: number,
) => {
const parameterPath = parameterName.split('.');
const propertyDescription = nodeType.description.properties.find(
(prop) =>
parameterPath[0] === prop.name && NodeHelpers.displayParameter(node.parameters, prop, node),
);
if (!propertyDescription) {
return parameterValue;
}
let validationResult: ExtendedValidationResult = { valid: true, newValue: parameterValue };
if (
parameterPath.length === 1 &&
propertyDescription.validateType &&
!propertyDescription.ignoreValidationDuringExecution
) {
validationResult = validateFieldType(
parameterName,
parameterValue,
propertyDescription.validateType,
);
} else if (
propertyDescription.type === 'resourceMapper' &&
parameterPath[1] === 'value' &&
typeof parameterValue === 'object'
) {
validationResult = validateResourceMapperValue(
parameterName,
parameterValue as { [key: string]: unknown },
node,
propertyDescription.typeOptions?.resourceMapper?.mode !== 'add',
);
} else if (['fixedCollection', 'collection'].includes(propertyDescription.type)) {
validationResult = validateCollection(
node,
runIndex,
itemIndex,
propertyDescription,
parameterPath,
validationResult,
);
}
if (!validationResult.valid) {
throw new ExpressionError(
`Invalid input for '${
validationResult.fieldName
? String(validationResult.fieldName)
: propertyDescription.displayName
}' [item ${itemIndex}]`,
{
description: validationResult.errorMessage,
runIndex,
itemIndex,
nodeCause: node.name,
},
);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return validationResult.newValue;
};

View file

@ -22,13 +22,13 @@ import { ApplicationError, createDeferredPromise } from 'n8n-workflow';
import {
copyBinaryFile,
getBinaryHelperFunctions,
getInputConnectionData,
getNodeWebhookUrl,
getRequestHelperFunctions,
returnJsonArray,
} from '@/NodeExecuteFunctions';
import { NodeExecutionContext } from './node-execution-context';
import { getInputConnectionData } from './utils/getInputConnectionData';
export class WebhookContext extends NodeExecutionContext implements IWebhookFunctions {
readonly helpers: IWebhookFunctions['helpers'];

View file

@ -1992,7 +1992,28 @@ export interface IWorkflowDataProxyData {
constructor: any;
}
export type IWorkflowDataProxyAdditionalKeys = IDataObject;
export type IWorkflowDataProxyAdditionalKeys = IDataObject & {
$execution?: {
id: string;
mode: 'test' | 'production';
resumeUrl: string;
resumeFormUrl: string;
customData?: {
set(key: string, value: string): void;
setAll(obj: Record<string, string>): void;
get(key: string): string;
getAll(): Record<string, string>;
};
};
$vars?: IDataObject;
$secrets?: IDataObject;
$pageCount?: number;
/** @deprecated */
$executionId?: string;
/** @deprecated */
$resumeWebhookUrl?: string;
};
export interface IWorkflowMetadata {
id?: string;