fix(editor): Make sure trimmed placeholder never reaches backend (#29842)

This commit is contained in:
Benjamin Schroth 2026-05-07 14:15:27 +02:00 committed by GitHub
parent f871d44cab
commit f7c7acc244
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 232 additions and 13 deletions

View File

@ -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",

View File

@ -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()', () => {

View File

@ -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

View File

@ -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: {

View File

@ -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);

View File

@ -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.

View File

@ -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',

View File

@ -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 });
</div>
<div
v-else-if="isTrimmedManualExecutionDataItem"
v-else-if="isTrimmedManualExecutionDataItem && !isExecutionInTerminalState"
:class="[$style.center, $style.executingMessage]"
data-test-id="ndv-trimmed-loading"
>
<div v-if="!props.compact" :class="$style.spinner">
<N8nSpinner type="ring" />
@ -1760,6 +1770,26 @@ defineExpose({ enterEditMode });
</N8nText>
</div>
<div
v-else-if="isTrimmedManualExecutionDataItem && isExecutionInTerminalState"
:class="[$style.center, $style.executingMessage]"
data-test-id="ndv-trimmed-corrupted"
>
<N8nText>
{{ i18n.baseText('runData.trimmedData.corrupted') }}
</N8nText>
<N8nButton
v-if="pinnedData.hasData.value"
class="mt-s"
type="secondary"
size="small"
data-test-id="ndv-trimmed-corrupted-unpin"
@click="onTogglePinData({ source: 'context-menu' })"
>
{{ i18n.baseText('runData.trimmedData.unpin') }}
</N8nButton>
</div>
<div v-else-if="editMode.enabled" :class="$style.editMode">
<N8nText v-if="previousExecutionDataUsedInEditMode" class="mb-2xs" size="small"
>{{ i18n.baseText('runData.pinData.insertedExecutionData') }}

View File

@ -10,3 +10,15 @@ export const ExecutionStatusList = [
] as const;
export type ExecutionStatus = (typeof ExecutionStatusList)[number];
export const TERMINAL_EXECUTION_STATUSES = ['canceled', 'crashed', 'error', 'success'] as const;
export type TerminalExecutionStatus = (typeof TERMINAL_EXECUTION_STATUSES)[number];
export function isTerminalExecutionStatus(
status: ExecutionStatus | undefined,
): status is TerminalExecutionStatus {
return (
status === 'canceled' || status === 'crashed' || status === 'error' || status === 'success'
);
}

View File

@ -16,6 +16,7 @@ export * from './interfaces';
export * from './run-execution-data-factory';
export * from './message-event-bus';
export * from './execution-status';
export * from './trimmed-task-data';
export * from './expression';
export * from './expressions/expression-helpers';
export * from './from-ai-parse-utils';

View File

@ -0,0 +1,19 @@
import { TRIMMED_TASK_DATA_CONNECTIONS_KEY } from './constants';
import type { INodeExecutionData, IPinData } from './interfaces';
/**
* The placeholder is FE-only and meant to be transient. If it ever appears in
* pinData, the workflow's stored state has been corrupted (the placeholder
* round-tripped through pinData engine DB), and any subsequent run will
* reproduce the corruption. Use these helpers to detect and refuse it.
*/
export function isTrimmedNodeExecutionData(data: INodeExecutionData[] | null | undefined): boolean {
return !!data?.some((entry) => entry?.json?.[TRIMMED_TASK_DATA_CONNECTIONS_KEY] === true);
}
export function getTrimmedPinDataNodeNames(pinData: IPinData | undefined): string[] {
if (!pinData) return [];
return Object.entries(pinData)
.filter(([, items]) => Array.isArray(items) && isTrimmedNodeExecutionData(items))
.map(([nodeName]) => nodeName);
}