import { UpdateWorkflowHistoryVersionDto } from '@n8n/api-types'; import { LicenseState, Logger } from '@n8n/backend-common'; import { GlobalConfig } from '@n8n/config'; import type { User, ListQueryDb, WorkflowFolderUnionFull, WorkflowHistory } from '@n8n/db'; import { SharedWorkflow, WorkflowEntity, FolderRepository, WorkflowTagMappingRepository, SharedWorkflowRepository, WorkflowRepository, WorkflowPublishHistoryRepository, WorkflowPublicationOutboxRepository, WorkflowPublicationReason, WorkflowPublishedVersionRepository, ProjectRepository, } from '@n8n/db'; import { Container, Service } from '@n8n/di'; import type { Scope } from '@n8n/permissions'; import { hasGlobalScope } from '@n8n/permissions'; import type { EntityManager } from '@n8n/typeorm'; import { In, QueryFailedError } from '@n8n/typeorm'; import type { QueryDeepPartialEntity } from '@n8n/typeorm/query-builder/QueryPartialEntity'; import { ensureError } from '@n8n/utils/errors/ensure-error'; import isEqual from 'lodash/isEqual'; import pick from 'lodash/pick'; import type { INode, INodes, IWorkflowSettings, JsonValue, IConnections } from 'n8n-workflow'; import { PROJECT_ROOT, Workflow, assert, calculateWorkflowChecksum } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; import { WorkflowPublicationNotifier } from './publication/workflow-publication-notifier'; import { getEnabledTriggerNodes } from './triggers/enabled-trigger-nodes'; import { getErrorDescription, getErrorNodeId, getRequiredRedactionScopes } from './utils'; import { WorkflowFinderService } from './workflow-finder.service'; import { WorkflowHistoryService } from './workflow-history/workflow-history.service'; import { WorkflowMutationHooksProxy } from './workflow-mutation-hooks-proxy.service'; import { WorkflowPublishGuardProxy } from './workflow-publish-guard-proxy.service'; import { WorkflowValidationService } from './workflow-validation.service'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; import { FolderNotFoundError } from '@/errors/folder-not-found.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ConflictError } from '@/errors/response-errors/conflict.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { WorkflowActivationBadRequestError } from '@/errors/response-errors/workflow-activation-bad-request.error'; import { WorkflowDeactivationBadRequestError } from '@/errors/response-errors/workflow-deactivation-bad-request.error'; import { WorkflowValidationError } from '@/errors/response-errors/workflow-validation.error'; import { WorkflowHistoryVersionNotFoundError } from '@/errors/workflow-history-version-not-found.error'; import { EventService } from '@/events/event.service'; import type { WorkflowActionSource } from '@/events/maps/relay.event-map'; import { ExecutionPersistence } from '@/executions/execution-persistence'; import { ExternalHooks, toWorkflowLifecycleHookActor } from '@/external-hooks'; import { validateEntity } from '@/generic-helpers'; import { RedactionEnforcementService } from '@/modules/redaction/redaction-enforcement.service'; import { NodeTypes } from '@/node-types'; import { userHasScopes } from '@/permissions.ee/check-access'; import { PolicyEnforcementService } from '@/policy/policy-enforcement.service'; import type { ListQuery } from '@/requests'; import { hasSharing } from '@/requests'; import { PollTriggerJobRegistrar } from '@/scheduling/poll-trigger-node/poll-trigger-job-registrar'; import { ScheduleTriggerJobRegistrar } from '@/scheduling/schedule-trigger-node/schedule-trigger-job-registrar'; import { OwnershipService } from '@/services/ownership.service'; import { ProjectService } from '@/services/project.service.ee'; import { RoleService } from '@/services/role.service'; import { TagService } from '@/services/tag.service'; import { WEBHOOK_CONFLICT_MESSAGE } from '@/webhooks/constants'; import { WebhookService } from '@/webhooks/webhook.service'; import { getBase as getWorkflowExecutionData } from '@/workflow-execute-additional-data'; import * as WorkflowHelpers from '@/workflow-helpers'; import { WorkflowHookContextService } from '@/workflow-hook-context.service'; /** Internal rollback vehicle for `publishAsSystem`'s guarded transaction; never escapes it. */ class SystemPublishSupersededError extends Error {} @Service() export class WorkflowService { constructor( private readonly logger: Logger, private readonly sharedWorkflowRepository: SharedWorkflowRepository, private readonly workflowRepository: WorkflowRepository, private readonly workflowTagMappingRepository: WorkflowTagMappingRepository, private readonly ownershipService: OwnershipService, private readonly tagService: TagService, private readonly workflowHistoryService: WorkflowHistoryService, private readonly externalHooks: ExternalHooks, private readonly activeWorkflowManager: ActiveWorkflowManager, private readonly roleService: RoleService, private readonly projectService: ProjectService, private readonly executionPersistence: ExecutionPersistence, private readonly eventService: EventService, private readonly globalConfig: GlobalConfig, private readonly folderRepository: FolderRepository, private readonly workflowFinderService: WorkflowFinderService, private readonly workflowPublishHistoryRepository: WorkflowPublishHistoryRepository, private readonly outboxRepository: WorkflowPublicationOutboxRepository, private readonly workflowValidationService: WorkflowValidationService, private readonly nodeTypes: NodeTypes, private readonly webhookService: WebhookService, private readonly licenseState: LicenseState, private readonly projectRepository: ProjectRepository, private readonly redactionEnforcementService: RedactionEnforcementService, private readonly workflowPublicationNotifier: WorkflowPublicationNotifier, private readonly scheduleTriggerJobRegistrar: ScheduleTriggerJobRegistrar, private readonly pollTriggerJobRegistrar: PollTriggerJobRegistrar, private readonly workflowPublishedVersionRepository: WorkflowPublishedVersionRepository, private readonly workflowHookContextService: WorkflowHookContextService, private readonly workflowPublishGuard: WorkflowPublishGuardProxy, private readonly workflowMutationHooks: WorkflowMutationHooksProxy, private readonly policyEnforcementService: PolicyEnforcementService, ) {} async getMany( user: User, options?: ListQuery.Options, includeScopes?: boolean, includeFolders?: boolean, onlySharedWithMe?: boolean, requiredScopes: Scope[] = ['workflow:read'], ) { let count; let workflows; let workflowsAndFolders: WorkflowFolderUnionFull[] = []; let isPersonalProject = false; let personalProjectOwnerId: string | null = null; if (options?.filter?.projectId) { const project = await this.projectRepository.findOneBy({ id: options.filter.projectId as string, }); if (!project) { return { workflows: [], count: 0 }; } isPersonalProject = project.type === 'personal'; personalProjectOwnerId = project.creatorId; } // Prepare sharing options for the subquery const sharingOptions: { scopes?: Scope[]; projectRoles?: string[]; workflowRoles?: string[]; isPersonalProject?: boolean; personalProjectOwnerId?: string; onlySharedWithMe?: boolean; } = {}; if (isPersonalProject && personalProjectOwnerId) { if (personalProjectOwnerId !== user.id && !hasGlobalScope(user, 'workflow:read')) { return { workflows: [], count: 0 }; } sharingOptions.isPersonalProject = true; sharingOptions.personalProjectOwnerId = personalProjectOwnerId; } else if (onlySharedWithMe) { sharingOptions.onlySharedWithMe = true; } else { // Get roles from scopes const projectRoles = await this.roleService.rolesWithScope('project', requiredScopes); const workflowRoles = await this.roleService.rolesWithScope('workflow', requiredScopes); sharingOptions.scopes = requiredScopes; sharingOptions.projectRoles = projectRoles; sharingOptions.workflowRoles = workflowRoles; } const callableForParentWorkflowId = await this.resolveCallableForParentWorkflowId( user, options, ); // Use the new subquery-based repository methods if (includeFolders) { [workflowsAndFolders, count] = await this.workflowRepository.getWorkflowsAndFoldersWithCountWithSharingSubquery( user, sharingOptions, options, callableForParentWorkflowId, ); workflows = workflowsAndFolders.filter((wf) => wf.resource === 'workflow'); } else { ({ workflows, count } = await this.workflowRepository.getManyAndCountWithSharingSubquery( user, sharingOptions, options, callableForParentWorkflowId, )); } /* Since we're filtering using project ID as part of the relation, we end up filtering out all the other relations, meaning that if it's shared to a project, it won't be able to find the home project. To solve this, we have to get all the relation now, even though we're deleting them later. */ if (hasSharing(workflows)) { workflows = await this.processSharedWorkflows(workflows, options); } if (includeScopes) { workflows = await this.addUserScopes(workflows, user); } this.cleanupSharedField(workflows); if (includeFolders) { workflows = this.mergeProcessedWorkflows(workflowsAndFolders, workflows); } // Add hasResolvableCredentials if dynamic credentials feature is licensed if (this.licenseState.isDynamicCredentialsLicensed()) { return { workflows: await this.addResolvableCredentialsFlag(workflows), count, }; } return { workflows, count, }; } private async resolveCallableForParentWorkflowId( user: User, options?: ListQuery.Options, ): Promise { if (options?.filter?.includeCallableSubworkflows !== true) return undefined; const parentWorkflowId = typeof options.filter.parentWorkflowId === 'string' ? options.filter.parentWorkflowId : undefined; if (!parentWorkflowId) return undefined; const parentWorkflow = await this.workflowFinderService.findWorkflowForUser( parentWorkflowId, user, ['workflow:read'], ); return parentWorkflow ? parentWorkflowId : undefined; } private async addResolvableCredentialsFlag< T extends ListQueryDb.Workflow.Plain | ListQueryDb.Workflow.WithSharing, >(workflows: T[]): Promise> { // Use lazy import to avoid circular dependency const { EnterpriseWorkflowService } = await import('./workflow.service.ee.js'); const enterpriseWorkflowService = Container.get(EnterpriseWorkflowService); const workflowIds = workflows.map((w) => w.id); const workflowIdsWithResolvable = await enterpriseWorkflowService.getWorkflowIdsWithResolvableCredentials(workflowIds); return workflows.map((workflow) => ({ ...workflow, hasResolvableCredentials: workflowIdsWithResolvable.has(workflow.id), })); } private async processSharedWorkflows( workflows: ListQueryDb.Workflow.WithSharing[], options?: ListQuery.Options, ) { const projectId = options?.filter?.projectId; const shouldAddProjectRelations = typeof projectId === 'string' && projectId !== ''; if (shouldAddProjectRelations) { await this.addSharedRelation(workflows); } return workflows.map((workflow) => this.ownershipService.addOwnedByAndSharedWith(workflow)); } private async addSharedRelation(workflows: ListQueryDb.Workflow.WithSharing[]): Promise { const workflowIds = workflows.map((workflow) => workflow.id); const relations = await this.sharedWorkflowRepository.getAllRelationsForWorkflows(workflowIds); workflows.forEach((workflow) => { workflow.shared = relations.filter((relation) => relation.workflowId === workflow.id); }); } private async addUserScopes( workflows: ListQueryDb.Workflow.Plain[] | ListQueryDb.Workflow.WithSharing[], user: User, ) { const projectRelations = await this.projectService.getProjectRelationsForUser(user); return workflows.map((workflow) => this.roleService.addScopes(workflow, user, projectRelations), ); } private isWorkflowWithSharing( workflow: ListQueryDb.Workflow.Plain, ): workflow is ListQueryDb.Workflow.WithSharing { return 'shared' in workflow; } private cleanupSharedField( workflows: ListQueryDb.Workflow.Plain[] | ListQueryDb.Workflow.WithSharing[], ): void { /* This is to emulate the old behavior of removing the shared field as part of `addOwnedByAndSharedWith`. We need this field in `addScopes` though. So to avoid leaking the information we just delete it. */ workflows.forEach((workflow) => { if (this.isWorkflowWithSharing(workflow)) { delete workflow.shared; } }); } private mergeProcessedWorkflows( workflowsAndFolders: WorkflowFolderUnionFull[], processedWorkflows: ListQueryDb.Workflow.Plain[] | ListQueryDb.Workflow.WithSharing[], ) { const workflowMap = new Map(processedWorkflows.map((workflow) => [workflow.id, workflow])); return workflowsAndFolders.map((item) => item.resource === 'workflow' ? (workflowMap.get(item.id) ?? item) : item, ); } /** * Updates the workflow content (such as name, nodes, connections, settings, etc.). * * This method never updates the workflow's active fields (active, activeVersionId) directly. * However, if settings change and the workflow has an active version, the workflow will be * automatically reactivated to ensure the ActiveWorkflowManager uses the updated settings. * For explicit activation or deactivation, use the activate/deactivate methods. */ // eslint-disable-next-line complexity async update( user: User, workflowUpdateData: WorkflowEntity, workflowId: string, options: { tagIds?: string[]; parentFolderId?: string; forceSave?: boolean; publicApi?: boolean; publishIfActive?: boolean; aiBuilderAssisted?: boolean; expectedChecksum?: string; autosaved?: boolean; source?: WorkflowActionSource; versionName?: string; versionDescription?: string; } = {}, ): Promise { const { expectedChecksum, tagIds, parentFolderId, forceSave = false, publicApi = false, publishIfActive = false, aiBuilderAssisted = false, autosaved = false, source = 'ui', versionName, versionDescription, } = options; const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [ 'workflow:update', ]); if (!workflow) { this.logger.warn('User attempted to update a workflow without permissions', { workflowId, userId: user.id, }); throw new NotFoundError( 'You do not have permission to update this workflow. Ask the owner to share it with you.', ); } if (workflow.isArchived) { throw new BadRequestError('Cannot update an archived workflow.'); } await this.redactionEnforcementService.assertPolicyChangeAllowed( workflow.settings?.redactionPolicy, workflowUpdateData.settings?.redactionPolicy, ); if (!forceSave && expectedChecksum) { await this._detectConflicts(workflow, expectedChecksum); } // check credentials for old format - scope to the workflow's owner project const ownerProject = await this.ownershipService.getWorkflowProjectCached(workflowId); await WorkflowHelpers.replaceInvalidCredentials(workflowUpdateData, ownerProject.id); // Central credential guard for every workflow write path. With sharing // enabled, reject new nodes that reference credentials the acting user // cannot access and revert edits to existing read-only credential nodes. // Runs after replaceInvalidCredentials so old-format/name references are // already resolved to IDs before the check. // Loaded lazily to avoid a circular import (workflow.service.ee pulls in // folder/project services which import this module). if (this.licenseState.isSharingLicensed()) { const { EnterpriseWorkflowService } = await import('./workflow.service.ee.js'); await Container.get(EnterpriseWorkflowService).preventTampering( workflowUpdateData, workflowId, user, ); } // Update the workflow's version when changing nodes, connections, or nodeGroups const hasNodesKey = 'nodes' in workflowUpdateData; const hasConnectionsKey = 'connections' in workflowUpdateData; const hasNodeGroupsKey = 'nodeGroups' in workflowUpdateData; const nodesChanged = hasNodesKey && !isEqual(workflowUpdateData.nodes, workflow.nodes); const connectionsChanged = hasConnectionsKey && !isEqual(workflowUpdateData.connections, workflow.connections); const nodeGroupsChanged = hasNodeGroupsKey && !isEqual(workflowUpdateData.nodeGroups, workflow.nodeGroups); const saveNewVersion = nodesChanged || connectionsChanged || nodeGroupsChanged; if (saveNewVersion) { workflowUpdateData.versionId = uuid(); this.logger.debug( `Updating versionId for workflow ${workflowId} for user ${user.id} after saving`, { previousVersionId: workflow.versionId, newVersionId: workflowUpdateData.versionId, }, ); // A saved version needs nodes, connections, and node groups; backfill any the update // omitted from the persisted workflow so the history row records the effective state. workflowUpdateData.nodes = workflowUpdateData.nodes ?? workflow.nodes; workflowUpdateData.connections = workflowUpdateData.connections ?? workflow.connections; workflowUpdateData.nodeGroups = workflowUpdateData.nodeGroups ?? workflow.nodeGroups; } else { // Do not let users change versionId directly workflowUpdateData.versionId = workflow.versionId; } WorkflowHelpers.addNodeIds(workflowUpdateData); WorkflowHelpers.resolveNodeWebhookIds(workflowUpdateData, this.nodeTypes); // Validate structure and node groups only for structural changes; a metadata-only edit // re-persists an already-validated graph, so re-checking is redundant and could block on // legacy data. Both are safe to read off workflowUpdateData: it was backfilled above. if (saveNewVersion) { WorkflowHelpers.validateWorkflowStructure({ nodes: workflowUpdateData.nodes, connections: workflowUpdateData.connections, }); WorkflowHelpers.validateWorkflowNodeGroups( { nodes: workflowUpdateData.nodes, nodeGroups: workflowUpdateData.nodeGroups, connections: workflowUpdateData.connections, }, WorkflowHelpers.makeGetNodeTypeForGrouping(this.nodeTypes), ); } // Strip redactionPolicy if instance lacks data-redaction license if ( workflowUpdateData.settings?.redactionPolicy !== undefined && workflowUpdateData.settings.redactionPolicy !== workflow.settings?.redactionPolicy && !this.licenseState.isDataRedactionLicensed() ) { delete workflowUpdateData.settings.redactionPolicy; } // Strip redactionPolicy if user lacks the required directional scope if ( workflowUpdateData.settings?.redactionPolicy !== undefined && workflowUpdateData.settings.redactionPolicy !== workflow.settings?.redactionPolicy ) { const requiredScopes = getRequiredRedactionScopes( workflow.settings?.redactionPolicy, workflowUpdateData.settings.redactionPolicy, ); const canUpdate = await userHasScopes(user, requiredScopes, false, { projectId: ownerProject.id, }); if (!canUpdate) { delete workflowUpdateData.settings.redactionPolicy; } } // Merge settings to support partial updates if (workflowUpdateData.settings && workflow.settings) { workflowUpdateData.settings = { ...workflow.settings, ...workflowUpdateData.settings, }; } if (workflowUpdateData.settings) { workflowUpdateData.settings = WorkflowHelpers.removeDefaultValues( workflowUpdateData.settings, this.globalConfig.executions.timeout, ); } // Check if settings actually changed const settingsChanged = workflowUpdateData.settings !== undefined && !isEqual(workflow.settings, workflowUpdateData.settings); // Always set updatedAt to get millisecond precision workflowUpdateData.updatedAt = new Date(); if (workflowUpdateData.name) { await validateEntity(workflowUpdateData); } // Validate pinData size after all mutations are applied if ('pinData' in workflowUpdateData) { WorkflowHelpers.validatePinDataSize({ ...workflow, ...workflowUpdateData }); } // Reject illegal credential-to-node bindings before persisting const restrictionValidation = this.workflowValidationService.validateCredentialNodeRestrictions( workflowUpdateData.nodes ?? workflow.nodes, ); if (!restrictionValidation.isValid) { throw new WorkflowValidationError( restrictionValidation.error ?? 'Credential binding is not allowed.', ); } // Run external hook after all validation has passed, right before persisting await this.externalHooks.run('workflow.update', [ workflowUpdateData, this.workflowHookContextService, toWorkflowLifecycleHookActor(user), ]); // Gate the save on policy before persisting, so the author learns about a violation // while editing rather than at runtime. Carries the stored workflow alongside the // submitted one so a check can restrict its verdict to what this save adds. await this.policyEnforcementService.enforceWorkflowSave({ workflow: { id: workflow.id, name: workflowUpdateData.name ?? workflow.name, nodes: workflowUpdateData.nodes ?? workflow.nodes, }, storedWorkflow: { id: workflow.id, name: workflow.name, nodes: workflow.nodes }, projectId: ownerProject.id, }); const fieldsToUpdate = [ 'name', 'nodes', 'connections', 'nodeGroups', 'meta', 'settings', 'staticData', 'pinData', 'versionId', 'description', 'updatedAt', // do not update active fields ]; const updatePayload = pick( workflowUpdateData, fieldsToUpdate, ) as QueryDeepPartialEntity; // Save the workflow to history first, so we can retrieve the complete version object for the update if (saveNewVersion) { await this.workflowHistoryService.saveVersion( user, workflowUpdateData, workflowId, autosaved, source, undefined, versionName || versionDescription ? { name: versionName, description: versionDescription } : undefined, ); } const versionIdToPublish = workflow.activeVersionId && publishIfActive ? workflowUpdateData.versionId : null; if (parentFolderId) { const project = await this.sharedWorkflowRepository.getWorkflowOwningProject(workflow.id); if (parentFolderId !== PROJECT_ROOT) { try { await this.folderRepository.findOneOrFailFolderInProject( parentFolderId, project?.id ?? '', ); } catch (e) { throw new FolderNotFoundError(parentFolderId); } } updatePayload.parentFolder = parentFolderId === PROJECT_ROOT ? null : { id: parentFolderId }; } await this.workflowRepository.update(workflowId, updatePayload); const tagsDisabled = this.globalConfig.tags.disabled; if (tagIds && !tagsDisabled) { await this.workflowTagMappingRepository.overwriteTaggings(workflowId, tagIds); } const relations = tagsDisabled ? ['activeVersion'] : ['tags', 'activeVersion']; // We sadly get nothing back from "update". Neither if it updated a record // nor the new value. So query now the hopefully updated entry. const updatedWorkflow = await this.workflowRepository.findOne({ where: { id: workflowId }, relations, }); if (updatedWorkflow === null) { throw new BadRequestError( `Workflow with ID "${workflowId}" could not be found to be updated.`, ); } if (updatedWorkflow.tags?.length && tagIds?.length) { updatedWorkflow.tags = this.tagService.sortByRequestOrder(updatedWorkflow.tags, { requestOrder: tagIds, }); } await this.externalHooks.run('workflow.afterUpdate', [ updatedWorkflow, this.workflowHookContextService, toWorkflowLifecycleHookActor(user), ]); const settingsChangesDetail = this.calculateSettingsChanges( workflow.settings, updatedWorkflow.settings, ); this.eventService.emit('workflow-saved', { user, workflow: updatedWorkflow, publicApi, previousWorkflow: workflow, aiBuilderAssisted, ...(settingsChangesDetail && { settingsChanged: settingsChangesDetail }), source, }); if (versionIdToPublish) { const publishedWorkflow = await this.activateWorkflow(user, workflowId, { versionId: versionIdToPublish, source, }); updatedWorkflow.active = publishedWorkflow.active; updatedWorkflow.activeVersionId = publishedWorkflow.activeVersionId; updatedWorkflow.activeVersion = publishedWorkflow.activeVersion; } else if (settingsChanged && workflow.activeVersionId) { await this.activateWorkflow(user, workflowId, { versionId: workflow.activeVersionId, source, }); } return updatedWorkflow; } private async _addToActiveWorkflowManager( user: User, workflowId: string, workflow: WorkflowEntity, mode: 'activate' | 'update', tracking: { source: WorkflowActionSource } = { source: 'ui' }, ): Promise { let didPublish = false; try { await this.activeWorkflowManager.add(workflowId, mode); didPublish = true; } catch (error) { // Activation failed partway through. It may already have registered triggers // e.g. a Schedule Trigger before throwing; this ensures they get deregistered, // which otherwise may cause them to start unintended executions. // Done before the rollback below so the active version is still // resolvable by `clearWebhooks`. try { await this.activeWorkflowManager.remove(workflowId); this.logger.warn( `Rolled back partial activation of workflow "${workflowId}"; triggers deregistered`, { workflowId }, ); } catch (cleanupError) { this.logger.error(`Failed to roll back partial activation of workflow "${workflowId}"`, { workflowId, error: cleanupError, }); } const rollbackPayload = { active: false, activeVersionId: null, activeVersion: null, }; await this.workflowRepository.update(workflowId, rollbackPayload); // Also set it in the returned data workflow.active = rollbackPayload.active; workflow.activeVersionId = rollbackPayload.activeVersionId; workflow.activeVersion = rollbackPayload.activeVersion; const message = (error as Error).message; const description = getErrorDescription(error); throw new WorkflowActivationBadRequestError(message, { nodeId: getErrorNodeId(error), description, }); } finally { if (didPublish) { assert(workflow.activeVersionId !== null); await this.workflowPublishHistoryRepository.addRecord({ workflowId, versionId: workflow.activeVersionId, event: 'activated', userId: user.id, }); this.eventService.emit('workflow-activated', { user, workflowId, workflow, publicApi: tracking.source === 'api', source: tracking.source, }); } } } private async _findConflictingWebhooks( workflowEntity: WorkflowEntity, versionToActivate: WorkflowHistory, ) { const workflow = new Workflow({ id: workflowEntity.id, nodes: versionToActivate.nodes, connections: versionToActivate.connections, active: !!workflowEntity.activeVersion, settings: workflowEntity.settings, nodeTypes: this.nodeTypes, }); const additionalData = await getWorkflowExecutionData({ workflowId: workflow.id, }); await workflow.expression.acquireIsolate(); try { return await this.webhookService.findWebhookConflicts(workflow, additionalData); } finally { await workflow.expression.releaseIsolate(); } } private async _detectWebhookConflicts( workflowEntity: WorkflowEntity, versionToActivate: WorkflowHistory, ) { const conflicts = await this._findConflictingWebhooks(workflowEntity, versionToActivate); if (conflicts.length > 0) { throw new ConflictError( WEBHOOK_CONFLICT_MESSAGE, JSON.stringify( conflicts.map(({ trigger, conflict }) => ({ trigger, conflict, })), ), ); } } /** * Activates a workflow by setting its activeVersionId and adding it to the active workflow manager. * @param user - The user activating the workflow * @param workflowId - The ID of the workflow to activate * @param options - Optional versionId, name and description updates * @param publicApi - Whether this is called from the public API (affects event emission) * @returns The activated workflow */ // eslint-disable-next-line complexity async activateWorkflow( user: User, workflowId: string, options?: { versionId?: string; name?: string; description?: string; expectedChecksum?: string; source?: WorkflowActionSource; }, ): Promise { const source = options?.source ?? 'ui'; const publicApi = source === 'api'; const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [ 'workflow:publish', ]); if (!workflow) { this.logger.warn('User attempted to activate a workflow without permissions', { workflowId, userId: user.id, }); throw new NotFoundError( 'You do not have permission to activate this workflow. Ask the owner to share it with you.', ); } if (workflow.isArchived) { throw new BadRequestError('Cannot activate an archived workflow.'); } const versionIdToActivate = options?.versionId ?? workflow.versionId; const previousActiveVersionId = workflow.activeVersionId; let versionToActivate: WorkflowHistory; try { versionToActivate = await this.workflowHistoryService.getVersion( user, workflow.id, versionIdToActivate, { includePublishHistory: false, }, ); } catch (error) { if (error instanceof WorkflowHistoryVersionNotFoundError) { throw new NotFoundError('Version not found'); } throw error; } if (options?.expectedChecksum) { await this._detectConflicts(workflow, options.expectedChecksum); } await this._detectWebhookConflicts(workflow, versionToActivate); this._validateNodes(workflowId, versionToActivate.nodes, versionToActivate.connections); await this._validateDynamicCredentials(workflowId, versionToActivate.nodes, workflow.settings); await this._validateSubWorkflowReferences(workflowId, versionToActivate.nodes); if (this.globalConfig.workflows.useWorkflowPublicationService) { this._validateTriggerNodeIds(workflowId, versionToActivate); } // Run hook before destructive state changes so a rejection leaves // the previous active version running instead of deactivating it. const candidateWorkflow = this.workflowRepository.create({ ...workflow, active: true, activeVersionId: versionIdToActivate, activeVersion: versionToActivate, nodes: versionToActivate.nodes, connections: versionToActivate.connections, }); try { await this.externalHooks.run('workflow.activate', [ candidateWorkflow, this.workflowHookContextService, toWorkflowLifecycleHookActor(user), ]); } catch (error) { throw new WorkflowActivationBadRequestError(ensureError(error).message, { nodeId: getErrorNodeId(error), description: getErrorDescription(error), }); } // re-applying the already-published version (e.g. a settings-only update) // publishes no new version, so the review gate must not block it. // // This check is deliberately not serialized with review mutations: putting // publishing behind the review feature's global lock would slow down a core // workflow operation for every instance. A review opened just after this // passes, or just before an approval's auto-publish reaches it, therefore // races — accepted, because both outcomes degrade gracefully (the approval // stands and auto-publish reports `failed`).`. if (versionIdToActivate !== previousActiveVersionId) { await this.workflowPublishGuard.assertCanPublish(workflowId); } if (this.globalConfig.workflows.useWorkflowPublicationService) { await this._publishViaOutbox( user.id, workflowId, versionIdToActivate, previousActiveVersionId, workflow.updatedAt, ); if (previousActiveVersionId) { this.eventService.emit('workflow-deactivated', { user, workflowId, workflow, publicApi, deactivatedVersionId: previousActiveVersionId, source, }); } const activatedWorkflow = this.workflowRepository.create({ ...workflow, active: true, activeVersionId: versionIdToActivate, activeVersion: versionToActivate, nodes: versionToActivate.nodes, connections: versionToActivate.connections, }); this.eventService.emit('workflow-activated', { user, workflowId, workflow: activatedWorkflow, publicApi, source, }); } else { if (previousActiveVersionId) { await this.activeWorkflowManager.remove(workflowId); this.eventService.emit('workflow-deactivated', { user, workflowId, workflow, publicApi, deactivatedVersionId: previousActiveVersionId, source, }); await this.workflowPublishHistoryRepository.addRecord({ workflowId, versionId: previousActiveVersionId, event: 'deactivated', userId: user.id, }); } const activationMode = previousActiveVersionId ? 'update' : 'activate'; await this.workflowRepository.update(workflowId, { activeVersionId: versionIdToActivate, active: true, // workflow content did not change, so we keep updatedAt as is updatedAt: workflow.updatedAt, }); const workflowForActivation = await this.workflowRepository.findOne({ where: { id: workflowId }, relations: ['activeVersion'], }); if (!workflowForActivation) { throw new NotFoundError(`Workflow with ID "${workflowId}" could not be found.`); } await this._addToActiveWorkflowManager( user, workflowId, workflowForActivation, activationMode, { source }, ); } // The publication commit boundary: both branches above have durably published the // version (outbox record committed / triggers registered and history recorded). The // metadata update and re-fetch below can still fail, but the publication — and this // record of it — stand. The hook must not throw. await this.workflowMutationHooks.afterWorkflowPublished({ workflowId, versionId: versionIdToActivate, userId: user.id, }); if (options?.name !== undefined || options?.description !== undefined) { const updateFields: UpdateWorkflowHistoryVersionDto = {}; if (options.name !== undefined) updateFields.name = options.name; if (options.description !== undefined) updateFields.description = options.description; await this.workflowHistoryService.updateVersion( workflowId, versionIdToActivate, updateFields, ); } // Fetch workflow again with workflowPublishHistory after activation to include the new entry const updatedWorkflow = await this.workflowRepository.findOne({ where: { id: workflowId }, relations: { activeVersion: { workflowPublishHistory: true, }, }, }); if (!updatedWorkflow) { throw new NotFoundError(`Workflow with ID "${workflowId}" could not be found.`); } return updatedWorkflow; } /** * Deactivates a workflow by removing it from the active workflow manager and setting activeVersionId to null. * @param user - The user deactivating the workflow * @param workflowId - The ID of the workflow to deactivate * @param options - Optional settings including expectedChecksum for conflict detection and publicApi flag * @returns The deactivated workflow */ async deactivateWorkflow( user: User, workflowId: string, options?: { expectedChecksum?: string; source?: WorkflowActionSource; }, ): Promise { const source = options?.source ?? 'ui'; const publicApi = source === 'api'; const workflow = await this.workflowFinderService.findWorkflowForUser( workflowId, user, ['workflow:unpublish'], { includeActiveVersion: true }, ); if (!workflow) { this.logger.warn('User attempted to deactivate a workflow without permissions', { workflowId, userId: user.id, }); throw new NotFoundError( 'You do not have permission to deactivate this workflow. Ask the owner to share it with you.', ); } const deactivatedVersionId = workflow.activeVersionId; if (deactivatedVersionId === null) { return workflow; } if (options?.expectedChecksum) { await this._detectConflicts(workflow, options.expectedChecksum); } // `active` is still true here: the hook sees the pre-deactivation state so it can veto. const deactivatedWorkflow = this.workflowRepository.create({ ...workflow, versionId: deactivatedVersionId, activeVersion: null, nodes: workflow.activeVersion?.nodes ?? workflow.nodes, connections: workflow.activeVersion?.connections ?? workflow.connections, }); try { await this.externalHooks.run('workflow.deactivate', [ deactivatedWorkflow, this.workflowHookContextService, toWorkflowLifecycleHookActor(user), ]); } catch (error) { throw new WorkflowDeactivationBadRequestError(ensureError(error).message, { description: getErrorDescription(error), }); } await this._teardownActiveVersion(workflow, deactivatedVersionId, user.id); // Update the workflow object for response workflow.active = false; workflow.activeVersionId = null; workflow.activeVersion = null; this.eventService.emit('workflow-deactivated', { user, workflowId, workflow, publicApi, deactivatedVersionId, source, }); return workflow; } /** * Deactivates a workflow without a user context (system-initiated, e.g. * crash-loop auto-deactivation). Skips permission and checksum checks and * does not emit `workflow-deactivated`; publish history records a null user. */ async deactivateWorkflowAsSystem(workflowId: string): Promise { const workflow = await this.workflowRepository.findOne({ where: { id: workflowId }, relations: { activeVersion: true }, }); if (!workflow) return; const deactivatedVersionId = workflow.activeVersionId; if (deactivatedVersionId === null) return; // `active` is still true here: the hook sees the pre-deactivation state. const deactivatedWorkflow = this.workflowRepository.create({ ...workflow, versionId: deactivatedVersionId, activeVersion: null, nodes: workflow.activeVersion?.nodes ?? workflow.nodes, connections: workflow.activeVersion?.connections ?? workflow.connections, }); try { await this.externalHooks.run('workflow.deactivate', [ deactivatedWorkflow, this.workflowHookContextService, ]); } catch (error) { // A failing hook must not leave a crash-looping workflow published this.logger.warn('workflow.deactivate hook failed during system deactivation, proceeding', { workflowId, error: ensureError(error).message, }); } await this._teardownActiveVersion(workflow, deactivatedVersionId, null); } /** * Publishes a new system-authored version of an already-active workflow, * without a user: inserts the `workflow_history` row (author `'n8n'`), * advances `activeVersionId`, records publish history with a null user, and * enqueues the outbox record. The draft plane — `workflow_entity.nodes`, * `versionId`, `updatedAt` — stays untouched. * * Publication-service plane only; the caller owns flag and leader concerns. * Skips user-facing activation validation and lifecycle hooks: the workflow * is already active, and the content comes from internal correction code, * which may legitimately fail user-facing checks (e.g. duplicate node * names). * * The write only lands if the active version is still * `expectedActiveVersionId` — the version the corrected copy was derived * from, supplied by the caller so the guard spans the caller's whole * read-correct-publish window, not just this method's own read * (compare-and-swap). Any move past that baseline — a newer user publish, * an unpublish, a deletion — wins, and this method returns * `{ published: false, reason: 'superseded' }` having written nothing: the * version row, publish history, and outbox record all live in one * transaction that a guard miss rolls back. The caller must not retry — * whatever superseded the baseline enqueued its own outbox record, and the * next activation pass covers it. Two accepted residuals: a concurrent draft save's * `updatedAt` bump can be rolled back to the value read here (the guard * deliberately excludes `updatedAt` — a datetime-equality quirk would make * the heal silently never land, a worse failure direction than a bounded * timestamp clobber; draft content is untouched either way), and the *user* * path's unguarded update can still record a stale `deactivated` version * when the user wins the gap — a pre-existing property of * `activateWorkflow`, not of this method. */ async publishAsSystem( workflowId: string, versionData: { nodes: WorkflowEntity['nodes']; connections: WorkflowEntity['connections']; nodeGroups?: WorkflowEntity['nodeGroups']; }, expectedActiveVersionId: string, ): Promise<{ published: true; versionId: string } | { published: false; reason: 'superseded' }> { const workflow = await this.workflowRepository.findOne({ where: { id: workflowId } }); // A missing or inactive workflow, or an active version that already moved // past the caller's baseline, is the same benign race as losing the guard // below — whatever moved the version owns the state now. The transaction // guard re-checks the same condition atomically; this early return just // skips the doomed version-row insert. if ( !workflow?.active || workflow.activeVersionId === null || workflow.activeVersionId !== expectedActiveVersionId ) { return { published: false, reason: 'superseded' }; } const versionId = uuid(); try { await this.workflowRepository.manager.transaction(async (trx) => { // The version row must precede the guarded update: `activeVersionId`'s // foreign key requires it. `saveVersion` swallows insert errors; a // missing row surfaces as a foreign-key violation on the update, which // rolls the transaction back. await this.workflowHistoryService.saveVersion( 'n8n', { versionId, ...versionData }, workflowId, false, undefined, trx, ); // Re-publication of the same version id in the gap (unpublish + // publish of the version read above) passes the guard; same id means // same content, so the heal is still a valid heal of the current // active version. const recorded = await this._recordPublishInTransaction( trx, null, workflowId, versionId, expectedActiveVersionId, workflow.updatedAt, { onlyIfActiveVersionIs: expectedActiveVersionId }, ); // Roll back the version row too — a lost race must leave no trace. if (!recorded) throw new SystemPublishSupersededError(); }); } catch (error) { if (error instanceof SystemPublishSupersededError) { return { published: false, reason: 'superseded' }; } throw error; } // Wake the leader now that the record is committed, so it drains without // waiting for the next poll cycle. this.workflowPublicationNotifier.requestDrain(); return { published: true, versionId }; } /** * Flag-branched teardown shared by user- and system-initiated deactivation. * Keeping it in one place prevents the two paths from drifting apart, which * is how system deactivation ended up skipping unpublishing (CAT-3814). */ private async _teardownActiveVersion( workflow: WorkflowEntity, deactivatedVersionId: string, userId: string | null, ): Promise { const workflowId = workflow.id; if (this.globalConfig.workflows.useWorkflowPublicationService) { await this._unpublishViaOutbox(userId, workflowId, deactivatedVersionId, workflow.updatedAt); } else { await this.activeWorkflowManager.remove(workflowId); await this.workflowRepository.update(workflowId, { active: false, activeVersionId: null, // workflow content did not change, so we keep updatedAt as is updatedAt: workflow.updatedAt, }); await this.workflowPublishHistoryRepository.addRecord({ workflowId, versionId: deactivatedVersionId, event: 'deactivated', userId, }); } } /** * Deletes a workflow and returns it. * * If the workflow is active this will deactivate the workflow. * If the user does not have the permissions to delete the workflow this does * nothing and returns void. */ async delete(user: User, workflowId: string, force = false): Promise { await this.externalHooks.run('workflow.delete', [ workflowId, toWorkflowLifecycleHookActor(user), ]); const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [ 'workflow:delete', ]); if (!workflow) { return; } if (this.globalConfig.workflows.useWorkflowPublicationService) { if (workflow.activeVersionId !== null) { throw new ConflictError( 'Cannot delete a published workflow. Unpublish it before deleting.', ); } // Unpublishing clears `activeVersionId` synchronously but defers trigger // teardown to the outbox consumer, which removes the published-version // mapping only once teardown succeeds. That mapping's FK to the workflow // is RESTRICT, so deleting before it is gone would fail at the DB level. const pendingPublishedVersionId = await this.workflowPublishedVersionRepository.getPublishedVersionId(workflowId); if (pendingPublishedVersionId !== null) { throw new ConflictError( 'Workflow is still being unpublished. Please try again in a few moments.', ); } } if (!workflow.isArchived && !force) { throw new BadRequestError('Workflow must be archived before it can be deleted.'); } // Ahead of every destructive step: the hook captures rows the delete is about // to cascade away, so `afterWorkflowsDeleted` can still explain what happened. await this.workflowMutationHooks.beforeWorkflowDeleted(workflowId, user.id); if (workflow.active) { // deactivate before deleting await this.activeWorkflowManager.remove(workflowId); } // Delete executions (incl. their binary and blob data) in batches before // the workflow row, so the FK cascade on the workflow row stays too small // to hit a DB statement timeout, however large the execution history. await this.executionPersistence.hardDeleteByWorkflowId(workflowId); await this.workflowRepository.delete(workflowId); await this.ownershipService.invalidateWorkflowProjectCacheByIds([workflowId]); // After the cascade, so it can see the rows the delete orphaned. Observes a // committed delete, so it must not throw — the module swallows its own errors. await this.workflowMutationHooks.afterWorkflowsDeleted([workflowId]); this.eventService.emit('workflow-deleted', { user, workflowId, publicApi: false }); await this.externalHooks.run('workflow.afterDelete', [ workflowId, toWorkflowLifecycleHookActor(user), ]); return workflow; } async archive( user: User, workflowId: string, options?: { skipArchived?: boolean; expectedChecksum?: string; publicApi?: boolean }, ): Promise { const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [ 'workflow:delete', ]); if (!workflow) { return; } if (workflow.isArchived) { if (options?.skipArchived) { return workflow; } throw new BadRequestError('Workflow is already archived.'); } if (options?.expectedChecksum) { await this._detectConflicts(workflow, options.expectedChecksum); } const activeVersionId = workflow.activeVersionId; if (activeVersionId !== null) { if (this.globalConfig.workflows.useWorkflowPublicationService) { await this._unpublishViaOutbox(user.id, workflowId, activeVersionId, workflow.updatedAt); } else { await this.activeWorkflowManager.remove(workflowId); await this.workflowPublishHistoryRepository.addRecord({ workflowId, versionId: activeVersionId, event: 'deactivated', userId: user.id, }); } } const versionId = uuid(); workflow.versionId = versionId; workflow.isArchived = true; workflow.active = false; workflow.activeVersionId = null; workflow.activeVersion = null; await this.workflowRepository.update(workflowId, { isArchived: true, active: false, activeVersion: null, versionId, }); await this.workflowHistoryService.saveVersion(user, workflow, workflowId); await this.workflowMutationHooks.afterWorkflowArchived(workflowId, user.id); this.eventService.emit('workflow-archived', { user, workflowId, publicApi: options?.publicApi ?? false, }); await this.externalHooks.run('workflow.afterArchive', [ workflowId, toWorkflowLifecycleHookActor(user), ]); return workflow; } async unarchive( user: User, workflowId: string, options?: { publicApi?: boolean }, ): Promise { const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [ 'workflow:delete', ]); if (!workflow) { return; } if (!workflow.isArchived) { throw new BadRequestError('Workflow is not archived.'); } const versionId = uuid(); workflow.versionId = versionId; workflow.isArchived = false; await this.workflowRepository.update(workflowId, { isArchived: false, versionId }); await this.workflowHistoryService.saveVersion(user, workflow, workflowId); this.eventService.emit('workflow-unarchived', { user, workflowId, publicApi: options?.publicApi ?? false, }); await this.externalHooks.run('workflow.afterUnarchive', [ workflowId, toWorkflowLifecycleHookActor(user), ]); return workflow; } async archiveForPublicApi(user: User, workflowId: string): Promise { return await this.archive(user, workflowId, { skipArchived: true, publicApi: true }); } async unarchiveForPublicApi(user: User, workflowId: string): Promise { return await this.unarchive(user, workflowId, { publicApi: true }); } async getWorkflowScopes(user: User, workflowId: string): Promise { const userProjectRelations = await this.projectService.getProjectRelationsForUser(user); const shared = await this.sharedWorkflowRepository.find({ where: { projectId: In([...new Set(userProjectRelations.map((pr) => pr.projectId))]), workflowId, }, }); return this.roleService.combineResourceScopes('workflow', user, shared, userProjectRelations); } /** * Transfers all workflows owned by a project to another one. * This has only been tested for personal projects. It may need to be amended * for team projects. **/ async transferAll(fromProjectId: string, toProjectId: string, trx?: EntityManager) { trx = trx ?? this.workflowRepository.manager; // Get all shared workflows for both projects. const allSharedWorkflows = await trx.findBy(SharedWorkflow, { projectId: In([fromProjectId, toProjectId]), }); const sharedWorkflowsOfFromProject = allSharedWorkflows.filter( (sw) => sw.projectId === fromProjectId, ); // For all workflows that the from-project owns transfer the ownership to // the to-project. // This will override whatever relationship the to-project already has to // the resources at the moment. const ownedWorkflowIds = sharedWorkflowsOfFromProject .filter((sw) => sw.role === 'workflow:owner') .map((sw) => sw.workflowId); await this.sharedWorkflowRepository.makeOwner(ownedWorkflowIds, toProjectId, trx); // Delete the relationship to the from-project. await this.sharedWorkflowRepository.deleteByIds(ownedWorkflowIds, fromProjectId, trx); // Transfer relationships that are not `workflow:owner`. // This will NOT override whatever relationship the from-project already // has to the resource at the moment. const sharedWorkflowIdsOfTransferee = allSharedWorkflows .filter((sw) => sw.projectId === toProjectId) .map((sw) => sw.workflowId); // All resources that are shared with the from-project, but not with the // to-project. const sharedWorkflowsToTransfer = sharedWorkflowsOfFromProject.filter( (sw) => sw.role !== 'workflow:owner' && !sharedWorkflowIdsOfTransferee.includes(sw.workflowId), ); await trx.insert( SharedWorkflow, sharedWorkflowsToTransfer.map((sw) => ({ workflowId: sw.workflowId, projectId: toProjectId, role: sw.role, })), ); // Caller must invalidate the workflow-project cache for these IDs after the // surrounding transaction commits, since their owner project has changed. return ownedWorkflowIds; } async getWorkflowsWithNodesIncluded(user: User, nodeTypes: string[], includeNodes = false) { const foundWorkflows = await this.workflowRepository.findWorkflowsWithNodeType( nodeTypes, includeNodes, ); let { workflows } = await this.workflowRepository.getManyAndCount( foundWorkflows.map((w) => w.id), ); if (hasSharing(workflows)) { workflows = await this.processSharedWorkflows(workflows); } const withScopes = await this.addUserScopes(workflows, user); this.cleanupSharedField(withScopes); return withScopes.map((workflow) => { const nodes = includeNodes ? (foundWorkflows.find((w) => w.id === workflow.id)?.nodes ?? []) : undefined; return { resourceType: 'workflow', ...workflow, ...(includeNodes ? { nodes } : {}) }; }); } async _detectConflicts(dbWorkflow: WorkflowEntity, expectedChecksum: string) { const currentChecksum = await calculateWorkflowChecksum(dbWorkflow); if (expectedChecksum !== currentChecksum) { throw new ConflictError( 'Your most recent changes may be lost, because someone else just updated this workflow. Open this workflow in a new tab to see those new updates.', ); } } _validateNodes(workflowId: string, nodes: INode[], connections: IConnections) { const nodesToValidate = nodes.reduce((acc, node) => { acc[node.name] = node; return acc; }, {}); const validation = this.workflowValidationService.validateForActivation( nodesToValidate, connections, this.nodeTypes, ); if (!validation.isValid) { this.logger.warn('Workflow activation failed validation', { workflowId, error: validation.error, }); throw new WorkflowValidationError(validation.error ?? 'Workflow validation failed'); } } private _validateTriggerNodeIds(workflowId: string, version: WorkflowHistory) { const validation = this.workflowValidationService.validateTriggerNodeIds( getEnabledTriggerNodes(version, this.nodeTypes), ); if (!validation.isValid) { this.logger.warn('Workflow activation failed trigger node id validation', { workflowId, error: validation.error, }); throw new WorkflowValidationError(validation.error ?? 'Trigger node id validation failed'); } } private async _validateDynamicCredentials( workflowId: string, nodes: INode[], workflowSettings?: IWorkflowSettings, ) { const validation = await this.workflowValidationService.validateDynamicCredentials( nodes, this.nodeTypes, workflowSettings, ); if (!validation.isValid) { this.logger.warn('Workflow activation failed dynamic credentials validation', { workflowId, error: validation.error, }); throw new WorkflowValidationError( validation.error ?? 'Dynamic credentials validation failed', ); } } /** * Calculates which workflow settings changed between two versions. * Returns an object with { settingKey: { from, to } } for each changed setting, * or undefined if no settings changed. */ private calculateSettingsChanges( previousSettings: IWorkflowSettings | undefined, newSettings: IWorkflowSettings | undefined, ): Record | undefined { const changes: Record = {}; const prev = previousSettings ?? {}; const next = newSettings ?? {}; // Get all unique keys from both previous and new settings const allKeys = new Set([...Object.keys(prev), ...Object.keys(next)]); for (const key of allKeys) { const prevValue = prev[key as keyof IWorkflowSettings]; const nextValue = next[key as keyof IWorkflowSettings]; if (!isEqual(prevValue, nextValue)) { const from: JsonValue = prevValue ?? null; const to: JsonValue = nextValue ?? null; changes[key] = { from, to }; } } return Object.keys(changes).length > 0 ? changes : undefined; } /** * Validates that all sub-workflow references in a workflow are published. * Prevents publishing a parent workflow that references draft-only sub-workflows. * * Note: A published workflow could still end up referencing draft-only sub-workflows if: * - A referenced sub-workflow gets unpublished after the parent workflow was published * - The workflow ID is provided via an expression (e.g., ={{ $json.workflowId }}) * - The workflow source is not 'database' (e.g., URL, parameter, localFile) * * In these cases, the invariant is enforced at execution time, where the workflow will * fail with a clear error message if the sub-workflow is not published (for production * executions) or not found. */ private async _validateSubWorkflowReferences(workflowId: string, nodes: INode[]) { const validation = await this.workflowValidationService.validateSubWorkflowReferences( workflowId, nodes, ); if (!validation.isValid) { this.logger.warn('Workflow activation failed sub-workflow validation', { workflowId, error: validation.error, invalidReferences: validation.invalidReferences, }); throw new WorkflowValidationError(validation.error ?? 'Sub-workflow validation failed'); } } /** * Atomically records the requested version and enqueues an outbox record. * The publication outbox consumer reapplies the triggers and advances the * published version asynchronously, so we do not touch the active workflow * manager here. */ private async _publishViaOutbox( userId: string | null, workflowId: string, versionIdToActivate: string, previousActiveVersionId: string | null, updatedAt: Date, ): Promise { await this.workflowRepository.manager.transaction(async (trx) => { await this._recordPublishInTransaction( trx, userId, workflowId, versionIdToActivate, previousActiveVersionId, updatedAt, ); }); // Wake the leader now that the record is committed, so it drains without // waiting for the next poll cycle. this.workflowPublicationNotifier.requestDrain(); } /** * Writes one publish into an open transaction: the workflow-row update, the * publish-history records, and the outbox record. With * `onlyIfActiveVersionIs`, the row update — this method's first write — is * guarded on the active version still being that value; a miss returns * `false` without writing anything further, and the caller owns rolling * back whatever it wrote earlier in the same transaction. */ private async _recordPublishInTransaction( trx: EntityManager, userId: string | null, workflowId: string, versionIdToActivate: string, previousActiveVersionId: string | null, updatedAt: Date, options?: { onlyIfActiveVersionIs: string }, ): Promise { const result = await trx.update( WorkflowEntity, options === undefined ? { id: workflowId } : { id: workflowId, activeVersionId: options.onlyIfActiveVersionIs }, { activeVersionId: versionIdToActivate, active: true, // workflow content did not change, so we keep updatedAt as is updatedAt, }, ); // A miss means a concurrent publish or unpublish moved the active version // since the caller's read. if (options !== undefined && (result.affected ?? 0) === 0) { return false; } if (previousActiveVersionId) { await this.workflowPublishHistoryRepository.addRecord( { workflowId, versionId: previousActiveVersionId, event: 'deactivated', userId, }, trx, ); } await this.workflowPublishHistoryRepository.addRecord( { workflowId, versionId: versionIdToActivate, event: 'activated', userId, }, trx, ); await this.outboxRepository.enqueue( workflowId, versionIdToActivate, WorkflowPublicationReason.Publish, trx, ); return true; } /** * Nulls the active version and enqueues an unpublish outbox record in a single * transaction. They must commit together: otherwise the consumer could claim * the record while the workflow still looks active and handle it as a publish. */ private async _unpublishViaOutbox( userId: string | null, workflowId: string, deactivatedVersionId: string, updatedAt: Date, ): Promise { await this.workflowRepository.manager.transaction(async (trx) => { await trx.update( WorkflowEntity, { id: workflowId }, { active: false, activeVersionId: null, // workflow content did not change, so we keep updatedAt as is updatedAt, }, ); await this.workflowPublishHistoryRepository.addRecord( { workflowId, versionId: deactivatedVersionId, event: 'deactivated', userId, }, trx, ); await this.outboxRepository.enqueue( workflowId, deactivatedVersionId, WorkflowPublicationReason.Publish, trx, ); // Durable jobs are DB state, so their removal commits here rather than // waiting on the leader's outbox handler: a lost hand-off would otherwise // leave them firing a workflow already marked inactive. await this.scheduleTriggerJobRegistrar.removeWorkflowInTransaction(trx, workflowId); await this.pollTriggerJobRegistrar.removeWorkflowInTransaction(trx, workflowId); }); // Wake the leader now that the record is committed, so it drains without // waiting for the next poll cycle. this.workflowPublicationNotifier.requestDrain(); } /** * Replace all tag mappings on a workflow. Missing tag IDs surface as NotFoundError. */ async updateWorkflowTags(user: User, workflowId: string, tagIds: string[]) { const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [ 'workflow:update', ]); if (!workflow) { throw new NotFoundError('Not Found'); } try { await this.workflowTagMappingRepository.overwriteTaggings(workflowId, tagIds); } catch (error) { if (error instanceof QueryFailedError) { throw new NotFoundError('Some tags not found'); } throw error; } return await this.tagService.getAllByWorkflowId(workflowId); } }