mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-24 02:09:26 +08:00
perf(editor): Store execution data in a shallowRef (no-changelog)
Store the execution payload in a shallowRef, so that Vue does not wrap the run data in proxies. Each execution data store action now builds new objects along the path that it changes. Unchanged branches keep their identity, so per-node views update only for the node that changed. Make the pending execution and the logs panel state shallow too. Keep the pending copy in step when a node is renamed or the run is stopped before it has an id. Remove copyExecutionData from the logs panel. https://linear.app/n8n/issue/CAP-418 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,12 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { setActivePinia, createPinia, getActivePinia } from 'pinia';
|
||||
import { nextTick } from 'vue';
|
||||
import { NodeConnectionTypes, SEND_AND_WAIT_OPERATION, WAIT_INDEFINITELY } from 'n8n-workflow';
|
||||
import { isProxy, nextTick, watch } from 'vue';
|
||||
import {
|
||||
NodeConnectionTypes,
|
||||
SEND_AND_WAIT_OPERATION,
|
||||
WAIT_INDEFINITELY,
|
||||
createRunExecutionData,
|
||||
} from 'n8n-workflow';
|
||||
import type { INode } from 'n8n-workflow';
|
||||
import {
|
||||
useExecutionDataStore,
|
||||
@@ -16,11 +21,20 @@ import {
|
||||
} from '@/app/constants';
|
||||
import {
|
||||
createTestNode,
|
||||
createTestTaskData,
|
||||
createTestWorkflow,
|
||||
createTestWorkflowExecutionResponse,
|
||||
} from '@/__tests__/mocks';
|
||||
import type { IExecutionResponse } from '@/features/execution/executions/executions.types';
|
||||
|
||||
function deepFreeze<T>(value: T): T {
|
||||
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
||||
Object.freeze(value);
|
||||
for (const child of Object.values(value)) deepFreeze(child);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function createTestExecution(overrides: Partial<IExecutionResponse> = {}): IExecutionResponse {
|
||||
return createTestWorkflowExecutionResponse({
|
||||
id: 'exec-1',
|
||||
@@ -616,9 +630,9 @@ describe('executionData.store', () => {
|
||||
});
|
||||
|
||||
describe('mutation commit channels', () => {
|
||||
// Every in-place runData mutation must commit through three channels
|
||||
// (timestamp bump, top-level identity replacement, change event) —
|
||||
// see commitExecutionMutation in the store. Fake timers make Date.now()
|
||||
// Every mutation must commit through three channels (new identities
|
||||
// along the changed path, timestamp bump, change event) — see
|
||||
// commitExecutionMutation in the store. Fake timers make Date.now()
|
||||
// deterministic so a bump is distinguishable from a same-millisecond
|
||||
// no-op.
|
||||
beforeEach(() => {
|
||||
@@ -638,6 +652,7 @@ describe('executionData.store', () => {
|
||||
resultData: {
|
||||
runData: {
|
||||
NodeA: [{ executionIndex: 0, executionStatus: 'running', source: [] } as never],
|
||||
NodeB: [{ executionIndex: 1, executionStatus: 'success', source: [] } as never],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
@@ -666,18 +681,22 @@ describe('executionData.store', () => {
|
||||
mutate: (store: ExecutionDataStore) => void;
|
||||
action: 'update' | 'delete';
|
||||
nodeName?: string;
|
||||
// The node whose task array the mutation replaces.
|
||||
changedNode?: string;
|
||||
}> = [
|
||||
{
|
||||
name: 'updateNodeExecutionStatus',
|
||||
mutate: (store) => store.updateNodeExecutionStatus(createNodeExecuteAfterDataPayload()),
|
||||
action: 'update',
|
||||
nodeName: 'NodeA',
|
||||
changedNode: 'NodeA',
|
||||
},
|
||||
{
|
||||
name: 'updateNodeExecutionRunData',
|
||||
mutate: (store) => store.updateNodeExecutionRunData(createNodeExecuteAfterDataPayload()),
|
||||
action: 'update',
|
||||
nodeName: 'NodeA',
|
||||
changedNode: 'NodeA',
|
||||
},
|
||||
{
|
||||
name: 'clearNodeExecutionData',
|
||||
@@ -687,7 +706,7 @@ describe('executionData.store', () => {
|
||||
},
|
||||
{
|
||||
name: 'renameExecutionDataNode',
|
||||
mutate: (store) => store.renameExecutionDataNode('NodeA', 'NodeB'),
|
||||
mutate: (store) => store.renameExecutionDataNode('NodeA', 'NodeC'),
|
||||
action: 'update',
|
||||
},
|
||||
{
|
||||
@@ -699,6 +718,7 @@ describe('executionData.store', () => {
|
||||
stoppedAt: new Date(1),
|
||||
}),
|
||||
action: 'update',
|
||||
changedNode: 'NodeA',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -724,6 +744,29 @@ describe('executionData.store', () => {
|
||||
expect(store.execution).not.toBe(before);
|
||||
});
|
||||
|
||||
it.each(mutations)(
|
||||
'$name replaces the changed path and keeps the identity of untouched nodes',
|
||||
({ mutate, changedNode }) => {
|
||||
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
|
||||
seedExecution(store);
|
||||
const before = store.execution;
|
||||
const beforeRunData = before?.data?.resultData.runData;
|
||||
|
||||
mutate(store);
|
||||
|
||||
const after = store.execution;
|
||||
expect(after?.data).not.toBe(before?.data);
|
||||
expect(after?.data?.resultData).not.toBe(before?.data?.resultData);
|
||||
expect(after?.data?.resultData.runData).not.toBe(beforeRunData);
|
||||
if (changedNode) {
|
||||
expect(after?.data?.resultData.runData[changedNode]).not.toBe(
|
||||
beforeRunData?.[changedNode],
|
||||
);
|
||||
}
|
||||
expect(after?.data?.resultData.runData.NodeB).toBe(beforeRunData?.NodeB);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(mutations)('$name fires exactly one change event', ({ mutate, action, nodeName }) => {
|
||||
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
|
||||
seedExecution(store);
|
||||
@@ -778,6 +821,327 @@ describe('executionData.store', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('copy-on-write', () => {
|
||||
type ExecutionDataStore = ReturnType<typeof useExecutionDataStore>;
|
||||
|
||||
const nodeA = createTestNode({ id: 'node-a', name: 'NodeA' });
|
||||
const nodeB = createTestNode({ id: 'node-b', name: 'NodeB' });
|
||||
|
||||
function createSeedExecution(overrides: Partial<IExecutionResponse> = {}) {
|
||||
return createTestExecution({
|
||||
executedNode: 'NodeA',
|
||||
workflowData: createTestWorkflow({
|
||||
nodes: [nodeA, nodeB],
|
||||
connections: {
|
||||
NodeA: { main: [[{ node: 'NodeB', type: 'main', index: 0 }]] },
|
||||
NodeB: { main: [[{ node: 'NodeA', type: 'main', index: 0 }]] },
|
||||
},
|
||||
pinData: { NodeA: [{ json: { pinned: true } }] },
|
||||
}),
|
||||
data: createRunExecutionData({
|
||||
resultData: {
|
||||
lastNodeExecuted: 'NodeA',
|
||||
runData: {
|
||||
NodeA: [
|
||||
createTestTaskData({ executionIndex: 0, executionStatus: 'success' }),
|
||||
createTestTaskData({ executionIndex: 1, executionStatus: 'running' }),
|
||||
],
|
||||
NodeB: [
|
||||
createTestTaskData({
|
||||
executionIndex: 2,
|
||||
executionStatus: 'success',
|
||||
source: [{ previousNode: 'NodeA' }],
|
||||
}),
|
||||
],
|
||||
},
|
||||
pinData: {
|
||||
NodeA: [
|
||||
{
|
||||
json: {},
|
||||
pairedItem: { item: 0, sourceOverwrite: { previousNode: 'NodeA' } },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function createPayload(
|
||||
nodeName: string,
|
||||
data: Partial<ReturnType<typeof createTestTaskData>> = {},
|
||||
) {
|
||||
return {
|
||||
executionId: 'exec-1',
|
||||
nodeName,
|
||||
data: createTestTaskData({ executionIndex: 1, executionStatus: 'success', ...data }),
|
||||
itemCountByConnectionType: {},
|
||||
} as never;
|
||||
}
|
||||
|
||||
const cases: Array<{
|
||||
name: string;
|
||||
execution?: () => IExecutionResponse;
|
||||
mutate: (store: ExecutionDataStore) => void;
|
||||
verify: (store: ExecutionDataStore) => void;
|
||||
}> = [
|
||||
{
|
||||
name: 'updateNodeExecutionStatus adds a new node',
|
||||
mutate: (store) => store.updateNodeExecutionStatus(deepFreeze(createPayload('NodeD'))),
|
||||
verify: (store) => {
|
||||
expect(store.executionRunData?.NodeD).toHaveLength(1);
|
||||
expect(store.execution?.data?.resultData.lastNodeExecuted).toBe('NodeD');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'updateNodeExecutionStatus replaces a running task',
|
||||
mutate: (store) => store.updateNodeExecutionStatus(deepFreeze(createPayload('NodeA'))),
|
||||
verify: (store) => {
|
||||
expect(store.executionRunData?.NodeA).toHaveLength(2);
|
||||
expect(store.executionRunData?.NodeA[1].executionStatus).toBe('success');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'updateNodeExecutionStatus appends a task',
|
||||
mutate: (store) =>
|
||||
store.updateNodeExecutionStatus(
|
||||
deepFreeze(createPayload('NodeB', { executionIndex: 3 })),
|
||||
),
|
||||
verify: (store) => {
|
||||
expect(store.executionRunData?.NodeB).toHaveLength(2);
|
||||
expect(store.executionRunData?.NodeB[1].executionIndex).toBe(3);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'updateNodeExecutionStatus appends a waiting task',
|
||||
mutate: (store) =>
|
||||
store.updateNodeExecutionStatus(
|
||||
deepFreeze(createPayload('NodeA', { executionIndex: 4, executionStatus: 'waiting' })),
|
||||
),
|
||||
verify: (store) => {
|
||||
expect(store.executionRunData?.NodeA).toHaveLength(3);
|
||||
expect(store.executionRunData?.NodeA[2].executionStatus).toBe('waiting');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'updateNodeExecutionStatus synthesizes provisional redaction',
|
||||
mutate: (store) =>
|
||||
store.updateNodeExecutionStatus(
|
||||
deepFreeze(
|
||||
createPayload('NodeA', {
|
||||
data: {
|
||||
main: [
|
||||
[{ json: {}, redaction: { redacted: true, reason: 'dynamic_credentials' } }],
|
||||
],
|
||||
} as never,
|
||||
}),
|
||||
),
|
||||
),
|
||||
verify: (store) => {
|
||||
expect(store.execution?.data?.redactionInfo).toEqual({
|
||||
isRedacted: true,
|
||||
reason: 'dynamic_credentials',
|
||||
canReveal: false,
|
||||
provisional: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'updateNodeExecutionRunData',
|
||||
mutate: (store) =>
|
||||
store.updateNodeExecutionRunData(
|
||||
deepFreeze(createPayload('NodeA', { executionStatus: 'error' })),
|
||||
),
|
||||
verify: (store) => {
|
||||
expect(store.executionRunData?.NodeA[1].executionStatus).toBe('error');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'clearNodeExecutionData',
|
||||
mutate: (store) => store.clearNodeExecutionData('NodeA'),
|
||||
verify: (store) => {
|
||||
expect(store.executionRunData?.NodeA).toBeUndefined();
|
||||
expect(store.executionRunData?.NodeB).toHaveLength(1);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'renameExecutionDataNode',
|
||||
mutate: (store) => store.renameExecutionDataNode('NodeA', 'NodeC'),
|
||||
verify: (store) => {
|
||||
const execution = store.execution;
|
||||
const resultData = execution?.data?.resultData;
|
||||
expect(Object.keys(resultData?.runData ?? {}).sort()).toEqual(['NodeB', 'NodeC']);
|
||||
expect(resultData?.runData.NodeB[0].source[0]?.previousNode).toBe('NodeC');
|
||||
expect(Object.keys(resultData?.pinData ?? {})).toEqual(['NodeC']);
|
||||
expect(resultData?.pinData?.NodeC[0].pairedItem).toEqual({
|
||||
item: 0,
|
||||
sourceOverwrite: { previousNode: 'NodeC' },
|
||||
});
|
||||
expect(execution?.workflowData.nodes.map((node) => node.name)).toEqual([
|
||||
'NodeC',
|
||||
'NodeB',
|
||||
]);
|
||||
expect(Object.keys(execution?.workflowData.connections ?? {}).sort()).toEqual([
|
||||
'NodeB',
|
||||
'NodeC',
|
||||
]);
|
||||
expect(execution?.workflowData.connections.NodeB.main[0]?.[0].node).toBe('NodeC');
|
||||
expect(Object.keys(execution?.workflowData.pinData ?? {})).toEqual(['NodeC']);
|
||||
expect(execution?.executedNode).toBe('NodeC');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'markAsStopped',
|
||||
mutate: (store) =>
|
||||
store.markAsStopped({
|
||||
status: 'canceled',
|
||||
startedAt: new Date(0),
|
||||
stoppedAt: new Date(1),
|
||||
}),
|
||||
verify: (store) => {
|
||||
expect(store.executionRunData?.NodeA).toHaveLength(1);
|
||||
expect(store.executionRunData?.NodeA[0].executionStatus).toBe('success');
|
||||
expect(store.execution?.status).toBe('canceled');
|
||||
expect(store.execution?.stoppedAt).toEqual(new Date(1));
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
it.each(cases)('$name never writes into a frozen execution', ({ mutate, verify }) => {
|
||||
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
|
||||
store.setExecution(deepFreeze(createSeedExecution()));
|
||||
|
||||
expect(() => mutate(store)).not.toThrow();
|
||||
verify(store);
|
||||
});
|
||||
|
||||
it.each(cases)('$name leaves no Vue proxy in the payload', ({ mutate }) => {
|
||||
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
|
||||
store.setExecution(createSeedExecution());
|
||||
|
||||
mutate(store);
|
||||
|
||||
expect(isProxy(store.execution)).toBe(false);
|
||||
expect(isProxy(store.execution?.data?.resultData.runData)).toBe(false);
|
||||
expect(isProxy(store.executionRunData)).toBe(false);
|
||||
expect(isProxy(store.executionRunData?.NodeB)).toBe(false);
|
||||
});
|
||||
|
||||
it('setExecution leaves no Vue proxy in the payload', () => {
|
||||
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
|
||||
const execution = createSeedExecution();
|
||||
|
||||
store.setExecution(execution);
|
||||
|
||||
expect(store.execution).toBe(execution);
|
||||
expect(isProxy(store.execution)).toBe(false);
|
||||
expect(isProxy(store.execution?.data?.resultData.runData)).toBe(false);
|
||||
expect(isProxy(store.executionRunData)).toBe(false);
|
||||
expect(isProxy(store.executionRunData?.NodeA)).toBe(false);
|
||||
});
|
||||
|
||||
it('setExecution strips the waiting task without changing the frozen input', () => {
|
||||
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
|
||||
const execution = createSeedExecution();
|
||||
execution.data = createRunExecutionData({
|
||||
...execution.data,
|
||||
waitTill: new Date(),
|
||||
});
|
||||
deepFreeze(execution);
|
||||
|
||||
expect(() => store.setExecution(execution)).not.toThrow();
|
||||
|
||||
expect(store.executionRunData?.NodeA).toBeUndefined();
|
||||
expect(store.executionRunData?.NodeB).toHaveLength(1);
|
||||
expect(execution.data?.resultData.runData.NodeA).toHaveLength(2);
|
||||
});
|
||||
|
||||
describe('notifies consumers during a live run', () => {
|
||||
const pushes: Array<{
|
||||
name: string;
|
||||
mutate: (store: ExecutionDataStore, payload: never) => void;
|
||||
}> = [
|
||||
{
|
||||
name: 'updateNodeExecutionStatus',
|
||||
mutate: (store, payload) => store.updateNodeExecutionStatus(payload),
|
||||
},
|
||||
{
|
||||
name: 'updateNodeExecutionRunData',
|
||||
mutate: (store, payload) => store.updateNodeExecutionRunData(payload),
|
||||
},
|
||||
];
|
||||
|
||||
it.each(pushes)('$name fires sync watchers with the new value', ({ mutate }) => {
|
||||
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
|
||||
store.setExecution(createSeedExecution());
|
||||
|
||||
const runDataSpy = vi.fn();
|
||||
const nodeARunDataSpy = vi.fn();
|
||||
const nodeBRunDataSpy = vi.fn();
|
||||
const statusSpy = vi.fn();
|
||||
const issuesSpy = vi.fn();
|
||||
watch(() => store.executionRunData, runDataSpy, { flush: 'sync' });
|
||||
watch(() => store.executionRunDataByNodeId.get('node-a')?.value, nodeARunDataSpy, {
|
||||
flush: 'sync',
|
||||
});
|
||||
watch(() => store.executionRunDataByNodeId.get('node-b')?.value, nodeBRunDataSpy, {
|
||||
flush: 'sync',
|
||||
});
|
||||
watch(() => store.executionStatusByNodeId.get('node-a')?.value, statusSpy, {
|
||||
flush: 'sync',
|
||||
});
|
||||
watch(() => store.executionIssuesByNodeName.get('NodeA')?.value, issuesSpy, {
|
||||
flush: 'sync',
|
||||
});
|
||||
|
||||
// A different status and error, so the structural gates see a change.
|
||||
mutate(
|
||||
store,
|
||||
createPayload('NodeA', { executionStatus: 'error', error: { message: 'boom' } as never }),
|
||||
);
|
||||
|
||||
expect(runDataSpy).toHaveBeenCalledTimes(1);
|
||||
expect(runDataSpy.mock.calls[0][0]).toBe(store.executionRunData);
|
||||
expect(nodeARunDataSpy).toHaveBeenCalledTimes(1);
|
||||
expect(nodeARunDataSpy.mock.calls[0][0]).toBe(store.executionRunData?.NodeA);
|
||||
expect(statusSpy).toHaveBeenCalledTimes(1);
|
||||
expect(statusSpy.mock.calls[0][0]).toBe('error');
|
||||
expect(issuesSpy).toHaveBeenCalledTimes(1);
|
||||
expect(issuesSpy.mock.calls[0][0]).toEqual(['boom']);
|
||||
expect(nodeBRunDataSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not write into run data that a previous execution shares', () => {
|
||||
const previous = useExecutionDataStore(createExecutionDataId('exec-previous'));
|
||||
previous.setExecution(createSeedExecution({ id: 'exec-previous' }));
|
||||
const sharedRunData = previous.execution?.data?.resultData.runData;
|
||||
const sharedNodeA = sharedRunData?.NodeA;
|
||||
const sharedNodeB = sharedRunData?.NodeB;
|
||||
const expected = structuredClone(sharedRunData);
|
||||
|
||||
// A partial run starts with the previous execution's runData object.
|
||||
const next = useExecutionDataStore(createExecutionDataId('exec-1'));
|
||||
next.setExecution(
|
||||
createSeedExecution({
|
||||
data: createRunExecutionData({ resultData: { runData: sharedRunData } }),
|
||||
}),
|
||||
);
|
||||
|
||||
next.markAsStopped({ status: 'canceled', startedAt: new Date(0), stoppedAt: new Date(1) });
|
||||
next.renameExecutionDataNode('NodeA', 'NodeC');
|
||||
|
||||
expect(next.executionRunData?.NodeC).toHaveLength(1);
|
||||
const runData = previous.execution?.data?.resultData.runData;
|
||||
expect(runData).toBe(sharedRunData);
|
||||
expect(Object.keys(runData ?? {})).toEqual(['NodeA', 'NodeB']);
|
||||
expect(runData?.NodeA).toBe(sharedNodeA);
|
||||
expect(runData?.NodeB).toBe(sharedNodeB);
|
||||
expect(runData).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disposeExecutionDataStore', () => {
|
||||
it('removes pinia state and recreate yields fresh state', () => {
|
||||
const id = createExecutionDataId('exec-disposable');
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { defineStore, getActivePinia } from 'pinia';
|
||||
import { STORES } from '@n8n/stores';
|
||||
import { computed, effectScope, inject, readonly, ref, shallowReactive } from 'vue';
|
||||
import {
|
||||
computed,
|
||||
effectScope,
|
||||
inject,
|
||||
readonly,
|
||||
ref,
|
||||
shallowReactive,
|
||||
shallowReadonly,
|
||||
shallowRef,
|
||||
} from 'vue';
|
||||
import type { ComputedRef } from 'vue';
|
||||
import { createEventHook, throttledWatch } from '@vueuse/core';
|
||||
import { structuralComputed } from '@n8n/composables/structuralComputed';
|
||||
@@ -11,7 +20,10 @@ import {
|
||||
SEND_AND_WAIT_OPERATION,
|
||||
isIndefiniteWait,
|
||||
type ExecutionStatus,
|
||||
type IConnections,
|
||||
type INode,
|
||||
type INodeExecutionData,
|
||||
type IPairedItemData,
|
||||
type IPinData,
|
||||
type IRunData,
|
||||
type IRunExecutionData,
|
||||
@@ -76,6 +88,119 @@ export function hasExecutionDataStore(id: ExecutionDataId): boolean {
|
||||
return getActivePinia()?.state.value[getExecutionDataStoreId(id)] !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the items of an array. Returns the same array when `fn` changes no item,
|
||||
* so consumers that gate on identity see no change.
|
||||
*/
|
||||
function mapArray<T>(items: T[], fn: (item: T) => T): T[] {
|
||||
let changed = false;
|
||||
const next = items.map((item) => {
|
||||
const mapped = fn(item);
|
||||
if (mapped !== item) changed = true;
|
||||
return mapped;
|
||||
});
|
||||
return changed ? next : items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the values of a record. Returns the same record when `fn` changes no
|
||||
* value, so consumers that gate on identity see no change.
|
||||
*/
|
||||
function mapRecord<T>(record: Record<string, T>, fn: (value: T) => T): Record<string, T> {
|
||||
let changed = false;
|
||||
const next = Object.fromEntries(
|
||||
Object.entries(record).map(([key, value]) => {
|
||||
const mapped = fn(value);
|
||||
if (mapped !== value) changed = true;
|
||||
return [key, mapped];
|
||||
}),
|
||||
);
|
||||
return changed ? next : record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy of `record` with the `oldName` entry moved to `newName`.
|
||||
* Returns the same record when it has no `oldName` entry.
|
||||
*/
|
||||
function renameRecordKey<T>(
|
||||
record: Record<string, T>,
|
||||
oldName: string,
|
||||
newName: string,
|
||||
): Record<string, T> {
|
||||
if (!record[oldName]) return record;
|
||||
const { [oldName]: value, ...rest } = record;
|
||||
return { ...rest, [newName]: value };
|
||||
}
|
||||
|
||||
function renameTaskSources(tasks: ITaskData[], oldName: string, newName: string): ITaskData[] {
|
||||
return mapArray(tasks, (task) => {
|
||||
if (!task.source) return task;
|
||||
const source = mapArray(task.source, (entry) =>
|
||||
entry?.previousNode === oldName ? { ...entry, previousNode: newName } : entry,
|
||||
);
|
||||
return source === task.source ? task : { ...task, source };
|
||||
});
|
||||
}
|
||||
|
||||
function renamePairedItemSource(
|
||||
pairedItem: IPairedItemData,
|
||||
oldName: string,
|
||||
newName: string,
|
||||
): IPairedItemData {
|
||||
if (pairedItem.sourceOverwrite?.previousNode !== oldName) return pairedItem;
|
||||
return {
|
||||
...pairedItem,
|
||||
sourceOverwrite: { ...pairedItem.sourceOverwrite, previousNode: newName },
|
||||
};
|
||||
}
|
||||
|
||||
function renameItemSources(
|
||||
items: INodeExecutionData[],
|
||||
oldName: string,
|
||||
newName: string,
|
||||
): INodeExecutionData[] {
|
||||
return mapArray(items, (item) => {
|
||||
const { pairedItem } = item;
|
||||
if (pairedItem === undefined || typeof pairedItem === 'number') return item;
|
||||
const next = Array.isArray(pairedItem)
|
||||
? mapArray(pairedItem, (entry) => renamePairedItemSource(entry, oldName, newName))
|
||||
: renamePairedItemSource(pairedItem, oldName, newName);
|
||||
return next === pairedItem ? item : { ...item, pairedItem: next };
|
||||
});
|
||||
}
|
||||
|
||||
function renameConnections(
|
||||
connections: IConnections,
|
||||
oldName: string,
|
||||
newName: string,
|
||||
): IConnections {
|
||||
return mapRecord(renameRecordKey(connections, oldName, newName), (outputs) =>
|
||||
mapRecord(outputs, (buckets) =>
|
||||
mapArray(buckets, (bucket) =>
|
||||
bucket
|
||||
? mapArray(bucket, (conn) => (conn.node === oldName ? { ...conn, node: newName } : conn))
|
||||
: bucket,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy of `execution` with new `data` and `resultData` objects.
|
||||
* Every branch that the changes do not replace keeps its identity.
|
||||
*/
|
||||
function withResultData(
|
||||
execution: IExecutionResponse,
|
||||
data: IRunExecutionData,
|
||||
resultDataChanges: Partial<IRunExecutionData['resultData']>,
|
||||
dataChanges: Partial<IRunExecutionData> = {},
|
||||
): IExecutionResponse {
|
||||
return {
|
||||
...execution,
|
||||
data: { ...data, ...dataChanges, resultData: { ...data.resultData, ...resultDataChanges } },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an execution data store keyed by execution id.
|
||||
*
|
||||
@@ -85,7 +210,7 @@ export function hasExecutionDataStore(id: ExecutionDataId): boolean {
|
||||
export function useExecutionDataStore(id: ExecutionDataId) {
|
||||
return defineStore(getExecutionDataStoreId(id), () => {
|
||||
const i18n = useI18n();
|
||||
const execution = ref<IExecutionResponse | null>(null);
|
||||
const execution = shallowRef<IExecutionResponse | null>(null);
|
||||
const executionResultDataLastUpdate = ref<number>();
|
||||
const executionStartedData = ref<ExecutionStartedDataValue>();
|
||||
const executionPairedItemMappings = ref<Record<string, Set<string>>>({});
|
||||
@@ -161,11 +286,9 @@ export function useExecutionDataStore(id: ExecutionDataId) {
|
||||
applyReconcileExecutionIssuesEntries(runData ? Object.keys(runData) : []);
|
||||
}
|
||||
|
||||
// Subscribe to the event hook rather than watching `executionRunData` —
|
||||
// renameExecutionDataNode mutates runData keys in place, leaving the
|
||||
// computed's reference unchanged. The event hook fires on every
|
||||
// mutation (including rename), so reconcile picks up key changes
|
||||
// regardless of reference identity.
|
||||
// Subscribe to the event hook rather than watching `executionRunData`.
|
||||
// The event hook fires once on every mutation (including rename), so
|
||||
// reconcile runs in step with the per-node-id projections below.
|
||||
void onExecutionDataChange.on(reconcileExecutionIssuesFromRunData);
|
||||
reconcileExecutionIssuesFromRunData();
|
||||
|
||||
@@ -176,7 +299,7 @@ export function useExecutionDataStore(id: ExecutionDataId) {
|
||||
// but consumers (canvas, NDV) need by-id lookup. The execution payload
|
||||
// embeds a `workflowData.nodes` snapshot, which is the authoritative
|
||||
// source for the name↔id mapping at execution time. `renameExecutionDataNode`
|
||||
// updates that snapshot in place, so renames propagate naturally.
|
||||
// replaces that snapshot with a renamed copy, so renames propagate naturally.
|
||||
//
|
||||
// Each map uses the same shallowReactive<Map<id, ComputedRef<T>>>
|
||||
// pattern as `executionIssuesByNodeName`: per-entry structuralComputed,
|
||||
@@ -313,9 +436,9 @@ export function useExecutionDataStore(id: ExecutionDataId) {
|
||||
|
||||
// Plain `computed` (Object.is) rather than `structuralComputed(..., isEqual)`:
|
||||
// per-task data can be megabytes for nodes with large outputs, and
|
||||
// every push replaces the runData array reference with new content
|
||||
// — so an isEqual gate would deep-compare megabytes and never
|
||||
// short-circuit. Reference identity is the right gate here.
|
||||
// every push replaces the node's task array with a new array — so an
|
||||
// isEqual gate would deep-compare megabytes and never short-circuit.
|
||||
// Reference identity is the right gate here.
|
||||
executionRunDataByNodeId.set(
|
||||
nodeId,
|
||||
computed(() => computeExecutionRunData(nodeId)),
|
||||
@@ -487,23 +610,21 @@ export function useExecutionDataStore(id: ExecutionDataId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits an in-place mutation of `execution.value` by signaling every
|
||||
* change channel downstream consumers rely on. Callers perform the deep,
|
||||
* in-place mutation first (channel 1 — nested reactivity for deep
|
||||
* watchers), then call this to cover the remaining three:
|
||||
* 2. replaces `execution.value` identity so identity-based watchers fire,
|
||||
* 3. bumps `executionResultDataLastUpdate`, driving the throttled rebuild
|
||||
* Commits a mutation of `execution.value` by signaling every change
|
||||
* channel downstream consumers rely on. Callers assign a new
|
||||
* `execution.value` first (channel 1 — new objects along the changed
|
||||
* path, so identity-based watchers fire). They never write into an
|
||||
* object that a previous `execution.value` can reach. Then they call
|
||||
* this to cover the remaining two:
|
||||
* 2. bumps `executionResultDataLastUpdate`, driving the throttled rebuild
|
||||
* of `executionRunDataOutputMapByNodeId`,
|
||||
* 4. emits `fireChange`, driving event-based reconciliation of the
|
||||
* 3. emits `fireChange`, driving event-based reconciliation of the
|
||||
* per-node projection maps.
|
||||
* Every in-place runData mutation must end by going through this helper —
|
||||
* skipping a channel causes subtle staleness.
|
||||
* Every mutation must end by going through this helper — skipping a
|
||||
* channel causes subtle staleness.
|
||||
*/
|
||||
function commitExecutionMutation(action: ChangeAction, nodeName?: string) {
|
||||
executionResultDataLastUpdate.value = Date.now();
|
||||
if (execution.value) {
|
||||
execution.value = { ...execution.value };
|
||||
}
|
||||
fireChange(action, nodeName);
|
||||
}
|
||||
|
||||
@@ -529,12 +650,16 @@ export function useExecutionDataStore(id: ExecutionDataId) {
|
||||
const { stripWaitingTaskData = true } = opts;
|
||||
// A run parked at worker shutdown has no node waiting: its last node finished for real.
|
||||
const isParked = value?.data?.waitReason === 'suspended';
|
||||
let next = value;
|
||||
if (stripWaitingTaskData && value?.data?.waitTill && !isParked) {
|
||||
delete value.data.resultData.runData[value.data.resultData.lastNodeExecuted as string];
|
||||
// Build a new runData: the caller still holds `value`.
|
||||
const { resultData } = value.data;
|
||||
const { [resultData.lastNodeExecuted ?? '']: _waiting, ...runData } = resultData.runData;
|
||||
next = withResultData(value, value.data, { runData });
|
||||
}
|
||||
execution.value = value;
|
||||
execution.value = next;
|
||||
executionResultDataLastUpdate.value = Date.now();
|
||||
executionPairedItemMappings.value = getPairedItemsMapping(value);
|
||||
executionPairedItemMappings.value = getPairedItemsMapping(next);
|
||||
executionStartedData.value = undefined;
|
||||
fireChange(value === null ? CHANGE_ACTION.DELETE : CHANGE_ACTION.UPDATE);
|
||||
}
|
||||
@@ -568,18 +693,15 @@ export function useExecutionDataStore(id: ExecutionDataId) {
|
||||
}
|
||||
|
||||
function updateNodeExecutionStatus(pushData: PushPayload<'nodeExecuteAfterData'>) {
|
||||
if (!execution.value?.data) return;
|
||||
const current = execution.value;
|
||||
if (!current?.data) return;
|
||||
|
||||
const { nodeName, data } = pushData;
|
||||
const isNodeWaiting = data.executionStatus === 'waiting';
|
||||
const { resultData } = current.data;
|
||||
|
||||
execution.value.data.resultData.lastNodeExecuted = nodeName;
|
||||
|
||||
if (execution.value.data.resultData.runData[nodeName] === undefined) {
|
||||
execution.value.data.resultData.runData[nodeName] = [];
|
||||
}
|
||||
|
||||
const tasksData = execution.value.data.resultData.runData[nodeName];
|
||||
// A copy, so the splice and push below never reach the previous array.
|
||||
const tasksData = [...(resultData.runData[nodeName] ?? [])];
|
||||
if (isNodeWaiting) {
|
||||
tasksData.push(data);
|
||||
} else {
|
||||
@@ -607,133 +729,113 @@ export function useExecutionDataStore(id: ExecutionDataId) {
|
||||
// The full redactionInfo (with accurate canReveal) arrives later via the
|
||||
// executionFinished REST call; this provides an early signal so the UI can
|
||||
// show the redacted state during live execution.
|
||||
if (!execution.value.data.redactionInfo?.isRedacted && data.data) {
|
||||
const dataChanges: Partial<IRunExecutionData> = {};
|
||||
if (!current.data.redactionInfo?.isRedacted && data.data) {
|
||||
for (const connectionType of Object.keys(data.data)) {
|
||||
for (const items of data.data[connectionType]) {
|
||||
if (items?.some((item) => item.redaction?.redacted)) {
|
||||
execution.value.data.redactionInfo = {
|
||||
isRedacted: true,
|
||||
reason:
|
||||
items.find((item) => item.redaction?.redacted)?.redaction?.reason ??
|
||||
'workflow_redaction_policy',
|
||||
canReveal: false,
|
||||
provisional: true,
|
||||
};
|
||||
break;
|
||||
}
|
||||
const redacted = data.data[connectionType]
|
||||
.flatMap((items) => items ?? [])
|
||||
.find((item) => item.redaction?.redacted);
|
||||
if (redacted) {
|
||||
dataChanges.redactionInfo = {
|
||||
isRedacted: true,
|
||||
reason: redacted.redaction?.reason ?? 'workflow_redaction_policy',
|
||||
canReveal: false,
|
||||
provisional: true,
|
||||
};
|
||||
break;
|
||||
}
|
||||
if (execution.value.data.redactionInfo?.isRedacted) break;
|
||||
}
|
||||
}
|
||||
|
||||
execution.value = withResultData(
|
||||
current,
|
||||
current.data,
|
||||
{ lastNodeExecuted: nodeName, runData: { ...resultData.runData, [nodeName]: tasksData } },
|
||||
dataChanges,
|
||||
);
|
||||
commitExecutionMutation(CHANGE_ACTION.UPDATE, nodeName);
|
||||
}
|
||||
|
||||
function updateNodeExecutionRunData(pushData: PushPayload<'nodeExecuteAfterData'>) {
|
||||
const tasksData = execution.value?.data?.resultData.runData[pushData.nodeName];
|
||||
const current = execution.value;
|
||||
const tasksData = current?.data?.resultData.runData[pushData.nodeName];
|
||||
const existingRunIndex =
|
||||
tasksData?.findIndex((item) => item.executionIndex === pushData.data.executionIndex) ?? -1;
|
||||
|
||||
if (tasksData?.[existingRunIndex]) {
|
||||
tasksData.splice(existingRunIndex, 1, pushData.data);
|
||||
if (current?.data && tasksData?.[existingRunIndex]) {
|
||||
execution.value = withResultData(current, current.data, {
|
||||
runData: {
|
||||
...current.data.resultData.runData,
|
||||
[pushData.nodeName]: tasksData.map((task, index) =>
|
||||
index === existingRunIndex ? pushData.data : task,
|
||||
),
|
||||
},
|
||||
});
|
||||
commitExecutionMutation(CHANGE_ACTION.UPDATE, pushData.nodeName);
|
||||
}
|
||||
}
|
||||
|
||||
function clearNodeExecutionData(nodeName: string) {
|
||||
if (!execution.value?.data) return;
|
||||
const { [nodeName]: _removed, ...remaining } = execution.value.data.resultData.runData;
|
||||
execution.value.data.resultData.runData = remaining;
|
||||
const current = execution.value;
|
||||
if (!current?.data) return;
|
||||
const { [nodeName]: _removed, ...remaining } = current.data.resultData.runData;
|
||||
execution.value = withResultData(current, current.data, { runData: remaining });
|
||||
commitExecutionMutation(CHANGE_ACTION.DELETE, nodeName);
|
||||
}
|
||||
|
||||
function renameExecutionDataNode(oldName: string, newName: string) {
|
||||
const data = execution.value?.data;
|
||||
if (!data) return;
|
||||
const current = execution.value;
|
||||
const data = current?.data;
|
||||
if (!current || !data) return;
|
||||
|
||||
// runData keys
|
||||
const changes: Partial<IRunExecutionData['resultData']> = {};
|
||||
|
||||
// runData keys, and task.source.previousNode in runData
|
||||
const runData = data.resultData?.runData;
|
||||
if (runData?.[oldName]) {
|
||||
runData[newName] = runData[oldName];
|
||||
delete runData[oldName];
|
||||
}
|
||||
|
||||
// pinData keys (in execution result, distinct from workflow document pinData)
|
||||
const pinData = data.resultData?.pinData;
|
||||
if (pinData?.[oldName]) {
|
||||
pinData[newName] = pinData[oldName];
|
||||
delete pinData[oldName];
|
||||
}
|
||||
|
||||
// pairedItem.sourceOverwrite.previousNode in pinData
|
||||
Object.values(pinData ?? {})
|
||||
.flatMap((items) =>
|
||||
items.flatMap((item) =>
|
||||
Array.isArray(item.pairedItem) ? item.pairedItem : [item.pairedItem],
|
||||
),
|
||||
)
|
||||
.forEach((pairedItem) => {
|
||||
if (
|
||||
typeof pairedItem === 'number' ||
|
||||
pairedItem?.sourceOverwrite?.previousNode !== oldName
|
||||
) {
|
||||
return;
|
||||
}
|
||||
pairedItem.sourceOverwrite.previousNode = newName;
|
||||
});
|
||||
|
||||
// task.source.previousNode in runData
|
||||
Object.values(runData ?? {})
|
||||
.flatMap((tasks) => tasks.flatMap((task) => task.source))
|
||||
.forEach((source) => {
|
||||
if (!source || source.previousNode !== oldName) return;
|
||||
source.previousNode = newName;
|
||||
});
|
||||
|
||||
// workflowData snapshot embedded in execution: nodes, connections, pinData
|
||||
const workflowData = execution.value?.workflowData;
|
||||
if (workflowData) {
|
||||
workflowData.nodes = workflowData.nodes.map((node) =>
|
||||
node.name === oldName ? { ...node, name: newName } : node,
|
||||
if (runData) {
|
||||
changes.runData = mapRecord(renameRecordKey(runData, oldName, newName), (tasks) =>
|
||||
renameTaskSources(tasks, oldName, newName),
|
||||
);
|
||||
if (workflowData.connections?.[oldName]) {
|
||||
workflowData.connections[newName] = workflowData.connections[oldName];
|
||||
delete workflowData.connections[oldName];
|
||||
}
|
||||
for (const sourceName of Object.keys(workflowData.connections ?? {})) {
|
||||
const outputs = workflowData.connections[sourceName];
|
||||
for (const connectionType of Object.keys(outputs)) {
|
||||
const outputBuckets = outputs[connectionType];
|
||||
for (const bucket of outputBuckets ?? []) {
|
||||
if (!bucket) continue;
|
||||
for (const conn of bucket) {
|
||||
if (conn?.node === oldName) conn.node = newName;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (workflowData.pinData?.[oldName]) {
|
||||
workflowData.pinData[newName] = workflowData.pinData[oldName];
|
||||
delete workflowData.pinData[oldName];
|
||||
}
|
||||
|
||||
// The snapshot above was mutated in place, so its object reference is
|
||||
// unchanged. Replace it so consumers that gate work on `workflowData`
|
||||
// identity detect the rename — notably the logs panel, which only
|
||||
// rebuilds its `Workflow` object (node names + connection topology)
|
||||
// when this reference changes. Without this the rebuilt run-data
|
||||
// (keyed by the new name) is matched against a stale topology (old
|
||||
// name), dropping renamed sub-nodes from the tree.
|
||||
if (execution.value) {
|
||||
execution.value.workflowData = { ...workflowData };
|
||||
}
|
||||
}
|
||||
|
||||
// executedNode reference
|
||||
if (execution.value && execution.value.executedNode === oldName) {
|
||||
execution.value.executedNode = newName;
|
||||
// pinData keys (in execution result, distinct from workflow document pinData),
|
||||
// and pairedItem.sourceOverwrite.previousNode in pinData
|
||||
const pinData = data.resultData?.pinData;
|
||||
if (pinData) {
|
||||
changes.pinData = mapRecord(renameRecordKey(pinData, oldName, newName), (items) =>
|
||||
renameItemSources(items, oldName, newName),
|
||||
);
|
||||
}
|
||||
|
||||
// workflowData snapshot embedded in execution: nodes, connections, pinData.
|
||||
// Always a new reference, so consumers that gate work on `workflowData`
|
||||
// identity detect the rename — notably the logs panel, which only
|
||||
// rebuilds its `Workflow` object (node names + connection topology)
|
||||
// when this reference changes. Without this the rebuilt run-data
|
||||
// (keyed by the new name) is matched against a stale topology (old
|
||||
// name), dropping renamed sub-nodes from the tree.
|
||||
const workflowData = current.workflowData;
|
||||
|
||||
execution.value = {
|
||||
...withResultData(current, data, changes),
|
||||
...(workflowData && {
|
||||
workflowData: {
|
||||
...workflowData,
|
||||
nodes: workflowData.nodes.map((node) =>
|
||||
node.name === oldName ? { ...node, name: newName } : node,
|
||||
),
|
||||
...(workflowData.connections && {
|
||||
connections: renameConnections(workflowData.connections, oldName, newName),
|
||||
}),
|
||||
...(workflowData.pinData && {
|
||||
pinData: renameRecordKey(workflowData.pinData, oldName, newName),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
// executedNode reference
|
||||
...(current.executedNode === oldName ? { executedNode: newName } : {}),
|
||||
};
|
||||
|
||||
commitExecutionMutation(CHANGE_ACTION.UPDATE);
|
||||
}
|
||||
|
||||
@@ -742,18 +844,21 @@ export function useExecutionDataStore(id: ExecutionDataId) {
|
||||
startedAt: Date;
|
||||
stoppedAt: Date;
|
||||
}) {
|
||||
if (!execution.value?.data) return;
|
||||
const runData = execution.value.data.resultData.runData;
|
||||
for (const nodeName in runData) {
|
||||
runData[nodeName] = runData[nodeName].filter(
|
||||
({ executionStatus }) => executionStatus === 'success',
|
||||
);
|
||||
}
|
||||
if (stopData) {
|
||||
execution.value.status = stopData.status;
|
||||
execution.value.startedAt = stopData.startedAt;
|
||||
execution.value.stoppedAt = stopData.stoppedAt;
|
||||
}
|
||||
const current = execution.value;
|
||||
if (!current?.data) return;
|
||||
execution.value = {
|
||||
...withResultData(current, current.data, {
|
||||
runData: mapRecord(current.data.resultData.runData, (tasks) => {
|
||||
const kept = tasks.filter(({ executionStatus }) => executionStatus === 'success');
|
||||
return kept.length === tasks.length ? tasks : kept;
|
||||
}),
|
||||
}),
|
||||
...(stopData && {
|
||||
status: stopData.status,
|
||||
startedAt: stopData.startedAt,
|
||||
stoppedAt: stopData.stoppedAt,
|
||||
}),
|
||||
};
|
||||
commitExecutionMutation(CHANGE_ACTION.UPDATE);
|
||||
}
|
||||
|
||||
@@ -768,7 +873,7 @@ export function useExecutionDataStore(id: ExecutionDataId) {
|
||||
return {
|
||||
executionId: id,
|
||||
// Read API
|
||||
execution: readonly(execution),
|
||||
execution: shallowReadonly(execution),
|
||||
executionResultDataLastUpdate: readonly(executionResultDataLastUpdate),
|
||||
executionRunData,
|
||||
executedNode,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { AgentNodeProgress } from '@n8n/api-types';
|
||||
import { setActivePinia, createPinia, getActivePinia } from 'pinia';
|
||||
import { defineComponent, provide, shallowRef } from 'vue';
|
||||
import { defineComponent, isProxy, provide, shallowRef, watch } from 'vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { WorkflowDocumentStoreKey } from '@/app/constants/injectionKeys';
|
||||
import { useWorkflowsStore } from '@/app/stores/workflows.store';
|
||||
@@ -24,7 +24,12 @@ import {
|
||||
useWorkflowDocumentStore,
|
||||
} from '@/app/stores/workflowDocument.store';
|
||||
import { useExecutionDataStore, createExecutionDataId } from '@/app/stores/executionData.store';
|
||||
import { createTestTaskData, createTestWorkflowExecutionResponse } from '@/__tests__/mocks';
|
||||
import {
|
||||
createTestNode,
|
||||
createTestTaskData,
|
||||
createTestWorkflow,
|
||||
createTestWorkflowExecutionResponse,
|
||||
} from '@/__tests__/mocks';
|
||||
import type { IExecutionResponse } from '@/features/execution/executions/executions.types';
|
||||
import {
|
||||
createRunExecutionData,
|
||||
@@ -345,6 +350,19 @@ describe('workflowExecutionState.store', () => {
|
||||
expect(placeholder.execution?.status).toBe('canceled');
|
||||
});
|
||||
|
||||
it('keeps the pending scaffold in step with the filtered IN_PROGRESS run data', () => {
|
||||
store.markExecutionAsStopped({
|
||||
status: 'canceled',
|
||||
startedAt: new Date('2023-01-01T10:00:00Z'),
|
||||
stoppedAt: new Date('2023-01-01T10:05:00Z'),
|
||||
mode: 'manual',
|
||||
});
|
||||
|
||||
const runData = store.pendingExecution?.data?.resultData.runData;
|
||||
expect(runData?.node1).toHaveLength(1);
|
||||
expect(runData?.node1[0].executionStatus).toBe('success');
|
||||
});
|
||||
|
||||
it('mirrors stopData onto the pendingExecution ref', () => {
|
||||
store.markExecutionAsStopped({
|
||||
status: 'canceled',
|
||||
@@ -641,6 +659,42 @@ describe('workflowExecutionState.store', () => {
|
||||
);
|
||||
expect(executionStateStore.activeExecutionRunData).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: 'updateNodeExecutionStatus' as const },
|
||||
{ name: 'updateNodeExecutionRunData' as const },
|
||||
])('fires a sync watcher with the new run data after $name', ({ name }) => {
|
||||
const executionStateStore = useWorkflowExecutionStateStore(
|
||||
createWorkflowDocumentId('wf-1'),
|
||||
);
|
||||
const executionDataStore = useExecutionDataStore(createExecutionDataId('exec-1'));
|
||||
executionDataStore.setExecution(
|
||||
makeExecution({
|
||||
id: 'exec-1',
|
||||
data: createRunExecutionData({
|
||||
resultData: {
|
||||
runData: {
|
||||
NodeA: [createTestTaskData({ executionIndex: 0, executionStatus: 'running' })],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
executionStateStore.setActiveExecutionId('exec-1');
|
||||
const spy = vi.fn();
|
||||
watch(() => executionStateStore.activeExecutionRunData, spy, { flush: 'sync' });
|
||||
|
||||
executionDataStore[name]({
|
||||
executionId: 'exec-1',
|
||||
nodeName: 'NodeA',
|
||||
data: createTestTaskData({ executionIndex: 0, executionStatus: 'error' }),
|
||||
itemCountByConnectionType: {},
|
||||
} as never);
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(spy.mock.calls[0][0]).toBe(executionDataStore.executionRunData);
|
||||
expect(spy.mock.calls[0][0].NodeA[0].executionStatus).toBe('error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('activeExecutionPinDataByNodeName', () => {
|
||||
@@ -1316,6 +1370,39 @@ describe('workflowExecutionState.store', () => {
|
||||
expect(uiStore.lastSelectedNode).toBe('Untouched');
|
||||
});
|
||||
|
||||
it('keeps a pending run renamed through promotion', () => {
|
||||
const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1'));
|
||||
executionStateStore.setWorkflowExecutionData(
|
||||
makeExecution({
|
||||
id: IN_PROGRESS_EXECUTION_ID,
|
||||
executedNode: 'Webhook',
|
||||
workflowData: createTestWorkflow({
|
||||
nodes: [createTestNode({ id: 'webhook', name: 'Webhook' })],
|
||||
}),
|
||||
data: createRunExecutionData({
|
||||
resultData: {
|
||||
runData: { Webhook: [createTestTaskData({ executionStatus: 'waiting' })] },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(isProxy(executionStateStore.pendingExecution)).toBe(false);
|
||||
|
||||
executionStateStore.renameActiveExecutionNode({ old: 'Webhook', new: 'Hook' });
|
||||
|
||||
const pending = executionStateStore.activeExecution;
|
||||
expect(pending?.workflowData.nodes.map((node) => node.name)).toEqual(['Hook']);
|
||||
expect(pending?.executedNode).toBe('Hook');
|
||||
|
||||
executionStateStore.promotePendingExecution('exec-1');
|
||||
|
||||
const promoted = useExecutionDataStore(createExecutionDataId('exec-1')).execution;
|
||||
expect(promoted?.workflowData.nodes.map((node) => node.name)).toEqual(['Hook']);
|
||||
expect(promoted?.executedNode).toBe('Hook');
|
||||
expect(Object.keys(promoted?.data?.resultData.runData ?? {})).toEqual(['Hook']);
|
||||
expect(isProxy(promoted?.data)).toBe(false);
|
||||
});
|
||||
|
||||
it('routes through the displayedExecutionId when active is cleared', () => {
|
||||
const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1'));
|
||||
const executionDataStore = useExecutionDataStore(createExecutionDataId('exec-1'));
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
readonly,
|
||||
ref,
|
||||
shallowReactive,
|
||||
shallowReadonly,
|
||||
shallowRef,
|
||||
type ComputedRef,
|
||||
} from 'vue';
|
||||
import { createEventHook } from '@vueuse/core';
|
||||
@@ -133,7 +135,7 @@ export function useWorkflowExecutionStateStore(id: WorkflowDocumentId) {
|
||||
* an execution id. `promotePendingExecution(executionId)` migrates this
|
||||
* payload into a real execution-data store keyed by the new id.
|
||||
*/
|
||||
const pendingExecution = ref<IExecutionResponse | null>(null);
|
||||
const pendingExecution = shallowRef<IExecutionResponse | null>(null);
|
||||
const executionWaitingForWebhook = ref(false);
|
||||
const isInDebugMode = ref(false);
|
||||
const chatMessages = ref<string[]>([]);
|
||||
@@ -299,9 +301,8 @@ export function useWorkflowExecutionStateStore(id: WorkflowDocumentId) {
|
||||
* -> the displayed executionData store (preserved after active is cleared)
|
||||
* - otherwise null
|
||||
*
|
||||
* Typed as a mutable `IExecutionResponse` for consumers (the executionData
|
||||
* store exposes a readonly ref); treat it as read-only — all writes go
|
||||
* through the store actions.
|
||||
* Typed as a mutable `IExecutionResponse` for consumers; treat it as
|
||||
* read-only — all writes go through the store actions.
|
||||
*/
|
||||
const activeExecution = computed<IExecutionResponse | null>(() => {
|
||||
if (activeExecutionId.value === null) return pendingExecution.value;
|
||||
@@ -312,12 +313,9 @@ export function useWorkflowExecutionStateStore(id: WorkflowDocumentId) {
|
||||
? displayedExecutionId.value
|
||||
: undefined;
|
||||
if (executionId === undefined) return null;
|
||||
const executionDataStore = useExecutionDataStore(createExecutionDataId(executionId));
|
||||
// Track the timestamp so in-place mutations that preserve the execution
|
||||
// object reference still propagate to consumers (same defensive pattern
|
||||
// as `activeExecutionRunData`).
|
||||
void executionDataStore.executionResultDataLastUpdate;
|
||||
return executionDataStore.execution as IExecutionResponse | null;
|
||||
// Every executionData store mutation replaces the execution object, so
|
||||
// its identity is the change signal.
|
||||
return useExecutionDataStore(createExecutionDataId(executionId)).execution;
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -374,10 +372,9 @@ export function useWorkflowExecutionStateStore(id: WorkflowDocumentId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Every executionData store mutation replaces the runData object, so
|
||||
// its identity is the change signal.
|
||||
const executionDataStore = useExecutionDataStore(createExecutionDataId(executionId));
|
||||
// Track the timestamp so in-place mutations to runData (which keep
|
||||
// the runData object reference) still propagate.
|
||||
void executionDataStore.executionResultDataLastUpdate;
|
||||
|
||||
return dropRunDataOfReplacedNodes(
|
||||
executionDataStore.executionRunData,
|
||||
@@ -721,6 +718,21 @@ export function useWorkflowExecutionStateStore(id: WorkflowDocumentId) {
|
||||
fireChange(CHANGE_ACTION.UPDATE, 'pendingExecution');
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the IN_PROGRESS store's execution into the pending scaffold.
|
||||
* Writers to the IN_PROGRESS store call this while a run is pending, so
|
||||
* `activeExecution` and `promotePendingExecution` see their change.
|
||||
* No-op when there is no pending scaffold.
|
||||
*/
|
||||
function syncPendingExecution() {
|
||||
if (!pendingExecution.value) return;
|
||||
setPendingExecution(
|
||||
useExecutionDataStore(
|
||||
createExecutionDataId(IN_PROGRESS_EXECUTION_ID),
|
||||
).getExecutionSnapshot(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Promotes the pending-execution scaffold into a backend-keyed
|
||||
* executionData store, then sets `activeExecutionId` to the new id.
|
||||
@@ -945,6 +957,9 @@ export function useWorkflowExecutionStateStore(id: WorkflowDocumentId) {
|
||||
nameData.new,
|
||||
);
|
||||
}
|
||||
if (activeExecutionId.value === null) {
|
||||
syncPendingExecution();
|
||||
}
|
||||
|
||||
renameExecutionStateNode(nameData.old, nameData.new);
|
||||
|
||||
@@ -1021,6 +1036,7 @@ export function useWorkflowExecutionStateStore(id: WorkflowDocumentId) {
|
||||
);
|
||||
executionDataStore.clearExecutionStartedData();
|
||||
executionDataStore.markAsStopped(stopData);
|
||||
syncPendingExecution();
|
||||
if (stopData) {
|
||||
applyStopDataToPendingExecution(stopData);
|
||||
}
|
||||
@@ -1045,7 +1061,7 @@ export function useWorkflowExecutionStateStore(id: WorkflowDocumentId) {
|
||||
activeExecutionId: readonly(activeExecutionId),
|
||||
displayedExecutionId: readonly(displayedExecutionId),
|
||||
previousExecutionId: readonly(previousExecutionId),
|
||||
pendingExecution: readonly(pendingExecution),
|
||||
pendingExecution: shallowReadonly(pendingExecution),
|
||||
executionWaitingForWebhook: readonly(executionWaitingForWebhook),
|
||||
isInDebugMode: readonly(isInDebugMode),
|
||||
chatMessages: readonly(chatMessages),
|
||||
|
||||
+10
-9
@@ -389,9 +389,13 @@ describe('LogsPanel', () => {
|
||||
expect(rendered.getByText(/Running/)).toBeInTheDocument();
|
||||
expect(rendered.queryByText('AI Agent')).not.toBeInTheDocument();
|
||||
|
||||
useExecutionDataStore(
|
||||
createExecutionDataId(IN_PROGRESS_EXECUTION_ID),
|
||||
).addNodeExecutionStartedData({
|
||||
// The run gets its id before node pushes arrive, as in the executionStarted handler.
|
||||
const executionStateStore = useWorkflowExecutionStateStore(
|
||||
createWorkflowDocumentId(workflowsStore.workflowId),
|
||||
);
|
||||
executionStateStore.promotePendingExecution('567');
|
||||
|
||||
useExecutionDataStore(createExecutionDataId('567')).addNodeExecutionStartedData({
|
||||
nodeName: 'AI Agent',
|
||||
sequenceNumber: 0,
|
||||
executionId: '567',
|
||||
@@ -410,9 +414,7 @@ describe('LogsPanel', () => {
|
||||
expect(lastTreeItem.getByText('AI Agent')).toBeInTheDocument();
|
||||
expect(lastTreeItem.getByText(/Running/)).toBeInTheDocument();
|
||||
|
||||
useExecutionDataStore(
|
||||
createExecutionDataId(IN_PROGRESS_EXECUTION_ID),
|
||||
).updateNodeExecutionStatus({
|
||||
useExecutionDataStore(createExecutionDataId('567')).updateNodeExecutionStatus({
|
||||
nodeName: 'AI Agent',
|
||||
executionId: '567',
|
||||
itemCountByConnectionType: { ai_agent: [1] },
|
||||
@@ -431,10 +433,9 @@ describe('LogsPanel', () => {
|
||||
expect(await lastTreeItem.findByText('Success')).toBeInTheDocument();
|
||||
expect(lastTreeItem.getByText('in 33ms')).toBeInTheDocument();
|
||||
|
||||
executionStateStore.setActiveExecutionId(undefined);
|
||||
setExecutionData({
|
||||
...useExecutionDataStore(
|
||||
createExecutionDataId(IN_PROGRESS_EXECUTION_ID),
|
||||
).getExecutionSnapshot()!,
|
||||
...useExecutionDataStore(createExecutionDataId('567')).getExecutionSnapshot()!,
|
||||
id: '1234',
|
||||
status: 'success',
|
||||
finished: true,
|
||||
|
||||
+22
-1
@@ -21,7 +21,8 @@ import {
|
||||
createWorkflowDocumentId,
|
||||
useWorkflowDocumentStore,
|
||||
} from '@/app/stores/workflowDocument.store';
|
||||
import { computed } from 'vue';
|
||||
import { computed, isProxy } from 'vue';
|
||||
import { createExecutionDataId, useExecutionDataStore } from '@/app/stores/executionData.store';
|
||||
|
||||
vi.mock('@n8n/composables/useToast');
|
||||
|
||||
@@ -72,6 +73,26 @@ describe(useLogsExecutionData, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('execution', () => {
|
||||
it("should hold the store's execution object, not a copy or a proxy", async () => {
|
||||
executionStateStore.setWorkflowExecutionData(
|
||||
createTestWorkflowExecutionResponse({
|
||||
id: 'exec-1',
|
||||
data: createRunExecutionData({ resultData: { runData: { n0: [createTestTaskData()] } } }),
|
||||
workflowData: createTestWorkflow({ nodes: [createTestNode({ name: 'n0' })] }),
|
||||
}),
|
||||
);
|
||||
|
||||
const { execution } = useLogsExecutionData();
|
||||
|
||||
await waitAllPromises();
|
||||
expect(execution.value).toBe(
|
||||
useExecutionDataStore(createExecutionDataId('exec-1')).execution,
|
||||
);
|
||||
expect(isProxy(execution.value)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('latestNodeNameById', () => {
|
||||
/**
|
||||
* The logs panel pairs the execution snapshot against the live canvas by node
|
||||
|
||||
+5
-4
@@ -1,4 +1,4 @@
|
||||
import { watch, computed, ref, type ComputedRef } from 'vue';
|
||||
import { watch, computed, ref, shallowRef, type ComputedRef } from 'vue';
|
||||
import type { IExecutionResponse } from '@/features/execution/executions/executions.types';
|
||||
import {
|
||||
Workflow,
|
||||
@@ -11,7 +11,6 @@ import { injectWorkflowExecutionStateStore } from '@/app/stores/workflowExecutio
|
||||
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
|
||||
import { useNodeHelpers } from '@/app/composables/useNodeHelpers';
|
||||
import {
|
||||
copyExecutionData,
|
||||
createLogTree,
|
||||
findSubExecutionLocator,
|
||||
mergeStartData,
|
||||
@@ -42,7 +41,9 @@ export function useLogsExecutionData({ isEnabled, filter }: UseLogsExecutionData
|
||||
const currentExecution = computed(() => workflowExecutionStateStore.value.activeExecution);
|
||||
const toast = useToast();
|
||||
|
||||
const state = ref<
|
||||
// Shallow: `response` is the store's execution object. The store never
|
||||
// mutates it, so it is a stable snapshot of the run.
|
||||
const state = shallowRef<
|
||||
| { response: IExecutionResponse; startData: { [nodeName: string]: ITaskStartedData[] } }
|
||||
| undefined
|
||||
>();
|
||||
@@ -166,7 +167,7 @@ export function useLogsExecutionData({ isEnabled, filter }: UseLogsExecutionData
|
||||
currentExecution.value === null
|
||||
? undefined
|
||||
: {
|
||||
response: copyExecutionData(currentExecution.value),
|
||||
response: currentExecution.value,
|
||||
startData: workflowExecutionStateStore.value.activeExecutionStartedData?.[1] ?? {},
|
||||
};
|
||||
|
||||
|
||||
@@ -1580,6 +1580,27 @@ describe(mergeStartData, () => {
|
||||
expect(mergeStartData({}, response)).toEqual(response);
|
||||
});
|
||||
|
||||
it('should add runs in start data when the execution response has no run data', () => {
|
||||
const response = createTestWorkflowExecutionResponse({ data: undefined });
|
||||
const startData: { [nodeName: string]: ITaskStartedData[] } = {
|
||||
A: [{ startTime: 1, executionIndex: 0, source: [] }],
|
||||
};
|
||||
|
||||
const merged = mergeStartData(startData, response);
|
||||
|
||||
expect(merged.data?.resultData.runData.A).toEqual([
|
||||
{ ...startData.A[0], executionStatus: 'running', executionTime: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should accept an execution response whose resultData has no runData', () => {
|
||||
const response = createTestWorkflowExecutionResponse({
|
||||
data: { resultData: {} } as IExecutionResponse['data'],
|
||||
});
|
||||
|
||||
expect(mergeStartData({}, response).data?.resultData.runData).toEqual({});
|
||||
});
|
||||
|
||||
it('should add runs in start data to the execution response as running state', () => {
|
||||
const response = createTestWorkflowExecutionResponse({
|
||||
data: createRunExecutionData({
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
type INodeExecutionData,
|
||||
type IWorkflowGroup,
|
||||
createEmptyRunExecutionData,
|
||||
createRunExecutionData,
|
||||
isEmptyGroupAnchor,
|
||||
} from 'n8n-workflow';
|
||||
import {
|
||||
@@ -715,18 +714,17 @@ export function mergeStartData(
|
||||
startData: { [nodeName: string]: ITaskStartedData[] },
|
||||
response: IExecutionResponse,
|
||||
): IExecutionResponse {
|
||||
if (!response.data) {
|
||||
return response;
|
||||
}
|
||||
// The response is the store's execution object as is, so `data` and
|
||||
// `runData` can be missing.
|
||||
const data = response.data ?? createEmptyRunExecutionData();
|
||||
const responseRunData = data.resultData.runData ?? {};
|
||||
|
||||
const nodeNames = [
|
||||
...new Set(
|
||||
Object.keys(startData).concat(Object.keys(response.data.resultData.runData)),
|
||||
).values(),
|
||||
...new Set(Object.keys(startData).concat(Object.keys(responseRunData))).values(),
|
||||
];
|
||||
const runData = Object.fromEntries(
|
||||
nodeNames.map<[string, ITaskData[]]>((nodeName) => {
|
||||
const tasks = response.data?.resultData.runData[nodeName] ?? [];
|
||||
const tasks = responseRunData[nodeName] ?? [];
|
||||
|
||||
const mergedTasks = tasks.concat(
|
||||
(startData[nodeName] ?? [])
|
||||
@@ -761,9 +759,9 @@ export function mergeStartData(
|
||||
return {
|
||||
...response,
|
||||
data: {
|
||||
...response.data,
|
||||
...data,
|
||||
resultData: {
|
||||
...response.data.resultData,
|
||||
...data.resultData,
|
||||
runData,
|
||||
},
|
||||
},
|
||||
@@ -1002,24 +1000,3 @@ export function isSubNodeLog(logEntry: LogEntry): boolean {
|
||||
export function isPlaceholderLog(treeNode: LogEntry): boolean {
|
||||
return isNodeLog(treeNode) && treeNode.runData === undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a copy of execution data just deep enough to keeps logs immutable and not reactive.
|
||||
* We deliberately avoid full deep copy here for performance reason.
|
||||
*
|
||||
* TODO: use shallowRef() for execution data in workflows store to make this unnecessary.
|
||||
*/
|
||||
export function copyExecutionData(executionData: IExecutionResponse): IExecutionResponse {
|
||||
return {
|
||||
...executionData,
|
||||
data: createRunExecutionData({
|
||||
...executionData.data,
|
||||
resultData: {
|
||||
...executionData.data?.resultData,
|
||||
runData: Object.fromEntries(
|
||||
Object.entries(executionData.data?.resultData.runData ?? {}).map(([k, v]) => [k, [...v]]),
|
||||
),
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user