From 710a6548d444fe740158333ce362fec62c8d3075 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frank=20Pr=C3=B6=C3=9Fdorf?= Date: Mon, 1 Dec 2025 16:05:28 +0200 Subject: [PATCH] feat(core): Deactivate crashed workflows (#21888) Co-authored-by: Artem Sorokin --- .../dismiss-banner-request.dto.test.ts | 1 + packages/@n8n/api-types/src/push/workflow.ts | 10 ++- .../src/schemas/banner-name.schema.ts | 1 + .../config/src/configs/executions.config.ts | 19 ++++ .../src/configs/user-management.config.ts | 4 + packages/@n8n/config/test/config.test.ts | 5 ++ .../message-event-bus/message-event-bus.ts | 19 +++- .../cli/src/events/maps/relay.event-map.ts | 2 + ...ution-recovery.service.integration.test.ts | 59 +++++++++++- .../executions/execution-recovery.service.ts | 90 ++++++++++++++++++- ...egacy-sqlite-execution-recovery.service.ts | 1 + .../email/templates/workflow-deactivated.mjml | 21 +++++ .../email/user-management-mailer.ts | 24 +++++ .../cli/src/workflows/workflow.service.ts | 14 +-- .../frontend/@n8n/i18n/src/locales/en.json | 3 +- .../usePushConnection/handlers/index.ts | 1 + .../handlers/workflowActivated.ts | 7 ++ .../handlers/workflowAutoDeactivated.ts | 14 +++ .../usePushConnection/usePushConnection.ts | 3 + .../src/app/composables/useWorkflowSaving.ts | 2 +- .../editor-ui/src/app/views/NodeView.vue | 1 + .../features/shared/banners/banners.store.ts | 1 + .../shared/banners/components/BannerStack.vue | 5 ++ .../banners/WorkflowAutoDeactivatedBanner.vue | 14 +++ 24 files changed, 297 insertions(+), 24 deletions(-) create mode 100644 packages/cli/src/user-management/email/templates/workflow-deactivated.mjml create mode 100644 packages/frontend/editor-ui/src/app/composables/usePushConnection/handlers/workflowAutoDeactivated.ts create mode 100644 packages/frontend/editor-ui/src/features/shared/banners/components/banners/WorkflowAutoDeactivatedBanner.vue diff --git a/packages/@n8n/api-types/src/dto/owner/__tests__/dismiss-banner-request.dto.test.ts b/packages/@n8n/api-types/src/dto/owner/__tests__/dismiss-banner-request.dto.test.ts index 5eb1f4e59e5..740476caf24 100644 --- a/packages/@n8n/api-types/src/dto/owner/__tests__/dismiss-banner-request.dto.test.ts +++ b/packages/@n8n/api-types/src/dto/owner/__tests__/dismiss-banner-request.dto.test.ts @@ -92,6 +92,7 @@ describe('DismissBannerRequestDto', () => { 'EMAIL_CONFIRMATION', 'DATA_TABLE_STORAGE_LIMIT_WARNING', 'DATA_TABLE_STORAGE_LIMIT_ERROR', + 'WORKFLOW_AUTO_DEACTIVATED', ]; expect(staticBannerNameSchema.options).toEqual(expectedBanners); diff --git a/packages/@n8n/api-types/src/push/workflow.ts b/packages/@n8n/api-types/src/push/workflow.ts index 36d9da857e8..f9a497af6ca 100644 --- a/packages/@n8n/api-types/src/push/workflow.ts +++ b/packages/@n8n/api-types/src/push/workflow.ts @@ -20,7 +20,15 @@ export type WorkflowDeactivated = { }; }; +export type WorkflowAutoDeactivated = { + type: 'workflowAutoDeactivated'; + data: { + workflowId: string; + }; +}; + export type WorkflowPushMessage = | WorkflowActivated | WorkflowFailedToActivate - | WorkflowDeactivated; + | WorkflowDeactivated + | WorkflowAutoDeactivated; diff --git a/packages/@n8n/api-types/src/schemas/banner-name.schema.ts b/packages/@n8n/api-types/src/schemas/banner-name.schema.ts index 005b2df84ff..66d507c506b 100644 --- a/packages/@n8n/api-types/src/schemas/banner-name.schema.ts +++ b/packages/@n8n/api-types/src/schemas/banner-name.schema.ts @@ -8,6 +8,7 @@ export const staticBannerNameSchema = z.enum([ 'EMAIL_CONFIRMATION', 'DATA_TABLE_STORAGE_LIMIT_WARNING', 'DATA_TABLE_STORAGE_LIMIT_ERROR', + 'WORKFLOW_AUTO_DEACTIVATED', ]); export const dynamicBannerNameSchema = z.string().regex(/^dynamic-banner-\d+$/); export const bannerNameSchema = z.union([staticBannerNameSchema, dynamicBannerNameSchema]); diff --git a/packages/@n8n/config/src/configs/executions.config.ts b/packages/@n8n/config/src/configs/executions.config.ts index d618100d65f..f38a519aaed 100644 --- a/packages/@n8n/config/src/configs/executions.config.ts +++ b/packages/@n8n/config/src/configs/executions.config.ts @@ -39,6 +39,22 @@ class QueueRecoveryConfig { batchSize: number = 100; } +@Config +class RecoveryConfig { + /** + * Number of last executions to check when determining if a workflow should be deactivated + * when all of the last N executions have crashed. + */ + @Env('N8N_WORKFLOW_AUTODEACTIVATION_MAX_LAST_EXECUTIONS') + maxLastExecutions: number = 3; + + /** + * Whether to automatically deactivate workflows that have all their last executions crashed. + */ + @Env('N8N_WORKFLOW_AUTODEACTIVATION_ENABLED') + workflowDeactivationEnabled: boolean = false; +} + const executionModeSchema = z.enum(['regular', 'queue']); export type ExecutionMode = z.infer; @@ -93,6 +109,9 @@ export class ExecutionsConfig { @Nested queueRecovery: QueueRecoveryConfig; + @Nested + recovery: RecoveryConfig; + /** Whether to save execution data for failed production executions. This default can be overridden at a workflow level. */ @Env('EXECUTIONS_DATA_SAVE_ON_ERROR') saveDataOnError: 'all' | 'none' = 'all'; diff --git a/packages/@n8n/config/src/configs/user-management.config.ts b/packages/@n8n/config/src/configs/user-management.config.ts index c6e61c28a0b..7fec50b1f5a 100644 --- a/packages/@n8n/config/src/configs/user-management.config.ts +++ b/packages/@n8n/config/src/configs/user-management.config.ts @@ -61,6 +61,10 @@ export class TemplateConfig { @Env('N8N_UM_EMAIL_TEMPLATES_WORKFLOW_SHARED') 'workflow-shared': string = ''; + /** Overrides default HTML template for notifying that a workflow was deactivated (use full path) */ + @Env('N8N_UM_EMAIL_TEMPLATES_WORKFLOW_AUTODEACTIVATED') + 'workflow-deactivated': string = ''; + /** Overrides default HTML template for notifying that credentials were shared (use full path) */ @Env('N8N_UM_EMAIL_TEMPLATES_CREDENTIALS_SHARED') 'credentials-shared': string = ''; diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index 5e0b821ee87..30a470816d8 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -137,6 +137,7 @@ describe('GlobalConfig', () => { 'credentials-shared': '', 'user-invited': '', 'password-reset-requested': '', + 'workflow-deactivated': '', 'workflow-shared': '', 'project-shared': '', }, @@ -347,6 +348,10 @@ describe('GlobalConfig', () => { interval: 180, batchSize: 100, }, + recovery: { + maxLastExecutions: 3, + workflowDeactivationEnabled: false, + }, saveDataOnError: 'all', saveDataOnSuccess: 'all', saveExecutionProgress: false, diff --git a/packages/cli/src/eventbus/message-event-bus/message-event-bus.ts b/packages/cli/src/eventbus/message-event-bus/message-event-bus.ts index 7ba3fdcc81b..a4ba70da628 100644 --- a/packages/cli/src/eventbus/message-event-bus/message-event-bus.ts +++ b/packages/cli/src/eventbus/message-event-bus/message-event-bus.ts @@ -186,14 +186,20 @@ export class MessageEventBus extends EventEmitter { // start actual recovery process and write recovery process flag file this.logWriter?.startRecoveryProcess(); const recoveredIds: string[] = []; + const crashedWorkflowIds: Set = new Set(); for (const executionId of unfinishedExecutionIds) { - const logMesssages = unsentAndUnfinished.unfinishedExecutions[executionId]; + const logMessages = unsentAndUnfinished.unfinishedExecutions[executionId]; const recoveredExecution = await this.recoveryService.recoverFromLogs( executionId, - logMesssages ?? [], + logMessages ?? [], ); - if (recoveredExecution) recoveredIds.push(executionId); + if (recoveredExecution) { + if (recoveredExecution.status === 'crashed') { + crashedWorkflowIds.add(recoveredExecution.workflowId); + } + recoveredIds.push(executionId); + } } if (recoveredIds.length > 0) { @@ -202,6 +208,13 @@ export class MessageEventBus extends EventEmitter { 'This could be due to a crash of an active workflow or a restart of n8n', ); } + + if ( + this.globalConfig.executions.recovery.workflowDeactivationEnabled && + crashedWorkflowIds.size > 0 + ) { + await this.recoveryService.autoDeactivateWorkflowsIfNeeded(crashedWorkflowIds); + } } // remove the recovery process flag file diff --git a/packages/cli/src/events/maps/relay.event-map.ts b/packages/cli/src/events/maps/relay.event-map.ts index 88c5a4e624b..b2c3414eb7a 100644 --- a/packages/cli/src/events/maps/relay.event-map.ts +++ b/packages/cli/src/events/maps/relay.event-map.ts @@ -263,6 +263,7 @@ export type RelayEventMap = { | 'Reset password' | 'New user invite' | 'Resend invite' + | 'Workflow auto-deactivated' | 'Workflow shared' | 'Credentials shared' | 'Project shared'; @@ -301,6 +302,7 @@ export type RelayEventMap = { | 'New user invite' | 'Resend invite' | 'Workflow shared' + | 'Workflow auto-deactivated' | 'Credentials shared' | 'Project shared'; publicApi: boolean; diff --git a/packages/cli/src/executions/__tests__/execution-recovery.service.integration.test.ts b/packages/cli/src/executions/__tests__/execution-recovery.service.integration.test.ts index 03ca6a49227..5cb215d3757 100644 --- a/packages/cli/src/executions/__tests__/execution-recovery.service.integration.test.ts +++ b/packages/cli/src/executions/__tests__/execution-recovery.service.integration.test.ts @@ -1,11 +1,20 @@ -import { createWorkflow, testDb, mockInstance } from '@n8n/backend-test-utils'; -import { ExecutionRepository } from '@n8n/db'; +import { + createActiveWorkflow, + createWorkflow, + testDb, + mockInstance, + getWorkflowById, +} from '@n8n/backend-test-utils'; +import { GlobalConfig } from '@n8n/config'; +import { ExecutionRepository, WorkflowRepository, ProjectRelationRepository } from '@n8n/db'; +import type { Project, User } from '@n8n/db'; import { Container } from '@n8n/di'; import { stringify } from 'flatted'; import { mock } from 'jest-mock-extended'; import { InstanceSettings } from 'n8n-core'; import { randomInt } from 'n8n-workflow'; import assert from 'node:assert'; +import { v4 as uuid } from 'uuid'; import { ARTIFICIAL_TASK_DATA } from '@/constants'; import { NodeCrashedError } from '@/errors/node-crashed.error'; @@ -14,6 +23,7 @@ import type { EventMessageTypes as EventMessage } from '@/eventbus/event-message import { EventMessageNode } from '@/eventbus/event-message-classes/event-message-node'; import { ExecutionRecoveryService } from '@/executions/execution-recovery.service'; import { Push } from '@/push'; +import { OwnershipService } from '@/services/ownership.service'; import { createExecution } from '@test-integration/db/executions'; import { IN_PROGRESS_EXECUTION_DATA, OOM_WORKFLOW } from './constants'; @@ -22,19 +32,30 @@ import { setupMessages } from './utils'; describe('ExecutionRecoveryService', () => { const push = mockInstance(Push); const instanceSettings = Container.get(InstanceSettings); + const ownershipService = mockInstance(OwnershipService); + const projectRelationRepository = mockInstance(ProjectRelationRepository); let executionRecoveryService: ExecutionRecoveryService; let executionRepository: ExecutionRepository; + let workflowRepository: WorkflowRepository; + let globalConfig: GlobalConfig; beforeAll(async () => { await testDb.init(); executionRepository = Container.get(ExecutionRepository); + workflowRepository = Container.get(WorkflowRepository); + globalConfig = Container.get(GlobalConfig); executionRecoveryService = new ExecutionRecoveryService( mock(), instanceSettings, push, executionRepository, + globalConfig.executions, + workflowRepository, + mock(), + ownershipService, + projectRelationRepository, ); }); @@ -44,6 +65,7 @@ describe('ExecutionRecoveryService', () => { afterEach(async () => { jest.restoreAllMocks(); + globalConfig.executions.recovery.workflowDeactivationEnabled = false; await testDb.truncate(['ExecutionEntity', 'ExecutionData', 'WorkflowEntity']); }); @@ -394,6 +416,39 @@ describe('ExecutionRecoveryService', () => { expect(debugHelperTaskData?.error).toBeUndefined(); expect(debugHelperTaskData?.data).toEqual(ARTIFICIAL_TASK_DATA); }); + + test('should deactivate workflow if all last executions are crashed', async () => { + /** + * Arrange + */ + globalConfig.executions.recovery.workflowDeactivationEnabled = true; + + const workflow = await createActiveWorkflow({ + ...OOM_WORKFLOW, + }); + expect(workflow.activeVersionId).not.toBeNull(); + await createExecution({ status: 'crashed' }, workflow); + await createExecution({ status: 'crashed' }, workflow); + await createExecution({ status: 'crashed' }, workflow); + + ownershipService.getWorkflowProjectCached.mockResolvedValue( + mock({ id: uuid(), type: 'personal' }), + ); + ownershipService.getInstanceOwner.mockResolvedValue(mock({ id: uuid() })); + projectRelationRepository.find.mockResolvedValue([]); + + /** + * Act + */ + await executionRecoveryService.autoDeactivateWorkflowsIfNeeded(new Set([workflow.id])); + + /** + * Assert + */ + const updatedWorkflow = await getWorkflowById(workflow.id); + if (!updatedWorkflow) fail('Expected `updatedWorkflow` to be defined'); + expect(updatedWorkflow.activeVersionId).toBeNull(); + }); }); }); }); diff --git a/packages/cli/src/executions/execution-recovery.service.ts b/packages/cli/src/executions/execution-recovery.service.ts index 284bc59d3c3..ffc004bbd7f 100644 --- a/packages/cli/src/executions/execution-recovery.service.ts +++ b/packages/cli/src/executions/execution-recovery.service.ts @@ -1,17 +1,27 @@ import { Logger } from '@n8n/backend-common'; -import type { IExecutionResponse } from '@n8n/db'; -import { ExecutionRepository } from '@n8n/db'; +import { ExecutionsConfig } from '@n8n/config'; +import { + In, + type IExecutionResponse, + ProjectRelationRepository, + WorkflowEntity, + User, +} from '@n8n/db'; +import { ExecutionRepository, WorkflowRepository } from '@n8n/db'; import { Service } from '@n8n/di'; +import { PROJECT_ADMIN_ROLE_SLUG, PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions'; import type { DateTime } from 'luxon'; import { InstanceSettings } from 'n8n-core'; import { createEmptyRunExecutionData, sleep } from 'n8n-workflow'; -import type { IRun, ITaskData } from 'n8n-workflow'; +import { ExecutionStatus, type IRun, type ITaskData } from 'n8n-workflow'; import { ARTIFICIAL_TASK_DATA } from '@/constants'; import { NodeCrashedError } from '@/errors/node-crashed.error'; import { WorkflowCrashedError } from '@/errors/workflow-crashed.error'; import { getLifecycleHooksForRegularMain } from '@/execution-lifecycle/execution-lifecycle-hooks'; import { Push } from '@/push'; +import { OwnershipService } from '@/services/ownership.service'; +import { UserManagementMailer } from '@/user-management/email/user-management-mailer'; import type { EventMessageTypes } from '../eventbus/event-message-classes'; @@ -25,8 +35,63 @@ export class ExecutionRecoveryService { private readonly instanceSettings: InstanceSettings, private readonly push: Push, private readonly executionRepository: ExecutionRepository, + private readonly executionsConfig: ExecutionsConfig, + private readonly workflowRepository: WorkflowRepository, + private readonly userManagementMailer: UserManagementMailer, + private readonly ownershipService: OwnershipService, + private readonly projectRelationRepository: ProjectRelationRepository, ) {} + async autoDeactivateWorkflowsIfNeeded(workflowIds: Set) { + for (const workflowId of workflowIds) { + const maxLastExecutions = this.executionsConfig.recovery.maxLastExecutions; + const lastExecutions = await this.executionRepository.findMultipleExecutions({ + select: ['id', 'status'], + where: { workflowId }, + order: { startedAt: 'DESC' }, + take: maxLastExecutions, + }); + const numberOfCrashedExecutions = lastExecutions.filter((e) => e.status === 'crashed').length; + + // If all of the last N executions are crashed, deactivate the workflow + if ( + lastExecutions.length >= maxLastExecutions && + lastExecutions.length === numberOfCrashedExecutions + ) { + // Get workflow to preserve existing meta + const workflow = await this.workflowRepository.findOne({ where: { id: workflowId } }); + + if (!workflow) { + this.logger.warn(`Workflow ${workflowId} not found, skipping workflow auto-deactivation`); + continue; + } + + if (workflow.activeVersionId !== null) { + await this.workflowRepository.updateActiveState(workflowId, false); + this.logger.warn( + `Autodeactivated workflow ${workflowId} due to too many crashed executions.`, + ); + + const recipient = await this.getAutodeactivationRecipient(workflow); + await this.userManagementMailer.notifyWorkflowAutodeactivated({ + recipient, + workflow, + }); + + this.push.once('editorUiConnected', async () => { + await sleep(1000); + this.push.broadcast({ type: 'workflowAutoDeactivated', data: { workflowId } }); + }); + } + + await this.executionRepository.update( + { workflowId, status: In(['running', 'new']) }, + { status: 'crashed', stoppedAt: new Date() }, + ); + } + } + } + /** * Recover key properties of a truncated execution using event logs. */ @@ -210,4 +275,23 @@ export class ExecutionRecoveryService { await lifecycleHooks.runHook('workflowExecuteAfter', [run]); } + + private async getAutodeactivationRecipient(workflow: WorkflowEntity): Promise { + const project = await this.ownershipService.getWorkflowProjectCached(workflow.id); + + const roleSlug = project.type === 'team' ? PROJECT_ADMIN_ROLE_SLUG : PROJECT_OWNER_ROLE_SLUG; + const projectRelations = await this.projectRelationRepository.find({ + where: { + projectId: project.id, + role: { slug: roleSlug }, + }, + relations: { user: true }, + }); + + if (projectRelations.length > 0) { + return projectRelations[0].user; + } else { + return await this.ownershipService.getInstanceOwner(); + } + } } diff --git a/packages/cli/src/executions/legacy-sqlite-execution-recovery.service.ts b/packages/cli/src/executions/legacy-sqlite-execution-recovery.service.ts index 34c4385303e..b80a8f0504a 100644 --- a/packages/cli/src/executions/legacy-sqlite-execution-recovery.service.ts +++ b/packages/cli/src/executions/legacy-sqlite-execution-recovery.service.ts @@ -11,6 +11,7 @@ import assert from 'assert'; @Service() export class LegacySqliteExecutionRecoveryService { private readonly logger: Logger; + constructor( logger: Logger, private readonly executionRepository: ExecutionRepository, diff --git a/packages/cli/src/user-management/email/templates/workflow-deactivated.mjml b/packages/cli/src/user-management/email/templates/workflow-deactivated.mjml new file mode 100644 index 00000000000..af024ba1432 --- /dev/null +++ b/packages/cli/src/user-management/email/templates/workflow-deactivated.mjml @@ -0,0 +1,21 @@ + + + + + + + Workflow automatically deactivated + n8n has automatically deactivated "{{ workflowName }}" due to repeated + crashes. + Please review its recent executions and resolve any issues before reactivating + it. + View Workflow + + + + + diff --git a/packages/cli/src/user-management/email/user-management-mailer.ts b/packages/cli/src/user-management/email/user-management-mailer.ts index 99909cfd061..255c6eb76b8 100644 --- a/packages/cli/src/user-management/email/user-management-mailer.ts +++ b/packages/cli/src/user-management/email/user-management-mailer.ts @@ -23,6 +23,7 @@ type Template = HandlebarsTemplateDelegate; type TemplateName = | 'user-invited' | 'password-reset-requested' + | 'workflow-deactivated' | 'workflow-shared' | 'credentials-shared' | 'project-shared'; @@ -138,6 +139,29 @@ export class UserManagementMailer { } } + async notifyWorkflowAutodeactivated({ + recipient, + workflow, + }: { + recipient: User; + workflow: IWorkflowBase; + }): Promise { + const recipients = await this.userRepository.getEmailsByIds([recipient.id]); + const baseUrl = this.urlService.getInstanceBaseUrl(); + + return await this.sendNotificationEmails({ + mailerTemplate: 'workflow-deactivated', + recipients, + sharer: recipient, + getTemplateData: () => ({ + workflowName: workflow.name, + workflowUrl: `${baseUrl}/workflow/${workflow.id}`, + }), + subjectBuilder: () => 'n8n has automatically autodeactivated a workflow', + messageType: 'Workflow auto-deactivated', + }); + } + async notifyWorkflowShared({ sharer, newShareeIds, diff --git a/packages/cli/src/workflows/workflow.service.ts b/packages/cli/src/workflows/workflow.service.ts index 803e8537581..e0a06dc69c3 100644 --- a/packages/cli/src/workflows/workflow.service.ts +++ b/packages/cli/src/workflows/workflow.service.ts @@ -272,7 +272,6 @@ export class WorkflowService { }, ); } - // Convert 'active' boolean from frontend to 'activeVersionId' for backend // Forbid updating active fields with FF on if (isDraftPublishDisabled && 'active' in workflowUpdateData) { @@ -323,7 +322,6 @@ export class WorkflowService { } const workflowSettings = workflowUpdateData.settings ?? {}; - const keysAllowingDefault = [ 'timezone', 'saveDataErrorExecution', @@ -362,7 +360,6 @@ export class WorkflowService { 'description', 'updatedAt', ]; - // Forbid updating active fields with FF on if (isDraftPublishDisabled) { fieldsToUpdate.push('activeVersionId', 'active'); @@ -377,7 +374,6 @@ export class WorkflowService { if (versionChanged) { await this.workflowHistoryService.saveVersion(user, workflowUpdateData, workflowId); } - if (isDraftPublishDisabled && needsActiveVersionUpdate) { const versionIdToFetch = versionChanged ? workflowUpdateData.versionId : workflow.versionId; const version = await this.workflowHistoryService.getVersion( @@ -385,14 +381,12 @@ export class WorkflowService { workflowId, versionIdToFetch, ); - updatePayload.activeVersion = WorkflowHelpers.getActiveVersionUpdateValue( workflow, version, isNowActive, ); } - if (parentFolderId) { const project = await this.sharedWorkflowRepository.getWorkflowOwningProject(workflow.id); if (parentFolderId !== PROJECT_ROOT) { @@ -407,9 +401,7 @@ export class WorkflowService { } updatePayload.parentFolder = parentFolderId === PROJECT_ROOT ? null : { id: parentFolderId }; } - await this.workflowRepository.update(workflowId, updatePayload); - const tagsDisabled = this.globalConfig.tags.disabled; if (tagIds && !tagsDisabled) { @@ -436,23 +428,20 @@ export class WorkflowService { requestOrder: tagIds, }); } - await this.externalHooks.run('workflow.afterUpdate', [updatedWorkflow]); this.eventService.emit('workflow-saved', { user, workflow: updatedWorkflow, publicApi, }); - // Skip activation/deactivation logic if draft/publish feature flag is enabled if (isDraftPublishDisabled) { if (activationStatusChanged && isNowActive) { - // Workflow is being activated this.eventService.emit('workflow-activated', { user, workflowId, workflow: updatedWorkflow, - publicApi, + publicApi: false, }); } else if (activationStatusChanged && !isNowActive) { // Workflow is being deactivated @@ -490,7 +479,6 @@ export class WorkflowService { ); } } - return updatedWorkflow; } diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index ecf6d85f080..66cbc0cd1d4 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -270,6 +270,7 @@ "banners.trial.message": "1 day left in your n8n trial | {count} days left in your n8n trial", "banners.trialOver.message": "Your trial is over. Upgrade now to keep automating.", "banners.v1.message": "n8n has been updated to version 1, introducing some breaking changes. Please consult the migration guide for more information.", + "banners.workflowAutoDeactivated.message": "This workflow was automatically deactivated due to multiple crashed executions. Please review and reactivate it when ready.", "binaryDataDisplay.backToList": "Back to list", "binaryDataDisplay.backToOverviewPage": "Back to overview page", "binaryDataDisplay.noDataFoundToDisplay": "No data found to display", @@ -3277,7 +3278,7 @@ "workflows.create.project.toast.title": "Workflow successfully created in {projectName}", "workflows.create.folder.toast.title": "Workflow successfully created in \"{projectName}\", within \"{folderName}\"", "workflows.create.project.toast.text": "All members from {projectName} will have access to this workflow.", - "workflows.deactivated": "Workflow deactivated", + "workflows.autodeactivated": "Workflow auto-deactivated", "workflows.ai.starter.collection.callout": "Learn how to build AI Agents in n8n", "workflows.ai.starter.collection.card": "Learn how to build AI Agents", "workflows.ai.starter.collection.folder.name": "🎁 n8n basics: Learn how to build Agents in n8n", diff --git a/packages/frontend/editor-ui/src/app/composables/usePushConnection/handlers/index.ts b/packages/frontend/editor-ui/src/app/composables/usePushConnection/handlers/index.ts index 1c7b6788055..4c0502d1aeb 100644 --- a/packages/frontend/editor-ui/src/app/composables/usePushConnection/handlers/index.ts +++ b/packages/frontend/editor-ui/src/app/composables/usePushConnection/handlers/index.ts @@ -13,5 +13,6 @@ export * from './sendWorkerStatusMessage'; export * from './testWebhookDeleted'; export * from './testWebhookReceived'; export * from './workflowActivated'; +export * from './workflowAutoDeactivated'; export * from './workflowDeactivated'; export * from './workflowFailedToActivate'; diff --git a/packages/frontend/editor-ui/src/app/composables/usePushConnection/handlers/workflowActivated.ts b/packages/frontend/editor-ui/src/app/composables/usePushConnection/handlers/workflowActivated.ts index b81eeedb48e..11db40db705 100644 --- a/packages/frontend/editor-ui/src/app/composables/usePushConnection/handlers/workflowActivated.ts +++ b/packages/frontend/editor-ui/src/app/composables/usePushConnection/handlers/workflowActivated.ts @@ -1,8 +1,15 @@ import type { WorkflowActivated } from '@n8n/api-types/push/workflow'; import { useWorkflowsStore } from '@/app/stores/workflows.store'; +import { useBannersStore } from '@/features/shared/banners/banners.store'; export async function workflowActivated({ data }: WorkflowActivated) { const workflowsStore = useWorkflowsStore(); + const bannersStore = useBannersStore(); workflowsStore.setWorkflowActive(data.workflowId); + + // Remove auto-deactivated banner if viewing this workflow + if (workflowsStore.workflowId === data.workflowId) { + bannersStore.removeBannerFromStack('WORKFLOW_AUTO_DEACTIVATED'); + } } diff --git a/packages/frontend/editor-ui/src/app/composables/usePushConnection/handlers/workflowAutoDeactivated.ts b/packages/frontend/editor-ui/src/app/composables/usePushConnection/handlers/workflowAutoDeactivated.ts new file mode 100644 index 00000000000..34c83840a1f --- /dev/null +++ b/packages/frontend/editor-ui/src/app/composables/usePushConnection/handlers/workflowAutoDeactivated.ts @@ -0,0 +1,14 @@ +import type { WorkflowAutoDeactivated } from '@n8n/api-types/push/workflow'; +import { useWorkflowsStore } from '@/app/stores/workflows.store'; +import { useBannersStore } from '@/features/shared/banners/banners.store'; + +export async function workflowAutoDeactivated({ data }: WorkflowAutoDeactivated) { + const workflowsStore = useWorkflowsStore(); + const bannersStore = useBannersStore(); + + workflowsStore.setWorkflowInactive(data.workflowId); + + if (workflowsStore.workflowId === data.workflowId) { + bannersStore.pushBannerToStack('WORKFLOW_AUTO_DEACTIVATED'); + } +} diff --git a/packages/frontend/editor-ui/src/app/composables/usePushConnection/usePushConnection.ts b/packages/frontend/editor-ui/src/app/composables/usePushConnection/usePushConnection.ts index a66726462d4..fa16e656278 100644 --- a/packages/frontend/editor-ui/src/app/composables/usePushConnection/usePushConnection.ts +++ b/packages/frontend/editor-ui/src/app/composables/usePushConnection/usePushConnection.ts @@ -20,6 +20,7 @@ import { executionRecovered, workflowActivated, workflowDeactivated, + workflowAutoDeactivated, } from '@/app/composables/usePushConnection/handlers'; import { injectWorkflowState, type WorkflowState } from '@/app/composables/useWorkflowState'; import { createEventQueue } from '@n8n/utils/event-queue'; @@ -91,6 +92,8 @@ export function usePushConnection({ return await workflowActivated(event); case 'workflowDeactivated': return await workflowDeactivated(event); + case 'workflowAutoDeactivated': + return await workflowAutoDeactivated(event); case 'updateBuilderCredits': return await builderCreditsUpdated(event); } diff --git a/packages/frontend/editor-ui/src/app/composables/useWorkflowSaving.ts b/packages/frontend/editor-ui/src/app/composables/useWorkflowSaving.ts index cccbd425e91..34a4bf732d7 100644 --- a/packages/frontend/editor-ui/src/app/composables/useWorkflowSaving.ts +++ b/packages/frontend/editor-ui/src/app/composables/useWorkflowSaving.ts @@ -146,7 +146,7 @@ export function useWorkflowSaving({ if (missingActivatableTriggerNode) { // Automatically deactivate if all activatable triggers are removed return { - title: i18n.baseText('workflows.deactivated'), + title: i18n.baseText('workflows.autodeactivated'), message: i18n.baseText('workflowActivator.thisWorkflowHasNoTriggerNodes'), type: 'info', }; diff --git a/packages/frontend/editor-ui/src/app/views/NodeView.vue b/packages/frontend/editor-ui/src/app/views/NodeView.vue index 3fe87877844..a0450a55d7c 100644 --- a/packages/frontend/editor-ui/src/app/views/NodeView.vue +++ b/packages/frontend/editor-ui/src/app/views/NodeView.vue @@ -1899,6 +1899,7 @@ watch( } }, ); + onBeforeRouteLeave(async (to, from, next) => { const toNodeViewTab = getNodeViewTab(to); diff --git a/packages/frontend/editor-ui/src/features/shared/banners/banners.store.ts b/packages/frontend/editor-ui/src/features/shared/banners/banners.store.ts index 60e73a575ef..e025c9ef7ad 100644 --- a/packages/frontend/editor-ui/src/features/shared/banners/banners.store.ts +++ b/packages/frontend/editor-ui/src/features/shared/banners/banners.store.ts @@ -127,6 +127,7 @@ export const useBannersStore = defineStore(STORES.BANNERS, () => { dismissBanner, updateBannersHeight, pushBannerToStack, + removeBannerFromStack, clearBannerStack, loadStaticBanners, loadDynamicBanners, diff --git a/packages/frontend/editor-ui/src/features/shared/banners/components/BannerStack.vue b/packages/frontend/editor-ui/src/features/shared/banners/components/BannerStack.vue index 1952639d027..51f534bdc3b 100644 --- a/packages/frontend/editor-ui/src/features/shared/banners/components/BannerStack.vue +++ b/packages/frontend/editor-ui/src/features/shared/banners/components/BannerStack.vue @@ -8,6 +8,7 @@ import V1Banner from './banners/V1Banner.vue'; import EmailConfirmationBanner from './banners/EmailConfirmationBanner.vue'; import DataTableStorageLimitWarningBanner from './banners/DataTableStorageLimitWarningBanner.vue'; import DataTableStorageLimitErrorBanner from './banners/DataTableStorageLimitErrorBanner.vue'; +import WorkflowAutoDeactivatedBanner from './banners/WorkflowAutoDeactivatedBanner.vue'; import type { Component } from 'vue'; import type { N8nBanners } from '../banners.types'; @@ -17,6 +18,10 @@ import type { N8nBanners } from '../banners.types'; // https://www.notion.so/n8n/Banner-stack-60948c4167c743718fde80d6745258d5 export const N8N_BANNERS: N8nBanners = { V1: { priority: 350, component: V1Banner as Component }, + WORKFLOW_AUTO_DEACTIVATED: { + priority: 340, + component: WorkflowAutoDeactivatedBanner as Component, + }, TRIAL_OVER: { priority: 260, component: TrialOverBanner as Component }, EMAIL_CONFIRMATION: { priority: 250, component: EmailConfirmationBanner as Component }, TRIAL: { priority: 150, component: TrialBanner as Component }, diff --git a/packages/frontend/editor-ui/src/features/shared/banners/components/banners/WorkflowAutoDeactivatedBanner.vue b/packages/frontend/editor-ui/src/features/shared/banners/components/banners/WorkflowAutoDeactivatedBanner.vue new file mode 100644 index 00000000000..a0fbc8d6eea --- /dev/null +++ b/packages/frontend/editor-ui/src/features/shared/banners/components/banners/WorkflowAutoDeactivatedBanner.vue @@ -0,0 +1,14 @@ + + +