refactor(editor): Resolve workflow push handlers by id and remove workflow state store and composable (no-changelog) (#31514)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Grozav 2026-06-04 15:06:06 +03:00 committed by GitHub
parent b66d33c305
commit 15749aa39e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
73 changed files with 1301 additions and 1461 deletions

View File

@ -37,7 +37,6 @@ export const STORES = {
FOLDERS: 'folders',
MODULES: 'modules',
FOCUS_PANEL: 'focusPanel',
WORKFLOW_STATE: 'workflowState',
AI_TEMPLATES_STARTER_COLLECTION: 'aiTemplatesStarterCollection',
PERSONALIZED_TEMPLATES: 'personalizedTemplates',
EXPERIMENT_READY_TO_RUN_WORKFLOWS: 'readyToRunWorkflows',

View File

@ -170,11 +170,35 @@ Object.defineProperty(workflowsStore, 'allNodes', {
});
```
## Current workflow id (`workflowsStore.workflowId`)
`workflowsStore.workflowId` is a deprecated global pointer to "the open workflow".
It is being removed: consumers become document-store-first and the id is derived
from the route. There is **no `storeToRefs` destructuring** of it — every access
is the literal `workflowsStore.workflowId` member expression, guarded by ESLint
(`no-restricted-syntax`, `warn` during migration, flipped to `error` once empty).
The per-document store is keyed *by* the id, so it cannot itself answer "which
workflow is current" — that comes from the route (or the injected current-document
pointer). Pick the replacement by context:
| Context | Get the current document | Read |
|---|---|---|
| Components / composables inside `WorkflowLayout` | `injectWorkflowDocumentStore()` (`ShallowRef<Store \| null>`) | `workflowDocumentStore.value?.workflowId ?? ''` |
| Out-of-tree Pinia stores / standalone composables | `computed(() => useWorkflowDocumentStore(createWorkflowDocumentId(useWorkflowId().value)))` | `workflowDocumentStore.value.workflowId` |
| Non-reactive functions (push handlers) | receive `documentId` via `options`, resolved per event in `usePushConnection.processEvent` from the injected document store | `useWorkflowDocumentStore(documentId)` / `useWorkflowExecutionStateStore(documentId)`, and their `.workflowId` for equality guards |
| Lifecycle snapshot (e.g. collaboration) | capture the id into a local/ref at the start of the operation | the captured value |
Notes:
- `useWorkflowId()` (`@/app/composables/useWorkflowId`) resolves `inject(WorkflowIdKey)` first, then the route — use it in any setup context.
- Replace `watch(() => workflowsStore.workflowId, …)` with `watch(workflowId, …)` where `workflowId = useWorkflowId()` (or the route-derived computed).
- Tests that set `workflowsStore.workflowId = 'x'` (or `workflow.id`) move to providing `WorkflowIdKey` / the route param. Component/composable tests already `provide` `WorkflowIdKey`; push-handler tests pass `documentId` in the handler `options` argument.
## What NOT to migrate
- **`workflowsStore.workflowObject`** (39 files) — provides indirect node access via `Workflow` class methods (`.getNode()`, `.nodes`, `.getParentNodes()`, etc.). This is intentionally NOT migrated until both nodes **and** connections move to `workflowDocumentStore`. No ESLint guard for this — it's accepted tech debt.
- **Execution-related methods** (e.g., `renameNodeSelectedAndExecution`, `removeNodeExecutionDataById`) — these are not node document state
- **`workflowState.executingNode`** and other execution-state properties — these are not node document state
- **`workflowExecutionStateStore.executingNode`** and other execution-state properties — these are not node document state (they live in `useWorkflowExecutionStateStore`)
## Maintaining this recipe

View File

@ -116,6 +116,17 @@ export default defineConfig(
message:
'Use workflowDocumentStore.setLastNodeParameters() instead of workflowsStore.setLastNodeParameters()',
},
{
selector: "MemberExpression[property.name='workflowId'][object.name='workflowsStore']",
message:
'Use the workflow document store instead of workflowsStore.workflowId: workflowDocumentStore.workflowId (components/composables via injectWorkflowDocumentStore(); stores via useWorkflowId()) or the documentId from the handler options in push handlers',
},
{
selector:
"CallExpression[callee.property.name='setWorkflowId'][callee.object.name='workflowsStore']",
message:
'Do not call workflowsStore.setWorkflowId() — the current workflow id is derived from the route (useWorkflowId())',
},
],
// TODO: Remove these
'n8n-local-rules/no-internal-package-import': 'warn',

View File

@ -39,6 +39,7 @@ import { useTelemetry } from '@/app/composables/useTelemetry';
import { computedAsync } from '@vueuse/core';
import { useExecutionData } from '@/features/execution/executions/composables/useExecutionData';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import ExperimentalNodeDetailsDrawer from '@/features/workflows/canvas/experimental/components/ExperimentalNodeDetailsDrawer.vue';
import { useExperimentalNdvStore } from '@/features/workflows/canvas/experimental/experimentalNdv.store';
import { injectNDVStore } from '@/features/ndv/shared/ndv.store';
@ -51,7 +52,6 @@ import { useSetupPanelStore } from '@/features/setupPanel/setupPanel.store';
import { N8nIcon, N8nInfoTip, N8nInput, N8nRadioButtons, N8nText } from '@n8n/design-system';
import { useInjectWorkflowId } from '@/app/composables/useInjectWorkflowId';
import { injectWorkflowState } from '@/app/composables/useWorkflowState';
defineOptions({ name: 'FocusPanel' });
const props = defineProps<{
@ -73,7 +73,9 @@ const nodeHelpers = useNodeHelpers();
const focusPanelStore = useFocusPanelStore();
const workflowId = useInjectWorkflowId();
const workflowDocumentStore = injectWorkflowDocumentStore();
const workflowState = injectWorkflowState();
const workflowExecutionStateStore = computed(() =>
useWorkflowExecutionStateStore(workflowDocumentStore.value.documentId),
);
const nodeTypesStore = useNodeTypesStore();
const setupPanelStore = useSetupPanelStore();
const telemetry = useTelemetry();
@ -225,7 +227,7 @@ const targetNodeParameterContext = computed<TargetNodeParameterContext | undefin
});
const isNodeExecuting = computed(() =>
workflowState.executingNode.isNodeExecuting(node.value?.name ?? ''),
workflowExecutionStateStore.value.executingNode.isNodeExecuting(node.value?.name ?? ''),
);
const selectedNodeIds = computed(() => vueFlow.getSelectedNodes.value.map((n) => n.id));

View File

@ -34,11 +34,6 @@ import { usePinnedData } from '@/app/composables/usePinnedData';
import { useMessage } from '@/app/composables/useMessage';
import { useToast } from '@/app/composables/useToast';
import * as buttonParameterUtils from '@/features/ndv/parameters/utils/buttonParameter.utils';
import {
injectWorkflowState,
useWorkflowState,
type WorkflowState,
} from '@/app/composables/useWorkflowState';
vi.mock('vue-router', () => ({
useRouter: () => ({}),
@ -105,14 +100,6 @@ vi.mock('@/app/composables/useMessage', () => {
};
});
vi.mock('@/app/composables/useWorkflowState', async () => {
const actual = await vi.importActual('@/app/composables/useWorkflowState');
return {
...actual,
injectWorkflowState: vi.fn(),
};
});
vi.mock('@/app/stores/workflowDocument.store', async (importOriginal) => ({
...(await importOriginal()),
injectWorkflowDocumentStore: vi.fn(),
@ -128,7 +115,7 @@ let runWorkflow: ReturnType<typeof useRunWorkflow>;
let externalHooks: ReturnType<typeof useExternalHooks>;
let message: ReturnType<typeof useMessage>;
let toast: ReturnType<typeof useToast>;
let workflowState: WorkflowState;
let workflowExecutionStateStore: ReturnType<typeof useWorkflowExecutionStateStore>;
let nodeViewEventBusEmitSpy: ReturnType<typeof vi.spyOn>;
describe('NodeExecuteButton', () => {
@ -148,8 +135,9 @@ describe('NodeExecuteButton', () => {
workflowsStore.workflowId = 'abc123';
workflowDocumentStore = useWorkflowDocumentStore(createWorkflowDocumentId('abc123'));
vi.mocked(injectWorkflowDocumentStore).mockReturnValue(shallowRef(workflowDocumentStore));
workflowState = useWorkflowState();
vi.mocked(injectWorkflowState).mockReturnValue(workflowState);
workflowExecutionStateStore = useWorkflowExecutionStateStore(
createWorkflowDocumentId('abc123'),
);
nodeTypesStore = mockedStore(useNodeTypesStore);
ndvStore = mockedStore(useNDVStore, createWorkflowDocumentId('abc123'));
@ -238,7 +226,7 @@ describe('NodeExecuteButton', () => {
it('displays "Stop Listening" when node is running and is a trigger node', () => {
const node = mockNode({ name: 'test-node', type: SET_NODE_TYPE });
vi.spyOn(workflowDocumentStore, 'getNodeByName').mockReturnValue(node);
workflowState.executingNode.isNodeExecuting = vi.fn().mockReturnValue(true);
workflowExecutionStateStore.executingNode.isNodeExecuting = vi.fn().mockReturnValue(true);
nodeTypesStore.isTriggerNode = () => true;
vi.spyOn(
useWorkflowExecutionStateStore(createWorkflowDocumentId('abc123')),
@ -253,7 +241,7 @@ describe('NodeExecuteButton', () => {
it('sets button to loading state when node is executing', () => {
const node = mockNode({ name: 'test-node', type: SET_NODE_TYPE });
vi.spyOn(workflowDocumentStore, 'getNodeByName').mockReturnValue(node);
workflowState.executingNode.isNodeExecuting = vi.fn().mockReturnValue(true);
workflowExecutionStateStore.executingNode.isNodeExecuting = vi.fn().mockReturnValue(true);
vi.spyOn(
useWorkflowExecutionStateStore(createWorkflowDocumentId('abc123')),
'isWorkflowRunning',
@ -288,7 +276,7 @@ describe('NodeExecuteButton', () => {
'isWorkflowRunning',
'get',
).mockReturnValue(true);
workflowState.executingNode.isNodeExecuting = vi.fn().mockReturnValue(false);
workflowExecutionStateStore.executingNode.isNodeExecuting = vi.fn().mockReturnValue(false);
vi.spyOn(workflowDocumentStore, 'getNodeByName').mockReturnValue(
mockNode({ name: 'test-node', type: SET_NODE_TYPE }),
);
@ -348,8 +336,8 @@ describe('NodeExecuteButton', () => {
'get',
).mockReturnValue(true);
nodeTypesStore.isTriggerNode = () => true;
useWorkflowState().setActiveExecutionId('test-execution-id');
workflowState.executingNode.isNodeExecuting = vi.fn().mockReturnValue(true);
workflowExecutionStateStore.setActiveExecutionId('test-execution-id');
workflowExecutionStateStore.executingNode.isNodeExecuting = vi.fn().mockReturnValue(true);
vi.spyOn(workflowDocumentStore, 'getNodeByName').mockReturnValue(
mockNode({ name: 'test-node', type: SET_NODE_TYPE }),
);

View File

@ -1,12 +1,7 @@
<script lang="ts" setup>
import { computed, provide, shallowRef } from 'vue';
import {
WorkflowDocumentStoreKey,
WorkflowIdKey,
WorkflowStateKey,
} from '@/app/constants/injectionKeys';
import { useWorkflowState } from '@/app/composables/useWorkflowState';
import type { WorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { WorkflowDocumentStoreKey, WorkflowIdKey } from '@/app/constants/injectionKeys';
import { type WorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import WorkflowCanvasHostBody from './WorkflowCanvasHostBody.vue';
const props = withDefaults(
@ -34,10 +29,6 @@ const emit = defineEmits<{
const localWorkflowId = computed(() => props.workflowId);
provide(WorkflowIdKey, localWorkflowId);
// Scoped workflow state independent of any sibling editor.
const workflowState = useWorkflowState();
provide(WorkflowStateKey, workflowState);
// Document store ref is populated by useWorkflowInitialization in the body.
// NodeView and its descendants read it via WorkflowDocumentStoreKey.
const currentWorkflowDocumentStore = shallowRef<WorkflowDocumentStore | null>(null);

View File

@ -2,7 +2,7 @@
import { computed, onBeforeUnmount, onMounted, watch } from 'vue';
import { N8nIcon } from '@n8n/design-system';
import { injectStrict } from '@/app/utils/injectStrict';
import { WorkflowDocumentStoreKey, WorkflowStateKey } from '@/app/constants/injectionKeys';
import { WorkflowDocumentStoreKey } from '@/app/constants/injectionKeys';
import { useWorkflowInitialization } from '@/app/composables/useWorkflowInitialization';
import MainHeader from '@/app/components/MainHeader/MainHeader.vue';
import NodeView from '@/app/views/NodeView.vue';
@ -22,17 +22,15 @@ const emit = defineEmits<{
'workflow-loaded': [workflowId: string];
}>();
// Inject the host's scoped provides. Workflow id / state / document store all
// resolve to the host's local refs, not the app-level globals.
const workflowState = injectStrict(WorkflowStateKey);
// Inject the host's scoped provides. Workflow id / document store resolve to the
// host's local refs, not the app-level globals.
const currentWorkflowDocumentStore = injectStrict(WorkflowDocumentStoreKey);
const canvasStore = useCanvasStore();
const nodeCreatorStore = useNodeCreatorStore();
const workflowSaveStore = useWorkflowSaveStore();
const { isLoading, initializeData, initializeWorkflow, cleanup } =
useWorkflowInitialization(workflowState);
const { isLoading, initializeData, initializeWorkflow, cleanup } = useWorkflowInitialization();
// NOTE: push-connection handlers (executionStarted, nodeExecuteAfter, etc.) are
// initialized today via MainHeader's onBeforeMount calling

View File

@ -63,11 +63,7 @@ import { useTelemetry } from './useTelemetry';
import { useToast } from '@/app/composables/useToast';
import * as nodeHelpers from '@/app/composables/useNodeHelpers';
import * as workflowsApi from '@/app/api/workflows';
import {
injectWorkflowState,
useWorkflowState,
type WorkflowState,
} from '@/app/composables/useWorkflowState';
import { useBuilderStore } from '@/features/ai/assistant/builder.store';
import {
useWorkflowDocumentStore,
createWorkflowDocumentId,
@ -158,14 +154,6 @@ vi.mock('@/app/composables/useToast', () => {
};
});
vi.mock('@/app/composables/useWorkflowState', async () => {
const actual = await vi.importActual('@/app/composables/useWorkflowState');
return {
...actual,
injectWorkflowState: vi.fn(),
};
});
const canPinNodeMock = vi.fn();
const setDataMock = vi.fn();
const unsetDataMock = vi.fn();
@ -218,7 +206,6 @@ describe('useCanvasOperations', () => {
type Writable<T> = { -readonly [K in keyof T]: T[K] };
type WritableDocumentStore = Writable<ReturnType<typeof useWorkflowDocumentStore>>;
let workflowState: WorkflowState;
let workflowDocumentStoreInstance: WritableDocumentStore;
beforeEach(() => {
@ -228,9 +215,6 @@ describe('useCanvasOperations', () => {
const pinia = createTestingPinia({ initialState: createInitialState() });
setActivePinia(pinia);
workflowState = useWorkflowState();
vi.mocked(injectWorkflowState).mockReturnValue(workflowState);
const workflowsStore = useWorkflowsStore();
workflowDocumentStoreInstance = useWorkflowDocumentStore(
createWorkflowDocumentId(workflowsStore.workflowId),
@ -4584,6 +4568,7 @@ describe('useCanvasOperations', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const uiStore = mockedStore(useUIStore);
const executionsStore = mockedStore(useExecutionsStore);
const builderStore = mockedStore(useBuilderStore);
const credentialsUpdatedRef = ref(true);
const credentialsSpy = vi.spyOn(credentialsUpdatedRef, 'value', 'set');
@ -4594,7 +4579,6 @@ describe('useCanvasOperations', () => {
credentialsUpdated: credentialsUpdatedRef,
};
});
const resetStateSpy = vi.spyOn(workflowState, 'resetState');
nodeCreatorStore.setNodeCreatorState = vi.fn();
workflowsStore.removeTestWebhook = vi.fn();
@ -4646,11 +4630,16 @@ describe('useCanvasOperations', () => {
createNodeActive: false,
});
expect(workflowsStore.removeTestWebhook).toHaveBeenCalledWith('workflow-id');
expect(resetStateSpy).toHaveBeenCalled();
expect(executionStateStore.resetExecutionState).toHaveBeenCalled();
expect(builderStore.resetManualExecutionStats).toHaveBeenCalled();
expect(resetWorkflowSpy).toHaveBeenCalled();
// resetState() must run BEFORE resetWorkflow() — resetState reads workflowId
// to target the per-workflow execution-state store, and resetWorkflow empties it.
expect(resetStateSpy.mock.invocationCallOrder[0]).toBeLessThan(
// The execution-state reset must run BEFORE resetWorkflow() — it targets the
// per-workflow execution-state store keyed on workflowId, and resetWorkflow()
// empties that id.
const resetExecutionStateSpy = executionStateStore.resetExecutionState as ReturnType<
typeof vi.fn
>;
expect(resetExecutionStateSpy.mock.invocationCallOrder[0]).toBeLessThan(
resetWorkflowSpy.mock.invocationCallOrder[0],
);
expect(workflowsStore.currentWorkflowExecutions).toEqual([]);
@ -4776,7 +4765,12 @@ describe('useCanvasOperations', () => {
it('should initialize workspace and set execution data when execution is found', async () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const uiStore = mockedStore(useUIStore);
const setWorkflowExecutionData = vi.spyOn(workflowState, 'setWorkflowExecutionData');
// Production stages the execution on the store keyed by the document
// `initializeWorkspace` opens — i.e. the execution's `workflowData.id`.
const setWorkflowExecutionData = vi.spyOn(
useWorkflowExecutionStateStore(createWorkflowDocumentId(workflowId)),
'setWorkflowExecutionData',
);
const { openExecution } = useCanvasOperations();
@ -6978,8 +6972,6 @@ describe('useCanvasOperations', () => {
});
setActivePinia(pinia);
workflowState = useWorkflowState();
vi.mocked(injectWorkflowState).mockReturnValue(workflowState);
workflowDocumentStoreInstance = useWorkflowDocumentStore(
createWorkflowDocumentId(useWorkflowsStore().workflowId),
) as WritableDocumentStore;
@ -7044,8 +7036,6 @@ describe('useCanvasOperations', () => {
});
setActivePinia(pinia);
workflowState = useWorkflowState();
vi.mocked(injectWorkflowState).mockReturnValue(workflowState);
workflowDocumentStoreInstance = useWorkflowDocumentStore(
createWorkflowDocumentId(useWorkflowsStore().workflowId),
) as WritableDocumentStore;
@ -7108,8 +7098,6 @@ describe('useCanvasOperations', () => {
});
setActivePinia(pinia);
workflowState = useWorkflowState();
vi.mocked(injectWorkflowState).mockReturnValue(workflowState);
workflowDocumentStoreInstance = useWorkflowDocumentStore(
createWorkflowDocumentId(useWorkflowsStore().workflowId),
) as WritableDocumentStore;
@ -7176,8 +7164,6 @@ describe('useCanvasOperations', () => {
});
setActivePinia(pinia);
workflowState = useWorkflowState();
vi.mocked(injectWorkflowState).mockReturnValue(workflowState);
workflowDocumentStoreInstance = useWorkflowDocumentStore(
createWorkflowDocumentId(useWorkflowsStore().workflowId),
) as WritableDocumentStore;
@ -7240,8 +7226,6 @@ describe('useCanvasOperations', () => {
});
setActivePinia(pinia);
workflowState = useWorkflowState();
vi.mocked(injectWorkflowState).mockReturnValue(workflowState);
workflowDocumentStoreInstance = useWorkflowDocumentStore(
createWorkflowDocumentId(useWorkflowsStore().workflowId),
) as WritableDocumentStore;
@ -7320,8 +7304,6 @@ describe('useCanvasOperations', () => {
});
setActivePinia(pinia);
workflowState = useWorkflowState();
vi.mocked(injectWorkflowState).mockReturnValue(workflowState);
workflowDocumentStoreInstance = useWorkflowDocumentStore(
createWorkflowDocumentId(useWorkflowsStore().workflowId),
) as WritableDocumentStore;
@ -7396,8 +7378,6 @@ describe('useCanvasOperations', () => {
});
setActivePinia(pinia);
workflowState = useWorkflowState();
vi.mocked(injectWorkflowState).mockReturnValue(workflowState);
workflowDocumentStoreInstance = useWorkflowDocumentStore(
createWorkflowDocumentId(useWorkflowsStore().workflowId),
) as WritableDocumentStore;
@ -7474,8 +7454,6 @@ describe('useCanvasOperations', () => {
});
setActivePinia(pinia);
workflowState = useWorkflowState();
vi.mocked(injectWorkflowState).mockReturnValue(workflowState);
workflowDocumentStoreInstance = useWorkflowDocumentStore(
createWorkflowDocumentId(useWorkflowsStore().workflowId),
) as WritableDocumentStore;

View File

@ -65,7 +65,10 @@ import { useSettingsStore } from '@/app/stores/settings.store';
import { useTagsStore } from '@/features/shared/tags/tags.store';
import { useUIStore } from '@/app/stores/ui.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import {
disposeWorkflowExecutionStateStore,
useWorkflowExecutionStateStore,
} from '@/app/stores/workflowExecutionState.store';
import type {
CanvasConnection,
CanvasConnectionCreateData,
@ -127,7 +130,7 @@ import {
} from 'n8n-workflow';
import { computed, nextTick, ref, type DeepReadonly } from 'vue';
import { useUniqueNodeName } from '@/app/composables/useUniqueNodeName';
import { injectWorkflowState } from '@/app/composables/useWorkflowState';
import { useBuilderStore } from '@/features/ai/assistant/builder.store';
import { isPresent, tryToParseNumber } from '@/app/utils/typesUtils';
import { ensureNodePosition, sanitizeConnections } from '@/app/utils/workflowUtils';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
@ -187,7 +190,6 @@ type AddNodeOptions = AddNodesBaseOptions & {
export function useCanvasOperations() {
const rootStore = useRootStore();
const workflowsStore = useWorkflowsStore();
const workflowState = injectWorkflowState();
const credentialsStore = useCredentialsStore();
const historyStore = useHistoryStore();
const uiStore = useUIStore();
@ -2446,20 +2448,31 @@ export function useCanvasOperations() {
createNodeActive: false,
});
// Make sure that if there is a waiting test-webhook, it gets removed
const workflowId = workflowsStore.workflowId;
const executionStateStore = useWorkflowExecutionStateStore(
createWorkflowDocumentId(workflowsStore.workflowId),
createWorkflowDocumentId(workflowId),
);
// Make sure that if there is a waiting test-webhook, it gets removed
if (executionStateStore.executionWaitingForWebhook) {
try {
void workflowsStore.removeTestWebhook(workflowsStore.workflowId);
void workflowsStore.removeTestWebhook(workflowId);
} catch (error) {}
}
// Reset editable workflow state. resetState() must run BEFORE resetWorkflow()
// — it reads ws.workflowId to target the per-workflow execution-state store, and
// Reset editable workflow execution state. This must run BEFORE resetWorkflow()
// — it targets the per-workflow execution-state store keyed on workflowId, and
// resetWorkflow() empties that id.
workflowState.resetState();
//
// Disposes every tracked executionData store + IN_PROGRESS placeholder and clears
// all session-level fields, then disposes the per-workflow store so pinia state
// doesn't accumulate one entry per workflow opened in this session. Runs
// unconditionally so the shared empty-id store (unsaved workflows) is reset too —
// otherwise its execution state / executing-node queue would leak across resets.
executionStateStore.resetExecutionState();
disposeWorkflowExecutionStateStore(executionStateStore);
useBuilderStore().resetManualExecutionStats();
workflowsStore.resetWorkflow();
workflowsStore.clearCurrentWorkflowExecutions();
workflowsStore.setLastSuccessfulExecution(null);
@ -3216,7 +3229,7 @@ export function useCanvasOperations() {
data.workflowData,
);
workflowState.setWorkflowExecutionData(data);
useWorkflowExecutionStateStore(openedDocumentStore.documentId).setWorkflowExecutionData(data);
if (!['manual', 'evaluation'].includes(data.mode)) {
// Clear on the store initializeWorkspace just populated — injection

View File

@ -18,21 +18,13 @@ import {
} from 'n8n-workflow';
import { defineComponent, provide, shallowRef } from 'vue';
import { createRouter, createWebHistory, type RouteLocationNormalizedLoaded } from 'vue-router';
import { useWorkflowState, injectWorkflowState, type WorkflowState } from './useWorkflowState';
import {
useWorkflowDocumentStore,
createWorkflowDocumentId,
} from '@/app/stores/workflowDocument.store';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { WorkflowDocumentStoreKey } from '@/app/constants/injectionKeys';
vi.mock('@/app/composables/useWorkflowState', async () => {
const actual = await vi.importActual('@/app/composables/useWorkflowState');
return {
...actual,
injectWorkflowState: vi.fn(),
};
});
describe(useNodeDirtiness, () => {
let nodeTypeStore: ReturnType<typeof useNodeTypesStore>;
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
@ -40,7 +32,6 @@ describe(useNodeDirtiness, () => {
let historyHelper: ReturnType<typeof useHistoryHelper>;
let canvasOperations: ReturnType<typeof useCanvasOperations>;
let uiStore: ReturnType<typeof useUIStore>;
let workflowState: WorkflowState;
const NODE_RUN_AT = new Date('2025-01-01T00:00:01');
const WORKFLOW_UPDATED_AT = new Date('2025-01-01T00:00:10');
@ -56,8 +47,6 @@ describe(useNodeDirtiness, () => {
workflowsStore = useWorkflowsStore();
workflowsStore.setWorkflowId(TEST_WORKFLOW_ID);
historyHelper = useHistoryHelper({} as RouteLocationNormalizedLoaded);
workflowState = useWorkflowState();
vi.mocked(injectWorkflowState).mockReturnValue(workflowState);
workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(TEST_WORKFLOW_ID),
@ -162,8 +151,9 @@ describe(useNodeDirtiness, () => {
const runAt = new Date(+WORKFLOW_UPDATED_AT + 1000);
const workflowState = useWorkflowState();
workflowState.setWorkflowExecutionData({
useWorkflowExecutionStateStore(
createWorkflowDocumentId(workflowsStore.workflowId),
).setWorkflowExecutionData({
id: workflowsStore.workflowId,
finished: true,
mode: 'manual',
@ -497,8 +487,9 @@ describe(useNodeDirtiness, () => {
workflowDocumentStore.pinNodeData(name, [{ json: {} }]);
}
const workflowState = useWorkflowState();
workflowState.setWorkflowExecutionData({
useWorkflowExecutionStateStore(
createWorkflowDocumentId(workflowsStore.workflowId),
).setWorkflowExecutionData({
id: workflow.id,
finished: true,
mode: 'manual',

View File

@ -10,11 +10,6 @@ import {
import type { INodeTypeDescription } from 'n8n-workflow';
import { useNodeExecution } from '@/app/composables/useNodeExecution';
import {
injectWorkflowState,
useWorkflowState,
type WorkflowState,
} from '@/app/composables/useWorkflowState';
import { useUIStore } from '@/app/stores/ui.store';
import { needsAgentInput } from '@/app/utils/nodes/nodeTransforms';
import { generateCodeForAiTransform } from '@/features/ndv/parameters/utils/buttonParameter.utils';
@ -49,6 +44,9 @@ const {
isWorkflowRunning: false,
executionWaitingForWebhook: false,
chatPartialExecutionDestinationNode: undefined as string | undefined,
executingNode: {
isNodeExecuting: vi.fn().mockReturnValue(false),
},
},
mockNodeTypesStore: {
getNodeType: vi.fn(),
@ -164,14 +162,6 @@ vi.mock('@/app/composables/useExternalHooks', () => ({
}),
}));
vi.mock('@/app/composables/useWorkflowState', async () => {
const actual = await vi.importActual('@/app/composables/useWorkflowState');
return {
...actual,
injectWorkflowState: vi.fn(),
};
});
vi.mock('@/app/utils/nodes/nodeTransforms', () => ({
needsAgentInput: vi.fn().mockReturnValue(false),
}));
@ -192,7 +182,6 @@ function createTestNode(overrides: Partial<INodeUi> = {}): INodeUi {
} as INodeUi;
}
let workflowState: WorkflowState;
let uiStore: ReturnType<typeof useUIStore>;
describe('useNodeExecution', () => {
@ -200,9 +189,6 @@ describe('useNodeExecution', () => {
const pinia = createTestingPinia({ stubActions: false });
setActivePinia(pinia);
workflowState = vi.mocked(useWorkflowState());
vi.mocked(injectWorkflowState).mockReturnValue(workflowState);
uiStore = useUIStore();
// Reset store properties to defaults
@ -210,6 +196,9 @@ describe('useNodeExecution', () => {
mockWorkflowsStore.executedNode = undefined;
mockWorkflowExecutionStateStore.executionWaitingForWebhook = false;
mockWorkflowExecutionStateStore.chatPartialExecutionDestinationNode = undefined;
mockWorkflowExecutionStateStore.executingNode.isNodeExecuting
.mockReset()
.mockReturnValue(false);
mockWorkflowDocumentStore.checkIfNodeHasChatParent.mockReturnValue(false);
mockWorkflowsStore.removeTestWebhook.mockReset();
mockWorkflowsStore.getNodeByName.mockReset();

View File

@ -24,7 +24,6 @@ import { useMessage } from '@/app/composables/useMessage';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { useToast } from '@/app/composables/useToast';
import { useExternalHooks } from '@/app/composables/useExternalHooks';
import { injectWorkflowState } from '@/app/composables/useWorkflowState';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { needsAgentInput } from '@/app/utils/nodes/nodeTransforms';
@ -99,7 +98,6 @@ export function useNodeExecution(
const nodeTypesStore = useNodeTypesStore();
const ndvStore = injectNDVStore();
const uiStore = useUIStore();
const workflowState = injectWorkflowState();
const workflowDocumentStore = injectWorkflowDocumentStore();
const workflowExecutionStateStore = computed(() =>
@ -148,7 +146,7 @@ export function useNodeExecution(
return false;
const triggeredNode = workflowsStore.executedNode;
return (
workflowState.executingNode.isNodeExecuting(nodeRef.value?.name ?? '') ||
workflowExecutionStateStore.value.executingNode.isNodeExecuting(nodeRef.value?.name ?? '') ||
triggeredNode === nodeRef.value?.name
);
});

View File

@ -6,7 +6,8 @@ import { jsonParse } from 'n8n-workflow';
import { usePostMessageHandler } from './usePostMessageHandler';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useUIStore } from '@/app/stores/ui.store';
import type { WorkflowState } from '@/app/composables/useWorkflowState';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { createWorkflowDocumentId } from '@/app/stores/workflowDocument.store';
import type { IExecutionResponse } from '@/features/execution/executions/executions.types';
const mockImportWorkflowExact = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
@ -85,6 +86,7 @@ vi.mock('@/features/execution/executions/executions.utils', async (importOrigina
const mockRoute = vi.hoisted(() => ({
name: 'workflow' as string,
query: {} as Record<string, string>,
params: {} as Record<string, string>,
}));
vi.mock('vue-router', async (importOriginal) => {
const actual = (await importOriginal()) as object;
@ -94,12 +96,6 @@ vi.mock('vue-router', async (importOriginal) => {
};
});
function createMockWorkflowState(): WorkflowState {
return {
setWorkflowExecutionData: vi.fn(),
} as unknown as WorkflowState;
}
function dispatchPostMessage(payload: Record<string, unknown>) {
window.dispatchEvent(
new MessageEvent('message', {
@ -109,17 +105,23 @@ function dispatchPostMessage(payload: Record<string, unknown>) {
}
describe('usePostMessageHandler', () => {
let workflowState: WorkflowState;
beforeEach(() => {
vi.clearAllMocks();
setActivePinia(createTestingPinia());
mockIsProductionExecutionPreview.value = false;
mockRoute.name = 'workflow';
mockRoute.query = {};
workflowState = createMockWorkflowState();
mockRoute.params = {};
});
// The handler resolves the execution-state store via
// `currentWorkflowDocumentStore.value.documentId`. Tests pass a mock document
// store whose `documentId` is `createWorkflowDocumentId('')`, so this returns the
// same store instance the handler writes to.
function getExecutionStateStore() {
return useWorkflowExecutionStateStore(createWorkflowDocumentId(''));
}
afterEach(() => {
// Ensure listeners are cleaned up
});
@ -128,7 +130,6 @@ describe('usePostMessageHandler', () => {
it('should add message event listener on setup', () => {
const addEventListenerSpy = vi.spyOn(window, 'addEventListener');
const { setup } = usePostMessageHandler({
workflowState,
currentWorkflowDocumentStore: shallowRef(null),
});
@ -140,7 +141,6 @@ describe('usePostMessageHandler', () => {
it('should remove message event listener on cleanup', () => {
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
const { setup, cleanup } = usePostMessageHandler({
workflowState,
currentWorkflowDocumentStore: shallowRef(null),
});
@ -153,7 +153,6 @@ describe('usePostMessageHandler', () => {
it('should emit n8nReady postMessage on setup', () => {
const postMessageSpy = vi.spyOn(window.parent, 'postMessage');
const { setup, cleanup } = usePostMessageHandler({
workflowState,
currentWorkflowDocumentStore: shallowRef(null),
});
@ -170,7 +169,6 @@ describe('usePostMessageHandler', () => {
it('should include pushRef in n8nReady postMessage', () => {
const postMessageSpy = vi.spyOn(window.parent, 'postMessage');
const { setup, cleanup } = usePostMessageHandler({
workflowState,
currentWorkflowDocumentStore: shallowRef(null),
});
@ -190,7 +188,6 @@ describe('usePostMessageHandler', () => {
describe('openWorkflow command', () => {
it('should call importWorkflowExact when openWorkflow message is received', async () => {
const { setup, cleanup } = usePostMessageHandler({
workflowState,
currentWorkflowDocumentStore: shallowRef(null),
});
setup();
@ -213,7 +210,6 @@ describe('usePostMessageHandler', () => {
setActivePinia(createTestingPinia({ stubActions: false }));
const uiStore = useUIStore();
const { setup, cleanup } = usePostMessageHandler({
workflowState,
currentWorkflowDocumentStore: shallowRef(null),
});
setup();
@ -244,7 +240,6 @@ describe('usePostMessageHandler', () => {
const uiStore = useUIStore();
uiStore.setNotificationsSuppressed(true, { allowErrors: true });
const { setup, cleanup } = usePostMessageHandler({
workflowState,
currentWorkflowDocumentStore: shallowRef(null),
});
setup();
@ -275,7 +270,6 @@ describe('usePostMessageHandler', () => {
const uiStore = useUIStore();
uiStore.setNotificationsSuppressed(true, { allowErrors: true });
const { setup, cleanup } = usePostMessageHandler({
workflowState,
currentWorkflowDocumentStore: shallowRef(null),
});
setup();
@ -303,7 +297,6 @@ describe('usePostMessageHandler', () => {
mockRoute.name = 'WorkflowDemo';
const { setup, cleanup } = usePostMessageHandler({
workflowState,
currentWorkflowDocumentStore: shallowRef(null),
});
setup();
@ -328,7 +321,6 @@ describe('usePostMessageHandler', () => {
mockRoute.query = { canExecute: 'true' };
const { setup, cleanup } = usePostMessageHandler({
workflowState,
currentWorkflowDocumentStore: shallowRef(null),
});
setup();
@ -350,7 +342,6 @@ describe('usePostMessageHandler', () => {
it('should emit tidyUp event when tidyUp is true', async () => {
const { setup, cleanup } = usePostMessageHandler({
workflowState,
currentWorkflowDocumentStore: shallowRef(null),
});
setup();
@ -383,7 +374,6 @@ describe('usePostMessageHandler', () => {
});
const { setup, cleanup } = usePostMessageHandler({
workflowState,
currentWorkflowDocumentStore: shallowRef(null),
});
setup();
@ -419,7 +409,6 @@ describe('usePostMessageHandler', () => {
const storeRef = shallowRef(null);
const { setup, cleanup } = usePostMessageHandler({
workflowState,
currentWorkflowDocumentStore: storeRef,
});
setup();
@ -450,7 +439,6 @@ describe('usePostMessageHandler', () => {
});
const { setup, cleanup } = usePostMessageHandler({
workflowState,
currentWorkflowDocumentStore: shallowRef(null),
});
setup();
@ -479,7 +467,6 @@ describe('usePostMessageHandler', () => {
mockOpenExecution.mockResolvedValue(null);
const { setup, cleanup } = usePostMessageHandler({
workflowState,
currentWorkflowDocumentStore: shallowRef(null),
});
setup();
@ -506,7 +493,6 @@ describe('usePostMessageHandler', () => {
setActivePinia(createTestingPinia({ stubActions: false }));
const uiStore = useUIStore();
const { setup, cleanup } = usePostMessageHandler({
workflowState,
currentWorkflowDocumentStore: shallowRef(null),
});
setup();
@ -551,7 +537,6 @@ describe('usePostMessageHandler', () => {
});
const { setup, cleanup } = usePostMessageHandler({
workflowState,
currentWorkflowDocumentStore: shallowRef(null),
});
setup();
@ -578,14 +563,19 @@ describe('usePostMessageHandler', () => {
describe('openExecutionPreview command', () => {
it('should call importWorkflowExact and set execution data', async () => {
const mockSetPinData = vi.fn();
const storeRef = shallowRef({ setPinData: mockSetPinData } as never);
const storeRef = shallowRef({
documentId: createWorkflowDocumentId(''),
setPinData: mockSetPinData,
} as never);
const mockExecutionData = {
workflowData: { id: 'w1' },
} as unknown as IExecutionResponse;
mockBuildExecutionResponseFromSchema.mockReturnValue(mockExecutionData);
const executionStateStore = getExecutionStateStore();
const setWorkflowExecutionDataSpy = vi.spyOn(executionStateStore, 'setWorkflowExecutionData');
const { setup, cleanup } = usePostMessageHandler({
workflowState,
currentWorkflowDocumentStore: storeRef,
});
setup();
@ -604,7 +594,7 @@ describe('usePostMessageHandler', () => {
expect(mockImportWorkflowExact).toHaveBeenCalled();
});
expect(workflowState.setWorkflowExecutionData).toHaveBeenCalledWith(mockExecutionData);
expect(setWorkflowExecutionDataSpy).toHaveBeenCalledWith(mockExecutionData);
expect(mockSetPinData).toHaveBeenCalledWith({});
cleanup();
@ -622,10 +612,12 @@ describe('usePostMessageHandler', () => {
});
const mockSetPinData = vi.fn();
const storeRef = shallowRef({ setPinData: mockSetPinData } as never);
const storeRef = shallowRef({
documentId: createWorkflowDocumentId(''),
setPinData: mockSetPinData,
} as never);
const { setup, cleanup } = usePostMessageHandler({
workflowState,
currentWorkflowDocumentStore: storeRef,
});
setup();
@ -660,7 +652,6 @@ describe('usePostMessageHandler', () => {
it('should throw if workflow has no nodes', async () => {
const { setup, cleanup } = usePostMessageHandler({
workflowState,
currentWorkflowDocumentStore: shallowRef(null),
});
setup();
@ -687,7 +678,6 @@ describe('usePostMessageHandler', () => {
setActivePinia(createTestingPinia({ stubActions: false }));
const uiStore = useUIStore();
const { setup, cleanup } = usePostMessageHandler({
workflowState,
currentWorkflowDocumentStore: shallowRef(null),
});
setup();
@ -728,7 +718,6 @@ describe('usePostMessageHandler', () => {
describe('fitView command', () => {
it('should emit fitView on canvasEventBus when fitView message is received', async () => {
const { setup, cleanup } = usePostMessageHandler({
workflowState,
currentWorkflowDocumentStore: shallowRef(null),
});
setup();
@ -746,7 +735,6 @@ describe('usePostMessageHandler', () => {
describe('message filtering', () => {
it('should ignore non-string messages', async () => {
const { setup, cleanup } = usePostMessageHandler({
workflowState,
currentWorkflowDocumentStore: shallowRef(null),
});
setup();
@ -763,7 +751,6 @@ describe('usePostMessageHandler', () => {
it('should ignore messages without "command" in data', async () => {
const { setup, cleanup } = usePostMessageHandler({
workflowState,
currentWorkflowDocumentStore: shallowRef(null),
});
setup();

View File

@ -19,7 +19,6 @@ import { buildExecutionResponseFromSchema } from '@/features/execution/execution
import type { ExecutionPreviewNodeSchema } from '@/features/execution/executions/executions.types';
import type { IWorkflowDb } from '@/Interface';
import type { WorkflowDataUpdate } from '@n8n/rest-api-client/api/workflows';
import type { WorkflowState } from '@/app/composables/useWorkflowState';
import {
useWorkflowDocumentStore as createWorkflowDocumentStore,
createWorkflowDocumentId,
@ -27,16 +26,13 @@ import {
} from '@/app/stores/workflowDocument.store';
import { useWorkflowImport } from '@/app/composables/useWorkflowImport';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
interface PostMessageHandlerDeps {
workflowState: WorkflowState;
currentWorkflowDocumentStore: ShallowRef<WorkflowDocumentStore | null>;
}
export function usePostMessageHandler({
workflowState,
currentWorkflowDocumentStore,
}: PostMessageHandlerDeps) {
export function usePostMessageHandler({ currentWorkflowDocumentStore }: PostMessageHandlerDeps) {
const i18n = useI18n();
const toast = useToast();
const canvasStore = useCanvasStore();
@ -105,7 +101,10 @@ export function usePostMessageHandler({
// "execution starting"). The user-triggered execution flow will handle
// activeExecutionId itself.
if (window !== window.parent && route.query.canExecute !== 'true') {
workflowState.setActiveExecutionId(null);
const workflowDocumentStore = currentWorkflowDocumentStore.value;
if (workflowDocumentStore) {
useWorkflowExecutionStateStore(workflowDocumentStore.documentId).setActiveExecutionId(null);
}
}
if (json.tidyUp === true) {
@ -201,8 +200,13 @@ export function usePostMessageHandler({
await importWorkflowExact(json);
workflowState.setWorkflowExecutionData(data);
currentWorkflowDocumentStore.value?.setPinData({});
const workflowDocumentStore = currentWorkflowDocumentStore.value;
if (workflowDocumentStore) {
useWorkflowExecutionStateStore(workflowDocumentStore.documentId).setWorkflowExecutionData(
data,
);
workflowDocumentStore.setPinData({});
}
canvasStore.stopLoading();

View File

@ -13,7 +13,6 @@ import { EVALUATION_TRIGGER_NODE_TYPE } from 'n8n-workflow';
import type { IExecutionResponse } from '@/features/execution/executions/executions.types';
import type { INodeUi, IWorkflowDb } from '@/Interface';
import type { Router } from 'vue-router';
import type { WorkflowState } from '@/app/composables/useWorkflowState';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
@ -28,10 +27,13 @@ import { mockedStore } from '@/__tests__/utils';
import { useReadyToRunStore } from '@/features/workflows/readyToRun/stores/readyToRun.store';
import { useBuilderStore } from '@/features/ai/assistant/builder.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useRunWorkflow } from '@/app/composables/useRunWorkflow';
import type { PushHandlerOptions } from './types';
const opts = {
workflowState: mock<WorkflowState>(),
const documentId = createWorkflowDocumentId('1');
const opts: PushHandlerOptions = {
router: mock<Router>(),
documentId,
};
const mockShowMessage = vi.fn();
@ -61,6 +63,8 @@ describe('continueEvaluationLoop()', () => {
});
it('should call runWorkflow() if workflow has eval trigger that executed successfully with rows left', () => {
setActivePinia(createTestingPinia());
const evalTriggerNodeName = 'eval-trigger';
const evalTriggerNodeData = mock<ITaskData>({
data: {
@ -103,6 +107,10 @@ describe('continueEvaluationLoop()', () => {
nodeData: evalTriggerNodeData,
rerunTriggerNode: true,
});
// The rerun must be bound to the document whose execution just finished (not the
// globally-current workflow) so it serializes and saves the correct workflow.
const runWorkflowOptions = vi.mocked(useRunWorkflow).mock.calls.at(-1)?.[0];
expect(runWorkflowOptions?.workflowDocumentStore?.value.documentId).toBe(documentId);
});
it('should not call runWorkflow() if workflow execution status is not success', () => {
@ -250,11 +258,9 @@ describe('executionFinished', () => {
});
it('should clear lastAddedExecutingNode when execution is finished', async () => {
const workflowState = mock<WorkflowState>({
executingNode: {
lastAddedExecutingNode: 'test-node',
},
});
const workflowExecutionStateStore = useWorkflowExecutionStateStore(documentId);
workflowExecutionStateStore.executingNode.lastAddedExecutingNode = 'test-node';
await executionFinished(
{
type: 'executionFinished',
@ -264,27 +270,21 @@ describe('executionFinished', () => {
status: 'success',
},
},
{
router: mock<Router>(),
workflowState,
},
opts,
);
expect(workflowState.executingNode.lastAddedExecutingNode).toBeNull();
expect(workflowExecutionStateStore.executingNode.lastAddedExecutingNode).toBeNull();
});
describe('ready-to-run AI workflow tracking', () => {
it('should track successful execution of ready-to-run-ai-workflow', async () => {
const pinia = createTestingPinia();
setActivePinia(pinia);
setActivePinia(createTestingPinia());
const workflowsStore = useWorkflowsStore();
const workflowsListStore = useWorkflowsListStore();
const readyToRunStore = useReadyToRunStore();
workflowsStore.workflowId = '1';
vi.spyOn(
useWorkflowExecutionStateStore(createWorkflowDocumentId('1')),
useWorkflowExecutionStateStore(documentId),
'activeExecutionId',
'get',
).mockReturnValue('123');
@ -299,12 +299,6 @@ describe('executionFinished', () => {
'trackExecuteAiWorkflowSuccess',
);
const workflowState = mock<WorkflowState>({
executingNode: {
lastAddedExecutingNode: null,
},
});
await executionFinished(
{
type: 'executionFinished',
@ -314,26 +308,20 @@ describe('executionFinished', () => {
status: 'success',
},
},
{
router: mock<Router>(),
workflowState,
},
opts,
);
expect(trackExecuteAiWorkflowSuccess).toHaveBeenCalled();
});
it('should track failed execution of ready-to-run-ai-workflow', async () => {
const pinia = createTestingPinia();
setActivePinia(pinia);
setActivePinia(createTestingPinia());
const workflowsStore = useWorkflowsStore();
const workflowsListStore = useWorkflowsListStore();
const readyToRunStore = useReadyToRunStore();
workflowsStore.workflowId = '1';
vi.spyOn(
useWorkflowExecutionStateStore(createWorkflowDocumentId('1')),
useWorkflowExecutionStateStore(documentId),
'activeExecutionId',
'get',
).mockReturnValue('123');
@ -345,12 +333,6 @@ describe('executionFinished', () => {
const trackExecuteAiWorkflow = vi.spyOn(readyToRunStore, 'trackExecuteAiWorkflow');
const workflowState = mock<WorkflowState>({
executingNode: {
lastAddedExecutingNode: null,
},
});
await executionFinished(
{
type: 'executionFinished',
@ -360,26 +342,20 @@ describe('executionFinished', () => {
status: 'error',
},
},
{
router: mock<Router>(),
workflowState,
},
opts,
);
expect(trackExecuteAiWorkflow).toHaveBeenCalledWith('error');
});
it('should track execution of ready-to-run-ai-workflow-v5', async () => {
const pinia = createTestingPinia();
setActivePinia(pinia);
setActivePinia(createTestingPinia());
const workflowsStore = useWorkflowsStore();
const workflowsListStore = useWorkflowsListStore();
const readyToRunStore = useReadyToRunStore();
workflowsStore.workflowId = '1';
vi.spyOn(
useWorkflowExecutionStateStore(createWorkflowDocumentId('1')),
useWorkflowExecutionStateStore(documentId),
'activeExecutionId',
'get',
).mockReturnValue('123');
@ -394,12 +370,6 @@ describe('executionFinished', () => {
'trackExecuteAiWorkflowSuccess',
);
const workflowState = mock<WorkflowState>({
executingNode: {
lastAddedExecutingNode: null,
},
});
await executionFinished(
{
type: 'executionFinished',
@ -409,26 +379,20 @@ describe('executionFinished', () => {
status: 'success',
},
},
{
router: mock<Router>(),
workflowState,
},
opts,
);
expect(trackExecuteAiWorkflowSuccess).toHaveBeenCalled();
});
it('should track execution of ready-to-run-ai-workflow-v6', async () => {
const pinia = createTestingPinia();
setActivePinia(pinia);
setActivePinia(createTestingPinia());
const workflowsStore = useWorkflowsStore();
const workflowsListStore = useWorkflowsListStore();
const readyToRunStore = useReadyToRunStore();
workflowsStore.workflowId = '1';
vi.spyOn(
useWorkflowExecutionStateStore(createWorkflowDocumentId('1')),
useWorkflowExecutionStateStore(documentId),
'activeExecutionId',
'get',
).mockReturnValue('123');
@ -440,12 +404,6 @@ describe('executionFinished', () => {
const trackExecuteAiWorkflow = vi.spyOn(readyToRunStore, 'trackExecuteAiWorkflow');
const workflowState = mock<WorkflowState>({
executingNode: {
lastAddedExecutingNode: null,
},
});
await executionFinished(
{
type: 'executionFinished',
@ -455,26 +413,20 @@ describe('executionFinished', () => {
status: 'canceled',
},
},
{
router: mock<Router>(),
workflowState,
},
opts,
);
expect(trackExecuteAiWorkflow).toHaveBeenCalledWith('canceled');
});
it('should not track execution for non-ready-to-run workflows', async () => {
const pinia = createTestingPinia();
setActivePinia(pinia);
setActivePinia(createTestingPinia());
const workflowsStore = useWorkflowsStore();
const workflowsListStore = useWorkflowsListStore();
const readyToRunStore = useReadyToRunStore();
workflowsStore.workflowId = '1';
vi.spyOn(
useWorkflowExecutionStateStore(createWorkflowDocumentId('1')),
useWorkflowExecutionStateStore(documentId),
'activeExecutionId',
'get',
).mockReturnValue('123');
@ -490,12 +442,6 @@ describe('executionFinished', () => {
);
const trackExecuteAiWorkflow = vi.spyOn(readyToRunStore, 'trackExecuteAiWorkflow');
const workflowState = mock<WorkflowState>({
executingNode: {
lastAddedExecutingNode: null,
},
});
await executionFinished(
{
type: 'executionFinished',
@ -505,10 +451,7 @@ describe('executionFinished', () => {
status: 'success',
},
},
{
router: mock<Router>(),
workflowState,
},
opts,
);
expect(trackExecuteAiWorkflowSuccess).not.toHaveBeenCalled();
@ -516,30 +459,15 @@ describe('executionFinished', () => {
});
});
it('should return early and clear active execution when fetchExecutionData returns undefined', async () => {
const pinia = createTestingPinia({
initialState: {
workflows: {
activeExecutionId: '123',
},
},
});
it('should set active execution id to undefined when execution data is not stored', async () => {
setActivePinia(createTestingPinia());
setActivePinia(pinia);
const workflowsStore = mockedStore(useWorkflowsStore);
const workflowsListStore = mockedStore(useWorkflowsListStore);
const uiStore = mockedStore(useUIStore);
const workflowExecutionStateStore = useWorkflowExecutionStateStore(documentId);
// Set workflowId + activeExecutionId via the state store
workflowsStore.workflowId = '1';
vi.spyOn(
useWorkflowExecutionStateStore(createWorkflowDocumentId('1')),
'activeExecutionId',
'get',
).mockReturnValue('123');
vi.spyOn(workflowExecutionStateStore, 'activeExecutionId', 'get').mockReturnValue('123');
// Mock getWorkflowById to return a workflow
vi.spyOn(workflowsListStore, 'getWorkflowById').mockReturnValue({
id: '1',
name: 'Test Workflow',
@ -549,17 +477,10 @@ describe('executionFinished', () => {
settings: {},
} as unknown as ReturnType<typeof workflowsListStore.getWorkflowById>);
vi.spyOn(workflowsStore, 'fetchExecutionDataById').mockResolvedValue(null);
vi.spyOn(useWorkflowsStore(), 'fetchExecutionDataById').mockResolvedValue(null);
const setProcessingExecutionResultsSpy = vi.spyOn(uiStore, 'setProcessingExecutionResults');
const workflowState = mock<WorkflowState>({
executingNode: {
lastAddedExecutingNode: 'test-node',
},
setActiveExecutionId: vi.fn(),
});
await executionFinished(
{
type: 'executionFinished',
@ -569,14 +490,11 @@ describe('executionFinished', () => {
status: 'error',
},
},
{
router: mock<Router>(),
workflowState,
},
opts,
);
// Verify that setActiveExecutionId was called with undefined
expect(workflowState.setActiveExecutionId).toHaveBeenCalledWith(undefined);
expect(workflowExecutionStateStore.setActiveExecutionId).toHaveBeenCalledWith(undefined);
// Verify that processing was set to false
expect(setProcessingExecutionResultsSpy).toHaveBeenCalledWith(false);
@ -585,25 +503,16 @@ describe('executionFinished', () => {
});
it('should clear executing node queue even when fetchExecutionData returns undefined', async () => {
const pinia = createTestingPinia({
initialState: {
workflows: {
activeExecutionId: '123',
},
},
});
setActivePinia(createTestingPinia());
setActivePinia(pinia);
const workflowsStore = mockedStore(useWorkflowsStore);
const workflowsListStore = mockedStore(useWorkflowsListStore);
const workflowExecutionStateStore = useWorkflowExecutionStateStore(documentId);
const clearNodeExecutionQueue = vi.spyOn(
workflowExecutionStateStore.executingNode,
'clearNodeExecutionQueue',
);
workflowsStore.workflowId = '1';
vi.spyOn(
useWorkflowExecutionStateStore(createWorkflowDocumentId('1')),
'activeExecutionId',
'get',
).mockReturnValue('123');
vi.spyOn(workflowExecutionStateStore, 'activeExecutionId', 'get').mockReturnValue('123');
vi.spyOn(workflowsListStore, 'getWorkflowById').mockReturnValue({
id: '1',
@ -615,16 +524,7 @@ describe('executionFinished', () => {
} as unknown as ReturnType<typeof workflowsListStore.getWorkflowById>);
// Simulate the iframe scenario: fetch returns no data
vi.spyOn(workflowsStore, 'fetchExecutionDataById').mockResolvedValue(null);
const clearNodeExecutionQueue = vi.fn();
const workflowState = mock<WorkflowState>({
executingNode: {
lastAddedExecutingNode: 'LastNode',
clearNodeExecutionQueue,
},
setActiveExecutionId: vi.fn(),
});
vi.spyOn(useWorkflowsStore(), 'fetchExecutionDataById').mockResolvedValue(null);
await executionFinished(
{
@ -635,10 +535,7 @@ describe('executionFinished', () => {
status: 'success',
},
},
{
router: mock<Router>(),
workflowState,
},
opts,
);
// The executing node queue must be cleared so nodes don't stay stuck
@ -647,21 +544,15 @@ describe('executionFinished', () => {
});
it('should clear executing node queue when activeExecutionId is undefined (iframe preview)', async () => {
const pinia = createTestingPinia();
setActivePinia(pinia);
setActivePinia(createTestingPinia());
const workflowsStore = mockedStore(useWorkflowsStore);
workflowsStore.workflowId = '1';
const workflowExecutionStateStore = useWorkflowExecutionStateStore(documentId);
const clearNodeExecutionQueue = vi.spyOn(
workflowExecutionStateStore.executingNode,
'clearNodeExecutionQueue',
);
// In iframe preview after resetWorkspace, activeExecutionId is undefined by default.
const clearNodeExecutionQueue = vi.fn();
const workflowState = mock<WorkflowState>({
executingNode: {
lastAddedExecutingNode: 'LastNode',
clearNodeExecutionQueue,
},
});
await executionFinished(
{
type: 'executionFinished',
@ -671,16 +562,65 @@ describe('executionFinished', () => {
status: 'success',
},
},
{
router: mock<Router>(),
workflowState,
},
opts,
);
// Even when activeExecutionId is undefined (iframe early return),
// the executing node queue must be cleared.
expect(clearNodeExecutionQueue).toHaveBeenCalled();
});
it('ignores a finish for an execution this document is not tracking', async () => {
setActivePinia(createTestingPinia());
const workflowExecutionStateStore = useWorkflowExecutionStateStore(documentId);
const workflowsStore = useWorkflowsStore();
// This document is tracking a different execution (e.g. a concurrent
// scheduled run of the same workflow finished). Even though the workflow id
// matches, the finish must not clear this document's active execution nor its
// per-node spinner queue.
vi.spyOn(workflowExecutionStateStore, 'activeExecutionId', 'get').mockReturnValue('our-exec');
workflowExecutionStateStore.executingNode.lastAddedExecutingNode = 'busy-node';
const clearNodeExecutionQueue = vi.spyOn(
workflowExecutionStateStore.executingNode,
'clearNodeExecutionQueue',
);
const fetchSpy = vi.spyOn(workflowsStore, 'fetchExecutionDataById');
await executionFinished(
{
type: 'executionFinished',
data: { executionId: 'foreign-exec', workflowId: '1', status: 'success' },
},
opts,
);
expect(fetchSpy).not.toHaveBeenCalled();
expect(workflowExecutionStateStore.setActiveExecutionId).not.toHaveBeenCalled();
expect(clearNodeExecutionQueue).not.toHaveBeenCalled();
expect(workflowExecutionStateStore.executingNode.lastAddedExecutingNode).toBe('busy-node');
});
it('processes a pending finish (null active) when the workflow id matches', async () => {
setActivePinia(createTestingPinia());
const workflowExecutionStateStore = useWorkflowExecutionStateStore(documentId);
const workflowsStore = useWorkflowsStore();
// The finish raced ahead of executionStarted: active is still pending (null).
// Fall back to the workflow id so our own run's finish isn't dropped.
vi.spyOn(workflowExecutionStateStore, 'activeExecutionId', 'get').mockReturnValue(null);
const fetchSpy = vi.spyOn(workflowsStore, 'fetchExecutionDataById').mockResolvedValue(null);
await executionFinished(
{
type: 'executionFinished',
data: { executionId: 'exec-x', workflowId: '1', status: 'error' },
},
opts,
);
expect(fetchSpy).toHaveBeenCalledWith('exec-x');
});
});
describe('manual execution stats tracking', () => {
@ -690,37 +630,34 @@ describe('manual execution stats tracking', () => {
describe('handleExecutionFinishedWithSuccessOrOther', () => {
it('increments success stats on successful execution', () => {
const pinia = createTestingPinia();
setActivePinia(pinia);
setActivePinia(createTestingPinia());
const builderStore = mockedStore(useBuilderStore);
const incrementSpy = vi.spyOn(builderStore, 'incrementManualExecutionStats');
handleExecutionFinishedWithSuccessOrOther(mock<WorkflowState>(), 'success', false);
handleExecutionFinishedWithSuccessOrOther(createWorkflowDocumentId(''), 'success', false);
expect(incrementSpy).toHaveBeenCalledWith('success');
});
it('does not increment success stats when successToastAlreadyShown is true', () => {
const pinia = createTestingPinia();
setActivePinia(pinia);
setActivePinia(createTestingPinia());
const builderStore = mockedStore(useBuilderStore);
const incrementSpy = vi.spyOn(builderStore, 'incrementManualExecutionStats');
handleExecutionFinishedWithSuccessOrOther(mock<WorkflowState>(), 'success', true);
handleExecutionFinishedWithSuccessOrOther(createWorkflowDocumentId(''), 'success', true);
expect(incrementSpy).not.toHaveBeenCalled();
});
it('does not increment stats for non-success status', () => {
const pinia = createTestingPinia();
setActivePinia(pinia);
setActivePinia(createTestingPinia());
const builderStore = mockedStore(useBuilderStore);
const incrementSpy = vi.spyOn(builderStore, 'incrementManualExecutionStats');
handleExecutionFinishedWithSuccessOrOther(mock<WorkflowState>(), 'error', false);
handleExecutionFinishedWithSuccessOrOther(createWorkflowDocumentId(''), 'error', false);
expect(incrementSpy).not.toHaveBeenCalled();
});
@ -732,8 +669,7 @@ describe('manual execution stats tracking', () => {
});
it('shows success toast when executed node has run data', () => {
const pinia = createTestingPinia();
setActivePinia(pinia);
setActivePinia(createTestingPinia());
const workflowsStore = mockedStore(useWorkflowsStore);
const nodeTypesStore = mockedStore(useNodeTypesStore);
@ -758,14 +694,13 @@ describe('manual execution stats tracking', () => {
nodeTypesStore.getNodeType = () =>
mock<INodeTypeDescription>({ polling: undefined, group: [] });
handleExecutionFinishedWithSuccessOrOther(mock<WorkflowState>(), 'success', false);
handleExecutionFinishedWithSuccessOrOther(createWorkflowDocumentId(''), 'success', false);
expect(mockShowMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
});
it('shows warning toast when executed node was not reached', () => {
const pinia = createTestingPinia();
setActivePinia(pinia);
setActivePinia(createTestingPinia());
const workflowsStore = mockedStore(useWorkflowsStore);
const nodeTypesStore = mockedStore(useNodeTypesStore);
@ -788,14 +723,13 @@ describe('manual execution stats tracking', () => {
nodeTypesStore.getNodeType = () =>
mock<INodeTypeDescription>({ polling: undefined, group: [] });
handleExecutionFinishedWithSuccessOrOther(mock<WorkflowState>(), 'success', false);
handleExecutionFinishedWithSuccessOrOther(createWorkflowDocumentId(''), 'success', false);
expect(mockShowMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'warning' }));
});
it('does not show warning toast when successToastAlreadyShown is true', () => {
const pinia = createTestingPinia();
setActivePinia(pinia);
setActivePinia(createTestingPinia());
const workflowsStore = mockedStore(useWorkflowsStore);
const nodeTypesStore = mockedStore(useNodeTypesStore);
@ -818,7 +752,7 @@ describe('manual execution stats tracking', () => {
nodeTypesStore.getNodeType = () =>
mock<INodeTypeDescription>({ polling: undefined, group: [] });
handleExecutionFinishedWithSuccessOrOther(mock<WorkflowState>(), 'success', true);
handleExecutionFinishedWithSuccessOrOther(createWorkflowDocumentId(''), 'success', true);
expect(mockShowMessage).not.toHaveBeenCalled();
});
@ -826,8 +760,7 @@ describe('manual execution stats tracking', () => {
describe('handleExecutionFinishedWithErrorOrCanceled', () => {
it('increments error stats on execution error', () => {
const pinia = createTestingPinia();
setActivePinia(pinia);
setActivePinia(createTestingPinia());
const builderStore = mockedStore(useBuilderStore);
const incrementSpy = vi.spyOn(builderStore, 'incrementManualExecutionStats');
@ -844,14 +777,14 @@ describe('manual execution stats tracking', () => {
handleExecutionFinishedWithErrorOrCanceled(
execution,
mock<IRunExecutionData>({ resultData: { error: { message: 'test', name: 'Error' } } }),
createWorkflowDocumentId(''),
);
expect(incrementSpy).toHaveBeenCalledWith('error');
});
it('does not increment stats for canceled executions', () => {
const pinia = createTestingPinia();
setActivePinia(pinia);
setActivePinia(createTestingPinia());
const builderStore = mockedStore(useBuilderStore);
const incrementSpy = vi.spyOn(builderStore, 'incrementManualExecutionStats');
@ -863,6 +796,7 @@ describe('manual execution stats tracking', () => {
handleExecutionFinishedWithErrorOrCanceled(
execution,
mock<IRunExecutionData>({ resultData: {} }),
createWorkflowDocumentId(''),
);
expect(incrementSpy).not.toHaveBeenCalled();

View File

@ -1,3 +1,4 @@
import { computed } from 'vue';
import type { IExecutionResponse } from '@/features/execution/executions/executions.types';
import { useExternalHooks } from '@/app/composables/useExternalHooks';
import { useNodeHelpers } from '@/app/composables/useNodeHelpers';
@ -17,6 +18,7 @@ import { useWorkflowsStore } from '@/app/stores/workflows.store';
import {
useWorkflowDocumentStore,
createWorkflowDocumentId,
type WorkflowDocumentId,
} from '@/app/stores/workflowDocument.store';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { createExecutionDataId, useExecutionDataStore } from '@/app/stores/executionData.store';
@ -46,42 +48,48 @@ import {
TelemetryHelpers,
createRunExecutionData,
} from 'n8n-workflow';
import type { useRouter } from 'vue-router';
import { type WorkflowState } from '@/app/composables/useWorkflowState';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import type { PushHandlerOptions } from './types';
export type SimplifiedExecution = Pick<
IExecutionResponse,
'workflowId' | 'workflowData' | 'data' | 'status' | 'startedAt' | 'stoppedAt' | 'id'
>;
export type ExecutionFinishedOptions = {
router: ReturnType<typeof useRouter>;
workflowState: WorkflowState;
};
/**
* Handles the 'executionFinished' event, which happens when a workflow execution is finished.
*/
export async function executionFinished(
{ data }: ExecutionFinished,
options: ExecutionFinishedOptions,
) {
const workflowsStore = useWorkflowsStore();
export async function executionFinished({ data }: ExecutionFinished, options: PushHandlerOptions) {
const { documentId } = options;
const workflowsListStore = useWorkflowsListStore();
const uiStore = useUIStore();
const aiTemplatesStarterCollectionStore = useAITemplatesStarterCollectionStore();
const readyToRunStore = useReadyToRunStore();
options.workflowState.executingNode.lastAddedExecutingNode = null;
options.workflowState.executingNode.clearNodeExecutionQueue();
const workflowExecutionStateStore = useWorkflowExecutionStateStore(documentId);
const workflowExecutionStateStore = useWorkflowExecutionStateStore(
createWorkflowDocumentId(workflowsStore.workflowId),
);
// Only act on the finish of the execution this document is actually tracking.
// Normal match is on the execution id; when the active execution is still
// pending (null) because this finish raced ahead of `executionStarted`, fall
// back to the document's workflow id so we don't drop our own run's finish.
// This rejects finishes from other workflows (which would otherwise clear this
// document's running state and show a spurious toast) and from concurrent runs
// of the same workflow that this document isn't displaying.
const { activeExecutionId } = workflowExecutionStateStore;
const belongsToThisDocument =
activeExecutionId === data.executionId ||
(activeExecutionId === null && data.workflowId === workflowExecutionStateStore.workflowId);
// No workflow is actively running, therefore we ignore this event
if (typeof workflowExecutionStateStore.activeExecutionId === 'undefined') {
// Clear the per-node spinner queue when this finish is ours, or when this
// document isn't tracking any run (`undefined`, e.g. idle or iframe preview)
// so stale spinners don't get stuck. Skip clearing only while a different live
// execution is being tracked, so a foreign finish can't wipe this document's
// running state. `clearNodeExecutionQueue` also resets `lastAddedExecutingNode`.
if (belongsToThisDocument || activeExecutionId === undefined) {
workflowExecutionStateStore.executingNode.clearNodeExecutionQueue();
}
if (!belongsToThisDocument) {
return;
}
@ -126,15 +134,11 @@ export async function executionFinished(
let successToastAlreadyShown = false;
if (data.status === 'success') {
handleExecutionFinishedWithSuccessOrOther(
options.workflowState,
data.status,
successToastAlreadyShown,
);
handleExecutionFinishedWithSuccessOrOther(documentId, data.status, successToastAlreadyShown);
successToastAlreadyShown = true;
}
const execution = await fetchExecutionData(data.executionId);
const execution = await fetchExecutionData(data.executionId, documentId);
/**
* This accounts for the case where the execution is not stored.
@ -142,7 +146,7 @@ export async function executionFinished(
* Returning early presists existing run data up to this point.
*/
if (!execution) {
options.workflowState.setActiveExecutionId(undefined);
workflowExecutionStateStore.setActiveExecutionId(undefined);
uiStore.setProcessingExecutionResults(false);
return;
}
@ -153,16 +157,16 @@ export async function executionFinished(
if (execution.data?.waitTill !== undefined) {
handleExecutionFinishedWithWaitTill(data.workflowId, options);
} else if (execution.status === 'error' || execution.status === 'canceled') {
handleExecutionFinishedWithErrorOrCanceled(execution, runExecutionData);
handleExecutionFinishedWithErrorOrCanceled(execution, runExecutionData, documentId);
} else {
handleExecutionFinishedWithSuccessOrOther(
options.workflowState,
documentId,
execution.status,
successToastAlreadyShown,
);
}
setRunExecutionData(execution, runExecutionData, options.workflowState);
setRunExecutionData(execution, runExecutionData, documentId);
continueEvaluationLoop(execution, options);
}
@ -170,12 +174,9 @@ export async function executionFinished(
/**
* Implicit looping: This will re-trigger the evaluation trigger if it exists on a successful execution of the workflow.
* @param execution
* @param router
* @param opts
*/
export function continueEvaluationLoop(
execution: SimplifiedExecution,
opts: ExecutionFinishedOptions,
) {
export function continueEvaluationLoop(execution: SimplifiedExecution, opts: PushHandlerOptions) {
if (execution.status !== 'success' || execution.data?.startData?.destinationNode !== undefined) {
return;
}
@ -196,7 +197,14 @@ export function continueEvaluationLoop(
const rowsLeft = mainData ? (mainData[0]?.json?._rowsLeft as number) : 0;
if (rowsLeft && rowsLeft > 0) {
const { runWorkflow } = useRunWorkflow(opts);
// useRunWorkflow needs a workflow document store; inject() doesn't resolve in
// this async, non-setup context, so bind it explicitly to the document whose
// execution just finished — otherwise the rerun targets the globally-current
// workflow instead of this one.
const { runWorkflow } = useRunWorkflow({
router: opts.router,
workflowDocumentStore: computed(() => useWorkflowDocumentStore(opts.documentId)),
});
void runWorkflow({
triggerNode: evaluationTrigger.name,
// pass output of previous node run to trigger next run
@ -211,11 +219,10 @@ export function continueEvaluationLoop(
*/
export async function fetchExecutionData(
executionId: string,
documentId: WorkflowDocumentId,
): Promise<SimplifiedExecution | undefined> {
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(workflowsStore.workflowId),
);
const workflowDocumentStore = useWorkflowDocumentStore(documentId);
try {
const executionResponse = await workflowsStore.fetchExecutionDataById(executionId);
@ -253,16 +260,16 @@ export function getRunExecutionData(execution: SimplifiedExecution): IRunExecuti
* Returns the error message for the execution run data if the execution status is crashed or canceled,
* or a fallback error message otherwise
*/
export function getRunDataExecutedErrorMessage(execution: SimplifiedExecution) {
export function getRunDataExecutedErrorMessage(
execution: SimplifiedExecution,
documentId: WorkflowDocumentId,
) {
const i18n = useI18n();
if (execution.status === 'crashed') {
return i18n.baseText('pushConnection.executionFailed.message');
} else if (execution.status === 'canceled') {
const workflowsStore = useWorkflowsStore();
const workflowExecutionStateStore = useWorkflowExecutionStateStore(
createWorkflowDocumentId(workflowsStore.workflowId),
);
const workflowExecutionStateStore = useWorkflowExecutionStateStore(documentId);
return i18n.baseText('executionsList.showMessage.stopExecution.message', {
interpolate: { activeExecutionId: workflowExecutionStateStore.activeExecutionId ?? '' },
@ -281,13 +288,11 @@ export function getRunDataExecutedErrorMessage(execution: SimplifiedExecution) {
*/
export function handleExecutionFinishedWithWaitTill(
workflowId: string,
options: {
router: ReturnType<typeof useRouter>;
},
options: PushHandlerOptions,
) {
const workflowsStore = useWorkflowsStore();
const settingsStore = useSettingsStore();
const workflowSaving = useWorkflowSaving(options);
const workflowSaving = useWorkflowSaving({ router: options.router });
const workflowDocumentStore = useWorkflowDocumentStore(createWorkflowDocumentId(workflowId));
const workflowSettings = workflowDocumentStore.settings;
@ -300,7 +305,9 @@ export function handleExecutionFinishedWithWaitTill(
globalLinkActionsEventBus.emit('registerGlobalLinkAction', {
key: 'open-settings',
action: async () => {
if (!workflowsStore.isWorkflowSaved[workflowsStore.workflowId])
if (
!workflowsStore.isWorkflowSaved[useWorkflowDocumentStore(options.documentId).workflowId]
)
await workflowSaving.saveAsNewWorkflow();
uiStore.openModal(WORKFLOW_SETTINGS_MODAL_KEY);
},
@ -317,14 +324,12 @@ export function handleExecutionFinishedWithWaitTill(
export function handleExecutionFinishedWithErrorOrCanceled(
execution: SimplifiedExecution,
runExecutionData: IRunExecutionData,
documentId: WorkflowDocumentId,
) {
const toast = useToast();
const i18n = useI18n();
const telemetry = useTelemetry();
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(workflowsStore.workflowId),
);
const workflowDocumentStore = useWorkflowDocumentStore(documentId);
const documentTitle = useDocumentTitle();
const workflowHelpers = useWorkflowHelpers();
@ -348,7 +353,7 @@ export function handleExecutionFinishedWithErrorOrCanceled(
workflowHelpers.getNodeTypes(),
).nodeGraph,
),
workflow_id: workflowsStore.workflowId,
workflow_id: workflowDocumentStore.workflowId,
};
if (
@ -397,12 +402,12 @@ export function handleExecutionFinishedWithErrorOrCanceled(
function handleExecutionFinishedSuccessfully(
workflowName: string,
message: string,
workflowState: WorkflowState,
documentId: WorkflowDocumentId,
) {
const toast = useToast();
useDocumentTitle().setDocumentTitle(workflowName, 'IDLE');
workflowState.setActiveExecutionId(undefined);
useWorkflowExecutionStateStore(documentId).setActiveExecutionId(undefined);
toast.showMessage({
title: message,
type: 'success',
@ -413,7 +418,7 @@ function handleExecutionFinishedSuccessfully(
* Handle the case when the workflow execution finished successfully.
*/
export function handleExecutionFinishedWithSuccessOrOther(
workflowState: WorkflowState,
documentId: WorkflowDocumentId,
executionStatus: ExecutionStatus,
successToastAlreadyShown: boolean,
) {
@ -422,9 +427,7 @@ export function handleExecutionFinishedWithSuccessOrOther(
const i18n = useI18n();
const nodeTypesStore = useNodeTypesStore();
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(workflowsStore.workflowId),
);
const workflowDocumentStore = useWorkflowDocumentStore(documentId);
const workflowName = workflowDocumentStore.name;
useDocumentTitle().setDocumentTitle(workflowName, 'IDLE');
@ -460,14 +463,14 @@ export function handleExecutionFinishedWithSuccessOrOther(
handleExecutionFinishedSuccessfully(
workflowName,
i18n.baseText('pushConnection.nodeExecutedSuccessfully'),
workflowState,
documentId,
);
}
} else if (!successToastAlreadyShown) {
handleExecutionFinishedSuccessfully(
workflowName,
i18n.baseText('pushConnection.workflowExecutedSuccessfully'),
workflowState,
documentId,
);
}
@ -481,16 +484,13 @@ export function handleExecutionFinishedWithSuccessOrOther(
export function setRunExecutionData(
execution: SimplifiedExecution,
runExecutionData: IRunExecutionData,
workflowState: WorkflowState,
documentId: WorkflowDocumentId,
) {
const workflowsStore = useWorkflowsStore();
const workflowExecutionStateStore = useWorkflowExecutionStateStore(
createWorkflowDocumentId(workflowsStore.workflowId),
);
const workflowExecutionStateStore = useWorkflowExecutionStateStore(documentId);
const nodeHelpers = useNodeHelpers();
const runDataExecutedErrorMessage = getRunDataExecutedErrorMessage(execution);
const runDataExecutedErrorMessage = getRunDataExecutedErrorMessage(execution, documentId);
workflowState.executingNode.clearNodeExecutionQueue();
workflowExecutionStateStore.executingNode.clearNodeExecutionQueue();
const executionDataStore = useExecutionDataStore(createExecutionDataId(execution.id));
const workflowExecution = executionDataStore.getExecutionSnapshot();

View File

@ -8,30 +8,27 @@ import {
handleExecutionFinishedWithWaitTill,
setRunExecutionData,
} from './executionFinished';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { createWorkflowDocumentId } from '@/app/stores/workflowDocument.store';
import type { useRouter } from 'vue-router';
import type { WorkflowState } from '@/app/composables/useWorkflowState';
import type { PushHandlerOptions } from './types';
export async function executionRecovered(
{ data }: ExecutionRecovered,
options: { router: ReturnType<typeof useRouter>; workflowState: WorkflowState },
options: PushHandlerOptions,
) {
const workflowsStore = useWorkflowsStore();
const workflowExecutionStateStore = useWorkflowExecutionStateStore(
createWorkflowDocumentId(workflowsStore.workflowId),
);
const { documentId } = options;
const workflowExecutionStateStore = useWorkflowExecutionStateStore(documentId);
const uiStore = useUIStore();
// No workflow is actively running, therefore we ignore this event
if (typeof workflowExecutionStateStore.activeExecutionId === 'undefined') {
// Only recover the execution this document is tracking. A mismatch (including
// the no-active-execution case, where activeExecutionId is undefined) means
// the event belongs to another execution and must be ignored.
if (workflowExecutionStateStore.activeExecutionId !== data.executionId) {
return;
}
uiStore.setProcessingExecutionResults(true);
const execution = await fetchExecutionData(data.executionId);
const execution = await fetchExecutionData(data.executionId, documentId);
if (!execution) {
uiStore.setProcessingExecutionResults(false);
return;
@ -43,10 +40,10 @@ export async function executionRecovered(
if (execution.data?.waitTill !== undefined) {
handleExecutionFinishedWithWaitTill(execution.workflowId ?? '', options);
} else if (execution.status === 'error' || execution.status === 'canceled') {
handleExecutionFinishedWithErrorOrCanceled(execution, runExecutionData);
handleExecutionFinishedWithErrorOrCanceled(execution, runExecutionData, documentId);
} else {
handleExecutionFinishedWithSuccessOrOther(options.workflowState, execution.status, false);
handleExecutionFinishedWithSuccessOrOther(documentId, execution.status, false);
}
setRunExecutionData(execution, runExecutionData, options.workflowState);
setRunExecutionData(execution, runExecutionData, documentId);
}

View File

@ -1,6 +1,7 @@
import { createPinia, setActivePinia } from 'pinia';
import { mock } from 'vitest-mock-extended';
import type { Router } from 'vue-router';
import { executionStarted } from './executionStarted';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import {
createWorkflowDocumentId,
useWorkflowDocumentStore,
@ -8,35 +9,34 @@ import {
import type { ExecutionStarted } from '@n8n/api-types/push/execution';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { createExecutionDataId, useExecutionDataStore } from '@/app/stores/executionData.store';
import type { PushHandlerOptions } from './types';
describe('executionStarted', () => {
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
const documentId = createWorkflowDocumentId('wf-123');
let options: PushHandlerOptions;
let workflowExecutionStateStore: ReturnType<typeof useWorkflowExecutionStateStore>;
function makeEvent(executionId = 'exec-1'): ExecutionStarted {
function makeEvent(executionId = 'exec-1', workflowId = 'wf-123'): ExecutionStarted {
return {
type: 'executionStarted',
data: { executionId } as ExecutionStarted['data'],
data: { executionId, workflowId } as ExecutionStarted['data'],
};
}
beforeEach(() => {
setActivePinia(createPinia());
workflowsStore = useWorkflowsStore();
workflowsStore.setWorkflowId('wf-123');
options = { router: mock<Router>(), documentId };
const workflowDocumentStore = useWorkflowDocumentStore(createWorkflowDocumentId('wf-123'));
const workflowDocumentStore = useWorkflowDocumentStore(documentId);
workflowDocumentStore.setName('My Workflow');
workflowExecutionStateStore = useWorkflowExecutionStateStore(
createWorkflowDocumentId('wf-123'),
);
workflowExecutionStateStore = useWorkflowExecutionStateStore(documentId);
});
it('should skip when activeExecutionId is undefined', async () => {
// activeExecutionId defaults to undefined, no need to set it
await executionStarted(makeEvent());
await executionStarted(makeEvent(), options);
// workflowExecutionStateStore.activeExecutionId should remain undefined
expect(workflowExecutionStateStore.activeExecutionId).toBeUndefined();
@ -49,7 +49,7 @@ describe('executionStarted', () => {
it('should accept execution when activeExecutionId is null and populate workflowData from store', async () => {
workflowExecutionStateStore.setActiveExecutionId(null);
await executionStarted(makeEvent('exec-1'));
await executionStarted(makeEvent('exec-1'), options);
expect(workflowExecutionStateStore.activeExecutionId).toBe('exec-1');
@ -61,6 +61,26 @@ describe('executionStarted', () => {
});
});
it('should skip when the event workflow id does not match the document', async () => {
// A pending run is staged for this document...
workflowExecutionStateStore.setActiveExecutionId(null);
// ...but the event belongs to a different workflow (e.g. a concurrent
// scheduled run). It must not hijack this document's pending slot.
await executionStarted(makeEvent('exec-9', 'other-wf'), options);
expect(workflowExecutionStateStore.activeExecutionId).toBeNull();
expect(useExecutionDataStore(createExecutionDataId('exec-9')).execution).toBeNull();
});
it('should accept when the event workflow id matches the document', async () => {
workflowExecutionStateStore.setActiveExecutionId(null);
await executionStarted(makeEvent('exec-1', 'wf-123'), options);
expect(workflowExecutionStateStore.activeExecutionId).toBe('exec-1');
});
it('should not reinitialize when same execution ID arrives', async () => {
// Set up an active execution with existing data
workflowExecutionStateStore.promotePendingExecution('exec-1');
@ -78,7 +98,7 @@ describe('executionStarted', () => {
const executionBefore = executionDataStore.execution;
await executionStarted(makeEvent('exec-1'));
await executionStarted(makeEvent('exec-1'), options);
// workflowExecutionStateStore.activeExecutionId should remain 'exec-1' without change
expect(workflowExecutionStateStore.activeExecutionId).toBe('exec-1');
@ -111,7 +131,7 @@ describe('executionStarted', () => {
it('should accept execution when activeExecutionId is undefined in iframe (post-executionFinished)', async () => {
// activeExecutionId defaults to undefined; in iframe context this should still accept
await executionStarted(makeEvent('exec-2'));
await executionStarted(makeEvent('exec-2'), options);
expect(workflowExecutionStateStore.activeExecutionId).toBe('exec-2');
@ -139,7 +159,7 @@ describe('executionStarted', () => {
} as never,
});
await executionStarted(makeEvent('exec-2'));
await executionStarted(makeEvent('exec-2'), options);
expect(workflowExecutionStateStore.activeExecutionId).toBe('exec-2');
@ -165,7 +185,7 @@ describe('executionStarted', () => {
data: { resultData: { runData: {} } } as never,
});
await executionStarted(makeEvent('exec-1'));
await executionStarted(makeEvent('exec-1'), options);
// Should remain exec-1 without reinitializing
expect(workflowExecutionStateStore.activeExecutionId).toBe('exec-1');

View File

@ -1,28 +1,33 @@
import type { ExecutionStarted } from '@n8n/api-types/push/execution';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import {
createWorkflowDocumentId,
useWorkflowDocumentStore,
} from '@/app/stores/workflowDocument.store';
import { useWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { createExecutionDataId, useExecutionDataStore } from '@/app/stores/executionData.store';
import { parse } from 'flatted';
import { createRunExecutionData } from 'n8n-workflow';
import type { IRunExecutionData } from 'n8n-workflow';
import type { PushHandlerOptions } from './types';
/**
* Handles the 'executionStarted' event, which happens when a workflow is executed.
*/
export async function executionStarted({ data }: ExecutionStarted) {
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(workflowsStore.workflowId),
);
const workflowExecutionStateStore = useWorkflowExecutionStateStore(
workflowDocumentStore.documentId,
);
export async function executionStarted(
{ data }: ExecutionStarted,
{ documentId }: PushHandlerOptions,
) {
const workflowDocumentStore = useWorkflowDocumentStore(documentId);
const workflowExecutionStateStore = useWorkflowExecutionStateStore(documentId);
const isIframe = window !== window.parent;
// A single push connection serves the active document, so a concurrent
// execution of a *different* workflow (e.g. a scheduled run firing while this
// document has a pending run) would otherwise hijack this document's pending
// (null) execution slot. Reject events for another workflow. The iframe/demo
// path is exempt: it only ever receives events relayed for the workflow it
// previews, and its document id may not carry a comparable workflow id.
if (!isIframe && data.workflowId !== workflowExecutionStateStore.workflowId) {
return;
}
// In non-iframe context, undefined means "not tracking executions" → skip.
// In iframe context, executionFinished resets activeExecutionId to undefined,
// but we still want to accept new executions (re-execution scenario).

View File

@ -1,16 +1,15 @@
import { createPinia, setActivePinia } from 'pinia';
import { nodeExecuteAfter } from './nodeExecuteAfter';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useAssistantStore } from '@/features/ai/assistant/assistant.store';
import type { NodeExecuteAfter } from '@n8n/api-types/push/execution';
import { TRIMMED_TASK_DATA_CONNECTIONS_KEY } from 'n8n-workflow';
import type { WorkflowState } from '@/app/composables/useWorkflowState';
import { mock } from 'vitest-mock-extended';
import type { Mocked } from 'vitest';
import type { Router } from 'vue-router';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { createWorkflowDocumentId } from '@/app/stores/workflowDocument.store';
import { createExecutionDataId, useExecutionDataStore } from '@/app/stores/executionData.store';
import { createTestWorkflow, createTestWorkflowExecutionResponse } from '@/__tests__/mocks';
import type { PushHandlerOptions } from './types';
vi.mock('@/features/ai/assistant/assistant.store', () => ({
useAssistantStore: vi.fn().mockReturnValue({
@ -27,8 +26,8 @@ vi.mock('@/features/execution/executions/executions.utils', async (importOrigina
import { openFormPopupWindow } from '@/features/execution/executions/executions.utils';
describe('nodeExecuteAfter', () => {
let mockOptions: { workflowState: Mocked<WorkflowState> };
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
const documentId = createWorkflowDocumentId('test-wf');
let options: PushHandlerOptions;
let workflowExecutionStateStore: ReturnType<typeof useWorkflowExecutionStateStore>;
let executionDataStore: ReturnType<typeof useExecutionDataStore>;
@ -36,12 +35,10 @@ describe('nodeExecuteAfter', () => {
vi.mocked(openFormPopupWindow).mockClear();
setActivePinia(createPinia());
workflowsStore = useWorkflowsStore();
workflowsStore.setWorkflowId('test-wf');
options = { router: mock<Router>(), documentId };
workflowExecutionStateStore = useWorkflowExecutionStateStore(
createWorkflowDocumentId('test-wf'),
);
workflowExecutionStateStore = useWorkflowExecutionStateStore(documentId);
vi.spyOn(workflowExecutionStateStore.executingNode, 'removeExecutingNode');
executionDataStore = useExecutionDataStore(createExecutionDataId('exec-1'));
executionDataStore.setExecution(
@ -54,14 +51,6 @@ describe('nodeExecuteAfter', () => {
);
workflowExecutionStateStore.setActiveExecutionId('exec-1');
mockOptions = {
workflowState: mock<WorkflowState>({
executingNode: {
removeExecutingNode: vi.fn(),
},
}),
};
});
it('should update node execution data with placeholder and remove executing node', async () => {
@ -82,10 +71,10 @@ describe('nodeExecuteAfter', () => {
},
};
await nodeExecuteAfter(event, mockOptions);
await nodeExecuteAfter(event, options);
expect(mockOptions.workflowState.executingNode.removeExecutingNode).toHaveBeenCalledTimes(1);
expect(mockOptions.workflowState.executingNode.removeExecutingNode).toHaveBeenCalledWith(
expect(workflowExecutionStateStore.executingNode.removeExecutingNode).toHaveBeenCalledTimes(1);
expect(workflowExecutionStateStore.executingNode.removeExecutingNode).toHaveBeenCalledWith(
'Test Node',
);
expect(assistantStore.onNodeExecution).toHaveBeenCalledTimes(1);
@ -102,6 +91,36 @@ describe('nodeExecuteAfter', () => {
});
});
it('should skip when the execution id does not match the active execution', async () => {
const assistantStore = useAssistantStore();
// onNodeExecution is a shared module-level mock; reset its call history so
// this assertion only reflects the call (if any) from this test.
vi.mocked(assistantStore.onNodeExecution).mockClear();
const event: NodeExecuteAfter = {
type: 'nodeExecuteAfter',
data: {
executionId: 'other-exec',
nodeName: 'Test Node',
itemCountByConnectionType: { main: [1] },
data: {
executionTime: 100,
startTime: 1234567890,
executionIndex: 0,
source: [],
},
},
};
await nodeExecuteAfter(event, options);
// Nothing belonging to the active execution should be touched.
expect(workflowExecutionStateStore.executingNode.removeExecutingNode).not.toHaveBeenCalled();
expect(assistantStore.onNodeExecution).not.toHaveBeenCalled();
const runData = executionDataStore.execution?.data?.resultData.runData;
expect(runData?.['Test Node']).toBeUndefined();
});
it('should handle multiple connection types', async () => {
const event: NodeExecuteAfter = {
type: 'nodeExecuteAfter',
@ -122,7 +141,7 @@ describe('nodeExecuteAfter', () => {
},
};
await nodeExecuteAfter(event, mockOptions);
await nodeExecuteAfter(event, options);
const runData = executionDataStore.execution?.data?.resultData.runData;
expect(runData?.['Test Node'][0].data).toEqual({
@ -155,7 +174,7 @@ describe('nodeExecuteAfter', () => {
},
};
await nodeExecuteAfter(event, mockOptions);
await nodeExecuteAfter(event, options);
const runData = executionDataStore.execution?.data?.resultData.runData;
expect(runData?.['Test Node'][0].data).toEqual({
@ -179,7 +198,7 @@ describe('nodeExecuteAfter', () => {
},
};
await nodeExecuteAfter(event, mockOptions);
await nodeExecuteAfter(event, options);
const runData = executionDataStore.execution?.data?.resultData.runData;
const taskData = runData?.['Test Node'][0];
@ -216,7 +235,7 @@ describe('nodeExecuteAfter', () => {
},
};
await nodeExecuteAfter(event, mockOptions);
await nodeExecuteAfter(event, options);
const runData = executionDataStore.execution?.data?.resultData.runData;
// Should only contain main connection, invalid_connection should be filtered out
@ -247,7 +266,7 @@ describe('nodeExecuteAfter', () => {
},
};
await nodeExecuteAfter(event, mockOptions);
await nodeExecuteAfter(event, options);
expect(openFormPopupWindow).toHaveBeenCalledWith(formUrl);
});
@ -269,7 +288,7 @@ describe('nodeExecuteAfter', () => {
},
};
await nodeExecuteAfter(event, mockOptions);
await nodeExecuteAfter(event, options);
expect(openFormPopupWindow).not.toHaveBeenCalled();
});

View File

@ -1,8 +1,6 @@
import type { NodeExecuteAfter } from '@n8n/api-types/push/execution';
import { useAssistantStore } from '@/features/ai/assistant/assistant.store';
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 { INodeExecutionData, ITaskData } from 'n8n-workflow';
import { TRIMMED_TASK_DATA_CONNECTIONS_KEY } from 'n8n-workflow';
@ -10,21 +8,26 @@ import type { PushPayload } from '@n8n/api-types';
import { isValidNodeConnectionType } from '@/app/utils/typeGuards';
import { openFormPopupWindow } from '@/features/execution/executions/executions.utils';
import { trackNodeExecution } from './trackNodeExecution';
import type { WorkflowState } from '@/app/composables/useWorkflowState';
import type { PushHandlerOptions } from './types';
/**
* Handles the 'nodeExecuteAfter' event, which happens after a node is executed.
*/
export async function nodeExecuteAfter(
{ data: pushData }: NodeExecuteAfter,
{ workflowState }: { workflowState: WorkflowState },
{ documentId }: PushHandlerOptions,
) {
const workflowsStore = useWorkflowsStore();
const workflowExecutionStateStore = useWorkflowExecutionStateStore(
createWorkflowDocumentId(workflowsStore.workflowId),
);
const workflowExecutionStateStore = useWorkflowExecutionStateStore(documentId);
const assistantStore = useAssistantStore();
// Ignore node events that don't belong to the execution this document is
// tracking — a concurrent execution's node must not write into this
// document's data or fire its side effects (form popups, tracking, assistant).
const activeExecutionId = workflowExecutionStateStore.activeExecutionId;
if (activeExecutionId !== pushData.executionId) {
return;
}
/**
* We trim the actual data returned from the node execution to avoid performance issues
* when dealing with large datasets. Instead of storing the actual data, we initially store
@ -58,24 +61,24 @@ export async function nodeExecuteAfter(
},
};
const activeExecutionId = workflowExecutionStateStore.activeExecutionId;
if (typeof activeExecutionId === 'string') {
useExecutionDataStore(createExecutionDataId(activeExecutionId)).updateNodeExecutionStatus(
pushDataWithPlaceholderOutputData,
);
useExecutionDataStore(createExecutionDataId(pushData.executionId)).updateNodeExecutionStatus(
pushDataWithPlaceholderOutputData,
);
if (pushDataWithPlaceholderOutputData.data.executionStatus !== 'waiting') {
void trackNodeExecution(pushDataWithPlaceholderOutputData, workflowsStore.workflowId);
}
if (pushDataWithPlaceholderOutputData.data.executionStatus !== 'waiting') {
void trackNodeExecution(
pushDataWithPlaceholderOutputData,
workflowExecutionStateStore.workflowId,
);
}
workflowState.executingNode.removeExecutingNode(pushData.nodeName);
workflowExecutionStateStore.executingNode.removeExecutingNode(pushData.nodeName);
// Side effects
if (pushData.data.executionStatus === 'waiting' && pushData.data.metadata?.resumeFormUrl) {
openFormPopupWindow(pushData.data.metadata.resumeFormUrl);
} else if (pushData.data.executionStatus !== 'waiting') {
void trackNodeExecution(pushData, workflowsStore.workflowId);
void trackNodeExecution(pushData, workflowExecutionStateStore.workflowId);
}
void assistantStore.onNodeExecution(pushData);

View File

@ -1,27 +1,27 @@
import { createPinia, setActivePinia } from 'pinia';
import { mock } from 'vitest-mock-extended';
import type { Router } from 'vue-router';
import { nodeExecuteAfterData } from './nodeExecuteAfterData';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import type { NodeExecuteAfterData } from '@n8n/api-types/push/execution';
import { createRunExecutionData } from 'n8n-workflow';
import { createTestWorkflowExecutionResponse } from '@/__tests__/mocks';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { createWorkflowDocumentId } from '@/app/stores/workflowDocument.store';
import { createExecutionDataId, useExecutionDataStore } from '@/app/stores/executionData.store';
import type { PushHandlerOptions } from './types';
describe('nodeExecuteAfterData', () => {
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
const documentId = createWorkflowDocumentId('test-wf');
let options: PushHandlerOptions;
let workflowExecutionStateStore: ReturnType<typeof useWorkflowExecutionStateStore>;
let executionDataStore: ReturnType<typeof useExecutionDataStore>;
beforeEach(() => {
setActivePinia(createPinia());
workflowsStore = useWorkflowsStore();
workflowsStore.setWorkflowId('test-wf');
options = { router: mock<Router>(), documentId };
workflowExecutionStateStore = useWorkflowExecutionStateStore(
createWorkflowDocumentId('test-wf'),
);
workflowExecutionStateStore = useWorkflowExecutionStateStore(documentId);
executionDataStore = useExecutionDataStore(createExecutionDataId('exec-1'));
executionDataStore.setExecution(
@ -71,7 +71,7 @@ describe('nodeExecuteAfterData', () => {
},
};
await nodeExecuteAfterData(event);
await nodeExecuteAfterData(event, options);
// The exec store's run data for 'Test Node' should now have the real data
const runData = executionDataStore.execution?.data?.resultData.runData;
@ -80,4 +80,32 @@ describe('nodeExecuteAfterData', () => {
main: [[{ json: { foo: 'bar' } }]],
});
});
it('should skip when the execution id does not match the active execution', async () => {
const event: NodeExecuteAfterData = {
type: 'nodeExecuteAfterData',
data: {
executionId: 'other-exec',
nodeName: 'Test Node',
itemCountByConnectionType: { main: [1] },
data: {
executionTime: 0,
startTime: 0,
executionIndex: 0,
source: [],
data: {
main: [[{ json: { foo: 'bar' } }]],
},
},
},
};
await nodeExecuteAfterData(event, options);
// The active execution (exec-1) keeps its placeholder data untouched.
const runData = executionDataStore.execution?.data?.resultData.runData;
expect(runData?.['Test Node'][0].data).toEqual({
main: [[{ json: { placeholder: true } }]],
});
});
});

View File

@ -1,39 +1,41 @@
import type { NodeExecuteAfterData } from '@n8n/api-types/push/execution';
import { useSchemaPreviewStore } from '@/features/ndv/runData/schemaPreview.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { computed } from 'vue';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { createExecutionDataId, useExecutionDataStore } from '@/app/stores/executionData.store';
import {
createWorkflowDocumentId,
useWorkflowDocumentStore,
} from '@/app/stores/workflowDocument.store';
import { useWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import type { PushHandlerOptions } from './types';
/**
* Handles the 'nodeExecuteAfterData' event, which is sent after a node has executed and contains the resulting data.
*/
export async function nodeExecuteAfterData({ data: pushData }: NodeExecuteAfterData) {
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = computed(() =>
useWorkflowDocumentStore(createWorkflowDocumentId(workflowsStore.workflowId)),
);
const workflowExecutionStateStore = useWorkflowExecutionStateStore(
workflowDocumentStore.value.documentId,
);
export async function nodeExecuteAfterData(
{ data: pushData }: NodeExecuteAfterData,
{ documentId }: PushHandlerOptions,
) {
const workflowDocumentStore = useWorkflowDocumentStore(documentId);
const workflowExecutionStateStore = useWorkflowExecutionStateStore(documentId);
const schemaPreviewStore = useSchemaPreviewStore();
// Ignore node events that don't belong to the execution this document is
// tracking — a concurrent execution's data must not land on this document.
const activeExecutionId = workflowExecutionStateStore.activeExecutionId;
if (typeof activeExecutionId === 'string') {
useExecutionDataStore(createExecutionDataId(activeExecutionId)).updateNodeExecutionRunData(
pushData,
);
if (activeExecutionId !== pushData.executionId) {
return;
}
const node = workflowDocumentStore.value.getNodeByName(pushData.nodeName);
useExecutionDataStore(createExecutionDataId(pushData.executionId)).updateNodeExecutionRunData(
pushData,
);
const node = workflowDocumentStore.getNodeByName(pushData.nodeName);
if (!node) {
return;
}
void schemaPreviewStore.trackSchemaPreviewExecution(workflowsStore.workflowId, node, pushData);
void schemaPreviewStore.trackSchemaPreviewExecution(
workflowDocumentStore.workflowId,
node,
pushData,
);
}

View File

@ -0,0 +1,55 @@
import { createPinia, setActivePinia } from 'pinia';
import { mock } from 'vitest-mock-extended';
import type { Router } from 'vue-router';
import { nodeExecuteBefore } from './nodeExecuteBefore';
import type { NodeExecuteBefore } from '@n8n/api-types/push/execution';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { createWorkflowDocumentId } from '@/app/stores/workflowDocument.store';
import { createExecutionDataId, useExecutionDataStore } from '@/app/stores/executionData.store';
import { createTestWorkflowExecutionResponse } from '@/__tests__/mocks';
import type { PushHandlerOptions } from './types';
describe('nodeExecuteBefore', () => {
const documentId = createWorkflowDocumentId('test-wf');
let options: PushHandlerOptions;
let workflowExecutionStateStore: ReturnType<typeof useWorkflowExecutionStateStore>;
function makeEvent(executionId = 'exec-1'): NodeExecuteBefore {
return {
type: 'nodeExecuteBefore',
data: {
executionId,
nodeName: 'Test Node',
data: { startTime: 0, executionIndex: 0, source: [] },
},
};
}
beforeEach(() => {
setActivePinia(createPinia());
options = { router: mock<Router>(), documentId };
workflowExecutionStateStore = useWorkflowExecutionStateStore(documentId);
vi.spyOn(workflowExecutionStateStore.executingNode, 'addExecutingNode');
useExecutionDataStore(createExecutionDataId('exec-1')).setExecution(
createTestWorkflowExecutionResponse({ id: 'exec-1', status: 'running' }),
);
workflowExecutionStateStore.setActiveExecutionId('exec-1');
});
it('adds the executing node when the execution id matches the active execution', async () => {
await nodeExecuteBefore(makeEvent('exec-1'), options);
expect(workflowExecutionStateStore.executingNode.addExecutingNode).toHaveBeenCalledWith(
'Test Node',
);
});
it('skips when the execution id does not match the active execution', async () => {
await nodeExecuteBefore(makeEvent('other-exec'), options);
expect(workflowExecutionStateStore.executingNode.addExecutingNode).not.toHaveBeenCalled();
});
});

View File

@ -1,28 +1,26 @@
import type { NodeExecuteBefore } from '@n8n/api-types/push/execution';
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 { WorkflowState } from '@/app/composables/useWorkflowState';
import type { PushHandlerOptions } from './types';
/**
* Handles the 'nodeExecuteBefore' event, which happens before a node is executed.
*/
export async function nodeExecuteBefore(
{ data }: NodeExecuteBefore,
{ workflowState }: { workflowState: WorkflowState },
{ documentId }: PushHandlerOptions,
) {
const workflowsStore = useWorkflowsStore();
const workflowExecutionStateStore = useWorkflowExecutionStateStore(
createWorkflowDocumentId(workflowsStore.workflowId),
);
workflowState.executingNode.addExecutingNode(data.nodeName);
const workflowExecutionStateStore = useWorkflowExecutionStateStore(documentId);
// Ignore node events that don't belong to the execution this document is
// tracking — otherwise a concurrent execution's node would pollute this
// document's spinner queue and execution data.
const activeExecutionId = workflowExecutionStateStore.activeExecutionId;
if (typeof activeExecutionId === 'string') {
useExecutionDataStore(createExecutionDataId(activeExecutionId)).addNodeExecutionStartedData(
data,
);
if (activeExecutionId !== data.executionId) {
return;
}
workflowExecutionStateStore.executingNode.addExecutingNode(data.nodeName);
useExecutionDataStore(createExecutionDataId(data.executionId)).addNodeExecutionStartedData(data);
}

View File

@ -1,22 +1,18 @@
import type { TestWebhookDeleted } from '@n8n/api-types/push/webhook';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { createWorkflowDocumentId } from '@/app/stores/workflowDocument.store';
import type { WorkflowState } from '@/app/composables/useWorkflowState';
import type { PushHandlerOptions } from './types';
/**
* Handles the 'testWebhookDeleted' push message, which is sent when a test webhook is deleted.
*/
export async function testWebhookDeleted(
{ data }: TestWebhookDeleted,
options: { workflowState: WorkflowState },
{ documentId }: PushHandlerOptions,
) {
const workflowsStore = useWorkflowsStore();
const workflowExecutionStateStore = useWorkflowExecutionStateStore(documentId);
if (data.workflowId === workflowsStore.workflowId) {
useWorkflowExecutionStateStore(
createWorkflowDocumentId(workflowsStore.workflowId),
).setExecutionWaitingForWebhook(false);
options.workflowState.setActiveExecutionId(undefined);
if (data.workflowId === workflowExecutionStateStore.workflowId) {
workflowExecutionStateStore.setExecutionWaitingForWebhook(false);
workflowExecutionStateStore.setActiveExecutionId(undefined);
}
}

View File

@ -1,22 +1,18 @@
import type { TestWebhookReceived } from '@n8n/api-types/push/webhook';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { createWorkflowDocumentId } from '@/app/stores/workflowDocument.store';
import type { WorkflowState } from '@/app/composables/useWorkflowState';
import type { PushHandlerOptions } from './types';
/**
* Handles the 'testWebhookReceived' push message, which is sent when a test webhook is received.
*/
export async function testWebhookReceived(
{ data }: TestWebhookReceived,
options: { workflowState: WorkflowState },
{ documentId }: PushHandlerOptions,
) {
const workflowsStore = useWorkflowsStore();
const workflowExecutionStateStore = useWorkflowExecutionStateStore(documentId);
if (data.workflowId === workflowsStore.workflowId) {
useWorkflowExecutionStateStore(
createWorkflowDocumentId(workflowsStore.workflowId),
).setExecutionWaitingForWebhook(false);
options.workflowState.setActiveExecutionId(data.executionId ?? null);
if (data.workflowId === workflowExecutionStateStore.workflowId) {
workflowExecutionStateStore.setExecutionWaitingForWebhook(false);
workflowExecutionStateStore.setActiveExecutionId(data.executionId ?? null);
}
}

View File

@ -0,0 +1,19 @@
import type { Router } from 'vue-router';
import type { WorkflowDocumentId } from '@/app/stores/workflowDocument.store';
/**
* Dependencies passed to push connection handlers, resolved per event in
* `usePushConnection.processEvent`.
*
* - `documentId` identifies the currently-open workflow document (resolved at
* event time from the injected workflow document store), so handlers can
* reach the right per-document stores without reading any global "current
* workflow" state.
* - `router` is only needed by handlers that re-run or save the workflow
* (`useRunWorkflow` / `useWorkflowSaving`), which cannot call `useRouter()`
* from an async, non-setup context.
*/
export interface PushHandlerOptions {
router: Router;
documentId: WorkflowDocumentId;
}

View File

@ -1,23 +1,25 @@
import type { WorkflowActivated } from '@n8n/api-types/push/workflow';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { useWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { useBannersStore } from '@/features/shared/banners/banners.store';
import { useUIStore } from '@/app/stores/ui.store';
import { useCanvasOperations } from '@/app/composables/useCanvasOperations';
import type { PushHandlerOptions } from './types';
export async function workflowActivated({ data }: WorkflowActivated) {
export async function workflowActivated(
{ data }: WorkflowActivated,
{ documentId }: PushHandlerOptions,
) {
const { initializeWorkspace } = useCanvasOperations();
const workflowsStore = useWorkflowsStore();
const workflowsListStore = useWorkflowsListStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const workflowDocumentStore = useWorkflowDocumentStore(documentId);
const bannersStore = useBannersStore();
const uiStore = useUIStore();
const { workflowId, activeVersionId } = data;
const workflowIsBeingViewed = workflowsStore.workflowId === workflowId;
const activeVersionChanged = workflowDocumentStore?.value?.activeVersionId !== activeVersionId;
const workflowIsBeingViewed = workflowDocumentStore.workflowId === workflowId;
const activeVersionChanged = workflowDocumentStore.activeVersionId !== activeVersionId;
if (workflowIsBeingViewed && activeVersionChanged) {
// Only update workflow if there are no unsaved changes
if (!uiStore.stateIsDirty) {

View File

@ -1,22 +1,26 @@
import type { WorkflowAutoDeactivated } from '@n8n/api-types/push/workflow';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { useWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { useBannersStore } from '@/features/shared/banners/banners.store';
import { useUIStore } from '@/app/stores/ui.store';
import { useCanvasOperations } from '@/app/composables/useCanvasOperations';
import type { PushHandlerOptions } from './types';
export async function workflowAutoDeactivated({ data }: WorkflowAutoDeactivated) {
export async function workflowAutoDeactivated(
{ data }: WorkflowAutoDeactivated,
{ documentId }: PushHandlerOptions,
) {
const workflowsStore = useWorkflowsStore();
const workflowsListStore = useWorkflowsListStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const workflowDocumentStore = useWorkflowDocumentStore(documentId);
const { initializeWorkspace } = useCanvasOperations();
const bannersStore = useBannersStore();
const uiStore = useUIStore();
workflowsStore.setWorkflowInactive(data.workflowId);
if (workflowsStore.workflowId === data.workflowId) {
if (workflowDocumentStore.workflowId === data.workflowId) {
// Only update workflow if there are no unsaved changes
if (!uiStore.stateIsDirty) {
const updatedWorkflow = await workflowsListStore.fetchWorkflow(data.workflowId);
@ -26,7 +30,7 @@ export async function workflowAutoDeactivated({ data }: WorkflowAutoDeactivated)
// initializeWorkspace calls initState which sets the document store
await initializeWorkspace(updatedWorkflow);
} else {
workflowDocumentStore?.value?.setActiveState({ activeVersionId: null, activeVersion: null });
workflowDocumentStore.setActiveState({ activeVersionId: null, activeVersion: null });
}
bannersStore.pushBannerToStack('WORKFLOW_AUTO_DEACTIVATED');

View File

@ -1,18 +1,20 @@
import type { WorkflowDeactivated } from '@n8n/api-types/push/workflow';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { useWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { useUIStore } from '@/app/stores/ui.store';
import { useCanvasOperations } from '@/app/composables/useCanvasOperations';
import type { PushHandlerOptions } from './types';
export async function workflowDeactivated({ data }: WorkflowDeactivated) {
export async function workflowDeactivated(
{ data }: WorkflowDeactivated,
{ documentId }: PushHandlerOptions,
) {
const { initializeWorkspace } = useCanvasOperations();
const workflowsStore = useWorkflowsStore();
const workflowsListStore = useWorkflowsListStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const workflowDocumentStore = useWorkflowDocumentStore(documentId);
const uiStore = useUIStore();
if (workflowsStore.workflowId === data.workflowId) {
if (workflowDocumentStore.workflowId === data.workflowId) {
// Only update workflow if there are no unsaved changes
if (!uiStore.stateIsDirty) {
const updatedWorkflow = await workflowsListStore.fetchWorkflow(data.workflowId);
@ -22,7 +24,7 @@ export async function workflowDeactivated({ data }: WorkflowDeactivated) {
// initializeWorkspace calls initState which sets the document store
await initializeWorkspace(updatedWorkflow);
} else {
workflowDocumentStore?.value?.setActiveState({ activeVersionId: null, activeVersion: null });
workflowDocumentStore.setActiveState({ activeVersionId: null, activeVersion: null });
}
}
}

View File

@ -3,18 +3,22 @@ import { useToast } from '@/app/composables/useToast';
import { useActivationError } from '@/app/composables/useActivationError';
import { useI18n } from '@n8n/i18n';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { useWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import type { PushHandlerOptions } from './types';
export async function workflowFailedToActivate({ data }: WorkflowFailedToActivate) {
export async function workflowFailedToActivate(
{ data }: WorkflowFailedToActivate,
{ documentId }: PushHandlerOptions,
) {
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const workflowDocumentStore = useWorkflowDocumentStore(documentId);
if (workflowsStore.workflowId !== data.workflowId) {
if (workflowDocumentStore.workflowId !== data.workflowId) {
return;
}
workflowsStore.setWorkflowInactive(data.workflowId);
workflowDocumentStore?.value?.setActiveState({ activeVersionId: null, activeVersion: null });
workflowDocumentStore.setActiveState({ activeVersionId: null, activeVersion: null });
const toast = useToast();
const i18n = useI18n();

View File

@ -1,16 +1,20 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mock } from 'vitest-mock-extended';
import type { Router } from 'vue-router';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import type { IWorkflowSettings } from 'n8n-workflow';
import type { WorkflowSettingsUpdated } from '@n8n/api-types/push/workflow';
import { workflowSettingsUpdated } from './workflowSettingsUpdated';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { createWorkflowDocumentId } from '@/app/stores/workflowDocument.store';
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
import { mockedStore } from '@/__tests__/utils';
import type { PushHandlerOptions } from './types';
const { mockWorkflowDocumentStore } = vi.hoisted(() => ({
mockWorkflowDocumentStore: {
workflowId: '',
allNodes: [],
name: '',
settings: {},
@ -38,14 +42,15 @@ const makeEvent = (
});
describe('workflowSettingsUpdated', () => {
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
let options: PushHandlerOptions;
let workflowsListStore: ReturnType<typeof mockedStore<typeof useWorkflowsListStore>>;
beforeEach(() => {
vi.clearAllMocks();
setActivePinia(createTestingPinia({ stubActions: false }));
workflowsStore = mockedStore(useWorkflowsStore);
workflowsListStore = mockedStore(useWorkflowsListStore);
mockWorkflowDocumentStore.workflowId = '';
options = { router: mock<Router>(), documentId: createWorkflowDocumentId('current') };
});
it('merges partial settings into an existing list entry', async () => {
@ -57,7 +62,7 @@ describe('workflowSettingsUpdated', () => {
},
} as unknown as typeof workflowsListStore.workflowsById;
await workflowSettingsUpdated(makeEvent('wf-1', { availableInMCP: true }));
await workflowSettingsUpdated(makeEvent('wf-1', { availableInMCP: true }), options);
expect(workflowsListStore.workflowsById['wf-1'].settings).toEqual({
availableInMCP: true,
@ -70,7 +75,7 @@ describe('workflowSettingsUpdated', () => {
'wf-1': { id: 'wf-1', name: 'wf' },
} as unknown as typeof workflowsListStore.workflowsById;
await workflowSettingsUpdated(makeEvent('wf-1', { availableInMCP: true }));
await workflowSettingsUpdated(makeEvent('wf-1', { availableInMCP: true }), options);
expect(workflowsListStore.workflowsById['wf-1'].settings).toEqual({
availableInMCP: true,
@ -78,16 +83,16 @@ describe('workflowSettingsUpdated', () => {
});
it('does nothing for the document store when the workflow is not the active one', async () => {
workflowsStore.setWorkflowId('other-workflow');
mockWorkflowDocumentStore.workflowId = 'other-workflow';
await workflowSettingsUpdated(makeEvent('wf-1', { availableInMCP: true }));
await workflowSettingsUpdated(makeEvent('wf-1', { availableInMCP: true }), options);
expect(mockWorkflowDocumentStore.mergeSettings).not.toHaveBeenCalled();
expect(mockWorkflowDocumentStore.setChecksum).not.toHaveBeenCalled();
});
it('merges settings and uses payload checksum for the active document', async () => {
workflowsStore.setWorkflowId('wf-current');
mockWorkflowDocumentStore.workflowId = 'wf-current';
workflowsListStore.workflowsById = {
'wf-current': {
id: 'wf-current',
@ -99,6 +104,7 @@ describe('workflowSettingsUpdated', () => {
await workflowSettingsUpdated(
makeEvent('wf-current', { availableInMCP: true }, 'fresh-checksum'),
options,
);
expect(mockWorkflowDocumentStore.mergeSettings).toHaveBeenCalledWith({ availableInMCP: true });
@ -107,7 +113,7 @@ describe('workflowSettingsUpdated', () => {
});
it('applies settings but skips checksum refresh when none is provided', async () => {
workflowsStore.setWorkflowId('wf-current');
mockWorkflowDocumentStore.workflowId = 'wf-current';
workflowsListStore.workflowsById = {
'wf-current': {
id: 'wf-current',
@ -117,7 +123,7 @@ describe('workflowSettingsUpdated', () => {
},
} as unknown as typeof workflowsListStore.workflowsById;
await workflowSettingsUpdated(makeEvent('wf-current', { availableInMCP: true }));
await workflowSettingsUpdated(makeEvent('wf-current', { availableInMCP: true }), options);
expect(mockWorkflowDocumentStore.mergeSettings).toHaveBeenCalledWith({ availableInMCP: true });
expect(mockWorkflowDocumentStore.setChecksum).not.toHaveBeenCalled();
@ -125,7 +131,7 @@ describe('workflowSettingsUpdated', () => {
});
it('merges multiple settings keys in one event', async () => {
workflowsStore.setWorkflowId('wf-current');
mockWorkflowDocumentStore.workflowId = 'wf-current';
workflowsListStore.workflowsById = {
'wf-current': {
id: 'wf-current',
@ -136,6 +142,7 @@ describe('workflowSettingsUpdated', () => {
await workflowSettingsUpdated(
makeEvent('wf-current', { availableInMCP: true, timezone: 'UTC' }),
options,
);
expect(mockWorkflowDocumentStore.mergeSettings).toHaveBeenCalledWith({

View File

@ -1,17 +1,14 @@
import type { WorkflowSettingsUpdated } from '@n8n/api-types/push/workflow';
import type { IWorkflowSettings } from '@/Interface';
import {
createWorkflowDocumentId,
useWorkflowDocumentStore,
} from '@/app/stores/workflowDocument.store';
import { useWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import type { PushHandlerOptions } from './types';
export async function workflowSettingsUpdated({
data: { workflowId, settings, checksum },
}: WorkflowSettingsUpdated) {
const workflowsStore = useWorkflowsStore();
export async function workflowSettingsUpdated(
{ data: { workflowId, settings, checksum } }: WorkflowSettingsUpdated,
{ documentId }: PushHandlerOptions,
) {
const workflowsListStore = useWorkflowsListStore();
// Keep the list entry in sync so other views (workflow cards, MCP
@ -28,10 +25,11 @@ export async function workflowSettingsUpdated({
}
}
// Only the editor tab needs to resync the document store + checksum.
if (workflowId !== workflowsStore.workflowId) return;
const workflowDocumentStore = useWorkflowDocumentStore(documentId);
// Only the editor tab needs to resync the document store + checksum.
if (workflowId !== workflowDocumentStore.workflowId) return;
const workflowDocumentStore = useWorkflowDocumentStore(createWorkflowDocumentId(workflowId));
workflowDocumentStore.mergeSettings(settings);
if (checksum) {

View File

@ -23,22 +23,14 @@ import {
workflowAutoDeactivated,
workflowSettingsUpdated,
} from '@/app/composables/usePushConnection/handlers';
import { injectWorkflowState, type WorkflowState } from '@/app/composables/useWorkflowState';
import type { PushHandlerOptions } from '@/app/composables/usePushConnection/handlers/types';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { createEventQueue } from '@n8n/utils/event-queue';
import type { useRouter } from 'vue-router';
export function usePushConnection({
router,
workflowState,
}: {
router: ReturnType<typeof useRouter>;
workflowState?: WorkflowState;
}) {
export function usePushConnection({ router }: { router: ReturnType<typeof useRouter> }) {
const pushStore = usePushConnectionStore();
const options = {
router,
workflowState: workflowState ?? injectWorkflowState(),
};
const workflowDocumentStore = injectWorkflowDocumentStore();
const { enqueue } = createEventQueue<PushMessage>(processEvent);
@ -60,6 +52,13 @@ export function usePushConnection({
* Process received push message event by calling the correct handler
*/
async function processEvent(event: PushMessage) {
// Resolve the current workflow document per event so handlers always act on
// the workflow the user is currently viewing, even as they navigate.
const options: PushHandlerOptions = {
router,
documentId: workflowDocumentStore.value.documentId,
};
switch (event.type) {
case 'testWebhookDeleted':
return await testWebhookDeleted(event, options);
@ -76,27 +75,27 @@ export function usePushConnection({
case 'nodeExecuteAfter':
return await nodeExecuteAfter(event, options);
case 'nodeExecuteAfterData':
return await nodeExecuteAfterData(event);
return await nodeExecuteAfterData(event, options);
case 'executionStarted':
return await executionStarted(event);
return await executionStarted(event, options);
case 'sendWorkerStatusMessage':
return await sendWorkerStatusMessage(event);
case 'sendConsoleMessage':
return await sendConsoleMessage(event);
case 'workflowFailedToActivate':
return await workflowFailedToActivate(event);
return await workflowFailedToActivate(event, options);
case 'executionFinished':
return await executionFinished(event, options);
case 'executionRecovered':
return await executionRecovered(event, options);
case 'workflowActivated':
return await workflowActivated(event);
return await workflowActivated(event, options);
case 'workflowDeactivated':
return await workflowDeactivated(event);
return await workflowDeactivated(event, options);
case 'workflowAutoDeactivated':
return await workflowAutoDeactivated(event);
return await workflowAutoDeactivated(event, options);
case 'workflowSettingsUpdated':
return await workflowSettingsUpdated(event);
return await workflowSettingsUpdated(event, options);
case 'updateBuilderCredits':
return await builderCreditsUpdated(event);
}

View File

@ -5,20 +5,11 @@ import * as workflowHelpers from './useWorkflowHelpers';
import { renderComponent } from '@/__tests__/render';
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { injectWorkflowState, useWorkflowState, type WorkflowState } from './useWorkflowState';
import {
useWorkflowDocumentStore,
createWorkflowDocumentId,
} from '@/app/stores/workflowDocument.store';
vi.mock('@/app/composables/useWorkflowState', async () => {
const actual = await vi.importActual('@/app/composables/useWorkflowState');
return {
...actual,
injectWorkflowState: vi.fn(),
};
});
async function renderTestComponent(...options: Parameters<typeof useResolvedExpression>) {
let resolvedExpression!: ReturnType<typeof useResolvedExpression>;
@ -44,15 +35,10 @@ const mockResolveExpression = () => {
return mock;
};
let workflowState: WorkflowState;
describe('useResolvedExpression', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }));
vi.useFakeTimers();
workflowState = useWorkflowState();
vi.mocked(injectWorkflowState).mockReturnValue(workflowState);
});
afterEach(() => {

View File

@ -14,11 +14,6 @@ import type {
} from 'n8n-workflow';
import { useRunWorkflow } from '@/app/composables/useRunWorkflow';
import {
injectWorkflowState,
useWorkflowState,
type WorkflowState,
} from '@/app/composables/useWorkflowState';
import { chatEventBus } from '@n8n/chat/event-buses';
import { useChat } from '@n8n/chat/composables';
import type { INodeUi, IStartRunData } from '@/Interface';
@ -265,16 +260,6 @@ vi.mock('vue-router', async (importOriginal) => {
};
});
vi.mock('@/app/composables/useWorkflowState', async () => {
const actual = await vi.importActual('@/app/composables/useWorkflowState');
return {
...actual,
injectWorkflowState: vi.fn(),
};
});
let workflowState: WorkflowState;
describe('useRunWorkflow({ router })', () => {
let pushConnectionStore: ReturnType<typeof usePushConnectionStore>;
let uiStore: ReturnType<typeof useUIStore>;
@ -282,6 +267,11 @@ describe('useRunWorkflow({ router })', () => {
let router: ReturnType<typeof useRouter>;
let workflowHelpers: ReturnType<typeof useWorkflowHelpers>;
let agentRequestStore: ReturnType<typeof useAgentRequestStore>;
// Production resolves the execution-state store keyed by the injected document
// store's `documentId` ('123@latest'). `createWorkflowDocumentId` is mocked to
// `${id}@latest`, so resolving by '123' returns the same store instance the
// composable writes to (Pinia dedupes by id).
let executionStateStore: ReturnType<typeof useWorkflowExecutionStateStore>;
beforeEach(() => {
const pinia = createTestingPinia({ stubActions: false });
@ -292,9 +282,7 @@ describe('useRunWorkflow({ router })', () => {
uiStore = useUIStore();
workflowsStore = useWorkflowsStore();
agentRequestStore = useAgentRequestStore();
workflowState = vi.mocked(useWorkflowState());
vi.mocked(injectWorkflowState).mockReturnValue(workflowState);
executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('123'));
router = useRouter();
workflowHelpers = useWorkflowHelpers();
@ -314,7 +302,7 @@ describe('useRunWorkflow({ router })', () => {
});
afterEach(() => {
workflowState.setActiveExecutionId(undefined);
executionStateStore.setActiveExecutionId(undefined);
vi.clearAllMocks();
});
@ -329,24 +317,8 @@ describe('useRunWorkflow({ router })', () => {
);
});
it('should use the passed-in workflowState when injection is unavailable', async () => {
vi.mocked(injectWorkflowState).mockReturnValue(undefined as unknown as WorkflowState);
const setActiveExecutionId = vi.spyOn(workflowState, 'setActiveExecutionId');
const { runWorkflowApi } = useRunWorkflow({ router, workflowState });
vi.mocked(pushConnectionStore).isConnected = true;
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue({
executionId: '123',
waitingForWebhook: false,
});
await expect(runWorkflowApi({} as IStartRunData)).resolves.not.toThrow();
expect(setActiveExecutionId).toHaveBeenCalled();
});
it('should successfully run a workflow', async () => {
const setActiveExecutionId = vi.spyOn(workflowState, 'setActiveExecutionId');
const setActiveExecutionId = vi.spyOn(executionStateStore, 'setActiveExecutionId');
const { runWorkflowApi } = useRunWorkflow({ router });
vi.mocked(pushConnectionStore).isConnected = true;
@ -378,7 +350,7 @@ describe('useRunWorkflow({ router })', () => {
});
it('should handle workflow run failure', async () => {
const setActiveExecutionId = vi.spyOn(workflowState, 'setActiveExecutionId');
const setActiveExecutionId = vi.spyOn(executionStateStore, 'setActiveExecutionId');
const { runWorkflowApi } = useRunWorkflow({ router });
vi.mocked(pushConnectionStore).isConnected = true;
@ -407,7 +379,7 @@ describe('useRunWorkflow({ router })', () => {
describe('runWorkflow()', () => {
it('should return undefined if UI action "workflowRunning" is active', async () => {
const { runWorkflow } = useRunWorkflow({ router });
workflowState.setActiveExecutionId('123');
executionStateStore.setActiveExecutionId('123');
const result = await runWorkflow({});
expect(result).toBeUndefined();
});
@ -501,6 +473,87 @@ describe('useRunWorkflow({ router })', () => {
});
});
describe('scoped workflow document store (eval-loop reruns)', () => {
// Async push handlers (e.g. evaluation-loop reruns) pass their own document
// store. The run must target that document, not whatever workflow happens to
// be globally current — otherwise navigating away mid-evaluation reruns the
// wrong workflow.
function createScopedDocumentStore(workflowId: string) {
return shallowRef({
...mockDocumentStore,
documentId: createWorkflowDocumentId(workflowId),
workflowId,
name: `Workflow ${workflowId}`,
serialize: vi.fn().mockReturnValue({
id: workflowId,
name: `Workflow ${workflowId}`,
active: false,
nodes: [],
pinData: {},
} as unknown as WorkflowData),
} as unknown as ReturnType<typeof useWorkflowDocumentStore>);
}
beforeEach(() => {
vi.mocked(pushConnectionStore).isConnected = true;
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue({ executionId: 'exec-1' });
vi.mocked(workflowsStore).getWorkflowRunData = null;
// The injected (globally-current) document store is workflow '123'. The
// scoped store passed per-call is a *different* workflow ('456'), so any
// fallback to global state surfaces as a wrong id in the assertions below.
// We deliberately leave `workflowsStore.workflowId` at its '123' default so
// later tests that rely on the global id aren't affected.
mockDocumentStore.serialize.mockReturnValue({
id: '123',
name: 'Globally current workflow',
active: false,
nodes: [],
pinData: {},
} as unknown as WorkflowData);
});
it('stages execution and start data with the scoped document workflow id, not the global one', async () => {
vi.mocked(workflowsStore).isWorkflowSaved = { '123': true, '456': true };
// The scoped run targets workflow '456', so the execution-state store the
// composable writes to is keyed by that document, not the global '123' one.
const scopedExecutionStateStore = useWorkflowExecutionStateStore(
createWorkflowDocumentId('456'),
);
const setWorkflowExecutionData = vi.spyOn(
scopedExecutionStateStore,
'setWorkflowExecutionData',
);
const dataCaptor = captor();
const { runWorkflow } = useRunWorkflow({
router,
workflowDocumentStore: createScopedDocumentStore('456'),
});
await runWorkflow({});
expect(workflowsStore.runWorkflow).toHaveBeenCalledWith(
expect.objectContaining({ workflowId: '456' }),
);
expect(setWorkflowExecutionData).toHaveBeenCalledWith(dataCaptor);
expect(dataCaptor.value).toMatchObject({ workflowData: { id: '456' } });
});
it('saves the scoped workflow (not the global one) when a save is required before running', async () => {
// Scoped '456' is not yet saved -> save required; global '123' is saved, so a
// fallback to the global id would wrongly skip the save entirely.
vi.mocked(workflowsStore).isWorkflowSaved = { '123': true };
const workflowSaving = useWorkflowSaving({ router });
const { runWorkflow } = useRunWorkflow({
router,
workflowDocumentStore: createScopedDocumentStore('456'),
});
await runWorkflow({});
expect(workflowSaving.saveCurrentWorkflow).toHaveBeenCalledWith({ id: '456' });
});
});
it('should prevent execution and show error when binary mode is "combined" with filesystem mode "default"', async () => {
const pinia = createTestingPinia({ stubActions: false });
setActivePinia(pinia);
@ -785,7 +838,7 @@ describe('useRunWorkflow({ router })', () => {
vi.mocked(workflowsStore).getWorkflowRunData = mockRunData;
vi.mocked(agentRequestStore).getAgentRequest.mockReturnValue(agentRequest);
const setWorkflowExecutionData = vi.spyOn(workflowState, 'setWorkflowExecutionData');
const setWorkflowExecutionData = vi.spyOn(executionStateStore, 'setWorkflowExecutionData');
// ACT
const result = await runWorkflow({
@ -834,7 +887,7 @@ describe('useRunWorkflow({ router })', () => {
);
vi.mocked(workflowsStore).getWorkflowRunData = mockRunData;
const setWorkflowExecutionData = vi.spyOn(workflowState, 'setWorkflowExecutionData');
const setWorkflowExecutionData = vi.spyOn(executionStateStore, 'setWorkflowExecutionData');
// ACT
const result = await runWorkflow({
@ -881,7 +934,7 @@ describe('useRunWorkflow({ router })', () => {
nodes: [],
} as unknown as WorkflowData);
const setWorkflowExecutionData = vi.spyOn(workflowState, 'setWorkflowExecutionData');
const setWorkflowExecutionData = vi.spyOn(executionStateStore, 'setWorkflowExecutionData');
// Simulate failed execution start
vi.mocked(workflowsStore).runWorkflow.mockRejectedValueOnce(new Error());
@ -1334,8 +1387,8 @@ describe('useRunWorkflow({ router })', () => {
},
} as IExecutionResponse['data'],
};
const setActiveExecutionIdSpy = vi.spyOn(workflowState, 'setActiveExecutionId');
const setWorkflowExecutionDataSpy = vi.spyOn(workflowState, 'setWorkflowExecutionData');
const setActiveExecutionIdSpy = vi.spyOn(executionStateStore, 'setActiveExecutionId');
const setWorkflowExecutionDataSpy = vi.spyOn(executionStateStore, 'setWorkflowExecutionData');
// Stop API throws because the execution already finished server-side.
const { useExecutionsStore } = await import(
@ -1348,7 +1401,7 @@ describe('useRunWorkflow({ router })', () => {
// getExecution returns the canonical finished snapshot (with id).
vi.spyOn(workflowsStore, 'getExecution').mockResolvedValue(finishedExecution);
workflowState.setActiveExecutionId('exec-fin');
executionStateStore.setActiveExecutionId('exec-fin');
(mockDocumentStore as unknown as { getSnapshot: () => unknown }).getSnapshot = vi
.fn()
@ -1390,7 +1443,7 @@ describe('useRunWorkflow({ router })', () => {
startedAt: new Date('2025-04-01T00:00:00.000Z'),
createdAt: new Date('2025-04-01T00:00:00.000Z'),
};
const markStoppedSpy = vi.spyOn(workflowState, 'markExecutionAsStopped');
const markStoppedSpy = vi.spyOn(executionStateStore, 'markExecutionAsStopped');
const getExecutionSpy = vi.spyOn(workflowsStore, 'getExecution');
const { useWorkflowsListStore } = await import('@/app/stores/workflowsList.store');
@ -1400,7 +1453,7 @@ describe('useRunWorkflow({ router })', () => {
workflowsListStore.activeWorkflows.length,
'test-wf-id',
);
workflowState.setActiveExecutionId('test-exec-id');
executionStateStore.setActiveExecutionId('test-exec-id');
useWorkflowExecutionStateStore(createWorkflowDocumentId('123')).setExecutionWaitingForWebhook(
false,
);

View File

@ -22,7 +22,7 @@ import {
BINARY_MODE_COMBINED,
} from 'n8n-workflow';
import { retry } from '@n8n/utils/retry';
import { computed } from 'vue';
import { computed, type Ref } from 'vue';
import { useToast } from '@/app/composables/useToast';
import { useNodeHelpers } from '@/app/composables/useNodeHelpers';
@ -39,7 +39,10 @@ import {
import { useRootStore } from '@n8n/stores/useRootStore';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import {
injectWorkflowDocumentStore,
type WorkflowDocumentStore,
} from '@/app/stores/workflowDocument.store';
import { displayForm } from '@/features/execution/executions/executions.utils';
import { useExternalHooks } from '@/app/composables/useExternalHooks';
import { useWorkflowHelpers } from '@/app/composables/useWorkflowHelpers';
@ -57,14 +60,19 @@ import { useCanvasOperations } from './useCanvasOperations';
import { chatEventBus } from '@n8n/chat/event-buses';
import { useAgentRequestStore } from '@n8n/stores/useAgentRequestStore';
import { useWorkflowSaving } from './useWorkflowSaving';
import { injectWorkflowState, type WorkflowState } from '@/app/composables/useWorkflowState';
import { useDocumentTitle } from './useDocumentTitle';
import { useChat } from '@n8n/chat/composables';
import type { WorkflowObjectAccessors } from '../types';
export function useRunWorkflow(useRunWorkflowOpts: {
router: ReturnType<typeof useRouter>;
workflowState?: WorkflowState;
/**
* Binds this instance to a specific workflow document. Pass this from
* async, non-setup callers (e.g. push handlers) where `inject()` can't
* resolve the current document; otherwise it falls back to the injected
* document store for normal setup-context callers.
*/
workflowDocumentStore?: Readonly<Ref<WorkflowDocumentStore>>;
}) {
const workflowHelpers = useWorkflowHelpers();
const i18n = useI18n();
@ -78,14 +86,11 @@ export function useRunWorkflow(useRunWorkflowOpts: {
const rootStore = useRootStore();
const pushConnectionStore = usePushConnectionStore();
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const workflowDocumentStore =
useRunWorkflowOpts.workflowDocumentStore ?? injectWorkflowDocumentStore();
const workflowExecutionState = computed(() =>
useWorkflowExecutionStateStore(workflowDocumentStore.value.documentId),
);
// `inject()` only resolves inside a setup context; callers from async event
// handlers must pass `workflowState` in.
const workflowState = useRunWorkflowOpts.workflowState ?? injectWorkflowState();
const nodeHelpers = useNodeHelpers();
const workflowSaving = useWorkflowSaving({
router: useRunWorkflowOpts.router,
@ -118,13 +123,13 @@ export function useRunWorkflow(useRunWorkflowOpts: {
}
// Set the execution as started, but still waiting for the execution to be retrieved
workflowState.setActiveExecutionId(null);
workflowExecutionState.value.setActiveExecutionId(null);
let response: IExecutionPushResponse;
try {
response = await workflowsStore.runWorkflow(runData);
} catch (error) {
workflowState.setActiveExecutionId(undefined);
workflowExecutionState.value.setActiveExecutionId(undefined);
throw error;
}
@ -132,7 +137,7 @@ export function useRunWorkflow(useRunWorkflowOpts: {
workflowExecutionState.value.previousExecutionId !== response.executionId;
const workflowExecutionIdIsPending = workflowExecutionState.value.activeExecutionId === null;
if (response.executionId && workflowExecutionIdIsNew && workflowExecutionIdIsPending) {
workflowState.setActiveExecutionId(response.executionId);
workflowExecutionState.value.setActiveExecutionId(response.executionId);
}
if (response.waitingForWebhook === true) {
@ -169,9 +174,9 @@ export function useRunWorkflow(useRunWorkflowOpts: {
const runData = workflowsStore.getWorkflowRunData;
const isNewWorkflow = !workflowsStore.isWorkflowSaved[workflowsStore.workflowId];
const isNewWorkflow = !workflowsStore.isWorkflowSaved[workflowDocumentStore.value.workflowId];
if (isNewWorkflow || (uiStore.stateIsDirty && settingsStore.isAutosaveEnabled)) {
await workflowSaving.saveCurrentWorkflow();
await workflowSaving.saveCurrentWorkflow({ id: workflowDocumentStore.value.workflowId });
}
const workflowData = workflowDocumentStore.value.serialize();
@ -405,7 +410,7 @@ export function useRunWorkflow(useRunWorkflowOpts: {
},
}),
workflowData: {
id: workflowsStore.workflowId,
id: workflowDocumentStore.value.workflowId,
name: workflowData.name!,
active: workflowData.active!,
createdAt: 0,
@ -413,7 +418,7 @@ export function useRunWorkflow(useRunWorkflowOpts: {
...workflowData,
} as IWorkflowDb,
};
workflowState.setWorkflowExecutionData(executionData);
workflowExecutionState.value.setWorkflowExecutionData(executionData);
nodeHelpers.updateNodesExecutionIssues();
useDocumentTitle().setDocumentTitle(workflowDocumentStore.value.name, 'EXECUTING');
@ -461,7 +466,7 @@ export function useRunWorkflow(useRunWorkflowOpts: {
return runWorkflowApiResponse;
} catch (error) {
console.error(error);
workflowState.setWorkflowExecutionData(null);
workflowExecutionState.value.setWorkflowExecutionData(null);
useDocumentTitle().setDocumentTitle(workflowDocumentStore.value.name, 'ERROR');
toast.showError(error, i18n.baseText('workflowRun.showError.title'));
return undefined;
@ -553,8 +558,8 @@ export function useRunWorkflow(useRunWorkflowOpts: {
} as IExecutionResponse;
// Clear the active id so setWorkflowExecutionData's else branch sets
// displayedExecutionId to the freshly-fetched finished id.
workflowState.setActiveExecutionId(undefined);
workflowState.setWorkflowExecutionData(executedData);
workflowExecutionState.value.setActiveExecutionId(undefined);
workflowExecutionState.value.setWorkflowExecutionData(executedData);
toast.showMessage({
title: i18n.baseText('nodeView.showMessage.stopExecutionCatch.title'),
message: i18n.baseText('nodeView.showMessage.stopExecutionCatch.message'),
@ -569,7 +574,7 @@ export function useRunWorkflow(useRunWorkflowOpts: {
async () => {
const execution = await workflowsStore.getExecution(executionId);
if (!['running', 'waiting'].includes(execution?.status as string)) {
workflowState.markExecutionAsStopped(stopData);
workflowExecutionState.value.markExecutionAsStopped(stopData);
return true;
}
@ -580,7 +585,7 @@ export function useRunWorkflow(useRunWorkflowOpts: {
);
if (!markedAsStopped) {
workflowState.markExecutionAsStopped(stopData);
workflowExecutionState.value.markExecutionAsStopped(stopData);
}
}
}

View File

@ -6,7 +6,6 @@ import { render } from '@testing-library/vue';
import { useWorkflowInitialization } from './useWorkflowInitialization';
import { WorkflowDocumentStoreKey } from '@/app/constants/injectionKeys';
import type { WorkflowState } from '@/app/composables/useWorkflowState';
import type { IWorkflowDb } from '@/Interface';
const mockSetDocumentTitle = vi.hoisted(() => vi.fn());
@ -139,7 +138,7 @@ function renderWithComposable(
) {
const TestComponent = defineComponent({
setup() {
const init = useWorkflowInitialization({} as unknown as WorkflowState);
const init = useWorkflowInitialization();
callback(init);
return () => h('div');
},

View File

@ -26,7 +26,6 @@ import { useTelemetry } from '@/app/composables/useTelemetry';
import { useExecutionDebugging } from '@/features/execution/executions/composables/useExecutionDebugging';
import { getSampleWorkflowByTemplateId } from '@/features/workflows/templates/utils/workflowSamples';
import { EnterpriseEditionFeature, VIEWS } from '@/app/constants';
import type { WorkflowState } from '@/app/composables/useWorkflowState';
import type { IWorkflowDb } from '@/Interface';
import {
useWorkflowDocumentStore,
@ -38,7 +37,7 @@ import { WorkflowDocumentStoreKey } from '@/app/constants/injectionKeys';
import { injectStrict } from '@/app/utils/injectStrict';
import { useWorkflowId } from '@/app/composables/useWorkflowId';
export function useWorkflowInitialization(workflowState: WorkflowState) {
export function useWorkflowInitialization() {
const route = useRoute();
const router = useRouter();
const i18n = useI18n();
@ -72,9 +71,7 @@ export function useWorkflowInitialization(workflowState: WorkflowState) {
openWorkflowTemplate,
openWorkflowTemplateFromJSON,
} = useCanvasOperations();
// Pass workflowState to useExecutionDebugging since we're in the same component
// that provides WorkflowStateKey (WorkflowLayout), so inject won't work
const { applyExecutionData } = useExecutionDebugging(workflowState);
const { applyExecutionData } = useExecutionDebugging();
const isLoading = ref(true);
const initializedWorkflowId = ref<string | undefined>();

View File

@ -1,292 +0,0 @@
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useWorkflowState, type WorkflowState } from './useWorkflowState';
import { createPinia, setActivePinia } from 'pinia';
import { createTestTaskData, createTestWorkflowExecutionResponse } from '@/__tests__/mocks';
import { createRunExecutionData } from 'n8n-workflow';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { createWorkflowDocumentId } from '@/app/stores/workflowDocument.store';
import { createExecutionDataId, useExecutionDataStore } from '@/app/stores/executionData.store';
import { IN_PROGRESS_EXECUTION_ID } from '@/app/constants/placeholders';
describe('useWorkflowState', () => {
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
let workflowState: WorkflowState;
let workflowExecutionStateStore: ReturnType<typeof useWorkflowExecutionStateStore>;
let executionDataStore: ReturnType<typeof useExecutionDataStore>;
beforeEach(() => {
setActivePinia(createPinia());
workflowsStore = useWorkflowsStore();
workflowsStore.setWorkflowId('test-wf');
workflowState = useWorkflowState();
workflowExecutionStateStore = useWorkflowExecutionStateStore(
createWorkflowDocumentId('test-wf'),
);
});
describe('markExecutionAsStopped', () => {
beforeEach(() => {
// Set up active execution in the facade stores
workflowExecutionStateStore.setActiveExecutionId('test-exec-id');
executionDataStore = useExecutionDataStore(createExecutionDataId('test-exec-id'));
executionDataStore.setExecution(
createTestWorkflowExecutionResponse({
id: 'test-exec-id',
status: 'running',
startedAt: new Date('2023-01-01T09:00:00Z'),
stoppedAt: undefined,
data: createRunExecutionData({
resultData: {
runData: {
node1: [
createTestTaskData({ executionStatus: 'success' }),
createTestTaskData({ executionStatus: 'error' }),
createTestTaskData({ executionStatus: 'running' }),
],
node2: [
createTestTaskData({ executionStatus: 'success' }),
createTestTaskData({ executionStatus: 'waiting' }),
],
},
},
}),
}),
);
});
it('should remove non successful node runs', () => {
workflowState.markExecutionAsStopped();
const runData = executionDataStore.execution?.data?.resultData?.runData;
expect(runData?.node1).toHaveLength(1);
expect(runData?.node1[0].executionStatus).toBe('success');
expect(runData?.node2).toHaveLength(1);
expect(runData?.node2[0].executionStatus).toBe('success');
});
it('should update execution status, startedAt and stoppedAt when data is provided', () => {
workflowState.markExecutionAsStopped({
status: 'canceled',
startedAt: new Date('2023-01-01T10:00:00Z'),
stoppedAt: new Date('2023-01-01T10:05:00Z'),
mode: 'manual',
});
expect(executionDataStore.execution?.status).toBe('canceled');
expect(executionDataStore.execution?.startedAt).toEqual(new Date('2023-01-01T10:00:00Z'));
expect(executionDataStore.execution?.stoppedAt).toEqual(new Date('2023-01-01T10:05:00Z'));
});
it('should not update execution data when stopData is not provided', () => {
workflowState.markExecutionAsStopped();
expect(executionDataStore.execution?.status).toBe('running');
expect(executionDataStore.execution?.startedAt).toEqual(new Date('2023-01-01T09:00:00Z'));
expect(executionDataStore.execution?.stoppedAt).toBeUndefined();
});
describe('when activeExecutionId is null (pending scaffold)', () => {
beforeEach(() => {
// Reset to pending state instead of the string-id default from outer beforeEach.
workflowExecutionStateStore.setActiveExecutionId(undefined);
workflowExecutionStateStore.setPendingExecution(
createTestWorkflowExecutionResponse({
id: IN_PROGRESS_EXECUTION_ID,
status: 'running',
}),
);
// Re-set since promotePendingExecution would have moved it; emulate raw scaffold state.
workflowExecutionStateStore.setActiveExecutionId(null);
useExecutionDataStore(createExecutionDataId(IN_PROGRESS_EXECUTION_ID)).setExecution(
createTestWorkflowExecutionResponse({
id: IN_PROGRESS_EXECUTION_ID,
status: 'running',
data: createRunExecutionData({
resultData: {
runData: {
node1: [
createTestTaskData({ executionStatus: 'success' }),
createTestTaskData({ executionStatus: 'error' }),
],
},
},
}),
}),
);
});
it('filters non-success runs in the IN_PROGRESS placeholder store', () => {
workflowState.markExecutionAsStopped({
status: 'canceled',
startedAt: new Date('2023-01-01T10:00:00Z'),
stoppedAt: new Date('2023-01-01T10:05:00Z'),
mode: 'manual',
});
const placeholder = useExecutionDataStore(createExecutionDataId(IN_PROGRESS_EXECUTION_ID));
expect(placeholder.execution?.data?.resultData?.runData?.node1).toHaveLength(1);
expect(placeholder.execution?.data?.resultData?.runData?.node1[0].executionStatus).toBe(
'success',
);
expect(placeholder.execution?.status).toBe('canceled');
});
it('mirrors stopData onto the pendingExecution ref', () => {
workflowState.markExecutionAsStopped({
status: 'canceled',
startedAt: new Date('2023-01-01T10:00:00Z'),
stoppedAt: new Date('2023-01-01T10:05:00Z'),
mode: 'manual',
});
expect(workflowExecutionStateStore.pendingExecution?.status).toBe('canceled');
expect(workflowExecutionStateStore.pendingExecution?.startedAt).toEqual(
new Date('2023-01-01T10:00:00Z'),
);
expect(workflowExecutionStateStore.pendingExecution?.stoppedAt).toEqual(
new Date('2023-01-01T10:05:00Z'),
);
});
});
describe('when activeExecutionId is undefined and displayedExecutionId is set', () => {
beforeEach(() => {
// Simulate post-stop-race: active was just cleared, but displayed still points
// at the freshly-fetched finished execution.
workflowExecutionStateStore.setActiveExecutionId('display-exec');
workflowExecutionStateStore.setActiveExecutionId(undefined);
expect(workflowExecutionStateStore.activeExecutionId).toBeUndefined();
expect(workflowExecutionStateStore.displayedExecutionId).toBe('display-exec');
useExecutionDataStore(createExecutionDataId('display-exec')).setExecution(
createTestWorkflowExecutionResponse({
id: 'display-exec',
status: 'running',
data: createRunExecutionData({
resultData: {
runData: {
node1: [
createTestTaskData({ executionStatus: 'success' }),
createTestTaskData({ executionStatus: 'error' }),
],
},
},
}),
}),
);
});
it('falls back to displayedExecutionId for filtering and status update', () => {
workflowState.markExecutionAsStopped({
status: 'canceled',
startedAt: new Date('2023-01-01T10:00:00Z'),
stoppedAt: new Date('2023-01-01T10:05:00Z'),
mode: 'manual',
});
const ds = useExecutionDataStore(createExecutionDataId('display-exec'));
expect(ds.execution?.data?.resultData?.runData?.node1).toHaveLength(1);
expect(ds.execution?.data?.resultData?.runData?.node1[0].executionStatus).toBe('success');
expect(ds.execution?.status).toBe('canceled');
});
});
});
describe('resetState', () => {
it('disposes every executionData store this workflow ever bound, including rolled-out ids', () => {
// Three sequential runs — exec-1 rolls out of previousExecutionId after run 3.
workflowExecutionStateStore.setActiveExecutionId('exec-1');
useExecutionDataStore(createExecutionDataId('exec-1')).setExecution(
createTestWorkflowExecutionResponse({ id: 'exec-1' }),
);
workflowExecutionStateStore.setActiveExecutionId('exec-2');
useExecutionDataStore(createExecutionDataId('exec-2')).setExecution(
createTestWorkflowExecutionResponse({ id: 'exec-2' }),
);
workflowExecutionStateStore.setActiveExecutionId('exec-3');
useExecutionDataStore(createExecutionDataId('exec-3')).setExecution(
createTestWorkflowExecutionResponse({ id: 'exec-3' }),
);
workflowState.resetState();
expect(useExecutionDataStore(createExecutionDataId('exec-1')).execution).toBeNull();
expect(useExecutionDataStore(createExecutionDataId('exec-2')).execution).toBeNull();
expect(useExecutionDataStore(createExecutionDataId('exec-3')).execution).toBeNull();
});
it('clears displayedExecutionId so workflowExecutionData reads as null', () => {
workflowExecutionStateStore.setActiveExecutionId('exec-A');
useExecutionDataStore(createExecutionDataId('exec-A')).setExecution(
createTestWorkflowExecutionResponse({ id: 'exec-A', finished: true, status: 'success' }),
);
// Simulate post-finish: active cleared, displayed preserved (the deliberate UX behavior).
workflowExecutionStateStore.setActiveExecutionId(undefined);
expect(workflowExecutionStateStore.displayedExecutionId).toBe('exec-A');
expect(workflowsStore.workflowExecutionData?.id).toBe('exec-A');
workflowState.resetState();
const fresh = useWorkflowExecutionStateStore(createWorkflowDocumentId('test-wf'));
expect(fresh.displayedExecutionId).toBeUndefined();
expect(fresh.activeExecutionId).toBeUndefined();
expect(fresh.pendingExecution).toBeNull();
expect(workflowsStore.workflowExecutionData).toBeNull();
});
it('disposes the IN_PROGRESS placeholder store along with the pending scaffold', () => {
workflowExecutionStateStore.setPendingExecution(
createTestWorkflowExecutionResponse({
id: IN_PROGRESS_EXECUTION_ID,
status: 'running',
}),
);
useExecutionDataStore(createExecutionDataId(IN_PROGRESS_EXECUTION_ID)).setExecution(
createTestWorkflowExecutionResponse({ id: IN_PROGRESS_EXECUTION_ID }),
);
workflowState.resetState();
expect(
useExecutionDataStore(createExecutionDataId(IN_PROGRESS_EXECUTION_ID)).execution,
).toBeNull();
});
it('is a no-op (other than executingNode/builder reset) when no workflow is loaded', () => {
workflowsStore.setWorkflowId('');
expect(() => workflowState.resetState()).not.toThrow();
});
it('reopening the same workflow id after resetWorkspace order surfaces no stale state', () => {
// Stage execution on test-wf
workflowExecutionStateStore.setActiveExecutionId('exec-A');
useExecutionDataStore(createExecutionDataId('exec-A')).setExecution(
createTestWorkflowExecutionResponse({ id: 'exec-A', finished: true, status: 'success' }),
);
workflowExecutionStateStore.setActiveExecutionId(undefined);
expect(workflowsStore.workflowExecutionData?.id).toBe('exec-A');
// Mirror resetWorkspace ordering: resetState first (while workflowId is still set),
// then resetWorkflow empties the id.
workflowState.resetState();
workflowsStore.resetWorkflow();
expect(workflowsStore.workflowId).toBe('');
// Reopen the same workflow id.
workflowsStore.setWorkflowId('test-wf');
// Fresh state — no leakage.
const fresh = useWorkflowExecutionStateStore(createWorkflowDocumentId('test-wf'));
expect(fresh.activeExecutionId).toBeUndefined();
expect(fresh.displayedExecutionId).toBeUndefined();
expect(fresh.pendingExecution).toBeNull();
expect(useExecutionDataStore(createExecutionDataId('exec-A')).execution).toBeNull();
expect(workflowsStore.workflowExecutionData).toBeNull();
});
});
});

View File

@ -1,163 +0,0 @@
import { WorkflowStateKey } from '@/app/constants';
import {
createWorkflowDocumentId,
useWorkflowDocumentStore,
} from '@/app/stores/workflowDocument.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useWorkflowStateStore } from '@/app/stores/workflowState.store';
import {
disposeWorkflowExecutionStateStore,
useWorkflowExecutionStateStore,
} from '@/app/stores/workflowExecutionState.store';
import { createExecutionDataId, useExecutionDataStore } from '@/app/stores/executionData.store';
import { useBuilderStore } from '@/features/ai/assistant/builder.store';
import type {
IExecutionResponse,
IExecutionsStopData,
} from '@/features/execution/executions/executions.types';
import { clearPopupWindowState } from '@/features/execution/executions/executions.utils';
import { inject } from 'vue';
import { useDocumentTitle } from './useDocumentTitle';
import { IN_PROGRESS_EXECUTION_ID } from '@/app/constants/placeholders';
export function useWorkflowState() {
const ws = useWorkflowsStore();
const workflowStateStore = useWorkflowStateStore();
////
// Workflow editing state
////
function setWorkflowExecutionData(workflowResultData: IExecutionResponse | null) {
const workflowExecutionStateStore = useWorkflowExecutionStateStore(
createWorkflowDocumentId(ws.workflowId),
);
if (workflowResultData === null) {
workflowExecutionStateStore.setPendingExecution(null);
workflowExecutionStateStore.clearDisplayedExecution();
} else if (workflowResultData.id === IN_PROGRESS_EXECUTION_ID) {
workflowExecutionStateStore.setPendingExecution(workflowResultData);
workflowExecutionStateStore.setActiveExecutionId(null);
useExecutionDataStore(createExecutionDataId(IN_PROGRESS_EXECUTION_ID)).setExecution(
workflowResultData,
);
} else {
workflowExecutionStateStore.trackExecutionId(workflowResultData.id);
useExecutionDataStore(createExecutionDataId(workflowResultData.id)).setExecution(
workflowResultData,
);
if (typeof workflowExecutionStateStore.activeExecutionId !== 'string') {
workflowExecutionStateStore.setPendingExecution(null);
workflowExecutionStateStore.setActiveExecutionId(undefined);
workflowExecutionStateStore.setDisplayedExecutionId(workflowResultData.id);
}
}
}
function setActiveExecutionId(id: string | null | undefined) {
useWorkflowExecutionStateStore(createWorkflowDocumentId(ws.workflowId)).setActiveExecutionId(
id,
);
}
////
// Execution
////
const documentTitle = useDocumentTitle();
function markExecutionAsStopped(stopData?: IExecutionsStopData) {
const workflowDocumentStore = useWorkflowDocumentStore(createWorkflowDocumentId(ws.workflowId));
const workflowExecutionStateStore = useWorkflowExecutionStateStore(
workflowDocumentStore.documentId,
);
const activeExecutionId = workflowExecutionStateStore.activeExecutionId;
workflowExecutionStateStore.setActiveExecutionId(undefined);
workflowStateStore.executingNode.clearNodeExecutionQueue();
workflowExecutionStateStore.setExecutionWaitingForWebhook(false);
documentTitle.setDocumentTitle(workflowDocumentStore.name, 'IDLE');
if (typeof activeExecutionId === 'string') {
const executionDataStore = useExecutionDataStore(createExecutionDataId(activeExecutionId));
executionDataStore.clearExecutionStartedData();
executionDataStore.markAsStopped(stopData);
} else if (activeExecutionId === null) {
// Pending scaffold: filter the IN_PROGRESS placeholder data and
// mirror status onto the pendingExecution ref so the UI sees the canceled state.
const executionDataStore = useExecutionDataStore(
createExecutionDataId(IN_PROGRESS_EXECUTION_ID),
);
executionDataStore.clearExecutionStartedData();
executionDataStore.markAsStopped(stopData);
if (stopData) {
workflowExecutionStateStore.applyStopDataToPendingExecution(stopData);
}
} else {
// activeExecutionId === undefined: fall back to displayedExecutionId for the
// stop-race-with-finished case where active was just cleared.
const displayedExecutionId = workflowExecutionStateStore.displayedExecutionId;
if (typeof displayedExecutionId === 'string') {
const executionDataStore = useExecutionDataStore(
createExecutionDataId(displayedExecutionId),
);
executionDataStore.clearExecutionStartedData();
executionDataStore.markAsStopped(stopData);
}
}
clearPopupWindowState();
}
function resetState() {
const wid = ws.workflowId;
if (!wid) {
workflowStateStore.executingNode.executingNode.length = 0;
useBuilderStore().resetManualExecutionStats();
return;
}
const workflowExecutionStateStore = useWorkflowExecutionStateStore(
createWorkflowDocumentId(wid),
);
// Disposes every tracked executionData store + IN_PROGRESS placeholder, then clears all
// session-level fields.
workflowExecutionStateStore.resetExecutionState();
// Then dispose the per-workflow state store so pinia state doesn't accumulate one entry
// per workflow ever opened in this session.
disposeWorkflowExecutionStateStore(workflowExecutionStateStore);
workflowStateStore.executingNode.executingNode.length = 0;
useBuilderStore().resetManualExecutionStats();
}
return {
// Workflow editing state
resetState,
setWorkflowExecutionData,
setActiveExecutionId,
// Execution
markExecutionAsStopped,
// reexport
executingNode: workflowStateStore.executingNode,
};
}
export type WorkflowState = ReturnType<typeof useWorkflowState>;
export function injectWorkflowState() {
return inject(
WorkflowStateKey,
() => {
// While we're migrating we're happy to fall back onto a separate instance since
// all data is still stored in the workflowsStore
// Once we're ready to move the actual refs to `useWorkflowState` we should error here
// to track down remaining usages that would break
// console.error('Attempted to inject workflowState outside of NodeView tree');
return useWorkflowState();
},
true,
);
}

View File

@ -58,14 +58,6 @@ vi.mock('@/app/stores/workflowDocument.store', () => ({
injectWorkflowDocumentStore: vi.fn().mockReturnValue({ value: mockDocumentStore }),
}));
// Mock useWorkflowState - using hoisted for proper initialization
const mockWorkflowState = vi.hoisted(() => ({
touchParametersLastUpdatedAt: vi.fn(),
}));
vi.mock('@/app/composables/useWorkflowState', () => ({
injectWorkflowState: vi.fn(() => mockWorkflowState),
}));
// Mock useCanvasOperations - using hoisted for proper initialization
const mockCanvasOperations = vi.hoisted(() => ({
deleteNode: vi.fn(),

View File

@ -6,7 +6,6 @@ import type {
import type { ComputedRef, InjectionKey, Ref, ShallowRef } from 'vue';
import type { ExpressionLocalResolveContext } from '@/app/types/expressions';
import type { TelemetryContext } from '@/app/types/telemetry';
import type { WorkflowState } from '@/app/composables/useWorkflowState';
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';
@ -22,7 +21,6 @@ export const ExpressionLocalResolveContextSymbol: InjectionKey<
ComputedRef<ExpressionLocalResolveContext | undefined>
> = Symbol('ExpressionLocalResolveContext');
export const TelemetryContextSymbol: InjectionKey<TelemetryContext> = Symbol('TelemetryContext');
export const WorkflowStateKey: InjectionKey<WorkflowState> = Symbol('WorkflowState');
export const WorkflowDocumentStoreKey: InjectionKey<ShallowRef<WorkflowDocumentStore | null>> =
Symbol('WorkflowDocumentStore');
export const ExecutionDataStoreKey: InjectionKey<

View File

@ -30,17 +30,6 @@ vi.mock('vue-router', async (importOriginal) => {
};
});
vi.mock('@/app/composables/useWorkflowState', async (importOriginal) => {
const actual = (await importOriginal()) as object;
return {
...actual,
useWorkflowState: vi.fn(() => ({
setActiveExecutionId: vi.fn(),
resetState: vi.fn(),
})),
};
});
vi.mock('@/app/composables/useWorkflowInitialization', () => ({
useWorkflowInitialization: vi.fn(() => ({
isLoading: ref(false),

View File

@ -1,10 +1,11 @@
<script lang="ts" setup>
import { computed, provide, onBeforeUnmount, onMounted } from 'vue';
import { computed, onBeforeUnmount, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import BaseLayout from './BaseLayout.vue';
import DemoFooter from '@/features/execution/logs/components/DemoFooter.vue';
import { WorkflowStateKey } from '@/app/constants/injectionKeys';
import { useWorkflowState } from '@/app/composables/useWorkflowState';
import { useWorkflowId } from '@/app/composables/useWorkflowId';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { createWorkflowDocumentId } from '@/app/stores/workflowDocument.store';
import { useWorkflowInitialization } from '@/app/composables/useWorkflowInitialization';
import { usePostMessageHandler } from '@/app/composables/usePostMessageHandler';
import { useReportWorkflowFailuresToParent } from '@/app/composables/useReportWorkflowFailuresToParent';
@ -24,18 +25,16 @@ if (window !== window.parent) {
useRootStore().setPushRef(randomString(10).toLowerCase());
}
const workflowState = useWorkflowState();
provide(WorkflowStateKey, workflowState);
const workflowId = useWorkflowId();
const {
initializeData,
initializeWorkflow,
currentWorkflowDocumentStore,
cleanup: cleanupInitialization,
} = useWorkflowInitialization(workflowState);
} = useWorkflowInitialization();
const { setup: setupPostMessages, cleanup: cleanupPostMessages } = usePostMessageHandler({
workflowState,
currentWorkflowDocumentStore,
});
@ -45,7 +44,7 @@ useReportWorkflowFailuresToParent();
// from the parent) are processed for node highlighting, execution state, etc.
// When canExecute is enabled, the iframe also establishes its own WebSocket
// connection for user-triggered executions (pushConnect below).
const pushConnection = usePushConnection({ router: useRouter(), workflowState });
const pushConnection = usePushConnection({ router: useRouter() });
const pushConnectionStore = usePushConnectionStore();
// When canExecute is disabled (read-only preview), set activeExecutionId to null
@ -54,7 +53,9 @@ const pushConnectionStore = usePushConnectionStore();
// button is not disabled the normal execution flow will set it to null when
// the user actually starts an execution.
if (!canExecute.value) {
workflowState.setActiveExecutionId(null);
useWorkflowExecutionStateStore(createWorkflowDocumentId(workflowId.value)).setActiveExecutionId(
null,
);
}
onMounted(async () => {

View File

@ -33,16 +33,6 @@ vi.mock('@/features/ai/assistant/assistant.store', () => ({
})),
}));
vi.mock('@/app/composables/useWorkflowState', () => {
const mockState = () => ({
resetState: vi.fn(),
});
return {
useWorkflowState: vi.fn(mockState),
injectWorkflowState: vi.fn(mockState),
};
});
vi.mock('@/app/composables/useWorkflowInitialization', () => ({
useWorkflowInitialization: vi.fn(() => ({
isLoading: ref(false),

View File

@ -1,8 +1,7 @@
<script lang="ts" setup>
import { provide, watch, onMounted, onBeforeUnmount } from 'vue';
import { watch, onMounted, onBeforeUnmount } from 'vue';
import BaseLayout from './BaseLayout.vue';
import { useLayoutProps } from '@/app/composables/useLayoutProps';
import { useWorkflowState } from '@/app/composables/useWorkflowState';
import { useWorkflowInitialization } from '@/app/composables/useWorkflowInitialization';
import { usePostMessageHandler } from '@/app/composables/usePostMessageHandler';
import { usePushConnectionStore } from '@/app/stores/pushConnection.store';
@ -14,7 +13,6 @@ import AppHeader from '@/app/components/app/AppHeader.vue';
import AppSidebar from '@/app/components/app/AppSidebar.vue';
import LogsPanel from '@/features/execution/logs/components/LogsPanel.vue';
import LoadingView from '@/app/views/LoadingView.vue';
import { WorkflowStateKey } from '@/app/constants/injectionKeys';
import { useSettingsStore } from '@/app/stores/settings.store';
const { layoutProps } = useLayoutProps();
@ -24,9 +22,6 @@ const pushConnectionStore = usePushConnectionStore();
const settingsStore = useSettingsStore();
const isCanvasOnly = settingsStore.isCanvasOnly;
const workflowState = useWorkflowState();
provide(WorkflowStateKey, workflowState);
const {
isLoading,
workflowId,
@ -36,10 +31,9 @@ const {
initializeWorkflow,
handleDebugModeRoute,
cleanup,
} = useWorkflowInitialization(workflowState);
} = useWorkflowInitialization();
const { setup: setupPostMessages, cleanup: cleanupPostMessages } = usePostMessageHandler({
workflowState,
currentWorkflowDocumentStore,
});

View File

@ -15,9 +15,9 @@ import {
} from '@/app/stores/workflowExecutionState.store';
import { createWorkflowDocumentId } from '@/app/stores/workflowDocument.store';
import { useExecutionDataStore, createExecutionDataId } from '@/app/stores/executionData.store';
import { createTestWorkflowExecutionResponse } from '@/__tests__/mocks';
import { createTestTaskData, createTestWorkflowExecutionResponse } from '@/__tests__/mocks';
import type { IExecutionResponse } from '@/features/execution/executions/executions.types';
import type { ExecutionSummary } from 'n8n-workflow';
import { createRunExecutionData, type ExecutionSummary } from 'n8n-workflow';
import { IN_PROGRESS_EXECUTION_ID } from '@/app/constants/placeholders';
function makeExecution(overrides: Partial<IExecutionResponse> = {}): IExecutionResponse {
@ -131,6 +131,227 @@ describe('workflowExecutionState.store', () => {
});
});
describe('setWorkflowExecutionData', () => {
it('clears pending + displayed execution when given null', () => {
const store = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1'));
store.setActiveExecutionId('exec-1');
store.setPendingExecution(makeExecution({ id: 'pending' }));
store.setWorkflowExecutionData(null);
expect(store.pendingExecution).toBeNull();
expect(store.displayedExecutionId).toBeUndefined();
});
it('stages an in-progress execution as the pending scaffold', () => {
const store = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1'));
const execution = makeExecution({ id: IN_PROGRESS_EXECUTION_ID });
store.setWorkflowExecutionData(execution);
expect(store.activeExecutionId).toBeNull();
expect(store.pendingExecution?.id).toBe(IN_PROGRESS_EXECUTION_ID);
const executionDataStore = useExecutionDataStore(
createExecutionDataId(IN_PROGRESS_EXECUTION_ID),
);
expect(executionDataStore.execution?.id).toBe(IN_PROGRESS_EXECUTION_ID);
});
it('tracks a finished execution as displayed when none is active', () => {
const store = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1'));
const execution = makeExecution({ id: 'exec-9', status: 'success', finished: true });
store.setWorkflowExecutionData(execution);
expect(store.displayedExecutionId).toBe('exec-9');
expect(store.activeExecutionId).toBeUndefined();
expect(store.pendingExecution).toBeNull();
const executionDataStore = useExecutionDataStore(createExecutionDataId('exec-9'));
expect(executionDataStore.execution?.id).toBe('exec-9');
});
it('leaves an active execution id untouched when finished data arrives', () => {
const store = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1'));
store.setActiveExecutionId('exec-active');
store.setWorkflowExecutionData(makeExecution({ id: 'exec-9', finished: true }));
expect(store.activeExecutionId).toBe('exec-active');
const executionDataStore = useExecutionDataStore(createExecutionDataId('exec-9'));
expect(executionDataStore.execution?.id).toBe('exec-9');
});
});
describe('markExecutionAsStopped', () => {
const documentId = createWorkflowDocumentId('test-wf');
let store: ReturnType<typeof useWorkflowExecutionStateStore>;
let executionDataStore: ReturnType<typeof useExecutionDataStore>;
beforeEach(() => {
store = useWorkflowExecutionStateStore(documentId);
store.setActiveExecutionId('test-exec-id');
executionDataStore = useExecutionDataStore(createExecutionDataId('test-exec-id'));
executionDataStore.setExecution(
createTestWorkflowExecutionResponse({
id: 'test-exec-id',
status: 'running',
startedAt: new Date('2023-01-01T09:00:00Z'),
stoppedAt: undefined,
data: createRunExecutionData({
resultData: {
runData: {
node1: [
createTestTaskData({ executionStatus: 'success' }),
createTestTaskData({ executionStatus: 'error' }),
createTestTaskData({ executionStatus: 'running' }),
],
node2: [
createTestTaskData({ executionStatus: 'success' }),
createTestTaskData({ executionStatus: 'waiting' }),
],
},
},
}),
}),
);
});
it('should remove non successful node runs', () => {
store.markExecutionAsStopped();
const runData = executionDataStore.execution?.data?.resultData?.runData;
expect(runData?.node1).toHaveLength(1);
expect(runData?.node1[0].executionStatus).toBe('success');
expect(runData?.node2).toHaveLength(1);
expect(runData?.node2[0].executionStatus).toBe('success');
});
it('should update execution status, startedAt and stoppedAt when data is provided', () => {
store.markExecutionAsStopped({
status: 'canceled',
startedAt: new Date('2023-01-01T10:00:00Z'),
stoppedAt: new Date('2023-01-01T10:05:00Z'),
mode: 'manual',
});
expect(executionDataStore.execution?.status).toBe('canceled');
expect(executionDataStore.execution?.startedAt).toEqual(new Date('2023-01-01T10:00:00Z'));
expect(executionDataStore.execution?.stoppedAt).toEqual(new Date('2023-01-01T10:05:00Z'));
});
it('should not update execution data when stopData is not provided', () => {
store.markExecutionAsStopped();
expect(executionDataStore.execution?.status).toBe('running');
expect(executionDataStore.execution?.startedAt).toEqual(new Date('2023-01-01T09:00:00Z'));
expect(executionDataStore.execution?.stoppedAt).toBeUndefined();
});
describe('when activeExecutionId is null (pending scaffold)', () => {
beforeEach(() => {
// Reset to pending state instead of the string-id default from outer beforeEach.
store.setActiveExecutionId(undefined);
store.setPendingExecution(
createTestWorkflowExecutionResponse({
id: IN_PROGRESS_EXECUTION_ID,
status: 'running',
}),
);
// Re-set since promotePendingExecution would have moved it; emulate raw scaffold state.
store.setActiveExecutionId(null);
useExecutionDataStore(createExecutionDataId(IN_PROGRESS_EXECUTION_ID)).setExecution(
createTestWorkflowExecutionResponse({
id: IN_PROGRESS_EXECUTION_ID,
status: 'running',
data: createRunExecutionData({
resultData: {
runData: {
node1: [
createTestTaskData({ executionStatus: 'success' }),
createTestTaskData({ executionStatus: 'error' }),
],
},
},
}),
}),
);
});
it('filters non-success runs in the IN_PROGRESS placeholder store', () => {
store.markExecutionAsStopped({
status: 'canceled',
startedAt: new Date('2023-01-01T10:00:00Z'),
stoppedAt: new Date('2023-01-01T10:05:00Z'),
mode: 'manual',
});
const placeholder = useExecutionDataStore(createExecutionDataId(IN_PROGRESS_EXECUTION_ID));
expect(placeholder.execution?.data?.resultData?.runData?.node1).toHaveLength(1);
expect(placeholder.execution?.data?.resultData?.runData?.node1[0].executionStatus).toBe(
'success',
);
expect(placeholder.execution?.status).toBe('canceled');
});
it('mirrors stopData onto the pendingExecution ref', () => {
store.markExecutionAsStopped({
status: 'canceled',
startedAt: new Date('2023-01-01T10:00:00Z'),
stoppedAt: new Date('2023-01-01T10:05:00Z'),
mode: 'manual',
});
expect(store.pendingExecution?.status).toBe('canceled');
expect(store.pendingExecution?.startedAt).toEqual(new Date('2023-01-01T10:00:00Z'));
expect(store.pendingExecution?.stoppedAt).toEqual(new Date('2023-01-01T10:05:00Z'));
});
});
describe('when activeExecutionId is undefined and displayedExecutionId is set', () => {
beforeEach(() => {
// Simulate post-stop-race: active was just cleared, but displayed still points
// at the freshly-fetched finished execution.
store.setActiveExecutionId('display-exec');
store.setActiveExecutionId(undefined);
expect(store.activeExecutionId).toBeUndefined();
expect(store.displayedExecutionId).toBe('display-exec');
useExecutionDataStore(createExecutionDataId('display-exec')).setExecution(
createTestWorkflowExecutionResponse({
id: 'display-exec',
status: 'running',
data: createRunExecutionData({
resultData: {
runData: {
node1: [
createTestTaskData({ executionStatus: 'success' }),
createTestTaskData({ executionStatus: 'error' }),
],
},
},
}),
}),
);
});
it('falls back to displayedExecutionId for filtering and status update', () => {
store.markExecutionAsStopped({
status: 'canceled',
startedAt: new Date('2023-01-01T10:00:00Z'),
stoppedAt: new Date('2023-01-01T10:05:00Z'),
mode: 'manual',
});
const ds = useExecutionDataStore(createExecutionDataId('display-exec'));
expect(ds.execution?.data?.resultData?.runData?.node1).toHaveLength(1);
expect(ds.execution?.data?.resultData?.runData?.node1[0].executionStatus).toBe('success');
expect(ds.execution?.status).toBe('canceled');
});
});
});
describe('activeExecution routing', () => {
it('returns pendingExecution when activeExecutionId === null', () => {
const workflowExecutionStateStore = useWorkflowExecutionStateStore(
@ -1057,6 +1278,7 @@ describe('workflowExecutionState.store', () => {
workflowExecutionStateStore.setSelectedTriggerNodeName('Trigger');
workflowExecutionStateStore.setCurrentWorkflowExecutions([makeExecutionSummary({ id: '1' })]);
workflowExecutionStateStore.setLastSuccessfulExecutionId('last-1');
workflowExecutionStateStore.executingNode.addExecutingNode('Node');
workflowExecutionStateStore.resetExecutionState();
@ -1071,6 +1293,8 @@ describe('workflowExecutionState.store', () => {
expect(workflowExecutionStateStore.selectedTriggerNodeName).toBeUndefined();
expect(workflowExecutionStateStore.currentWorkflowExecutions).toEqual([]);
expect(workflowExecutionStateStore.lastSuccessfulExecutionId).toBeNull();
expect(workflowExecutionStateStore.executingNode.executingNode).toEqual([]);
expect(workflowExecutionStateStore.executingNode.lastAddedExecutingNode).toBeNull();
});
it('disposes per-execution data stores for every tracked id', () => {
@ -1119,6 +1343,30 @@ describe('workflowExecutionState.store', () => {
});
});
describe('executingNode', () => {
it('tracks the executing-node queue', () => {
const workflowExecutionStateStore = useWorkflowExecutionStateStore(
createWorkflowDocumentId('wf-1'),
);
workflowExecutionStateStore.executingNode.addExecutingNode('Node A');
expect(workflowExecutionStateStore.executingNode.isNodeExecuting('Node A')).toBe(true);
expect(workflowExecutionStateStore.executingNode.lastAddedExecutingNode).toBe('Node A');
});
it('isolates the executing-node queue per document id', () => {
const a = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-a'));
const b = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-b'));
a.executingNode.addExecutingNode('Node A');
expect(a.executingNode.isNodeExecuting('Node A')).toBe(true);
expect(b.executingNode.isNodeExecuting('Node A')).toBe(false);
expect(b.executingNode.executingNode).toEqual([]);
});
});
describe('trackExecutionId', () => {
it('tracks ids written via setActiveExecutionId across rolling runs', () => {
const workflowExecutionStateStore = useWorkflowExecutionStateStore(

View File

@ -4,9 +4,13 @@ import { computed, inject, readonly, ref, type ComputedRef } from 'vue';
import { createEventHook } from '@vueuse/core';
import type { ExecutionSummary, IRunExecutionData } from 'n8n-workflow';
import type { NodeExecuteBefore } from '@n8n/api-types/push/execution';
import type { IExecutionResponse } from '@/features/execution/executions/executions.types';
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';
import {
createExecutionDataId,
@ -14,6 +18,8 @@ import {
useExecutionDataStore,
} from './executionData.store';
import { 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';
import type { ChangeAction, ChangeEvent } from './workflowDocument/types';
@ -97,6 +103,14 @@ export function useWorkflowExecutionStateStore(id: WorkflowDocumentId) {
*/
const trackedExecutionIds = ref<Set<string>>(new Set());
/**
* Queue of currently-executing node names driving per-node loading
* spinners. Owned by the per-document store so spinner state stays
* isolated per workflow document. Read purely via Vue reactivity; it is
* intentionally not wired into the change-event mechanism below.
*/
const executingNode = useExecutingNode();
const onWorkflowExecutionStateChange = createEventHook<WorkflowExecutionStateChangeEvent>();
function fireChange(action: ChangeAction, field: WorkflowExecutionStateField) {
@ -342,6 +356,34 @@ export function useWorkflowExecutionStateStore(id: WorkflowDocumentId) {
fireChange(CHANGE_ACTION.UPDATE, 'pendingExecution');
}
/**
* Applies a fetched/started execution result to this document's session state:
* clears it when null, stages it as the pending scaffold while in progress, or
* tracks it as a displayed execution once it has a backend id.
*/
function setWorkflowExecutionData(workflowResultData: IExecutionResponse | null) {
if (workflowResultData === null) {
setPendingExecution(null);
clearDisplayedExecution();
} else if (workflowResultData.id === IN_PROGRESS_EXECUTION_ID) {
setPendingExecution(workflowResultData);
setActiveExecutionId(null);
useExecutionDataStore(createExecutionDataId(IN_PROGRESS_EXECUTION_ID)).setExecution(
workflowResultData,
);
} else {
trackExecutionId(workflowResultData.id);
useExecutionDataStore(createExecutionDataId(workflowResultData.id)).setExecution(
workflowResultData,
);
if (typeof activeExecutionId.value !== 'string') {
setPendingExecution(null);
setActiveExecutionId(undefined);
setDisplayedExecutionId(workflowResultData.id);
}
}
}
function clearActiveNodeExecutionData(nodeName: string) {
if (typeof activeExecutionId.value !== 'string') return;
useExecutionDataStore(createExecutionDataId(activeExecutionId.value)).clearNodeExecutionData(
@ -587,9 +629,55 @@ export function useWorkflowExecutionStateStore(id: WorkflowDocumentId) {
selectedTriggerNodeName.value = undefined;
currentWorkflowExecutions.value = [];
lastSuccessfulExecutionId.value = null;
executingNode.clearNodeExecutionQueue();
fireChange(CHANGE_ACTION.DELETE, 'state');
}
/**
* Resets this document's execution session after a stop: clears the active
* execution id / executing-node queue / webhook-wait, restores the IDLE
* document title, and marks the relevant executionData store as stopped
* (active id IN_PROGRESS scaffold displayed-id fallback for the
* stop-race-with-finished case).
*/
function markExecutionAsStopped(stopData?: IExecutionsStopData) {
const activeId = activeExecutionId.value;
setActiveExecutionId(undefined);
executingNode.clearNodeExecutionQueue();
setExecutionWaitingForWebhook(false);
useDocumentTitle().setDocumentTitle(useWorkflowDocumentStore(documentId).name, 'IDLE');
if (typeof activeId === 'string') {
const executionDataStore = useExecutionDataStore(createExecutionDataId(activeId));
executionDataStore.clearExecutionStartedData();
executionDataStore.markAsStopped(stopData);
} else if (activeId === null) {
// Pending scaffold: filter the IN_PROGRESS placeholder data and
// mirror status onto the pendingExecution ref so the UI sees the canceled state.
const executionDataStore = useExecutionDataStore(
createExecutionDataId(IN_PROGRESS_EXECUTION_ID),
);
executionDataStore.clearExecutionStartedData();
executionDataStore.markAsStopped(stopData);
if (stopData) {
applyStopDataToPendingExecution(stopData);
}
} else {
// activeExecutionId === undefined: fall back to displayedExecutionId for the
// stop-race-with-finished case where active was just cleared.
const displayedId = displayedExecutionId.value;
if (typeof displayedId === 'string') {
const executionDataStore = useExecutionDataStore(createExecutionDataId(displayedId));
executionDataStore.clearExecutionStartedData();
executionDataStore.markAsStopped(stopData);
}
}
clearPopupWindowState();
}
return {
documentId,
workflowId,
@ -605,6 +693,7 @@ export function useWorkflowExecutionStateStore(id: WorkflowDocumentId) {
selectedTriggerNodeName: readonly(selectedTriggerNodeName),
currentWorkflowExecutions: readonly(currentWorkflowExecutions),
lastSuccessfulExecutionId: readonly(lastSuccessfulExecutionId),
executingNode,
activeExecution,
activeExecutionRunData,
activeExecutionExecutedNode,
@ -621,6 +710,7 @@ export function useWorkflowExecutionStateStore(id: WorkflowDocumentId) {
// Write API
trackExecutionId,
setActiveExecutionId,
setWorkflowExecutionData,
setDisplayedExecutionId,
setPendingExecution,
setPendingExecutionRunData,
@ -648,6 +738,7 @@ export function useWorkflowExecutionStateStore(id: WorkflowDocumentId) {
addActiveNodeExecutionStartedData,
renameActiveExecutionNode,
resetExecutionState,
markExecutionAsStopped,
// Events
onWorkflowExecutionStateChange: onWorkflowExecutionStateChange.on,
};

View File

@ -1,14 +0,0 @@
import { useExecutingNode } from '@/app/composables/useExecutingNode';
import { STORES } from '@n8n/stores';
import { defineStore } from 'pinia';
// This store acts as a temporary home for per-workflow state moved out of workflows.store.ts
// intended for the useWorkflowState composable until we inject a single instance
// of it for any applicable component
export const useWorkflowStateStore = defineStore(STORES.WORKFLOW_STATE, () => {
const executingNode = useExecutingNode();
return {
executingNode,
};
});

View File

@ -12,6 +12,7 @@ vi.mock('@/features/execution/executions/executions.store', () => ({
vi.mock('@/app/stores/workflows.store', () => ({
useWorkflowsStore: () => ({
workflowId: 'wf-1',
workflowExecutionData: null,
getNodeTypes: () => ({
getByName: () => undefined,
@ -21,8 +22,8 @@ vi.mock('@/app/stores/workflows.store', () => ({
}),
}));
vi.mock('@/app/composables/useWorkflowState', () => ({
useWorkflowState: () => ({
vi.mock('@/app/stores/workflowExecutionState.store', () => ({
useWorkflowExecutionStateStore: () => ({
setWorkflowExecutionData: vi.fn(),
}),
}));

View File

@ -7,7 +7,8 @@ import { ChatSymbol } from '@n8n/chat/constants';
import type { Chat } from '@n8n/chat/types';
import { WorkflowIdKey } from '@/app/constants/injectionKeys';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useWorkflowState } from '@/app/composables/useWorkflowState';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { createWorkflowDocumentId } from '@/app/stores/workflowDocument.store';
import { useWorkflowHelpers } from '@/app/composables/useWorkflowHelpers';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import RunData from '@/features/ndv/runData/components/RunData.vue';
@ -50,7 +51,6 @@ const props = withDefaults(
const i18n = useI18n();
const workflowsStore = useWorkflowsStore();
const workflowState = useWorkflowState();
const workflowHelpers = useWorkflowHelpers();
const nodeTypesStore = useNodeTypesStore();
@ -221,12 +221,16 @@ onMounted(async () => {
// hook already restored the previous execution data installing the synth
// payload now would clobber the real workflow's state.
if (unmounted) return;
workflowState.setWorkflowExecutionData(synthExecution.value);
useWorkflowExecutionStateStore(
createWorkflowDocumentId(workflowsStore.workflowId),
).setWorkflowExecutionData(synthExecution.value);
});
onBeforeUnmount(() => {
unmounted = true;
workflowState.setWorkflowExecutionData(previousWorkflowExecutionData);
useWorkflowExecutionStateStore(
createWorkflowDocumentId(workflowsStore.workflowId),
).setWorkflowExecutionData(previousWorkflowExecutionData);
});
</script>

View File

@ -7,7 +7,8 @@ import type { Chat } from '@n8n/chat/types';
import { WorkflowIdKey } from '@/app/constants/injectionKeys';
import { useExecutionsStore } from '@/features/execution/executions/executions.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useWorkflowState } from '@/app/composables/useWorkflowState';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { createWorkflowDocumentId } from '@/app/stores/workflowDocument.store';
import { useWorkflowHelpers } from '@/app/composables/useWorkflowHelpers';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import LogsOverviewRow from '@/features/execution/logs/components/LogsOverviewRow.vue';
@ -30,7 +31,6 @@ const props = defineProps<{
const i18n = useI18n();
const executionsStore = useExecutionsStore();
const workflowsStore = useWorkflowsStore();
const workflowState = useWorkflowState();
const workflowHelpers = useWorkflowHelpers();
const nodeTypesStore = useNodeTypesStore();
@ -176,7 +176,9 @@ onMounted(async () => {
// pairedItemMappings, and various NodeErrorView code paths read from the
// store rather than the prop, so the prop alone isn't enough to make the
// table/JSON views render correctly for non-trivial nodes.
workflowState.setWorkflowExecutionData(result);
useWorkflowExecutionStateStore(
createWorkflowDocumentId(workflowsStore.workflowId),
).setWorkflowExecutionData(result);
// Default-select the first entry (the trigger) so the user sees data immediately.
const first = flatEntries.value[0];
if (first) selected.value = first;
@ -192,7 +194,9 @@ onMounted(async () => {
onBeforeUnmount(() => {
unmounted = true;
// Restore whatever execution data was in the store before we hijacked it.
workflowState.setWorkflowExecutionData(previousWorkflowExecutionData);
useWorkflowExecutionStateStore(
createWorkflowDocumentId(workflowsStore.workflowId),
).setWorkflowExecutionData(previousWorkflowExecutionData);
});
</script>

View File

@ -23,11 +23,6 @@ import { DEFAULT_POSTHOG_SETTINGS } from '@/app/stores/posthog.store.test';
import { nextTick, reactive } from 'vue';
import * as chatAPI from '@/features/ai/assistant/assistant.api';
import * as telemetryModule from '@/app/composables/useTelemetry';
import {
injectWorkflowState,
useWorkflowState,
type WorkflowState,
} from '@/app/composables/useWorkflowState';
import type { Telemetry } from '@/app/plugins/telemetry';
import type { ChatUI } from '@n8n/design-system/types/assistant';
import type { ChatRequest } from '@/features/ai/assistant/assistant.types';
@ -69,15 +64,6 @@ vi.mock('@/app/composables/useToast', () => ({
}),
}));
// Mock to inject workflowState
vi.mock('@/app/composables/useWorkflowState', async () => {
const actual = await vi.importActual('@/app/composables/useWorkflowState');
return {
...actual,
injectWorkflowState: vi.fn(),
};
});
// Mock useWorkflowSaving
const saveCurrentWorkflowMock = vi.fn().mockResolvedValue(true);
vi.mock('@/app/composables/useWorkflowSaving', () => ({
@ -148,8 +134,6 @@ vi.mock('vue-router', () => ({
RouterLink: vi.fn(),
}));
let workflowState: WorkflowState;
describe('AI Builder store', () => {
beforeEach(() => {
mockDocumentState = undefined;
@ -182,9 +166,6 @@ describe('AI Builder store', () => {
workflowDocumentStore.setConnections({});
workflowsStore.setWorkflowExecutionData(null);
workflowState = useWorkflowState();
vi.mocked(injectWorkflowState).mockReturnValue(workflowState);
getNodeTypeSpy = vi.fn();
vi.spyOn(nodeTypesStore, 'getNodeType', 'get').mockReturnValue(getNodeTypeSpy);

View File

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { defineComponent, h, ref } from 'vue';
import { defineComponent, h } from 'vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import { createEventBus } from '@n8n/utils/event-bus';
@ -66,17 +66,6 @@ vi.mock('@n8n/i18n', async (importOriginal) => ({
}),
}));
// Mock useWorkflowState
vi.mock('@/app/composables/useWorkflowState', async () => {
const actual = await vi.importActual('@/app/composables/useWorkflowState');
return {
...actual,
injectWorkflowState: vi.fn(() => ({
isWorkflowRunning: ref(false),
})),
};
});
// Mock useWorkflowSaving
vi.mock('@/app/composables/useWorkflowSaving', () => ({
useWorkflowSaving: () => ({

View File

@ -65,13 +65,6 @@ vi.mock('@/app/stores/ui.store', () => ({
}),
}));
const mockUpdateNodeProperties = vi.fn();
vi.mock('@/app/composables/useWorkflowState', () => ({
injectWorkflowState: () => ({
updateNodeProperties: mockUpdateNodeProperties,
}),
}));
vi.mock('@/app/composables/useNodeHelpers', () => ({
useNodeHelpers: () => ({
updateNodesParameterIssues: vi.fn(),

View File

@ -1,4 +1,4 @@
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect } from 'vitest';
import { screen } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { createTestingPinia } from '@pinia/testing';
@ -9,10 +9,6 @@ import { useCredentialsStore } from '../../credentials.store';
import { mockedStore } from '@/__tests__/utils';
import type { ICredentialType } from 'n8n-workflow';
vi.mock('@/app/composables/useWorkflowState', () => ({
injectWorkflowState: vi.fn(),
}));
const googleSheetsOAuth2Api: ICredentialType = {
name: 'googleSheetsOAuth2Api',
extends: ['googleOAuth2Api'],

View File

@ -2,16 +2,12 @@ import { createTestingPinia } from '@pinia/testing';
import { mockedStore } from '@/__tests__/utils';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useUIStore } from '@/app/stores/ui.store';
import {
injectWorkflowState,
useWorkflowState,
type WorkflowState,
} from '@/app/composables/useWorkflowState';
import { useExecutionDebugging } from './useExecutionDebugging';
import type { INodeUi } from '@/Interface';
import type { IExecutionResponse } from '../executions.types';
import { useToast } from '@/app/composables/useToast';
import type { useWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { TRIMMED_TASK_DATA_CONNECTIONS_KEY } from 'n8n-workflow';
vi.mock('@/app/composables/useToast', () => {
@ -23,16 +19,9 @@ vi.mock('@/app/composables/useToast', () => {
};
});
vi.mock('@/app/composables/useWorkflowState', async () => {
const actual = await vi.importActual('@/app/composables/useWorkflowState');
return {
...actual,
injectWorkflowState: vi.fn(),
};
});
const { mockWorkflowDocumentStore } = vi.hoisted(() => ({
mockWorkflowDocumentStore: {
documentId: 'test-id@latest',
allNodes: [] as INodeUi[],
workflowTriggerNodes: [] as INodeUi[],
getParentNodes: vi.fn().mockReturnValue([]),
@ -51,9 +40,9 @@ vi.mock('@/app/stores/workflowDocument.store', () => ({
injectWorkflowDocumentStore: () => ({ value: mockWorkflowDocumentStore }),
}));
let workflowState: WorkflowState;
let executionDebugging: ReturnType<typeof useExecutionDebugging>;
let toast: ReturnType<typeof useToast>;
let executionStateStore: ReturnType<typeof useWorkflowExecutionStateStore>;
describe('useExecutionDebugging()', () => {
beforeEach(() => {
@ -68,8 +57,9 @@ describe('useExecutionDebugging()', () => {
toast = useToast();
workflowState = useWorkflowState();
vi.mocked(injectWorkflowState).mockReturnValue(workflowState);
// Production resolves the execution-state store by the injected document
// store's `documentId` ('test-id@latest' on the mock above).
executionStateStore = useWorkflowExecutionStateStore('test-id@latest');
executionDebugging = useExecutionDebugging();
});
@ -192,7 +182,7 @@ describe('useExecutionDebugging()', () => {
mockWorkflowDocumentStore.allNodes = [{ name: 'testNode2' }] as INodeUi[];
workflowStore.getExecution.mockResolvedValueOnce(mockExecution);
const setWorkflowExecutionData = vi.spyOn(workflowState, 'setWorkflowExecutionData');
const setWorkflowExecutionData = vi.spyOn(executionStateStore, 'setWorkflowExecutionData');
await executionDebugging.applyExecutionData('1');
@ -220,7 +210,7 @@ describe('useExecutionDebugging()', () => {
mockWorkflowDocumentStore.allNodes = [{ name: 'testNode' }] as INodeUi[];
workflowStore.getExecution.mockResolvedValueOnce(mockExecution);
const setWorkflowExecutionData = vi.spyOn(workflowState, 'setWorkflowExecutionData');
const setWorkflowExecutionData = vi.spyOn(executionStateStore, 'setWorkflowExecutionData');
await executionDebugging.applyExecutionData('1');

View File

@ -3,7 +3,6 @@ import { useRouter } from 'vue-router';
import { useI18n } from '@n8n/i18n';
import { useMessage } from '@/app/composables/useMessage';
import { useToast } from '@/app/composables/useToast';
import { injectWorkflowState, type WorkflowState } from '@/app/composables/useWorkflowState';
import { EnterpriseEditionFeature, MODAL_CONFIRM, VIEWS } from '@/app/constants';
import { DEBUG_PAYWALL_MODAL_KEY } from '../executions.constants';
import type { INodeUi } from '@/Interface';
@ -22,12 +21,7 @@ import { sanitizeHtml } from '@/app/utils/htmlUtils';
import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper';
import { isTrimmedNodeExecutionData } from 'n8n-workflow';
/**
* @param providedWorkflowState - Optional workflow state to use instead of injecting.
* This is needed when called from the same component that provides WorkflowStateKey
* (e.g., WorkflowLayout), since Vue's provide/inject works parent-to-child only.
*/
export const useExecutionDebugging = (providedWorkflowState?: WorkflowState) => {
export const useExecutionDebugging = () => {
const telemetry = useTelemetry();
const router = useRouter();
@ -36,7 +30,6 @@ export const useExecutionDebugging = (providedWorkflowState?: WorkflowState) =>
const toast = useToast();
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const workflowState = providedWorkflowState ?? injectWorkflowState();
const settingsStore = useSettingsStore();
const uiStore = useUIStore();
const { markStateDirty } = uiStore;
@ -106,7 +99,9 @@ export const useExecutionDebugging = (providedWorkflowState?: WorkflowState) =>
// Set execution data
workflowDocumentStore.value.resetAllNodesIssues();
workflowState.setWorkflowExecutionData(execution);
useWorkflowExecutionStateStore(workflowDocumentStore.value.documentId).setWorkflowExecutionData(
execution,
);
// Pin data of all nodes which do not have a parent node
const pinnableNodes = workflowNodes.filter(

View File

@ -11,6 +11,8 @@ import {
useWorkflowDocumentStore,
createWorkflowDocumentId,
} from '@/app/stores/workflowDocument.store';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import type { IExecutionResponse } from '@/features/execution/executions/executions.types';
import { computed, h, nextTick, ref, shallowRef } from 'vue';
import {
aiAgentNode,
@ -21,7 +23,7 @@ import {
nodeTypes,
} from '../__test__/data';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { IN_PROGRESS_EXECUTION_ID, WorkflowStateKey } from '@/app/constants';
import { IN_PROGRESS_EXECUTION_ID } from '@/app/constants';
import { createExecutionDataId, useExecutionDataStore } from '@/app/stores/executionData.store';
import { WorkflowDocumentStoreKey, WorkflowIdKey } from '@/app/constants/injectionKeys';
import { useCanvasOperations } from '@/app/composables/useCanvasOperations';
@ -36,7 +38,6 @@ import { userEvent } from '@testing-library/user-event';
import type { ChatMessage } from '@n8n/chat/types';
import * as useChatMessaging from '@/features/execution/logs/composables/useChatMessaging';
import { useToast } from '@/app/composables/useToast';
import { useWorkflowState, type WorkflowState } from '@/app/composables/useWorkflowState';
import type { IWorkflowDb } from '@/Interface';
vi.mock('@/app/composables/useToast', () => {
@ -81,7 +82,6 @@ describe('LogsPanel', () => {
let logsStore: ReturnType<typeof mockedStore<typeof useLogsStore>>;
let ndvStore: ReturnType<typeof mockedStore<typeof useNDVStore>>;
let uiStore: ReturnType<typeof mockedStore<typeof useUIStore>>;
let workflowState: WorkflowState;
let aiChatExecutionResponse: typeof aiChatExecutionResponseTemplate;
@ -92,6 +92,15 @@ describe('LogsPanel', () => {
ndvStore = mockedStore(useNDVStore, createWorkflowDocumentId(workflow.id));
}
// Production stages execution data on the per-document execution-state store.
// The component resolves it from the injected document store, which is keyed by
// `workflowsStore.workflowId` — so resolve the same store here for test setup.
function setExecutionData(execution: IExecutionResponse | null) {
useWorkflowExecutionStateStore(
createWorkflowDocumentId(workflowsStore.workflowId),
).setWorkflowExecutionData(execution);
}
function render() {
const wfId = workflowsStore.workflowId;
const wrapper = renderComponent(LogsPanel, {
@ -99,7 +108,6 @@ describe('LogsPanel', () => {
provide: {
[ChatSymbol as symbol]: {},
[ChatOptionsSymbol as symbol]: {},
[WorkflowStateKey as symbol]: workflowState,
[WorkflowIdKey as unknown as string]: computed(() => wfId),
[WorkflowDocumentStoreKey as symbol]: shallowRef(
useWorkflowDocumentStore(createWorkflowDocumentId(wfId)),
@ -128,8 +136,7 @@ describe('LogsPanel', () => {
setActivePinia(pinia);
workflowsStore = mockedStore(useWorkflowsStore);
workflowState = useWorkflowState();
workflowState.setWorkflowExecutionData(null);
setExecutionData(null);
logsStore = mockedStore(useLogsStore);
logsStore.toggleOpen(false);
@ -190,7 +197,7 @@ describe('LogsPanel', () => {
it('should render only output panel of selected node by default', async () => {
logsStore.toggleOpen(true);
setWorkflow(aiManualWorkflow);
workflowState.setWorkflowExecutionData(aiManualExecutionResponse);
setExecutionData(aiManualExecutionResponse);
const rendered = render();
@ -204,7 +211,7 @@ describe('LogsPanel', () => {
it('should render both input and output panel of selected node by default if it is sub node', async () => {
logsStore.toggleOpen(true);
setWorkflow(aiChatWorkflow);
workflowState.setWorkflowExecutionData(aiChatExecutionResponse);
setExecutionData(aiChatExecutionResponse);
const rendered = render();
@ -247,7 +254,7 @@ describe('LogsPanel', () => {
it('should open log details panel when a log entry is clicked in the logs overview panel', async () => {
setWorkflow(aiChatWorkflow);
workflowState.setWorkflowExecutionData(aiChatExecutionResponse);
setExecutionData(aiChatExecutionResponse);
const rendered = render();
@ -264,7 +271,7 @@ describe('LogsPanel', () => {
it("should show the button to toggle panel in the header of log details panel when it's opened", async () => {
setWorkflow(aiChatWorkflow);
workflowState.setWorkflowExecutionData(aiChatExecutionResponse);
setExecutionData(aiChatExecutionResponse);
const rendered = render();
@ -329,7 +336,7 @@ describe('LogsPanel', () => {
it('should reflect changes to execution data in workflow store if execution is in progress', async () => {
logsStore.toggleOpen(true);
setWorkflow(aiChatWorkflow);
workflowState.setWorkflowExecutionData({
setExecutionData({
...aiChatExecutionResponse,
id: IN_PROGRESS_EXECUTION_ID,
status: 'running',
@ -388,7 +395,7 @@ describe('LogsPanel', () => {
expect(await lastTreeItem.findByText('Success')).toBeInTheDocument();
expect(lastTreeItem.getByText('in 33ms')).toBeInTheDocument();
workflowState.setWorkflowExecutionData({
setExecutionData({
...workflowsStore.workflowExecutionData!,
id: '1234',
status: 'success',
@ -409,7 +416,7 @@ describe('LogsPanel', () => {
const workflow = deepCopy(aiChatWorkflow);
setWorkflow(workflow);
logsStore.toggleOpen(true);
workflowState.setWorkflowExecutionData({
setExecutionData({
...aiChatExecutionResponse,
id: '2345',
status: 'success',
@ -434,7 +441,7 @@ describe('LogsPanel', () => {
it('should open NDV if the button is clicked', async () => {
logsStore.toggleOpen(true);
setWorkflow(aiChatWorkflow);
workflowState.setWorkflowExecutionData(aiChatExecutionResponse);
setExecutionData(aiChatExecutionResponse);
const rendered = render();
const aiAgentRow = (await rendered.findAllByRole('treeitem'))[0];
@ -453,7 +460,7 @@ describe('LogsPanel', () => {
it('should toggle subtree when chevron icon button is pressed', async () => {
logsStore.toggleOpen(true);
setWorkflow(aiChatWorkflow);
workflowState.setWorkflowExecutionData(aiChatExecutionResponse);
setExecutionData(aiChatExecutionResponse);
const rendered = render();
const overview = within(rendered.getByTestId('logs-overview'));
@ -480,7 +487,7 @@ describe('LogsPanel', () => {
it('should toggle input and output panel when the button is clicked', async () => {
logsStore.toggleOpen(true);
setWorkflow(aiChatWorkflow);
workflowState.setWorkflowExecutionData(aiChatExecutionResponse);
setExecutionData(aiChatExecutionResponse);
const rendered = render();
@ -510,7 +517,7 @@ describe('LogsPanel', () => {
const workflow = deepCopy(aiChatWorkflow);
workflow.id = 'test-workflow-id';
setWorkflow(workflow);
workflowState.setWorkflowExecutionData(aiChatExecutionResponse);
setExecutionData(aiChatExecutionResponse);
const rendered = render();
@ -535,7 +542,7 @@ describe('LogsPanel', () => {
beforeEach(() => {
logsStore.toggleOpen(true);
setWorkflow(aiChatWorkflow);
workflowState.setWorkflowExecutionData(aiChatExecutionResponse);
setExecutionData(aiChatExecutionResponse);
});
it('should allow to select previous and next row via keyboard shortcut', async () => {
@ -594,7 +601,7 @@ describe('LogsPanel', () => {
const workflow = deepCopy(aiChatWorkflow);
workflow.id = 'test-workflow-id';
setWorkflow(workflow);
workflowState.setWorkflowExecutionData(aiChatExecutionResponse);
setExecutionData(aiChatExecutionResponse);
logsStore.toggleLogSelectionSync(true);

View File

@ -35,12 +35,6 @@ vi.mock('@/app/composables/useWorkflowHelpers', async (importOriginal) => {
}),
};
});
vi.mock('@/app/composables/useWorkflowState', () => ({
injectWorkflowState: vi.fn(() => ({
setWorkflowExecutionData: vi.fn(),
setActiveExecutionId: vi.fn(),
})),
}));
vi.mock('@/app/composables/useNodeHelpers', () => ({
useNodeHelpers: vi.fn(() => ({
updateNodesExecutionIssues: vi.fn(),

View File

@ -18,7 +18,6 @@ import { usePushConnectionStore } from '@/app/stores/pushConnection.store';
import { restoreChatHistory } from '@/features/execution/logs/logs.utils';
import { type INode, type INodeParameters, NodeHelpers } from 'n8n-workflow';
import { isChatNode } from '@/app/utils/aiUtils';
import { injectWorkflowState } from '@/app/composables/useWorkflowState';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { MessageComponentKey } from '@n8n/chat/constants/messageComponents';
@ -49,7 +48,6 @@ export function useChatState(
const workflowExecutionState = computed(() =>
useWorkflowExecutionStateStore(workflowDocumentStore.value.documentId),
);
const workflowState = injectWorkflowState();
const rootStore = useRootStore();
const logsStore = useLogsStore();
const router = useRouter();
@ -199,8 +197,8 @@ export function useChatState(
}
// Clear any existing execution to allow fresh webhook registration
workflowState.setWorkflowExecutionData(null);
workflowState.setActiveExecutionId(undefined);
workflowExecutionState.value.setWorkflowExecutionData(null);
workflowExecutionState.value.setActiveExecutionId(undefined);
// Use the useRunWorkflow composable to properly register the webhook
// Only include destinationNode if set for partial execution support
@ -331,7 +329,7 @@ export function useChatState(
);
function refreshSession() {
workflowState.setWorkflowExecutionData(null);
workflowExecutionState.value.setWorkflowExecutionData(null);
nodeHelpers.updateNodesExecutionIssues();
logsStore.resetChatSessionId();
logsStore.resetMessages();

View File

@ -15,29 +15,17 @@ import {
} from '@/__tests__/mocks';
import { createRunExecutionData, type IRunExecutionData } from 'n8n-workflow';
import { useToast } from '@/app/composables/useToast';
import {
injectWorkflowState,
useWorkflowState,
type WorkflowState,
} from '@/app/composables/useWorkflowState';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { createWorkflowDocumentId } from '@/app/stores/workflowDocument.store';
import { computed } from 'vue';
vi.mock('@/app/composables/useToast');
vi.mock('@/app/composables/useWorkflowState', async () => {
const actual = await vi.importActual('@/app/composables/useWorkflowState');
return {
...actual,
injectWorkflowState: vi.fn(),
};
});
let workflowState: WorkflowState;
describe(useLogsExecutionData, () => {
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
let workflowsListStore: ReturnType<typeof mockedStore<typeof useWorkflowsListStore>>;
let nodeTypeStore: ReturnType<typeof mockedStore<typeof useNodeTypesStore>>;
let executionStateStore: ReturnType<typeof useWorkflowExecutionStateStore>;
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }));
@ -45,8 +33,9 @@ describe(useLogsExecutionData, () => {
workflowsStore = mockedStore(useWorkflowsStore);
workflowsListStore = mockedStore(useWorkflowsListStore);
workflowState = useWorkflowState();
vi.mocked(injectWorkflowState).mockReturnValue(workflowState);
// The composable resolves the execution-state store via the injected
// document store (falls back to `workflowsStore.workflowId`, '' here).
executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId(''));
nodeTypeStore = mockedStore(useNodeTypesStore);
nodeTypeStore.setNodeTypes(nodeTypes);
@ -54,7 +43,7 @@ describe(useLogsExecutionData, () => {
describe('isEnabled', () => {
beforeEach(() => {
workflowState.setWorkflowExecutionData(
executionStateStore.setWorkflowExecutionData(
createTestWorkflowExecutionResponse({
data: createRunExecutionData({ resultData: { runData: { n0: [createTestTaskData()] } } }),
workflowData: createTestWorkflow({ nodes: [createTestNode({ name: 'n0' })] }),
@ -81,7 +70,7 @@ describe(useLogsExecutionData, () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
workflowState.setWorkflowExecutionData(
executionStateStore.setWorkflowExecutionData(
createTestWorkflowExecutionResponse({
id: 'e0',
workflowData: createTestWorkflow({

View File

@ -20,7 +20,6 @@ import { isChatNode } from '@/app/utils/aiUtils';
import { CHAT_TRIGGER_NODE_TYPE, LOGS_EXECUTION_DATA_THROTTLE_DURATION } from '@/app/constants';
import { useChatHubPanelStore } from '@/features/ai/chatHub/chatHubPanel.store';
import { useThrottleFn } from '@vueuse/core';
import { injectWorkflowState } from '@/app/composables/useWorkflowState';
import { useThrottleWithReactiveDelay } from '@n8n/composables/useThrottleWithReactiveDelay';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
@ -37,7 +36,6 @@ export function useLogsExecutionData({ isEnabled, filter }: UseLogsExecutionData
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const workflowState = injectWorkflowState();
const toast = useToast();
const state = ref<
@ -109,7 +107,9 @@ export function useLogsExecutionData({ isEnabled, filter }: UseLogsExecutionData
function resetExecutionData() {
state.value = undefined;
workflowState.setWorkflowExecutionData(null);
useWorkflowExecutionStateStore(workflowDocumentStore.value.documentId).setWorkflowExecutionData(
null,
);
nodeHelpers.updateNodesExecutionIssues();
// Clear partial execution destination to allow full workflow execution
useWorkflowExecutionStateStore(

View File

@ -14,12 +14,13 @@ import { setActivePinia } from 'pinia';
import { computed, shallowRef } from 'vue';
import { WorkflowIdKey } from '@/app/constants/injectionKeys';
import { useWorkflowState } from '@/app/composables/useWorkflowState';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import {
injectWorkflowDocumentStore,
useWorkflowDocumentStore,
createWorkflowDocumentId,
} from '@/app/stores/workflowDocument.store';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
vi.mock('@/app/stores/workflowDocument.store', async () => {
const actual = await vi.importActual('@/app/stores/workflowDocument.store');
@ -66,7 +67,7 @@ const render = (props: Partial<Props> = {}, pinData?: INodeExecutionData[], runD
setActivePinia(pinia);
const workflow = createTestWorkflow({ nodes, connections });
const workflowState = useWorkflowState();
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = useWorkflowDocumentStore(createWorkflowDocumentId(workflow.id));
workflowDocumentStore.hydrate(workflow);
@ -78,7 +79,11 @@ const render = (props: Partial<Props> = {}, pinData?: INodeExecutionData[], runD
}
if (runData) {
workflowState.setWorkflowExecutionData({
// The component reads run data via `workflowsStore.getWorkflowExecution`, which
// resolves through the execution-state store keyed by `workflowsStore.workflowId`.
useWorkflowExecutionStateStore(
createWorkflowDocumentId(workflowsStore.workflowId),
).setWorkflowExecutionData({
id: '',
workflowData: {
id: '',

View File

@ -37,7 +37,6 @@ import { useRouter } from 'vue-router';
import { useRunWorkflow } from '@/app/composables/useRunWorkflow';
import { N8nIcon, N8nRadioButtons, N8nText, N8nTooltip } from '@n8n/design-system';
import { injectWorkflowState } from '@/app/composables/useWorkflowState';
type MappingMode = 'debugging' | 'mapping';
export type Props = {
@ -112,7 +111,6 @@ const workflowDocumentStore = injectWorkflowDocumentStore();
const workflowExecutionStateStore = computed(() =>
useWorkflowExecutionStateStore(workflowDocumentStore.value.documentId),
);
const workflowState = injectWorkflowState();
const router = useRouter();
const { runWorkflow } = useRunWorkflow({ router });
const { canReveal, isDynamicCredentials, revealData } = useExecutionRedaction();
@ -202,12 +200,12 @@ const isExecutingPrevious = computed(() => {
return false;
}
const triggeredNode = workflowsStore.executedNode;
const executingNode = workflowState.executingNode.executingNode;
const executingNode = workflowExecutionStateStore.value.executingNode.executingNode;
if (
activeNode.value &&
triggeredNode === activeNode.value.name &&
workflowState.executingNode.isNodeExecuting(props.currentNodeName)
workflowExecutionStateStore.value.executingNode.isNodeExecuting(props.currentNodeName)
) {
return true;
}
@ -215,7 +213,8 @@ const isExecutingPrevious = computed(() => {
if (executingNode.length || triggeredNode) {
return !!parentNodes.value.find(
(node) =>
workflowState.executingNode.isNodeExecuting(node.name) || node.name === triggeredNode,
workflowExecutionStateStore.value.executingNode.isNodeExecuting(node.name) ||
node.name === triggeredNode,
);
}
return false;

View File

@ -24,7 +24,6 @@ import RedactedDataState from '@/features/ndv/panel/components/RedactedDataState
import NodeExecuteButton from '@/app/components/NodeExecuteButton.vue';
import { N8nIcon, N8nRadioButtons, N8nSpinner, N8nText } from '@n8n/design-system';
import { injectWorkflowState } from '@/app/composables/useWorkflowState';
import { useUIStore } from '@/app/stores/ui.store';
import { WORKFLOW_SETTINGS_MODAL_KEY } from '@/app/constants';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
@ -81,7 +80,6 @@ const workflowId = useInjectWorkflowId();
const ndvStore = injectNDVStore();
const nodeTypesStore = useNodeTypesStore();
const workflowsStore = useWorkflowsStore();
const workflowState = injectWorkflowState();
const workflowDocumentStore = injectWorkflowDocumentStore();
const workflowExecutionStateStore = computed(() =>
useWorkflowExecutionStateStore(workflowDocumentStore.value.documentId),
@ -162,7 +160,7 @@ const isNodeRunning = computed(() => {
return (
workflowRunning.value &&
!!node.value &&
workflowState.executingNode.isNodeExecuting(node.value.name)
workflowExecutionStateStore.value.executingNode.isNodeExecuting(node.value.name)
);
});

View File

@ -11,13 +11,13 @@ import { createComponentRenderer } from '@/__tests__/render';
import NodeSettings from './NodeSettings.vue';
import { useNDVStore } from '@/features/ndv/shared/ndv.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useWorkflowState } from '@/app/composables/useWorkflowState';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import {
createWorkflowDocumentId,
injectWorkflowDocumentStore,
useWorkflowDocumentStore,
} from '@/app/stores/workflowDocument.store';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
vi.mock('@/app/stores/workflowDocument.store', async () => {
const actual = await vi.importActual('@/app/stores/workflowDocument.store');
@ -54,7 +54,6 @@ const renderNodeSettings = (runData?: IRunData) => {
const workflow = createTestWorkflow({ nodes: [httpNode], connections: {} });
const workflowsStore = useWorkflowsStore();
const workflowState = useWorkflowState();
const nodeTypesStore = useNodeTypesStore();
workflowsStore.setWorkflowId(workflow.id);
const ndvStore = useNDVStore(createWorkflowDocumentId(workflow.id));
@ -65,7 +64,7 @@ const renderNodeSettings = (runData?: IRunData) => {
ndvStore.activeNodeName = httpNode.name;
if (runData) {
workflowState.setWorkflowExecutionData({
useWorkflowExecutionStateStore(createWorkflowDocumentId(workflow.id)).setWorkflowExecutionData({
id: 'exec-1',
workflowData: {
...workflow,

View File

@ -33,14 +33,6 @@ vi.mock('@/app/composables/useTelemetry', () => {
};
});
vi.mock('@/app/composables/useWorkflowState', async () => {
const actual = await vi.importActual('@/app/composables/useWorkflowState');
return {
...actual,
injectWorkflowState: vi.fn(),
};
});
const renderComponent = createComponentRenderer(CommunityPlusEnrollmentModal, {
global: {
stubs: {

View File

@ -32,16 +32,6 @@ vi.mock('@/features/ndv/parameters/components/ParameterInputList.vue', () => ({
},
}));
const { mockUpdateNodeProperties } = vi.hoisted(() => ({
mockUpdateNodeProperties: vi.fn(),
}));
vi.mock('@/app/composables/useWorkflowState', () => ({
injectWorkflowState: vi.fn(() => ({
updateNodeProperties: mockUpdateNodeProperties,
})),
}));
const renderComponent = createComponentRenderer(SetupCardBody);
const NODE_PROPERTIES = [
@ -82,7 +72,6 @@ describe('SetupCardBody', () => {
let nodeTypesStore: ReturnType<typeof mockedStore<typeof useNodeTypesStore>>;
beforeEach(() => {
mockUpdateNodeProperties.mockClear();
createTestingPinia();
nodeTypesStore = mockedStore(useNodeTypesStore);
nodeTypesStore.getNodeType = vi.fn().mockReturnValue({ properties: NODE_PROPERTIES });

View File

@ -40,11 +40,6 @@ import { useRootStore } from '@n8n/stores/useRootStore';
import { createTestingPinia } from '@pinia/testing';
import { MarkerType } from '@vue-flow/core';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import {
injectWorkflowState,
useWorkflowState,
type WorkflowState,
} from '@/app/composables/useWorkflowState';
vi.mock('@n8n/i18n', async (importOriginal) => ({
...(await importOriginal()),
@ -100,16 +95,6 @@ function createRenderDataWithExecutionIssuesByNodeName(
});
}
vi.mock('@/app/composables/useWorkflowState', async () => {
const actual = await vi.importActual('@/app/composables/useWorkflowState');
return {
...actual,
injectWorkflowState: vi.fn(),
};
});
let workflowState: WorkflowState;
beforeEach(() => {
const pinia = createTestingPinia({
stubActions: false,
@ -143,9 +128,6 @@ beforeEach(() => {
});
setActivePinia(pinia);
workflowState = useWorkflowState();
vi.mocked(injectWorkflowState).mockReturnValue(workflowState);
renderNodeInputsMap.clear();
renderNodeOutputsMap.clear();
@ -174,6 +156,12 @@ function setWorkflowRunning(running: boolean) {
vi.spyOn(workflowExecutionStateStore, 'isWorkflowRunning', 'get').mockReturnValue(running);
}
function getExecutingNode() {
const workflowsStore = useWorkflowsStore();
return useWorkflowExecutionStateStore(createWorkflowDocumentId(workflowsStore.workflowId))
.executingNode;
}
/**
* Populate the render data maps directly for tests
* that rely on per-node inputs/outputs from the render data.
@ -230,7 +218,7 @@ describe('useCanvasMapping', () => {
nodes,
connections,
});
workflowState.executingNode.isNodeExecuting = vi.fn().mockReturnValue(false);
getExecutingNode().isNodeExecuting = vi.fn().mockReturnValue(false);
const { nodes: mappedNodes } = useCanvasMapping({
nodes: ref(nodes),
@ -329,7 +317,7 @@ describe('useCanvasMapping', () => {
connections,
});
workflowState.executingNode.isNodeExecuting = vi.fn().mockReturnValue(true);
getExecutingNode().isNodeExecuting = vi.fn().mockReturnValue(true);
const { nodes: mappedNodes } = useCanvasMapping({
nodes: ref(nodes),
@ -1789,8 +1777,8 @@ describe('useCanvasMapping', () => {
connections,
});
workflowState.executingNode.executingNode = [];
workflowState.executingNode.lastAddedExecutingNode = node1.name;
getExecutingNode().executingNode = [];
getExecutingNode().lastAddedExecutingNode = node1.name;
setWorkflowRunning(true);
const { nodeExecutionWaitingForNextById } = useCanvasMapping({
@ -1819,8 +1807,8 @@ describe('useCanvasMapping', () => {
connections,
});
workflowState.executingNode.executingNode = [];
workflowState.executingNode.lastAddedExecutingNode = node1.name;
getExecutingNode().executingNode = [];
getExecutingNode().lastAddedExecutingNode = node1.name;
setWorkflowRunning(false);
const { nodeExecutionWaitingForNextById } = useCanvasMapping({
@ -1849,8 +1837,8 @@ describe('useCanvasMapping', () => {
connections,
});
workflowState.executingNode.executingNode = [node2.name];
workflowState.executingNode.lastAddedExecutingNode = node1.name;
getExecutingNode().executingNode = [node2.name];
getExecutingNode().lastAddedExecutingNode = node1.name;
setWorkflowRunning(false);
const { nodeExecutionWaitingForNextById } = useCanvasMapping({
@ -2336,11 +2324,9 @@ describe('useCanvasMapping', () => {
connections,
});
workflowState.executingNode.isNodeExecuting = vi
.fn()
.mockImplementation((nodeName: string) => {
return nodeName === manualTriggerNode.name;
});
getExecutingNode().isNodeExecuting = vi.fn().mockImplementation((nodeName: string) => {
return nodeName === manualTriggerNode.name;
});
workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => {
if (nodeName === manualTriggerNode.name) {
@ -2919,11 +2905,9 @@ describe('useCanvasMapping', () => {
connections,
});
workflowState.executingNode.isNodeExecuting = vi
.fn()
.mockImplementation((nodeName: string) => {
return nodeName === manualTriggerNode.name;
});
getExecutingNode().isNodeExecuting = vi.fn().mockImplementation((nodeName: string) => {
return nodeName === manualTriggerNode.name;
});
workflowsStore.getWorkflowResultDataByNodeName.mockReturnValue(null);
const { connections: mappedConnections } = useCanvasMapping({

View File

@ -55,7 +55,6 @@ import { useNodeDirtiness } from '@/app/composables/useNodeDirtiness';
import { getNodeIconSource } from '@/app/utils/nodeIcon';
import * as workflowUtils from 'n8n-workflow/common';
import { throttledWatch } from '@vueuse/core';
import { injectWorkflowState } from '@/app/composables/useWorkflowState';
import type { WorkflowObjectAccessors } from '@/app/types';
export function useCanvasMapping({
@ -75,7 +74,6 @@ export function useCanvasMapping({
const workflowExecutionStateStore = computed(() =>
useWorkflowExecutionStateStore(workflowDocumentStore.value.documentId),
);
const workflowState = injectWorkflowState();
const nodeTypesStore = useNodeTypesStore();
const nodeHelpers = useNodeHelpers();
const { dirtinessByName } = useNodeDirtiness();
@ -249,7 +247,7 @@ export function useCanvasMapping({
const nodeExecutionRunningById = computed(() =>
nodes.value.reduce<Record<string, boolean>>((acc, node) => {
acc[node.id] = workflowState.executingNode.isNodeExecuting(node.name);
acc[node.id] = workflowExecutionStateStore.value.executingNode.isNodeExecuting(node.name);
return acc;
}, {}),
);
@ -257,8 +255,8 @@ export function useCanvasMapping({
const nodeExecutionWaitingForNextById = computed(() =>
nodes.value.reduce<Record<string, boolean>>((acc, node) => {
acc[node.id] =
node.name === workflowState.executingNode.lastAddedExecutingNode &&
workflowState.executingNode.executingNode.length === 0 &&
node.name === workflowExecutionStateStore.value.executingNode.lastAddedExecutingNode &&
workflowExecutionStateStore.value.executingNode.executingNode.length === 0 &&
workflowExecutionStateStore.value.isWorkflowRunning;
return acc;