fix(editor): Logs not shown when tools are partially executed (#16274)

This commit is contained in:
Suguru Inoue
2025-06-13 10:09:52 +02:00
committed by GitHub
parent 84c51b1bd9
commit b2eb33351f
12 changed files with 290 additions and 127 deletions

View File

@@ -0,0 +1 @@
export const TOOL_EXECUTOR_NODE_NAME = 'PartialExecutionToolExecutor';

View File

@@ -2,6 +2,7 @@ export * from './api';
export * from './browser'; export * from './browser';
export * from './community-nodes'; export * from './community-nodes';
export * from './instance'; export * from './instance';
export * from './execution';
export const LICENSE_FEATURES = { export const LICENSE_FEATURES = {
SHARING: 'feat:sharing', SHARING: 'feat:sharing',

View File

@@ -1,10 +1,9 @@
import { TOOL_EXECUTOR_NODE_NAME } from '@n8n/constants';
import * as a from 'assert/strict'; import * as a from 'assert/strict';
import { type AiAgentRequest, type INode, NodeConnectionTypes } from 'n8n-workflow'; import { type AiAgentRequest, type INode, NodeConnectionTypes } from 'n8n-workflow';
import { type DirectedGraph } from './directed-graph'; import { type DirectedGraph } from './directed-graph';
export const TOOL_EXECUTOR_NODE_NAME = 'PartialExecutionToolExecutor';
export function rewireGraph( export function rewireGraph(
tool: INode, tool: INode,
graph: DirectedGraph, graph: DirectedGraph,

View File

@@ -2,6 +2,7 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
import { TOOL_EXECUTOR_NODE_NAME } from '@n8n/constants';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import * as assert from 'assert/strict'; import * as assert from 'assert/strict';
import { setMaxListeners } from 'events'; import { setMaxListeners } from 'events';
@@ -74,7 +75,6 @@ import {
rewireGraph, rewireGraph,
getNextExecutionIndex, getNextExecutionIndex,
} from './partial-execution-utils'; } from './partial-execution-utils';
import { TOOL_EXECUTOR_NODE_NAME } from './partial-execution-utils/rewire-graph';
import { RoutingNode } from './routing-node'; import { RoutingNode } from './routing-node';
import { TriggersAndPollers } from './triggers-and-pollers'; import { TriggersAndPollers } from './triggers-and-pollers';

View File

@@ -12,11 +12,12 @@ import NodeIcon from '@/components/NodeIcon.vue';
import { useI18n } from '@n8n/i18n'; import { useI18n } from '@n8n/i18n';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import LogsViewNodeName from '@/features/logs/components/LogsViewNodeName.vue'; import LogsViewNodeName from '@/features/logs/components/LogsViewNodeName.vue';
import { N8nButton, N8nResizeWrapper } from '@n8n/design-system'; import { N8nButton, N8nResizeWrapper, N8nText } from '@n8n/design-system';
import { computed, useTemplateRef } from 'vue'; import { computed, useTemplateRef } from 'vue';
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue'; import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
import { getSubtreeTotalConsumedTokens } from '@/features/logs/logs.utils'; import { getSubtreeTotalConsumedTokens } from '@/features/logs/logs.utils';
import { LOG_DETAILS_PANEL_STATE } from '@/features/logs/logs.constants'; import { LOG_DETAILS_PANEL_STATE } from '@/features/logs/logs.constants';
import { isPlaceholderLog } from '@/features/logs/logs.utils';
const MIN_IO_PANEL_WIDTH = 200; const MIN_IO_PANEL_WIDTH = 200;
@@ -82,7 +83,7 @@ function handleResizeEnd() {
:is-deleted="latestInfo?.deleted ?? false" :is-deleted="latestInfo?.deleted ?? false"
/> />
<LogsViewExecutionSummary <LogsViewExecutionSummary
v-if="isOpen" v-if="isOpen && logEntry.runData !== undefined"
:class="$style.executionSummary" :class="$style.executionSummary"
:status="logEntry.runData.executionStatus ?? 'unknown'" :status="logEntry.runData.executionStatus ?? 'unknown'"
:consumed-tokens="consumedTokens" :consumed-tokens="consumedTokens"
@@ -92,7 +93,7 @@ function handleResizeEnd() {
</div> </div>
</template> </template>
<template #actions> <template #actions>
<div v-if="isOpen && !isTriggerNode" :class="$style.actions"> <div v-if="isOpen && !isTriggerNode && !isPlaceholderLog(logEntry)" :class="$style.actions">
<KeyboardShortcutTooltip <KeyboardShortcutTooltip
:label="locale.baseText('generic.shortcutHint')" :label="locale.baseText('generic.shortcutHint')"
:shortcut="{ keys: ['i'] }" :shortcut="{ keys: ['i'] }"
@@ -124,36 +125,43 @@ function handleResizeEnd() {
</template> </template>
</LogsPanelHeader> </LogsPanelHeader>
<div v-if="isOpen" :class="$style.content" data-test-id="logs-details-body"> <div v-if="isOpen" :class="$style.content" data-test-id="logs-details-body">
<N8nResizeWrapper <div v-if="isPlaceholderLog(logEntry)" :class="$style.placeholder">
v-if="!isTriggerNode && panels !== LOG_DETAILS_PANEL_STATE.OUTPUT" <N8nText color="text-base">
:class="{ {{ locale.baseText('ndv.output.runNodeHint') }}
[$style.inputResizer]: true, </N8nText>
[$style.collapsed]: resizer.isCollapsed.value, </div>
[$style.full]: resizer.isFullSize.value, <template v-else>
}" <N8nResizeWrapper
:width="resizer.size.value" v-if="!isTriggerNode && panels !== LOG_DETAILS_PANEL_STATE.OUTPUT"
:style="shouldResize ? { width: `${resizer.size.value ?? 0}px` } : undefined" :class="{
:supported-directions="['right']" [$style.inputResizer]: true,
:is-resizing-enabled="shouldResize" [$style.collapsed]: resizer.isCollapsed.value,
:window="window" [$style.full]: resizer.isFullSize.value,
@resize="resizer.onResize" }"
@resizeend="handleResizeEnd" :width="resizer.size.value"
> :style="shouldResize ? { width: `${resizer.size.value ?? 0}px` } : undefined"
:supported-directions="['right']"
:is-resizing-enabled="shouldResize"
:window="window"
@resize="resizer.onResize"
@resizeend="handleResizeEnd"
>
<LogsViewRunData
data-test-id="log-details-input"
pane-type="input"
:title="locale.baseText('logs.details.header.actions.input')"
:log-entry="logEntry"
/>
</N8nResizeWrapper>
<LogsViewRunData <LogsViewRunData
data-test-id="log-details-input" v-if="isTriggerNode || panels !== LOG_DETAILS_PANEL_STATE.INPUT"
pane-type="input" data-test-id="log-details-output"
:title="locale.baseText('logs.details.header.actions.input')" pane-type="output"
:class="$style.outputPanel"
:title="locale.baseText('logs.details.header.actions.output')"
:log-entry="logEntry" :log-entry="logEntry"
/> />
</N8nResizeWrapper> </template>
<LogsViewRunData
v-if="isTriggerNode || panels !== LOG_DETAILS_PANEL_STATE.INPUT"
data-test-id="log-details-output"
pane-type="output"
:class="$style.outputPanel"
:title="locale.baseText('logs.details.header.actions.output')"
:log-entry="logEntry"
/>
</div> </div>
</div> </div>
</template> </template>
@@ -218,4 +226,11 @@ function handleResizeEnd() {
border-right: var(--border-base); border-right: var(--border-base);
} }
} }
.placeholder {
flex-grow: 1;
display: flex;
align-items: center;
justify-content: center;
}
</style> </style>

View File

@@ -37,11 +37,15 @@ const nodeTypeStore = useNodeTypesStore();
const type = computed(() => nodeTypeStore.getNodeType(props.data.node.type)); const type = computed(() => nodeTypeStore.getNodeType(props.data.node.type));
const isSettled = computed( const isSettled = computed(
() => () =>
props.data.runData.executionStatus && props.data.runData?.executionStatus &&
!['running', 'waiting'].includes(props.data.runData.executionStatus), !['running', 'waiting'].includes(props.data.runData.executionStatus),
); );
const isError = computed(() => !!props.data.runData.error); const isError = computed(() => !!props.data.runData?.error);
const startedAtText = computed(() => { const startedAtText = computed(() => {
if (props.data.runData === undefined) {
return '—';
}
const time = new Date(props.data.runData.startTime); const time = new Date(props.data.runData.startTime);
return locale.baseText('logs.overview.body.started', { return locale.baseText('logs.overview.body.started', {
@@ -50,14 +54,16 @@ const startedAtText = computed(() => {
}, },
}); });
}); });
const statusText = computed(() => upperFirst(props.data.runData.executionStatus)); const statusText = computed(() => upperFirst(props.data.runData?.executionStatus ?? ''));
const timeText = computed(() => const timeText = computed(() =>
locale.displayTimer( props.data.runData
isSettled.value ? locale.displayTimer(
? props.data.runData.executionTime isSettled.value
: Math.floor((now.value - props.data.runData.startTime) / 1000) * 1000, ? props.data.runData.executionTime
true, : Math.floor((now.value - props.data.runData.startTime) / 1000) * 1000,
), true,
)
: undefined,
); );
const subtreeConsumedTokens = computed(() => const subtreeConsumedTokens = computed(() =>
@@ -65,7 +71,7 @@ const subtreeConsumedTokens = computed(() =>
); );
const hasChildren = computed( const hasChildren = computed(
() => props.data.children.length > 0 || !!props.data.runData.metadata?.subExecution, () => props.data.children.length > 0 || !!props.data.runData?.metadata?.subExecution,
); );
function isLastChild(level: number) { function isLastChild(level: number) {
@@ -144,13 +150,14 @@ watch(
</template> </template>
<template #time>{{ timeText }}</template> <template #time>{{ timeText }}</template>
</I18nT> </I18nT>
<template v-else> <template v-else-if="timeText !== undefined">
{{ {{
locale.baseText('logs.overview.body.summaryText.for', { locale.baseText('logs.overview.body.summaryText.for', {
interpolate: { status: statusText, time: timeText }, interpolate: { status: statusText, time: timeText },
}) })
}} }}
</template> </template>
<template v-else>—</template>
</N8nText> </N8nText>
<N8nText <N8nText
v-if="!isCompact" v-if="!isCompact"

View File

@@ -90,7 +90,7 @@ async function handleOpenNdv(treeNode: LogEntry) {
ndvStore.setActiveNodeName(treeNode.node.name); ndvStore.setActiveNodeName(treeNode.node.name);
await nextTick(() => { await nextTick(() => {
const source = treeNode.runData.source[0]; const source = treeNode.runData?.source[0];
const inputBranch = source?.previousNodeOutput ?? 0; const inputBranch = source?.previousNodeOutput ?? 0;
ndvEventBus.emit('updateInputNodeName', source?.previousNode); ndvEventBus.emit('updateInputNodeName', source?.previousNode);

View File

@@ -19,7 +19,9 @@ const locale = useI18n();
const ndvStore = useNDVStore(); const ndvStore = useNDVStore();
const displayMode = ref<IRunDataDisplayMode>(paneType === 'input' ? 'schema' : 'table'); const displayMode = ref<IRunDataDisplayMode>(paneType === 'input' ? 'schema' : 'table');
const isMultipleInput = computed(() => paneType === 'input' && logEntry.runData.source.length > 1); const isMultipleInput = computed(
() => paneType === 'input' && (logEntry.runData?.source.length ?? 0) > 1,
);
const runDataProps = computed< const runDataProps = computed<
Pick<InstanceType<typeof RunData>['$props'], 'node' | 'runIndex' | 'overrideOutputs'> | undefined Pick<InstanceType<typeof RunData>['$props'], 'node' | 'runIndex' | 'overrideOutputs'> | undefined
>(() => { >(() => {
@@ -27,7 +29,7 @@ const runDataProps = computed<
return { node: logEntry.node, runIndex: logEntry.runIndex }; return { node: logEntry.node, runIndex: logEntry.runIndex };
} }
const source = logEntry.runData.source[0]; const source = logEntry.runData?.source[0];
const node = source && logEntry.workflow.getNode(source.previousNode); const node = source && logEntry.workflow.getNode(source.previousNode);
if (!source || !node) { if (!source || !node) {
@@ -46,8 +48,8 @@ const runDataProps = computed<
const isExecuting = computed( const isExecuting = computed(
() => () =>
paneType === 'output' && paneType === 'output' &&
(logEntry.runData.executionStatus === 'running' || (logEntry.runData?.executionStatus === 'running' ||
logEntry.runData.executionStatus === 'waiting'), logEntry.runData?.executionStatus === 'waiting'),
); );
function handleClickOpenNdv() { function handleClickOpenNdv() {

View File

@@ -68,8 +68,8 @@ export function useLogsExecutionData() {
} }
async function loadSubExecution(logEntry: LogEntry) { async function loadSubExecution(logEntry: LogEntry) {
const executionId = logEntry.runData.metadata?.subExecution?.executionId; const executionId = logEntry.runData?.metadata?.subExecution?.executionId;
const workflowId = logEntry.runData.metadata?.subExecution?.workflowId; const workflowId = logEntry.runData?.metadata?.subExecution?.workflowId;
if (!execData.value?.data || !executionId || !workflowId) { if (!execData.value?.data || !executionId || !workflowId) {
return; return;

View File

@@ -9,7 +9,7 @@ export interface LogEntry {
children: LogEntry[]; children: LogEntry[];
depth: number; depth: number;
runIndex: number; runIndex: number;
runData: ITaskData; runData: ITaskData | undefined;
consumedTokens: LlmTokenUsageData; consumedTokens: LlmTokenUsageData;
workflow: Workflow; workflow: Workflow;
executionId: string; executionId: string;

View File

@@ -20,7 +20,12 @@ import {
type ExecutionError, type ExecutionError,
type ITaskStartedData, type ITaskStartedData,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { createTestLogTreeCreationContext } from './__test__/data'; import {
aiAgentNode,
aiChatWorkflow,
aiModelNode,
createTestLogTreeCreationContext,
} from './__test__/data';
import type { LogEntrySelection } from './logs.types'; import type { LogEntrySelection } from './logs.types';
import type { IExecutionResponse } from '@/Interface'; import type { IExecutionResponse } from '@/Interface';
import { isReactive, reactive } from 'vue'; import { isReactive, reactive } from 'vue';
@@ -29,10 +34,11 @@ import { AGENT_NODE_TYPE, CHAT_TRIGGER_NODE_TYPE } from '@/constants';
describe(getTreeNodeData, () => { describe(getTreeNodeData, () => {
it('should generate one node per execution', () => { it('should generate one node per execution', () => {
const nodeA = createTestNode({ name: 'A', id: 'test-node-id-a' });
const workflow = createTestWorkflowObject({ const workflow = createTestWorkflowObject({
id: 'test-wf-id', id: 'test-wf-id',
nodes: [ nodes: [
createTestNode({ name: 'A', id: 'test-node-id-a' }), nodeA,
createTestNode({ name: 'B', id: 'test-node-id-b' }), createTestNode({ name: 'B', id: 'test-node-id-b' }),
createTestNode({ name: 'C', id: 'test-node-id-c' }), createTestNode({ name: 'C', id: 'test-node-id-c' }),
], ],
@@ -68,7 +74,7 @@ describe(getTreeNodeData, () => {
createTestTaskData({ startTime: 1740528000004 }), createTestTaskData({ startTime: 1740528000004 }),
], ],
}); });
const logTree = getTreeNodeData('A', ctx.data.resultData.runData.A[0], undefined, ctx); const logTree = getTreeNodeData(nodeA, ctx.data.resultData.runData.A[0], undefined, ctx);
expect(logTree.length).toBe(1); expect(logTree.length).toBe(1);
@@ -76,14 +82,14 @@ describe(getTreeNodeData, () => {
expect(logTree[0].depth).toBe(0); expect(logTree[0].depth).toBe(0);
expect(logTree[0].runIndex).toBe(0); expect(logTree[0].runIndex).toBe(0);
expect(logTree[0].parent).toBe(undefined); expect(logTree[0].parent).toBe(undefined);
expect(logTree[0].runData.startTime).toBe(1740528000000); expect(logTree[0].runData?.startTime).toBe(1740528000000);
expect(logTree[0].children.length).toBe(2); expect(logTree[0].children.length).toBe(2);
expect(logTree[0].children[0].id).toBe('test-wf-id:B:test-execution-id:0'); expect(logTree[0].children[0].id).toBe('test-wf-id:B:test-execution-id:0');
expect(logTree[0].children[0].depth).toBe(1); expect(logTree[0].children[0].depth).toBe(1);
expect(logTree[0].children[0].runIndex).toBe(0); expect(logTree[0].children[0].runIndex).toBe(0);
expect(logTree[0].children[0].parent?.node.name).toBe('A'); expect(logTree[0].children[0].parent?.node.name).toBe('A');
expect(logTree[0].children[0].runData.startTime).toBe(1740528000001); expect(logTree[0].children[0].runData?.startTime).toBe(1740528000001);
expect(logTree[0].children[0].consumedTokens.isEstimate).toBe(false); expect(logTree[0].children[0].consumedTokens.isEstimate).toBe(false);
expect(logTree[0].children[0].consumedTokens.completionTokens).toBe(1); expect(logTree[0].children[0].consumedTokens.completionTokens).toBe(1);
expect(logTree[0].children[0].children.length).toBe(1); expect(logTree[0].children[0].children.length).toBe(1);
@@ -111,12 +117,10 @@ describe(getTreeNodeData, () => {
}); });
it('should filter node executions based on source node', () => { it('should filter node executions based on source node', () => {
const rootNode1 = createTestNode({ name: 'RootNode1' });
const rootNode2 = createTestNode({ name: 'RootNode2' });
const workflow = createTestWorkflowObject({ const workflow = createTestWorkflowObject({
nodes: [ nodes: [rootNode1, rootNode2, createTestNode({ name: 'SharedSubNode' })],
createTestNode({ name: 'RootNode1' }),
createTestNode({ name: 'RootNode2' }),
createTestNode({ name: 'SharedSubNode' }),
],
connections: { connections: {
SharedSubNode: { SharedSubNode: {
ai_tool: [ ai_tool: [
@@ -159,7 +163,7 @@ describe(getTreeNodeData, () => {
// Test for RootNode1 - should only show SharedSubNode with source RootNode1 // Test for RootNode1 - should only show SharedSubNode with source RootNode1
const rootNode1Tree = getTreeNodeData( const rootNode1Tree = getTreeNodeData(
'RootNode1', rootNode1,
runData.RootNode1[0], runData.RootNode1[0],
undefined, undefined,
createTestLogTreeCreationContext(workflow, runData), createTestLogTreeCreationContext(workflow, runData),
@@ -170,7 +174,7 @@ describe(getTreeNodeData, () => {
// Test for RootNode2 - should only show SharedSubNode with source RootNode2 // Test for RootNode2 - should only show SharedSubNode with source RootNode2
const rootNode2Tree = getTreeNodeData( const rootNode2Tree = getTreeNodeData(
'RootNode2', rootNode2,
runData.RootNode2[0], runData.RootNode2[0],
undefined, undefined,
createTestLogTreeCreationContext(workflow, runData), createTestLogTreeCreationContext(workflow, runData),
@@ -181,8 +185,9 @@ describe(getTreeNodeData, () => {
}); });
it('should filter node executions based on source run index', () => { it('should filter node executions based on source run index', () => {
const rootNode = createTestNode({ name: 'RootNode' });
const workflow = createTestWorkflowObject({ const workflow = createTestWorkflowObject({
nodes: [createTestNode({ name: 'RootNode' }), createTestNode({ name: 'SubNode' })], nodes: [rootNode, createTestNode({ name: 'SubNode' })],
connections: { connections: {
SubNode: { SubNode: {
ai_tool: [[{ node: 'RootNode', type: NodeConnectionTypes.AiTool, index: 0 }]], ai_tool: [[{ node: 'RootNode', type: NodeConnectionTypes.AiTool, index: 0 }]],
@@ -216,7 +221,7 @@ describe(getTreeNodeData, () => {
// Test for run #1 of RootNode - should only show SubNode with source run index 0 // Test for run #1 of RootNode - should only show SubNode with source run index 0
const rootNode1Tree = getTreeNodeData( const rootNode1Tree = getTreeNodeData(
'RootNode', rootNode,
runData.RootNode[0], runData.RootNode[0],
0, 0,
createTestLogTreeCreationContext(workflow, runData), createTestLogTreeCreationContext(workflow, runData),
@@ -227,7 +232,7 @@ describe(getTreeNodeData, () => {
// Test for run #2 of RootNode - should only show SubNode with source run index 1 // Test for run #2 of RootNode - should only show SubNode with source run index 1
const rootNode2Tree = getTreeNodeData( const rootNode2Tree = getTreeNodeData(
'RootNode', rootNode,
runData.RootNode[1], runData.RootNode[1],
1, 1,
createTestLogTreeCreationContext(workflow, runData), createTestLogTreeCreationContext(workflow, runData),
@@ -238,8 +243,9 @@ describe(getTreeNodeData, () => {
}); });
it('should include nodes without source information', () => { it('should include nodes without source information', () => {
const rootNode = createTestNode({ name: 'RootNode' });
const workflow = createTestWorkflowObject({ const workflow = createTestWorkflowObject({
nodes: [createTestNode({ name: 'RootNode' }), createTestNode({ name: 'SubNode' })], nodes: [rootNode, createTestNode({ name: 'SubNode' })],
connections: { connections: {
SubNode: { SubNode: {
ai_tool: [[{ node: 'RootNode', type: NodeConnectionTypes.AiTool, index: 0 }]], ai_tool: [[{ node: 'RootNode', type: NodeConnectionTypes.AiTool, index: 0 }]],
@@ -267,7 +273,7 @@ describe(getTreeNodeData, () => {
// Test for RootNode - should still show SubNode even without source info // Test for RootNode - should still show SubNode even without source info
const rootNodeTree = getTreeNodeData( const rootNodeTree = getTreeNodeData(
'RootNode', rootNode,
runData.RootNode[0], runData.RootNode[0],
undefined, undefined,
createTestLogTreeCreationContext(workflow, runData), createTestLogTreeCreationContext(workflow, runData),
@@ -278,8 +284,9 @@ describe(getTreeNodeData, () => {
}); });
it('should include nodes with empty source array', () => { it('should include nodes with empty source array', () => {
const rootNode = createTestNode({ name: 'RootNode' });
const workflow = createTestWorkflowObject({ const workflow = createTestWorkflowObject({
nodes: [createTestNode({ name: 'RootNode' }), createTestNode({ name: 'SubNode' })], nodes: [rootNode, createTestNode({ name: 'SubNode' })],
connections: { connections: {
SubNode: { SubNode: {
ai_tool: [[{ node: 'RootNode', type: NodeConnectionTypes.AiTool, index: 0 }]], ai_tool: [[{ node: 'RootNode', type: NodeConnectionTypes.AiTool, index: 0 }]],
@@ -307,7 +314,7 @@ describe(getTreeNodeData, () => {
// Test for RootNode - should still show SubNode even with empty source array // Test for RootNode - should still show SubNode even with empty source array
const rootNodeTree = getTreeNodeData( const rootNodeTree = getTreeNodeData(
'RootNode', rootNode,
runData.RootNode[0], runData.RootNode[0],
undefined, undefined,
createTestLogTreeCreationContext(workflow, runData), createTestLogTreeCreationContext(workflow, runData),
@@ -318,8 +325,9 @@ describe(getTreeNodeData, () => {
}); });
it('should include nodes with source array without previous node', () => { it('should include nodes with source array without previous node', () => {
const rootNode = createTestNode({ name: 'RootNode' });
const workflow = createTestWorkflowObject({ const workflow = createTestWorkflowObject({
nodes: [createTestNode({ name: 'RootNode' }), createTestNode({ name: 'SubNode' })], nodes: [rootNode, createTestNode({ name: 'SubNode' })],
connections: { connections: {
SubNode: { SubNode: {
ai_tool: [[{ node: 'RootNode', type: NodeConnectionTypes.AiTool, index: 0 }]], ai_tool: [[{ node: 'RootNode', type: NodeConnectionTypes.AiTool, index: 0 }]],
@@ -334,7 +342,7 @@ describe(getTreeNodeData, () => {
}; };
const rootNodeTree = getTreeNodeData( const rootNodeTree = getTreeNodeData(
'RootNode', rootNode,
runData.RootNode[0], runData.RootNode[0],
undefined, undefined,
createTestLogTreeCreationContext(workflow, runData), createTestLogTreeCreationContext(workflow, runData),
@@ -345,10 +353,12 @@ describe(getTreeNodeData, () => {
}); });
it('should filter deeper nested nodes based on source', () => { it('should filter deeper nested nodes based on source', () => {
const rootNode1 = createTestNode({ name: 'RootNode1' });
const rootNode2 = createTestNode({ name: 'RootNode2' });
const workflow = createTestWorkflowObject({ const workflow = createTestWorkflowObject({
nodes: [ nodes: [
createTestNode({ name: 'RootNode1' }), rootNode1,
createTestNode({ name: 'RootNode2' }), rootNode2,
createTestNode({ name: 'SharedSubNode' }), createTestNode({ name: 'SharedSubNode' }),
createTestNode({ name: 'DeepSubNode' }), createTestNode({ name: 'DeepSubNode' }),
], ],
@@ -411,7 +421,7 @@ describe(getTreeNodeData, () => {
// Test filtering for RootNode1 // Test filtering for RootNode1
const rootNode1Tree = getTreeNodeData( const rootNode1Tree = getTreeNodeData(
'RootNode1', rootNode1,
runData.RootNode1[0], runData.RootNode1[0],
undefined, undefined,
createTestLogTreeCreationContext(workflow, runData), createTestLogTreeCreationContext(workflow, runData),
@@ -425,7 +435,7 @@ describe(getTreeNodeData, () => {
// Test filtering for RootNode2 // Test filtering for RootNode2
const rootNode2Tree = getTreeNodeData( const rootNode2Tree = getTreeNodeData(
'RootNode2', rootNode2,
runData.RootNode2[0], runData.RootNode2[0],
undefined, undefined,
createTestLogTreeCreationContext(workflow, runData), createTestLogTreeCreationContext(workflow, runData),
@@ -439,10 +449,12 @@ describe(getTreeNodeData, () => {
}); });
it('should handle complex tree with multiple branches and filters correctly', () => { it('should handle complex tree with multiple branches and filters correctly', () => {
const rootNode1 = createTestNode({ name: 'RootNode1' });
const rootNode2 = createTestNode({ name: 'RootNode2' });
const workflow = createTestWorkflowObject({ const workflow = createTestWorkflowObject({
nodes: [ nodes: [
createTestNode({ name: 'RootNode1' }), rootNode1,
createTestNode({ name: 'RootNode2' }), rootNode2,
createTestNode({ name: 'SubNodeA' }), createTestNode({ name: 'SubNodeA' }),
createTestNode({ name: 'SubNodeB' }), createTestNode({ name: 'SubNodeB' }),
createTestNode({ name: 'DeepNode' }), createTestNode({ name: 'DeepNode' }),
@@ -511,7 +523,7 @@ describe(getTreeNodeData, () => {
// Test filtering for RootNode1 -> SubNodeA -> DeepNode // Test filtering for RootNode1 -> SubNodeA -> DeepNode
const rootNode1Tree = getTreeNodeData( const rootNode1Tree = getTreeNodeData(
'RootNode1', rootNode1,
runData.RootNode1[0], runData.RootNode1[0],
undefined, undefined,
createTestLogTreeCreationContext(workflow, runData), createTestLogTreeCreationContext(workflow, runData),
@@ -524,7 +536,7 @@ describe(getTreeNodeData, () => {
// Test filtering for RootNode2 -> SubNodeB -> DeepNode // Test filtering for RootNode2 -> SubNodeB -> DeepNode
const rootNode2Tree = getTreeNodeData( const rootNode2Tree = getTreeNodeData(
'RootNode2', rootNode2,
runData.RootNode2[0], runData.RootNode2[0],
undefined, undefined,
createTestLogTreeCreationContext(workflow, runData), createTestLogTreeCreationContext(workflow, runData),
@@ -986,6 +998,88 @@ describe(createLogTree, () => {
expect(logs[0].children[1].children[0].node.name).toBe('C'); expect(logs[0].children[1].children[0].node.name).toBe('C');
expect(logs[0].children[1].children[1].node.name).toBe('C'); expect(logs[0].children[1].children[1].node.name).toBe('C');
}); });
it('should not include nodes without run data', () => {
const logs = createLogTree(
createTestWorkflowObject(aiChatWorkflow),
createTestWorkflowExecutionResponse({ data: { resultData: { runData: {} } } }),
);
expect(logs).toHaveLength(0);
});
it('should include sub node log without run data in its root node', () => {
const taskData = createTestTaskData({
source: [{ previousNode: 'PartialExecutionToolExecutor' }],
});
const logs = createLogTree(
createTestWorkflowObject(aiChatWorkflow),
createTestWorkflowExecutionResponse({
data: { resultData: { runData: { [aiModelNode.name]: [taskData] } } },
}),
);
expect(logs).toHaveLength(1);
expect(logs[0].node.name).toBe(aiAgentNode.name);
expect(logs[0].runData).toBe(undefined);
expect(logs[0].children).toHaveLength(1);
expect(logs[0].children[0].node.name).toBe(aiModelNode.name);
});
it('should include sub node log with its root node disabled', () => {
const taskData = createTestTaskData({
source: [{ previousNode: 'PartialExecutionToolExecutor' }],
});
const logs = createLogTree(
createTestWorkflowObject({
...aiChatWorkflow,
nodes: [{ ...aiAgentNode, disabled: true }, aiModelNode],
}),
createTestWorkflowExecutionResponse({
data: { resultData: { runData: { [aiModelNode.name]: [taskData] } } },
}),
);
expect(logs).toHaveLength(1);
expect(logs[0].node.name).toBe(aiAgentNode.name);
expect(logs[0].runData).toBe(undefined);
expect(logs[0].children).toHaveLength(1);
expect(logs[0].children[0].node.name).toBe(aiModelNode.name);
});
it('should not include duplicate sub node log when the node belongs to multiple root nodes with no run data', () => {
const taskData = createTestTaskData({
source: [{ previousNode: 'PartialExecutionToolExecutor' }],
});
const logs = createLogTree(
createTestWorkflowObject({
nodes: [
{ ...aiAgentNode, name: 'Agent A' },
{ ...aiAgentNode, name: 'Agent B' },
aiModelNode,
],
connections: {
[aiModelNode.name]: {
[NodeConnectionTypes.AiLanguageModel]: [
[
{ node: 'Agent A', index: 0, type: NodeConnectionTypes.AiLanguageModel },
{ node: 'Agent B', index: 0, type: NodeConnectionTypes.AiLanguageModel },
],
],
},
},
}),
createTestWorkflowExecutionResponse({
data: { resultData: { runData: { [aiModelNode.name]: [taskData] } } },
}),
);
expect(logs).toHaveLength(1);
expect(logs[0].node.name).toBe('Agent B');
expect(logs[0].runData).toBe(undefined);
expect(logs[0].children).toHaveLength(1);
expect(logs[0].children[0].node.name).toBe(aiModelNode.name);
});
}); });
describe(deepToRaw, () => { describe(deepToRaw, () => {

View File

@@ -8,6 +8,8 @@ import {
type ITaskData, type ITaskData,
type ITaskStartedData, type ITaskStartedData,
type Workflow, type Workflow,
type INode,
type ISourceData,
} from 'n8n-workflow'; } from 'n8n-workflow';
import type { LogEntry, LogEntrySelection, LogTreeCreationContext } from './logs.types'; import type { LogEntry, LogEntrySelection, LogTreeCreationContext } from './logs.types';
import { isProxy, isReactive, isRef, toRaw } from 'vue'; import { isProxy, isReactive, isRef, toRaw } from 'vue';
@@ -16,6 +18,7 @@ import { type ChatMessage } from '@n8n/chat/types';
import get from 'lodash-es/get'; import get from 'lodash-es/get';
import isEmpty from 'lodash-es/isEmpty'; import isEmpty from 'lodash-es/isEmpty';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { TOOL_EXECUTOR_NODE_NAME } from '@n8n/constants';
function getConsumedTokens(task: ITaskData): LlmTokenUsageData { function getConsumedTokens(task: ITaskData): LlmTokenUsageData {
if (!task.data) { if (!task.data) {
@@ -43,7 +46,7 @@ function createNode(
node: INodeUi, node: INodeUi,
context: LogTreeCreationContext, context: LogTreeCreationContext,
runIndex: number, runIndex: number,
runData: ITaskData, runData: ITaskData | undefined,
children: LogEntry[] = [], children: LogEntry[] = [],
): LogEntry { ): LogEntry {
return { return {
@@ -54,24 +57,13 @@ function createNode(
runIndex, runIndex,
runData, runData,
children, children,
consumedTokens: getConsumedTokens(runData), consumedTokens: runData ? getConsumedTokens(runData) : emptyTokenUsageData,
workflow: context.workflow, workflow: context.workflow,
executionId: context.executionId, executionId: context.executionId,
execution: context.data, execution: context.data,
}; };
} }
export function getTreeNodeData(
nodeName: string,
runData: ITaskData,
runIndex: number | undefined,
context: LogTreeCreationContext,
): LogEntry[] {
const node = context.workflow.getNode(nodeName);
return node ? getTreeNodeDataRec(node, runData, context, runIndex) : [];
}
function getChildNodes( function getChildNodes(
treeNode: LogEntry, treeNode: LogEntry,
node: INodeUi, node: INodeUi,
@@ -79,8 +71,8 @@ function getChildNodes(
context: LogTreeCreationContext, context: LogTreeCreationContext,
) { ) {
if (hasSubExecution(treeNode)) { if (hasSubExecution(treeNode)) {
const workflowId = treeNode.runData.metadata?.subExecution?.workflowId; const workflowId = treeNode.runData?.metadata?.subExecution?.workflowId;
const executionId = treeNode.runData.metadata?.subExecution?.executionId; const executionId = treeNode.runData?.metadata?.subExecution?.executionId;
const workflow = workflowId ? context.workflows[workflowId] : undefined; const workflow = workflowId ? context.workflows[workflowId] : undefined;
const subWorkflowRunData = executionId ? context.subWorkflowData[executionId] : undefined; const subWorkflowRunData = executionId ? context.subWorkflowData[executionId] : undefined;
@@ -102,6 +94,14 @@ function getChildNodes(
const connectedSubNodes = context.workflow.getParentNodes(node.name, 'ALL_NON_MAIN', 1); const connectedSubNodes = context.workflow.getParentNodes(node.name, 'ALL_NON_MAIN', 1);
const isExecutionRoot = !isSubNodeLog(treeNode); const isExecutionRoot = !isSubNodeLog(treeNode);
function isMatchedSource(source: ISourceData | null): boolean {
return (
(source?.previousNode === node.name ||
(isPlaceholderLog(treeNode) && source?.previousNode === TOOL_EXECUTOR_NODE_NAME)) &&
(runIndex === undefined || source.previousNodeRun === runIndex)
);
}
return connectedSubNodes.flatMap((subNodeName) => return connectedSubNodes.flatMap((subNodeName) =>
(context.data.resultData.runData[subNodeName] ?? []).flatMap((t, index) => { (context.data.resultData.runData[subNodeName] ?? []).flatMap((t, index) => {
// At root depth, filter out node executions that weren't triggered by this node // At root depth, filter out node executions that weren't triggered by this node
@@ -109,11 +109,7 @@ function getChildNodes(
// Only filter nodes that have source information with valid previousNode references // Only filter nodes that have source information with valid previousNode references
const isMatched = const isMatched =
isExecutionRoot && t.source.some((source) => source !== null) isExecutionRoot && t.source.some((source) => source !== null)
? t.source.some( ? t.source.some(isMatchedSource)
(source) =>
source?.previousNode === node.name &&
(runIndex === undefined || source.previousNodeRun === runIndex),
)
: runIndex === undefined || index === runIndex; : runIndex === undefined || index === runIndex;
if (!isMatched) { if (!isMatched) {
@@ -123,26 +119,29 @@ function getChildNodes(
const subNode = context.workflow.getNode(subNodeName); const subNode = context.workflow.getNode(subNodeName);
return subNode return subNode
? getTreeNodeDataRec( ? getTreeNodeData(subNode, t, index, {
subNode, ...context,
t, depth: context.depth + 1,
{ ...context, depth: context.depth + 1, parent: treeNode }, parent: treeNode,
index, })
)
: []; : [];
}), }),
); );
} }
function getTreeNodeDataRec( export function getTreeNodeData(
node: INodeUi, node: INodeUi,
runData: ITaskData, runData: ITaskData | undefined,
context: LogTreeCreationContext,
runIndex: number | undefined, runIndex: number | undefined,
context: LogTreeCreationContext,
): LogEntry[] { ): LogEntry[] {
const treeNode = createNode(node, context, runIndex ?? 0, runData); const treeNode = createNode(node, context, runIndex ?? 0, runData);
const children = getChildNodes(treeNode, node, runIndex, context).sort(sortLogEntries); const children = getChildNodes(treeNode, node, runIndex, context).sort(sortLogEntries);
if ((runData === undefined || node.disabled) && children.length === 0) {
return [];
}
treeNode.children = children; treeNode.children = children;
return [treeNode]; return [treeNode];
@@ -185,6 +184,10 @@ function findLogEntryToAutoSelectRec(subTree: LogEntry[], depth: number): LogEnt
} }
if (entry.node.type === AGENT_LANGCHAIN_NODE_TYPE) { if (entry.node.type === AGENT_LANGCHAIN_NODE_TYPE) {
if (isPlaceholderLog(entry) && entry.children.length > 0) {
return entry.children[0];
}
return entry; return entry;
} }
} }
@@ -210,23 +213,52 @@ export function createLogTree(
} }
function createLogTreeRec(context: LogTreeCreationContext) { function createLogTreeRec(context: LogTreeCreationContext) {
const runs = Object.entries(context.data.resultData.runData) const runData = context.data.resultData.runData;
.flatMap(([nodeName, taskData]) =>
context.workflow.getChildNodes(nodeName, 'ALL_NON_MAIN').length > 0 || return Object.entries(runData)
context.workflow.getNode(nodeName)?.disabled .flatMap<{
? [] // skip sub nodes and disabled nodes node: INode;
: taskData.map((task, runIndex) => ({ task?: ITaskData;
nodeName, runIndex?: number;
runData: task, nodeHasMultipleRuns: boolean;
runIndex, }>(([nodeName, taskData]) => {
nodeHasMultipleRuns: taskData.length > 1, const node = context.workflow.getNode(nodeName);
})),
if (node === null) {
return [];
}
const childNodes = context.workflow.getChildNodes(nodeName, 'ALL_NON_MAIN');
if (childNodes.length === 0) {
// The node is root node
return taskData.map((task, runIndex) => ({
node,
task,
runIndex,
nodeHasMultipleRuns: taskData.length > 1,
}));
}
// The node is sub node
if (childNodes.some((child) => (runData[child] ?? []).length > 0)) {
return [];
}
// The sub node has data but its children don't: this can happen for partial execution of tools.
// In this case, we insert first child as placeholder so that the node is included in the tree.
const firstChild = context.workflow.getNode(childNodes[0]);
if (firstChild === null) {
return [];
}
return [{ node: firstChild, nodeHasMultipleRuns: false }];
})
.flatMap(({ node, runIndex, task, nodeHasMultipleRuns }) =>
getTreeNodeData(node, task, nodeHasMultipleRuns ? runIndex : undefined, context),
) )
.sort(sortLogEntries); .sort(sortLogEntries);
return runs.flatMap(({ nodeName, runIndex, runData, nodeHasMultipleRuns }) =>
getTreeNodeData(nodeName, runData, nodeHasMultipleRuns ? runIndex : undefined, context),
);
} }
export function findLogEntryRec( export function findLogEntryRec(
@@ -334,7 +366,15 @@ export function getEntryAtRelativeIndex(
return offset === -1 ? undefined : entries[offset + relativeIndex]; return offset === -1 ? undefined : entries[offset + relativeIndex];
} }
function sortLogEntries<T extends { runData: ITaskData }>(a: T, b: T) { function sortLogEntries(a: LogEntry, b: LogEntry): number {
if (a.runData === undefined) {
return a.children.length > 0 ? sortLogEntries(a.children[0], b) : 0;
}
if (b.runData === undefined) {
return b.children.length > 0 ? sortLogEntries(a, b.children[0]) : 0;
}
// We rely on execution index only when startTime is different // We rely on execution index only when startTime is different
// Because it is reset to 0 when execution is waited, and therefore not necessarily unique // Because it is reset to 0 when execution is waited, and therefore not necessarily unique
if (a.runData.startTime === b.runData.startTime) { if (a.runData.startTime === b.runData.startTime) {
@@ -394,7 +434,7 @@ export function mergeStartData(
} }
export function hasSubExecution(entry: LogEntry): boolean { export function hasSubExecution(entry: LogEntry): boolean {
return !!entry.runData.metadata?.subExecution; return !!entry.runData?.metadata?.subExecution;
} }
export function getDefaultCollapsedEntries(entries: LogEntry[]): Record<string, boolean> { export function getDefaultCollapsedEntries(entries: LogEntry[]): Record<string, boolean> {
@@ -542,3 +582,7 @@ export function restoreChatHistory(
export function isSubNodeLog(logEntry: LogEntry): boolean { export function isSubNodeLog(logEntry: LogEntry): boolean {
return logEntry.parent !== undefined && logEntry.parent.executionId === logEntry.executionId; return logEntry.parent !== undefined && logEntry.parent.executionId === logEntry.executionId;
} }
export function isPlaceholderLog(treeNode: LogEntry): boolean {
return treeNode.runData === undefined;
}