mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
fix(editor): Make sure trimmed placeholder never reaches backend (#29842)
This commit is contained in:
parent
f871d44cab
commit
f7c7acc244
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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') }}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
19
packages/workflow/src/trimmed-task-data.ts
Normal file
19
packages/workflow/src/trimmed-task-data.ts
Normal 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);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user