diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index f8bdb80268cce..3c6db394c211c 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -44,6 +44,7 @@ export { CredentialsGetOneRequestQuery } from './credentials/credentials-get-one export { CredentialsGetManyRequestQuery } from './credentials/credentials-get-many-request.dto'; export { ImportWorkflowFromUrlDto } from './workflows/import-workflow-from-url.dto'; +export { ManualRunQueryDto } from './workflows/manual-run-query.dto'; export { CreateOrUpdateTagRequestDto } from './tag/create-or-update-tag-request.dto'; export { RetrieveTagQueryDto } from './tag/retrieve-tag-query.dto'; diff --git a/packages/@n8n/api-types/src/dto/workflows/__tests__/manual-run-query.dto.test.ts b/packages/@n8n/api-types/src/dto/workflows/__tests__/manual-run-query.dto.test.ts new file mode 100644 index 0000000000000..eb40310fb4c5f --- /dev/null +++ b/packages/@n8n/api-types/src/dto/workflows/__tests__/manual-run-query.dto.test.ts @@ -0,0 +1,47 @@ +import { ManualRunQueryDto } from '../manual-run-query.dto'; + +describe('ManualRunQueryDto', () => { + describe('Valid requests', () => { + test.each([ + { name: 'version number 1', partialExecutionVersion: '1' }, + { name: 'version number 2', partialExecutionVersion: '2' }, + { name: 'missing version' }, + ])('should validate $name', ({ partialExecutionVersion }) => { + const result = ManualRunQueryDto.safeParse({ partialExecutionVersion }); + + if (!result.success) { + return fail('expected validation to succeed'); + } + expect(result.success).toBe(true); + expect(typeof result.data.partialExecutionVersion).toBe('number'); + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'invalid version 0', + partialExecutionVersion: '0', + expectedErrorPath: ['partialExecutionVersion'], + }, + { + name: 'invalid type (boolean)', + partialExecutionVersion: true, + expectedErrorPath: ['partialExecutionVersion'], + }, + { + name: 'invalid type (number)', + partialExecutionVersion: 1, + expectedErrorPath: ['partialExecutionVersion'], + }, + ])('should fail validation for $name', ({ partialExecutionVersion, expectedErrorPath }) => { + const result = ManualRunQueryDto.safeParse({ partialExecutionVersion }); + + if (result.success) { + return fail('expected validation to fail'); + } + + expect(result.error.issues[0].path).toEqual(expectedErrorPath); + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/workflows/manual-run-query.dto.ts b/packages/@n8n/api-types/src/dto/workflows/manual-run-query.dto.ts new file mode 100644 index 0000000000000..a97ef7d161a8d --- /dev/null +++ b/packages/@n8n/api-types/src/dto/workflows/manual-run-query.dto.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +export class ManualRunQueryDto extends Z.class({ + partialExecutionVersion: z + .enum(['1', '2']) + .default('1') + .transform((version) => Number.parseInt(version) as 1 | 2), +}) {} diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts index ceaac19a69dbe..2a8bb3ea86874 100644 --- a/packages/@n8n/api-types/src/frontend-settings.ts +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -178,4 +178,8 @@ export interface FrontendSettings { }; betaFeatures: FrontendBetaFeatures[]; easyAIWorkflowOnboarded: boolean; + partialExecution: { + version: 1 | 2; + enforce: boolean; + }; } diff --git a/packages/@n8n/config/src/configs/partial-executions.config.ts b/packages/@n8n/config/src/configs/partial-executions.config.ts new file mode 100644 index 0000000000000..7937f451d3ccd --- /dev/null +++ b/packages/@n8n/config/src/configs/partial-executions.config.ts @@ -0,0 +1,12 @@ +import { Config, Env } from '../decorators'; + +@Config +export class PartialExecutionsConfig { + /** Partial execution logic version to use by default. */ + @Env('N8N_PARTIAL_EXECUTION_VERSION_DEFAULT') + version: 1 | 2 = 1; + + /** Set this to true to enforce using the default version. Users cannot use the other version then by setting a local storage key. */ + @Env('N8N_PARTIAL_EXECUTION_ENFORCE_VERSION') + enforce: boolean = false; +} diff --git a/packages/@n8n/config/src/index.ts b/packages/@n8n/config/src/index.ts index 1f686999f749d..edcc794ca556e 100644 --- a/packages/@n8n/config/src/index.ts +++ b/packages/@n8n/config/src/index.ts @@ -14,6 +14,7 @@ import { LicenseConfig } from './configs/license.config'; import { LoggingConfig } from './configs/logging.config'; import { MultiMainSetupConfig } from './configs/multi-main-setup.config'; import { NodesConfig } from './configs/nodes.config'; +import { PartialExecutionsConfig } from './configs/partial-executions.config'; import { PublicApiConfig } from './configs/public-api.config'; import { TaskRunnersConfig } from './configs/runners.config'; import { ScalingModeConfig } from './configs/scaling-mode.config'; @@ -134,4 +135,7 @@ export class GlobalConfig { @Nested tags: TagsConfig; + + @Nested + partialExecutions: PartialExecutionsConfig; } diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index 213776056968c..2cefd5568368f 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -302,6 +302,10 @@ describe('GlobalConfig', () => { tags: { disabled: false, }, + partialExecutions: { + version: 1, + enforce: false, + }, }; it('should use all default values when no env variables are defined', () => { diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index dba86112577f7..6041549ec59b3 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -370,13 +370,4 @@ export const schema = { env: 'N8N_PROXY_HOPS', doc: 'Number of reverse-proxies n8n is running behind', }, - - featureFlags: { - partialExecutionVersionDefault: { - format: String, - default: '0', - env: 'PARTIAL_EXECUTION_VERSION_DEFAULT', - doc: 'Set this to 1 to enable the new partial execution logic by default.', - }, - }, }; diff --git a/packages/cli/src/evaluation.ee/test-runner/test-runner.service.ee.ts b/packages/cli/src/evaluation.ee/test-runner/test-runner.service.ee.ts index 3f8dfacea310b..09c4fe4ec65aa 100644 --- a/packages/cli/src/evaluation.ee/test-runner/test-runner.service.ee.ts +++ b/packages/cli/src/evaluation.ee/test-runner/test-runner.service.ee.ts @@ -141,7 +141,7 @@ export class TestRunnerService { pinData, workflowData: { ...workflow, pinData }, userId: metadata.userId, - partialExecutionVersion: '1', + partialExecutionVersion: 2, }; // Trigger the workflow under test with mocked data diff --git a/packages/cli/src/manual-execution.service.ts b/packages/cli/src/manual-execution.service.ts index 0b8b9360d39ae..6f55130b25c52 100644 --- a/packages/cli/src/manual-execution.service.ts +++ b/packages/cli/src/manual-execution.service.ts @@ -105,7 +105,7 @@ export class ManualExecutionService { // Execute only the nodes between start and destination nodes const workflowExecute = new WorkflowExecute(additionalData, data.executionMode); - if (data.partialExecutionVersion === '1') { + if (data.partialExecutionVersion === 2) { return workflowExecute.runPartialWorkflow2( workflow, data.runData, diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index ad94bb60ea065..609e30ec606f3 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -234,6 +234,7 @@ export class FrontendService { }, betaFeatures: this.frontendConfig.betaFeatures, easyAIWorkflowOnboarded: false, + partialExecution: this.globalConfig.partialExecutions, }; } diff --git a/packages/cli/src/workflows/workflow-execution.service.ts b/packages/cli/src/workflows/workflow-execution.service.ts index bccd5c1f13f14..cd6f936717c2e 100644 --- a/packages/cli/src/workflows/workflow-execution.service.ts +++ b/packages/cli/src/workflows/workflow-execution.service.ts @@ -97,7 +97,7 @@ export class WorkflowExecutionService { }: WorkflowRequest.ManualRunPayload, user: User, pushRef?: string, - partialExecutionVersion?: string, + partialExecutionVersion: 1 | 2 = 1, ) { const pinData = workflowData.pinData; const pinnedTrigger = this.selectPinnedActivatorStarter( @@ -142,7 +142,7 @@ export class WorkflowExecutionService { startNodes, workflowData, userId: user.id, - partialExecutionVersion: partialExecutionVersion ?? '0', + partialExecutionVersion, dirtyNodeNames, triggerToStartFrom, }; diff --git a/packages/cli/src/workflows/workflow.request.ts b/packages/cli/src/workflows/workflow.request.ts index 47fd2cdb93d6b..3e44d5b950428 100644 --- a/packages/cli/src/workflows/workflow.request.ts +++ b/packages/cli/src/workflows/workflow.request.ts @@ -55,12 +55,7 @@ export declare namespace WorkflowRequest { type NewName = AuthenticatedRequest<{}, {}, {}, { name?: string }>; - type ManualRun = AuthenticatedRequest< - { workflowId: string }, - {}, - ManualRunPayload, - { partialExecutionVersion?: string } - >; + type ManualRun = AuthenticatedRequest<{ workflowId: string }, {}, ManualRunPayload, {}>; type Share = AuthenticatedRequest<{ workflowId: string }, {}, { shareWithIds: string[] }>; diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index 2716b8c4b2546..76904e5201be5 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -1,4 +1,4 @@ -import { ImportWorkflowFromUrlDto } from '@n8n/api-types'; +import { ImportWorkflowFromUrlDto, ManualRunQueryDto } from '@n8n/api-types'; import { GlobalConfig } from '@n8n/config'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In, type FindOptionsRelations } from '@n8n/typeorm'; @@ -9,7 +9,6 @@ import { ApplicationError } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; import { z } from 'zod'; -import config from '@/config'; import type { Project } from '@/databases/entities/project'; import { SharedWorkflow } from '@/databases/entities/shared-workflow'; import { WorkflowEntity } from '@/databases/entities/workflow-entity'; @@ -367,7 +366,11 @@ export class WorkflowsController { @Post('/:workflowId/run') @ProjectScope('workflow:execute') - async runManually(req: WorkflowRequest.ManualRun) { + async runManually( + req: WorkflowRequest.ManualRun, + _res: unknown, + @Query query: ManualRunQueryDto, + ) { if (!req.body.workflowData.id) { throw new ApplicationError('You cannot execute a workflow without an ID', { level: 'warning', @@ -395,9 +398,7 @@ export class WorkflowsController { req.body, req.user, req.headers['push-ref'], - req.query.partialExecutionVersion === '-1' - ? config.getEnv('featureFlags.partialExecutionVersionDefault') - : req.query.partialExecutionVersion, + query.partialExecutionVersion, ); } diff --git a/packages/editor-ui/src/__tests__/defaults.ts b/packages/editor-ui/src/__tests__/defaults.ts index 2272c2f40f282..f1d16ed7e3d9f 100644 --- a/packages/editor-ui/src/__tests__/defaults.ts +++ b/packages/editor-ui/src/__tests__/defaults.ts @@ -137,4 +137,8 @@ export const defaultSettings: FrontendSettings = { }, betaFeatures: [], easyAIWorkflowOnboarded: false, + partialExecution: { + version: 1, + enforce: false, + }, }; diff --git a/packages/editor-ui/src/composables/useRunWorkflow.test.ts b/packages/editor-ui/src/composables/useRunWorkflow.test.ts index 5d3b1005903cd..f6a38d471ebb2 100644 --- a/packages/editor-ui/src/composables/useRunWorkflow.test.ts +++ b/packages/editor-ui/src/composables/useRunWorkflow.test.ts @@ -19,9 +19,8 @@ import { useUIStore } from '@/stores/ui.store'; import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; import { useToast } from './useToast'; import { useI18n } from '@/composables/useI18n'; -import { useLocalStorage } from '@vueuse/core'; -import { ref } from 'vue'; import { captor, mock } from 'vitest-mock-extended'; +import { useSettingsStore } from '@/stores/settings.store'; vi.mock('@/stores/workflows.store', () => ({ useWorkflowsStore: vi.fn().mockReturnValue({ @@ -41,16 +40,6 @@ vi.mock('@/stores/workflows.store', () => ({ }), })); -vi.mock('@vueuse/core', async () => { - // eslint-disable-next-line @typescript-eslint/consistent-type-imports - const originalModule = await vi.importActual('@vueuse/core'); - - return { - ...originalModule, // Keep all original exports - useLocalStorage: vi.fn().mockReturnValue({ value: undefined }), // Mock useLocalStorage - }; -}); - vi.mock('@/composables/useTelemetry', () => ({ useTelemetry: vi.fn().mockReturnValue({ track: vi.fn() }), })); @@ -106,6 +95,7 @@ describe('useRunWorkflow({ router })', () => { let workflowsStore: ReturnType; let router: ReturnType; let workflowHelpers: ReturnType; + let settingsStore: ReturnType; beforeAll(() => { const pinia = createTestingPinia({ stubActions: false }); @@ -115,6 +105,7 @@ describe('useRunWorkflow({ router })', () => { rootStore = useRootStore(); uiStore = useUIStore(); workflowsStore = useWorkflowsStore(); + settingsStore = useSettingsStore(); router = useRouter(); workflowHelpers = useWorkflowHelpers({ router }); @@ -322,8 +313,8 @@ describe('useRunWorkflow({ router })', () => { expect(result).toEqual(mockExecutionResponse); }); - it('should send dirty nodes for partial executions', async () => { - vi.mocked(useLocalStorage).mockReturnValueOnce(ref(1)); + it('should send dirty nodes for partial executions v2', async () => { + vi.mocked(settingsStore).partialExecutionVersion = 2; const composable = useRunWorkflow({ router }); const parentName = 'When clicking'; const executeName = 'Code'; @@ -404,7 +395,7 @@ describe('useRunWorkflow({ router })', () => { ); }); - it('does not use the original run data if `PartialExecution.version` is set to 0', async () => { + it('does not use the original run data if `partialExecutionVersion` is set to 1', async () => { // ARRANGE const mockExecutionResponse = { executionId: '123' }; const mockRunData = { nodeName: [] }; @@ -413,7 +404,7 @@ describe('useRunWorkflow({ router })', () => { const workflow = mock({ name: 'Test Workflow' }); workflow.getParentNodes.mockReturnValue([]); - vi.mocked(useLocalStorage).mockReturnValueOnce(ref(0)); + vi.mocked(settingsStore).partialExecutionVersion = 1; vi.mocked(rootStore).pushConnectionActive = true; vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockExecutionResponse); vi.mocked(workflowsStore).nodesIssuesExist = false; @@ -435,7 +426,7 @@ describe('useRunWorkflow({ router })', () => { }); }); - it('retains the original run data if `PartialExecution.version` is set to 1', async () => { + it('retains the original run data if `partialExecutionVersion` is set to 2', async () => { // ARRANGE const mockExecutionResponse = { executionId: '123' }; const mockRunData = { nodeName: [] }; @@ -444,7 +435,7 @@ describe('useRunWorkflow({ router })', () => { const workflow = mock({ name: 'Test Workflow' }); workflow.getParentNodes.mockReturnValue([]); - vi.mocked(useLocalStorage).mockReturnValueOnce(ref(1)); + vi.mocked(settingsStore).partialExecutionVersion = 2; vi.mocked(rootStore).pushConnectionActive = true; vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockExecutionResponse); vi.mocked(workflowsStore).nodesIssuesExist = false; @@ -464,7 +455,7 @@ describe('useRunWorkflow({ router })', () => { expect(dataCaptor.value).toMatchObject({ data: { resultData: { runData: mockRunData } } }); }); - it("does not send run data if it's not a partial execution even if `PartialExecution.version` is set to 1", async () => { + it("does not send run data if it's not a partial execution even if `partialExecutionVersion` is set to 2", async () => { // ARRANGE const mockExecutionResponse = { executionId: '123' }; const mockRunData = { nodeName: [] }; @@ -473,7 +464,7 @@ describe('useRunWorkflow({ router })', () => { const workflow = mock({ name: 'Test Workflow' }); workflow.getParentNodes.mockReturnValue([]); - vi.mocked(useLocalStorage).mockReturnValueOnce(ref(1)); + vi.mocked(settingsStore).partialExecutionVersion = 2; vi.mocked(rootStore).pushConnectionActive = true; vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockExecutionResponse); vi.mocked(workflowsStore).nodesIssuesExist = false; diff --git a/packages/editor-ui/src/composables/useRunWorkflow.ts b/packages/editor-ui/src/composables/useRunWorkflow.ts index 880713e1e55f1..ca84f21ecf9b4 100644 --- a/packages/editor-ui/src/composables/useRunWorkflow.ts +++ b/packages/editor-ui/src/composables/useRunWorkflow.ts @@ -35,7 +35,7 @@ import { isEmpty } from '@/utils/typesUtils'; import { useI18n } from '@/composables/useI18n'; import { get } from 'lodash-es'; import { useExecutionsStore } from '@/stores/executions.store'; -import { useLocalStorage } from '@vueuse/core'; +import { useSettingsStore } from '@/stores/settings.store'; const getDirtyNodeNames = ( runData: IRunData, @@ -260,18 +260,18 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType ({ getSettings: vi.fn(), @@ -54,6 +56,16 @@ vi.mock('@/stores/versions.store', () => ({ })), })); +vi.mock('@vueuse/core', async () => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const originalModule = await vi.importActual('@vueuse/core'); + + return { + ...originalModule, + useLocalStorage: vi.fn().mockReturnValue({ value: undefined }), + }; +}); + const mockSettings = mock({ authCookie: { secure: true }, }); @@ -99,4 +111,47 @@ describe('settings.store', () => { expect(sessionStarted).not.toHaveBeenCalled(); }); }); + + describe('partialExecutionVersion', () => { + it.each([ + { + name: 'pick the default', + default: 1 as const, + enforce: false, + userVersion: -1, + result: 1, + }, + { + name: "pick the user' choice", + default: 1 as const, + enforce: false, + userVersion: 2, + result: 2, + }, + { + name: 'enforce the default', + default: 1 as const, + enforce: true, + userVersion: 2, + result: 1, + }, + { + name: 'enforce the default', + default: 2 as const, + enforce: true, + userVersion: 1, + result: 2, + }, + ])('%name', async ({ default: defaultVersion, userVersion, enforce, result }) => { + const settingsStore = useSettingsStore(); + + settingsStore.settings.partialExecution = { + version: defaultVersion, + enforce, + }; + vi.mocked(useLocalStorage).mockReturnValueOnce(ref(userVersion)); + + expect(settingsStore.partialExecutionVersion).toBe(result); + }); + }); }); diff --git a/packages/editor-ui/src/stores/settings.store.ts b/packages/editor-ui/src/stores/settings.store.ts index 1765925f1d097..6f0eab341d22c 100644 --- a/packages/editor-ui/src/stores/settings.store.ts +++ b/packages/editor-ui/src/stores/settings.store.ts @@ -19,6 +19,7 @@ import { useVersionsStore } from './versions.store'; import { makeRestApiRequest } from '@/utils/apiUtils'; import { useToast } from '@/composables/useToast'; import { i18n } from '@/plugins/i18n'; +import { useLocalStorage } from '@vueuse/core'; export const useSettingsStore = defineStore(STORES.SETTINGS, () => { const initialized = ref(false); @@ -98,6 +99,22 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { const isCloudDeployment = computed(() => settings.value.deployment?.type === 'cloud'); + const partialExecutionVersion = computed(() => { + const defaultVersion = settings.value.partialExecution?.version ?? 1; + const enforceVersion = settings.value.partialExecution?.enforce ?? false; + // -1 means we pick the defaultVersion + // 1 is the old flow + // 2 is the new flow + const userVersion = useLocalStorage('PartialExecution.version', -1).value; + const version = enforceVersion + ? defaultVersion + : userVersion === -1 + ? defaultVersion + : userVersion; + + return version; + }); + const isAiCreditsEnabled = computed(() => settings.value.aiCredits?.enabled); const aiCreditsQuota = computed(() => settings.value.aiCredits?.credits); @@ -430,5 +447,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { getSettings, setSettings, initialize, + partialExecutionVersion, }; }); diff --git a/packages/editor-ui/src/stores/workflows.store.ts b/packages/editor-ui/src/stores/workflows.store.ts index ae3b142a579a9..e09de1dc3ba21 100644 --- a/packages/editor-ui/src/stores/workflows.store.ts +++ b/packages/editor-ui/src/stores/workflows.store.ts @@ -79,7 +79,6 @@ import { computed, ref } from 'vue'; import { useProjectsStore } from '@/stores/projects.store'; import type { ProjectSharingData } from '@/types/projects.types'; import type { PushPayload } from '@n8n/api-types'; -import { useLocalStorage } from '@vueuse/core'; import { useTelemetry } from '@/composables/useTelemetry'; import { TelemetryHelpers } from 'n8n-workflow'; import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; @@ -125,10 +124,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { const nodeHelpers = useNodeHelpers(); const usersStore = useUsersStore(); - // -1 means the backend chooses the default - // 0 is the old flow - // 1 is the new flow - const partialExecutionVersion = useLocalStorage('PartialExecution.version', -1); + const version = settingsStore.partialExecutionVersion; const workflow = ref(createEmptyWorkflow()); const usedCredentials = ref>({}); @@ -1474,7 +1470,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { return await makeRestApiRequest( rootStore.restApiContext, 'POST', - `/workflows/${startRunData.workflowData.id}/run?partialExecutionVersion=${partialExecutionVersion.value}`, + `/workflows/${startRunData.workflowData.id}/run?partialExecutionVersion=${version}`, startRunData as unknown as IDataObject, ); } catch (error) { diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 0d67b07f37505..ce1797ce01959 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -2293,12 +2293,10 @@ export interface IWorkflowExecutionDataProcess { /** * Defines which version of the partial execution flow is used. * Possible values are: - * 0 - use the old flow - * 1 - use the new flow - * -1 - the backend chooses which flow based on the environment variable - * PARTIAL_EXECUTION_VERSION_DEFAULT + * 1 - use the old flow + * 2 - use the new flow */ - partialExecutionVersion?: string; + partialExecutionVersion?: 1 | 2; dirtyNodeNames?: string[]; triggerToStartFrom?: { name: string;