mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-19 16:55:02 +02:00
1868 lines
62 KiB
TypeScript
1868 lines
62 KiB
TypeScript
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<string | undefined> {
|
|
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<Array<T & { hasResolvableCredentials: boolean }>> {
|
|
// 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<void> {
|
|
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<WorkflowEntity> {
|
|
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<WorkflowEntity>;
|
|
|
|
// 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<void> {
|
|
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<WorkflowEntity> {
|
|
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<WorkflowEntity> {
|
|
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<void> {
|
|
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<void> {
|
|
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<WorkflowEntity | undefined> {
|
|
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<WorkflowEntity | undefined> {
|
|
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<WorkflowEntity | undefined> {
|
|
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<WorkflowEntity | undefined> {
|
|
return await this.archive(user, workflowId, { skipArchived: true, publicApi: true });
|
|
}
|
|
|
|
async unarchiveForPublicApi(user: User, workflowId: string): Promise<WorkflowEntity | undefined> {
|
|
return await this.unarchive(user, workflowId, { publicApi: true });
|
|
}
|
|
|
|
async getWorkflowScopes(user: User, workflowId: string): Promise<Scope[]> {
|
|
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<INodes>((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<string, { from: JsonValue; to: JsonValue }> | undefined {
|
|
const changes: Record<string, { from: JsonValue; to: JsonValue }> = {};
|
|
|
|
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<void> {
|
|
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<boolean> {
|
|
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<void> {
|
|
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);
|
|
}
|
|
}
|