From f7c7acc2441481235d81a38ea14ed637546d3b40 Mon Sep 17 00:00:00 2001 From: Benjamin Schroth <68321970+schrothbn@users.noreply.github.com> Date: Thu, 7 May 2026 14:15:27 +0200 Subject: [PATCH] fix(editor): Make sure trimmed placeholder never reaches backend (#29842) --- .../frontend/@n8n/i18n/src/locales/en.json | 2 + .../src/app/composables/usePinnedData.test.ts | 32 ++++++++- .../src/app/composables/usePinnedData.ts | 15 ++++- .../composables/useExecutionDebugging.test.ts | 51 +++++++++++++++ .../composables/useExecutionDebugging.ts | 5 ++ .../execution/executions/executions.utils.ts | 9 +-- .../ndv/runData/components/RunData.test.ts | 65 ++++++++++++++++++- .../ndv/runData/components/RunData.vue | 34 +++++++++- packages/workflow/src/execution-status.ts | 12 ++++ packages/workflow/src/index.ts | 1 + packages/workflow/src/trimmed-task-data.ts | 19 ++++++ 11 files changed, 232 insertions(+), 13 deletions(-) create mode 100644 packages/workflow/src/trimmed-task-data.ts diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 8f21b9ecf12..5bc84ceadb4 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -2820,6 +2820,8 @@ "runData.aiContentBlock.tokens.prompt": "Prompt:", "runData.aiContentBlock.tokens.completion": "Completion:", "runData.trimmedData.loading": "Loading data", + "runData.trimmedData.corrupted": "Pinned data on this node is corrupted and can't be displayed.", + "runData.trimmedData.unpin": "Unpin data", "runData.panel.actions.collapse": "Collapse panel", "runData.panel.actions.open": "Open panel", "runData.panel.actions.popOut": "Pop out panel", diff --git a/packages/frontend/editor-ui/src/app/composables/usePinnedData.test.ts b/packages/frontend/editor-ui/src/app/composables/usePinnedData.test.ts index f9e073f6faf..ffb67c3e7dc 100644 --- a/packages/frontend/editor-ui/src/app/composables/usePinnedData.test.ts +++ b/packages/frontend/editor-ui/src/app/composables/usePinnedData.test.ts @@ -10,7 +10,11 @@ import { createWorkflowDocumentId, } from '@/app/stores/workflowDocument.store'; import { useTelemetry } from '@/app/composables/useTelemetry'; -import { NodeConnectionTypes, STICKY_NODE_TYPE } from 'n8n-workflow'; +import { + NodeConnectionTypes, + STICKY_NODE_TYPE, + TRIMMED_TASK_DATA_CONNECTIONS_KEY, +} from 'n8n-workflow'; import type { NodeConnectionType, INodeTypeDescription } from 'n8n-workflow'; vi.mock('@/app/composables/useToast', () => ({ useToast: vi.fn(() => ({ showError: vi.fn() })) })); @@ -97,6 +101,32 @@ describe('usePinnedData', () => { ); expect(workflowDocumentStore.pinData?.[node.value.name]).toEqual(testData); }); + + it('should throw and not pin data when input contains the trimmed-execution-data marker', () => { + const workflowsStore = useWorkflowsStore(); + workflowsStore.workflow.id = 'test-workflow'; + const telemetry = useTelemetry(); + const trackSpy = vi.spyOn(telemetry, 'track'); + const node = ref({ name: 'testNode' } as INodeUi); + const { setData } = usePinnedData(node); + const trimmedData = [ + { + json: { [TRIMMED_TASK_DATA_CONNECTIONS_KEY]: true }, + pairedItem: { item: 0 }, + }, + ]; + + expect(() => setData(trimmedData, 'pin-icon-click')).toThrow(); + + const workflowDocumentStore = useWorkflowDocumentStore( + createWorkflowDocumentId(workflowsStore.workflow.id), + ); + expect(workflowDocumentStore.pinData?.[node.value.name]).toBeUndefined(); + expect(trackSpy).toHaveBeenCalledWith( + 'Ndv data pinning failure', + expect.objectContaining({ error_type: 'trimmed-data' }), + ); + }); }); describe('unsetData()', () => { diff --git a/packages/frontend/editor-ui/src/app/composables/usePinnedData.ts b/packages/frontend/editor-ui/src/app/composables/usePinnedData.ts index d5be2a4c914..7e1f6b9cfa6 100644 --- a/packages/frontend/editor-ui/src/app/composables/usePinnedData.ts +++ b/packages/frontend/editor-ui/src/app/composables/usePinnedData.ts @@ -1,7 +1,13 @@ import { useToast } from '@/app/composables/useToast'; import { useI18n } from '@n8n/i18n'; import type { IDataObject, INodeExecutionData, IPinData } from 'n8n-workflow'; -import { jsonParse, jsonStringify, NodeConnectionTypes, NodeHelpers } from 'n8n-workflow'; +import { + isTrimmedNodeExecutionData, + jsonParse, + jsonStringify, + NodeConnectionTypes, + NodeHelpers, +} from 'n8n-workflow'; import { MAX_EXPECTED_REQUEST_SIZE, MAX_PINNED_DATA_SIZE, @@ -243,7 +249,7 @@ export function usePinnedData( errorType, source, }: { - errorType: 'data-too-large' | 'invalid-json'; + errorType: 'data-too-large' | 'invalid-json' | 'trimmed-data'; source: PinDataSource; }) { const targetNode = unref(node); @@ -281,6 +287,11 @@ export function usePinnedData( throw new Error('Data too large'); } + if (Array.isArray(data) && isTrimmedNodeExecutionData(data as INodeExecutionData[])) { + onSetDataError({ errorType: 'trimmed-data', source }); + throw new Error('Cannot pin trimmed execution data'); + } + if (workflowDocumentStore.value) { const nodeName = targetNode.name; // Update metadata timestamp for existing pinned data diff --git a/packages/frontend/editor-ui/src/features/execution/executions/composables/useExecutionDebugging.test.ts b/packages/frontend/editor-ui/src/features/execution/executions/composables/useExecutionDebugging.test.ts index e51c8a3bb7b..c31a68edc01 100644 --- a/packages/frontend/editor-ui/src/features/execution/executions/composables/useExecutionDebugging.test.ts +++ b/packages/frontend/editor-ui/src/features/execution/executions/composables/useExecutionDebugging.test.ts @@ -12,6 +12,7 @@ import type { INodeUi } from '@/Interface'; import type { IExecutionResponse } from '../executions.types'; import { useToast } from '@/app/composables/useToast'; import type { useWorkflowDocumentStore } from '@/app/stores/workflowDocument.store'; +import { TRIMMED_TASK_DATA_CONNECTIONS_KEY } from 'n8n-workflow'; vi.mock('@/app/composables/useToast', () => { const showToast = vi.fn(); @@ -253,6 +254,56 @@ describe('useExecutionDebugging()', () => { expect(uiStore.markStateDirty).toHaveBeenCalledTimes(1); }); + it('should skip pinning nodes whose run data contains the trimmed-execution-data marker but still pin clean nodes', async () => { + const mockExecution = { + data: { + resultData: { + runData: { + TrimmedTrigger: [ + { + data: { + main: [ + [ + { + json: { [TRIMMED_TASK_DATA_CONNECTIONS_KEY]: true }, + pairedItem: { item: 0 }, + }, + ], + ], + }, + }, + ], + CleanTrigger: [ + { + data: { + main: [[{ json: { ok: true } }]], + }, + }, + ], + }, + }, + }, + } as unknown as IExecutionResponse; + + const workflowStore = mockedStore(useWorkflowsStore); + mockWorkflowDocumentStore.allNodes = [ + { name: 'TrimmedTrigger' }, + { name: 'CleanTrigger' }, + ] as INodeUi[]; + workflowStore.getExecution.mockResolvedValueOnce(mockExecution); + + await executionDebugging.applyExecutionData('1'); + + expect(mockWorkflowDocumentStore.pinNodeData).toHaveBeenCalledTimes(1); + expect(mockWorkflowDocumentStore.pinNodeData).toHaveBeenCalledWith('CleanTrigger', [ + { json: { ok: true } }, + ]); + expect(mockWorkflowDocumentStore.pinNodeData).not.toHaveBeenCalledWith( + 'TrimmedTrigger', + expect.anything(), + ); + }); + it('should not mark workflow state dirty when nothing is pinned or unpinned', async () => { const mockExecution = { data: { diff --git a/packages/frontend/editor-ui/src/features/execution/executions/composables/useExecutionDebugging.ts b/packages/frontend/editor-ui/src/features/execution/executions/composables/useExecutionDebugging.ts index d1dafcf77a1..7b3784bef21 100644 --- a/packages/frontend/editor-ui/src/features/execution/executions/composables/useExecutionDebugging.ts +++ b/packages/frontend/editor-ui/src/features/execution/executions/composables/useExecutionDebugging.ts @@ -19,6 +19,7 @@ import { useRootStore } from '@n8n/stores/useRootStore'; import { isFullExecutionResponse } from '@/app/utils/typeGuards'; import { sanitizeHtml } from '@/app/utils/htmlUtils'; import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper'; +import { isTrimmedNodeExecutionData } from 'n8n-workflow'; /** * @param providedWorkflowState - Optional workflow state to use instead of injecting. @@ -121,6 +122,10 @@ export const useExecutionDebugging = (providedWorkflowState?: WorkflowState) => // Get the first main output that has data, preserving all execution data including binary const nodeData = taskData.data.main.find((output) => output && output.length > 0); if (nodeData) { + // Pinning a placeholder would round-trip it through the next manual run and persist it to DB. + if (isTrimmedNodeExecutionData(nodeData)) { + return; + } pinnings++; workflowDocumentStore.value.pinNodeData(node.name, nodeData); diff --git a/packages/frontend/editor-ui/src/features/execution/executions/executions.utils.ts b/packages/frontend/editor-ui/src/features/execution/executions/executions.utils.ts index 04200100a13..99db3d2d6e9 100644 --- a/packages/frontend/editor-ui/src/features/execution/executions/executions.utils.ts +++ b/packages/frontend/editor-ui/src/features/execution/executions/executions.utils.ts @@ -1,7 +1,7 @@ import { MANUAL_TRIGGER_NODE_TYPE, - TRIMMED_TASK_DATA_CONNECTIONS_KEY, createRunExecutionData, + isTrimmedNodeExecutionData, } from 'n8n-workflow'; import type { ITaskData, @@ -229,12 +229,7 @@ export const waitingNodeTooltip = ( return ''; }; -/** - * Check whether node execution data contains a trimmed item. - */ -export function isTrimmedNodeExecutionData(data: INodeExecutionData[] | null) { - return data?.some((entry) => entry.json?.[TRIMMED_TASK_DATA_CONNECTIONS_KEY]); -} +export { isTrimmedNodeExecutionData }; /** * Check whether task data contains a trimmed item. diff --git a/packages/frontend/editor-ui/src/features/ndv/runData/components/RunData.test.ts b/packages/frontend/editor-ui/src/features/ndv/runData/components/RunData.test.ts index 890566d1bfb..b11072c4f7a 100644 --- a/packages/frontend/editor-ui/src/features/ndv/runData/components/RunData.test.ts +++ b/packages/frontend/editor-ui/src/features/ndv/runData/components/RunData.test.ts @@ -16,7 +16,8 @@ import { useWorkflowsStore } from '@/app/stores/workflows.store'; import { createTestingPinia } from '@pinia/testing'; import userEvent from '@testing-library/user-event'; import { waitFor } from '@testing-library/vue'; -import type { INodeExecutionData, ITaskData, ITaskMetadata } from 'n8n-workflow'; +import type { ExecutionStatus, INodeExecutionData, ITaskData, ITaskMetadata } from 'n8n-workflow'; +import { TRIMMED_TASK_DATA_CONNECTIONS_KEY } from 'n8n-workflow'; import { setActivePinia } from 'pinia'; import { useNodeTypesStore } from '@/app/stores/nodeTypes.store'; import { useSchemaPreviewStore } from '@/features/ndv/runData/schemaPreview.store'; @@ -1298,6 +1299,65 @@ describe('RunData', () => { }); }); + describe('trimmed execution data placeholder', () => { + const trimmedRun = { + startTime: Date.now(), + executionIndex: 0, + executionTime: 1, + data: { + main: [ + [ + { + json: { [TRIMMED_TASK_DATA_CONNECTIONS_KEY]: true }, + pairedItem: { item: 0 }, + }, + ], + ], + }, + source: [null], + } as unknown as ITaskData; + + it('shows the loading spinner for trimmed data while the workflow execution is still running', () => { + const { getByTestId } = render({ + displayMode: 'json', + runs: [trimmedRun], + executionStatus: 'running', + }); + + expect(getByTestId('ndv-trimmed-loading')).toBeInTheDocument(); + }); + + it('shows the recovery state with an unpin button for trimmed pinned data after the workflow execution finished', () => { + const { getByTestId, queryByTestId } = render({ + displayMode: 'json', + runs: [trimmedRun], + pinnedData: [ + { + json: { [TRIMMED_TASK_DATA_CONNECTIONS_KEY]: true }, + pairedItem: { item: 0 }, + }, + ], + executionStatus: 'success', + }); + + expect(queryByTestId('ndv-trimmed-loading')).not.toBeInTheDocument(); + expect(getByTestId('ndv-trimmed-corrupted')).toBeInTheDocument(); + expect(getByTestId('ndv-trimmed-corrupted-unpin')).toBeInTheDocument(); + }); + + it('shows the recovery state without an unpin button when the trimmed marker is on a different node', () => { + const { getByTestId, queryByTestId } = render({ + displayMode: 'json', + runs: [trimmedRun], + executionStatus: 'success', + }); + + expect(queryByTestId('ndv-trimmed-loading')).not.toBeInTheDocument(); + expect(getByTestId('ndv-trimmed-corrupted')).toBeInTheDocument(); + expect(queryByTestId('ndv-trimmed-corrupted-unpin')).not.toBeInTheDocument(); + }); + }); + // Default values for the render function const nodes = [ { @@ -1322,6 +1382,7 @@ describe('RunData', () => { overrideOutputs, lastSuccessfulExecution, redactionInfo, + executionStatus, }: { defaultRunItems?: INodeExecutionData[]; workflowId?: string; @@ -1333,6 +1394,7 @@ describe('RunData', () => { runs?: ITaskData[]; overrideOutputs?: number[]; redactionInfo?: { isRedacted: boolean; reason: string; canReveal: boolean }; + executionStatus?: ExecutionStatus; lastSuccessfulExecution?: { id: string; finished: boolean; @@ -1372,6 +1434,7 @@ describe('RunData', () => { finished: true, mode: 'trigger', startedAt: new Date(), + ...(executionStatus ? { status: executionStatus } : {}), workflowData: { id: '1', name: 'Test Workflow', diff --git a/packages/frontend/editor-ui/src/features/ndv/runData/components/RunData.vue b/packages/frontend/editor-ui/src/features/ndv/runData/components/RunData.vue index 61d42c4d6d3..13125b0f46c 100644 --- a/packages/frontend/editor-ui/src/features/ndv/runData/components/RunData.vue +++ b/packages/frontend/editor-ui/src/features/ndv/runData/components/RunData.vue @@ -14,7 +14,12 @@ import type { NodeHint, NodeConnectionType, } from 'n8n-workflow'; -import { parseErrorMetadata, NodeConnectionTypes, NodeHelpers } from 'n8n-workflow'; +import { + isTerminalExecutionStatus, + parseErrorMetadata, + NodeConnectionTypes, + NodeHelpers, +} from 'n8n-workflow'; import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, ref, toRef, watch } from 'vue'; import type { INodeUi, IRunDataDisplayMode, ITab } from '@/Interface'; @@ -436,6 +441,10 @@ const isTrimmedManualExecutionDataItem = computed(() => workflowRunData.value ? hasTrimmedRunData(workflowRunData.value) : false, ); +const isExecutionInTerminalState = computed(() => + isTerminalExecutionStatus(workflowsStore.getWorkflowExecution?.status ?? undefined), +); + const isExecutionRedacted = computed( () => hasNodeRun.value && @@ -1749,8 +1758,9 @@ defineExpose({ enterEditMode });