feat(core): Show sub-node error on the logs pane. Open logs pane on sub-node error (#10248)

This commit is contained in:
Eugene 2024-08-02 16:00:09 +02:00 committed by GitHub
parent 0faf46f4f8
commit 57d1c9a99e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 333 additions and 7 deletions

View file

@ -37,6 +37,7 @@ export const INSTANCE_MEMBERS = [
export const MANUAL_TRIGGER_NODE_NAME = 'Manual Trigger';
export const MANUAL_TRIGGER_NODE_DISPLAY_NAME = 'When clicking Test workflow';
export const MANUAL_CHAT_TRIGGER_NODE_NAME = 'Chat Trigger';
export const MANUAL_CHAT_TRIGGER_NODE_DISPLAY_NAME = 'When chat message received';
export const SCHEDULE_TRIGGER_NODE_NAME = 'Schedule Trigger';
export const CODE_NODE_NAME = 'Code';
export const SET_NODE_NAME = 'Set';
@ -57,6 +58,7 @@ export const AI_TOOL_CODE_NODE_NAME = 'Code Tool';
export const AI_TOOL_WIKIPEDIA_NODE_NAME = 'Wikipedia';
export const AI_TOOL_HTTP_NODE_NAME = 'HTTP Request Tool';
export const AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME = 'OpenAI Chat Model';
export const AI_MEMORY_POSTGRES_NODE_NAME = 'Postgres Chat Memory';
export const AI_OUTPUT_PARSER_AUTO_FIXING_NODE_NAME = 'Auto-fixing Output Parser';
export const WEBHOOK_NODE_NAME = 'Webhook';

View file

@ -0,0 +1,279 @@
import type { ExecutionError } from 'n8n-workflow/src';
import { NDV, WorkflowPage as WorkflowPageClass } from '../pages';
import {
addLanguageModelNodeToParent,
addMemoryNodeToParent,
addNodeToCanvas,
addToolNodeToParent,
navigateToNewWorkflowPage,
openNode,
} from '../composables/workflow';
import {
AGENT_NODE_NAME,
AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME,
AI_MEMORY_POSTGRES_NODE_NAME,
AI_TOOL_CALCULATOR_NODE_NAME,
MANUAL_CHAT_TRIGGER_NODE_DISPLAY_NAME,
MANUAL_CHAT_TRIGGER_NODE_NAME,
MANUAL_TRIGGER_NODE_DISPLAY_NAME,
MANUAL_TRIGGER_NODE_NAME,
} from '../constants';
import {
clickCreateNewCredential,
clickExecuteNode,
clickGetBackToCanvas,
} from '../composables/ndv';
import { setCredentialValues } from '../composables/modals/credential-modal';
import {
closeManualChatModal,
getManualChatMessages,
getManualChatModalLogs,
getManualChatModalLogsEntries,
sendManualChatMessage,
} from '../composables/modals/chat-modal';
import { createMockNodeExecutionData, getVisibleSelect, runMockWorkflowExecution } from '../utils';
const ndv = new NDV();
const WorkflowPage = new WorkflowPageClass();
function createRunDataWithError(inputMessage: string) {
return [
createMockNodeExecutionData(MANUAL_CHAT_TRIGGER_NODE_NAME, {
jsonData: {
main: { input: inputMessage },
},
}),
createMockNodeExecutionData(AI_MEMORY_POSTGRES_NODE_NAME, {
jsonData: {
ai_memory: {
json: {
action: 'loadMemoryVariables',
values: {
input: inputMessage,
system_message: 'You are a helpful assistant',
formatting_instructions:
'IMPORTANT: Always call `format_final_response` to format your final response!',
},
},
},
},
inputOverride: {
ai_memory: [
[
{
json: {
action: 'loadMemoryVariables',
values: {
input: inputMessage,
system_message: 'You are a helpful assistant',
formatting_instructions:
'IMPORTANT: Always call `format_final_response` to format your final response!',
},
},
},
],
],
},
error: {
message: 'Internal error',
timestamp: 1722591723244,
name: 'NodeOperationError',
description: 'Internal error',
context: {},
cause: {
name: 'error',
severity: 'FATAL',
code: '3D000',
file: 'postinit.c',
line: '885',
routine: 'InitPostgres',
} as unknown as Error,
} as ExecutionError,
}),
createMockNodeExecutionData(AGENT_NODE_NAME, {
executionStatus: 'error',
error: {
level: 'error',
tags: {
packageName: 'workflow',
},
context: {},
functionality: 'configuration-node',
name: 'NodeOperationError',
timestamp: 1722591723244,
node: {
parameters: {
notice: '',
sessionIdType: 'fromInput',
tableName: 'n8n_chat_histories',
},
id: '6b9141da-0135-4e9d-94d1-2d658cbf48b5',
name: 'Postgres Chat Memory',
type: '@n8n/n8n-nodes-langchain.memoryPostgresChat',
typeVersion: 1,
position: [1140, 500],
credentials: {
postgres: {
id: 'RkyZetVpGsSfEAhQ',
name: 'Postgres account',
},
},
},
messages: ['database "chat11" does not exist'],
description: 'Internal error',
message: 'Internal error',
} as unknown as ExecutionError,
metadata: {
subRun: [
{
node: 'Postgres Chat Memory',
runIndex: 0,
},
],
},
}),
];
}
function setupTestWorkflow(chatTrigger: boolean = false) {
// Setup test workflow with AI Agent, Postgres Memory Node (source of error), Calculator Tool, and OpenAI Chat Model
if (chatTrigger) {
addNodeToCanvas(MANUAL_CHAT_TRIGGER_NODE_NAME, true);
} else {
addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME, true);
}
addNodeToCanvas(AGENT_NODE_NAME, true);
if (!chatTrigger) {
// Remove chat trigger
WorkflowPage.getters
.canvasNodeByName(MANUAL_CHAT_TRIGGER_NODE_DISPLAY_NAME)
.find('[data-test-id="delete-node-button"]')
.click({ force: true });
// Set manual trigger to output standard pinned data
openNode(MANUAL_TRIGGER_NODE_DISPLAY_NAME);
ndv.actions.editPinnedData();
ndv.actions.savePinnedData();
ndv.actions.close();
}
// Calculator is added just to make OpenAI Chat Model work (tools can not be empty with OpenAI model)
addToolNodeToParent(AI_TOOL_CALCULATOR_NODE_NAME, AGENT_NODE_NAME);
clickGetBackToCanvas();
addMemoryNodeToParent(AI_MEMORY_POSTGRES_NODE_NAME, AGENT_NODE_NAME);
clickCreateNewCredential();
setCredentialValues({
password: 'testtesttest',
});
ndv.getters.parameterInput('sessionIdType').click();
getVisibleSelect().contains('Define below').click();
ndv.getters.parameterInput('sessionKey').type('asdasd');
clickGetBackToCanvas();
addLanguageModelNodeToParent(
AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME,
AGENT_NODE_NAME,
true,
);
clickCreateNewCredential();
setCredentialValues({
apiKey: 'sk_test_123',
});
clickGetBackToCanvas();
WorkflowPage.actions.zoomToFit();
}
function checkMessages(inputMessage: string, outputMessage: string) {
const messages = getManualChatMessages();
messages.should('have.length', 2);
messages.should('contain', inputMessage);
messages.should('contain', outputMessage);
getManualChatModalLogs().should('exist');
getManualChatModalLogsEntries()
.should('have.length', 1)
.should('contain', AI_MEMORY_POSTGRES_NODE_NAME);
}
describe("AI-233 Make root node's logs pane active in case of an error in sub-nodes", () => {
beforeEach(() => {
navigateToNewWorkflowPage();
});
it('should open logs tab by default when there was an error', () => {
setupTestWorkflow(true);
openNode(AGENT_NODE_NAME);
const inputMessage = 'Test the code tool';
clickExecuteNode();
runMockWorkflowExecution({
trigger: () => sendManualChatMessage(inputMessage),
runData: createRunDataWithError(inputMessage),
lastNodeExecuted: AGENT_NODE_NAME,
});
checkMessages(inputMessage, '[ERROR: Internal error]');
closeManualChatModal();
// Open the AI Agent node to see the logs
openNode(AGENT_NODE_NAME);
// Finally check that logs pane is opened by default
ndv.getters.outputDataContainer().should('be.visible');
ndv.getters.aiOutputModeToggle().should('be.visible');
ndv.getters
.aiOutputModeToggle()
.find('[role="radio"]')
.should('have.length', 2)
.eq(1)
.should('have.attr', 'aria-checked', 'true');
ndv.getters
.outputPanel()
.findChildByTestId('node-error-message')
.should('be.visible')
.should('contain', 'Error in sub-node');
});
it('should switch to logs tab on error, when NDV is already opened', () => {
setupTestWorkflow(false);
openNode(AGENT_NODE_NAME);
const inputMessage = 'Test the code tool';
runMockWorkflowExecution({
trigger: () => clickExecuteNode(),
runData: createRunDataWithError(inputMessage),
lastNodeExecuted: AGENT_NODE_NAME,
});
// Check that logs pane is opened by default
ndv.getters.outputDataContainer().should('be.visible');
ndv.getters.aiOutputModeToggle().should('be.visible');
ndv.getters
.aiOutputModeToggle()
.find('[role="radio"]')
.should('have.length', 2)
.eq(1)
.should('have.attr', 'aria-checked', 'true');
ndv.getters
.outputPanel()
.findChildByTestId('node-error-message')
.should('be.visible')
.should('contain', 'Error in sub-node');
});
});

View file

@ -24,6 +24,7 @@ export class NDV extends BasePage {
editPinnedDataButton: () => cy.getByTestId('ndv-edit-pinned-data'),
pinnedDataEditor: () => this.getters.outputPanel().find('.cm-editor .cm-scroller .cm-content'),
runDataPaneHeader: () => cy.getByTestId('run-data-pane-header'),
aiOutputModeToggle: () => cy.getByTestId('ai-output-mode-select'),
nodeOutputHint: () => cy.getByTestId('ndv-output-run-node-hint'),
savePinnedDataButton: () =>
this.getters.runDataPaneHeader().find('button').filter(':visible').contains('Save'),

View file

@ -6,6 +6,7 @@ import type {
SupplyData,
ExecutionError,
} from 'n8n-workflow';
import { NodeConnectionType, NodeOperationError } from 'n8n-workflow';
import type { Sandbox } from 'n8n-nodes-base/dist/nodes/Code/Sandbox';
import { getSandboxContext } from 'n8n-nodes-base/dist/nodes/Code/Sandbox';
@ -208,7 +209,7 @@ export class ToolCode implements INodeType {
try {
response = await runFunction(query);
} catch (error: unknown) {
executionError = error as ExecutionError;
executionError = new NodeOperationError(this.getNode(), error as ExecutionError);
response = `There was an error: "${executionError.message}"`;
}
@ -229,6 +230,7 @@ export class ToolCode implements INodeType {
} else {
void this.addOutputData(NodeConnectionType.AiTool, index, [[{ json: { response } }]]);
}
return response;
},
}),

View file

@ -21,6 +21,7 @@ import type { BaseTextKey } from '@/plugins/i18n';
type Props = {
error: NodeError | NodeApiError | NodeOperationError;
compact?: boolean;
};
const props = defineProps<Props>();
@ -377,7 +378,7 @@ function copySuccess() {
></div>
</div>
<div class="node-error-view__info">
<div v-if="!compact" class="node-error-view__info">
<div class="node-error-view__info-header">
<p class="node-error-view__info-title">
{{ i18n.baseText('nodeErrorView.details.title') }}

View file

@ -28,6 +28,7 @@
<div :class="$style.titleSection">
<template v-if="hasAiMetadata">
<n8n-radio-buttons
data-test-id="ai-output-mode-select"
v-model="outputMode"
:options="outputTypes"
@update:model-value="onUpdateOutputMode"
@ -83,6 +84,7 @@
<template v-if="outputMode === 'logs' && node" #content>
<RunDataAi :node="node" :run-index="runIndex" />
</template>
<template #recovered-artificial-output-data>
<div :class="$style.recoveredOutputData">
<n8n-text tag="div" :bold="true" color="text-dark" size="large">{{
@ -101,8 +103,8 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import type { IRunData, IRunExecutionData, Workflow } from 'n8n-workflow';
import { ref, computed, onMounted, watch } from 'vue';
import type { IRunData, IRunExecutionData, NodeError, Workflow } from 'n8n-workflow';
import RunData from './RunData.vue';
import RunInfo from './RunInfo.vue';
import { storeToRefs } from 'pinia';
@ -183,7 +185,7 @@ const pinnedData = usePinnedData(activeNode, {
// Data
const outputMode = ref<OutputType>('regular');
const outputMode = ref<OutputType>(OUTPUT_TYPE.REGULAR);
const outputTypes = ref([
{ label: i18n.baseText('ndv.output.outType.regular'), value: OUTPUT_TYPE.REGULAR },
{ label: i18n.baseText('ndv.output.outType.logs'), value: OUTPUT_TYPE.LOGS },
@ -213,6 +215,16 @@ const hasAiMetadata = computed(() => {
return false;
});
// Determine the initial output mode to logs if the node has an error and the logs are available
const defaultOutputMode = computed<OutputType>(() => {
const hasError =
workflowRunData.value &&
node.value &&
(workflowRunData.value[node.value.name]?.[props.runIndex]?.error as NodeError);
return Boolean(hasError) && hasAiMetadata.value ? OUTPUT_TYPE.LOGS : OUTPUT_TYPE.REGULAR;
});
const isNodeRunning = computed(() => {
return !!node.value && workflowsStore.isNodeExecuting(node.value.name);
});
@ -351,6 +363,20 @@ const onUpdateOutputMode = (outputMode: OutputType) => {
}
};
// Set the initial output mode when the component is mounted
onMounted(() => {
outputMode.value = defaultOutputMode.value;
});
// In case the output panel was opened when the node has not run yet,
// defaultOutputMode will be "regular" at the time of mounting.
// This is why we need to watch the defaultOutputMode and change the outputMode to "logs" if the node has run and criteria are met.
watch(defaultOutputMode, (newValue: OutputType, oldValue: OutputType) => {
if (newValue === OUTPUT_TYPE.LOGS && oldValue === OUTPUT_TYPE.REGULAR && hasNodeRun.value) {
outputMode.value = defaultOutputMode.value;
}
});
const activatePane = () => {
emit('activatePane');
};

View file

@ -281,7 +281,15 @@
})
}}
</n8n-text>
<slot v-else-if="$slots['content']" name="content"></slot>
<div v-else-if="$slots['content']">
<NodeErrorView
v-if="workflowRunErrorAsNodeError"
:error="workflowRunErrorAsNodeError"
:class="$style.inlineError"
compact
/>
<slot name="content"></slot>
</div>
<NodeErrorView
v-else-if="workflowRunErrorAsNodeError"
:error="workflowRunErrorAsNodeError"
@ -1147,7 +1155,7 @@ export default defineComponent({
const error = this.workflowRunData?.[this.node.name]?.[this.runIndex]?.error;
const errorsToTrack = ['unknown error'];
if (error && errorsToTrack.some((e) => error.message.toLowerCase().includes(e))) {
if (error && errorsToTrack.some((e) => error.message?.toLowerCase().includes(e))) {
this.$telemetry.track(
`User encountered an error: "${error.message}"`,
{
@ -1776,6 +1784,13 @@ export default defineComponent({
height: 100%;
}
.inlineError {
line-height: var(--font-line-height-xloose);
padding-left: var(--spacing-s);
padding-right: var(--spacing-s);
padding-bottom: var(--spacing-s);
}
.outputs {
display: flex;
flex-direction: column;