UNPKG

@nocobase/plugin-workflow

Version:

A powerful BPM tool that provides foundational support for business automation, with the capability to extend unlimited triggers and nodes.

705 lines (703 loc) • 24.6 kB
/** * This file is part of the NocoBase (R) project. * Copyright (c) 2020-2024 NocoBase Co., Ltd. * Authors: NocoBase Team. * * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. * For more information, please refer to: https://www.nocobase.com/agreement. */ var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); var Processor_exports = {}; __export(Processor_exports, { default: () => Processor }); module.exports = __toCommonJS(Processor_exports); var import_sequelize = require("sequelize"); var import_database = require("@nocobase/database"); var import_evaluators = require("@nocobase/evaluators"); var import_utils = require("@nocobase/utils"); var import_set = __toESM(require("lodash/set")); var import_constants = require("./constants"); var import_timeout_errors = require("./timeout-errors"); class Processor { constructor(execution, options) { this.execution = execution; this.options = options; this.logger = options.plugin.getLogger(execution.workflowId); this.transaction = options.transaction; } static StatusMap = { [import_constants.JOB_STATUS.PENDING]: import_constants.EXECUTION_STATUS.STARTED, [import_constants.JOB_STATUS.RESOLVED]: import_constants.EXECUTION_STATUS.RESOLVED, [import_constants.JOB_STATUS.FAILED]: import_constants.EXECUTION_STATUS.FAILED, [import_constants.JOB_STATUS.ERROR]: import_constants.EXECUTION_STATUS.ERROR, [import_constants.JOB_STATUS.ABORTED]: import_constants.EXECUTION_STATUS.ABORTED, [import_constants.JOB_STATUS.CANCELED]: import_constants.EXECUTION_STATUS.CANCELED, [import_constants.JOB_STATUS.REJECTED]: import_constants.EXECUTION_STATUS.REJECTED, [import_constants.JOB_STATUS.RETRY_NEEDED]: import_constants.EXECUTION_STATUS.RETRY_NEEDED }; logger; /** * @experimental */ transaction = null; /** * @experimental */ nodes = []; /** * @experimental */ nodesMap = /* @__PURE__ */ new Map(); jobsMapByNodeKey = {}; jobResultsMapByNodeKey = {}; jobsToSave = /* @__PURE__ */ new Map(); rerunContext = null; /** * @experimental */ lastSavedJob = null; abortController = new AbortController(); timeoutGuard = null; runningRegistered = false; abortReason = null; aborted = false; get abortSignal() { return this.abortController.signal; } setTimeoutGuard(ms) { if (this.timeoutGuard) { clearTimeout(this.timeoutGuard); } this.timeoutGuard = setTimeout(() => { this.abortExecution(import_constants.EXECUTION_REASON.TIMEOUT); }, ms); } abortExecution(reason) { this.aborted = true; this.abortReason = reason ?? null; if (!this.abortController.signal.aborted) { this.abortController.abort( reason === import_constants.EXECUTION_REASON.TIMEOUT ? new import_timeout_errors.WorkflowTimeoutError("Workflow execution has been aborted") : new Error("Workflow execution has been aborted") ); } } isTimeoutAborted() { return this.abortSignal.aborted; } /** * Create an independent abort handle for background work that outlives this processor's * run loop (e.g. fire-and-forget instructions that resume the job later). It mirrors the * current abort state and sets its own timer based on the execution's `expiresAt`, so the * timeout still applies after the processor has exited its synchronous run. * * The caller must invoke `dispose()` once the background work settles to release the timer * and the abort listener. */ createBackgroundAbortHandle() { const controller = new AbortController(); const sourceSignal = this.abortSignal; let timeoutGuard = null; let sourceListener = null; const abort = (reason) => { if (!controller.signal.aborted) { controller.abort((0, import_timeout_errors.isWorkflowTimeoutError)(reason) ? reason : new import_timeout_errors.WorkflowTimeoutError()); } }; if (sourceSignal.aborted) { abort(sourceSignal.reason); } else { sourceListener = () => abort(sourceSignal.reason); sourceSignal.addEventListener("abort", sourceListener, { once: true }); } const remaining = this.execution.expiresAt ? this.execution.expiresAt.getTime() - Date.now() : null; if (remaining != null) { if (remaining <= 0) { abort(); } else { timeoutGuard = setTimeout(abort, remaining); } } return { signal: controller.signal, dispose: () => { if (timeoutGuard) { clearTimeout(timeoutGuard); timeoutGuard = null; } if (sourceListener) { sourceSignal.removeEventListener("abort", sourceListener); sourceListener = null; } }, throwIfAborted: () => { if (controller.signal.aborted) { throw controller.signal.reason ?? new import_timeout_errors.WorkflowTimeoutError(); } } }; } /** * Reload a job and return it only when it is still pending, otherwise `null`. Background * work uses this before resuming so it never overwrites a job that another path (timeout * abort, a competing resume) has already settled. */ async findPendingJob(jobId) { const job = await this.options.plugin.db.getRepository("jobs").findOne({ filterByTk: jobId }); return (job == null ? void 0 : job.status) === import_constants.JOB_STATUS.PENDING ? job : null; } // make dual linked nodes list then cache makeNodes(nodes = []) { this.nodes = nodes; nodes.forEach((node) => { this.nodesMap.set(node.id, node); }); nodes.forEach((node) => { if (node.upstreamId) { node.upstream = this.nodesMap.get(node.upstreamId); } if (node.downstreamId) { node.downstream = this.nodesMap.get(node.downstreamId); } }); } makeJobs(jobs) { for (const job of jobs) { const node = this.nodesMap.get(job.nodeId); if (!node) { this.logger.warn(`node (#${job.nodeId}) not found for job (#${job.id}), this will lead to unexpected error`); continue; } this.jobsMapByNodeKey[node.key] = job; this.jobResultsMapByNodeKey[node.key] = job.result; } } async prepare() { const { execution, options: { plugin } } = this; if (!execution.workflow) { execution.workflow = plugin.enabledCache.get(execution.workflowId) || await execution.getWorkflow(); } if (!execution.workflow) { throw new Error(`workflow (#${execution.workflowId}) not found for execution (#${execution.id})`); } const nodes = execution.workflow.nodes || await execution.workflow.getNodes(); execution.workflow.nodes = nodes; this.makeNodes(nodes); const JobDBModel = plugin.db.getModel("jobs"); const jobIds = await JobDBModel.findAll({ attributes: ["executionId", "nodeId", [(0, import_sequelize.fn)("MAX", (0, import_sequelize.col)("id")), "id"]], group: ["executionId", "nodeId"], where: { executionId: execution.id }, raw: true }); const jobs = await execution.getJobs({ where: { id: jobIds.map((item) => item.id) }, order: [["id", "ASC"]] }); execution.jobs = jobs; this.makeJobs(jobs); } async start() { const { execution } = this; if (!await this.shouldContinueExecution()) { this.logger.warn(`execution was ended with status ${execution.status} before, could not be started again`, { workflowId: execution.workflowId }); return; } this.enterRunningState(); try { await this.prepare(); if (this.nodes.length) { const head = this.nodes.find((item) => !item.upstream); if (!head) { this.logger.warn(`head node not found for workflow (${execution.workflowId}), could not be started`, { workflowId: execution.workflowId }); return this.exit(import_constants.JOB_STATUS.ERROR); } await this.run(head); } else { await this.exit(import_constants.JOB_STATUS.RESOLVED); } } finally { this.leaveRunningState(); } } async resume(job) { const { execution } = this; if (!await this.shouldContinueExecution()) { this.logger.warn(`execution was ended with status ${execution.status} before, could not be resumed`, { workflowId: execution.workflowId }); return; } this.enterRunningState(); try { await this.prepare(); const node = this.nodesMap.get(job.nodeId); await this.recall(node, job); } finally { this.leaveRunningState(); } } resolveRerun(options = {}) { const node = this.getRerunNode(options.nodeId); const targetJob = this.jobsMapByNodeKey[node.key]; if (options.nodeId != null && !targetJob) { throw new Error(`job of node (#${node.id}) not found in execution (#${this.execution.id})`); } if (options.nodeId == null && options.overwrite && !targetJob) { throw new Error(`job of head node (#${node.id}) not found in execution (#${this.execution.id})`); } const input = this.getRerunInput(node); return { node, input, targetJob }; } async rerun(options = {}) { const { execution } = this; if (execution.status !== import_constants.EXECUTION_STATUS.STARTED) { throw new Error(`execution (#${execution.id}) is not started`); } if (!await this.shouldContinueExecution()) { this.logger.warn(`execution was ended with status ${execution.status} before, could not be rerun`, { workflowId: execution.workflowId }); return; } this.enterRunningState(); try { await this.prepare(); const { node, input, targetJob } = this.resolveRerun(options); this.rerunContext = { overwrite: options.overwrite === true, targetJob }; return await this.run(node, input, { rerun: true }); } finally { this.rerunContext = null; this.leaveRunningState(); } } getRerunNode(nodeId) { if (nodeId != null) { const node = this.nodesMap.get(nodeId) || this.nodes.find((item) => String(item.id) === String(nodeId)); if (!node) { throw new Error(`node (#${nodeId}) not found in workflow (#${this.execution.workflowId})`); } return node; } const head = this.nodes.find((item) => !item.upstream); if (!head) { throw new Error(`head node not found in workflow (#${this.execution.workflowId})`); } return head; } getRerunInput(node) { if (!node.upstream) { return { result: this.execution.context }; } const upstreamJob = this.jobsMapByNodeKey[node.upstream.key]; if (!upstreamJob) { throw new Error(`upstream job of node (#${node.id}) not found in execution (#${this.execution.id})`); } return upstreamJob; } async exec(instruction, node, prevJob, options = {}) { let job; if (!await this.shouldContinueExecution()) { return this.exit(); } try { this.logger.debug(`config of node`, { data: node.config, workflowId: node.workflowId }); job = await instruction(node, prevJob, this, { ...options, signal: this.abortSignal }); if (job === null) { return this.exit(); } if (!job) { return this.exit(true); } } catch (err) { if ((0, import_timeout_errors.isWorkflowTimeoutError)(err) || this.abortSignal.aborted && this.aborted) { job = { result: { message: err.message }, status: import_constants.JOB_STATUS.ABORTED }; } else { this.logger.error( `execution (${this.execution.id}) run instruction [${node.type}] for node (${node.id}) failed: `, { error: err, workflowId: node.workflowId } ); job = { result: err instanceof Error ? { ...err, message: err.message } : err, status: import_constants.JOB_STATUS.ERROR }; } if (prevJob instanceof import_database.Model && prevJob.nodeId === node.id) { prevJob.set(job); job = prevJob; } } if (!(job instanceof import_database.Model)) { job.nodeId = node.id; job.nodeKey = node.key; } const savedJob = this.saveJob(job); this.logger.info( `execution (${this.execution.id}) run instruction [${node.type}] for node (${node.id}) finished as status: ${savedJob.status}`, { workflowId: node.workflowId } ); this.logger.debug(`result of node`, { data: savedJob.result }); if (this.execution.status === import_constants.EXECUTION_STATUS.ABORTED || this.isTimeoutAborted()) { return this.exit(import_constants.JOB_STATUS.ABORTED); } if (savedJob.status === import_constants.JOB_STATUS.RESOLVED && node.downstream) { this.logger.debug(`run next node (${node.downstreamId})`); return this.run(node.downstream, savedJob); } return this.end(node, savedJob); } async run(node, input, options) { const { instructions } = this.options.plugin; const instruction = instructions.get(node.type); if (!instruction) { return Promise.reject(new Error(`instruction [${node.type}] not found for node (#${node.id})`)); } if (typeof instruction.run !== "function") { return Promise.reject(new Error("`run` should be implemented for customized execution of the node")); } this.logger.info(`execution (${this.execution.id}) run instruction [${node.type}] for node (${node.id})`, { workflowId: node.workflowId }); return this.exec(instruction.run.bind(instruction), node, input, options); } // parent node should take over the control async end(node, job) { this.logger.debug(`branch ended at node (${node.id})`); const parentNode = this.findBranchParentNode(node); if (parentNode) { this.logger.debug(`not on main, recall to parent entry node (${node.id})})`, { workflowId: node.workflowId }); await this.recall(parentNode, job); return null; } return this.exit(job.status); } async recall(node, job) { const { instructions } = this.options.plugin; const instruction = instructions.get(node.type); if (!instruction) { return Promise.reject(new Error(`instruction [${node.type}] not found for node (#${node.id})`)); } if (typeof instruction.resume !== "function") { return Promise.reject( new Error(`"resume" method should be implemented for [${node.type}] instruction of node (#${node.id})`) ); } this.logger.info(`execution (${this.execution.id}) resume instruction [${node.type}] for node (${node.id})`, { workflowId: node.workflowId }); return this.exec(instruction.resume.bind(instruction), node, job); } async exit(s) { this.leaveRunningState(); if (s === true) { return; } if (this.jobsToSave.size) { const newJobs = []; for (const job of this.jobsToSave.values()) { if (job.isNewRecord) { newJobs.push(job); } else { const JobCollection = this.options.plugin.db.getCollection("jobs"); const changes = []; if (job.changed("status")) { changes.push([`status`, job.status]); job.changed("status", false); } if (job.changed("meta")) { changes.push([`meta`, JSON.stringify(job.meta ?? null)]); job.changed("meta", false); } if (job.changed("result")) { changes.push([`result`, JSON.stringify(job.result ?? null)]); job.changed("result", false); } if (changes.length) { await this.options.plugin.db.sequelize.query( `UPDATE ${JobCollection.quotedTableName()} SET ${changes.map(([key]) => `${key} = ?`)} WHERE id='${job.id}'`, { replacements: changes.map(([, value]) => value) } ); } } } if (newJobs.length) { const JobsModel = this.options.plugin.db.getModel("jobs"); await JobsModel.bulkCreate( newJobs.map((job) => job.toJSON()), { returning: false } ); for (const job of newJobs) { job.isNewRecord = false; } } this.jobsToSave.clear(); } if (typeof s === "number") { const status = this.constructor.StatusMap[s] ?? Math.sign(s); const values = { status }; if (status === import_constants.EXECUTION_STATUS.ABORTED && this.abortReason) { values.reason = this.abortReason; } const ExecutionModelClass = this.options.plugin.db.getModel("executions"); const [affected] = await ExecutionModelClass.update(values, { where: { id: this.execution.id, status: import_constants.EXECUTION_STATUS.STARTED }, individualHooks: true }); if (affected) { this.execution.set(values); } else { await this.execution.reload(); } } if (this.execution.status === import_constants.EXECUTION_STATUS.STARTED) { this.options.plugin.timeoutManager.scheduleExecutionTimeout(this.execution); } else { this.options.plugin.timeoutManager.clear(this.execution.id); this.options.plugin.timeoutManager.invalidateNextExpiresAtIfMatches(this.execution.expiresAt); } this.logger.info(`execution (${this.execution.id}) exiting with status ${this.execution.status}`, { workflowId: this.execution.workflowId }); return null; } /** * @experimental */ saveJob(payload) { var _a; const { database } = this.execution.constructor; const model = database.getModel("jobs"); let job; if (payload instanceof model) { job = payload; job.set("updatedAt", /* @__PURE__ */ new Date()); } else if (((_a = this.rerunContext) == null ? void 0 : _a.overwrite) && this.rerunContext.targetJob && this.rerunContext.targetJob.nodeId === payload.nodeId) { job = this.rerunContext.targetJob; job.set({ status: payload.status, result: Object.prototype.hasOwnProperty.call(payload, "result") ? payload.result : null, meta: Object.prototype.hasOwnProperty.call(payload, "meta") ? payload.meta : null, updatedAt: /* @__PURE__ */ new Date() }); } else { job = model.build( { ...payload, id: this.options.plugin.snowflake.getUniqueID().toString(), createdAt: /* @__PURE__ */ new Date(), updatedAt: /* @__PURE__ */ new Date(), executionId: this.execution.id }, { isNewRecord: true } ); } this.jobsToSave.set(job.id.toString(), job); this.lastSavedJob = job; this.jobsMapByNodeKey[job.nodeKey] = job; this.jobResultsMapByNodeKey[job.nodeKey] = job.result; this.logger.debug(`job added to save list: ${JSON.stringify(job)}`, { workflowId: this.execution.workflowId }); return job; } /** * @experimental */ getBranches(node) { return this.nodes.filter((item) => item.upstream === node && item.branchIndex !== null).sort((a, b) => Number(a.branchIndex) - Number(b.branchIndex)); } enterRunningState() { this.options.plugin.timeoutManager.clear(this.execution.id); this.abortReason = null; this.aborted = false; this.options.plugin.registerRunningExecution(this.execution.id, (reason) => this.abortExecution(reason)); this.runningRegistered = true; const remaining = this.execution.expiresAt ? this.execution.expiresAt.getTime() - Date.now() : null; if (remaining == null) { return; } if (remaining <= 0) { this.abortExecution(import_constants.EXECUTION_REASON.TIMEOUT); return; } this.setTimeoutGuard(remaining); } async shouldContinueExecution() { return this.options.plugin.timeoutManager.shouldContinue(this.execution); } leaveRunningState() { if (this.timeoutGuard) { clearTimeout(this.timeoutGuard); this.timeoutGuard = null; } if (!this.runningRegistered) { return; } this.options.plugin.unregisterRunningExecution(this.execution.id); this.runningRegistered = false; } /** * @experimental * find the first node in current branch */ findBranchStartNode(node, parent) { for (let n = node; n; n = n.upstream) { if (!parent) { if (n.branchIndex !== null) { return n; } } else { if (n.upstream === parent) { return n; } } } return null; } /** * @experimental * find the node start current branch */ findBranchParentNode(node) { for (let n = node; n; n = n.upstream) { if (n.branchIndex !== null) { return n.upstream; } } return null; } /** * @experimental */ findBranchEndNode(node) { for (let n = node; n; n = n.downstream) { if (!n.downstream) { return n; } } return null; } /** * @experimental */ findBranchParentJob(job, node) { return this.jobsMapByNodeKey[node.key]; } /** * @experimental */ findBranchLastJob(node, job) { const allJobs = Object.values(this.jobsMapByNodeKey); const branchJobs = []; for (let n = this.findBranchEndNode(node); n && n !== node.upstream; n = n.upstream) { branchJobs.push(...allJobs.filter((item) => item.nodeId === n.id)); } branchJobs.sort((a, b) => a.id - b.id); return branchJobs[branchJobs.length - 1] || null; } /** * @experimental */ getScope(sourceNodeId, includeSelfScope = false) { const node = sourceNodeId ? this.nodesMap.get(sourceNodeId) : void 0; const systemFns = {}; const scope = { execution: this.execution, node }; for (const [name, fn2] of this.options.plugin.functions.getEntities()) { (0, import_set.default)(systemFns, name, fn2.bind(scope)); } const $scopes = {}; for (let n = includeSelfScope ? node : this.findBranchParentNode(node); n; n = this.findBranchParentNode(n)) { const instruction = this.options.plugin.instructions.get(n.type); if (typeof (instruction == null ? void 0 : instruction.getScope) === "function") { $scopes[n.id] = $scopes[n.key] = instruction.getScope(n, this.jobResultsMapByNodeKey[n.key], this); } } const scopes = { $context: this.execution.context, $jobsMapByNodeKey: this.jobResultsMapByNodeKey, $system: systemFns, $scopes, $env: this.options.plugin.app.environment.getVariables() }; return { ...scopes, ctx: scopes // 2.0 }; } /** * @experimental */ getParsedValue(value, sourceNodeId, { additionalScope = {}, includeSelfScope = false } = {}) { const template = (0, import_utils.parse)(value); const scope = Object.assign(this.getScope(sourceNodeId, includeSelfScope), additionalScope); template.parameters.forEach(({ key }) => { (0, import_evaluators.appendArrayColumn)(scope, key); }); return template(scope); } }