refactor(editor): Scope execution-state reads by the injected workflow document (no-changelog) (#32219)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Alex Grozav
2026-06-15 10:30:53 +03:00
committed by GitHub
parent 4c9e42db75
commit 3d59a5e2e9
44 changed files with 775 additions and 302 deletions
@@ -127,6 +127,64 @@ export default defineConfig(
message:
'Do not call workflowsStore.setWorkflowId() — the current workflow id is derived from the route (useWorkflowId())',
},
// Guard: the legacy execution bridge on workflowsStore resolves by the
// global workflow id, which silently reads the wrong instance inside
// scoped hosts (execution preview, embedded editors). Read through
// injectWorkflowExecutionStateStore() (or the documentId-keyed
// useWorkflowExecutionStateStore) instead.
{
selector:
"MemberExpression[property.name='getWorkflowExecution'][object.name='workflowsStore']",
message:
'Use injectWorkflowExecutionStateStore().value.activeExecution instead of workflowsStore.getWorkflowExecution — the bridge resolves by global workflow id and reads the wrong instance inside scoped hosts',
},
{
selector:
"MemberExpression[property.name='workflowExecutionData'][object.name='workflowsStore']",
message:
'Use injectWorkflowExecutionStateStore().value.activeExecution instead of workflowsStore.workflowExecutionData — the bridge resolves by global workflow id and reads the wrong instance inside scoped hosts',
},
{
selector:
"MemberExpression[property.name='getWorkflowRunData'][object.name='workflowsStore']",
message:
'Use injectWorkflowExecutionStateStore().value.activeExecutionRunData instead of workflowsStore.getWorkflowRunData',
},
{
selector: "MemberExpression[property.name='executedNode'][object.name='workflowsStore']",
message:
'Use injectWorkflowExecutionStateStore().value.activeExecutionExecutedNode instead of workflowsStore.executedNode',
},
{
selector:
"MemberExpression[property.name='workflowExecutionStartedData'][object.name='workflowsStore']",
message:
'Use injectWorkflowExecutionStateStore().value.activeExecutionStartedData instead of workflowsStore.workflowExecutionStartedData',
},
{
selector:
"MemberExpression[property.name='workflowExecutionResultDataLastUpdate'][object.name='workflowsStore']",
message:
'Use injectWorkflowExecutionStateStore().value.activeExecutionResultDataLastUpdate instead of workflowsStore.workflowExecutionResultDataLastUpdate',
},
{
selector:
"MemberExpression[property.name='workflowExecutionPairedItemMappings'][object.name='workflowsStore']",
message:
'Use injectWorkflowExecutionStateStore().value.activeExecutionPairedItemMappings instead of workflowsStore.workflowExecutionPairedItemMappings',
},
{
selector:
"MemberExpression[property.name='lastSuccessfulExecution'][object.name='workflowsStore']",
message:
'Use injectWorkflowExecutionStateStore().value.lastSuccessfulExecution instead of workflowsStore.lastSuccessfulExecution',
},
{
selector:
"MemberExpression[property.name='getWorkflowResultDataByNodeName'][object.name='workflowsStore']",
message:
'Use injectWorkflowExecutionStateStore().value.getActiveExecutionRunDataByNodeName() instead of workflowsStore.getWorkflowResultDataByNodeName()',
},
],
// TODO: Remove these
'n8n-local-rules/no-internal-package-import': 'warn',
@@ -33,15 +33,13 @@ import { type PinDataSource, usePinnedData } from '@/app/composables/usePinnedDa
import { useTelemetry } from '@/app/composables/useTelemetry';
import { useToast } from '@/app/composables/useToast';
import { useWorkflowHelpers } from '@/app/composables/useWorkflowHelpers';
import { useWorkflowNormalization } from '@/app/composables/useWorkflowNormalization';
import { getExecutionErrorToastConfiguration } from '@/features/execution/executions/executions.utils';
import {
EnterpriseEditionFeature,
FORM_TRIGGER_NODE_TYPE,
MCP_TRIGGER_NODE_TYPE,
STICKY_NODE_TYPE,
UPDATE_WEBHOOK_ID_NODE_TYPES,
VIEWS,
WEBHOOK_NODE_TYPE,
} from '@/app/constants';
import {
AddConnectionCommand,
@@ -126,7 +124,6 @@ import {
TelemetryHelpers,
isCommunityPackageName,
isHitlToolType,
resolveNodeWebhookId,
} from 'n8n-workflow';
import { computed, nextTick, ref, type DeepReadonly } from 'vue';
import { useUniqueNodeName } from '@/app/composables/useUniqueNodeName';
@@ -212,6 +209,12 @@ export function useCanvasOperations() {
const toast = useToast();
const workflowHelpers = useWorkflowHelpers();
const nodeHelpers = useNodeHelpers();
const {
requireNodeTypeDescription,
resolveNodeParameters,
resolveNodeWebhook,
normalizeWorkflowData,
} = useWorkflowNormalization();
const telemetry = useTelemetry();
const externalHooks = useExternalHooks();
const clipboard = useClipboard();
@@ -866,26 +869,6 @@ export function useCanvasOperations() {
}
}
function requireNodeTypeDescription(
type: INodeUi['type'],
version?: INodeUi['typeVersion'],
): INodeTypeDescription {
return (
nodeTypesStore.getNodeType(type, version) ??
nodeTypesStore.communityNodeType(type)?.nodeDescription ?? {
properties: [],
displayName: type,
name: type,
group: [],
description: '',
version: version ?? 1,
defaults: {},
inputs: [],
outputs: [],
}
);
}
async function addNodes(
nodes: AddedNodesAndConnections['nodes'],
{ viewport, ...options }: AddNodesOptions = {},
@@ -1315,18 +1298,6 @@ export function useCanvasOperations() {
return nodeVersion;
}
function resolveNodeParameters(node: INodeUi, nodeTypeDescription: INodeTypeDescription) {
const nodeParameters = NodeHelpers.getNodeParameters(
nodeTypeDescription?.properties ?? [],
node.parameters,
true,
false,
node,
nodeTypeDescription,
);
node.parameters = nodeParameters ?? {};
}
function resolveNodePosition(
node: Omit<INodeUi, 'position'> & { position?: INodeUi['position'] },
nodeTypeDescription: INodeTypeDescription,
@@ -1617,18 +1588,6 @@ export function useCanvasOperations() {
node.name = uniqueNodeName(localizedName);
}
function resolveNodeWebhook(node: INodeUi, nodeTypeDescription: INodeTypeDescription) {
resolveNodeWebhookId(node, nodeTypeDescription);
// if it's a webhook and the path is empty set the UUID as the default path
if (
[WEBHOOK_NODE_TYPE, FORM_TRIGGER_NODE_TYPE, MCP_TRIGGER_NODE_TYPE].includes(node.type) &&
node.parameters.path === ''
) {
node.parameters.path = node.webhookId as string;
}
}
/**
* Gets the bounding rectangle of a node including its size
*/
@@ -2492,25 +2451,10 @@ export function useCanvasOperations() {
const { workflowDocumentStore: initializedDocumentStore } =
await workflowHelpers.initState(data);
// Filter out nodes with missing type to prevent canvas rendering crashes
const validNodes = data.nodes
.filter((node) => !!node.type)
.map((node) => ({ ...node, position: ensureNodePosition(node.position) }));
const validNodeNames = validNodes.map((node) => node.name);
const { nodes, connections } = normalizeWorkflowData(data);
validNodes.forEach((node) => {
const nodeTypeDescription = requireNodeTypeDescription(node.type, node.typeVersion);
const isInstalledNode = nodeTypesStore.getIsNodeInstalled(node.type);
nodeHelpers.matchCredentials(node);
// skip this step because nodeTypeDescription is missing for unknown nodes
if (isInstalledNode) {
resolveNodeParameters(node, nodeTypeDescription);
resolveNodeWebhook(node, nodeTypeDescription);
}
});
initializedDocumentStore.setNodes(validNodes);
initializedDocumentStore.setConnections(sanitizeConnections(data.connections, validNodeNames));
initializedDocumentStore.setNodes(nodes);
initializedDocumentStore.setConnections(connections);
return { workflowDocumentStore: initializedDocumentStore };
}
@@ -29,6 +29,22 @@ vi.mock('@/app/stores/workflowDocument.store', async (importOriginal) => {
};
});
let mockExecution: IExecutionResponse | null = null;
vi.mock('@/app/stores/workflowExecutionState.store', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
injectWorkflowExecutionStateStore: vi.fn(() => ({
// Plain accessor (not `computed`) so per-test reassignment of the
// non-reactive `mockExecution` is always picked up.
get value() {
return { activeExecution: mockExecution };
},
})),
};
});
describe('useDataSchema', () => {
const getSchema = useDataSchema().getSchema;
@@ -850,11 +866,8 @@ describe('useDataSchema', () => {
],
])(
'should return correct output %s',
([node, runIndex, outputIndex, getWorkflowExecution], output) => {
vi.mocked(useWorkflowsStore).mockReturnValue({
...useWorkflowsStore(),
getWorkflowExecution: getWorkflowExecution as IExecutionResponse,
});
([node, runIndex, outputIndex, workflowExecution], output) => {
mockExecution = (workflowExecution ?? null) as IExecutionResponse | null;
expect(getNodeInputData(node as INodeUi, runIndex, outputIndex)).toEqual(output);
},
);
@@ -7,7 +7,7 @@ import type {
Schema,
SchemaType,
} from '@/Interface';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { injectWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { generatePath, getNodeParentExpression } from '@/app/utils/mappingUtils';
import { isObject } from '@/app/utils/objectUtils';
@@ -30,6 +30,7 @@ import { DEFAULT_SETTINGS } from '@/app/constants/workflows';
export function useDataSchema() {
const workflowDocumentStore = injectWorkflowDocumentStore();
const workflowExecutionStateStore = injectWorkflowExecutionStateStore();
function getSchema(
input: Optional<Primitives | object>,
@@ -180,15 +181,15 @@ export function useDataSchema() {
runIndex = 0,
outputIndex = 0,
): INodeExecutionData[] {
const { getWorkflowExecution } = useWorkflowsStore();
if (node === null) {
return [];
}
if (getWorkflowExecution === null) {
const workflowExecution = workflowExecutionStateStore.value.activeExecution;
if (workflowExecution === null) {
return [];
}
const executionData = getWorkflowExecution.data;
const executionData = workflowExecution.data;
if (!executionData?.resultData) {
// unknown status
return [];
@@ -8,11 +8,11 @@ import {
type Undoable,
} from '@/app/models/history';
import { useHistoryStore } from '@/app/stores/history.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import {
useWorkflowDocumentStore,
type WorkflowDocumentId,
} from '@/app/stores/workflowDocument.store';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import {
CanvasNodeDirtiness,
type CanvasNodeDirtinessType,
@@ -137,11 +137,13 @@ function findLoop(
*/
export function useNodeDirtiness(workflowDocumentId: MaybeRefOrGetter<WorkflowDocumentId>) {
const historyStore = useHistoryStore();
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = computed(() =>
useWorkflowDocumentStore(toValue(workflowDocumentId)),
);
const executionStateStore = computed(() =>
useWorkflowExecutionStateStore(toValue(workflowDocumentId)),
);
function getIncomingConnections(nodeName: string): INodeConnections {
return workflowDocumentStore.value.incomingConnectionsByNodeName(nodeName);
@@ -255,7 +257,7 @@ export function useNodeDirtiness(workflowDocumentId: MaybeRefOrGetter<WorkflowDo
const dirtinessByName = computed(() => {
const dirtiness: Record<string, CanvasNodeDirtinessType | undefined> = {};
const runDataByNode = workflowsStore.getWorkflowRunData ?? {};
const runDataByNode = executionStateStore.value.activeExecutionRunData ?? {};
function setDirtiness(nodeName: string, value: CanvasNodeDirtinessType) {
dirtiness[nodeName] = dirtiness[nodeName] ?? value;
@@ -102,6 +102,18 @@ vi.mock('@/app/stores/workflows.store', () => ({
vi.mock('@/app/stores/workflowExecutionState.store', () => ({
useWorkflowExecutionStateStore: vi.fn().mockReturnValue(mockWorkflowExecutionStateStore),
injectWorkflowExecutionStateStore: vi.fn(() => ({
// Plain accessor so per-test reassignment of the mock fields is always
// picked up.
get value() {
return {
...mockWorkflowExecutionStateStore,
get activeExecutionExecutedNode() {
return mockWorkflowsStore.executedNode;
},
};
},
})),
}));
vi.mock('@/app/stores/nodeTypes.store', () => ({
@@ -12,7 +12,7 @@ import {
import type { INodeUi, IUpdateInformation } from '@/Interface';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { injectWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { injectNDVStore } from '@/features/ndv/shared/ndv.store';
import { useUIStore } from '@/app/stores/ui.store';
@@ -102,9 +102,7 @@ export function useNodeExecution(
const uiStore = useUIStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const workflowExecutionStateStore = computed(() =>
useWorkflowExecutionStateStore(workflowDocumentStore.value.documentId),
);
const workflowExecutionStateStore = injectWorkflowExecutionStateStore();
const { runWorkflow, stopCurrentExecution } = useRunWorkflow({ router });
const nodeHelpers = useNodeHelpers();
@@ -146,7 +144,7 @@ export function useNodeExecution(
const isNodeRunning = computed(() => {
if (!workflowExecutionStateStore.value.isWorkflowRunning || codeGenerationInProgress.value)
return false;
const triggeredNode = workflowsStore.executedNode;
const triggeredNode = workflowExecutionStateStore.value.activeExecutionExecutedNode;
return (
workflowExecutionStateStore.value.executingNode.isNodeExecuting(nodeRef.value?.name ?? '') ||
triggeredNode === nodeRef.value?.name
@@ -155,7 +153,7 @@ export function useNodeExecution(
const isListening = computed(() => {
const waitingOnWebhook = workflowExecutionStateStore.value.executionWaitingForWebhook;
const executedNode = workflowsStore.executedNode;
const executedNode = workflowExecutionStateStore.value.activeExecutionExecutedNode;
return (
!!nodeRef.value &&
@@ -58,6 +58,22 @@ vi.mock('@/app/stores/workflowDocument.store', async () => {
};
});
const mockInjectedRunData = { value: null as IRunData | null };
vi.mock('@/app/stores/workflowExecutionState.store', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
injectWorkflowExecutionStateStore: vi.fn(() => ({
// Plain accessor (not `computed`) so per-test reassignment of the
// non-reactive holder is always picked up.
get value() {
return { activeExecutionRunData: mockInjectedRunData.value };
},
})),
};
});
describe('useNodeHelpers()', () => {
beforeAll(() => {
setActivePinia(createTestingPinia());
@@ -72,6 +88,7 @@ describe('useNodeHelpers()', () => {
afterEach(() => {
vi.clearAllMocks();
mockInjectedRunData.value = null;
// Clear mock document store state
for (const key of Object.keys(mockDocumentStoreUsedCredentials)) {
delete mockDocumentStoreUsedCredentials[key];
@@ -372,7 +389,7 @@ describe('useNodeHelpers()', () => {
});
it('should return an empty array when runData is not available', () => {
mockedStore(useWorkflowsStore).getWorkflowRunData = null;
mockInjectedRunData.value = null;
const { getNodeInputData } = useNodeHelpers();
const node = createTestNode({
name: 'test',
@@ -385,7 +402,7 @@ describe('useNodeHelpers()', () => {
it('should return an empty array when taskData is unavailable', () => {
const nodeName = 'Code';
mockedStore(useWorkflowsStore).getWorkflowRunData = mock<IRunData>({
mockInjectedRunData.value = mock<IRunData>({
[nodeName]: [],
});
const { getNodeInputData } = useNodeHelpers();
@@ -400,7 +417,7 @@ describe('useNodeHelpers()', () => {
it('should return an empty array when taskData.data is unavailable', () => {
const nodeName = 'Code';
mockedStore(useWorkflowsStore).getWorkflowRunData = mock<IRunData>({
mockInjectedRunData.value = mock<IRunData>({
[nodeName]: [{ data: undefined }],
});
const { getNodeInputData } = useNodeHelpers();
@@ -416,7 +433,7 @@ describe('useNodeHelpers()', () => {
it('should return input data from inputOverride', () => {
const nodeName = 'Code';
const data = [{ json: { hello: 'world' } }];
mockedStore(useWorkflowsStore).getWorkflowRunData = mock<IRunData>({
mockInjectedRunData.value = mock<IRunData>({
[nodeName]: [
{
inputOverride: {
@@ -439,7 +456,7 @@ describe('useNodeHelpers()', () => {
'should return input data for "%s" node name, with given connection type and output index',
(nodeName) => {
const data = [{ json: { hello: 'world' } }];
mockedStore(useWorkflowsStore).getWorkflowRunData = mock<IRunData>({
mockInjectedRunData.value = mock<IRunData>({
[nodeName]: [{ data: { main: [data] } }],
});
const { getNodeInputData } = useNodeHelpers();
@@ -460,7 +477,7 @@ describe('useNodeHelpers()', () => {
const nodeName = 'Test Node';
const { getLastRunIndexWithData } = useNodeHelpers();
mockedStore(useWorkflowsStore).getWorkflowRunData = mock<IRunData>({
mockInjectedRunData.value = mock<IRunData>({
[nodeName]: [{ data: { main: [mockData] } }, { data: { main: [mockData] } }],
});
expect(getLastRunIndexWithData(nodeName)).toEqual(1);
@@ -470,7 +487,7 @@ describe('useNodeHelpers()', () => {
const nodeName = 'Test Node';
const { getLastRunIndexWithData } = useNodeHelpers();
mockedStore(useWorkflowsStore).getWorkflowRunData = mock<IRunData>({
mockInjectedRunData.value = mock<IRunData>({
[nodeName]: [],
});
expect(getLastRunIndexWithData(nodeName)).toEqual(-1);
@@ -480,7 +497,7 @@ describe('useNodeHelpers()', () => {
const nodeName = 'Test Node';
const { getLastRunIndexWithData } = useNodeHelpers();
mockedStore(useWorkflowsStore).getWorkflowRunData = null;
mockInjectedRunData.value = null;
expect(getLastRunIndexWithData(nodeName)).toEqual(-1);
});
@@ -488,7 +505,7 @@ describe('useNodeHelpers()', () => {
const nodeName = 'Test Node';
const { getLastRunIndexWithData } = useNodeHelpers();
mockedStore(useWorkflowsStore).getWorkflowRunData = mock<IRunData>({
mockInjectedRunData.value = mock<IRunData>({
[nodeName]: [
{ data: { main: [mockData, []] } },
{ data: { main: [mockData, []] } },
@@ -504,7 +521,7 @@ describe('useNodeHelpers()', () => {
const nodeName = 'Test Node';
const { getLastRunIndexWithData } = useNodeHelpers();
mockedStore(useWorkflowsStore).getWorkflowRunData = mock<IRunData>({
mockInjectedRunData.value = mock<IRunData>({
[nodeName]: [
{ data: { main: [mockData], ai_tool: [mockData] } },
{ data: { ai_tool: [mockData] } },
@@ -518,7 +535,7 @@ describe('useNodeHelpers()', () => {
describe('hasNodeExecuted()', () => {
it('should return false when runData is not available', () => {
const nodeName = 'Test Node';
mockedStore(useWorkflowsStore).getWorkflowRunData = null;
mockInjectedRunData.value = null;
const { hasNodeExecuted } = useNodeHelpers();
expect(hasNodeExecuted(nodeName)).toBe(false);
});
@@ -532,7 +549,7 @@ describe('useNodeHelpers()', () => {
])('should return $expected when execution status is $status', ({ status, expected }) => {
const nodeName = 'Test Node';
mockedStore(useWorkflowsStore).getWorkflowRunData = mock<IRunData>({
mockInjectedRunData.value = mock<IRunData>({
[nodeName]: [{ executionStatus: status }],
});
const { hasNodeExecuted } = useNodeHelpers();
@@ -50,6 +50,7 @@ import { hasPermission } from '@/app/utils/rbac/permissions';
import { useCanvasStore } from '@/app/stores/canvas.store';
import { useSettingsStore } from '@/app/stores/settings.store';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { injectWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { useDynamicCredentials } from '@/features/resolvers/composables/useDynamicCredentials';
declare namespace HttpRequestNode {
@@ -71,6 +72,7 @@ export function useNodeHelpers() {
const i18n = useI18n();
const canvasStore = useCanvasStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const workflowExecutionStateStore = injectWorkflowExecutionStateStore();
const { isEnabled: isDynamicCredentialsEnabled } = useDynamicCredentials();
const isInsertingNodes = ref(false);
@@ -242,7 +244,7 @@ export function useNodeHelpers() {
// Set the status on all the nodes which produced an error so that it can be
// displayed in the node-view
function hasNodeExecutionIssues(node: INodeUi): boolean {
const workflowResultData = workflowsStore.getWorkflowRunData;
const workflowResultData = workflowExecutionStateStore.value.activeExecutionRunData;
if (!workflowResultData?.hasOwnProperty(node.name)) {
return false;
@@ -653,7 +655,8 @@ export function useNodeHelpers() {
}
function getAllNodeTaskData(nodeName: string, execution?: IRunExecutionData) {
const runData = execution?.resultData.runData ?? workflowsStore.getWorkflowRunData;
const runData =
execution?.resultData.runData ?? workflowExecutionStateStore.value.activeExecutionRunData;
return runData?.[nodeName] ?? null;
}
@@ -1,5 +1,5 @@
import { injectNDVStore } from '@/features/ndv/shared/ndv.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { injectWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import {
isExpression as isExpressionUtil,
stringifyExpressionResult,
@@ -36,7 +36,7 @@ export function useResolvedExpression({
contextNodeName?: MaybeRefOrGetter<string>;
}) {
const ndvStore = injectNDVStore();
const workflowsStore = useWorkflowsStore();
const workflowExecutionStateStore = injectWorkflowExecutionStateStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const { resolveExpression } = useWorkflowHelpers();
@@ -53,7 +53,7 @@ export function useResolvedExpression({
const activeNode = computed(() => ndvStore.value.activeNode);
const hasRunData = computed(() =>
Boolean(
workflowsStore.workflowExecutionData?.data?.resultData?.runData[activeNode.value?.name ?? ''],
workflowExecutionStateStore.value.activeExecutionRunData?.[activeNode.value?.name ?? ''],
),
);
const isExpression = computed(() => isExpressionUtil(toValue(expression)));
@@ -123,8 +123,8 @@ export function useResolvedExpression({
expressionLocalResolveCtx,
toRef(expression),
toRef(additionalData),
() => workflowsStore.getWorkflowExecution,
() => workflowsStore.getWorkflowRunData,
() => workflowExecutionStateStore.value.activeExecution,
() => workflowExecutionStateStore.value.activeExecutionRunData,
() => workflowDocumentStore.value.name,
targetItem,
],
@@ -653,7 +653,7 @@ describe('useRunWorkflow({ router })', () => {
? { main: [[{ node: parentName, type: NodeConnectionTypes.Main, index: 0 }]] }
: ({} as INodeConnections),
);
vi.mocked(workflowsStore).getWorkflowRunData = {
const runData = {
[parentName]: [
{
startTime: 1,
@@ -675,6 +675,19 @@ describe('useRunWorkflow({ router })', () => {
},
],
};
vi.mocked(workflowsStore).getWorkflowRunData = runData;
// Node dirtiness resolves run data through the execution-state store
// keyed by the document id, so seed the real store as well.
executionStateStore.setWorkflowExecutionData({
id: 'previous-execution',
workflowData: { id: '123', nodes: [], connections: {} },
finished: true,
mode: 'manual',
status: 'success',
startedAt: new Date(),
createdAt: new Date(),
data: { resultData: { runData } },
} as unknown as IExecutionResponse);
mockDocumentStore.serialize.mockReturnValue({
nodes: [],
} as unknown as WorkflowData);
@@ -5,6 +5,8 @@ import { ref, shallowRef, nextTick } from 'vue';
import { waitFor } from '@testing-library/vue';
import { useToolParameters } from './useToolParameters';
import { useWorkflowsStore } from '../stores/workflows.store';
import { useWorkflowExecutionStateStore } from '../stores/workflowExecutionState.store';
import type { WorkflowDocumentId } from '../stores/workflowDocument.store';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { useNodeTypesStore } from '../stores/nodeTypes.store';
import { useAgentRequestStore } from '@n8n/stores/useAgentRequestStore';
@@ -15,6 +17,7 @@ import { AI_MCP_TOOL_NODE_TYPE } from '../constants';
const { mockWorkflowDocumentStore } = vi.hoisted(() => ({
mockWorkflowDocumentStore: {
documentId: 'test-workflow@latest',
getNodeByName: vi.fn(),
getParentNodes: vi.fn().mockReturnValue([]),
allNodes: [],
@@ -49,7 +52,12 @@ describe('useToolParameters', () => {
mockWorkflowDocumentStore.getNodeByName.mockReset();
projectsStore.currentProjectId = 'test-project';
workflowsStore.setWorkflowId('test-workflow');
workflowsStore.getWorkflowExecution = null;
(
mockedStore(
useWorkflowExecutionStateStore,
'test-workflow@latest' as WorkflowDocumentId,
) as unknown as { activeExecution: unknown }
).activeExecution = null;
agentRequestStore.getQueryValue = vi.fn().mockReturnValue(null);
});
@@ -158,7 +166,12 @@ describe('useToolParameters', () => {
},
};
workflowsStore.getWorkflowExecution = {
(
mockedStore(
useWorkflowExecutionStateStore,
'test-workflow@latest' as WorkflowDocumentId,
) as unknown as { activeExecution: unknown }
).activeExecution = {
data: {
resultData: {
runData: {
@@ -7,7 +7,7 @@ import {
traverseNodeParameters,
} from 'n8n-workflow';
import { computed, reactive, ref, watch, type Ref } from 'vue';
import { useWorkflowsStore } from '../stores/workflows.store';
import { injectWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { useNodeTypesStore } from '../stores/nodeTypes.store';
@@ -27,7 +27,7 @@ interface GetToolParametersProps {
export function useToolParameters({ node }: GetToolParametersProps) {
const parameters = ref<IFormInput[]>([]);
const workflowsStore = useWorkflowsStore();
const workflowExecutionStateStore = injectWorkflowExecutionStateStore();
const projectsStore = useProjectsStore();
const nodeTypesStore = useNodeTypesStore();
const agentRequestStore = useAgentRequestStore();
@@ -40,7 +40,7 @@ export function useToolParameters({ node }: GetToolParametersProps) {
const nodeRunData = computed(() => {
if (!node.value) return undefined;
const workflowExecutionData = workflowsStore.getWorkflowExecution;
const workflowExecutionData = workflowExecutionStateStore.value.activeExecution;
const lastRunData = workflowExecutionData?.data?.resultData.runData[node.value?.name];
if (!lastRunData) return undefined;
return lastRunData[0];
@@ -56,6 +56,8 @@ import {
injectWorkflowDocumentStore,
type WorkflowDocumentId,
} from '@/app/stores/workflowDocument.store';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import type { IExecutionResponse } from '@/features/execution/executions/executions.types';
export type ResolveParameterOptions = {
targetItem?: TargetItem;
@@ -89,10 +91,10 @@ export async function resolveParameter<T = IDataObject>(
}
: opts_;
const workflowsStore = useWorkflowsStore();
const workflowObject = workflowDocumentStore.getWorkflowObjectAccessorSnapshot();
const connections = workflowDocumentStore.connectionsBySourceNode;
const executionData = workflowsStore.workflowExecutionData;
const executionData = useWorkflowExecutionStateStore(workflowDocumentId)
.activeExecution as IExecutionResponse | null;
const pinData = workflowDocumentStore.getPinDataSnapshot();
let itemIndex = opts?.targetItem?.itemIndex || 0;
@@ -0,0 +1,182 @@
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { mock } from 'vitest-mock-extended';
import { STORES } from '@n8n/stores';
import {
createTestNode,
createTestNodeProperties,
createTestWorkflow,
mockNodeTypeDescription,
} from '@/__tests__/mocks';
import { mockedStore } from '@/__tests__/utils';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { SET_NODE_TYPE } from '@/app/constants';
import type { INodeUi, IWorkflowDb } from '@/Interface';
import { useWorkflowNormalization } from '@/app/composables/useWorkflowNormalization';
describe('useWorkflowNormalization', () => {
const workflowId = 'test';
beforeEach(() => {
setActivePinia(
createTestingPinia({
initialState: {
[STORES.NODE_TYPES]: {},
[STORES.WORKFLOWS]: {
workflowId,
workflow: mock<IWorkflowDb>({
id: workflowId,
nodes: [],
connections: {},
tags: [],
usedCredentials: [],
}),
},
[STORES.SETTINGS]: {
settings: {
enterprise: {},
},
},
},
}),
);
});
describe('normalizeWorkflowData', () => {
it('should drop nodes with a missing type and sanitize their connections', () => {
const validNode = createTestNode({ name: 'Start' });
const invalidNode = createTestNode({ name: 'Missing', type: '' });
const targetNode = createTestNode({ name: 'End' });
const workflow = createTestWorkflow({
nodes: [validNode, invalidNode, targetNode],
connections: {
Start: {
main: [
[
{ node: 'End', type: 'main', index: 0 },
{ node: 'Missing', type: 'main', index: 0 },
],
],
},
Missing: {
main: [[{ node: 'End', type: 'main', index: 0 }]],
},
},
});
const { normalizeWorkflowData } = useWorkflowNormalization();
const { nodes, connections } = normalizeWorkflowData(workflow);
expect(nodes.map((node) => node.name)).toEqual(['Start', 'End']);
expect(connections).toEqual({
Start: {
main: [[{ node: 'End', type: 'main', index: 0 }]],
},
});
});
it('should default position to [0, 0] for nodes with missing position', () => {
const { position: _, ...nodeWithoutPosition } = createTestNode({ name: 'Start' });
const workflow = createTestWorkflow({
nodes: [nodeWithoutPosition as INodeUi],
connections: {},
});
const { normalizeWorkflowData } = useWorkflowNormalization();
const { nodes } = normalizeWorkflowData(workflow);
expect(nodes).toEqual([expect.objectContaining({ name: 'Start', position: [0, 0] })]);
});
it('should resolve node parameters from the node type description for installed nodes', () => {
const nodeTypesStore = mockedStore(useNodeTypesStore);
const type = SET_NODE_TYPE;
const version = 1;
nodeTypesStore.nodeTypes = {
[type]: {
[version]: mockNodeTypeDescription({
name: type,
version,
properties: [
createTestNodeProperties({
displayName: 'Value',
name: 'value',
type: 'boolean',
default: true,
}),
],
}),
},
};
const workflow = createTestWorkflow({
nodes: [createTestNode({ type, typeVersion: version })],
connections: {},
});
const { normalizeWorkflowData } = useWorkflowNormalization();
const { nodes } = normalizeWorkflowData(workflow);
expect(nodes).toEqual([expect.objectContaining({ parameters: { value: true } })]);
});
it('should leave parameters untouched for not-installed node types', () => {
const workflow = createTestWorkflow({
nodes: [
createTestNode({
type: 'n8n-nodes-community.unknown',
parameters: { custom: 'value' },
}),
],
connections: {},
});
const { normalizeWorkflowData } = useWorkflowNormalization();
const { nodes } = normalizeWorkflowData(workflow);
expect(nodes).toEqual([expect.objectContaining({ parameters: { custom: 'value' } })]);
});
it('should not mutate the input nodes array', () => {
const node = createTestNode({ name: 'Start' });
const workflow = createTestWorkflow({ nodes: [node], connections: {} });
const inputNodes = workflow.nodes;
const { normalizeWorkflowData } = useWorkflowNormalization();
const { nodes } = normalizeWorkflowData(workflow);
expect(workflow.nodes).toBe(inputNodes);
expect(nodes).not.toBe(inputNodes);
expect(nodes[0]).not.toBe(node);
});
});
describe('requireNodeTypeDescription', () => {
it('should return a fallback description for unknown node types', () => {
const { requireNodeTypeDescription } = useWorkflowNormalization();
const result = requireNodeTypeDescription('unknown-type', 2);
expect(result).toEqual(
expect.objectContaining({
displayName: 'unknown-type',
name: 'unknown-type',
version: 2,
properties: [],
}),
);
});
it('should return the registered description for known node types', () => {
const nodeTypesStore = mockedStore(useNodeTypesStore);
const type = SET_NODE_TYPE;
const version = 1;
const description = mockNodeTypeDescription({ name: type, version });
nodeTypesStore.nodeTypes = { [type]: { [version]: description } };
const { requireNodeTypeDescription } = useWorkflowNormalization();
expect(requireNodeTypeDescription(type, version)).toBe(description);
});
});
});
@@ -0,0 +1,108 @@
import type { IConnections, INodeTypeDescription } from 'n8n-workflow';
import { NodeHelpers, resolveNodeWebhookId } from 'n8n-workflow';
import type { INodeUi, IWorkflowDb } from '@/Interface';
import { FORM_TRIGGER_NODE_TYPE, MCP_TRIGGER_NODE_TYPE, WEBHOOK_NODE_TYPE } from '@/app/constants';
import { ensureNodePosition, sanitizeConnections } from '@/app/utils/workflowUtils';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useNodeHelpers } from '@/app/composables/useNodeHelpers';
export interface NormalizedWorkflowData {
nodes: INodeUi[];
connections: IConnections;
}
/**
* Node and connection normalization shared by every surface that hydrates a
* workflow document store from raw workflow data (editor workspace
* initialization, execution preview, ...).
*
* Reads from the node types and credentials stores but never writes global
* workflow state safe to use against any document store instance.
*/
export function useWorkflowNormalization() {
const nodeTypesStore = useNodeTypesStore();
const nodeHelpers = useNodeHelpers();
function requireNodeTypeDescription(
type: INodeUi['type'],
version?: INodeUi['typeVersion'],
): INodeTypeDescription {
return (
nodeTypesStore.getNodeType(type, version) ??
nodeTypesStore.communityNodeType(type)?.nodeDescription ?? {
properties: [],
displayName: type,
name: type,
group: [],
description: '',
version: version ?? 1,
defaults: {},
inputs: [],
outputs: [],
}
);
}
function resolveNodeParameters(node: INodeUi, nodeTypeDescription: INodeTypeDescription) {
const nodeParameters = NodeHelpers.getNodeParameters(
nodeTypeDescription?.properties ?? [],
node.parameters,
true,
false,
node,
nodeTypeDescription,
);
node.parameters = nodeParameters ?? {};
}
function resolveNodeWebhook(node: INodeUi, nodeTypeDescription: INodeTypeDescription) {
resolveNodeWebhookId(node, nodeTypeDescription);
// if it's a webhook and the path is empty set the UUID as the default path
if (
[WEBHOOK_NODE_TYPE, FORM_TRIGGER_NODE_TYPE, MCP_TRIGGER_NODE_TYPE].includes(node.type) &&
node.parameters.path === ''
) {
node.parameters.path = node.webhookId as string;
}
}
/**
* Normalizes raw workflow data for rendering: drops nodes with a missing
* type, coerces node positions, matches credentials, resolves parameters
* and webhook ids for installed node types, and sanitizes connections
* against the surviving node names.
*/
function normalizeWorkflowData(
data: Pick<IWorkflowDb, 'nodes' | 'connections'>,
): NormalizedWorkflowData {
// Filter out nodes with missing type to prevent canvas rendering crashes
const validNodes = data.nodes
.filter((node) => !!node.type)
.map((node) => ({ ...node, position: ensureNodePosition(node.position) }));
const validNodeNames = validNodes.map((node) => node.name);
validNodes.forEach((node) => {
const nodeTypeDescription = requireNodeTypeDescription(node.type, node.typeVersion);
const isInstalledNode = nodeTypesStore.getIsNodeInstalled(node.type);
nodeHelpers.matchCredentials(node);
// skip this step because nodeTypeDescription is missing for unknown nodes
if (isInstalledNode) {
resolveNodeParameters(node, nodeTypeDescription);
resolveNodeWebhook(node, nodeTypeDescription);
}
});
return {
nodes: validNodes,
connections: sanitizeConnections(data.connections, validNodeNames),
};
}
return {
requireNodeTypeDescription,
resolveNodeParameters,
resolveNodeWebhook,
normalizeWorkflowData,
};
}
@@ -9,7 +9,6 @@ import type { TelemetryContext } from '@/app/types/telemetry';
import type { useExecutionDataStore } from '@/app/stores/executionData.store';
import type { WorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import type { CanvasRenderData } from '@/features/workflows/canvas/canvas.utils';
import type { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
export const WorkflowIdKey = 'workflowId' as unknown as InjectionKey<ComputedRef<string>>;
export const CanvasKey = 'canvas' as unknown as InjectionKey<CanvasInjectionData>;
@@ -26,9 +25,10 @@ export const WorkflowDocumentStoreKey: InjectionKey<ShallowRef<WorkflowDocumentS
export const ExecutionDataStoreKey: InjectionKey<
ShallowRef<ReturnType<typeof useExecutionDataStore> | null>
> = Symbol('ExecutionDataStore');
export const WorkflowExecutionStateStoreKey: InjectionKey<
ShallowRef<ReturnType<typeof useWorkflowExecutionStateStore> | null>
> = Symbol('WorkflowExecutionStateStore');
// NOTE: there is intentionally no injection key for the workflow-execution-state
// store — it shares its identity with the workflow document store and is always
// derived from it via injectWorkflowExecutionStateStore(), so a subtree's
// document scope and execution scope can never diverge.
export const CanvasRenderDataKey: InjectionKey<Ref<CanvasRenderData>> = Symbol('CanvasRenderData');
export const ChatHubToolContextKey: InjectionKey<boolean> = Symbol('ChatHubToolContext');
export const AiBuilderScrollToBottomKey: InjectionKey<() => void> = Symbol('ChatScrollToBottom');
@@ -8,10 +8,15 @@
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { setActivePinia, createPinia, getActivePinia } from 'pinia';
import { defineComponent, provide, shallowRef } from 'vue';
import { createComponentRenderer } from '@/__tests__/render';
import { WorkflowDocumentStoreKey } from '@/app/constants/injectionKeys';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import {
useWorkflowExecutionStateStore,
getWorkflowExecutionStateStoreId,
disposeWorkflowExecutionStateStore,
injectWorkflowExecutionStateStore,
} from '@/app/stores/workflowExecutionState.store';
import {
createWorkflowDocumentId,
@@ -1396,4 +1401,50 @@ describe('workflowExecutionState.store', () => {
expect(executionStateStore.executionWaitingForNextByNodeId.size).toBe(0);
});
});
describe('injectWorkflowExecutionStateStore', () => {
function renderWithInjector({ providedWorkflowId }: { providedWorkflowId?: string }) {
let injected!: ReturnType<typeof injectWorkflowExecutionStateStore>;
// inject() only resolves provides from ancestor components, so the
// provide must live in a parent component.
const ChildComponent = defineComponent({
setup() {
injected = injectWorkflowExecutionStateStore();
},
template: '<div />',
});
const ParentComponent = defineComponent({
components: { ChildComponent },
setup() {
if (providedWorkflowId) {
const scopedDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(providedWorkflowId, 'scoped'),
);
provide(WorkflowDocumentStoreKey, shallowRef(scopedDocumentStore));
}
},
template: '<ChildComponent />',
});
createComponentRenderer(ParentComponent)();
return injected;
}
it('resolves the execution-state store of the provided workflow document', () => {
const injected = renderWithInjector({ providedWorkflowId: 'scoped-workflow' });
expect(injected.value.documentId).toBe(createWorkflowDocumentId('scoped-workflow', 'scoped'));
});
it('falls back to the global workflow id when nothing is provided', () => {
useWorkflowsStore().setWorkflowId('global-workflow');
const injected = renderWithInjector({});
expect(injected.value.documentId).toBe(createWorkflowDocumentId('global-workflow'));
});
});
});
@@ -3,7 +3,6 @@ import { STORES } from '@n8n/stores';
import {
computed,
effectScope,
inject,
onScopeDispose,
readonly,
ref,
@@ -12,13 +11,18 @@ import {
} from 'vue';
import { createEventHook } from '@vueuse/core';
import { structuralComputed } from '@n8n/composables/structuralComputed';
import type { ExecutionStatus, ExecutionSummary, IRunExecutionData, ITaskData } from 'n8n-workflow';
import type {
ExecutionStatus,
ExecutionSummary,
IRunExecutionData,
ITaskData,
ITaskStartedData,
} from 'n8n-workflow';
import type { NodeExecuteBefore } from '@n8n/api-types/push/execution';
import type {
IExecutionResponse,
IExecutionsStopData,
} from '@/features/execution/executions/executions.types';
import { WorkflowExecutionStateStoreKey } from '@/app/constants/injectionKeys';
import { IN_PROGRESS_EXECUTION_ID } from '@/app/constants/placeholders';
import { useExecutingNode } from '@/app/composables/useExecutingNode';
import { useUIStore } from '@/app/stores/ui.store';
@@ -27,7 +31,11 @@ import {
disposeExecutionDataStore,
useExecutionDataStore,
} from './executionData.store';
import { useWorkflowDocumentStore, type WorkflowDocumentId } from './workflowDocument.store';
import {
injectWorkflowDocumentStore,
useWorkflowDocumentStore,
type WorkflowDocumentId,
} from './workflowDocument.store';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import { clearPopupWindowState } from '@/features/execution/executions/executions.utils';
import { CHANGE_ACTION } from './workflowDocument/types';
@@ -166,16 +174,26 @@ export function useWorkflowExecutionStateStore(id: WorkflowDocumentId) {
* - `activeExecutionId === undefined` and `displayedExecutionId === string`
* -> 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.
*/
const activeExecution = computed(() => {
const activeExecution = computed<IExecutionResponse | null>(() => {
if (activeExecutionId.value === null) return pendingExecution.value;
if (typeof activeExecutionId.value === 'string') {
return useExecutionDataStore(createExecutionDataId(activeExecutionId.value)).execution;
}
if (typeof displayedExecutionId.value === 'string') {
return useExecutionDataStore(createExecutionDataId(displayedExecutionId.value)).execution;
}
return null;
const executionId =
typeof activeExecutionId.value === 'string'
? activeExecutionId.value
: typeof displayedExecutionId.value === 'string'
? 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;
});
/**
@@ -214,13 +232,18 @@ export function useWorkflowExecutionStateStore(id: WorkflowDocumentId) {
const activeExecutionStartedData = computed(() => {
const executionId = getResolvedActiveExecutionId();
if (!executionId) return undefined;
return useExecutionDataStore(createExecutionDataId(executionId)).executionStartedData;
// Mutable-typed for consumers (the executionData store exposes a
// readonly ref); treat it as read-only.
return useExecutionDataStore(createExecutionDataId(executionId)).executionStartedData as
| [executionId: string, data: { [nodeName: string]: ITaskStartedData[] }]
| undefined;
});
const activeExecutionPairedItemMappings = computed(() => {
const executionId = getResolvedActiveExecutionId();
if (!executionId) return {};
return useExecutionDataStore(createExecutionDataId(executionId)).executionPairedItemMappings;
return useExecutionDataStore(createExecutionDataId(executionId))
.executionPairedItemMappings as Record<string, Set<string>>;
});
const activeExecutionResultDataLastUpdate = computed(() => {
@@ -289,10 +312,13 @@ export function useWorkflowExecutionStateStore(id: WorkflowDocumentId) {
return useExecutionDataStore(createExecutionDataId(executionId)).executionWaitingByNodeId;
});
const lastSuccessfulExecution = computed(() => {
const lastSuccessfulExecution = computed<IExecutionResponse | null>(() => {
const lid = lastSuccessfulExecutionId.value;
if (!lid) return null;
return useExecutionDataStore(createExecutionDataId(lid)).execution;
// Mutable-typed for consumers (the executionData store exposes a
// readonly ref); treat it as read-only.
return useExecutionDataStore(createExecutionDataId(lid))
.execution as IExecutionResponse | null;
});
const isWorkflowRunning = computed(() => {
@@ -897,9 +923,20 @@ export function disposeWorkflowExecutionStateStore(
}
/**
* Injects the active workflow-execution-state store from the component tree.
* Returns null when not within a context that has provided the store.
* Resolves the workflow-execution-state store for the current workflow
* document scope.
*
* There is deliberately no separate provide for this store: the workflow
* document store (`WorkflowDocumentStoreKey`) is the single provided source
* of truth for a subtree's scope, and the execution-state store shares its
* identity (same `WorkflowDocumentId`). Deriving from the injected document
* store keeps the two from ever pointing at different scopes. Falls back to
* the global workflow id outside any provide tree, exactly like
* `injectWorkflowDocumentStore()`.
*/
export function injectWorkflowExecutionStateStore() {
return inject(WorkflowExecutionStateStoreKey, null);
export function injectWorkflowExecutionStateStore(): ComputedRef<
ReturnType<typeof useWorkflowExecutionStateStore>
> {
const workflowDocumentStore = injectWorkflowDocumentStore();
return computed(() => useWorkflowExecutionStateStore(workflowDocumentStore.value.documentId));
}
@@ -1,24 +1,13 @@
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import type { INode, IRunExecutionData } from 'n8n-workflow';
import { injectWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import type { INode } from 'n8n-workflow';
import { computed, type ComputedRef } from 'vue';
export function useExecutionData({ node }: { node: ComputedRef<INode | undefined> }) {
const workflowsStore = useWorkflowsStore();
const workflowExecutionStateStore = injectWorkflowExecutionStateStore();
const workflowExecution = computed(() => {
return workflowsStore.getWorkflowExecution;
});
const workflowExecution = computed(() => workflowExecutionStateStore.value.activeExecution);
const workflowRunData = computed(() => {
if (workflowExecution.value === null) {
return null;
}
const executionData: IRunExecutionData | undefined = workflowExecution.value.data;
if (!executionData?.resultData?.runData) {
return null;
}
return executionData.resultData.runData;
});
const workflowRunData = computed(() => workflowExecutionStateStore.value.activeExecutionRunData);
const hasNodeRun = computed(() => {
return Boolean(
@@ -1,6 +1,10 @@
import { createTestingPinia } from '@pinia/testing';
import { mockedStore } from '@/__tests__/utils';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { createWorkflowDocumentId } from '@/app/stores/workflowDocument.store';
import { createExecutionDataId, useExecutionDataStore } from '@/app/stores/executionData.store';
import type { IExecutionResponse } from '@/features/execution/executions/executions.types';
import { useExecutionRedaction } from './useExecutionRedaction';
import { MODAL_CONFIRM } from '@/app/constants/modals';
@@ -24,6 +28,19 @@ vi.mock('vue-router', () => ({
describe('useExecutionRedaction()', () => {
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
// The composable reads the execution through the injected workflow document
// scope; with nothing provided it falls back to the workflows store's
// (empty) workflow id, so seed the execution-state store keyed by that id.
// Testing pinia makes store getters writable at runtime; the cast makes
// that writability visible to the type checker.
function setActiveExecution(execution: IExecutionResponse | null) {
const executionStateStore = mockedStore(
useWorkflowExecutionStateStore,
createWorkflowDocumentId(''),
) as unknown as { activeExecution: IExecutionResponse | null };
executionStateStore.activeExecution = execution;
}
beforeEach(() => {
vi.clearAllMocks();
createTestingPinia({ stubActions: false });
@@ -32,7 +49,7 @@ describe('useExecutionRedaction()', () => {
describe('computed properties', () => {
it('should return isRedacted=false when no execution', () => {
workflowsStore.getWorkflowExecution = null;
setActiveExecution(null);
const { isRedacted, canReveal, isDynamicCredentials } = useExecutionRedaction();
expect(isRedacted.value).toBe(false);
@@ -41,11 +58,11 @@ describe('useExecutionRedaction()', () => {
});
it('should return isRedacted=true when redactionInfo.isRedacted is true', () => {
workflowsStore.getWorkflowExecution = {
setActiveExecution({
data: {
redactionInfo: { isRedacted: true, reason: 'workflow_redaction_policy', canReveal: true },
},
} as never;
} as never);
const { isRedacted, canReveal, isDynamicCredentials } = useExecutionRedaction();
@@ -55,7 +72,7 @@ describe('useExecutionRedaction()', () => {
});
it('should detect dynamic credentials reason', () => {
workflowsStore.getWorkflowExecution = {
setActiveExecution({
data: {
redactionInfo: {
isRedacted: true,
@@ -63,7 +80,7 @@ describe('useExecutionRedaction()', () => {
canReveal: false,
},
},
} as never;
} as never);
const { isDynamicCredentials, canReveal } = useExecutionRedaction();
@@ -74,12 +91,12 @@ describe('useExecutionRedaction()', () => {
describe('revealData', () => {
it('should not fetch when user cancels confirmation', async () => {
workflowsStore.getWorkflowExecution = {
setActiveExecution({
id: 'exec-123',
data: {
redactionInfo: { isRedacted: true, reason: 'workflow_redaction_policy', canReveal: true },
},
} as never;
} as never);
confirm.mockResolvedValue('cancel');
@@ -91,34 +108,37 @@ describe('useExecutionRedaction()', () => {
it('should fetch with redactExecutionData=false on confirm', async () => {
const revealedData = { resultData: { runData: { Node: [] } } };
workflowsStore.getWorkflowExecution = {
setActiveExecution({
id: 'exec-123',
data: {
redactionInfo: { isRedacted: true, reason: 'workflow_redaction_policy', canReveal: true },
},
} as never;
} as never);
confirm.mockResolvedValue(MODAL_CONFIRM);
workflowsStore.fetchExecutionDataById = vi.fn().mockResolvedValue({
data: revealedData,
});
const executionDataStore = useExecutionDataStore(createExecutionDataId('exec-123'));
const setExecutionRunDataSpy = vi.spyOn(executionDataStore, 'setExecutionRunData');
const { revealData } = useExecutionRedaction();
await revealData();
expect(workflowsStore.fetchExecutionDataById).toHaveBeenCalledWith('exec-123', {
redactExecutionData: false,
});
expect(workflowsStore.setWorkflowExecutionRunData).toHaveBeenCalledWith(revealedData);
expect(setExecutionRunDataSpy).toHaveBeenCalledWith(revealedData);
});
it('should show error toast when fetch fails', async () => {
workflowsStore.getWorkflowExecution = {
setActiveExecution({
id: 'exec-123',
data: {
redactionInfo: { isRedacted: true, reason: 'workflow_redaction_policy', canReveal: true },
},
} as never;
} as never);
const error = new Error('Forbidden');
confirm.mockResolvedValue(MODAL_CONFIRM);
@@ -131,11 +151,11 @@ describe('useExecutionRedaction()', () => {
});
it('should not fetch when execution id is missing', async () => {
workflowsStore.getWorkflowExecution = {
setActiveExecution({
data: {
redactionInfo: { isRedacted: true, reason: 'workflow_redaction_policy', canReveal: true },
},
} as never;
} as never);
confirm.mockResolvedValue(MODAL_CONFIRM);
@@ -1,5 +1,8 @@
import { computed, h } from 'vue';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { injectWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { createExecutionDataId, useExecutionDataStore } from '@/app/stores/executionData.store';
import { useMessage } from '@/app/composables/useMessage';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { useToast } from '@/app/composables/useToast';
@@ -9,12 +12,16 @@ import RevealDataWarning from '../components/RevealDataWarning.vue';
export function useExecutionRedaction() {
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const workflowExecutionStateStore = injectWorkflowExecutionStateStore();
const message = useMessage();
const telemetry = useTelemetry();
const { showError } = useToast();
const i18n = useI18n();
const redactionInfo = computed(() => workflowsStore.getWorkflowExecution?.data?.redactionInfo);
const redactionInfo = computed(
() => workflowExecutionStateStore.value.activeExecution?.data?.redactionInfo,
);
const isRedacted = computed(() => redactionInfo.value?.isRedacted === true);
@@ -26,8 +33,8 @@ export function useExecutionRedaction() {
async function revealData() {
telemetry.track('User clicked reveal data', {
workflow_id: workflowsStore.workflowId,
execution_id: workflowsStore.getWorkflowExecution?.id,
workflow_id: workflowDocumentStore.value.workflowId,
execution_id: workflowExecutionStateStore.value.activeExecution?.id,
});
const warningContent = h(RevealDataWarning, {
@@ -49,7 +56,7 @@ export function useExecutionRedaction() {
if (confirmed !== MODAL_CONFIRM) return;
const executionId = workflowsStore.getWorkflowExecution?.id;
const executionId = workflowExecutionStateStore.value.activeExecution?.id;
if (!executionId) return;
try {
@@ -57,7 +64,12 @@ export function useExecutionRedaction() {
redactExecutionData: false,
});
if (revealed?.data) {
workflowsStore.setWorkflowExecutionRunData(revealed.data);
// Write to the per-execution data store directly (keyed by the
// revealed execution's id) so the update lands on the execution we
// revealed regardless of which document scope we run under.
useExecutionDataStore(createExecutionDataId(executionId)).setExecutionRunData(
revealed.data,
);
}
} catch (error) {
showError(error, i18n.baseText('ndv.redacted.revealError'));
@@ -1,12 +1,12 @@
<script setup lang="ts">
import LogsPanel from '@/features/execution/logs/components/LogsPanel.vue';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { injectWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { computed } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute();
const workflowsStore = useWorkflowsStore();
const hasExecutionData = computed(() => workflowsStore.workflowExecutionData);
const workflowExecutionStateStore = injectWorkflowExecutionStateStore();
const hasExecutionData = computed(() => workflowExecutionStateStore.value.activeExecution);
const canExecute = computed(() => route.query.canExecute === 'true');
</script>
@@ -3,7 +3,7 @@ import { useNodeHelpers } from '@/app/composables/useNodeHelpers';
import { useRunWorkflow } from '@/app/composables/useRunWorkflow';
import { VIEWS } from '@/app/constants';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { injectWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { useRootStore } from '@n8n/stores/useRootStore';
import MessageWithButtons from '@n8n/chat/components/MessageWithButtons.vue';
@@ -45,9 +45,7 @@ export function useChatState(
const locale = useI18n();
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const workflowExecutionState = computed(() =>
useWorkflowExecutionStateStore(workflowDocumentStore.value.documentId),
);
const workflowExecutionState = injectWorkflowExecutionStateStore();
const rootStore = useRootStore();
const logsStore = useLogsStore();
const router = useRouter();
@@ -322,7 +320,7 @@ export function useChatState(
const restoredChatMessages = computed(() =>
restoreChatHistory(
workflowsStore.workflowExecutionData,
workflowExecutionState.value.activeExecution,
locale.baseText('chat.window.chat.response.empty'),
locale.baseText('chat.window.chat.response.redacted'),
),
@@ -1,20 +1,16 @@
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { injectWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
export function useClearExecutionButtonVisible() {
const route = useRoute();
const sourceControlStore = useSourceControlStore();
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const workflowExecutionStateStore = computed(() =>
useWorkflowExecutionStateStore(workflowDocumentStore.value.documentId),
);
const workflowExecutionData = computed(() => workflowsStore.workflowExecutionData);
const workflowExecutionStateStore = injectWorkflowExecutionStateStore();
const workflowExecutionData = computed(() => workflowExecutionStateStore.value.activeExecution);
const isWorkflowRunning = computed(() => workflowExecutionStateStore.value.isWorkflowRunning);
const isReadOnlyRoute = computed(() => !!route?.meta?.readOnlyCanvas);
const nodeTypesStore = useNodeTypesStore();
@@ -2,11 +2,8 @@ import { watch, computed, ref, type ComputedRef } from 'vue';
import type { IExecutionResponse } from '@/features/execution/executions/executions.types';
import { Workflow, type IRunExecutionData, type ITaskStartedData } from 'n8n-workflow';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import {
createWorkflowDocumentId,
injectWorkflowDocumentStore,
} from '@/app/stores/workflowDocument.store';
import { injectWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { useNodeHelpers } from '@/app/composables/useNodeHelpers';
import {
copyExecutionData,
@@ -36,6 +33,8 @@ export function useLogsExecutionData({ isEnabled, filter }: UseLogsExecutionData
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const workflowExecutionStateStore = injectWorkflowExecutionStateStore();
const currentExecution = computed(() => workflowExecutionStateStore.value.activeExecution);
const toast = useToast();
const state = ref<
@@ -43,8 +42,8 @@ export function useLogsExecutionData({ isEnabled, filter }: UseLogsExecutionData
| undefined
>();
const updateInterval = computed(() =>
workflowsStore.workflowExecutionData?.status === 'running' &&
Object.keys(workflowsStore.workflowExecutionData.data?.resultData.runData ?? {}).length > 1
currentExecution.value?.status === 'running' &&
Object.keys(currentExecution.value.data?.resultData.runData ?? {}).length > 1
? LOGS_EXECUTION_DATA_THROTTLE_DURATION
: 0,
);
@@ -107,14 +106,10 @@ export function useLogsExecutionData({ isEnabled, filter }: UseLogsExecutionData
function resetExecutionData() {
state.value = undefined;
useWorkflowExecutionStateStore(workflowDocumentStore.value.documentId).setWorkflowExecutionData(
null,
);
workflowExecutionStateStore.value.setWorkflowExecutionData(null);
nodeHelpers.updateNodesExecutionIssues();
// Clear partial execution destination to allow full workflow execution
useWorkflowExecutionStateStore(
createWorkflowDocumentId(workflowsStore.workflowId),
).setChatPartialExecutionDestinationNode(null);
workflowExecutionStateStore.value.setChatPartialExecutionDestinationNode(null);
void workflowsStore.fetchLastSuccessfulExecution();
}
@@ -146,20 +141,20 @@ export function useLogsExecutionData({ isEnabled, filter }: UseLogsExecutionData
watch(
// Fields that should trigger update
[
() => workflowsStore.workflowExecutionData?.id,
() => workflowsStore.workflowExecutionData?.workflowData.id,
() => workflowsStore.workflowExecutionData?.status,
() => workflowsStore.workflowExecutionResultDataLastUpdate,
() => workflowsStore.workflowExecutionStartedData,
() => currentExecution.value?.id,
() => currentExecution.value?.workflowData.id,
() => currentExecution.value?.status,
() => workflowExecutionStateStore.value.activeExecutionResultDataLastUpdate,
() => workflowExecutionStateStore.value.activeExecutionStartedData,
],
useThrottleFn(
([executionId], [previousExecutionId]) => {
state.value =
workflowsStore.workflowExecutionData === null
currentExecution.value === null
? undefined
: {
response: copyExecutionData(workflowsStore.workflowExecutionData),
startData: workflowsStore.workflowExecutionStartedData?.[1] ?? {},
response: copyExecutionData(currentExecution.value),
startData: workflowExecutionStateStore.value.activeExecutionStartedData?.[1] ?? {},
};
if (executionId !== previousExecutionId) {
@@ -176,7 +171,7 @@ export function useLogsExecutionData({ isEnabled, filter }: UseLogsExecutionData
);
watch(
() => workflowsStore.workflowId,
() => workflowDocumentStore.value.workflowId,
() => {
resetExecutionData();
},
@@ -14,7 +14,6 @@ import { setActivePinia } from 'pinia';
import { computed, shallowRef } from 'vue';
import { WorkflowIdKey } from '@/app/constants/injectionKeys';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import {
injectWorkflowDocumentStore,
useWorkflowDocumentStore,
@@ -67,7 +66,6 @@ const render = (props: Partial<Props> = {}, pinData?: INodeExecutionData[], runD
setActivePinia(pinia);
const workflow = createTestWorkflow({ nodes, connections });
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = useWorkflowDocumentStore(createWorkflowDocumentId(workflow.id));
workflowDocumentStore.hydrate(workflow);
@@ -79,11 +77,10 @@ const render = (props: Partial<Props> = {}, pinData?: INodeExecutionData[], runD
}
if (runData) {
// The component reads run data via `workflowsStore.getWorkflowExecution`, which
// resolves through the execution-state store keyed by `workflowsStore.workflowId`.
useWorkflowExecutionStateStore(
createWorkflowDocumentId(workflowsStore.workflowId),
).setWorkflowExecutionData({
// The component reads run data via `injectWorkflowExecutionStateStore()`,
// which resolves through the execution-state store keyed by the injected
// workflow document's id.
useWorkflowExecutionStateStore(createWorkflowDocumentId(workflow.id)).setWorkflowExecutionData({
id: '',
workflowData: {
id: '',
@@ -10,9 +10,8 @@ import {
} from '@/app/constants';
import { useUIStore } from '@/app/stores/ui.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { injectWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { waitingNodeTooltip } from '@/features/execution/executions/executions.utils';
import { useExecutionRedaction } from '@/features/execution/executions/composables/useExecutionRedaction';
import uniqBy from 'lodash/uniqBy';
@@ -106,11 +105,9 @@ const inputModes = [
const workflowId = useInjectWorkflowId();
const nodeTypesStore = useNodeTypesStore();
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const workflowExecutionStateStore = computed(() =>
useWorkflowExecutionStateStore(workflowDocumentStore.value.documentId),
);
const workflowExecutionStateStore = injectWorkflowExecutionStateStore();
const workflowExecution = computed(() => workflowExecutionStateStore.value.activeExecution);
const router = useRouter();
const { runWorkflow } = useRunWorkflow({ router });
const { canReveal, isDynamicCredentials, revealData } = useExecutionRedaction();
@@ -135,15 +132,12 @@ const rootNode = computed(() => {
});
const hasRootNodeRun = computed(() => {
return !!(
rootNode.value && workflowsStore.getWorkflowExecution?.data?.resultData.runData[rootNode.value]
);
return !!(rootNode.value && workflowExecution.value?.data?.resultData.runData[rootNode.value]);
});
const inputMode = ref<MappingMode>(
// Show debugging mode by default only when the node has already run
activeNode.value &&
workflowsStore.getWorkflowExecution?.data?.resultData.runData[activeNode.value.name]
activeNode.value && workflowExecution.value?.data?.resultData.runData[activeNode.value.name]
? 'debugging'
: 'mapping',
);
@@ -199,7 +193,7 @@ const isExecutingPrevious = computed(() => {
if (!workflowExecutionStateStore.value.isWorkflowRunning) {
return false;
}
const triggeredNode = workflowsStore.executedNode;
const triggeredNode = workflowExecutionStateStore.value.activeExecutionExecutedNode;
const executingNode = workflowExecutionStateStore.value.executingNode.executingNode;
if (
@@ -271,7 +265,7 @@ const waitingMessage = computed(() => {
const parentNode = parentNodes.value[0];
if (!parentNode) return '';
const runData = workflowsStore.getWorkflowExecution?.data?.resultData?.runData;
const runData = workflowExecution.value?.data?.resultData?.runData;
const parentRunData = runData?.[parentNode.name]?.[0];
return waitingNodeTooltip(
@@ -3,7 +3,6 @@ import { ref, computed, onMounted, watch } from 'vue';
import { NodeConnectionTypes, type IRunData } from 'n8n-workflow';
import RunData from '@/features/ndv/runData/components/RunData.vue';
import RunInfo from '@/features/ndv/runData/components/RunInfo.vue';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { injectNDVStore } from '@/features/ndv/shared/ndv.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import RunDataAi from '@/features/ndv/runData/components/ai/RunDataAi.vue';
@@ -27,7 +26,7 @@ import { N8nIcon, N8nRadioButtons, N8nSpinner, N8nText } from '@n8n/design-syste
import { useUIStore } from '@/app/stores/ui.store';
import { WORKFLOW_SETTINGS_MODAL_KEY } from '@/app/constants';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { injectWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
// Types
type RunDataRef = InstanceType<typeof RunData>;
@@ -79,11 +78,8 @@ const emit = defineEmits<{
const workflowId = useInjectWorkflowId();
const ndvStore = injectNDVStore();
const nodeTypesStore = useNodeTypesStore();
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const workflowExecutionStateStore = computed(() =>
useWorkflowExecutionStateStore(workflowDocumentStore.value.documentId),
);
const workflowExecutionStateStore = injectWorkflowExecutionStateStore();
const telemetry = useTelemetry();
const i18n = useI18n();
const activeNode = computed(() => ndvStore.value.activeNode);
@@ -136,7 +132,9 @@ const hasAiMetadata = computed(() => {
node.value.name,
'ALL_NON_MAIN',
);
const resultData = connectedSubNodes.map(workflowsStore.getWorkflowResultDataByNodeName);
const resultData = connectedSubNodes.map(
workflowExecutionStateStore.value.getActiveExecutionRunDataByNodeName,
);
return resultData && Array.isArray(resultData) && resultData.length > 0;
}
@@ -51,6 +51,16 @@ describe('TriggerPanel.vue', () => {
vi.resetAllMocks();
});
function setExecutedNode(executedNode: string) {
// Testing pinia makes store getters writable at runtime; the cast makes
// that writability visible to the type checker.
const executionStateStore = mockedStore(
useWorkflowExecutionStateStore,
createWorkflowDocumentId('1'),
) as unknown as { activeExecutionExecutedNode: string | undefined };
executionStateStore.activeExecutionExecutedNode = executedNode;
}
it('renders default state', () => {
const { getByTestId } = renderComponent(TriggerPanel, {
props: { nodeName: 'Webhook' },
@@ -69,7 +79,7 @@ describe('TriggerPanel.vue', () => {
useWorkflowExecutionStateStore(createWorkflowDocumentId('1')).setExecutionWaitingForWebhook(
true,
);
workflowsStore.executedNode = 'Webhook';
setExecutedNode('Webhook');
const { getByTestId } = renderComponent(TriggerPanel, {
props: { nodeName: 'Webhook' },
global: {
@@ -85,7 +95,7 @@ describe('TriggerPanel.vue', () => {
useWorkflowExecutionStateStore(createWorkflowDocumentId('1')).setExecutionWaitingForWebhook(
true,
);
workflowsStore.executedNode = 'OtherNode';
setExecutedNode('OtherNode');
const { queryByTestId } = renderComponent(TriggerPanel, {
props: { nodeName: 'Webhook' },
global: {
@@ -101,7 +111,7 @@ describe('TriggerPanel.vue', () => {
useWorkflowExecutionStateStore(createWorkflowDocumentId('1')).setExecutionWaitingForWebhook(
true,
);
workflowsStore.executedNode = 'ChildNode';
setExecutedNode('ChildNode');
vi.spyOn(workflowDocStore, 'getParentNodes').mockReturnValue(['Webhook']);
const { getByTestId } = renderComponent(TriggerPanel, {
props: { nodeName: 'Webhook' },
@@ -118,7 +128,7 @@ describe('TriggerPanel.vue', () => {
useWorkflowExecutionStateStore(createWorkflowDocumentId('1')).setExecutionWaitingForWebhook(
true,
);
workflowsStore.executedNode = 'UnrelatedNode';
setExecutedNode('UnrelatedNode');
const { queryByTestId } = renderComponent(TriggerPanel, {
props: { nodeName: 'Webhook' },
global: {
@@ -15,8 +15,7 @@ import NodeExecuteButton from '@/app/components/NodeExecuteButton.vue';
import CopyInput from '@/app/components/CopyInput.vue';
import NodeIcon from '@/app/components/NodeIcon.vue';
import { useUIStore } from '@/app/stores/ui.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { injectWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { injectNDVStore } from '@/features/ndv/shared/ndv.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { createEventBus } from '@n8n/utils/event-bus';
@@ -55,11 +54,8 @@ const emit = defineEmits<{
const workflowId = useInjectWorkflowId();
const nodesTypeStore = useNodeTypesStore();
const uiStore = useUIStore();
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const workflowExecutionStateStore = computed(() =>
useWorkflowExecutionStateStore(workflowDocumentStore.value.documentId),
);
const workflowExecutionStateStore = injectWorkflowExecutionStateStore();
const ndvStore = injectNDVStore();
const router = useRouter();
@@ -179,7 +175,7 @@ const isListeningForEvents = computed(() => {
return false;
}
const executedNode = workflowsStore.executedNode;
const executedNode = workflowExecutionStateStore.value.activeExecutionExecutedNode;
const isCurrentNodeExecuted = executedNode === props.nodeName;
const isChildNodeExecuted = executedNode
? (workflowDocumentStore?.value?.getParentNodes(executedNode).includes(props.nodeName) ?? false)
@@ -191,7 +187,7 @@ const isListeningForEvents = computed(() => {
const workflowRunning = computed(() => workflowExecutionStateStore.value.isWorkflowRunning);
const isActivelyPolling = computed(() => {
const triggeredNode = workflowsStore.executedNode;
const triggeredNode = workflowExecutionStateStore.value.activeExecutionExecutedNode;
return workflowRunning.value && isPollingNode.value && props.nodeName === triggeredNode;
});
@@ -112,6 +112,7 @@ vi.mock('vue-router', async () => {
let ndvStore: ReturnType<typeof mockedStore<typeof useNDVStore>>;
const workflowDocumentStoreMock = {
documentId: createWorkflowDocumentId(''),
getChildNodes: vi.fn().mockReturnValue([]),
getParentNodes: vi.fn().mockReturnValue([]),
getParentNodesByDepth: vi.fn().mockReturnValue([]),
@@ -27,7 +27,7 @@ import {
import { isFullExecutionResponse, isResourceMapperValue } from '@/app/utils/typeGuards';
import { i18n as locale } from '@n8n/i18n';
import { injectNDVStore } from '@/features/ndv/shared/ndv.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { injectWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { useDocumentVisibility } from '@/app/composables/useDocumentVisibility';
import isEqual from 'lodash/isEqual';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
@@ -49,7 +49,7 @@ type Props = {
const nodeTypesStore = useNodeTypesStore();
const ndvStore = injectNDVStore();
const workflowsStore = useWorkflowsStore();
const workflowExecutionStateStore = injectWorkflowExecutionStateStore();
const projectsStore = useProjectsStore();
const expressionLocalResolveCtx = inject(ExpressionLocalResolveContextSymbol, undefined);
const workflowDocumentStore = injectWorkflowDocumentStore();
@@ -140,7 +140,7 @@ async function checkStaleFields(): Promise<void> {
// Reload fields to map when node is executed
watch(
() => workflowsStore.getWorkflowExecution,
() => workflowExecutionStateStore.value.activeExecution,
async (data) => {
if (
data &&
@@ -2,7 +2,7 @@
import { computed } from 'vue';
import type { IBinaryData, IRunData } from 'n8n-workflow';
import BinaryDataDisplayEmbed from './BinaryDataDisplayEmbed.vue';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { injectWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { useNodeHelpers } from '@/app/composables/useNodeHelpers';
import { useI18n } from '@n8n/i18n';
@@ -17,18 +17,13 @@ const emit = defineEmits<{
}>();
const nodeHelpers = useNodeHelpers();
const workflowsStore = useWorkflowsStore();
const workflowExecutionStateStore = injectWorkflowExecutionStateStore();
const i18n = useI18n();
const workflowRunData = computed<IRunData | null>(() => {
const workflowExecution = workflowsStore.getWorkflowExecution;
if (workflowExecution === null) {
return null;
}
const executionData = workflowExecution.data;
return executionData ? executionData.resultData.runData : null;
});
const workflowRunData = computed<IRunData | null>(
() => workflowExecutionStateStore.value.activeExecutionRunData,
);
const binaryData = computed<IBinaryData | null>(() => {
if (
@@ -63,7 +63,7 @@ import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
import { useCollaborationStore } from '@/features/collaboration/collaboration/collaboration.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { injectWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { executionDataToJson } from '@/app/utils/nodeTypesUtils';
import { getGenericHints } from '@/app/utils/nodeViewUtils';
import { searchInObject } from '@/app/utils/objectUtils';
@@ -232,7 +232,11 @@ const dataContainerRef = ref<HTMLDivElement>();
const workflowId = useInjectWorkflowId();
const nodeTypesStore = useNodeTypesStore();
const ndvStore = injectNDVStore();
const workflowsStore = useWorkflowsStore();
const workflowExecutionStateStore = injectWorkflowExecutionStateStore();
const currentExecution = computed(() => workflowExecutionStateStore.value.activeExecution);
const lastSuccessfulExecution = computed(
() => workflowExecutionStateStore.value.lastSuccessfulExecution,
);
const workflowDocumentStore = injectWorkflowDocumentStore();
const sourceControlStore = useSourceControlStore();
const collaborationStore = useCollaborationStore();
@@ -295,7 +299,7 @@ const hasAnyDataAvailable = computed(() => {
node.value?.disabled ||
hasPreviewSchema.value ||
hasAnyUpstreamExecuted.value ||
!!workflowsStore.lastSuccessfulExecution
!!lastSuccessfulExecution.value
);
});
const isSingleNodeView = computed(() => !displaysMultipleNodes.value);
@@ -314,7 +318,7 @@ const shouldShowSchemaView = computed(() => {
return (
hasNodeRun.value ||
hasPreviewSchema.value ||
(!hasNodeRun.value && (hasAnyUpstreamExecuted.value || workflowsStore.lastSuccessfulExecution))
(!hasNodeRun.value && (hasAnyUpstreamExecuted.value || lastSuccessfulExecution.value))
);
});
@@ -420,7 +424,7 @@ const executionHints = computed(() => {
});
const workflowExecution = computed(
() => props.workflowExecution ?? workflowsStore.getWorkflowExecution?.data ?? undefined,
() => props.workflowExecution ?? currentExecution.value?.data ?? undefined,
);
const workflowRunData = computed(() => {
if (workflowExecution.value === undefined) {
@@ -441,7 +445,7 @@ const isTrimmedManualExecutionDataItem = computed(() =>
);
const isExecutionInTerminalState = computed(() =>
isTerminalExecutionStatus(workflowsStore.getWorkflowExecution?.status ?? undefined),
isTerminalExecutionStatus(currentExecution.value?.status ?? undefined),
);
const isExecutionRedacted = computed(
@@ -967,7 +971,7 @@ function enterEditMode({ origin }: EnterEditModeArgs) {
: Object.keys(inputData ?? {}).length;
const lastSuccessfulExecutionItems = getOutputtedNodeItems(
workflowsStore.lastSuccessfulExecution,
lastSuccessfulExecution.value,
node.value,
);
previousExecutionDataUsedInEditMode.value =
@@ -2182,7 +2186,7 @@ defineExpose({ enterEditMode });
:class="$style.schema"
:compact="props.compact"
:truncate-limit="props.truncateLimit"
:preview-execution="workflowsStore.lastSuccessfulExecution"
:preview-execution="lastSuccessfulExecution"
@clear:search="onSearchClear"
@execute="executeNode"
/>
@@ -2,7 +2,7 @@
import { useExternalHooks } from '@/app/composables/useExternalHooks';
import type { INodeUi, IRunDataDisplayMode, ITableData } from '@/Interface';
import { injectNDVStore } from '@/features/ndv/shared/ndv.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { injectWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { getMappedExpression } from '@/app/utils/mappingUtils';
import { getPairedItemId } from '@/app/utils/pairedItemUtils';
@@ -76,7 +76,7 @@ const draggableRef = ref<DraggableRef>();
const fixedColumnWidths = ref<number[] | undefined>();
const ndvStore = injectNDVStore();
const workflowsStore = useWorkflowsStore();
const workflowExecutionStateStore = injectWorkflowExecutionStateStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const i18n = useI18n();
@@ -90,7 +90,9 @@ const highlight = computed(() => ndvStore.value.highlightDraggables);
const canDraggableDrop = computed(() => ndvStore.value.canDraggableDrop);
const draggableStickyPosition = computed(() => ndvStore.value.draggableStickyPos);
const pairedItemMappings = computed(() => workflowsStore.workflowExecutionPairedItemMappings);
const pairedItemMappings = computed(
() => workflowExecutionStateStore.value.activeExecutionPairedItemMappings,
);
const tableData = computed(() => convertToTable(props.inputData));
const collapsingColumnIndex = computed(() => {
if (!props.collapsingColumnName) {
@@ -11,6 +11,7 @@ import { useChatPanelStore } from '@/features/ai/assistant/chatPanel.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useNDVStore } from '@/features/ndv/shared/ndv.store';
import { createWorkflowDocumentId } from '@/app/stores/workflowDocument.store';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
const mockRouterResolve = vi.fn(() => ({
@@ -238,7 +239,11 @@ describe('NodeErrorView.vue', () => {
it('opens new window when error has different workflow and execution IDs', async () => {
mockWorkflowsStore.workflowId = 'current-workflow-id';
mockWorkflowsStore.getWorkflowExecution = {
const mockExecutionStateStore = mockedStore(
useWorkflowExecutionStateStore,
createWorkflowDocumentId('current-workflow-id'),
) as unknown as { activeExecution: IExecutionResponse | null };
mockExecutionStateStore.activeExecution = {
id: 'current-execution-id',
} as IExecutionResponse;
@@ -273,7 +278,11 @@ describe('NodeErrorView.vue', () => {
it('sets active node name when error is in current workflow/execution', async () => {
mockWorkflowsStore.workflowId = 'current-workflow-id';
mockNDVStore = mockedStore(useNDVStore, createWorkflowDocumentId('current-workflow-id'));
mockWorkflowsStore.getWorkflowExecution = {
const mockExecutionStateStore = mockedStore(
useWorkflowExecutionStateStore,
createWorkflowDocumentId('current-workflow-id'),
) as unknown as { activeExecution: IExecutionResponse | null };
mockExecutionStateStore.activeExecution = {
id: 'current-execution-id',
} as IExecutionResponse;
@@ -8,7 +8,7 @@ import { useToast } from '@/app/composables/useToast';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { injectNDVStore } from '@/features/ndv/shared/ndv.store';
import { useEditorContext } from '@/app/composables/useEditorContext';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { injectWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { useRootStore } from '@n8n/stores/useRootStore';
import type {
IDataObject,
@@ -54,13 +54,13 @@ const assistantHelpers = useAIAssistantHelpers();
const workflowId = useInjectWorkflowId();
const nodeTypesStore = useNodeTypesStore();
const ndvStore = injectNDVStore();
const workflowsStore = useWorkflowsStore();
const workflowExecutionStateStore = injectWorkflowExecutionStateStore();
const rootStore = useRootStore();
const assistantStore = useAssistantStore();
const chatPanelStore = useChatPanelStore();
const uiStore = useUIStore();
const executionId = computed(() => workflowsStore.getWorkflowExecution?.id);
const executionId = computed(() => workflowExecutionStateStore.value.activeExecution?.id);
const displayCause = computed(() => {
return JSON.stringify(props.error.cause ?? '').length < MAX_DISPLAY_DATA_SIZE;
@@ -26,6 +26,7 @@ import {
createWorkflowDocumentId,
type WorkflowDocumentId,
} from '@/app/stores/workflowDocument.store';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { computed, inject, ref, type ShallowRef } from 'vue';
import type { TelemetryNdvSource } from '@/app/types/telemetry';
import { WorkflowDocumentStoreKey } from '@/app/constants/injectionKeys';
@@ -104,14 +105,17 @@ function defineNDVStore(id: NDVStoreId) {
const highlightDraggables = ref(false);
const lastSetActiveNodeSource = ref<TelemetryNdvSource>();
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = useWorkflowDocumentStore(id);
const executionStateStore = useWorkflowExecutionStateStore(id);
const activeNode = computed(() => {
return workflowDocumentStore.getNodeByName(activeNodeName.value || '') ?? null;
});
const ndvInputData = computed(() => {
const executionData = workflowsStore.getWorkflowExecution;
// Touch the timestamp so in-place runData mutations (which keep the
// execution object reference) still propagate.
void executionStateStore.activeExecutionResultDataLastUpdate;
const executionData = executionStateStore.activeExecution;
const inputNodeName: string | undefined = input.value.nodeName;
const inputRunIndex: number = input.value.run ?? 0;
const inputBranchIndex: number = input.value.branch ?? 0;
@@ -22,8 +22,7 @@ import {
import type { DataPinningDiscoveryEvent } from '@/app/event-bus';
import { dataPinningEventBus } from '@/app/event-bus';
import { ndvEventBus } from '@/features/ndv/shared/ndv.eventBus';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { injectWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { injectNDVStore } from '@/features/ndv/shared/ndv.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
@@ -69,11 +68,8 @@ const nodeHelpers = useNodeHelpers();
const activeNode = computed(() => ndvStore.value.activeNode);
const pinnedData = usePinnedData(activeNode);
const nodeTypesStore = useNodeTypesStore();
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const workflowExecutionStateStore = computed(() =>
useWorkflowExecutionStateStore(workflowDocumentStore.value.documentId),
);
const workflowExecutionStateStore = injectWorkflowExecutionStateStore();
const deviceSupport = useDeviceSupport();
const workflowId = useInjectWorkflowId();
const telemetry = useTelemetry();
@@ -219,7 +215,7 @@ const isActiveStickyNode = computed(
() => !!ndvStore.value.activeNode && ndvStore.value.activeNode.type === STICKY_NODE_TYPE,
);
const workflowExecution = computed(() => workflowsStore.getWorkflowExecution);
const workflowExecution = computed(() => workflowExecutionStateStore.value.activeExecution);
const maxOutputRun = computed(() => {
if (activeNode.value === null) {
@@ -31,8 +31,7 @@ import { ndvEventBus } from '../ndv.eventBus';
import { injectNDVStore } from '../ndv.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useUIStore } from '@/app/stores/ui.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { injectWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
import { useI18n } from '@n8n/i18n';
@@ -71,7 +70,6 @@ const activeNode = computed(() => ndvStore.value.activeNode);
const pinnedData = usePinnedData(activeNode);
const nodeTypesStore = useNodeTypesStore();
const uiStore = useUIStore();
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const deviceSupport = useDeviceSupport();
const workflowId = useInjectWorkflowId();
@@ -220,7 +218,8 @@ const isActiveStickyNode = computed(
() => !!ndvStore.value.activeNode && ndvStore.value.activeNode.type === STICKY_NODE_TYPE,
);
const workflowExecution = computed(() => workflowsStore.getWorkflowExecution);
const workflowExecutionStateStore = injectWorkflowExecutionStateStore();
const workflowExecution = computed(() => workflowExecutionStateStore.value.activeExecution);
const maxOutputRun = computed(() => {
if (activeNode.value === null) {
@@ -304,9 +303,7 @@ const outputPanelEditMode = computed(() => ndvStore.value.outputPanelEditMode);
const isWorkflowRunning = computed(() => uiStore.isActionActive.workflowRunning);
const isExecutionWaitingForWebhook = computed(
() =>
useWorkflowExecutionStateStore(workflowDocumentStore.value.documentId)
.executionWaitingForWebhook,
() => workflowExecutionStateStore.value.executionWaitingForWebhook,
);
const blockUi = computed(() => isWorkflowRunning.value || isExecutionWaitingForWebhook.value);
@@ -1,6 +1,6 @@
import { escape } from '../utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { injectWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { useNDVStore } from '@/features/ndv/shared/ndv.store';
import { isAllowedInDotNotation } from '@/features/shared/editors/plugins/codemirror/completions/utils';
import { useI18n } from '@n8n/i18n';
@@ -10,7 +10,7 @@ import { computed } from 'vue';
function useJsonFieldCompletions() {
const i18n = useI18n();
const workflowsStore = useWorkflowsStore();
const workflowExecutionStateStore = injectWorkflowExecutionStateStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const ndvStore = computed(() => useNDVStore(workflowDocumentStore.value.documentId));
@@ -273,7 +273,7 @@ function useJsonFieldCompletions() {
} catch {}
}
const runData: IRunData | null = workflowsStore.getWorkflowRunData;
const runData: IRunData | null = workflowExecutionStateStore.value.activeExecutionRunData;
const nodeRunData = runData?.[nodeName];
@@ -45,7 +45,7 @@ import { EditorView, type ViewUpdate } from '@codemirror/view';
import debounce from 'lodash/debounce';
import isEqual from 'lodash/isEqual';
import { useI18n } from '@n8n/i18n';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { injectWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { useAutocompleteTelemetry } from '@/app/composables/useAutocompleteTelemetry';
import { ignoreUpdateAnnotation } from '@/app/utils/forceParse';
import {
@@ -83,7 +83,7 @@ export const useExpressionEditor = ({
}) => {
const ndvStore = injectNDVStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const workflowsStore = useWorkflowsStore();
const workflowExecutionStateStore = injectWorkflowExecutionStateStore();
const workflowHelpers = useWorkflowHelpers();
const { isMacOs } = useDeviceSupport();
const i18n = useI18n();
@@ -419,7 +419,7 @@ export const useExpressionEditor = ({
}
} catch (error) {
const hasRunData =
!!workflowsStore.workflowExecutionData?.data?.resultData?.runData[
!!workflowExecutionStateStore.value.activeExecutionRunData?.[
ndvStore.value.activeNode?.name ?? ''
];
result.resolved = `[${getExpressionErrorMessage(error, workflowDocumentStore.value.getPinDataSnapshot(), hasRunData)}]`;
@@ -524,7 +524,10 @@ export const useExpressionEditor = ({
});
watch(
[() => workflowsStore.getWorkflowExecution, () => workflowsStore.getWorkflowRunData],
[
() => workflowExecutionStateStore.value.activeExecution,
() => workflowExecutionStateStore.value.activeExecutionRunData,
],
debouncedUpdateSegments,
);
@@ -4,7 +4,7 @@ import { useNodeHelpers } from '@/app/composables/useNodeHelpers';
import { autocompletableNodeNames } from '@/features/shared/editors/plugins/codemirror/completions/utils';
import useEnvironmentsStore from '@/features/settings/environments.ee/environments.store';
import { injectNDVStore } from '@/features/ndv/shared/ndv.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { injectWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { forceParse } from '@/app/utils/forceParse';
import { executionDataToJson } from '@/app/utils/nodeTypesUtils';
@@ -33,7 +33,7 @@ export function useTypescript(
) {
const { getInputDataWithPinned, getSchemaForExecutionData } = useDataSchema();
const ndvStore = injectNDVStore();
const workflowsStore = useWorkflowsStore();
const workflowExecutionStateStore = injectWorkflowExecutionStateStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const { debounce } = useDebounce();
const activeNodeName =
@@ -71,7 +71,7 @@ export function useTypescript(
if (node) {
const inputData: INodeExecutionData[] = getInputDataWithPinned(node);
const schema = getSchemaForExecutionData(executionDataToJson(inputData), true);
const execution = workflowsStore.getWorkflowExecution;
const execution = workflowExecutionStateStore.value.activeExecution;
const binaryData = useNodeHelpers()
.getBinaryData(
execution?.data?.resultData?.runData ?? null,
@@ -131,7 +131,10 @@ export function useTypescript(
}
watch(
[() => workflowsStore.getWorkflowExecution, () => workflowsStore.getWorkflowRunData],
[
() => workflowExecutionStateStore.value.activeExecution,
() => workflowExecutionStateStore.value.activeExecutionRunData,
],
debounce(onWorkflowDataChange, { debounceTime: 200, trailing: true }),
);