feat(core): Deactivate crashed workflows (#21888)

Co-authored-by: Artem Sorokin <artem@n8n.io>
This commit is contained in:
Frank Prößdorf
2025-12-01 16:05:28 +02:00
committed by GitHub
co-authored by Artem Sorokin
parent 7186dcfe7e
commit 710a6548d4
24 changed files with 297 additions and 24 deletions
@@ -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);
+9 -1
View File
@@ -20,7 +20,15 @@ export type WorkflowDeactivated = {
};
};
export type WorkflowAutoDeactivated = {
type: 'workflowAutoDeactivated';
data: {
workflowId: string;
};
};
export type WorkflowPushMessage =
| WorkflowActivated
| WorkflowFailedToActivate
| WorkflowDeactivated;
| WorkflowDeactivated
| WorkflowAutoDeactivated;
@@ -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]);
@@ -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<typeof executionModeSchema>;
@@ -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';
@@ -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 = '';
+5
View File
@@ -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,
@@ -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<string> = 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
@@ -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;
@@ -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<Project>({ id: uuid(), type: 'personal' }),
);
ownershipService.getInstanceOwner.mockResolvedValue(mock<User>({ 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();
});
});
});
});
@@ -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<string>) {
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<ExecutionStatus>(['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<User> {
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();
}
}
}
@@ -11,6 +11,7 @@ import assert from 'assert';
@Service()
export class LegacySqliteExecutionRecoveryService {
private readonly logger: Logger;
constructor(
logger: Logger,
private readonly executionRepository: ExecutionRepository,
@@ -0,0 +1,21 @@
<mjml>
<mj-include path="./_common.mjml" />
<mj-body>
<mj-section padding="0 20px">
<mj-column>
<mj-include path="./_logo.mjml" />
<mj-text font-size="22px" font-weight="400">Workflow automatically deactivated</mj-text>
<mj-text
>n8n has automatically deactivated <b>"{{ workflowName }}"</b> due to repeated
crashes.</mj-text
>
<mj-text
>Please review its recent executions and resolve any issues before reactivating
it.</mj-text
>
<mj-button href="{{ workflowUrl }}" background-color="#ff6d5a">View Workflow</mj-button>
<mj-include path="./_footer.mjml" />
</mj-column>
</mj-section>
</mj-body>
</mjml>
@@ -23,6 +23,7 @@ type Template = HandlebarsTemplateDelegate<unknown>;
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<SendEmailResult> {
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,
+1 -13
View File
@@ -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;
}
@@ -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 <a target=\"_blank\" href=\"https://docs.n8n.io/1-0-migration-checklist\">migration guide</a> 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",
@@ -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';
@@ -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');
}
}
@@ -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');
}
}
@@ -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);
}
@@ -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',
};
@@ -1899,6 +1899,7 @@ watch(
}
},
);
onBeforeRouteLeave(async (to, from, next) => {
const toNodeViewTab = getNodeViewTab(to);
@@ -127,6 +127,7 @@ export const useBannersStore = defineStore(STORES.BANNERS, () => {
dismissBanner,
updateBannersHeight,
pushBannerToStack,
removeBannerFromStack,
clearBannerStack,
loadStaticBanners,
loadDynamicBanners,
@@ -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 },
@@ -0,0 +1,14 @@
<script lang="ts" setup>
import BaseBanner from './BaseBanner.vue';
import { i18n as locale } from '@n8n/i18n';
</script>
<template>
<BaseBanner name="WORKFLOW_AUTO_DEACTIVATED" theme="warning" custom-icon="info">
<template #mainContent>
<span>
{{ locale.baseText('banners.workflowAutoDeactivated.message') }}
</span>
</template>
</BaseBanner>
</template>