fix(core): Introduce batch workflow rule to fix subworkflow with wait node detection (#22447)

This commit is contained in:
Guillaume Jacquart 2025-11-28 14:56:13 +01:00 committed by GitHub
parent 3edb952aae
commit e0bc4416ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 758 additions and 344 deletions

View File

@ -35,7 +35,7 @@ const affectedWorkflowSchema = z.object({
active: z.boolean(),
numberOfExecutions: z.number(),
lastUpdatedAt: z.date(),
lastExecutedAt: z.date().nullable(),
lastExecutedAt: z.date().optional(),
issues: z.array(workflowIssueSchema),
});
export type BreakingChangeAffectedWorkflow = z.infer<typeof affectedWorkflowSchema>;

View File

@ -13,6 +13,7 @@ import { createNode, createWorkflow } from './test-helpers';
import { FileAccessRule } from '../rules/v2/file-access.rule';
import { ProcessEnvAccessRule } from '../rules/v2/process-env-access.rule';
import { RemovedNodesRule } from '../rules/v2/removed-nodes.rule';
import { WaitNodeSubworkflowRule } from '../rules/v2/wait-node-subworkflow.rule';
describe('BreakingChangeService', () => {
const logger = mockLogger();
@ -120,6 +121,38 @@ describe('BreakingChangeService', () => {
expect(report.report).toHaveProperty('workflowResults');
expect(Array.isArray(report.report.workflowResults)).toBe(true);
});
it('should aggregate results from batch rules', async () => {
// Register the batch rule
const waitNodeRule = new WaitNodeSubworkflowRule();
ruleRegistry.registerAll([waitNodeRule]);
// Create a sub-workflow with ExecuteWorkflowTrigger and Wait node
const { workflow: subWorkflow } = createWorkflow('sub-wf-1', 'Sub Workflow', [
createNode('Execute Workflow Trigger', 'n8n-nodes-base.executeWorkflowTrigger'),
createNode('Wait', 'n8n-nodes-base.wait'),
]);
// Create a parent workflow that calls the sub-workflow
const { workflow: parentWorkflow } = createWorkflow('parent-wf-1', 'Parent Workflow', [
createNode('Execute Workflow', 'n8n-nodes-base.executeWorkflow', {
source: 'database',
workflowId: 'sub-wf-1',
}),
]);
workflowRepository.find.mockResolvedValue([subWorkflow, parentWorkflow] as never);
workflowRepository.count.mockResolvedValue(2);
const report = await service.detect('v2');
const waitNodeResult = report.report.workflowResults.find(
(r) => r.ruleId === 'wait-node-subworkflow-v2',
);
expect(waitNodeResult).toBeDefined();
expect(waitNodeResult?.affectedWorkflows).toHaveLength(1);
expect(waitNodeResult?.affectedWorkflows[0].id).toBe('parent-wf-1');
});
});
describe('getDetectionResults()', () => {

View File

@ -1,5 +1,5 @@
import type { BreakingChangeAffectedWorkflow } from '@n8n/api-types';
import {
BreakingChangeAffectedWorkflow,
BreakingChangeInstanceRuleResult,
BreakingChangeReportResult,
BreakingChangeVersion,
@ -10,18 +10,31 @@ import { Time } from '@n8n/constants';
import { WorkflowRepository } from '@n8n/db';
import { Container, Service } from '@n8n/di';
import { ErrorReporter } from 'n8n-core';
import { INode } from 'n8n-workflow';
import type { INode } from 'n8n-workflow';
import { RuleRegistry } from './breaking-changes.rule-registry.service';
import { allRules, RuleInstances } from './rules';
import type { IBreakingChangeWorkflowRule, IBreakingChangeInstanceRule } from './types';
import type {
IBreakingChangeBatchWorkflowRule,
IBreakingChangeInstanceRule,
IBreakingChangeWorkflowRule,
} from './types';
import { N8N_VERSION } from '../../constants';
import { CacheService } from '@/services/cache/cache.service';
interface WorkflowMetadata {
name: string;
active: boolean;
numberOfExecutions: number;
lastExecutedAt?: Date;
lastUpdatedAt: Date;
}
@Service()
export class BreakingChangeService {
private readonly batchSize = 100;
private static readonly REPORT_DURATION_CACHE_THRESHOLD = Time.seconds.toMilliseconds * 2;
private static readonly CACHE_KEY_PREFIX = 'breaking-changes:results:';
private readonly ongoingDetections = new Map<
BreakingChangeVersion,
@ -72,77 +85,28 @@ export class BreakingChangeService {
return instanceLevelResults;
}
private async getAllWorkflowRulesResults(
workflowLevelRules: IBreakingChangeWorkflowRule[],
totalWorkflows: number,
): Promise<BreakingChangeWorkflowRuleResult[]> {
const allAffectedWorkflowsByRule: Map<string, BreakingChangeAffectedWorkflow[]> = new Map();
const allResults: BreakingChangeWorkflowRuleResult[] = [];
this.logger.debug('Processing workflows in batches', {
totalWorkflows,
batchSize: this.batchSize,
});
// Process workflows in batches
for (let skip = 0; skip < totalWorkflows; skip += this.batchSize) {
const workflows = await this.workflowRepository.find({
select: ['id', 'name', 'active', 'activeVersionId', 'nodes', 'updatedAt', 'statistics'],
skip,
take: this.batchSize,
order: { id: 'ASC' },
relations: {
statistics: true,
},
});
this.logger.debug('Processing batch', {
skip,
workflowsInBatch: workflows.length,
});
for (const workflow of workflows) {
const nodesGroupedByType: Map<string, INode[]> = new Map();
for (const node of workflow.nodes) {
if (!nodesGroupedByType.has(node.type)) {
nodesGroupedByType.set(node.type, []);
}
nodesGroupedByType.get(node.type)!.push(node);
}
for (const rule of workflowLevelRules) {
const workflowDetectionResult = await rule.detectWorkflow(workflow, nodesGroupedByType);
if (workflowDetectionResult.isAffected) {
const affectedWorkflow: BreakingChangeAffectedWorkflow = {
id: workflow.id,
name: workflow.name,
active: !!workflow.activeVersionId,
issues: workflowDetectionResult.issues,
numberOfExecutions: workflow.statistics.reduce(
(acc, cur) => acc + (cur.count || 0),
0,
),
lastExecutedAt: workflow.statistics.sort(
(a, b) => b.latestEvent.getTime() - a.latestEvent.getTime(),
)[0]?.latestEvent,
lastUpdatedAt: workflow.updatedAt,
};
if (!allAffectedWorkflowsByRule.has(rule.id)) {
allAffectedWorkflowsByRule.set(rule.id, [affectedWorkflow]);
} else {
allAffectedWorkflowsByRule.get(rule.id)!.push(affectedWorkflow);
}
}
}
private groupNodesByType(nodes: INode[]): Map<string, INode[]> {
const nodesGroupedByType: Map<string, INode[]> = new Map();
for (const node of nodes) {
if (!nodesGroupedByType.has(node.type)) {
nodesGroupedByType.set(node.type, []);
}
nodesGroupedByType.get(node.type)!.push(node);
}
return nodesGroupedByType;
}
// Aggregate results
private async aggregateRegularRuleResults(
workflowLevelRules: IBreakingChangeWorkflowRule[],
allAffectedWorkflowsByRule: Map<string, BreakingChangeAffectedWorkflow[]>,
): Promise<BreakingChangeWorkflowRuleResult[]> {
const results: BreakingChangeWorkflowRuleResult[] = [];
for (const rule of workflowLevelRules) {
const workflowResults = allAffectedWorkflowsByRule.get(rule.id) || [];
const workflowResults = allAffectedWorkflowsByRule.get(rule.id) ?? [];
const isAffected = workflowResults.some((wr) => wr.issues.length > 0);
if (isAffected) {
allResults.push({
results.push({
ruleId: rule.id,
ruleTitle: rule.getMetadata().title,
ruleDescription: rule.getMetadata().description,
@ -153,7 +117,130 @@ export class BreakingChangeService {
});
}
}
return allResults;
return results;
}
private async aggregateBatchRuleResults(
batchRules: IBreakingChangeBatchWorkflowRule[],
workflowMetadataMap: Map<string, WorkflowMetadata>,
): Promise<BreakingChangeWorkflowRuleResult[]> {
const results: BreakingChangeWorkflowRuleResult[] = [];
for (const rule of batchRules) {
const batchReport = await rule.produceReport();
if (batchReport.affectedWorkflows.length === 0) {
continue;
}
const affectedWorkflows: BreakingChangeAffectedWorkflow[] = [];
for (const affected of batchReport.affectedWorkflows) {
const metadata = workflowMetadataMap.get(affected.workflowId);
if (!metadata) {
this.logger.warn('Workflow metadata not found for batch rule result', {
workflowId: affected.workflowId,
ruleId: rule.id,
});
continue;
}
affectedWorkflows.push({
id: affected.workflowId,
name: metadata.name,
active: metadata.active,
issues: affected.issues,
numberOfExecutions: metadata.numberOfExecutions,
lastExecutedAt: metadata.lastExecutedAt,
lastUpdatedAt: metadata.lastUpdatedAt,
});
}
if (affectedWorkflows.length > 0) {
results.push({
ruleId: rule.id,
ruleTitle: rule.getMetadata().title,
ruleDescription: rule.getMetadata().description,
ruleSeverity: rule.getMetadata().severity,
ruleDocumentationUrl: rule.getMetadata().documentationUrl,
affectedWorkflows,
recommendations: await rule.getRecommendations(affectedWorkflows),
});
}
}
return results;
}
private async getAllWorkflowRulesResults(
workflowLevelRules: IBreakingChangeWorkflowRule[],
batchRules: IBreakingChangeBatchWorkflowRule[],
totalWorkflows: number,
): Promise<BreakingChangeWorkflowRuleResult[]> {
const allAffectedWorkflowsByRule: Map<string, BreakingChangeAffectedWorkflow[]> = new Map();
const workflowMetadataMap: Map<string, WorkflowMetadata> = new Map();
// Reset batch rules internal state before processing
batchRules.forEach((rule) => rule.reset());
this.logger.debug('Processing workflows in batches', {
totalWorkflows,
batchSize: this.batchSize,
regularRulesCount: workflowLevelRules.length,
batchRulesCount: batchRules.length,
});
for (let skip = 0; skip < totalWorkflows; skip += this.batchSize) {
const workflows = await this.workflowRepository.find({
select: ['id', 'name', 'active', 'activeVersionId', 'nodes', 'updatedAt', 'statistics'],
skip,
take: this.batchSize,
order: { id: 'ASC' },
relations: { statistics: true },
});
this.logger.debug('Processing batch', { skip, workflowsInBatch: workflows.length });
for (const workflow of workflows) {
const nodesGroupedByType = this.groupNodesByType(workflow.nodes);
const workflowMetadata: WorkflowMetadata = {
name: workflow.name,
active: !!workflow.activeVersionId,
numberOfExecutions: workflow.statistics.reduce((acc, cur) => acc + (cur.count || 0), 0),
lastExecutedAt: workflow.statistics.sort(
(a, b) => b.latestEvent.getTime() - a.latestEvent.getTime(),
)[0]?.latestEvent,
lastUpdatedAt: workflow.updatedAt,
};
workflowMetadataMap.set(workflow.id, workflowMetadata);
for (const rule of workflowLevelRules) {
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
if (result.isAffected) {
const affectedWorkflow: BreakingChangeAffectedWorkflow = {
id: workflow.id,
issues: result.issues,
...workflowMetadata,
};
const existing = allAffectedWorkflowsByRule.get(rule.id);
if (existing) {
existing.push(affectedWorkflow);
} else {
allAffectedWorkflowsByRule.set(rule.id, [affectedWorkflow]);
}
}
}
for (const rule of batchRules) {
await rule.collectWorkflowData(workflow, nodesGroupedByType);
}
}
}
const regularResults = await this.aggregateRegularRuleResults(
workflowLevelRules,
allAffectedWorkflowsByRule,
);
const batchResults = await this.aggregateBatchRuleResults(batchRules, workflowMetadataMap);
return regularResults.concat(batchResults);
}
async refreshDetectionResults(
@ -208,7 +295,7 @@ export class BreakingChangeService {
}
private shouldCacheDetection(durationMs: number): boolean {
return durationMs > Time.seconds.toMilliseconds * 10;
return durationMs > BreakingChangeService.REPORT_DURATION_CACHE_THRESHOLD;
}
async detect(targetVersion: BreakingChangeVersion): Promise<BreakingChangeReportResult> {
@ -217,14 +304,21 @@ export class BreakingChangeService {
const rules = this.ruleRegistry.getRules(targetVersion);
const workflowLevelRules = rules.filter((rule) => 'detectWorkflow' in rule);
const instanceLevelRules = rules.filter((rule) => 'detect' in rule);
const workflowLevelRules = rules.filter(
(rule): rule is IBreakingChangeWorkflowRule => 'detectWorkflow' in rule,
);
const batchWorkflowRules = rules.filter(
(rule): rule is IBreakingChangeBatchWorkflowRule => 'collectWorkflowData' in rule,
);
const instanceLevelRules = rules.filter(
(rule): rule is IBreakingChangeInstanceRule => 'detect' in rule,
);
const totalWorkflows = await this.workflowRepository.count();
const [instanceLevelResults, workflowLevelResults] = await Promise.all([
this.getAllInstanceRulesResults(instanceLevelRules),
this.getAllWorkflowRulesResults(workflowLevelRules, totalWorkflows),
this.getAllWorkflowRulesResults(workflowLevelRules, batchWorkflowRules, totalWorkflows),
]);
const report = this.createDetectionReport(
@ -253,9 +347,13 @@ export class BreakingChangeService {
return undefined;
}
const totalWorkflows = await this.workflowRepository.count();
if ('detectWorkflow' in rule) {
const totalWorkflows = await this.workflowRepository.count();
return (await this.getAllWorkflowRulesResults([rule], totalWorkflows))[0];
return (await this.getAllWorkflowRulesResults([rule], [], totalWorkflows))[0];
}
if ('collectWorkflowData' in rule) {
return (await this.getAllWorkflowRulesResults([], [rule], totalWorkflows))[0];
}
return (await this.getAllInstanceRulesResults([rule]))[0];
}

View File

@ -8,6 +8,7 @@ describe('WaitNodeSubworkflowRule', () => {
beforeEach(() => {
jest.clearAllMocks();
rule = new WaitNodeSubworkflowRule();
rule.reset();
});
describe('getMetadata()', () => {
@ -16,9 +17,8 @@ describe('WaitNodeSubworkflowRule', () => {
expect(metadata).toMatchObject({
version: 'v2',
title: 'Waiting node behavior change in sub-workflows',
description:
'Waiting nodes (Wait, Form, and HITL nodes) in sub-workflows now return data from the last node instead of the node before the waiting node',
title: 'Sub-workflow waiting node output behavior change',
description: expect.stringContaining('Parent workflows calling sub-workflows'),
category: BreakingChangeCategory.workflow,
severity: 'medium',
});
@ -29,252 +29,393 @@ describe('WaitNodeSubworkflowRule', () => {
it('should return recommendations', async () => {
const recommendations = await rule.getRecommendations([]);
expect(recommendations).toHaveLength(3);
expect(recommendations).toEqual([
{
action: 'Review sub-workflow output handling',
description:
'Check workflows that use Execute Workflow node to call sub-workflows containing waiting nodes (Wait, Form, or HITL nodes). The output data structure may have changed.',
},
{
action: 'Update downstream logic',
description:
'Adjust any logic in parent workflows that depends on the data returned from sub-workflows with waiting nodes, as it now returns the last node data instead of the node before the waiting node.',
},
{
action: 'Test affected workflows',
description:
'Test all workflows with Execute Workflow nodes calling sub-workflows that contain waiting nodes to ensure the new behavior works as expected.',
},
]);
expect(recommendations).toHaveLength(2);
expect(recommendations[0].action).toBe('Review Execute Workflow node outputs');
expect(recommendations[1].action).toBe('Test affected parent workflows');
});
});
describe('detectWorkflow()', () => {
it.each([
{
description: 'workflow has no waiting nodes',
nodes: [
createNode('HTTP', 'n8n-nodes-base.httpRequest'),
createNode('ExecuteWorkflowTrigger', 'n8n-nodes-base.executeWorkflowTrigger'),
],
},
{
description: 'workflow has waiting nodes but no Execute Workflow Trigger',
nodes: [
createNode('HTTP', 'n8n-nodes-base.httpRequest'),
createNode('Wait', 'n8n-nodes-base.wait'),
],
},
{
description: 'workflow has Execute Workflow Trigger but no waiting nodes',
nodes: [
createNode('HTTP', 'n8n-nodes-base.httpRequest'),
createNode('ExecuteWorkflowTrigger', 'n8n-nodes-base.executeWorkflowTrigger'),
],
},
])('should return no issues when $description', async ({ nodes }) => {
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', nodes);
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
expect(result).toEqual({
isAffected: false,
issues: [],
});
});
it('should detect sub-workflow with Wait nodes', async () => {
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
describe('batch workflow detection', () => {
it('should flag parent workflow when it calls a sub-workflow with waiting nodes', async () => {
// Create a sub-workflow with waiting nodes
const subWorkflow = createWorkflow('sub-wf-1', 'Sub Workflow', [
createNode('ExecuteWorkflowTrigger', 'n8n-nodes-base.executeWorkflowTrigger'),
createNode('Wait', 'n8n-nodes-base.wait'),
createNode('HTTP', 'n8n-nodes-base.httpRequest'),
]);
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
expect(result.isAffected).toBe(true);
expect(result.issues).toHaveLength(1);
expect(result.issues[0].nodeId).toBeDefined();
expect(result.issues[0].nodeName).toBe('Wait');
});
it('should detect sub-workflow with multiple Wait nodes', async () => {
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
createNode('ExecuteWorkflowTrigger', 'n8n-nodes-base.executeWorkflowTrigger'),
createNode('Wait1', 'n8n-nodes-base.wait'),
createNode('Wait2', 'n8n-nodes-base.wait'),
createNode('HTTP', 'n8n-nodes-base.httpRequest'),
// Create a parent workflow that calls the sub-workflow
const parentWorkflow = createWorkflow('parent-wf-1', 'Parent Workflow', [
createNode('Start', 'n8n-nodes-base.manualTrigger'),
createNode('ExecuteWorkflow', 'n8n-nodes-base.executeWorkflow', {
source: 'database',
workflowId: { value: 'sub-wf-1' },
}),
]);
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
// Collect data from both workflows
await rule.collectWorkflowData(subWorkflow.workflow, subWorkflow.nodesGroupedByType);
await rule.collectWorkflowData(parentWorkflow.workflow, parentWorkflow.nodesGroupedByType);
expect(result.isAffected).toBe(true);
expect(result.issues).toHaveLength(2);
expect(result.issues[0].nodeId).toBeDefined();
expect(result.issues[0].nodeName).toBe('Wait1');
expect(result.issues[1].nodeId).toBeDefined();
expect(result.issues[1].nodeName).toBe('Wait2');
// Produce report
const report = await rule.produceReport();
expect(report.affectedWorkflows).toHaveLength(1);
expect(report.affectedWorkflows[0].workflowId).toBe('parent-wf-1');
expect(report.affectedWorkflows[0].issues).toHaveLength(1);
expect(report.affectedWorkflows[0].issues[0].nodeName).toBe('ExecuteWorkflow');
expect(report.affectedWorkflows[0].issues[0].description).toContain('sub-wf-1');
});
it('should detect complex sub-workflow with Wait among other nodes', async () => {
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
it('should NOT flag sub-workflow itself', async () => {
// Create a sub-workflow with waiting nodes (no parent calling it)
const subWorkflow = createWorkflow('sub-wf-1', 'Sub Workflow', [
createNode('ExecuteWorkflowTrigger', 'n8n-nodes-base.executeWorkflowTrigger'),
createNode('Wait', 'n8n-nodes-base.wait'),
]);
await rule.collectWorkflowData(subWorkflow.workflow, subWorkflow.nodesGroupedByType);
const report = await rule.produceReport();
// Sub-workflow should NOT be in the affected list - only parents should be flagged
expect(report.affectedWorkflows).toHaveLength(0);
});
it('should NOT flag parent when waitForSubWorkflow is false', async () => {
// Create a sub-workflow with waiting nodes
const subWorkflow = createWorkflow('sub-wf-1', 'Sub Workflow', [
createNode('ExecuteWorkflowTrigger', 'n8n-nodes-base.executeWorkflowTrigger'),
createNode('Wait', 'n8n-nodes-base.wait'),
]);
// Create a parent workflow that does NOT wait for sub-workflow completion
const parentWorkflow = createWorkflow('parent-wf-1', 'Parent Workflow', [
createNode('Start', 'n8n-nodes-base.manualTrigger'),
createNode('ExecuteWorkflow', 'n8n-nodes-base.executeWorkflow', {
source: 'database',
workflowId: { value: 'sub-wf-1' },
options: { waitForSubWorkflow: false },
}),
]);
await rule.collectWorkflowData(subWorkflow.workflow, subWorkflow.nodesGroupedByType);
await rule.collectWorkflowData(parentWorkflow.workflow, parentWorkflow.nodesGroupedByType);
const report = await rule.produceReport();
expect(report.affectedWorkflows).toHaveLength(0);
});
it('should flag parent when waitForSubWorkflow is true (explicit)', async () => {
const subWorkflow = createWorkflow('sub-wf-1', 'Sub Workflow', [
createNode('ExecuteWorkflowTrigger', 'n8n-nodes-base.executeWorkflowTrigger'),
createNode('Wait', 'n8n-nodes-base.wait'),
]);
const parentWorkflow = createWorkflow('parent-wf-1', 'Parent Workflow', [
createNode('ExecuteWorkflow', 'n8n-nodes-base.executeWorkflow', {
source: 'database',
workflowId: { value: 'sub-wf-1' },
options: { waitForSubWorkflow: true },
}),
]);
await rule.collectWorkflowData(subWorkflow.workflow, subWorkflow.nodesGroupedByType);
await rule.collectWorkflowData(parentWorkflow.workflow, parentWorkflow.nodesGroupedByType);
const report = await rule.produceReport();
expect(report.affectedWorkflows).toHaveLength(1);
expect(report.affectedWorkflows[0].workflowId).toBe('parent-wf-1');
});
it('should flag parent when waitForSubWorkflow is not set (defaults to true)', async () => {
const subWorkflow = createWorkflow('sub-wf-1', 'Sub Workflow', [
createNode('ExecuteWorkflowTrigger', 'n8n-nodes-base.executeWorkflowTrigger'),
createNode('Wait', 'n8n-nodes-base.wait'),
]);
const parentWorkflow = createWorkflow('parent-wf-1', 'Parent Workflow', [
createNode('ExecuteWorkflow', 'n8n-nodes-base.executeWorkflow', {
source: 'database',
workflowId: { value: 'sub-wf-1' },
// No options set - waitForSubWorkflow defaults to true
}),
]);
await rule.collectWorkflowData(subWorkflow.workflow, subWorkflow.nodesGroupedByType);
await rule.collectWorkflowData(parentWorkflow.workflow, parentWorkflow.nodesGroupedByType);
const report = await rule.produceReport();
expect(report.affectedWorkflows).toHaveLength(1);
});
it('should flag parent when waitForSubWorkflow is an expression (treated as true)', async () => {
const subWorkflow = createWorkflow('sub-wf-1', 'Sub Workflow', [
createNode('ExecuteWorkflowTrigger', 'n8n-nodes-base.executeWorkflowTrigger'),
createNode('Wait', 'n8n-nodes-base.wait'),
]);
const parentWorkflow = createWorkflow('parent-wf-1', 'Parent Workflow', [
createNode('ExecuteWorkflow', 'n8n-nodes-base.executeWorkflow', {
source: 'database',
workflowId: { value: 'sub-wf-1' },
options: { waitForSubWorkflow: '={{ $json.shouldWait }}' },
}),
]);
await rule.collectWorkflowData(subWorkflow.workflow, subWorkflow.nodesGroupedByType);
await rule.collectWorkflowData(parentWorkflow.workflow, parentWorkflow.nodesGroupedByType);
const report = await rule.produceReport();
// Expression is treated as true to avoid false negatives
expect(report.affectedWorkflows).toHaveLength(1);
expect(report.affectedWorkflows[0].workflowId).toBe('parent-wf-1');
});
it('should NOT flag parent when sub-workflow has no waiting nodes', async () => {
// Create a sub-workflow WITHOUT waiting nodes
const subWorkflow = createWorkflow('sub-wf-1', 'Sub Workflow', [
createNode('ExecuteWorkflowTrigger', 'n8n-nodes-base.executeWorkflowTrigger'),
createNode('HTTP', 'n8n-nodes-base.httpRequest'),
createNode('Wait', 'n8n-nodes-base.wait'),
createNode('Code', 'n8n-nodes-base.code'),
createNode('Set', 'n8n-nodes-base.set'),
]);
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
expect(result.isAffected).toBe(true);
expect(result.issues).toHaveLength(1);
expect(result.issues[0].level).toBe('warning');
expect(result.issues[0].nodeId).toBeDefined();
expect(result.issues[0].nodeName).toBe('Wait');
});
it('should not detect regular workflow with Wait and Execute Workflow nodes', async () => {
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
createNode('HTTP', 'n8n-nodes-base.httpRequest'),
createNode('Wait', 'n8n-nodes-base.wait'),
createNode('ExecuteWorkflow', 'n8n-nodes-base.executeWorkflow'),
const parentWorkflow = createWorkflow('parent-wf-1', 'Parent Workflow', [
createNode('ExecuteWorkflow', 'n8n-nodes-base.executeWorkflow', {
source: 'database',
workflowId: { value: 'sub-wf-1' },
}),
]);
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
await rule.collectWorkflowData(subWorkflow.workflow, subWorkflow.nodesGroupedByType);
await rule.collectWorkflowData(parentWorkflow.workflow, parentWorkflow.nodesGroupedByType);
const report = await rule.produceReport();
expect(result.isAffected).toBe(false);
expect(result.issues).toHaveLength(0);
expect(report.affectedWorkflows).toHaveLength(0);
});
it('should detect sub-workflow with Form node', async () => {
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
it('should NOT flag parent when sub-workflow has waiting nodes but no ExecuteWorkflowTrigger', async () => {
// A workflow with waiting nodes but NOT a sub-workflow (no trigger)
const regularWorkflow = createWorkflow('regular-wf', 'Regular Workflow', [
createNode('ManualTrigger', 'n8n-nodes-base.manualTrigger'),
createNode('Wait', 'n8n-nodes-base.wait'),
]);
const parentWorkflow = createWorkflow('parent-wf-1', 'Parent Workflow', [
createNode('ExecuteWorkflow', 'n8n-nodes-base.executeWorkflow', {
source: 'database',
workflowId: { value: 'regular-wf' },
}),
]);
await rule.collectWorkflowData(regularWorkflow.workflow, regularWorkflow.nodesGroupedByType);
await rule.collectWorkflowData(parentWorkflow.workflow, parentWorkflow.nodesGroupedByType);
const report = await rule.produceReport();
expect(report.affectedWorkflows).toHaveLength(0);
});
it('should handle workflowId as string', async () => {
const subWorkflow = createWorkflow('sub-wf-1', 'Sub Workflow', [
createNode('ExecuteWorkflowTrigger', 'n8n-nodes-base.executeWorkflowTrigger'),
createNode('Wait', 'n8n-nodes-base.wait'),
]);
const parentWorkflow = createWorkflow('parent-wf-1', 'Parent Workflow', [
createNode('ExecuteWorkflow', 'n8n-nodes-base.executeWorkflow', {
source: 'database',
workflowId: 'sub-wf-1', // String instead of object
}),
]);
await rule.collectWorkflowData(subWorkflow.workflow, subWorkflow.nodesGroupedByType);
await rule.collectWorkflowData(parentWorkflow.workflow, parentWorkflow.nodesGroupedByType);
const report = await rule.produceReport();
expect(report.affectedWorkflows).toHaveLength(1);
});
it('should flag when workflowId is an expression (dynamic call)', async () => {
const parentWorkflow = createWorkflow('parent-wf-1', 'Parent Workflow', [
createNode('ExecuteWorkflow', 'n8n-nodes-base.executeWorkflow', {
source: 'database',
workflowId: '={{ $json.workflowId }}', // Expression - can't evaluate statically
}),
]);
await rule.collectWorkflowData(parentWorkflow.workflow, parentWorkflow.nodesGroupedByType);
const report = await rule.produceReport();
// Should flag with "may call" warning since we can't determine the workflow ID
expect(report.affectedWorkflows).toHaveLength(1);
expect(report.affectedWorkflows[0].workflowId).toBe('parent-wf-1');
expect(report.affectedWorkflows[0].issues[0].title).toContain('may call');
expect(report.affectedWorkflows[0].issues[0].description).toContain('dynamically');
});
it('should flag when source is not database (dynamic call)', async () => {
const parentWorkflow = createWorkflow('parent-wf-1', 'Parent Workflow', [
createNode('ExecuteWorkflow', 'n8n-nodes-base.executeWorkflow', {
source: 'parameter', // JSON parameter source
workflowJson: '{}',
}),
]);
await rule.collectWorkflowData(parentWorkflow.workflow, parentWorkflow.nodesGroupedByType);
const report = await rule.produceReport();
// Should flag with "may call" warning since we can't determine the workflow ID
expect(report.affectedWorkflows).toHaveLength(1);
expect(report.affectedWorkflows[0].workflowId).toBe('parent-wf-1');
expect(report.affectedWorkflows[0].issues[0].title).toContain('may call');
});
it('should flag parent when sub-workflow has Form node', async () => {
const subWorkflow = createWorkflow('sub-wf-1', 'Sub Workflow', [
createNode('ExecuteWorkflowTrigger', 'n8n-nodes-base.executeWorkflowTrigger'),
createNode('Form', 'n8n-nodes-base.form'),
createNode('HTTP', 'n8n-nodes-base.httpRequest'),
]);
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
const parentWorkflow = createWorkflow('parent-wf-1', 'Parent Workflow', [
createNode('ExecuteWorkflow', 'n8n-nodes-base.executeWorkflow', {
source: 'database',
workflowId: { value: 'sub-wf-1' },
}),
]);
expect(result.isAffected).toBe(true);
expect(result.issues).toHaveLength(1);
expect(result.issues[0].description).toContain('form');
expect(result.issues[0].nodeId).toBeDefined();
expect(result.issues[0].nodeName).toBe('Form');
await rule.collectWorkflowData(subWorkflow.workflow, subWorkflow.nodesGroupedByType);
await rule.collectWorkflowData(parentWorkflow.workflow, parentWorkflow.nodesGroupedByType);
const report = await rule.produceReport();
expect(report.affectedWorkflows).toHaveLength(1);
});
it.each([
{
nodeName: 'Slack',
nodeType: 'n8n-nodes-base.slack',
operation: 'sendAndWait',
expectedInDescription: 'slack',
},
])(
'should detect sub-workflow with $nodeName HITL node using $operation operation',
async ({ nodeName, nodeType, operation, expectedInDescription }) => {
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
createNode('ExecuteWorkflowTrigger', 'n8n-nodes-base.executeWorkflowTrigger'),
createNode(nodeName, nodeType, { operation }),
createNode('HTTP', 'n8n-nodes-base.httpRequest'),
]);
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
expect(result.isAffected).toBe(true);
expect(result.issues).toHaveLength(1);
expect(result.issues[0].description).toContain(expectedInDescription);
expect(result.issues[0].nodeId).toBeDefined();
expect(result.issues[0].nodeName).toBe(nodeName);
},
);
it.each([
{
nodeName: 'Slack',
nodeType: 'n8n-nodes-base.slack',
nonWaitingOperation: 'sendMessage',
},
{
nodeName: 'Telegram',
nodeType: 'n8n-nodes-base.telegram',
nonWaitingOperation: 'sendMessage',
},
{
nodeName: 'GitHub',
nodeType: 'n8n-nodes-base.github',
nonWaitingOperation: 'getIssue',
},
])(
'should NOT detect sub-workflow with $nodeName node without $waitingOperation operation',
async ({ nodeName, nodeType, nonWaitingOperation }) => {
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
createNode('ExecuteWorkflowTrigger', 'n8n-nodes-base.executeWorkflowTrigger'),
createNode(nodeName, nodeType, { operation: nonWaitingOperation }),
createNode('HTTP', 'n8n-nodes-base.httpRequest'),
]);
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
expect(result.isAffected).toBe(false);
expect(result.issues).toHaveLength(0);
},
);
it('should detect sub-workflow with multiple HITL node types', async () => {
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
it('should flag parent when sub-workflow has HITL node with sendAndWait operation', async () => {
const subWorkflow = createWorkflow('sub-wf-1', 'Sub Workflow', [
createNode('ExecuteWorkflowTrigger', 'n8n-nodes-base.executeWorkflowTrigger'),
createNode('Wait', 'n8n-nodes-base.wait'),
createNode('Form', 'n8n-nodes-base.form'),
createNode('Slack', 'n8n-nodes-base.slack', { operation: 'sendAndWait' }),
createNode('HTTP', 'n8n-nodes-base.httpRequest'),
]);
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
const parentWorkflow = createWorkflow('parent-wf-1', 'Parent Workflow', [
createNode('ExecuteWorkflow', 'n8n-nodes-base.executeWorkflow', {
source: 'database',
workflowId: { value: 'sub-wf-1' },
}),
]);
expect(result.isAffected).toBe(true);
expect(result.issues).toHaveLength(3);
expect(result.issues[0].description).toContain('wait');
expect(result.issues[0].nodeId).toBeDefined();
expect(result.issues[0].nodeName).toBe('Wait');
expect(result.issues[1].description).toContain('form');
expect(result.issues[1].nodeId).toBeDefined();
expect(result.issues[1].nodeName).toBe('Form');
expect(result.issues[2].description).toContain('slack');
expect(result.issues[2].nodeId).toBeDefined();
expect(result.issues[2].nodeName).toBe('Slack');
await rule.collectWorkflowData(subWorkflow.workflow, subWorkflow.nodesGroupedByType);
await rule.collectWorkflowData(parentWorkflow.workflow, parentWorkflow.nodesGroupedByType);
const report = await rule.produceReport();
expect(report.affectedWorkflows).toHaveLength(1);
});
it.each([
{ nodeName: 'Telegram', nodeType: 'n8n-nodes-base.telegram', operation: 'sendAndWait' },
{ nodeName: 'EmailSend', nodeType: 'n8n-nodes-base.emailSend', operation: 'sendAndWait' },
{
nodeName: 'MicrosoftTeams',
nodeType: 'n8n-nodes-base.microsoftTeams',
operation: 'sendAndWait',
},
{
nodeName: 'MicrosoftOutlook',
nodeType: 'n8n-nodes-base.microsoftOutlook',
operation: 'sendAndWait',
},
{ nodeName: 'Discord', nodeType: 'n8n-nodes-base.discord', operation: 'sendAndWait' },
{ nodeName: 'GitHub', nodeType: 'n8n-nodes-base.github', operation: 'dispatchAndWait' },
])(
'should detect sub-workflow with $nodeName HITL node using $operation',
async ({ nodeName, nodeType, operation }) => {
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
createNode('ExecuteWorkflowTrigger', 'n8n-nodes-base.executeWorkflowTrigger'),
createNode(nodeName, nodeType, { operation }),
]);
it('should NOT flag when HITL node does not use waiting operation', async () => {
const subWorkflow = createWorkflow('sub-wf-1', 'Sub Workflow', [
createNode('ExecuteWorkflowTrigger', 'n8n-nodes-base.executeWorkflowTrigger'),
createNode('Slack', 'n8n-nodes-base.slack', { operation: 'sendMessage' }), // Not sendAndWait
]);
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
const parentWorkflow = createWorkflow('parent-wf-1', 'Parent Workflow', [
createNode('ExecuteWorkflow', 'n8n-nodes-base.executeWorkflow', {
source: 'database',
workflowId: { value: 'sub-wf-1' },
}),
]);
expect(result.isAffected).toBe(true);
expect(result.issues).toHaveLength(1);
expect(result.issues[0].nodeId).toBeDefined();
expect(result.issues[0].nodeName).toBe(nodeName);
},
);
await rule.collectWorkflowData(subWorkflow.workflow, subWorkflow.nodesGroupedByType);
await rule.collectWorkflowData(parentWorkflow.workflow, parentWorkflow.nodesGroupedByType);
const report = await rule.produceReport();
expect(report.affectedWorkflows).toHaveLength(0);
});
it('should flag parent calling multiple affected sub-workflows', async () => {
const subWorkflow1 = createWorkflow('sub-wf-1', 'Sub Workflow 1', [
createNode('ExecuteWorkflowTrigger', 'n8n-nodes-base.executeWorkflowTrigger'),
createNode('Wait', 'n8n-nodes-base.wait'),
]);
const subWorkflow2 = createWorkflow('sub-wf-2', 'Sub Workflow 2', [
createNode('ExecuteWorkflowTrigger', 'n8n-nodes-base.executeWorkflowTrigger'),
createNode('Form', 'n8n-nodes-base.form'),
]);
const parentWorkflow = createWorkflow('parent-wf-1', 'Parent Workflow', [
createNode('ExecuteWorkflow1', 'n8n-nodes-base.executeWorkflow', {
source: 'database',
workflowId: { value: 'sub-wf-1' },
}),
createNode('ExecuteWorkflow2', 'n8n-nodes-base.executeWorkflow', {
source: 'database',
workflowId: { value: 'sub-wf-2' },
}),
]);
await rule.collectWorkflowData(subWorkflow1.workflow, subWorkflow1.nodesGroupedByType);
await rule.collectWorkflowData(subWorkflow2.workflow, subWorkflow2.nodesGroupedByType);
await rule.collectWorkflowData(parentWorkflow.workflow, parentWorkflow.nodesGroupedByType);
const report = await rule.produceReport();
expect(report.affectedWorkflows).toHaveLength(1);
expect(report.affectedWorkflows[0].workflowId).toBe('parent-wf-1');
expect(report.affectedWorkflows[0].issues).toHaveLength(2);
});
it('should flag multiple parents calling the same affected sub-workflow', async () => {
const subWorkflow = createWorkflow('sub-wf-1', 'Sub Workflow', [
createNode('ExecuteWorkflowTrigger', 'n8n-nodes-base.executeWorkflowTrigger'),
createNode('Wait', 'n8n-nodes-base.wait'),
]);
const parentWorkflow1 = createWorkflow('parent-wf-1', 'Parent Workflow 1', [
createNode('ExecuteWorkflow', 'n8n-nodes-base.executeWorkflow', {
source: 'database',
workflowId: { value: 'sub-wf-1' },
}),
]);
const parentWorkflow2 = createWorkflow('parent-wf-2', 'Parent Workflow 2', [
createNode('ExecuteWorkflow', 'n8n-nodes-base.executeWorkflow', {
source: 'database',
workflowId: { value: 'sub-wf-1' },
}),
]);
await rule.collectWorkflowData(subWorkflow.workflow, subWorkflow.nodesGroupedByType);
await rule.collectWorkflowData(parentWorkflow1.workflow, parentWorkflow1.nodesGroupedByType);
await rule.collectWorkflowData(parentWorkflow2.workflow, parentWorkflow2.nodesGroupedByType);
const report = await rule.produceReport();
expect(report.affectedWorkflows).toHaveLength(2);
const workflowIds = report.affectedWorkflows.map((w) => w.workflowId);
expect(workflowIds).toContain('parent-wf-1');
expect(workflowIds).toContain('parent-wf-2');
});
it('should reset state correctly', async () => {
const subWorkflow = createWorkflow('sub-wf-1', 'Sub Workflow', [
createNode('ExecuteWorkflowTrigger', 'n8n-nodes-base.executeWorkflowTrigger'),
createNode('Wait', 'n8n-nodes-base.wait'),
]);
const parentWorkflow = createWorkflow('parent-wf-1', 'Parent Workflow', [
createNode('ExecuteWorkflow', 'n8n-nodes-base.executeWorkflow', {
source: 'database',
workflowId: { value: 'sub-wf-1' },
}),
]);
// First run
await rule.collectWorkflowData(subWorkflow.workflow, subWorkflow.nodesGroupedByType);
await rule.collectWorkflowData(parentWorkflow.workflow, parentWorkflow.nodesGroupedByType);
const report1 = await rule.produceReport();
expect(report1.affectedWorkflows).toHaveLength(1);
// Reset
rule.reset();
// Second run should be empty without collecting data again
const report2 = await rule.produceReport();
expect(report2.affectedWorkflows).toHaveLength(0);
});
});
});

View File

@ -1,20 +1,34 @@
import type { BreakingChangeAffectedWorkflow, BreakingChangeRecommendation } from '@n8n/api-types';
import type {
BreakingChangeAffectedWorkflow,
BreakingChangeRecommendation,
BreakingChangeWorkflowIssue,
} from '@n8n/api-types';
import type { WorkflowEntity } from '@n8n/db';
import { Service } from '@n8n/di';
import type { INode } from 'n8n-workflow';
import type { INode, INodeParameters } from 'n8n-workflow';
import { SEND_AND_WAIT_OPERATION } from 'n8n-workflow';
import type {
BatchWorkflowDetectionReport,
BreakingChangeRuleMetadata,
IBreakingChangeWorkflowRule,
WorkflowDetectionReport,
IBreakingChangeBatchWorkflowRule,
} from '../../types';
import { BreakingChangeCategory } from '../../types';
interface ParentWorkflowInfo {
parentWorkflowId: string;
executeWorkflowNode: INode;
calledWorkflowId?: string;
}
@Service()
export class WaitNodeSubworkflowRule implements IBreakingChangeWorkflowRule {
export class WaitNodeSubworkflowRule implements IBreakingChangeBatchWorkflowRule {
id: string = 'wait-node-subworkflow-v2';
// Internal state for batch processing
private subWorkflowsWithWaitingNodes: Map<string, string> = new Map(); // workflowId -> workflowName
private parentWorkflowsCalling: ParentWorkflowInfo[] = [];
// Configuration for node types and their waiting conditions
private readonly waitingNodeConfig: Array<{ nodeTypes: string[]; operation?: string }> = [
{
@ -51,13 +65,13 @@ export class WaitNodeSubworkflowRule implements IBreakingChangeWorkflowRule {
getMetadata(): BreakingChangeRuleMetadata {
return {
version: 'v2',
title: 'Waiting node behavior change in sub-workflows',
title: 'Sub-workflow waiting node output behavior change',
description:
'Waiting nodes (Wait, Form, and HITL nodes) in sub-workflows now return data from the last node instead of the node before the waiting node',
'Parent workflows calling sub-workflows with waiting nodes (Wait, Form, HITL) now receive correct data. Previously, incorrect results were returned when the sub-workflow entered a waiting state.',
category: BreakingChangeCategory.workflow,
severity: 'medium',
documentationUrl:
'https://docs.n8n.io/2-0-breaking-changes/#return-expected-sub-workflow-data-when-it-contains-a-wait-node',
'https://docs.n8n.io/2-0-breaking-changes/#return-expected-sub-workflow-data-when-the-sub-workflow-resumes-from-waiting-waiting-for-webhook-forms-hitl-etc',
};
}
@ -66,80 +80,155 @@ export class WaitNodeSubworkflowRule implements IBreakingChangeWorkflowRule {
): Promise<BreakingChangeRecommendation[]> {
return [
{
action: 'Review sub-workflow output handling',
action: 'Review Execute Workflow node outputs',
description:
'Check workflows that use Execute Workflow node to call sub-workflows containing waiting nodes (Wait, Form, or HITL nodes). The output data structure may have changed.',
'Check the Execute Workflow nodes flagged above. If your workflow logic depends on the specific data returned from sub-workflows containing waiting nodes, verify the data structure is correct.',
},
{
action: 'Update downstream logic',
action: 'Test affected parent workflows',
description:
'Adjust any logic in parent workflows that depends on the data returned from sub-workflows with waiting nodes, as it now returns the last node data instead of the node before the waiting node.',
},
{
action: 'Test affected workflows',
description:
'Test all workflows with Execute Workflow nodes calling sub-workflows that contain waiting nodes to ensure the new behavior works as expected.',
'Run the affected parent workflows to verify the data returned from sub-workflows with waiting nodes is now correct and matches your expectations.',
},
];
}
private hasWaitingOperation(node: INode, requiredOperation: string): boolean {
const operation = node.parameters.operation;
return operation === requiredOperation;
reset(): void {
this.subWorkflowsWithWaitingNodes.clear();
this.parentWorkflowsCalling = [];
}
async detectWorkflow(
_workflow: WorkflowEntity,
async collectWorkflowData(
workflow: WorkflowEntity,
nodesGroupedByType: Map<string, INode[]>,
): Promise<WorkflowDetectionReport> {
// Check if the workflow contains any waiting nodes (Wait, Form, or HITL nodes)
const foundWaitingNodes: Array<{ node: INode; nodeTypeName: string }> = [];
): Promise<void> {
// Check if this workflow IS a sub-workflow with waiting nodes
const hasExecuteWorkflowTrigger =
(nodesGroupedByType.get('n8n-nodes-base.executeWorkflowTrigger')?.length ?? 0) > 0;
const hasWaitingNodes = this.findWaitingNodes(nodesGroupedByType).length > 0;
if (hasExecuteWorkflowTrigger && hasWaitingNodes) {
this.subWorkflowsWithWaitingNodes.set(workflow.id, workflow.name);
}
// Check if this workflow CALLS sub-workflows with waitForSubWorkflow enabled
const executeWorkflowNodes = nodesGroupedByType.get('n8n-nodes-base.executeWorkflow') ?? [];
for (const node of executeWorkflowNodes) {
// Check if waitForSubWorkflow is enabled (default is true)
const options = node.parameters.options as INodeParameters | undefined;
const waitForSubWorkflowValue = options?.waitForSubWorkflow;
// If waitForSubWorkflow is explicitly false, skip. Otherwise treat as true (default).
// Expressions (strings starting with =) are treated as true to avoid false negatives.
const isExplicitlyFalse = waitForSubWorkflowValue === false;
if (isExplicitlyFalse) {
continue; // Skip if not waiting for sub-workflow completion
}
const calledWorkflowId = this.extractCalledWorkflowId(node);
this.parentWorkflowsCalling.push({
parentWorkflowId: workflow.id,
executeWorkflowNode: node,
calledWorkflowId,
});
}
}
async produceReport(): Promise<BatchWorkflowDetectionReport> {
// Group issues by parent workflow ID (a parent may call multiple affected sub-workflows)
const issuesByParentWorkflow = new Map<string, BreakingChangeWorkflowIssue[]>();
// For each parent workflow calling a sub-workflow, check if it calls an affected sub-workflow
for (const parent of this.parentWorkflowsCalling) {
const isUnknownWorkflow = parent.calledWorkflowId === undefined;
const subWorkflowName =
parent.calledWorkflowId !== undefined
? this.subWorkflowsWithWaitingNodes.get(parent.calledWorkflowId)
: undefined;
const isKnownAffectedWorkflow = subWorkflowName !== undefined;
if (!isUnknownWorkflow && !isKnownAffectedWorkflow) {
continue;
}
const issue: BreakingChangeWorkflowIssue = {
title: isUnknownWorkflow
? 'Execute Workflow node may call sub-workflow with changed output behavior'
: 'Execute Workflow node calls sub-workflow with changed output behavior',
description: isUnknownWorkflow
? `The "${parent.executeWorkflowNode.name}" node calls a sub-workflow dynamically (via expression or parameter). If the called sub-workflow contains waiting nodes (Wait, Form, Human-in-the-loop), the data returned has changed in v2 - it now returns the correct data instead of the previously incorrect results.`
: `The "${parent.executeWorkflowNode.name}" node calls sub-workflow "${subWorkflowName}" (ID: ${parent.calledWorkflowId}) which contains waiting nodes. The data returned from this sub-workflow has changed in v2 - it now returns the correct data instead of the previously incorrect results.`,
level: 'warning',
nodeId: parent.executeWorkflowNode.id,
nodeName: parent.executeWorkflowNode.name,
};
const existingIssues = issuesByParentWorkflow.get(parent.parentWorkflowId);
if (existingIssues) {
existingIssues.push(issue);
} else {
issuesByParentWorkflow.set(parent.parentWorkflowId, [issue]);
}
}
// Convert to the expected format
const affectedWorkflows: BatchWorkflowDetectionReport['affectedWorkflows'] = [];
for (const [workflowId, issues] of issuesByParentWorkflow) {
affectedWorkflows.push({ workflowId, issues });
}
return { affectedWorkflows };
}
private findWaitingNodes(nodesGroupedByType: Map<string, INode[]>): INode[] {
const waitingNodes: INode[] = [];
// Check all configured node types
for (const { nodeTypes, operation } of this.waitingNodeConfig) {
for (const nodeType of nodeTypes) {
const nodes = nodesGroupedByType.get(nodeType) ?? [];
// If no operation is specified, all nodes of this type wait
// Otherwise, filter for nodes with the specific operation
const waitingNodes = operation
? nodes.filter((node) => this.hasWaitingOperation(node, operation))
const matchingNodes = operation
? nodes.filter((node) => node.parameters.operation === operation)
: nodes;
for (const node of waitingNodes) {
const nodeTypeName = nodeType.split('.').pop() ?? nodeType;
foundWaitingNodes.push({ node, nodeTypeName });
}
waitingNodes.push(...matchingNodes);
}
}
if (foundWaitingNodes.length === 0) {
return { isAffected: false, issues: [] };
return waitingNodes;
}
private extractCalledWorkflowId(node: INode): string | undefined {
const source = node.parameters.source as string | undefined;
// Only handle database source - for other sources we can't determine the workflow ID statically
if (source !== 'database' && source !== undefined) {
return undefined;
}
// Check if this workflow IS a subworkflow by looking for Execute Workflow Trigger
const executeWorkflowTriggerNodes =
nodesGroupedByType.get('n8n-nodes-base.executeWorkflowTrigger') ?? [];
// Default source is 'database', so also handle when source is undefined
const workflowId = node.parameters.workflowId;
if (executeWorkflowTriggerNodes.length === 0) {
return { isAffected: false, issues: [] };
if (typeof workflowId === 'string') {
// Check if it's an expression (starts with =)
if (workflowId.startsWith('=')) {
return undefined; // Can't evaluate expressions statically
}
return workflowId;
}
// This workflow is a subworkflow (has Execute Workflow Trigger) and contains waiting nodes
// The output behavior has changed
// Create one issue per waiting node
if (typeof workflowId === 'object' && workflowId !== null && 'value' in workflowId) {
const value = workflowId.value;
if (typeof value === 'string') {
// Check if it's an expression (starts with =)
if (value.startsWith('=')) {
return undefined; // Can't evaluate expressions statically
}
return value;
}
}
const issues = foundWaitingNodes.map(({ node, nodeTypeName }) => ({
title: 'Sub-workflow with waiting node has changed output behavior',
description: `This workflow is a sub-workflow (contains Execute Workflow Trigger) with a waiting node (${nodeTypeName}). The data returned to the parent workflow from sub-workflows containing waiting nodes has changed. Previously, the child workflow returned data from the node before the waiting node. Now they return data from the last node in the workflow.`,
level: 'warning' as const,
nodeId: node.id,
nodeName: node.name,
}));
return {
isAffected: true,
issues,
};
return undefined;
}
}

View File

@ -14,3 +14,14 @@ export interface InstanceDetectionReport {
instanceIssues: BreakingChangeInstanceIssue[];
recommendations: BreakingChangeRecommendation[];
}
/**
* Report returned by batch workflow rules after processing all workflows.
* Used when a rule needs to correlate data across multiple workflows before producing results.
*/
export interface BatchWorkflowDetectionReport {
affectedWorkflows: Array<{
workflowId: string;
issues: BreakingChangeWorkflowIssue[];
}>;
}

View File

@ -7,7 +7,11 @@ import type {
import type { WorkflowEntity } from '@n8n/db';
import type { INode } from 'n8n-workflow';
import type { InstanceDetectionReport, WorkflowDetectionReport } from './detection.types';
import type {
BatchWorkflowDetectionReport,
InstanceDetectionReport,
WorkflowDetectionReport,
} from './detection.types';
export const enum BreakingChangeCategory {
workflow = 'workflow',
@ -45,4 +49,43 @@ export interface IBreakingChangeWorkflowRule {
): Promise<WorkflowDetectionReport>;
}
export type IBreakingChangeRule = IBreakingChangeInstanceRule | IBreakingChangeWorkflowRule;
/**
* Interface for batch-based workflow rules that need to correlate data across multiple workflows.
* Unlike IBreakingChangeWorkflowRule which processes each workflow independently,
* batch rules collect data from all workflows first, then produce a final report.
*
* Use case example: Detecting parent workflows that call sub-workflows with specific characteristics,
* where the rule needs to identify both the sub-workflows and their callers before reporting.
*/
export interface IBreakingChangeBatchWorkflowRule {
id: string;
getMetadata(): BreakingChangeRuleMetadata;
getRecommendations(
workflowResults: BreakingChangeAffectedWorkflow[],
): Promise<BreakingChangeRecommendation[]>;
/**
* Called for each workflow during the scanning phase.
* The rule should collect and store any relevant data internally.
*/
collectWorkflowData(
workflow: WorkflowEntity,
nodesGroupedByType: Map<string, INode[]>,
): Promise<void>;
/**
* Called after all workflows have been scanned to produce the final report.
* The rule should correlate the collected data and return the affected workflows.
*/
produceReport(): Promise<BatchWorkflowDetectionReport>;
/**
* Called to reset internal state before a new detection run.
*/
reset(): void;
}
export type IBreakingChangeRule =
| IBreakingChangeInstanceRule
| IBreakingChangeWorkflowRule
| IBreakingChangeBatchWorkflowRule;

View File

@ -40,7 +40,6 @@ const mockWorkflowWithMultipleNodes = {
active: false,
numberOfExecutions: 50,
lastUpdatedAt: new Date('2024-01-10'),
lastExecutedAt: null,
issues: [
{
nodeId: 'node-2',