feat(core): Improve debugging of sub-workflows (#11602)
Some checks are pending
Test Master / install-and-build (push) Waiting to run
Test Master / Unit tests (18.x) (push) Blocked by required conditions
Test Master / Unit tests (20.x) (push) Blocked by required conditions
Test Master / Unit tests (22.4) (push) Blocked by required conditions
Test Master / Lint (push) Blocked by required conditions
Test Master / Notify Slack on failure (push) Blocked by required conditions
Benchmark Docker Image CI / build (push) Waiting to run

This commit is contained in:
Mutasem Aldmour 2024-11-14 23:04:43 +01:00 committed by GitHub
parent f4ca4b792f
commit fd3254d587
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 1843 additions and 265 deletions

View file

@ -0,0 +1,29 @@
/**
* Getters
*/
export const getExecutionsSidebar = () => cy.getByTestId('executions-sidebar');
export const getWorkflowExecutionPreviewIframe = () => cy.getByTestId('workflow-preview-iframe');
export const getExecutionPreviewBody = () =>
getWorkflowExecutionPreviewIframe()
.its('0.contentDocument.body')
.then((el) => cy.wrap(el));
export const getExecutionPreviewBodyNodes = () =>
getExecutionPreviewBody().findChildByTestId('canvas-node');
export const getExecutionPreviewBodyNodesByName = (name: string) =>
getExecutionPreviewBody().findChildByTestId('canvas-node').filter(`[data-name="${name}"]`).eq(0);
export function getExecutionPreviewOutputPanelRelatedExecutionLink() {
return getExecutionPreviewBody().findChildByTestId('related-execution-link');
}
/**
* Actions
*/
export const openExecutionPreviewNode = (name: string) =>
getExecutionPreviewBodyNodesByName(name).dblclick();

View file

@ -48,10 +48,38 @@ export function getOutputTableRow(row: number) {
return getOutputTableRows().eq(row);
}
export function getOutputTableHeaders() {
return getOutputPanelDataContainer().find('table thead th');
}
export function getOutputTableHeaderByText(text: string) {
return getOutputTableHeaders().contains(text);
}
export function getOutputTbodyCell(row: number, col: number) {
return getOutputTableRows().eq(row).find('td').eq(col);
}
export function getOutputRunSelector() {
return getOutputPanel().findChildByTestId('run-selector');
}
export function getOutputRunSelectorInput() {
return getOutputRunSelector().find('input');
}
export function getOutputPanelTable() {
return getOutputPanelDataContainer().get('table');
}
export function getOutputPanelItemsCount() {
return getOutputPanel().getByTestId('ndv-items-count');
}
export function getOutputPanelRelatedExecutionLink() {
return getOutputPanel().getByTestId('related-execution-link');
}
/**
* Actions
*/
@ -90,3 +118,8 @@ export function setParameterSelectByContent(name: string, content: string) {
getParameterInputByName(name).realClick();
getVisibleSelect().find('.option-headline').contains(content).click();
}
export function changeOutputRunSelector(runName: string) {
getOutputRunSelector().click();
getVisibleSelect().find('.el-select-dropdown__item').contains(runName).click();
}

View file

@ -76,6 +76,14 @@ export function getCanvasNodes() {
);
}
export function getSaveButton() {
return cy.getByTestId('workflow-save-button');
}
export function getZoomToFitButton() {
return cy.getByTestId('zoom-to-fit');
}
/**
* Actions
*/
@ -170,3 +178,19 @@ export function clickManualChatButton() {
export function openNode(nodeName: string) {
getNodeByName(nodeName).dblclick();
}
export function saveWorkflowOnButtonClick() {
cy.intercept('POST', '/rest/workflows').as('createWorkflow');
getSaveButton().should('contain', 'Save');
getSaveButton().click();
getSaveButton().should('contain', 'Saved');
cy.url().should('not.have.string', '/new');
}
export function pasteWorkflow(workflow: object) {
cy.get('body').paste(JSON.stringify(workflow));
}
export function clickZoomToFit() {
getZoomToFitButton().click();
}

View file

@ -31,29 +31,31 @@ describe('NDV', () => {
ndv.getters.inputTableRow(1).invoke('attr', 'data-test-id').should('equal', 'hovering-item');
ndv.getters.inputTableRow(1).realHover();
ndv.actions.dragMainPanelToRight();
ndv.getters.inputTableRow(1).realMouseMove(10, 1);
ndv.getters.outputTableRow(4).invoke('attr', 'data-test-id').should('equal', 'hovering-item');
ndv.getters.inputTableRow(2).realHover();
ndv.getters.inputTableRow(2).realMouseMove(10, 1);
ndv.getters.outputTableRow(2).invoke('attr', 'data-test-id').should('equal', 'hovering-item');
ndv.getters.inputTableRow(3).realHover();
ndv.getters.inputTableRow(3).realMouseMove(10, 1);
ndv.getters.outputTableRow(6).invoke('attr', 'data-test-id').should('equal', 'hovering-item');
// output to input
ndv.getters.outputTableRow(1).realHover();
ndv.actions.dragMainPanelToLeft();
ndv.getters.outputTableRow(1).realMouseMove(10, 1);
ndv.getters.inputTableRow(4).invoke('attr', 'data-test-id').should('equal', 'hovering-item');
ndv.getters.outputTableRow(4).realHover();
ndv.getters.outputTableRow(4).realMouseMove(10, 1);
ndv.getters.inputTableRow(1).invoke('attr', 'data-test-id').should('equal', 'hovering-item');
ndv.getters.outputTableRow(2).realHover();
ndv.getters.outputTableRow(2).realMouseMove(10, 1);
ndv.getters.inputTableRow(2).invoke('attr', 'data-test-id').should('equal', 'hovering-item');
ndv.getters.outputTableRow(6).realHover();
ndv.getters.outputTableRow(6).realMouseMove(10, 1);
ndv.getters.inputTableRow(3).invoke('attr', 'data-test-id').should('equal', 'hovering-item');
ndv.getters.outputTableRow(1).realHover();
ndv.getters.outputTableRow(1).realMouseMove(10, 1);
ndv.getters.inputTableRow(4).invoke('attr', 'data-test-id').should('equal', 'hovering-item');
});
@ -75,31 +77,32 @@ describe('NDV', () => {
ndv.actions.switchInputMode('Table');
ndv.actions.switchOutputMode('Table');
ndv.getters.backToCanvas().realHover(); // reset to default hover
ndv.getters.backToCanvas().realMouseMove(10, 1); // reset to default hover
ndv.getters.outputHoveringItem().should('not.exist');
ndv.getters.parameterExpressionPreview('value').should('include.text', '1111');
ndv.actions.selectInputNode('Set1');
ndv.getters.backToCanvas().realHover(); // reset to default hover
ndv.getters.backToCanvas().realMouseMove(10, 1); // reset to default hover
ndv.getters.inputTableRow(1).should('have.text', '1000');
ndv.getters.inputTableRow(1).invoke('attr', 'data-test-id').should('equal', 'hovering-item');
ndv.getters.inputTableRow(1).realHover();
cy.wait(50);
ndv.actions.dragMainPanelToRight();
ndv.getters.inputTbodyCell(1, 0).realMouseMove(10, 1);
ndv.getters.outputHoveringItem().should('have.text', '1000');
ndv.getters.parameterExpressionPreview('value').should('include.text', '1000');
ndv.actions.selectInputNode('Sort');
ndv.actions.dragMainPanelToLeft();
ndv.actions.changeOutputRunSelector('1 of 2 (6 items)');
ndv.getters.backToCanvas().realHover(); // reset to default hover
ndv.getters.backToCanvas().realMouseMove(10, 1); // reset to default hover
ndv.getters.inputTableRow(1).should('have.text', '1111');
ndv.getters.inputTableRow(1).invoke('attr', 'data-test-id').should('equal', 'hovering-item');
ndv.getters.inputTableRow(1).realHover();
cy.wait(50);
ndv.actions.dragMainPanelToRight();
ndv.getters.inputTbodyCell(1, 0).realMouseMove(10, 1);
ndv.getters.outputHoveringItem().should('have.text', '1111');
ndv.getters.parameterExpressionPreview('value').should('include.text', '1111');
});
@ -132,20 +135,22 @@ describe('NDV', () => {
ndv.getters.inputTableRow(1).should('have.text', '1111');
ndv.getters.inputTableRow(1).invoke('attr', 'data-test-id').should('equal', 'hovering-item');
ndv.actions.dragMainPanelToLeft();
ndv.getters.outputTableRow(1).should('have.text', '1111');
ndv.getters.outputTableRow(1).realHover();
ndv.getters.outputTableRow(1).realMouseMove(10, 1);
ndv.getters.outputTableRow(3).should('have.text', '4444');
ndv.getters.outputTableRow(3).realHover();
ndv.getters.outputTableRow(3).realMouseMove(10, 1);
ndv.getters.inputTableRow(3).should('have.text', '4444');
ndv.getters.inputTableRow(3).invoke('attr', 'data-test-id').should('equal', 'hovering-item');
ndv.actions.changeOutputRunSelector('2 of 2 (6 items)');
cy.wait(50);
ndv.getters.inputTableRow(1).should('have.text', '1000');
ndv.getters.inputTableRow(1).realHover();
ndv.actions.dragMainPanelToRight();
ndv.getters.inputTableRow(1).realMouseMove(10, 1);
ndv.getters.outputTableRow(1).should('have.text', '1000');
ndv.getters
@ -155,7 +160,8 @@ describe('NDV', () => {
.should('equal', 'hovering-item');
ndv.getters.outputTableRow(3).should('have.text', '2000');
ndv.getters.outputTableRow(3).realHover();
ndv.actions.dragMainPanelToLeft();
ndv.getters.outputTableRow(3).realMouseMove(10, 1);
ndv.getters.inputTableRow(3).should('have.text', '2000');
@ -175,14 +181,15 @@ describe('NDV', () => {
ndv.actions.switchOutputBranch('False Branch (2 items)');
ndv.getters.outputTableRow(1).should('have.text', '8888');
ndv.getters.outputTableRow(1).realHover();
ndv.actions.dragMainPanelToLeft();
ndv.getters.outputTableRow(1).realMouseMove(10, 1);
ndv.getters.inputTableRow(5).should('have.text', '8888');
ndv.getters.inputTableRow(5).invoke('attr', 'data-test-id').should('equal', 'hovering-item');
ndv.getters.outputTableRow(2).should('have.text', '9999');
ndv.getters.outputTableRow(2).realHover();
ndv.getters.outputTableRow(2).realMouseMove(10, 1);
ndv.getters.inputTableRow(6).should('have.text', '9999');
@ -192,29 +199,35 @@ describe('NDV', () => {
workflowPage.actions.openNode('Set5');
ndv.actions.dragMainPanelToRight();
ndv.actions.switchInputBranch('True Branch');
ndv.actions.dragMainPanelToLeft();
ndv.actions.changeOutputRunSelector('1 of 2 (2 items)');
ndv.getters.outputTableRow(1).should('have.text', '8888');
ndv.getters.outputTableRow(1).realHover();
cy.wait(100);
ndv.getters.outputTableRow(1).realMouseMove(10, 1);
ndv.getters.inputHoveringItem().should('not.exist');
ndv.getters.inputTableRow(1).should('have.text', '1111');
ndv.getters.inputTableRow(1).realHover();
cy.wait(100);
ndv.actions.dragMainPanelToRight();
ndv.getters.inputTableRow(1).realMouseMove(10, 1);
ndv.getters.outputHoveringItem().should('not.exist');
ndv.actions.switchInputBranch('False Branch');
ndv.getters.inputTableRow(1).should('have.text', '8888');
ndv.getters.inputTableRow(1).realHover();
ndv.actions.dragMainPanelToRight();
ndv.getters.inputTableRow(1).realMouseMove(10, 1);
ndv.actions.dragMainPanelToLeft();
ndv.actions.changeOutputRunSelector('2 of 2 (4 items)');
ndv.getters.outputTableRow(1).should('have.text', '1111');
ndv.getters.outputTableRow(1).realHover();
ndv.getters.outputTableRow(1).realMouseMove(10, 1);
ndv.actions.changeOutputRunSelector('1 of 2 (2 items)');
ndv.getters.inputTableRow(1).should('have.text', '8888');
ndv.getters.inputTableRow(1).realHover();
ndv.actions.dragMainPanelToRight();
ndv.getters.inputTableRow(1).realMouseMove(10, 1);
ndv.getters.outputHoveringItem().should('have.text', '8888');
// todo there's a bug here need to fix ADO-534
// ndv.getters.outputHoveringItem().should('not.exist');

View file

@ -0,0 +1,140 @@
import {
getExecutionPreviewOutputPanelRelatedExecutionLink,
getExecutionsSidebar,
getWorkflowExecutionPreviewIframe,
openExecutionPreviewNode,
} from '../composables/executions';
import {
changeOutputRunSelector,
getOutputPanelItemsCount,
getOutputPanelRelatedExecutionLink,
getOutputRunSelectorInput,
getOutputTableHeaders,
getOutputTableRows,
getOutputTbodyCell,
} from '../composables/ndv';
import {
clickExecuteWorkflowButton,
clickZoomToFit,
getCanvasNodes,
navigateToNewWorkflowPage,
openNode,
pasteWorkflow,
saveWorkflowOnButtonClick,
} from '../composables/workflow';
import SUBWORKFLOW_DEBUGGING_EXAMPLE from '../fixtures/Subworkflow-debugging-execute-workflow.json';
describe('Subworkflow debugging', () => {
beforeEach(() => {
navigateToNewWorkflowPage();
pasteWorkflow(SUBWORKFLOW_DEBUGGING_EXAMPLE);
saveWorkflowOnButtonClick();
getCanvasNodes().should('have.length', 11);
clickZoomToFit();
clickExecuteWorkflowButton();
});
describe('can inspect sub executed workflow', () => {
it('(Run once with all items/ Wait for Sub-workflow completion) (default behavior)', () => {
openNode('Execute Workflow with param');
getOutputPanelItemsCount().should('contain.text', '2 items, 1 sub-execution');
getOutputPanelRelatedExecutionLink().should('contain.text', 'Inspect Sub-Execution');
getOutputPanelRelatedExecutionLink().should('have.attr', 'href');
// ensure workflow executed and waited on output
getOutputTableHeaders().should('have.length', 2);
getOutputTbodyCell(1, 0).should('have.text', 'world Natalie Moore');
});
it('(Run once for each item/ Wait for Sub-workflow completion)', () => {
openNode('Execute Workflow with param1');
getOutputPanelItemsCount().should('contain.text', '2 items, 2 sub-execution');
getOutputPanelRelatedExecutionLink().should('not.exist');
// ensure workflow executed and waited on output
getOutputTableHeaders().should('have.length', 3);
getOutputTbodyCell(1, 0).find('a').should('have.attr', 'href');
getOutputTbodyCell(1, 1).should('have.text', 'world Natalie Moore');
});
it('(Run once with all items/ Wait for Sub-workflow completion)', () => {
openNode('Execute Workflow with param2');
getOutputPanelItemsCount().should('not.exist');
getOutputPanelRelatedExecutionLink().should('contain.text', 'Inspect Sub-Execution');
getOutputPanelRelatedExecutionLink().should('have.attr', 'href');
// ensure workflow executed but returned same data as input
getOutputRunSelectorInput().should('have.value', '2 of 2 (3 items, 1 sub-execution)');
getOutputTableHeaders().should('have.length', 6);
getOutputTableHeaders().eq(0).should('have.text', 'uid');
getOutputTableRows().should('have.length', 4);
getOutputTbodyCell(1, 1).should('include.text', 'Jon_Ebert@yahoo.com');
changeOutputRunSelector('1 of 2 (2 items, 1 sub-execution)');
getOutputRunSelectorInput().should('have.value', '1 of 2 (2 items, 1 sub-execution)');
getOutputTableHeaders().should('have.length', 6);
getOutputTableHeaders().eq(0).should('have.text', 'uid');
getOutputTableRows().should('have.length', 3);
getOutputTbodyCell(1, 1).should('include.text', 'Terry.Dach@hotmail.com');
});
it('(Run once for each item/ Wait for Sub-workflow completion)', () => {
openNode('Execute Workflow with param3');
// ensure workflow executed but returned same data as input
getOutputRunSelectorInput().should('have.value', '2 of 2 (3 items, 3 sub-executions)');
getOutputTableHeaders().should('have.length', 7);
getOutputTableHeaders().eq(1).should('have.text', 'uid');
getOutputTableRows().should('have.length', 4);
getOutputTbodyCell(1, 0).find('a').should('have.attr', 'href');
getOutputTbodyCell(1, 2).should('include.text', 'Jon_Ebert@yahoo.com');
changeOutputRunSelector('1 of 2 (2 items, 2 sub-executions)');
getOutputRunSelectorInput().should('have.value', '1 of 2 (2 items, 2 sub-executions)');
getOutputTableHeaders().should('have.length', 7);
getOutputTableHeaders().eq(1).should('have.text', 'uid');
getOutputTableRows().should('have.length', 3);
getOutputTbodyCell(1, 0).find('a').should('have.attr', 'href');
getOutputTbodyCell(1, 2).should('include.text', 'Terry.Dach@hotmail.com');
});
});
it('can inspect parent executions', () => {
cy.url().then((workflowUrl) => {
openNode('Execute Workflow with param');
getOutputPanelItemsCount().should('contain.text', '2 items, 1 sub-execution');
getOutputPanelRelatedExecutionLink().should('contain.text', 'Inspect Sub-Execution');
getOutputPanelRelatedExecutionLink().should('have.attr', 'href');
// ensure workflow executed and waited on output
getOutputTableHeaders().should('have.length', 2);
getOutputTbodyCell(1, 0).should('have.text', 'world Natalie Moore');
// cypress cannot handle new tabs so removing it
getOutputPanelRelatedExecutionLink().invoke('removeAttr', 'target').click();
getExecutionsSidebar().should('be.visible');
getWorkflowExecutionPreviewIframe().should('be.visible');
openExecutionPreviewNode('Execute Workflow Trigger');
getExecutionPreviewOutputPanelRelatedExecutionLink().should(
'include.text',
'Inspect Parent Execution',
);
getExecutionPreviewOutputPanelRelatedExecutionLink()
.invoke('removeAttr', 'target')
.click({ force: true });
cy.url().then((currentUrl) => {
expect(currentUrl === workflowUrl);
});
});
});
});

View file

@ -0,0 +1,354 @@
{
"meta": {
"instanceId": "08ce71ad998aeaade0abedb8dd96153d8eaa03fcb84cfccc1530095bf9ee478e"
},
"nodes": [
{
"parameters": {},
"id": "4535ce3e-280e-49b0-8854-373472ec86d1",
"name": "When clicking Test workflow",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [80, 860]
},
{
"parameters": {
"category": "randomData",
"randomDataSeed": "0",
"randomDataCount": 2
},
"id": "d7fba18a-d51f-4509-af45-68cd9425ac6b",
"name": "DebugHelper1",
"type": "n8n-nodes-base.debugHelper",
"typeVersion": 1,
"position": [280, 860]
},
{
"parameters": {
"source": "parameter",
"workflowJson": "{\n \"meta\": {\n \"instanceId\": \"a786b722078489c1fa382391a9f3476c2784761624deb2dfb4634827256d51a0\"\n },\n \"nodes\": [\n {\n \"parameters\": {},\n \"type\": \"n8n-nodes-base.executeWorkflowTrigger\",\n \"typeVersion\": 1,\n \"position\": [\n 0,\n 0\n ],\n \"id\": \"00600a51-e63a-4b6e-93f5-f01d50a21e0c\",\n \"name\": \"Execute Workflow Trigger\"\n },\n {\n \"parameters\": {\n \"assignments\": {\n \"assignments\": [\n {\n \"id\": \"87ff01af-2e28-48da-ae6c-304040200b15\",\n \"name\": \"hello\",\n \"value\": \"=world {{ $json.firstname }} {{ $json.lastname }}\",\n \"type\": \"string\"\n }\n ]\n },\n \"includeOtherFields\": false,\n \"options\": {}\n },\n \"type\": \"n8n-nodes-base.set\",\n \"typeVersion\": 3.4,\n \"position\": [\n 280,\n 0\n ],\n \"id\": \"642219a1-d655-4a30-af5c-fcccbb690322\",\n \"name\": \"Edit Fields\"\n }\n ],\n \"connections\": {\n \"Execute Workflow Trigger\": {\n \"main\": [\n [\n {\n \"node\": \"Edit Fields\",\n \"type\": \"main\",\n \"index\": 0\n }\n ]\n ]\n }\n },\n \"pinData\": {}\n}",
"mode": "each",
"options": {
"waitForSubWorkflow": false
}
},
"type": "n8n-nodes-base.executeWorkflow",
"typeVersion": 1.1,
"position": [680, 1540],
"id": "f90a25da-dd89-4bf8-8f5b-bf8ee1de0b70",
"name": "Execute Workflow with param3"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "c93f26bd-3489-467b-909e-6462e1463707",
"name": "uid",
"value": "={{ $json.uid }}",
"type": "string"
},
{
"id": "3dd706ce-d925-4219-8531-ad12369972fe",
"name": "email",
"value": "={{ $json.email }}",
"type": "string"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [900, 1540],
"id": "3be57648-3be8-4b0f-abfa-8fdcafee804d",
"name": "Edit Fields8"
},
{
"parameters": {
"source": "parameter",
"workflowJson": "{\n \"meta\": {\n \"instanceId\": \"a786b722078489c1fa382391a9f3476c2784761624deb2dfb4634827256d51a0\"\n },\n \"nodes\": [\n {\n \"parameters\": {},\n \"type\": \"n8n-nodes-base.executeWorkflowTrigger\",\n \"typeVersion\": 1,\n \"position\": [\n 0,\n 0\n ],\n \"id\": \"00600a51-e63a-4b6e-93f5-f01d50a21e0c\",\n \"name\": \"Execute Workflow Trigger\"\n },\n {\n \"parameters\": {\n \"assignments\": {\n \"assignments\": [\n {\n \"id\": \"87ff01af-2e28-48da-ae6c-304040200b15\",\n \"name\": \"hello\",\n \"value\": \"=world {{ $json.firstname }} {{ $json.lastname }}\",\n \"type\": \"string\"\n }\n ]\n },\n \"includeOtherFields\": false,\n \"options\": {}\n },\n \"type\": \"n8n-nodes-base.set\",\n \"typeVersion\": 3.4,\n \"position\": [\n 280,\n 0\n ],\n \"id\": \"642219a1-d655-4a30-af5c-fcccbb690322\",\n \"name\": \"Edit Fields\"\n }\n ],\n \"connections\": {\n \"Execute Workflow Trigger\": {\n \"main\": [\n [\n {\n \"node\": \"Edit Fields\",\n \"type\": \"main\",\n \"index\": 0\n }\n ]\n ]\n }\n },\n \"pinData\": {}\n}",
"options": {
"waitForSubWorkflow": false
}
},
"type": "n8n-nodes-base.executeWorkflow",
"typeVersion": 1.1,
"position": [620, 1220],
"id": "dabc2356-3660-4d17-b305-936a002029ba",
"name": "Execute Workflow with param2"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "c93f26bd-3489-467b-909e-6462e1463707",
"name": "uid",
"value": "={{ $json.uid }}",
"type": "string"
},
{
"id": "3dd706ce-d925-4219-8531-ad12369972fe",
"name": "email",
"value": "={{ $json.email }}",
"type": "string"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [840, 1220],
"id": "9d2a9dda-e2a1-43e8-a66f-a8a555692e5f",
"name": "Edit Fields7"
},
{
"parameters": {
"source": "parameter",
"workflowJson": "{\n \"meta\": {\n \"instanceId\": \"a786b722078489c1fa382391a9f3476c2784761624deb2dfb4634827256d51a0\"\n },\n \"nodes\": [\n {\n \"parameters\": {},\n \"type\": \"n8n-nodes-base.executeWorkflowTrigger\",\n \"typeVersion\": 1,\n \"position\": [\n 0,\n 0\n ],\n \"id\": \"00600a51-e63a-4b6e-93f5-f01d50a21e0c\",\n \"name\": \"Execute Workflow Trigger\"\n },\n {\n \"parameters\": {\n \"assignments\": {\n \"assignments\": [\n {\n \"id\": \"87ff01af-2e28-48da-ae6c-304040200b15\",\n \"name\": \"hello\",\n \"value\": \"=world {{ $json.firstname }} {{ $json.lastname }}\",\n \"type\": \"string\"\n }\n ]\n },\n \"includeOtherFields\": false,\n \"options\": {}\n },\n \"type\": \"n8n-nodes-base.set\",\n \"typeVersion\": 3.4,\n \"position\": [\n 280,\n 0\n ],\n \"id\": \"642219a1-d655-4a30-af5c-fcccbb690322\",\n \"name\": \"Edit Fields\"\n }\n ],\n \"connections\": {\n \"Execute Workflow Trigger\": {\n \"main\": [\n [\n {\n \"node\": \"Edit Fields\",\n \"type\": \"main\",\n \"index\": 0\n }\n ]\n ]\n }\n },\n \"pinData\": {}\n}",
"mode": "each",
"options": {
"waitForSubWorkflow": true
}
},
"type": "n8n-nodes-base.executeWorkflow",
"typeVersion": 1.1,
"position": [560, 900],
"id": "07e47f60-622a-484c-ab24-35f6f2280595",
"name": "Execute Workflow with param1"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "c93f26bd-3489-467b-909e-6462e1463707",
"name": "uid",
"value": "={{ $json.uid }}",
"type": "string"
},
{
"id": "3dd706ce-d925-4219-8531-ad12369972fe",
"name": "email",
"value": "={{ $json.email }}",
"type": "string"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [760, 900],
"id": "80563d0a-0bab-444f-a04c-4041a505d78b",
"name": "Edit Fields6"
},
{
"parameters": {
"source": "parameter",
"workflowJson": "{\n \"meta\": {\n \"instanceId\": \"a786b722078489c1fa382391a9f3476c2784761624deb2dfb4634827256d51a0\"\n },\n \"nodes\": [\n {\n \"parameters\": {},\n \"type\": \"n8n-nodes-base.executeWorkflowTrigger\",\n \"typeVersion\": 1,\n \"position\": [\n 0,\n 0\n ],\n \"id\": \"00600a51-e63a-4b6e-93f5-f01d50a21e0c\",\n \"name\": \"Execute Workflow Trigger\"\n },\n {\n \"parameters\": {\n \"assignments\": {\n \"assignments\": [\n {\n \"id\": \"87ff01af-2e28-48da-ae6c-304040200b15\",\n \"name\": \"hello\",\n \"value\": \"=world {{ $json.firstname }} {{ $json.lastname }}\",\n \"type\": \"string\"\n }\n ]\n },\n \"includeOtherFields\": false,\n \"options\": {}\n },\n \"type\": \"n8n-nodes-base.set\",\n \"typeVersion\": 3.4,\n \"position\": [\n 280,\n 0\n ],\n \"id\": \"642219a1-d655-4a30-af5c-fcccbb690322\",\n \"name\": \"Edit Fields\"\n }\n ],\n \"connections\": {\n \"Execute Workflow Trigger\": {\n \"main\": [\n [\n {\n \"node\": \"Edit Fields\",\n \"type\": \"main\",\n \"index\": 0\n }\n ]\n ]\n }\n },\n \"pinData\": {}\n}",
"options": {
"waitForSubWorkflow": true
}
},
"type": "n8n-nodes-base.executeWorkflow",
"typeVersion": 1.1,
"position": [560, 580],
"id": "f04af481-f4d9-4d91-a60a-a377580e8393",
"name": "Execute Workflow with param"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "c93f26bd-3489-467b-909e-6462e1463707",
"name": "uid",
"value": "={{ $json.uid }}",
"type": "string"
},
{
"id": "3dd706ce-d925-4219-8531-ad12369972fe",
"name": "email",
"value": "={{ $json.email }}",
"type": "string"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [760, 580],
"id": "80c10607-a0ac-4090-86a1-890da0a2aa52",
"name": "Edit Fields2"
},
{
"parameters": {
"content": "## Execute Workflow (Run once with all items/ DONT Wait for Sub-workflow completion)",
"height": 254.84308966329985,
"width": 457.58120569815793
},
"id": "534ef523-3453-4a16-9ff0-8ac9f025d47d",
"name": "Sticky Note5",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [500, 1080]
},
{
"parameters": {
"content": "## Execute Workflow (Run once with for each item/ DONT Wait for Sub-workflow completion) ",
"height": 284.59778445962905,
"width": 457.58120569815793
},
"id": "838f0fa3-5ee4-4d1a-afb8-42e009f1aa9e",
"name": "Sticky Note4",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [580, 1400]
},
{
"parameters": {
"category": "randomData",
"randomDataSeed": "1",
"randomDataCount": 3
},
"id": "86699a49-2aa7-488e-8ea9-828404c98f08",
"name": "DebugHelper",
"type": "n8n-nodes-base.debugHelper",
"typeVersion": 1,
"position": [320, 1120]
},
{
"parameters": {
"content": "## Execute Workflow (Run once with for each item/ Wait for Sub-workflow completion) ",
"height": 284.59778445962905,
"width": 457.58120569815793
},
"id": "885d35f0-8ae6-45ec-821b-a82c27e7577a",
"name": "Sticky Note3",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [480, 760]
},
{
"parameters": {
"content": "## Execute Workflow (Run once with all items/ Wait for Sub-workflow completion) (default behavior)",
"height": 254.84308966329985,
"width": 457.58120569815793
},
"id": "505bd7f2-767e-41b8-9325-77300aed5883",
"name": "Sticky Note2",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [460, 460]
}
],
"connections": {
"When clicking Test workflow": {
"main": [
[
{
"node": "DebugHelper1",
"type": "main",
"index": 0
},
{
"node": "DebugHelper",
"type": "main",
"index": 0
}
]
]
},
"DebugHelper1": {
"main": [
[
{
"node": "Execute Workflow with param3",
"type": "main",
"index": 0
},
{
"node": "Execute Workflow with param2",
"type": "main",
"index": 0
},
{
"node": "Execute Workflow with param1",
"type": "main",
"index": 0
},
{
"node": "Execute Workflow with param",
"type": "main",
"index": 0
}
]
]
},
"Execute Workflow with param3": {
"main": [
[
{
"node": "Edit Fields8",
"type": "main",
"index": 0
}
]
]
},
"Execute Workflow with param2": {
"main": [
[
{
"node": "Edit Fields7",
"type": "main",
"index": 0
}
]
]
},
"Execute Workflow with param1": {
"main": [
[
{
"node": "Edit Fields6",
"type": "main",
"index": 0
}
]
]
},
"Execute Workflow with param": {
"main": [
[
{
"node": "Edit Fields2",
"type": "main",
"index": 0
}
]
]
},
"DebugHelper": {
"main": [
[
{
"node": "Execute Workflow with param2",
"type": "main",
"index": 0
},
{
"node": "Execute Workflow with param3",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {}
}

View file

@ -323,6 +323,12 @@ export class NDV extends BasePage {
addItemToFixedCollection: (paramName: string) => {
this.getters.fixedCollectionParameter(paramName).getByTestId('fixed-collection-add').click();
},
dragMainPanelToLeft: () => {
cy.drag('[data-test-id=panel-drag-button]', [-1000, 0], { moveTwice: true });
},
dragMainPanelToRight: () => {
cy.drag('[data-test-id=panel-drag-button]', [1000, 0], { moveTwice: true });
},
};
}

View file

@ -177,6 +177,16 @@ Cypress.Commands.add('drag', (selector, pos, options) => {
pageY: newPosition.y,
force: true,
});
if (options?.moveTwice) {
// first move like hover to trigger object to be visible
// like in main panel in ndv
element.trigger('mousemove', {
which: 1,
pageX: newPosition.x,
pageY: newPosition.y,
force: true,
});
}
if (options?.clickToFinish) {
// Click to finish the drag
// For some reason, mouseup isn't working when moving nodes

View file

@ -59,7 +59,13 @@ declare global {
drag(
selector: string | Chainable<JQuery<HTMLElement>>,
target: [number, number],
options?: { abs?: boolean; index?: number; realMouse?: boolean; clickToFinish?: boolean },
options?: {
abs?: boolean;
index?: number;
realMouse?: boolean;
clickToFinish?: boolean;
moveTwice?: boolean;
},
): void;
draganddrop(
draggableSelector: string,

View file

@ -10,6 +10,7 @@ import type {
INodeTypeDescription,
SupplyData,
INodeParameterResourceLocator,
ExecuteWorkflowData,
} from 'n8n-workflow';
import { BaseRetriever, type BaseRetrieverInput } from '@langchain/core/retrievers';
@ -293,6 +294,8 @@ export class RetrieverWorkflow implements INodeType {
};
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
const workflowProxy = this.getWorkflowDataProxy(0);
class WorkflowRetriever extends BaseRetriever {
lc_namespace = ['n8n-nodes-langchain', 'retrievers', 'workflow'];
@ -349,6 +352,9 @@ export class RetrieverWorkflow implements INodeType {
},
);
}
// same as current workflow
baseMetadata.workflowId = workflowProxy.$workflow.id;
}
const rawData: IDataObject = { query };
@ -384,21 +390,29 @@ export class RetrieverWorkflow implements INodeType {
const items = [newItem] as INodeExecutionData[];
let receivedItems: INodeExecutionData[][];
let receivedData: ExecuteWorkflowData;
try {
receivedItems = (await this.executeFunctions.executeWorkflow(
receivedData = await this.executeFunctions.executeWorkflow(
workflowInfo,
items,
config?.getChild(),
)) as INodeExecutionData[][];
{
parentExecution: {
executionId: workflowProxy.$execution.id,
workflowId: workflowProxy.$workflow.id,
},
},
);
} catch (error) {
// Make sure a valid error gets returned that can by json-serialized else it will
// not show up in the frontend
throw new NodeOperationError(this.executeFunctions.getNode(), error as Error);
}
const receivedItems = receivedData.data?.[0] ?? [];
const returnData: Document[] = [];
for (const [index, itemData] of receivedItems[0].entries()) {
for (const [index, itemData] of receivedItems.entries()) {
const pageContent = objectToString(itemData.json);
returnData.push(
new Document({
@ -406,6 +420,7 @@ export class RetrieverWorkflow implements INodeType {
metadata: {
...baseMetadata,
itemIndex: index,
executionId: receivedData.executionId,
},
}),
);

View file

@ -14,8 +14,10 @@ import type {
ISupplyDataFunctions,
SupplyData,
ExecutionError,
ExecuteWorkflowData,
IDataObject,
INodeParameterResourceLocator,
ITaskMetadata,
} from 'n8n-workflow';
import { NodeConnectionType, NodeOperationError, jsonParse } from 'n8n-workflow';
@ -358,9 +360,14 @@ export class ToolWorkflow implements INodeType {
};
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
const workflowProxy = this.getWorkflowDataProxy(0);
const name = this.getNodeParameter('name', itemIndex) as string;
const description = this.getNodeParameter('description', itemIndex) as string;
let subExecutionId: string | undefined;
let subWorkflowId: string | undefined;
const useSchema = this.getNodeParameter('specifyInputSchema', itemIndex) as boolean;
let tool: DynamicTool | DynamicStructuredTool | undefined = undefined;
@ -396,11 +403,16 @@ export class ToolWorkflow implements INodeType {
) as INodeParameterResourceLocator;
workflowInfo.id = value as string;
}
subWorkflowId = workflowInfo.id;
} else if (source === 'parameter') {
// Read workflow from parameter
const workflowJson = this.getNodeParameter('workflowJson', itemIndex) as string;
try {
workflowInfo.code = JSON.parse(workflowJson) as IWorkflowBase;
// subworkflow is same as parent workflow
subWorkflowId = workflowProxy.$workflow.id;
} catch (error) {
throw new NodeOperationError(
this.getNode(),
@ -440,13 +452,15 @@ export class ToolWorkflow implements INodeType {
const items = [newItem] as INodeExecutionData[];
let receivedData: INodeExecutionData;
let receivedData: ExecuteWorkflowData;
try {
receivedData = (await this.executeWorkflow(
workflowInfo,
items,
runManager?.getChild(),
)) as INodeExecutionData;
receivedData = await this.executeWorkflow(workflowInfo, items, runManager?.getChild(), {
parentExecution: {
executionId: workflowProxy.$execution.id,
workflowId: workflowProxy.$workflow.id,
},
});
subExecutionId = receivedData.executionId;
} catch (error) {
// Make sure a valid error gets returned that can by json-serialized else it will
// not show up in the frontend
@ -454,6 +468,7 @@ export class ToolWorkflow implements INodeType {
}
const response: string | undefined = get(receivedData, [
'data',
0,
0,
'json',
@ -503,10 +518,25 @@ export class ToolWorkflow implements INodeType {
response = `There was an error: "${executionError.message}"`;
}
let metadata: ITaskMetadata | undefined;
if (subExecutionId && subWorkflowId) {
metadata = {
subExecution: {
executionId: subExecutionId,
workflowId: subWorkflowId,
},
};
}
if (executionError) {
void this.addOutputData(NodeConnectionType.AiTool, index, executionError);
void this.addOutputData(NodeConnectionType.AiTool, index, executionError, metadata);
} else {
void this.addOutputData(NodeConnectionType.AiTool, index, [[{ json: { response } }]]);
void this.addOutputData(
NodeConnectionType.AiTool,
index,
[[{ json: { response } }]],
metadata,
);
}
return response;
};

View file

@ -10,7 +10,12 @@ import type { Tool } from '@langchain/core/tools';
import { VectorStore } from '@langchain/core/vectorstores';
import { TextSplitter } from '@langchain/textsplitters';
import type { BaseDocumentLoader } from 'langchain/dist/document_loaders/base';
import type { IExecuteFunctions, INodeExecutionData, ISupplyDataFunctions } from 'n8n-workflow';
import type {
IExecuteFunctions,
INodeExecutionData,
ISupplyDataFunctions,
ITaskMetadata,
} from 'n8n-workflow';
import { NodeOperationError, NodeConnectionType } from 'n8n-workflow';
import { logAiEvent, isToolsInstance, isBaseChatMemory, isBaseChatMessageHistory } from './helpers';
@ -220,8 +225,24 @@ export function logWrapper(
arguments: [query, config],
})) as Array<Document<Record<string, any>>>;
const executionId: string | undefined = response[0]?.metadata?.executionId as string;
const workflowId: string | undefined = response[0]?.metadata?.workflowId as string;
const metadata: ITaskMetadata = {};
if (executionId && workflowId) {
metadata.subExecution = {
executionId,
workflowId,
};
}
logAiEvent(executeFunctions, 'ai-documents-retrieved', { query });
executeFunctions.addOutputData(connectionType, index, [[{ json: { response } }]]);
executeFunctions.addOutputData(
connectionType,
index,
[[{ json: { response } }]],
metadata,
);
return response;
};
}

View file

@ -25,6 +25,7 @@
"start:default": "cd bin && ./n8n",
"start:windows": "cd bin && n8n",
"test": "pnpm test:sqlite",
"test:dev": "N8N_LOG_LEVEL=silent DB_TYPE=sqlite jest --watch",
"test:sqlite": "N8N_LOG_LEVEL=silent DB_TYPE=sqlite jest",
"test:postgres": "N8N_LOG_LEVEL=silent DB_TYPE=postgresdb DB_POSTGRESDB_SCHEMA=alt_schema DB_TABLE_PREFIX=test_ jest --no-coverage",
"test:mysql": "N8N_LOG_LEVEL=silent DB_TYPE=mysqldb DB_TABLE_PREFIX=test_ jest --no-coverage",

View file

@ -1,9 +1,11 @@
import { mock } from 'jest-mock-extended';
import type {
IExecuteWorkflowInfo,
IWorkflowExecuteAdditionalData,
ExecuteWorkflowOptions,
IRun,
import type { IWorkflowBase } from 'n8n-workflow';
import {
type IExecuteWorkflowInfo,
type IWorkflowExecuteAdditionalData,
type ExecuteWorkflowOptions,
type IRun,
type INodeExecutionData,
} from 'n8n-workflow';
import type PCancelable from 'p-cancelable';
import Container from 'typedi';
@ -21,43 +23,59 @@ import { WorkflowStatisticsService } from '@/services/workflow-statistics.servic
import { SubworkflowPolicyChecker } from '@/subworkflows/subworkflow-policy-checker.service';
import { Telemetry } from '@/telemetry';
import { PermissionChecker } from '@/user-management/permission-checker';
import { executeWorkflow, getBase } from '@/workflow-execute-additional-data';
import { executeWorkflow, getBase, getRunData } from '@/workflow-execute-additional-data';
import { mockInstance } from '@test/mocking';
const run = mock<IRun>({
data: { resultData: {} },
finished: true,
mode: 'manual',
startedAt: new Date(),
status: 'new',
});
const EXECUTION_ID = '123';
const LAST_NODE_EXECUTED = 'Last node executed';
const cancelablePromise = mock<PCancelable<IRun>>({
then: jest
.fn()
.mockImplementation(async (onfulfilled) => await Promise.resolve(run).then(onfulfilled)),
catch: jest
.fn()
.mockImplementation(async (onrejected) => await Promise.resolve(run).catch(onrejected)),
finally: jest
.fn()
.mockImplementation(async (onfinally) => await Promise.resolve(run).finally(onfinally)),
[Symbol.toStringTag]: 'PCancelable',
});
const getMockRun = ({ lastNodeOutput }: { lastNodeOutput: Array<INodeExecutionData[] | null> }) =>
mock<IRun>({
data: {
resultData: {
runData: {
[LAST_NODE_EXECUTED]: [
{
startTime: 100,
data: {
main: lastNodeOutput,
},
},
],
},
lastNodeExecuted: LAST_NODE_EXECUTED,
},
},
finished: true,
mode: 'manual',
startedAt: new Date(),
status: 'new',
});
const getCancelablePromise = async (run: IRun) =>
await mock<PCancelable<IRun>>({
then: jest
.fn()
.mockImplementation(async (onfulfilled) => await Promise.resolve(run).then(onfulfilled)),
catch: jest
.fn()
.mockImplementation(async (onrejected) => await Promise.resolve(run).catch(onrejected)),
finally: jest
.fn()
.mockImplementation(async (onfinally) => await Promise.resolve(run).finally(onfinally)),
[Symbol.toStringTag]: 'PCancelable',
});
const processRunExecutionData = jest.fn();
jest.mock('n8n-core', () => ({
__esModule: true,
...jest.requireActual('n8n-core'),
WorkflowExecute: jest.fn().mockImplementation(() => ({
processRunExecutionData: jest.fn().mockReturnValue(cancelablePromise),
processRunExecutionData,
})),
}));
jest.mock('../workflow-helpers', () => ({
...jest.requireActual('../workflow-helpers'),
getDataLastExecutedNodeData: jest.fn().mockReturnValue({ data: { main: [] } }),
}));
describe('WorkflowExecuteAdditionalData', () => {
const variablesService = mockInstance(VariablesService);
variablesService.getAllCached.mockResolvedValue([]);
@ -95,17 +113,129 @@ describe('WorkflowExecuteAdditionalData', () => {
expect(eventService.emit).toHaveBeenCalledWith(eventName, payload);
});
it('`executeWorkflow` should set subworkflow execution as running', async () => {
const executionId = '123';
workflowRepository.get.mockResolvedValue(mock<WorkflowEntity>({ id: executionId, nodes: [] }));
activeExecutions.add.mockResolvedValue(executionId);
describe('executeWorkflow', () => {
const runWithData = getMockRun({ lastNodeOutput: [[{ json: { test: 1 } }]] });
await executeWorkflow(
mock<IExecuteWorkflowInfo>(),
mock<IWorkflowExecuteAdditionalData>(),
mock<ExecuteWorkflowOptions>({ loadedWorkflowData: undefined }),
);
beforeEach(() => {
workflowRepository.get.mockResolvedValue(
mock<WorkflowEntity>({ id: EXECUTION_ID, nodes: [] }),
);
activeExecutions.add.mockResolvedValue(EXECUTION_ID);
processRunExecutionData.mockReturnValue(getCancelablePromise(runWithData));
});
expect(executionRepository.setRunning).toHaveBeenCalledWith(executionId);
it('should execute workflow, return data and execution id', async () => {
const response = await executeWorkflow(
mock<IExecuteWorkflowInfo>(),
mock<IWorkflowExecuteAdditionalData>(),
mock<ExecuteWorkflowOptions>({ loadedWorkflowData: undefined, doNotWaitToFinish: false }),
);
expect(response).toEqual({
data: runWithData.data.resultData.runData[LAST_NODE_EXECUTED][0].data!.main,
executionId: EXECUTION_ID,
});
});
it('should execute workflow, skip waiting', async () => {
const response = await executeWorkflow(
mock<IExecuteWorkflowInfo>(),
mock<IWorkflowExecuteAdditionalData>(),
mock<ExecuteWorkflowOptions>({ loadedWorkflowData: undefined, doNotWaitToFinish: true }),
);
expect(response).toEqual({
data: [null],
executionId: EXECUTION_ID,
});
});
it('should set sub workflow execution as running', async () => {
await executeWorkflow(
mock<IExecuteWorkflowInfo>(),
mock<IWorkflowExecuteAdditionalData>(),
mock<ExecuteWorkflowOptions>({ loadedWorkflowData: undefined }),
);
expect(executionRepository.setRunning).toHaveBeenCalledWith(EXECUTION_ID);
});
});
describe('getRunData', () => {
it('should throw error to add trigger ndoe', async () => {
const workflow = mock<IWorkflowBase>({
id: '1',
name: 'test',
nodes: [],
active: false,
});
await expect(getRunData(workflow)).rejects.toThrowError('Missing node to start execution');
});
const workflow = mock<IWorkflowBase>({
id: '1',
name: 'test',
nodes: [
{
type: 'n8n-nodes-base.executeWorkflowTrigger',
},
],
active: false,
});
it('should return default data', async () => {
expect(await getRunData(workflow)).toEqual({
executionData: {
executionData: {
contextData: {},
metadata: {},
nodeExecutionStack: [
{
data: { main: [[{ json: {} }]] },
metadata: { parentExecution: undefined },
node: workflow.nodes[0],
source: null,
},
],
waitingExecution: {},
waitingExecutionSource: {},
},
resultData: { runData: {} },
startData: {},
},
executionMode: 'integrated',
workflowData: workflow,
});
});
it('should return run data with input data and metadata', async () => {
const data = [{ json: { test: 1 } }];
const parentExecution = {
executionId: '123',
workflowId: '567',
};
expect(await getRunData(workflow, data, parentExecution)).toEqual({
executionData: {
executionData: {
contextData: {},
metadata: {},
nodeExecutionStack: [
{
data: { main: [data] },
metadata: { parentExecution },
node: workflow.nodes[0],
source: null,
},
],
waitingExecution: {},
waitingExecutionSource: {},
},
resultData: { runData: {} },
startData: {},
},
executionMode: 'integrated',
workflowData: workflow,
});
});
});
});

View file

@ -1,6 +1,5 @@
import type { Scope } from '@n8n/permissions';
import type { Application } from 'express';
import type { WorkflowExecute } from 'n8n-core';
import type {
ExecutionError,
ICredentialDataDecryptedObject,
@ -14,7 +13,6 @@ import type {
ITelemetryTrackProperties,
IWorkflowBase,
CredentialLoadingDetails,
Workflow,
WorkflowExecuteMode,
ExecutionStatus,
ExecutionSummary,
@ -300,12 +298,6 @@ export interface IWorkflowErrorData {
};
}
export interface IWorkflowExecuteProcess {
startedAt: Date;
workflow: Workflow;
workflowExecute: WorkflowExecute;
}
export interface IWorkflowStatisticsDataLoaded {
dataLoaded: boolean;
}

View file

@ -36,6 +36,8 @@ import type {
ExecuteWorkflowOptions,
IWorkflowExecutionDataProcess,
EnvProviderState,
ExecuteWorkflowData,
RelatedExecution,
} from 'n8n-workflow';
import { Container } from 'typedi';
@ -45,11 +47,7 @@ import { CredentialsHelper } from '@/credentials-helper';
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
import type { AiEventMap, AiEventPayload } from '@/events/maps/ai.event-map';
import { ExternalHooks } from '@/external-hooks';
import type {
IWorkflowExecuteProcess,
IWorkflowErrorData,
UpdateExecutionPayload,
} from '@/interfaces';
import type { IWorkflowErrorData, UpdateExecutionPayload } from '@/interfaces';
import { NodeTypes } from '@/node-types';
import { Push } from '@/push';
import { WorkflowStatisticsService } from '@/services/workflow-statistics.service';
@ -650,6 +648,7 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks {
export async function getRunData(
workflowData: IWorkflowBase,
inputData?: INodeExecutionData[],
parentExecution?: RelatedExecution,
): Promise<IWorkflowExecutionDataProcess> {
const mode = 'integrated';
@ -669,6 +668,7 @@ export async function getRunData(
data: {
main: [inputData],
},
metadata: { parentExecution },
source: null,
});
@ -740,7 +740,41 @@ export async function executeWorkflow(
workflowInfo: IExecuteWorkflowInfo,
additionalData: IWorkflowExecuteAdditionalData,
options: ExecuteWorkflowOptions,
): Promise<Array<INodeExecutionData[] | null> | IWorkflowExecuteProcess> {
): Promise<ExecuteWorkflowData> {
const activeExecutions = Container.get(ActiveExecutions);
const workflowData =
options.loadedWorkflowData ??
(await getWorkflowData(workflowInfo, options.parentWorkflowId, options.parentWorkflowSettings));
const runData =
options.loadedRunData ??
(await getRunData(workflowData, options.inputData, options.parentExecution));
const executionId = await activeExecutions.add(runData);
const executionPromise = startExecution(
additionalData,
options,
executionId,
runData,
workflowData,
);
if (options.doNotWaitToFinish) {
return { executionId, data: [null] };
}
return await executionPromise;
}
async function startExecution(
additionalData: IWorkflowExecuteAdditionalData,
options: ExecuteWorkflowOptions,
executionId: string,
runData: IWorkflowExecutionDataProcess,
workflowData: IWorkflowBase,
): Promise<ExecuteWorkflowData> {
const externalHooks = Container.get(ExternalHooks);
await externalHooks.init();
@ -749,10 +783,6 @@ export async function executeWorkflow(
const eventService = Container.get(EventService);
const executionRepository = Container.get(ExecutionRepository);
const workflowData =
options.loadedWorkflowData ??
(await getWorkflowData(workflowInfo, options.parentWorkflowId, options.parentWorkflowSettings));
const workflowName = workflowData ? workflowData.name : undefined;
const workflow = new Workflow({
id: workflowData.id,
@ -765,10 +795,6 @@ export async function executeWorkflow(
settings: workflowData.settings,
});
const runData = options.loadedRunData ?? (await getRunData(workflowData, options.inputData));
const executionId = await activeExecutions.add(runData);
/**
* A subworkflow execution in queue mode is not enqueued, but rather runs in the
* same worker process as the parent execution. Hence ensure the subworkflow
@ -890,7 +916,10 @@ export async function executeWorkflow(
activeExecutions.finalizeExecution(executionId, data);
const returnData = WorkflowHelpers.getDataLastExecutedNodeData(data);
return returnData!.data!.main;
return {
executionId,
data: returnData!.data!.main,
};
}
activeExecutions.finalizeExecution(executionId, data);

View file

@ -20,7 +20,8 @@
"lint": "eslint . --quiet",
"lintfix": "eslint . --fix",
"watch": "tsc-watch -p tsconfig.build.json --onCompilationComplete \"tsc-alias -p tsconfig.build.json\"",
"test": "jest"
"test": "jest",
"test:dev": "jest --watch"
},
"files": [
"dist",

View file

@ -39,6 +39,7 @@ import type {
BinaryHelperFunctions,
CloseFunction,
ContextType,
ExecuteWorkflowData,
FieldType,
FileSystemHelperFunctions,
FunctionsBase,
@ -78,6 +79,7 @@ import type {
IRunExecutionData,
ITaskData,
ITaskDataConnections,
ITaskMetadata,
ITriggerFunctions,
IWebhookData,
IWebhookDescription,
@ -109,6 +111,7 @@ import type {
ISupplyDataFunctions,
WebhookType,
SchedulingFunctions,
RelatedExecution,
} from 'n8n-workflow';
import {
NodeConnectionType,
@ -2721,6 +2724,7 @@ const addExecutionDataFunctions = async (
sourceNodeName: string,
sourceNodeRunIndex: number,
currentNodeRunIndex: number,
metadata?: ITaskMetadata,
): Promise<void> => {
if (connectionType === NodeConnectionType.Main) {
throw new ApplicationError('Setting type is not supported for main connection', {
@ -2746,6 +2750,7 @@ const addExecutionDataFunctions = async (
if (taskData === undefined) {
return;
}
taskData.metadata = metadata;
}
taskData = taskData!;
@ -3622,6 +3627,12 @@ export function getExecuteFunctions(
itemIndex,
),
getExecuteData: () => executeData,
setMetadata: (metadata: ITaskMetadata): void => {
executeData.metadata = {
...(executeData.metadata ?? {}),
...metadata,
};
},
continueOnFail: () => {
return continueOnFail(node);
},
@ -3643,23 +3654,28 @@ export function getExecuteFunctions(
workflowInfo: IExecuteWorkflowInfo,
inputData?: INodeExecutionData[],
parentCallbackManager?: CallbackManager,
): Promise<any> {
options?: {
doNotWaitToFinish?: boolean;
parentExecution?: RelatedExecution;
},
): Promise<ExecuteWorkflowData> {
return await additionalData
.executeWorkflow(workflowInfo, additionalData, {
...options,
parentWorkflowId: workflow.id?.toString(),
inputData,
parentWorkflowSettings: workflow.settings,
node,
parentCallbackManager,
})
.then(
async (result) =>
await Container.get(BinaryDataService).duplicateBinaryData(
workflow.id,
additionalData.executionId!,
result,
),
);
.then(async (result) => {
const data = await Container.get(BinaryDataService).duplicateBinaryData(
workflow.id,
additionalData.executionId!,
result.data,
);
return { ...result, data };
});
},
getContext(type: ContextType): IContextObject {
return NodeHelpers.getContext(runExecutionData, type, node);
@ -3853,6 +3869,7 @@ export function getExecuteFunctions(
connectionType: NodeConnectionType,
currentNodeRunIndex: number,
data: INodeExecutionData[][] | ExecutionBaseError,
metadata?: ITaskMetadata,
): void {
addExecutionDataFunctions(
'output',
@ -3864,6 +3881,7 @@ export function getExecuteFunctions(
node.name,
runIndex,
currentNodeRunIndex,
metadata,
).catch((error) => {
Logger.warn(
`There was a problem logging output data of node "${this.getNode().name}": ${
@ -3972,7 +3990,11 @@ export function getSupplyDataFunctions(
workflowInfo: IExecuteWorkflowInfo,
inputData?: INodeExecutionData[],
parentCallbackManager?: CallbackManager,
) =>
options?: {
doNotWaitToFinish?: boolean;
parentExecution?: RelatedExecution;
},
): Promise<ExecuteWorkflowData> =>
await additionalData
.executeWorkflow(workflowInfo, additionalData, {
parentWorkflowId: workflow.id?.toString(),
@ -3980,15 +4002,16 @@ export function getSupplyDataFunctions(
parentWorkflowSettings: workflow.settings,
node,
parentCallbackManager,
...options,
})
.then(
async (result) =>
await Container.get(BinaryDataService).duplicateBinaryData(
workflow.id,
additionalData.executionId!,
result,
),
),
.then(async (result) => {
const data = await Container.get(BinaryDataService).duplicateBinaryData(
workflow.id,
additionalData.executionId!,
result.data,
);
return { ...result, data };
}),
getNodeOutputs() {
const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
return NodeHelpers.getNodeOutputs(workflow, node, nodeType.description).map((output) => {
@ -4143,6 +4166,7 @@ export function getSupplyDataFunctions(
connectionType: NodeConnectionType,
currentNodeRunIndex: number,
data: INodeExecutionData[][],
metadata?: ITaskMetadata,
): void {
addExecutionDataFunctions(
'output',
@ -4154,6 +4178,7 @@ export function getSupplyDataFunctions(
node.name,
runIndex,
currentNodeRunIndex,
metadata,
).catch((error) => {
Logger.warn(
`There was a problem logging output data of node "${this.getNode().name}": ${

View file

@ -408,7 +408,10 @@ export class WorkflowExecute {
let metaRunData: ITaskMetadata;
for (const nodeName of Object.keys(metadata)) {
for ([index, metaRunData] of metadata[nodeName].entries()) {
runData[nodeName][index].metadata = metaRunData;
runData[nodeName][index].metadata = {
...(runData[nodeName][index].metadata ?? {}),
...metaRunData,
};
}
}
}
@ -1448,6 +1451,7 @@ export class WorkflowExecute {
startTime,
executionTime: new Date().getTime() - startTime,
source: !executionData.source ? [] : executionData.source.main,
metadata: executionData.metadata,
executionStatus: 'success',
};

View file

@ -17,6 +17,7 @@ import type {
IContextObject,
ICredentialDataDecryptedObject,
ISourceData,
ITaskMetadata,
} from 'n8n-workflow';
import { ApplicationError, NodeHelpers } from 'n8n-workflow';
@ -298,4 +299,33 @@ describe('ExecuteSingleContext', () => {
});
});
});
describe('setMetadata', () => {
it('sets metadata on execution data', () => {
const context = new ExecuteSingleContext(
workflow,
node,
additionalData,
mode,
runExecutionData,
runIndex,
connectionInputData,
inputData,
itemIndex,
executeData,
abortSignal,
);
const metadata: ITaskMetadata = {
subExecution: {
workflowId: '123',
executionId: 'xyz',
},
};
expect(context.getExecuteData().metadata?.subExecution).toEqual(undefined);
context.setMetadata(metadata);
expect(context.getExecuteData().metadata?.subExecution).toEqual(metadata.subExecution);
});
});
});

View file

@ -13,6 +13,7 @@ import type {
ContextType,
AiEvent,
ISourceData,
ITaskMetadata,
} from 'n8n-workflow';
import {
ApplicationError,
@ -85,6 +86,13 @@ export class ExecuteSingleContext extends NodeExecutionContext implements IExecu
this.abortSignal?.addEventListener('abort', fn);
}
setMetadata(metadata: ITaskMetadata): void {
this.executeData.metadata = {
...(this.executeData.metadata ?? {}),
...metadata,
};
}
continueOnFail() {
return continueOnFail(this.node);
}

View file

@ -182,6 +182,10 @@ export interface IAiDataContent {
metadata: {
executionTime: number;
startTime: number;
subExecution?: {
workflowId: string;
executionId: string;
};
};
}
@ -202,6 +206,10 @@ export interface ITableData {
columns: string[];
data: GenericValue[][];
hasJson: { [key: string]: boolean };
metadata: {
hasExecutionIds: boolean;
data: Array<INodeExecutionData['metadata'] | undefined>;
};
}
// Simple version of n8n-workflow.Workflow

View file

@ -35,7 +35,7 @@ const onDragStart = () => {
@dragend="onDragEnd"
>
<template #default="{ isDragging }">
<div :class="{ [$style.dragButton]: true }">
<div :class="{ [$style.dragButton]: true }" data-test-id="panel-drag-button">
<span
v-if="canMoveLeft"
:class="{ [$style.leftArrow]: true, [$style.visible]: isDragging }"

View file

@ -8,10 +8,17 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import { waitFor } from '@testing-library/vue';
import type { INodeExecutionData } from 'n8n-workflow';
import type { INodeExecutionData, ITaskData, ITaskMetadata } from 'n8n-workflow';
import { setActivePinia } from 'pinia';
import { useNodeTypesStore } from '../stores/nodeTypes.store';
const MOCK_EXECUTION_URL = 'execution.url/123';
const { trackOpeningRelatedExecution, resolveRelatedExecutionUrl } = vi.hoisted(() => ({
trackOpeningRelatedExecution: vi.fn(),
resolveRelatedExecutionUrl: vi.fn(),
}));
vi.mock('vue-router', () => {
return {
useRouter: () => ({}),
@ -20,6 +27,13 @@ vi.mock('vue-router', () => {
};
});
vi.mock('@/composables/useExecutionHelpers', () => ({
useExecutionHelpers: () => ({
trackOpeningRelatedExecution,
resolveRelatedExecutionUrl,
}),
}));
const nodes = [
{
id: '1',
@ -32,50 +46,55 @@ const nodes = [
] as INodeUi[];
describe('RunData', () => {
beforeAll(() => {
resolveRelatedExecutionUrl.mockReturnValue('execution.url/123');
});
it("should render pin button in output panel disabled when there's binary data", () => {
const { getByTestId } = render(
[
const { getByTestId } = render({
defaultRunItems: [
{
json: {},
binary: {
data: {
fileName: 'test.xyz',
mimeType: 'application/octet-stream',
data: '',
},
},
},
],
'binary',
);
displayMode: 'binary',
});
expect(getByTestId('ndv-pin-data')).toBeInTheDocument();
expect(getByTestId('ndv-pin-data')).toHaveAttribute('disabled');
});
it("should not render pin button in input panel when there's binary data", () => {
const { queryByTestId } = render(
[
const { queryByTestId } = render({
defaultRunItems: [
{
json: {},
binary: {
data: {
fileName: 'test.xyz',
mimeType: 'application/octet-stream',
data: '',
},
},
},
],
'binary',
undefined,
'input',
);
displayMode: 'binary',
paneType: 'input',
});
expect(queryByTestId('ndv-pin-data')).not.toBeInTheDocument();
});
it('should render data correctly even when "item.json" has another "json" key', async () => {
const { getByText, getAllByTestId, getByTestId } = render(
[
const { getByText, getAllByTestId, getByTestId } = render({
defaultRunItems: [
{
json: {
id: 1,
@ -95,8 +114,8 @@ describe('RunData', () => {
},
},
],
'schema',
);
displayMode: 'schema',
});
await userEvent.click(getByTestId('ndv-pin-data'));
await waitFor(() => getAllByTestId('run-data-schema-item'), { timeout: 1000 });
@ -105,8 +124,8 @@ describe('RunData', () => {
});
it('should render view and download buttons for PDFs', async () => {
const { getByTestId } = render(
[
const { getByTestId } = render({
defaultRunItems: [
{
json: {},
binary: {
@ -114,12 +133,13 @@ describe('RunData', () => {
fileName: 'test.pdf',
fileType: 'pdf',
mimeType: 'application/pdf',
data: '',
},
},
},
],
'binary',
);
displayMode: 'binary',
});
await waitFor(() => {
expect(getByTestId('ndv-view-binary-data')).toBeInTheDocument();
@ -129,20 +149,21 @@ describe('RunData', () => {
});
it('should not render a view button for unknown content-type', async () => {
const { getByTestId, queryByTestId } = render(
[
const { getByTestId, queryByTestId } = render({
defaultRunItems: [
{
json: {},
binary: {
data: {
fileName: 'test.xyz',
mimeType: 'application/octet-stream',
data: '',
},
},
},
],
'binary',
);
displayMode: 'binary',
});
await waitFor(() => {
expect(queryByTestId('ndv-view-binary-data')).not.toBeInTheDocument();
@ -152,25 +173,32 @@ describe('RunData', () => {
});
it('should not render pin data button when there is no output data', async () => {
const { queryByTestId } = render([], 'table');
const { queryByTestId } = render({ defaultRunItems: [], displayMode: 'table' });
expect(queryByTestId('ndv-pin-data')).not.toBeInTheDocument();
});
it('should disable pin data button when data is pinned', async () => {
const { getByTestId } = render([], 'table', [{ json: { name: 'Test' } }]);
const { getByTestId } = render({
defaultRunItems: [],
displayMode: 'table',
pinnedData: [{ json: { name: 'Test' } }],
});
const pinDataButton = getByTestId('ndv-pin-data');
expect(pinDataButton).toBeDisabled();
});
it('should enable pin data button when data is not pinned', async () => {
const { getByTestId } = render([{ json: { name: 'Test' } }], 'table');
const { getByTestId } = render({
defaultRunItems: [{ json: { name: 'Test' } }],
displayMode: 'table',
});
const pinDataButton = getByTestId('ndv-pin-data');
expect(pinDataButton).toBeEnabled();
});
it('should not render pagination on binary tab', async () => {
const { queryByTestId } = render(
Array.from({ length: 11 }).map((_, i) => ({
const { queryByTestId } = render({
defaultRunItems: Array.from({ length: 11 }).map((_, i) => ({
json: {
data: {
id: i,
@ -180,17 +208,19 @@ describe('RunData', () => {
binary: {
data: {
a: 'b',
data: '',
mimeType: '',
},
},
})),
'binary',
);
displayMode: 'binary',
});
expect(queryByTestId('ndv-data-pagination')).not.toBeInTheDocument();
});
it('should render pagination with binary data on non-binary tab', async () => {
const { getByTestId } = render(
Array.from({ length: 11 }).map((_, i) => ({
const { getByTestId } = render({
defaultRunItems: Array.from({ length: 11 }).map((_, i) => ({
json: {
data: {
id: i,
@ -200,20 +230,177 @@ describe('RunData', () => {
binary: {
data: {
a: 'b',
data: '',
mimeType: '',
},
},
})),
'json',
);
displayMode: 'json',
});
expect(getByTestId('ndv-data-pagination')).toBeInTheDocument();
});
const render = (
outputData: unknown[],
displayMode: IRunDataDisplayMode,
pinnedData?: INodeExecutionData[],
paneType: NodePanelType = 'output',
) => {
it('should render sub-execution link in header', async () => {
const metadata: ITaskMetadata = {
subExecution: {
workflowId: 'xyz',
executionId: '123',
},
subExecutionsCount: 1,
};
const { getByTestId } = render({
defaultRunItems: [
{
json: {},
},
],
displayMode: 'table',
paneType: 'output',
metadata,
});
expect(getByTestId('related-execution-link')).toBeInTheDocument();
expect(getByTestId('related-execution-link')).toHaveTextContent('Inspect Sub-Execution 123');
expect(resolveRelatedExecutionUrl).toHaveBeenCalledWith(metadata);
expect(getByTestId('related-execution-link')).toHaveAttribute('href', MOCK_EXECUTION_URL);
expect(getByTestId('ndv-items-count')).toHaveTextContent('1 item, 1 sub-execution');
getByTestId('related-execution-link').click();
expect(trackOpeningRelatedExecution).toHaveBeenCalledWith(metadata, 'table');
});
it('should render parent-execution link in header', async () => {
const metadata: ITaskMetadata = {
parentExecution: {
workflowId: 'xyz',
executionId: '123',
},
};
const { getByTestId } = render({
defaultRunItems: [
{
json: {},
},
],
displayMode: 'table',
paneType: 'output',
metadata,
});
expect(getByTestId('related-execution-link')).toBeInTheDocument();
expect(getByTestId('related-execution-link')).toHaveTextContent('Inspect Parent Execution 123');
expect(resolveRelatedExecutionUrl).toHaveBeenCalledWith(metadata);
expect(getByTestId('related-execution-link')).toHaveAttribute('href', MOCK_EXECUTION_URL);
expect(getByTestId('ndv-items-count')).toHaveTextContent('1 item');
getByTestId('related-execution-link').click();
expect(trackOpeningRelatedExecution).toHaveBeenCalledWith(metadata, 'table');
});
it('should render sub-execution link in header with multiple items', async () => {
const metadata: ITaskMetadata = {
subExecution: {
workflowId: 'xyz',
executionId: '123',
},
subExecutionsCount: 3,
};
const { getByTestId } = render({
defaultRunItems: [
{
json: {},
},
{
json: {},
},
],
displayMode: 'json',
paneType: 'output',
metadata,
});
expect(getByTestId('related-execution-link')).toBeInTheDocument();
expect(getByTestId('related-execution-link')).toHaveTextContent('Inspect Sub-Execution 123');
expect(resolveRelatedExecutionUrl).toHaveBeenCalledWith(metadata);
expect(getByTestId('related-execution-link')).toHaveAttribute('href', MOCK_EXECUTION_URL);
expect(getByTestId('ndv-items-count')).toHaveTextContent('2 items, 3 sub-executions');
getByTestId('related-execution-link').click();
expect(trackOpeningRelatedExecution).toHaveBeenCalledWith(metadata, 'json');
});
it('should render sub-execution link in header with multiple runs', async () => {
const metadata: ITaskMetadata = {
subExecution: {
workflowId: 'xyz',
executionId: '123',
},
subExecutionsCount: 3,
};
const { getByTestId, queryByTestId } = render({
runs: [
{
startTime: new Date().getTime(),
executionTime: new Date().getTime(),
data: {
main: [[{ json: {} }]],
},
source: [null],
metadata,
},
{
startTime: new Date().getTime(),
executionTime: new Date().getTime(),
data: {
main: [[{ json: {} }]],
},
source: [null],
metadata,
},
],
displayMode: 'json',
paneType: 'output',
metadata,
});
expect(getByTestId('related-execution-link')).toBeInTheDocument();
expect(getByTestId('related-execution-link')).toHaveTextContent('Inspect Sub-Execution 123');
expect(queryByTestId('ndv-items-count')).not.toBeInTheDocument();
expect(getByTestId('run-selector')).toBeInTheDocument();
getByTestId('related-execution-link').click();
expect(trackOpeningRelatedExecution).toHaveBeenCalledWith(metadata, 'json');
});
const render = ({
defaultRunItems,
displayMode,
pinnedData,
paneType = 'output',
metadata,
runs,
}: {
defaultRunItems?: INodeExecutionData[];
displayMode: IRunDataDisplayMode;
pinnedData?: INodeExecutionData[];
paneType?: NodePanelType;
metadata?: ITaskMetadata;
runs?: ITaskData[];
}) => {
const defaultRun: ITaskData = {
startTime: new Date().getTime(),
executionTime: new Date().getTime(),
data: {
main: [defaultRunItems ?? [{ json: {} }]],
},
source: [null],
metadata,
};
const pinia = createTestingPinia({
stubActions: false,
initialState: {
@ -246,16 +433,7 @@ describe('RunData', () => {
data: {
resultData: {
runData: {
'Test Node': [
{
startTime: new Date().getTime(),
executionTime: new Date().getTime(),
data: {
main: [outputData],
},
source: [null],
},
],
'Test Node': runs ?? [defaultRun],
},
},
},

View file

@ -9,6 +9,7 @@ import type {
INodeOutputConfiguration,
IRunData,
IRunExecutionData,
ITaskMetadata,
NodeError,
NodeHint,
Workflow,
@ -77,6 +78,7 @@ import {
} from 'n8n-design-system';
import { storeToRefs } from 'pinia';
import { useRoute } from 'vue-router';
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
const LazyRunDataTable = defineAsyncComponent(
async () => await import('@/components/RunDataTable.vue'),
@ -180,6 +182,7 @@ const nodeHelpers = useNodeHelpers();
const externalHooks = useExternalHooks();
const telemetry = useTelemetry();
const i18n = useI18n();
const { trackOpeningRelatedExecution, resolveRelatedExecutionUrl } = useExecutionHelpers();
const node = toRef(props, 'node');
@ -315,7 +318,9 @@ const workflowRunData = computed(() => {
}
return null;
});
const dataCount = computed(() => getDataCount(props.runIndex, currentOutputIndex.value));
const dataCount = computed(() =>
getDataCount(props.runIndex, currentOutputIndex.value, connectionType.value),
);
const unfilteredDataCount = computed(() =>
pinnedData.data.value ? pinnedData.data.value.length : rawInputData.value.length,
@ -506,6 +511,28 @@ const pinButtonDisabled = computed(
readOnlyEnv.value,
);
const activeTaskMetadata = computed((): ITaskMetadata | null => {
if (!node.value) {
return null;
}
return workflowRunData.value?.[node.value.name]?.[props.runIndex]?.metadata ?? null;
});
const hasReleatedExectuion = computed((): boolean => {
return Boolean(
activeTaskMetadata.value?.subExecution || activeTaskMetadata.value?.parentExecution,
);
});
const hasInputOverwrite = computed((): boolean => {
if (!node.value) {
return false;
}
const taskData = nodeHelpers.getNodeTaskData(node.value, props.runIndex);
return Boolean(taskData?.inputOverride);
});
watch(node, (newNode, prevNode) => {
if (newNode?.id === prevNode?.id) return;
init();
@ -953,6 +980,10 @@ function onDisplayModeChange(newDisplayMode: IRunDataDisplayMode) {
}
function getRunLabel(option: number) {
if (!node.value) {
return;
}
let itemsCount = 0;
for (let i = 0; i <= maxOutputIndex.value; i++) {
itemsCount += getPinDataOrLiveData(getRawInputData(option - 1, i)).length;
@ -961,7 +992,18 @@ function getRunLabel(option: number) {
adjustToNumber: itemsCount,
interpolate: { count: itemsCount },
});
const itemsLabel = itemsCount > 0 ? ` (${items})` : '';
const metadata = workflowRunData.value?.[node.value.name]?.[option - 1]?.metadata ?? null;
const subexecutions = metadata?.subExecutionsCount
? i18n.baseText('ndv.output.andSubExecutions', {
adjustToNumber: metadata.subExecutionsCount,
interpolate: {
count: metadata.subExecutionsCount,
},
})
: '';
const itemsLabel = itemsCount > 0 ? ` (${items}${subexecutions})` : '';
return option + i18n.baseText('ndv.output.of') + (maxRunIndex.value + 1) + itemsLabel;
}
@ -1185,6 +1227,22 @@ function onSearchClear() {
document.dispatchEvent(new KeyboardEvent('keyup', { key: '/' }));
}
function getExecutionLinkLabel(task: ITaskMetadata): string | undefined {
if (task.parentExecution) {
return i18n.baseText('runData.openParentExecution', {
interpolate: { id: task.parentExecution.executionId },
});
}
if (task.subExecution) {
return i18n.baseText('runData.openSubExecution', {
interpolate: { id: task.subExecution.executionId },
});
}
return;
}
defineExpose({ enterEditMode });
</script>
@ -1311,42 +1369,58 @@ defineExpose({ enterEditMode });
v-show="!editMode.enabled"
:class="$style.runSelector"
>
<slot v-if="inputSelectLocation === 'runs'" name="input-select"></slot>
<div :class="$style.runSelectorInner">
<slot v-if="inputSelectLocation === 'runs'" name="input-select"></slot>
<N8nSelect
:model-value="runIndex"
:class="$style.runSelectorInner"
size="small"
teleported
data-test-id="run-selector"
@update:model-value="onRunIndexChange"
@click.stop
>
<template #prepend>{{ $locale.baseText('ndv.output.run') }}</template>
<N8nOption
v-for="option in maxRunIndex + 1"
:key="option"
:label="getRunLabel(option)"
:value="option - 1"
></N8nOption>
</N8nSelect>
<N8nTooltip v-if="canLinkRuns" placement="right">
<template #content>
{{ $locale.baseText(linkedRuns ? 'runData.unlinking.hint' : 'runData.linking.hint') }}
</template>
<N8nIconButton
:icon="linkedRuns ? 'unlink' : 'link'"
class="linkRun"
text
type="tertiary"
<N8nSelect
:model-value="runIndex"
:class="$style.runSelectorSelect"
size="small"
data-test-id="link-run"
@click="toggleLinkRuns"
/>
</N8nTooltip>
teleported
data-test-id="run-selector"
@update:model-value="onRunIndexChange"
@click.stop
>
<template #prepend>{{ $locale.baseText('ndv.output.run') }}</template>
<N8nOption
v-for="option in maxRunIndex + 1"
:key="option"
:label="getRunLabel(option)"
:value="option - 1"
></N8nOption>
</N8nSelect>
<slot name="run-info"></slot>
<N8nTooltip v-if="canLinkRuns" placement="right">
<template #content>
{{ $locale.baseText(linkedRuns ? 'runData.unlinking.hint' : 'runData.linking.hint') }}
</template>
<N8nIconButton
:icon="linkedRuns ? 'unlink' : 'link'"
class="linkRun"
text
type="tertiary"
size="small"
data-test-id="link-run"
@click="toggleLinkRuns"
/>
</N8nTooltip>
<slot name="run-info"></slot>
</div>
<a
v-if="
activeTaskMetadata && hasReleatedExectuion && !(paneType === 'input' && hasInputOverwrite)
"
:class="$style.relatedExecutionInfo"
data-test-id="related-execution-link"
:href="resolveRelatedExecutionUrl(activeTaskMetadata)"
target="_blank"
@click.stop="trackOpeningRelatedExecution(activeTaskMetadata, displayMode)"
>
<N8nIcon icon="external-link-alt" size="xsmall" />
{{ getExecutionLinkLabel(activeTaskMetadata) }}
</a>
</div>
<slot v-if="!displaysMultipleNodes" name="before-data" />
@ -1400,13 +1474,37 @@ defineExpose({ enterEditMode });
}}
</N8nText>
<N8nText v-else :class="$style.itemsText">
{{
$locale.baseText('ndv.output.items', {
adjustToNumber: dataCount,
interpolate: { count: dataCount },
})
}}
<span>
{{
$locale.baseText('ndv.output.items', {
adjustToNumber: dataCount,
interpolate: { count: dataCount },
})
}}
</span>
<span v-if="activeTaskMetadata?.subExecutionsCount">
{{
$locale.baseText('ndv.output.andSubExecutions', {
adjustToNumber: activeTaskMetadata.subExecutionsCount,
interpolate: { count: activeTaskMetadata.subExecutionsCount },
})
}}
</span>
</N8nText>
<a
v-if="
activeTaskMetadata && hasReleatedExectuion && !(paneType === 'input' && hasInputOverwrite)
"
:class="$style.relatedExecutionInfo"
data-test-id="related-execution-link"
:href="resolveRelatedExecutionUrl(activeTaskMetadata)"
target="_blank"
@click.stop="trackOpeningRelatedExecution(activeTaskMetadata, displayMode)"
>
<N8nIcon icon="external-link-alt" size="xsmall" />
{{ getExecutionLinkLabel(activeTaskMetadata) }}
</a>
</div>
<div ref="dataContainerRef" :class="$style.dataContainer" data-test-id="ndv-data-container">
@ -1877,6 +1975,7 @@ defineExpose({ enterEditMode });
padding-left: var(--spacing-s);
padding-right: var(--spacing-s);
padding-bottom: var(--spacing-s);
flex-flow: wrap;
.itemsText {
flex-shrink: 0;
@ -1898,24 +1997,31 @@ defineExpose({ enterEditMode });
}
.runSelector {
display: flex;
align-items: center;
flex-flow: wrap;
padding-left: var(--spacing-s);
padding-right: var(--spacing-s);
padding-bottom: var(--spacing-s);
display: flex;
gap: var(--spacing-4xs);
align-items: center;
margin-bottom: var(--spacing-s);
gap: var(--spacing-3xs);
:global(.el-input--suffix .el-input__inner) {
padding-right: var(--spacing-l);
}
}
.search {
margin-left: auto;
.runSelectorInner {
display: flex;
gap: var(--spacing-4xs);
align-items: center;
}
.runSelectorInner {
max-width: 172px;
.runSelectorSelect {
max-width: 205px;
}
.search {
margin-left: auto;
}
.pagination {
@ -2075,6 +2181,15 @@ defineExpose({ enterEditMode });
.schema {
padding: 0 var(--spacing-s);
}
.relatedExecutionInfo {
font-size: var(--font-size-s);
margin-left: var(--spacing-3xs);
svg {
padding-bottom: 2px;
}
}
</style>
<style lang="scss" scoped>

View file

@ -60,6 +60,7 @@ function getReferencedData(
metadata: {
executionTime: taskData.executionTime,
startTime: taskData.startTime,
subExecution: taskData.metadata?.subExecution,
},
});
});

View file

@ -11,6 +11,7 @@ import type {
import { computed } from 'vue';
import NodeIcon from '@/components/NodeIcon.vue';
import AiRunContentBlock from './AiRunContentBlock.vue';
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
interface RunMeta {
startTimeMs: number;
@ -18,6 +19,10 @@ interface RunMeta {
node: INodeTypeDescription | null;
type: 'input' | 'output';
connectionType: NodeConnectionType;
subExecution?: {
workflowId: string;
executionId: string;
};
}
const props = defineProps<{
inputData: IAiData;
@ -27,6 +32,8 @@ const props = defineProps<{
const nodeTypesStore = useNodeTypesStore();
const workflowsStore = useWorkflowsStore();
const { trackOpeningRelatedExecution, resolveRelatedExecutionUrl } = useExecutionHelpers();
type TokenUsageData = {
completionTokens: number;
promptTokens: number;
@ -75,6 +82,7 @@ function extractRunMeta(run: IAiDataContent) {
node: nodeType,
type: run.inOut,
connectionType: run.type,
subExecution: run.metadata?.subExecution,
};
return runMeta;
@ -131,6 +139,22 @@ const outputError = computed(() => {
}}
</n8n-tooltip>
</li>
<li v-if="runMeta?.subExecution">
<a
:href="resolveRelatedExecutionUrl(runMeta)"
target="_blank"
@click.stop="trackOpeningRelatedExecution(runMeta, 'ai')"
>
<N8nIcon icon="external-link-alt" size="xsmall" />
{{
$locale.baseText('runData.openSubExecution', {
interpolate: {
id: runMeta.subExecution?.executionId,
},
})
}}
</a>
</li>
<li v-if="(consumedTokensSum?.totalTokens ?? 0) > 0" :class="$style.tokensUsage">
{{
$locale.baseText('runData.aiContentBlock.tokens', {

View file

@ -13,8 +13,9 @@ import MappingPill from './MappingPill.vue';
import TextWithHighlights from './TextWithHighlights.vue';
import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { N8nInfoTip, N8nTooltip, N8nTree } from 'n8n-design-system';
import { N8nIconButton, N8nInfoTip, N8nTooltip, N8nTree } from 'n8n-design-system';
import { storeToRefs } from 'pinia';
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
const MAX_COLUMNS_LIMIT = 40;
@ -63,6 +64,7 @@ const workflowsStore = useWorkflowsStore();
const i18n = useI18n();
const telemetry = useTelemetry();
const { trackOpeningRelatedExecution, resolveRelatedExecutionUrl } = useExecutionHelpers();
const {
hoveringItem,
@ -116,6 +118,18 @@ function isHoveringRow(row: number): boolean {
return pairedItemMappings.value[itemNodeId].has(hoveringItemId);
}
function showExecutionLink(index: number) {
if (index === activeRow.value) {
return true;
}
if (activeRow.value === null) {
return index === 0;
}
return false;
}
function onMouseEnterCell(e: MouseEvent) {
const target = e.target;
if (target && props.mappingEnabled) {
@ -304,6 +318,11 @@ function convertToTable(inputData: INodeExecutionData[]): ITableData {
let leftEntryColumns: string[], entryRows: GenericValue[];
// Go over all entries
let entry: IDataObject;
const metadata: ITableData['metadata'] = {
hasExecutionIds: false,
data: [],
};
const hasJson: { [key: string]: boolean } = {};
inputData.forEach((data) => {
if (!data.hasOwnProperty('json')) {
@ -322,6 +341,13 @@ function convertToTable(inputData: INodeExecutionData[]): ITableData {
leftEntryColumns = entryColumns;
}
if (data.metadata?.subExecution) {
metadata.data.push(data.metadata);
metadata.hasExecutionIds = true;
} else {
metadata.data.push(undefined);
}
// Go over all the already existing column-keys
tableColumns.forEach((key) => {
if (entry.hasOwnProperty(key)) {
@ -368,6 +394,7 @@ function convertToTable(inputData: INodeExecutionData[]): ITableData {
hasJson,
columns: tableColumns,
data: resultTableData,
metadata,
};
}
@ -390,6 +417,9 @@ watch(focusedMappableInput, (curr) => {
<table v-if="tableData.columns && tableData.columns.length === 0" :class="$style.table">
<thead>
<tr>
<th v-if="tableData.metadata.hasExecutionIds" :class="$style.executionLinkRowHeader">
<!-- column for execution link -->
</th>
<th :class="$style.emptyCell"></th>
<th :class="$style.tableRightMargin"></th>
</tr>
@ -400,6 +430,37 @@ watch(focusedMappableInput, (curr) => {
:key="index1"
:class="{ [$style.hoveringRow]: isHoveringRow(index1) }"
>
<td
v-if="tableData.metadata.hasExecutionIds"
:data-row="index1"
:class="$style.executionLinkCell"
@mouseenter="onMouseEnterCell"
@mouseleave="onMouseLeaveCell"
>
<N8nTooltip
:content="
i18n.baseText('runData.table.inspectSubExecution', {
interpolate: {
id: `${tableData.metadata.data[index1]?.subExecution.executionId}`,
},
})
"
placement="left"
:hide-after="0"
>
<N8nIconButton
v-if="tableData.metadata.data[index1]"
v-show="showExecutionLink(index1)"
type="secondary"
icon="external-link-alt"
data-test-id="debug-sub-execution"
size="mini"
:href="resolveRelatedExecutionUrl(tableData.metadata.data[index1])"
target="_blank"
@click="trackOpeningRelatedExecution(tableData.metadata.data[index1], 'table')"
/>
</N8nTooltip>
</td>
<td
:data-row="index1"
:data-col="0"
@ -415,6 +476,9 @@ watch(focusedMappableInput, (curr) => {
<table v-else :class="$style.table">
<thead>
<tr>
<th v-if="tableData.metadata.hasExecutionIds" :class="$style.executionLinkRowHeader">
<!-- column for execution link -->
</th>
<th v-for="(column, i) in tableData.columns || []" :key="column">
<N8nTooltip placement="bottom-start" :disabled="!mappingEnabled" :show-after="1000">
<template #content>
@ -502,6 +566,40 @@ watch(focusedMappableInput, (curr) => {
:class="{ [$style.hoveringRow]: isHoveringRow(index1) }"
:data-test-id="isHoveringRow(index1) ? 'hovering-item' : undefined"
>
<td
v-if="tableData.metadata.hasExecutionIds"
:data-row="index1"
:class="$style.executionLinkCell"
@mouseenter="onMouseEnterCell"
@mouseleave="onMouseLeaveCell"
>
<N8nTooltip
:content="
i18n.baseText('runData.table.inspectSubExecution', {
interpolate: {
id: `${tableData.metadata.data[index1]?.subExecution.executionId}`,
},
})
"
placement="left"
:hide-after="0"
>
<a
v-if="tableData.metadata.data[index1]"
v-show="showExecutionLink(index1)"
:href="resolveRelatedExecutionUrl(tableData.metadata.data[index1])"
target="_blank"
@click="trackOpeningRelatedExecution(tableData.metadata.data[index1], 'table')"
>
<N8nIconButton
type="secondary"
icon="external-link-alt"
data-test-id="debug-sub-execution"
size="mini"
/>
</a>
</N8nTooltip>
</td>
<td
v-for="(data, index2) in row"
:key="index2"
@ -736,4 +834,12 @@ watch(focusedMappableInput, (curr) => {
.warningTooltip {
color: var(--color-warning);
}
.executionLinkCell {
padding: var(--spacing-3xs) !important;
}
.executionLinkRowHeader {
width: var(--spacing-m);
}
</style>

View file

@ -2,8 +2,7 @@
import { ref, computed, useCssModule } from 'vue';
import type { ExecutionSummary } from 'n8n-workflow';
import { useI18n } from '@/composables/useI18n';
import { VIEWS, WAIT_TIME_UNLIMITED } from '@/constants';
import { useRouter } from 'vue-router';
import { WAIT_TIME_UNLIMITED } from '@/constants';
import { convertToDisplayDate } from '@/utils/formatters/dateFormatter';
import { i18n as locale } from '@/plugins/i18n';
import ExecutionsTime from '@/components/executions/ExecutionsTime.vue';
@ -35,7 +34,6 @@ const props = withDefaults(
const style = useCssModule();
const i18n = useI18n();
const router = useRouter();
const executionHelpers = useExecutionHelpers();
const isStopping = ref(false);
@ -138,11 +136,7 @@ function formatDate(fullDate: Date | string | number) {
}
function displayExecution() {
const route = router.resolve({
name: VIEWS.EXECUTION_PREVIEW,
params: { name: props.execution.workflowId, executionId: props.execution.id },
});
window.open(route.href, '_blank');
executionHelpers.openExecutionInNewTab(props.execution.id, props.execution.workflowId);
}
function onStopExecution() {

View file

@ -3,6 +3,22 @@ import type { ExecutionSummary } from 'n8n-workflow';
import { i18n } from '@/plugins/i18n';
import { convertToDisplayDate } from '@/utils/formatters/dateFormatter';
const { resolve, track } = vi.hoisted(() => ({
resolve: vi.fn(),
track: vi.fn(),
}));
vi.mock('vue-router', () => ({
useRouter: () => ({
resolve,
}),
RouterLink: vi.fn(),
}));
vi.mock('@/composables/useTelemetry', () => ({
useTelemetry: () => ({ track }),
}));
describe('useExecutionHelpers()', () => {
describe('getUIDetails()', () => {
it.each([
@ -68,4 +84,70 @@ describe('useExecutionHelpers()', () => {
).toEqual(false);
});
});
describe('openExecutionInNewTab', () => {
const executionId = '123';
const workflowId = 'xyz';
const href = 'test.com';
global.window.open = vi.fn();
it('opens execution in new tab', () => {
const { openExecutionInNewTab } = useExecutionHelpers();
resolve.mockReturnValue({ href });
openExecutionInNewTab(executionId, workflowId);
expect(window.open).toHaveBeenCalledWith(href, '_blank');
});
});
describe('trackOpeningRelatedExecution', () => {
it('tracks sub execution click', () => {
const { trackOpeningRelatedExecution } = useExecutionHelpers();
trackOpeningRelatedExecution(
{ subExecution: { executionId: '123', workflowId: 'xyz' } },
'table',
);
expect(track).toHaveBeenCalledWith('User clicked inspect sub-workflow', { view: 'table' });
});
it('tracks parent execution click', () => {
const { trackOpeningRelatedExecution } = useExecutionHelpers();
trackOpeningRelatedExecution(
{ parentExecution: { executionId: '123', workflowId: 'xyz' } },
'json',
);
expect(track).toHaveBeenCalledWith('User clicked parent execution button', { view: 'json' });
});
});
describe('resolveRelatedExecutionUrl', () => {
it('resolves sub execution url', () => {
const fullPath = 'test.com';
resolve.mockReturnValue({ fullPath });
const { resolveRelatedExecutionUrl } = useExecutionHelpers();
expect(
resolveRelatedExecutionUrl({ subExecution: { executionId: '123', workflowId: 'xyz' } }),
).toEqual(fullPath);
});
it('resolves parent execution url', () => {
const fullPath = 'test.com';
resolve.mockReturnValue({ fullPath });
const { resolveRelatedExecutionUrl } = useExecutionHelpers();
expect(
resolveRelatedExecutionUrl({ parentExecution: { executionId: '123', workflowId: 'xyz' } }),
).toEqual(fullPath);
});
it('returns empty if no related execution url', () => {
const { resolveRelatedExecutionUrl } = useExecutionHelpers();
expect(resolveRelatedExecutionUrl({})).toEqual('');
});
});
});

View file

@ -1,6 +1,10 @@
import type { ExecutionSummary } from 'n8n-workflow';
import type { ExecutionSummary, RelatedExecution } from 'n8n-workflow';
import { convertToDisplayDate } from '@/utils/formatters/dateFormatter';
import { useI18n } from '@/composables/useI18n';
import { useRouter } from 'vue-router';
import { VIEWS } from '@/constants';
import { useTelemetry } from './useTelemetry';
import type { IRunDataDisplayMode } from '@/Interface';
export interface IExecutionUIData {
name: string;
@ -14,6 +18,8 @@ export interface IExecutionUIData {
export function useExecutionHelpers() {
const i18n = useI18n();
const router = useRouter();
const telemetry = useTelemetry();
function getUIDetails(execution: ExecutionSummary): IExecutionUIData {
const status = {
@ -69,9 +75,57 @@ export function useExecutionHelpers() {
return ['crashed', 'error'].includes(execution.status) && !execution.retrySuccessId;
}
function openExecutionInNewTab(executionId: string, workflowId: string): void {
const route = router.resolve({
name: VIEWS.EXECUTION_PREVIEW,
params: { name: workflowId, executionId },
});
window.open(route.href, '_blank');
}
function resolveRelatedExecutionUrl(metadata: {
parentExecution?: RelatedExecution;
subExecution?: RelatedExecution;
}): string {
const info = metadata.parentExecution || metadata.subExecution;
if (!info) {
return '';
}
const { workflowId, executionId } = info;
return router.resolve({
name: VIEWS.EXECUTION_PREVIEW,
params: { name: workflowId, executionId },
}).fullPath;
}
function trackOpeningRelatedExecution(
metadata: { parentExecution?: RelatedExecution; subExecution?: RelatedExecution },
view: IRunDataDisplayMode,
) {
const info = metadata.parentExecution || metadata.subExecution;
if (!info) {
return;
}
telemetry.track(
metadata.parentExecution
? 'User clicked parent execution button'
: 'User clicked inspect sub-workflow',
{
view,
},
);
}
return {
getUIDetails,
formatDate,
isExecutionRetriable,
openExecutionInNewTab,
trackOpeningRelatedExecution,
resolveRelatedExecutionUrl,
};
}

View file

@ -566,6 +566,29 @@ export function useNodeHelpers() {
}
}
function getNodeTaskData(node: INodeUi | null, runIndex = 0) {
if (node === null) {
return null;
}
if (workflowsStore.getWorkflowExecution === null) {
return null;
}
const executionData = workflowsStore.getWorkflowExecution.data;
if (!executionData?.resultData) {
// unknown status
return null;
}
const runData = executionData.resultData.runData;
const taskData = get(runData, [node.name, runIndex]);
if (!taskData) {
return null;
}
return taskData;
}
function getNodeInputData(
node: INodeUi | null,
runIndex = 0,
@ -583,22 +606,8 @@ export function useNodeHelpers() {
runIndex = runIndex - 1;
}
if (node === null) {
return [];
}
if (workflowsStore.getWorkflowExecution === null) {
return [];
}
const executionData = workflowsStore.getWorkflowExecution.data;
if (!executionData?.resultData) {
// unknown status
return [];
}
const runData = executionData.resultData.runData;
const taskData = get(runData, [node.name, runIndex]);
if (!taskData) {
const taskData = getNodeTaskData(node, runIndex);
if (taskData === null) {
return [];
}
@ -1282,5 +1291,6 @@ export function useNodeHelpers() {
removeConnectionByConnectionInfo,
addPinDataConnections,
removePinDataConnections,
getNodeTaskData,
};
}

View file

@ -966,6 +966,7 @@
"ndv.output.branch": "Branch",
"ndv.output.executing": "Executing node...",
"ndv.output.items": "{count} item | {count} items",
"ndv.output.andSubExecutions": ", {count} sub-execution | , {count} sub-executions",
"ndv.output.noOutputData.message": "n8n stops executing the workflow when a node has no output data. You can change this default behaviour via",
"ndv.output.noOutputData.message.settings": "Settings",
"ndv.output.noOutputData.message.settingsOption": "> \"Always Output Data\".",
@ -1564,6 +1565,8 @@
"resourceMapper.addAllFields": "Add All {fieldWord}",
"resourceMapper.removeAllFields": "Remove All {fieldWord}",
"resourceMapper.refreshFieldList": "Refresh {fieldWord} List",
"runData.openSubExecution": "Inspect Sub-Execution {id}",
"runData.openParentExecution": "Inspect Parent Execution {id}",
"runData.emptyItemHint": "This is an item, but it's empty.",
"runData.emptyArray": "[empty array]",
"runData.emptyString": "[empty]",
@ -1611,6 +1614,7 @@
"runData.showBinaryData": "View",
"runData.startTime": "Start Time",
"runData.table": "Table",
"runData.table.inspectSubExecution": "Inspect sub-execution {id}",
"runData.pindata.learnMore": "Learn more",
"runData.pindata.thisDataIsPinned": "This data is pinned.",
"runData.pindata.unpin": "Unpin",
@ -2623,6 +2627,8 @@
"executionUsage.button.upgrade": "Upgrade plan",
"executionUsage.expired.text": "Your trial is over. Upgrade now to keep your data.",
"executionUsage.ranOutOfExecutions.text": "Youre out of executions. Upgrade your plan to keep automating.",
"openExecution.missingExeuctionId.title": "Could not find execution",
"openExecution.missingExeuctionId.message": "Make sure this workflow saves executions via the settings",
"type.string": "String",
"type.number": "Number",
"type.dateTime": "Date & Time",

View file

@ -25,6 +25,7 @@ const route = useRoute();
const router = useRouter();
const toast = useToast();
const { callDebounced } = useDebounce();
const workflowHelpers = useWorkflowHelpers({ router });
const nodeHelpers = useNodeHelpers();
@ -100,6 +101,16 @@ async function fetchExecution() {
} catch (error) {
toast.showError(error, i18n.baseText('nodeView.showError.openExecution.title'));
}
if (!currentExecution.value) {
toast.showMessage({
type: 'error',
title: i18n.baseText('openExecution.missingExeuctionId.title'),
message: i18n.baseText('openExecution.missingExeuctionId.message'),
});
return;
}
}
function onDocumentVisibilityChange() {

View file

@ -1,5 +1,6 @@
import { NodeConnectionType, NodeOperationError } from 'n8n-workflow';
import type {
ExecuteWorkflowData,
IExecuteFunctions,
INodeExecutionData,
INodeType,
@ -209,8 +210,11 @@ export class ExecuteWorkflow implements INodeType {
const mode = this.getNodeParameter('mode', 0, false) as string;
const items = this.getInputData();
const workflowProxy = this.getWorkflowDataProxy(0);
const currentWorkflowId = workflowProxy.$workflow.id as string;
if (mode === 'each') {
let returnData: INodeExecutionData[][] = [];
const returnData: INodeExecutionData[][] = [];
for (let i = 0; i < items.length; i++) {
try {
@ -222,14 +226,28 @@ export class ExecuteWorkflow implements INodeType {
const workflowInfo = await getWorkflowInfo.call(this, source, i);
if (waitForSubWorkflow) {
const workflowResult: INodeExecutionData[][] = await this.executeWorkflow(
const executionResult: ExecuteWorkflowData = await this.executeWorkflow(
workflowInfo,
[items[i]],
undefined,
{
parentExecution: {
executionId: workflowProxy.$execution.id,
workflowId: workflowProxy.$workflow.id,
},
},
);
const workflowResult = executionResult.data as INodeExecutionData[][];
for (const [outputIndex, outputData] of workflowResult.entries()) {
for (const item of outputData) {
item.pairedItem = { item: i };
item.metadata = {
subExecution: {
executionId: executionResult.executionId,
workflowId: workflowInfo.id ?? currentWorkflowId,
},
};
}
if (returnData[outputIndex] === undefined) {
@ -239,8 +257,32 @@ export class ExecuteWorkflow implements INodeType {
returnData[outputIndex].push(...outputData);
}
} else {
void this.executeWorkflow(workflowInfo, [items[i]]);
returnData = [items];
const executionResult: ExecuteWorkflowData = await this.executeWorkflow(
workflowInfo,
[items[i]],
undefined,
{
doNotWaitToFinish: true,
parentExecution: {
executionId: workflowProxy.$execution.id,
workflowId: workflowProxy.$workflow.id,
},
},
);
if (returnData.length === 0) {
returnData.push([]);
}
returnData[0].push({
...items[i],
metadata: {
subExecution: {
workflowId: workflowInfo.id ?? currentWorkflowId,
executionId: executionResult.executionId,
},
},
});
}
} catch (error) {
if (this.continueOnFail()) {
@ -258,6 +300,10 @@ export class ExecuteWorkflow implements INodeType {
}
}
this.setMetadata({
subExecutionsCount: items.length,
});
return returnData;
} else {
try {
@ -268,15 +314,32 @@ export class ExecuteWorkflow implements INodeType {
) as boolean;
const workflowInfo = await getWorkflowInfo.call(this, source);
const executionResult: ExecuteWorkflowData = await this.executeWorkflow(
workflowInfo,
items,
undefined,
{
doNotWaitToFinish: !waitForSubWorkflow,
parentExecution: {
executionId: workflowProxy.$execution.id,
workflowId: workflowProxy.$workflow.id,
},
},
);
this.setMetadata({
subExecution: {
executionId: executionResult.executionId,
workflowId: workflowInfo.id ?? (workflowProxy.$workflow.id as string),
},
subExecutionsCount: 1,
});
if (!waitForSubWorkflow) {
void this.executeWorkflow(workflowInfo, items);
return [items];
}
const workflowResult: INodeExecutionData[][] = await this.executeWorkflow(
workflowInfo,
items,
);
const workflowResult = executionResult.data as INodeExecutionData[][];
const fallbackPairedItemData = generatePairedItemData(items.length);

View file

@ -484,6 +484,7 @@ export interface ISourceDataConnections {
export interface IExecuteData {
data: ITaskDataConnections;
metadata?: ITaskMetadata;
node: INode;
source: ITaskDataConnectionsSource | null;
}
@ -936,6 +937,7 @@ export type ContextType = 'flow' | 'node';
type BaseExecutionFunctions = FunctionsBaseWithRequiredKeys<'getMode'> & {
continueOnFail(): boolean;
setMetadata(metadata: ITaskMetadata): void;
evaluateExpression(expression: string, itemIndex: number): NodeParameterValueType;
getContext(type: ContextType): IContextObject;
getExecuteData(): IExecuteData;
@ -953,7 +955,11 @@ export type IExecuteFunctions = ExecuteFunctions.GetNodeParameterFn &
workflowInfo: IExecuteWorkflowInfo,
inputData?: INodeExecutionData[],
parentCallbackManager?: CallbackManager,
): Promise<any>;
options?: {
doNotWaitToFinish?: boolean;
parentExecution?: RelatedExecution;
},
): Promise<ExecuteWorkflowData>;
getInputConnectionData(
inputName: NodeConnectionType,
itemIndex: number,
@ -976,6 +982,7 @@ export type IExecuteFunctions = ExecuteFunctions.GetNodeParameterFn &
connectionType: NodeConnectionType,
currentNodeRunIndex: number,
data: INodeExecutionData[][] | ExecutionError,
metadata?: ITaskMetadata,
): void;
nodeHelpers: NodeHelperFunctions;
@ -1208,6 +1215,9 @@ export interface INodeExecutionData {
binary?: IBinaryKeyData;
error?: NodeApiError | NodeOperationError;
pairedItem?: IPairedItemData | IPairedItemData[] | number;
metadata?: {
subExecution: RelatedExecution;
};
index?: number;
}
@ -1545,6 +1555,11 @@ export interface ITriggerResponse {
manualTriggerResponse?: Promise<INodeExecutionData[][]>;
}
export interface ExecuteWorkflowData {
executionId: string;
data: Array<INodeExecutionData[] | null>;
}
export type WebhookSetupMethodNames = 'checkExists' | 'create' | 'delete';
export namespace MultiPartFormData {
@ -2133,8 +2148,16 @@ export interface ITaskSubRunMetadata {
runIndex: number;
}
export interface RelatedExecution {
executionId: string;
workflowId: string;
}
export interface ITaskMetadata {
subRun?: ITaskSubRunMetadata[];
parentExecution?: RelatedExecution;
subExecution?: RelatedExecution;
subExecutionsCount?: number;
}
// The data that gets returned when a node runs
@ -2261,6 +2284,8 @@ export interface ExecuteWorkflowOptions {
loadedRunData?: IWorkflowExecutionDataProcess;
parentWorkflowSettings?: IWorkflowSettings;
parentCallbackManager?: CallbackManager;
doNotWaitToFinish?: boolean;
parentExecution?: RelatedExecution;
}
export type AiEvent =
@ -2294,7 +2319,7 @@ export interface IWorkflowExecuteAdditionalData {
workflowInfo: IExecuteWorkflowInfo,
additionalData: IWorkflowExecuteAdditionalData,
options: ExecuteWorkflowOptions,
) => Promise<any>;
) => Promise<ExecuteWorkflowData>;
executionId?: string;
restartExecutionId?: string;
hooks?: WorkflowHooks;